Compare commits

..
12 Commits
Author SHA1 Message Date
Atlas Zeng a2751cfbde Merge pull request #782: 文件页/文件变动页 UIUX 重构 + DSH 0.2.0 适配(v0.24.1)
汇总自 v0.22.1 以来的全部更新:文件页多选 / Git 着色 / 新建文件夹 / 压缩下载进度 / 打开方式双源 / 菜单层级 / 性能(fs.tree 22.8→3.8ms、批量 fs.trees),文件变动页重构为层级树,删除工作区路径检测(放开权限,安全相关),DSH 0.2.0-rc.1 支持线(无运行时兼容分支),以及展开整树重建、在侧边打开、展开状态重置等修复。

CI:ci / ci-windows / plugin-mount 三绿(1-review 规则按仓库 bootstrap 免审名单由 owner 账号放行)。本地 137 文件 / 1491 用例、真实宿主 lane 9/9。
2026-09-28 23:28:46 +08:00
Menghuan1918 52be73e176 test(zip): 让两条断言平台正确,修 ci-windows 红
ci-windows(首次在本分支跑)报 tests/zip.spec.ts 两条失败,均为**测试可移植性**问题,不是产品缺陷:

1. 「keeps a backslash as part of the file name (POSIX names are literal)」:
   Windows 上反斜杠就是路径分隔符,join(root, 'a\b.txt') 得到 root\a\b.txt,父目录不存在 →
   writeFileSync 报 ENOENT。该语义在 Windows 上根本不存在 → 按仓库既有约定
   it.skipIf(isWin32) 跳过,并在注释里写明理由。
2. 「passes unzip -t when the binary exists」:断言 outMode === inMode & 0o644 在 Windows 上
   无意义(两边 statSync().mode 都是 0o666 → expected 420, received 438)。CRC 与解压断言保留,
   只把 mode 一致性放进 !isWin32 分支。

本地:tests/zip.spec.ts 37/37 通过;全量 typecheck / lint / test 绿(137 文件 / 1491 通过)。
2026-09-28 23:21:06 +08:00
Menghuan1918 0ac62b3a49 Merge origin/main (v0.22.1 与三个热修 PR) into feat/files-changes-uiux
冲突面 11 个文件,逐项裁决如下:

1. src/client/native/index.ts —— **取 main 版**。main 的 v0.22.1 在同一处做了更强的修复
   (清理循环同样跳过 FILES_KIND,另加 `disposeSafely` 与「注册失败必须回滚已占用 id /
   已建槽位」),是我们那行守卫的超集。
   ⚠️ 但 main 分支从来没有过我们 task-19 的 `records.attachStore(store)` 调用,整文件
   采用会**静默丢掉**它(records 拿不到 store → `toggleExpanded` 直接 return → 点目录
   树毫无反应),且 jsdom 单测看不见(它们自己绑 records)。已按原注释恢复该调用,并在
   注释里记下这次合并教训。真实宿主 lane 证明:恢复前 `zz-expand-refresh` 红(请求为
   空、`big` 不展开),恢复后 9/9 全绿。
2. package.json / dsh.plugin.json / src/client/service.ts —— 版本相关冲突取我们的
   0.24.1(支持线 ^0.2.0-rc.1 不变)。
3. CHANGELOG / README(中英各一)—— 双方版本节全部保留:我们的 v0.24.1 / v0.24.0 +
   **新增缺失的 v0.23.0 节**(开发线,内容随 v0.24.1 发布)+ main 的 v0.22.1。
   同时修正两处事实错误:① 「0.1.7 线请固定 v0.23.0」→ **v0.22.1**(0.23.0 从未发布);
   ② 安装对照表里 0.1.7 行的安装命令同改。README 并补上 v0.23.0 摘要(含 ⚠️ 删除工作区
   路径检测这一安全相关的行为变更)。
4. docs/external-plugin-guide.md —— 版本行取我们的(0.2.0 线)。

验证(合并后的树):pnpm typecheck / lint / test = **137 文件 / 1491 通过 / 9 跳过**
(含 main 新增的 native-registration.spec.ts);pnpm build ✓;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = **9/9**
(mount 5 + perf 2 + main 的 native-reload 1 + 我们的 zz-expand-refresh 1)。
2026-09-28 23:13:36 +08:00
Menghuan1918 98a446bbfc fix(sidebar): 点击展开时文件树整体重建(两处根因)+ v0.24.1
用户报告:「点击展开的时候,文件树会频繁整体刷新,看起来非常难受」。

根因 1(主):src/client/native/index.ts 的 sync() 把 files 接管项当成多余注册。
它按 KIND 记账、永远不在 wanted 里,于是每次 sync 都被 dispose + 重建;而
disposeSubscriptions 同时订阅了 service 与 store,**任何会话状态写入**(展开/收起、
切 tab、拖底部工作台、选中…)都会跑一遍 sync。替换 slot 注册 = 宿主换 slot entry →
插件 tab body 被 unmount/remount → 文件树丢失层缓存、滚动位置与目录 watcher,并重新
fs.trees([root, ...expanded]) 重列整棵可见树。机械证明(临时插桩,已回退):
  [probe-sync] enter live=[editor,git,subagent,sidechat,diff,files]
  [probe-registerSlots] slot inject ran for dsh-better-sidebar:files
  [probe-body] unmount tab3 kind=files
  [probe-body] mount tab3 kind=files
修法:wantsFiles 提前,删除循环里对 files 接管项 skip(禁用 editor 时仍照旧释放)。

根因 2(次):FileTree.tsx 的 retryDir 先把该层缓存删掉再请求,行被「Loading…」
占位替换后重建——目录有外部写入(构建 / 格式化 / 模型跑 bash)时整层闪空。
修法:复用既有 force 路径 loadLevels([dir], { force: true }),保留旧行直到新答案到达
(与 refreshTick 的强制刷新路径行为一致)。

判别性证据(回退任一修复即红)
- tests/native-surface.spec.ts:三次 store 写入不得产生任何注册/注销 → 回退后
  registered 6 → 12、disposed 全是 dsh-better-sidebar:files
- tests/file-tree-batch-load.spec.tsx:重列期间旧行仍在屏 → 回退后层被删空
- 新增真实宿主 lane tests/e2e/zz-expand-refresh.e2e.ts(quiescence 判据,无固定 sleep;
  zz- 前缀保证它最后跑,因为它会自建 workspace + session):A1 展开只请求被展开那一层、
  A2 tab body 存活(不重开 watch socket)、A3 屏上行数不归零;回退修复 1 后退出码 1
  (Error: expanding a folder must ask for that folder, not the whole visible set)

修复后实测(40 子项目录,同一探针/机器/工作区)
| 阶段 | fs.trees payload | 新开 ws | DOM +/- | 被删行 | body 替换 | 层内最小行数 |
| 展开 big | [big](原 [root, big]) | 0(原 1) | +41/-1 | 0(原 45) | 0(原是) | 40(原 40) |
| 外部写入 big | [big] | 0 | +1/-0(原 +41/-40) | 0 | 0 | 40(原 0) |
| 展开 bravo | [bravo] | 0 | +2/-1 | 0(原 45) | 0(原是) | 41 |

证伪记录:新建 fs.watch(dir,{persistent:false}) 在 macOS 上无改动时 0 事件(Node 微
探针两遍 + 8 轮真实时序 + 浏览器侧从未收到 {dir} 陈旧帧)→ src/fs-watch.ts 与
use-dir-watch.ts 一行未改。

验证:pnpm typecheck / lint / test(136 文件 1486 通过 9 跳过,基线 1484 + 新增 2)/ build
全绿;DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = 8/8。
版本 0.24.0 → 0.24.1(package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION /
README×2 / CHANGELOG×2 同步)。
2026-09-28 22:33:30 +08:00
Menghuan1918 b7825e1bd0 docs(dsh): 记录 profile overrides 必须与宿主同批抬版本的坑(0.2.0 升级实测)
升级 3080 后出现「加载提供商目录失败: settings service is absent」,根因是 profile 的
pnpm-workspace.yaml 里上一轮留下的 overrides '@deepseek-ai/dsh-settings': 0.1.7-rc.2——
它让 profile 顶层留住 0.1.7-rc.2 的 dsh-settings,后者用精确版本拉来 dsh-config-editor
0.1.7-rc.2 并遮蔽 bundle 自带的 0.2.0-rc.1,于是 0.2.0 的启动预检禁用 config-editor 行,
settings 行永远 pending (waiting for service: configEditor)。

已修:override 抬到 0.2.0-rc.1(profile 顶层全部收敛到 0.2.0-rc.1,.pnpm 里 0.1.7 副本归零)。
文档:适配记录新增 §5.1(含原始日志证据),AGENTS §3.0 增加「用户 profile 自己的 pin 也要
同批抬」一条。
2026-09-28 21:38:58 +08:00
Menghuan1918 b91675ae61 feat(dsh): 适配 DSH 0.2.0-rc.1(单线支持)+ v0.24.0
支持线从 ^0.1.7-rc.1 整体前移到 ^0.2.0-rc.1;0.1.7 线(含 npm latest 的
0.1.7-rc.2)停留在 v0.23.0。适配前实测确认这是纯增量,故本版无运行时兼容分支。

必改项(否则装不上/不加载)
- 14 条 DSH peer → ^0.2.0-rc.1。实测 caret 跨 minor 不成立:`^0.1.7-rc.1`
  展开为 >=0.1.7-rc.1 <0.2.0-0,对 0.2.0-rc.1 判定 false(includePrerelease
  也救不了)。判别性验证:把 peer 回退成 ^0.1.7-rc.1 后,在 0.2.0-rc.1 宿主上
  `dsh plugin add` 直接拒绝并回滚包树——
  "installation rejected: Plugin dsh-better-sidebar@0.24.0 is incompatible with
   dsh 0.2.0-rc.1: peerDependencies {…^0.1.7-rc.1…}";恢复 ^0.2.0-rc.1 后安装通过
- 28 个 @deepseek-ai/* devDependencies 精确钉 0.2.0-rc.1(cordis 4.0.4 /
  cordis-plugin-group 1.0.4 / cordis-plugin-include 1.0.9 不变)
- 新增 @deepseek-ai/dsh-util-code-language@0.2.0-rc.1:0.2.0 的 ui-primitives
  产物裸 import 它(0.1.7 的产物同样 import,只是旧树里恰好被传递提供),
  不补则所有加载 ui-primitives 的 20 个 spec 在收集期失败

实测证据(写进 docs/plans/2026-09-28-dsh-0.2.0-rc.1-adaptation.md)
- 本插件消费的 19 个宿主包零值导出删除(对比 export {} / exports.x=)
- 类型面仅 3 处且全为增量:ui-primitives(DisclosureRow/TextShimmer/Tooltip
  新增可选 prop、overlay 顶部内距)、dsh-session(新增 ToolCallRecovery)、
  dsh-api-remotes(新增 product-analytics remote)
- 会话格式仍 v4、SUBAGENT_DESCRIPTOR_VERSION 仍 3、dsh/lib/bin.js 与
  dsh-client-modules 运行时逐字节相同、web profile 客户端 inject 列表相同、
  ui-primitives 裸 import 集合不变、agent-team 四包类型逐字相同
- 宿主 web profile:新增 desktop-only product-analytics/telemetry(web 上
  disabled);time-context/schedule/ui-schedule(本就 disabled)移入可选
  dsh-experimental-schedule-bundle;插件不依赖宿主 schedule

其余同步
- CI 与挂载 lane 钉 @deepseek-ai/dsh@0.2.0-rc.1(scripts/e2e-common.sh 的
  DSH_NPX_SPEC/DSH_EXPECT_VERSION、ci.yml),注释记下「0.2.0 首个候选版只挂
  next」与「caret 跨 minor 必失效」两条教训
- tests/market-manifest.spec.ts 的 peer 形状断言钉当前基线 tuple
- dsh.plugin.json engines.dsh → >=0.2.0-rc.1;SIDEBAR_SERVICE_VERSION → 0.24.0
- README/README_EN、CHANGELOG/CHANGELOG_EN、AGENTS.md(§2 钉版句 + §3 改为
  「DSH 版本适配要点」并新增 §3.0 当前基线,0.1.7 内容降为历史分节)、
  docs/external-plugin-guide.md 全部同步

验证:pnpm peers check 仅剩上游 better-locale 的 5 条 unmet(非本插件 peer);
typecheck / lint / test(136 文件 1484 通过 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = 7/7;
pnpm test:mount:aggregate 通过(聚合双挂载退让)
2026-09-28 21:21:45 +08:00
Menghuan1918 5c95002641 perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话
用户实测反馈:

1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
   - 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
   - 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
     (slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
     (14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
     (键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
     fs-watch 的变更通知显式失效
   - 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)

2) 删除工作区路径检测(默认不检测、放开权限):
   - path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
     分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
     (schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
   - ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
     upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
     指南 §8.1 已加醒目声明
   - 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
     fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
     store prop + 相关 CSS 块

3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
   挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
   refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)

4) 修复「关闭文件后返回文件树,展开状态被重置」:
   原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
   的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
   attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab

验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
2026-09-28 20:37:56 +08:00
Menghuan1918 f29743f03f chore(release): v0.23.0 —— 文件页/文件变动页重构、菜单层级与性能、ZIP 进度、修复在侧边打开
本地安装需要与已发布的 0.22.0 区分(同一路径的 tarball 会被 pnpm 复用旧解析),
故把版本抬到 0.23.0;正式发版流程(tag → release.yml)不受影响。
2026-09-28 20:12:48 +08:00
Menghuan1918 ee1fe05067 feat(sidebar): 菜单层级/性能 + 选中条吸底 + 打包进度 + 修复「在侧边打开」
用户实测反馈 5 条:

1) 文件列表性能 / 右键菜单不再刷屏(先测量后改,探针 tests/file-tree-menu-perf.spec.tsx)
   - 宿主应用列表按路径缓存:N 次打开菜单只请求 1 次(原每次 1 次 + Loading 帧)
   - 行上的 git 数据改为原始值(tone/letter):共享 store 每次轮询重建对象不再触达行
     (探针:开窗 folderIcons 4 → 0;root 行也 memo 化)
   - 菜单开关不触发目录重列(fsTree=0);实测「菜单打开期间暂停轮询」会让变更行瞬时
     掉色并多两次重渲染,故按测量结果放弃该做法(数字与理由写进探针注释)

2) 「已选择 N 项」条移到底部:滚动容器最后一个内容子节点 + sticky bottom(不遮最后
   一行),窄栏 flex-wrap 换行不出横向滚动条

3) 菜单层级重构:一级只留默认动作(宿主默认应用 / 打开方式 ▸ / 在文件管理器中显示 /
   新 Tab / 在侧边打开 / 下载 / 压缩并下载 / 复制路径 / 重命名 / 删除;目录行另有
   新建文件夹、上传到此处),应用列表整体收进「打开方式」子菜单;宿主探测到本机关联
   应用时默认不显示插件自研 open-with 项目,新增设置开关 openWithPluginTargets
   (editor descriptor 的 pluginToggles)可强制并存;宿主不可用时插件项目照旧

4) 压缩并下载显示进度:宿主改为可观测任务(archive.build / archive.status /
   /sidebar/archive?id=,4 并发 + 5 分钟 TTL + sessionId 绑定,buildZip 逐条目
   onProgress),客户端轮询 250ms 显示「正在打包 done/total · N%」,ready 后 blob 下载,
   失败进 zipFailed

5) 修复「在侧边打开没有效果」:根因是它只写插件底部工作台(原生右侧栏的 tab 不在
   bottomSplits 里),现按承载面分流 —— 原生 tab 走 service.openTab(target:'side') →
   宿主 openResource(preferNewPane, revealIfOpened:false) 真正分栏;底部工作台行为不变
   (接入 API 变更已同步 docs/external-plugin-guide.md)

验证:typecheck / lint / test(135 文件 1461 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
新增/更新测试:menu-perf(3)、archive(10)、open-in-app(18)、open-with(10)、multiselect(9)、
zip(37)、service(64)、editor-host(11)、builtins(23)、locales(20)
2026-09-28 20:11:46 +08:00
Menghuan1918 13d4bae321 fix(sidebar): 二轮独立验证的 9 项缺陷 + 目录级暂存
verifier 二轮报告(F1–F9)全部修复,每条都有「临时回退即变红」的判别性验证:

- F1【高】中文等非 latin1 下载名 → 真实宿主 500:content-disposition 改用
  RFC 5987(ASCII 回退 + filename*=UTF-8'');补真 node:http 用例与根因对照断言
- F2【中】多选同名文件归档重名(解压互相覆盖):冲突项逐级上溯父目录消歧
- F3【中低】maxBytes 在读完之后才判定;budget<=0 的 0 字节 FIFO 会永久阻塞:
  改为先 stat、读取前拒绝(FIFO 探针验证)
- F4【中低】SSH 模式 + 宿主不可用时 reveal 整条消失:保留 kind==='reveal' 目标
- F5【低】图钉嵌套交互语义非法且键盘不可达:改为诚实的鼠标热区,键盘入口归设置面板
- F6【低】变更树空闲 tick 全量重渲染:行 memo + 恒定 props + 内容键派生 +
  共享 store 的 sameStatus 保住快照身份
- F7【低】排序随运行时 locale 漂移:显式 'en' collation + 码点兜底
- F8【低】POSIX 文件名的反斜杠被当成层级:不再替换
- F9【低】打包中重复点击静默、无反馈:busy 行(role=status)+ in-flight 守卫
- 新增:目录行「暂存/取消暂存该目录」(git pathspec 语义,不触发折叠)

验证:typecheck / lint / test(134 文件 1441 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过
2026-09-28 18:20:08 +08:00
Menghuan1918 b011be1e98 feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树
用户追加三条需求,全部落地:

1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存)
- 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts /
  src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor
  的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文)
- 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了)
- FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」
  子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件
  explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用)
- 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除

2) 文件页多选右键「压缩并打包下载」
- 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、
  自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+
  GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、
  10000 条目 / 256 MiB 上限)+ api.archiveUrl
- FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载,
  路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条
- 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix,
  Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000,
  << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错

3) 文件变动页层级树 + 视觉打磨
- 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、
  大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数
- GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 +
  计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 =
  interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线;
  历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐

验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
2026-09-28 18:08:21 +08:00
Menghuan1918 36fbc5ac7e feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取
文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
2026-09-28 00:27:31 +08:00
107 changed files with 13891 additions and 2967 deletions
+4 -2
View File
@@ -154,7 +154,9 @@ jobs:
# Write an EXACT version, never a dist-tag: when 0.1.7-rc.1 shipped, the
# npm `alpha` tag still meant 0.1.7-alpha.2, rc.1 went out on `next`, and
# `latest` was still the 0.1.5 line — `@alpha` would mount a host this
# plugin no longer supports. The lanes speak the slash-only RPC dialect
# plugin no longer supports. The 0.2.0 line repeats the pattern: its
# first candidate is `next` while `latest` is still 0.1.7-rc.2.
# The lanes speak the slash-only RPC dialect
# (tests/e2e/host-protocol.ts).
#
# The larger heap is the fix for one of the two ways this step used to
@@ -177,7 +179,7 @@ jobs:
- name: Install dsh CLI (pinned)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: npm install -g @deepseek-ai/dsh@0.1.7-rc.1
run: npm install -g @deepseek-ai/dsh@0.2.0-rc.1
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
+20 -3
View File
@@ -19,11 +19,28 @@
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add <tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开本插件贡献的 tab 类型**以及宿主自己的终端条目**(必须恰好 1 条——多于 1 条即意味着插件又在遮蔽宿主),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。**`tests/e2e/native-reload.e2e.ts` 是同一 lane 里的第二条门**:它 `utimesSync` 已安装的 `lib/client.js`(**在钉住的宿主 0.1.7-rc.1 上**:HMR 每 500ms stat-poll,rev = `mtime/ctime/size` 的 sha1;DSH 源码树的新版本改成只比 `mtime+size` 并对**内容**取 sha1——pin 上调时这个触发器必须同步复核,否则该用例会红在替换断言上而不是静默失效)触发**页面内条目替换**,断言替换前后都无 `native register … error`、无诊断条、且 `files` 接管重新可用——这是唯一能在部署层复现「teardown 期间注册把 id 孤儿化」(见 §3.4 第 9 条)的用例,v0.22.0 上连续 3 次运行全红、修复后连续 3 次全绿。**`browser` 不在这个清单里,而是显式断言缺席**:DSH 0.1.7 的 `ui-sidebar-browser` 只在 desktop profile 挂载(`web-app` 的 patch 里 `disabled: ctx.get('profileContext')?.name !== 'desktop'`),lane 全走 web profile,钉住「没有」比默默放过更能暴露宿主立场的变化。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(`DSH_CMD` 未设时 `scripts/e2e-common.sh` 回退的 npx 钉版是 `DSH_NPX_SPEC`,默认 `@deepseek-ai/dsh@0.1.7-rc.1`——**不钉就会解析 `latest`,而 `latest` 仍是插件已不支持的 0.1.5 线**)。CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`(**pin 一律写精确版本号,不要写 `@alpha`**:rc.1 上线时 npm `alpha` 指的是 **0.1.7-alpha.2**,rc.1 走的是 `next`;`latest` 仍是 0.1.5-rc.3;peer 下限 `^0.1.7-rc.1`)。该步骤用 `NODE_OPTIONS=--max-old-space-size=4096`:钉版自身的传递依赖是浮动 `^` 范围,上游分阶段发布预发布版时(rc.2 于 2026-09-10 的 14:43–14:57 逐包上线)npm 会组出混合 peer 图,3062 条 ERESOLVE 后 OOM(exit 134);另一个失败模式是对未发布兄弟包 ETARGET,靠「钉一个已完整发布的版本」修掉。**不要加 `--legacy-peer-deps`**:它跳过的正是全局安装必须提供的 peer,`@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`(实测过一次,见 rc.2 计划 C 节)。`ci-windows` 的 `Test` 跑 `pnpm test:windows`(`--maxWorkers=1`)而非 `pnpm test`——多个 spec 真起进程(`smoke` / `install-powershell` / `git`),2 核 runner 上并行起 ConPTY / 冷启 `powershell.exe` 是超时与 worker 静默死亡的放大器;`vitest.config.ts` 的全局 `testTimeout: 15_000`(默认 5000 在 Windows 上对真起进程的用例太低,三个不同文件先后翻车)是配套的一半。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(`DSH_CMD` 未设时 `scripts/e2e-common.sh` 回退的 npx 钉版是 `DSH_NPX_SPEC`,默认 `@deepseek-ai/dsh@0.2.0-rc.1`——**不钉就会解析 `latest`,而 `latest` 仍是 0.1.7-rc.2**;本机 pnpm 的 registry 是 npmmirror,新预发布常滞后,装不到时用 `pnpm install --registry https://registry.npmjs.org` 一次性覆盖)。CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`(**pin 一律写精确版本号,不要写 dist-tag**:0.1.7-rc.1 上线时 `alpha` 指的是 **0.1.7-alpha.2**,而 0.2.0-rc.1 只挂在 `next` 上;peer 下限 `^0.2.0-rc.1`)。该步骤用 `NODE_OPTIONS=--max-old-space-size=4096`:钉版自身的传递依赖是浮动 `^` 范围,上游分阶段发布预发布版时(rc.2 于 2026-09-10 的 14:43–14:57 逐包上线)npm 会组出混合 peer 图,3062 条 ERESOLVE 后 OOM(exit 134);另一个失败模式是对未发布兄弟包 ETARGET,靠「钉一个已完整发布的版本」修掉。**不要加 `--legacy-peer-deps`**:它跳过的正是全局安装必须提供的 peer,`@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`(实测过一次,见 rc.2 计划 C 节)。`ci-windows` 的 `Test` 跑 `pnpm test:windows`(`--maxWorkers=1`)而非 `pnpm test`——多个 spec 真起进程(`smoke` / `install-powershell` / `git`),2 核 runner 上并行起 ConPTY / 冷启 `powershell.exe` 是超时与 worker 静默死亡的放大器;`vitest.config.ts` 的全局 `testTimeout: 15_000`(默认 5000 在 Windows 上对真起进程的用例太低,三个不同文件先后翻车)是配套的一半。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
---
## 3. DSH 0.1.7 适配要点(0.1.7-rc.1+ 基线)
## 3. DSH 版本适配要点
### 3.0 当前基线:DSH 0.2.0-rc.1(v0.24.0 起)
**v0.24.0 起仅支持 DSH 0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 与挂载 lane 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)停留在 v0.23.0**。
**教训(本次最贵的一条)**:**caret 范围永远跨不过 minor**。`^0.1.7-rc.1` 展开为 `>=0.1.7-rc.1 <0.2.0-0`,对 `0.2.0-rc.1` 判定为 **false**(实测,`includePrerelease: true` 也救不了)→ 宿主启动预检(§3.4 第 12 条)把整行 `row.disabled = true`,只在 stderr 留一行,插件**静默不加载**。§3.4 第 12 条里「`includePrerelease` 让判定更宽松(`^0.1.6-alpha.2` 也能满足 `0.1.7-rc.1`)」只在**同一 minor 内**成立:那里的上界 `<0.2.0-0` 没被跨过。显式比较器不受此限(`>=0.1.1-rc.2 <0.2.0` 反而容纳 `0.2.0-rc.1`),本仓库仍只用 caret + 当前基线 tuple,并由 `tests/market-manifest.spec.ts` 钉住。
**0.2.0-rc.1 复查结论(适配前实测,故本版无任何运行时兼容分支)**:
- 本插件消费的 **19 个宿主包零值导出删除**(脚本对比每包的 `export {}` / `exports.x=`);唯一新增是 `ui-primitives` 的 `pointerModality`。
- 类型面变化只有三处且全为增量:`ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距改用 frame overlay inset)、`dsh-session`(新增 `ToolCallRecovery` 与 `openTurnClosers` 的新措辞;`interruptedTurnClosers` 仍在)、`dsh-api-remotes`(新增 product-analytics remote 行)。
- **未变**:会话格式仍 v4(无 `session-format-v4-to-v5`)、`SUBAGENT_DESCRIPTOR_VERSION` 仍 3、`dsh/lib/bin.js` 与 `dsh-client-modules` 运行时**逐字节相同**、web profile 客户端 inject 列表相同、`ui-primitives` 裸 import 集合相同(无需新增测试期依赖)、agent-team 四个包类型逐字相同。
- **宿主 web profile 组成**:新增 desktop-only `product-analytics` / `desktop-product-telemetry`(web 上 `disabled`);`time-context` / `schedule` / `ui-schedule`(本就 `disabled`)移入可选 `dsh-experimental-schedule-bundle`。挂载 lane 的「宿主 tab 恰好 1 条(terminal)、browser 缺席」断言不受影响。插件**不依赖**宿主 schedule 服务。
- **生态连带**:可选集成 `@huanlin/dsh-plugin-better-locale` 的 peer 钉 `^0.1.x`(最新 0.4.3 也钉 `^0.1.5-rc.2`),在 0.2.0 上必被预检禁用 → `pnpm peers check` 会报它的 5 条 unmet peer,属上游未适配,与本插件自身 14 条 peer 无关。
- **本机 dev 树**:先 `pnpm install --registry https://registry.npmjs.org`(npmmirror 对新预发布滞后,直接 `pnpm install` 会 `ERR_PNPM_NO_MATCHING_VERSION`)。
- **用户 profile 自己的 pin 也要同批抬**(2026-09-28 实测踩到):profile 的 `pnpm-workspace.yaml` 里若有 `overrides: '@deepseek-ai/dsh-*'`(例如历史上为修「旧宿主包遮蔽 bundle」而加的 `dsh-settings` pin),**升级宿主时必须与宿主版本同批更新**。忘了抬的后果:profile 顶层留住旧版 `dsh-settings`,它用精确版本拉来旧版 `dsh-config-editor` 并遮蔽 bundle 自带的 0.2.0 版本 → 预检禁用 `config-editor` 行 → `settings` 行永远 `pending (waiting for service: configEditor)` → 界面报「加载提供商目录失败: settings service is absent」。症状与原因隔着好几层,先看宿主日志里的 `disabling profile plugin row` 与 `pending (waiting for service: …)` 两行。
### 3.1~3.4 历史:DSH 0.1.7 适配要点(0.1.7-rc.1+ 基线,v0.21.1–v0.23.0)
**v0.21.1 起仅支持 DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)。**下限必须动**:semver 的预发布规则要求比较子与候选版本同 `[major,minor,patch]` 元组,`^0.1.6-alpha.2` 在数学上**永远容纳不下任何 `0.1.7-*` 预发布版**(实测 `0.1.7-alpha.1` 对 `^0.1.6-alpha.2` / `>=0.1.6-alpha.2` 全是 false),而 `^0.1.7-rc.1` 天然容纳 0.1.7 的 rc/正式全线。**下限从 alpha.1 收到 rc.1 是刻意的**:`0.21.0-alpha.1` 从未发布到 npm(`latest` 仍是 v0.19.1,npm 上没有 alpha.1 的下载量),而修掉 rc.1 的 `DiffBlockLabels` 之后,同一份代码在 alpha.1 上会**崩**——alpha.1 的 `DiffBlock` 仍读上游已删除的 `labels.files`。声明与「真机验证过的宿主」一致,比声明一条跑不通的支持线更安全;rc.1 起 peer 还是**启动期硬约束**(见 §3.4 第 12 条),声明过宽的代价从「可能报错」变成「静默不加载」。**支持线取舍**:0.1.6-alpha.2 及更早的用户停留在 **v0.19.1**(0.20.0 这一版从未发布到 npm)——本版破坏面足够大(`dsh-settings` 整体重写、25 个图标具名导出消失、会话格式 v3→v4),按本仓库先例不写运行时兼容层。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag(`0.21.1` 不含 `-` → `latest`,npm `latest` 由此从 0.19.1 前移到 0.21.1;中间的 `0.21.0-rc.1` 含 `-`,只进过 `alpha` 通道)。
@@ -49,7 +66,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 引入,但这次一并修)
+27
View File
@@ -2,6 +2,33 @@
> 本文档收录 dsh-better-sidebar 的完整发布历史(最新版摘要见 [README](README.md);同步发布于 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases))。
### v0.24.1
> 🎁 **本版包含从 v0.22.1 起的全部内容**(v0.23.0 的文件页 / 文件变动页 UI/UX 重构与性能打磨、v0.24.0 的 DSH 0.2.0-rc.1 支持线前移,以及本节的修复)。
>
> 🐞 **修复版**:文件树「一操作就整体刷新」的两处根因。① 原生承载面(`src/client/native/index.ts` 的 `sync()`)把 `files` 接管项当成多余注册——它按 KIND 记账、永远不在 `wanted` 里,于是**每次会话状态写入**都被 dispose + 重建;替换 slot 注册等于替换宿主的 slot entry,**整个插件 tab body 被卸载重挂**(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher,并重新 `fs.trees([root, ...expanded])` 重列整棵可见树。② 目录实时刷新(`FileTree.tsx` 的 `retryDir`)**先把该层缓存删掉**再请求,行被占位替换后重建(构建 / 格式化 / 模型跑 bash 时整层闪空)。
- 🧪 判别性证据:真实挂载 lane 新增 `tests/e2e/zz-expand-refresh.e2e.ts`(展开只请求该层、tab body 存活、行数不归零;回退任一修复即红),配套两个 jsdom 单测(回退后 `registered` 6 → 12、`disposed` 全是 `dsh-better-sidebar:files`;另一条断言重列期间旧行仍在屏)
- 📉 修复后实测(40 子项目录):展开 `fs.trees` payload 由 `[root, big]`(空缓存特征)变为 **`[big]`**,新开 WebSocket 1 → **0**,被删行 45 → **0**,外部写入由「删 40 建 40」变为 **只增 1 行**
- 🔧 `src/fs-watch.ts` / `use-dir-watch.ts` / `TreePanel.tsx` / `tab-adapter.tsx` 零改动(`fs.watch` 新建自身不投递事件,已实测证伪)
### v0.24.0
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.22.1(0.1.7 线最后发布的版本)**——宿主 0.2.0 的启动兼容性预检按 `semver.satisfies(宿主版本, peer范围, {includePrerelease:true})` 判定,caret 范围跨 minor 不成立(实测 `^0.1.7-rc.1` 对 `0.2.0-rc.1` 为 **false**,整行会被静默禁用),因此本版不保留任何 0.1.7 兼容分支。
- 📦 **基线抬到 `0.2.0-rc.1`**:14 条 DSH peer + 27 个 `@deepseek-ai/*` devDependencies 同步钉版;`dsh.plugin.json` 的 `engines.dsh` 随之前移;挂载 lane(`scripts/e2e-common.sh`)与 CI 钉版同步。
- 🔍 **适配前的实测结论(纯增量,故无运行时改动)**:本插件消费的 19 个宿主包里**零个值导出被删除**;类型面仅 `dsh-client-ui-primitives`(`DisclosureRow`/`TextShimmer`/`Tooltip` 新增可选 prop、overlay 顶部内距)、`dsh-session`(新增 `ToolCallRecovery`)、`dsh-api-remotes`(新增 product-analytics remote)有变化;会话格式仍 **v4**(无迁移包)、`SUBAGENT_DESCRIPTOR_VERSION` 仍 **3**、`dsh/lib/bin.js`(`dsh plugin` / `dsh web` / 启动行 / `--version`)与 `dsh-client-modules` 运行时**逐字节相同**、web profile 的客户端 inject 列表相同、`ui-primitives` 的裸 import 集合相同(无需新增测试期依赖)。
- 🧩 **宿主 web profile 组成变化与本插件无关但已核对**:新增 desktop-only 的 `product-analytics` / `desktop-product-telemetry`(web profile 上 `disabled`);原先就 `disabled` 的 `time-context` / `schedule` / `ui-schedule` 三行移入可选的 `dsh-experimental-schedule-bundle`。「宿主 tab 只有 terminal、browser 缺席」的挂载断言不受影响。
- 🧪 **测试与规则**:`tests/market-manifest.spec.ts` 的 peer 形状断言改为钉当前基线 tuple,并把「caret 跨 minor 必失效」写进注释(这条与 §3.4 第 12 条里「includePrerelease 更宽松」的经验不冲突:宽松只发生在同一 minor 内)。
- ⚠️ **唯一残留**:可选集成 `@huanlin/dsh-plugin-better-locale`(未被任何源码/测试 import,仅结构化运行时探测)的 peer 钉在 `^0.1.0-rc.8`,其最新 0.4.3 同样钉 `^0.1.5-rc.2`——两者在 0.2.0 上都会被宿主预检禁用,故 `pnpm peers check` 会报它的 5 条 unmet peer。这是上游未适配 0.2.0 的结果,与本插件自身 14 条 peer 无关。
### v0.23.0
> 🧭 **开发线版本(从未发布到 npm,全部内容随 v0.24.1 一并发布)**:文件页与文件变动页的整体 UI/UX 重构,加上一轮性能与交互打磨。文件页新增 **Ctrl/Cmd 与 Shift 多选**(VS Code 语义)、批量操作条、**Git 变更着色**(与 VS Code 同色的新增/修改/删除/冲突,目录带汇总点)、**新建文件夹**、拖拽上传体验重做;**多选可右键「压缩并下载」**,打包在宿主侧成为带进度的任务(`archive.build` / `archive.status`,逐条目回调,4 并发 + 5 分钟 TTL + sessionId 绑定)。「打开方式」改为**双源并存**:宿主探测到的本机关联应用(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器,含 SSH 远端)与插件自研目标,可用设置开关 `openWithPluginTargets` 强制并存;右键菜单收敛层级(一级只留默认动作,应用列表进子菜单)。文件变动页**重构为层级树**,Git 视角(工作区 / 暂存 / 历史)与 Agent 视角(会话内改动)各自保留,目录行可暂存/取消暂存。交互与性能:`/sidebar/api/fs.tree` 实测 **22.8ms → 3.8ms**(10k 条目,readdir 一次取回 + 只为 cap 建行 + 小写比较排序 + 1.5s 目录级缓存,由写操作与 fs-watch 失效),新增批量路由 `fs.trees`(挂载/刷新的 N+1 请求变 1 个),右键菜单打开不再重列目录、行不再随 git 轮询重渲染。修掉「在侧边打开」没有效果(原来只写未展开的底部工作台,现按承载面分流到宿主 `openResource(preferNewPane)` 真正分栏)。
- ⚠️ **行为变更(安全相关)**:**删除工作区路径检测**——插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/upload/archive)不再做包含性检查,可读写宿主用户能访问的任意路径(仅受 OS 权限约束),`workspaceFence` 设置与围栏提示一并移除。
- 🧩 抽出可复用的全局组件与共享 store(`IconButton` / `Chip` / `SectionHeader` / `Notice` / `ConfirmDialog` / `StatusBadge` + 共享 git 状态 store),皮肤契约(`tests/theme.spec.ts`)继续守护;ZIP 打包器自研(无第三方依赖)并修掉非 latin1 文件名的下载头 500(RFC 5987)。
- 🐞 同时修掉:关闭文件后返回文件树时展开状态被重置(原生承载面的展开集合原先按 tab 记录,现改为**每会话状态**的投影)。
### v0.22.1
> 📦 **正式版**(npm `latest`):仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)——**支持线不变**,0.21.1 / 0.22.0 的用户直接升级即可;**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。这是一次补丁版:两个缺陷在真机上可稳定复现,而当时的单元测试**全绿**。
+27
View File
@@ -2,6 +2,33 @@
> Full release history of dsh-better-sidebar (see the [README](README_EN.md) for the latest release; also mirrored on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)).
### v0.24.1
> 🎁 **This release carries everything since v0.22.1** (the Files/Changes UI-UX rebuild and performance work of v0.23.0, the DSH 0.2.0-rc.1 support-line move of v0.24.0, and the fixes below).
>
> 🐞 **Fix release** for the file tree rebuilding itself on every interaction. (1) The native surface (`sync()` in `src/client/native/index.ts`) treated the `files` takeover as a surplus registration — it is keyed by KIND and never appears in `wanted`, so **every session-state write** disposed and re-created it. Replacing a slot registration replaces the host's slot entry, which **unmounts and remounts the whole plugin tab body** (folder toggles, tab switches, bottom-workbench drags all trigger it): the explorer lost its level cache, scroll position and directory watcher, then re-listed the entire visible tree via `fs.trees([root, ...expanded])`. (2) The live-refresh path (`retryDir` in `FileTree.tsx`) **deleted a level from the cache** before refetching, so its rows were replaced by a placeholder and rebuilt (a build, a formatter or the model's own bash made entire folders blink).
- 🧪 Discriminating evidence: a new real-host lane `tests/e2e/zz-expand-refresh.e2e.ts` (a toggle asks only for the toggled level, the tab body survives, rows never drop to zero; reverting either fix turns it red) plus two jsdom guards (reverting the surface fix: `registered` 6 → 12 with every `disposed` entry being `dsh-better-sidebar:files`; the other asserts stale rows stay on screen during a re-list)
- 📉 Measured after the fix (40-child directory): the toggle's `fs.trees` payload goes from `[root, big]` (the empty-cache signature) to **`[big]`**, new WebSockets 1 → **0**, removed rows 45 → **0**, and an external write goes from "remove 40, add 40" to **one added row**
- 🔧 `src/fs-watch.ts` / `use-dir-watch.ts` / `TreePanel.tsx` / `tab-adapter.tsx` untouched (`fs.watch` does not emit on creation — measured and ruled out)
### v0.24.0
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.22.1 (the last published release on the 0.1.7 line)** — the 0.2.0 host's startup compatibility preflight evaluates `semver.satisfies(hostVersion, peerRange, {includePrerelease:true})`, and a caret never spans a minor bump (measured: `^0.1.7-rc.1` vs `0.2.0-rc.1` is **false**, so the whole row is disabled silently). This version therefore keeps no 0.1.7 compatibility branch.
- 📦 **Baseline lifted to `0.2.0-rc.1`**: 14 DSH peers and 27 `@deepseek-ai/*` devDependencies move together; `dsh.plugin.json`'s `engines.dsh` follows; the mount lane (`scripts/e2e-common.sh`) and CI pins follow.
- 🔍 **Measured before adapting (purely additive, hence no runtime change)**: across the 19 host packages this plugin consumes, **zero value exports were removed**; the only type-surface changes are `dsh-client-ui-primitives` (optional props on `DisclosureRow` / `TextShimmer` / `Tooltip`, overlay top inset), `dsh-session` (new `ToolCallRecovery`) and `dsh-api-remotes` (a new product-analytics remote); session format stays **v4** (no migration package), `SUBAGENT_DESCRIPTOR_VERSION` stays **3**, `dsh/lib/bin.js` (`dsh plugin` / `dsh web` / the ready line / `--version`) and the `dsh-client-modules` runtime are **byte-identical**, the web profile's client inject list is unchanged, and `ui-primitives`' bare imports are identical (no new test-time dependency).
- 🧩 **Host web-profile composition changes, checked though unrelated**: desktop-only `product-analytics` / `desktop-product-telemetry` rows are added (disabled on the web profile); the already-disabled `time-context` / `schedule` / `ui-schedule` rows move into the optional `dsh-experimental-schedule-bundle`. The mount assertion "the host owns exactly one tab entry (terminal) and browser is absent" is unaffected.
- 🧪 **Tests and rules**: the peer-shape assertion in `tests/market-manifest.spec.ts` now pins the current baseline tuple and records the "a caret never spans a minor bump" lesson (compatible with §3.4 item 12's "includePrerelease is more permissive" note — that permissiveness only holds inside one minor line).
- ⚠️ **Only residue**: the optional `@huanlin/dsh-plugin-better-locale` integration (imported by no source file or test; detected structurally at runtime) pins `^0.1.0-rc.8`, and even its newest 0.4.3 pins `^0.1.5-rc.2` — both fail the 0.2.0 preflight, so `pnpm peers check` reports its 5 unmet peers. That is upstream not having adapted to 0.2.0, unrelated to this plugin's own 14 peers.
### v0.23.0
> 🧭 **Development-line version (never published to npm; its content ships with v0.24.1)**: the Files and Changes pages rebuilt end to end, plus a round of performance and interaction polish. The Files page gains **Ctrl/Cmd and Shift multi-select** (VS Code semantics), a batch action bar, **Git change coloring** (VS Code's add/modify/delete/conflict tones, directories get a summary dot), **new-folder creation**, reworked drag-and-drop upload, and **"Zip and download" on a multi-selection** — archiving became a host-side task with progress (`archive.build` / `archive.status`, per-entry callbacks, 4 concurrent builds, 5-minute TTL, sessionId-bound). "Open with" now keeps **both sources side by side**: the local associated apps the host detects (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes included) plus the plugin's own targets, with a setting (`openWithPluginTargets`) to force coexistence; the context menu was restructured (only default actions at level one, the app list moves into a submenu). The Changes page is **rebuilt as a hierarchy tree** with both the Git view (working tree / staged / history) and the Agent view (per-session edits), and directory rows stage/unstage. Interaction and performance: `/sidebar/api/fs.tree` measured **22.8ms → 3.8ms** (10k entries: one `readdir`, rows built only up to the cap, lowercase-comparison sort, 1.5s directory-level cache invalidated by writes and `fs-watch`), a new batch route `fs.trees` (mount/refresh goes from N+1 requests to one), opening the context menu no longer re-lists directories, and rows no longer re-render with every git poll. Also fixed: "Open to the Side" did nothing (it only wrote into the collapsed bottom workbench; it now routes by surface to the host's `openResource(preferNewPane)` and really splits).
- ⚠️ **Behavior change (security-relevant)**: the **workspace path fence was removed** — the plugin's fs routes (tree/read/write/rename/remove/mkdir/media/HTML/upload/archive) no longer enforce containment and can read or write any path the host user can reach (OS permissions are the only limit); the `workspaceFence` setting and its notice row are gone.
- 🧩 Reusable global components and shared stores were extracted (`IconButton` / `Chip` / `SectionHeader` / `Notice` / `ConfirmDialog` / `StatusBadge` plus a shared git-status store) under the skin contract (`tests/theme.spec.ts`); the ZIP writer is our own (no third-party dependency) and a non-latin1 download name no longer 500s (RFC 5987).
- 🐞 Also fixed: the tree's expansion was reset when a file preview was closed and the tree came back (the native surface kept the expansion set per tab; it is now projected from **per-session state**).
### v0.22.1
> 📦 **Stable release** (npm `latest`): DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`) — the support line is **unchanged**, so 0.21.1 / 0.22.0 users can upgrade straight away; **hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1**. A patch release: both defects reproduced reliably on a real host while the unit suite stayed **green**.
+31 -11
View File
@@ -3,7 +3,7 @@
> [!IMPORTANT]
> **v0.19.0 起接入 DSH 原生侧边栏**:右列就是 DSH 自己的右侧栏,插件把每个 tab 类型注册为原生 tab(不再自绘右侧面板),只保留自绘的底部工作台与开放给所有插件的 `ctx.betterSidebar` 服务。
>
> **v0.21.1 起要求 DSH `0.1.7-rc.1+`**(peer 下限 `^0.1.7-rc.1`;本版 v0.22.1 即 npm `latest`)。DSH 0.1.7 自带完整文档预览,插件把只读预览(表格 / PDF / 图片 / Office)整体让给内置,只保留 Markdown / HTML 与可编辑的代码编辑器。**0.1.6-alpha.2 及更早的用户请固定 `dsh-better-sidebar@0.19.1`**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
> **v0.24.1 起要求 DSH `0.2.0-rc.1+`**(peer 下限 `^0.2.0-rc.1`)。0.2.0 对本插件所用的全部宿主 API 是**纯增量**(零导出删除、会话格式仍 v4、CLI 与客户端运行时未变),所以这一版没有运行时兼容分支,只把支持线整体前移。**DSH 0.1.7 线的用户请固定 `dsh-better-sidebar@0.22.1`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
<!-- Hero -->
<div align="center">
@@ -14,7 +14,7 @@
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.1):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="底部工作台" src="https://img.shields.io/badge/-底部工作台-4d6bfe" /> <img alt="文件变动" src="https://img.shields.io/badge/-文件变动-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
@@ -48,8 +48,8 @@
相比 DSH 官方侧边栏,本插件补上的关键能力:
- **🖥️ 可编辑的代码编辑器**:官方文档预览是**只读**的 → 插件保留**可编辑**的 CodeMirror 编辑器(保存、语法高亮、预览切换);Markdown / HTML 也走插件自有渲染(Mermaid 图表安全渲染 + 点击放大、README 级内嵌 HTML、浮动目录大纲、HTML 沙箱预览)
- **🗂️ 增强文件树**:接管内置「文件」页——懒加载目录树、**展开的目录实时 watch 自动刷新**、软链接识别、全局文件名搜索、拖拽上传、悬浮 `@文件` 一键引用进输入框
- **🌿 文件变动**(官方侧栏没有 Git 面板):Git 视角(diff / 历史 / 暂存·提交·还原)+ 本轮 AI 改动视角双合一,统一 diff 渲染(行内字符级高亮、语法着色、敏感内容脱敏)
- **🗂️ 增强文件树**:接管内置「文件」页——懒加载目录树、**展开的目录实时 watch 自动刷新**、软链接识别、全局文件名搜索、拖拽上传、悬浮 `@文件` 一键引用进输入框;**Ctrl/Cmd 多选 + Shift 连选**(批量复制路径 / 批量删除)、**Git 变更着色 + 状态字母**(VS Code 同款)、**新建文件夹**、**多选右键「压缩并打包下载」**(服务端流式打 ZIP,无第三方依赖);右键「打开方式」= **DSH 自带 open-in-app**(宿主探测到的本机关联应用 + 文件管理器显示)**+ 插件自研打开方式**(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器 URL 模板、SSH 远端、固定到菜单)两者并存
- **🌿 文件变动**(官方侧栏没有 Git 面板):Git 视角(暂存 / 提交 / 历史 / 工作树与子仓库)+ 本轮 AI 改动视角双合一,统一 diff 渲染(行内字符级高亮、语法着色、敏感内容脱敏);两视角共用一套 28px 行、单一空态/错误通道与吸底提交条
- **🧩 任务管理**(官方没有):子代理拓扑实时预览 + 后台任务清单(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话**(官方没有,beta):Codex 风格侧边线程——继承主会话完整上下文独立运行,可持续追问,一键提升为顶层会话
- **🖥️ 底部工作台**(官方没有):右列交给 DSH 原生右侧栏,插件另加自绘底部工作台(拖拽分栏 / 按会话持久化),可与原生栏同时展开
@@ -62,15 +62,16 @@
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**支持的 DSH 版本**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **通道与支持线**:`v0.22.1` 是**正式版**(npm `latest`),仅适配 DSH **0.1.7-rc.1+**。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.1.7-rc.1`(rc.1 走 npm `next` 通道)。**DSH 0.1.6-alpha.2 及更早的用户请固定 `dsh-better-sidebar@0.19.1`**——0.1.7 的破坏面(设置服务重写、图标导出改名、会话格式 v3→v4)大到本版不写兼容层。
> 📌 **通道与支持线**:`v0.24.1` 适配 DSH **0.2.0-rc.1+**(0.2.0 首个候选版走 npm `next` 通道,`latest` 仍是 0.1.7-rc.2)。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.2.0-rc.1`。**0.1.7 线的用户请固定 `dsh-better-sidebar@0.22.1`**:0.2.0 是宿主 minor 变更,`^0.1.7-rc.1` 这类 caret 范围在 0.2.0 上会被宿主的启动兼容性预检判定失败、整行静默禁用。
> 🧭 **按你的 DSH 版本选插件版本**:
>
> | 你的 DSH 版本 | 安装命令 | 版本 / peer 声明 |
> | --- | --- | --- |
> | **0.1.7-rc.1+**(含之后的 0.1.7 正式版) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.22.1**,`^0.1.7-rc.1` |
> | **0.2.0-rc.1+**(含之后的 0.2.0 正式版) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.24.1**,`^0.2.0-rc.1` |
> | **0.1.7-rc.1 ~ 0.1.7-rc.2**(含 0.1.7 正式版;npm `latest` 目前仍是 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.22.1` | **0.22.1**,`^0.1.7-rc.1` |
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **没有可装版本**——先把 DSH 升到 rc.1,再跑上一行:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
> | 0.1.6-alpha.2 及更早、`0.1.5-rc.*`(含 npm `latest` 的 0.1.5-rc.3) | `dsh plugin --profile web add dsh-better-sidebar@0.19.1` | **0.19.1**,`^0.1.5-rc.1` |
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-better-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
@@ -171,9 +172,9 @@ dsh registry enable dsh-external/dsh-better-sidebar
| | |
|---|---|
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、**展开的目录由宿主按目录 watch、改动后自动重列**、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · HTML 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);HTML 走插件自带的**沙箱预览**,并带 `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个宿主没有的逃生门开关。**图片 / PDF / 表格 / Office 不再是插件能力**——那些格式由 DSH 自己的文档预览渲染。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、**展开的目录由宿主按目录 watch、改动后自动重列**、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 新建文件夹 / 打开方式:**宿主探测到的系统关联应用**(open-in-app)+ **插件自研目标**(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器,支持 SSH 远端与固定到菜单)/ 复制路径 / 重命名 / 删除)、**Ctrl/Cmd 多选与 Shift 连选**(批量复制路径 / 批量删除 / **压缩并下载**)、**Git 变更按状态着色并带 M/A/D/U 字母**、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · HTML 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);HTML 走插件自带的**沙箱预览**,并带 `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个宿主没有的逃生门开关。**图片 / PDF / 表格 / Office 不再是插件能力**——那些格式由 DSH 自己的文档预览渲染。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror 代码编辑器**<br/><sub>**可编辑**的文本 / 代码编辑器(保存、语法高亮、预览切换)——宿主自己的文档预览是**只读**的,这是插件保留 catch-all viewer 的理由。</sub><br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片 / PDF / 表格 / Office 预览(由 DSH 内置提供)**<br/><sub>这些只读格式由 DSH 自己的 `ui-sidebar-documentpreview` 渲染:宿主侧 Office→PDF 转换、电子表格 worker 表格、图片 / PDF 缩放视口,并**按目录自动刷新**。插件已删除自己的 image / pdf / 下载兜底 viewer,也不再认领这些扩展名。</sub><br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 终端(由 DSH 内置提供)**<br/><sub>右侧栏终端由 DSH 自己的 `ui-sidebar-terminal` 提供:shell 选择、双击重命名、断线重连、刷新后恢复、主题与对比度跟随。插件不再自带终端实现。<br/><br/>⚠️ **模型侧提示**:插件原来自带的 8 个 `terminal_*` 工具(默认关)是模型唯一的**跨调用持久**终端;上游等价物 `@deepseek-ai/dsh-tool-terminal` 未被任何内置 bundle 默认挂载,若你需要该能力,请在 profile 的 `cordis.patch.yml` 里自行插入一行 `tool-terminal`。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 文件变动:Git 视角 + 本轮文件**<br/><sub>双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。</sub><br/><div align="center"><img width="420" alt="文件变动" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **💻 终端(由 DSH 内置提供)**<br/><sub>右侧栏终端由 DSH 自己的 `ui-sidebar-terminal` 提供:shell 选择、双击重命名、断线重连、刷新后恢复、主题与对比度跟随。插件不再自带终端实现。<br/><br/>⚠️ **模型侧提示**:插件原来自带的 8 个 `terminal_*` 工具(默认关)是模型唯一的**跨调用持久**终端;上游等价物 `@deepseek-ai/dsh-tool-terminal` 未被任何内置 bundle 默认挂载,若你需要该能力,请在 profile 的 `cordis.patch.yml` 里自行插入一行 `tool-terminal`。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 文件变动:Git 视角 + 本轮文件**<br/><sub>双视角合一:**Git 视角**保留完整源代码管理(暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原、历史、worktree 与子仓库选择);**本轮文件视角**实时折叠会话事件日志,记录模型读 / 写 / 编辑的每个文件(按文件分组、按类型筛选、操作数角标)。点击任意改动在底部**可拖拽预览面板**查看统一 diff——删红 / 增绿 / 改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠——也可一键展开为 VSCode 式独立 diff tab(同一渲染栈)。两个视角共用 36px 头(宿主 SegmentedControl 切换)、28px 行、单一空态与吸底提交条;**Git 视角按目录层级(树)展示变更**——单子目录链压缩成一行、目录行可折叠并带下级变更数、文件行带状态字母与文件图标;git 状态与文件树共享同一份快照,暂存/提交后文件树着色同步刷新。</sub><br/><div align="center"><img width="420" alt="文件变动" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 外链接管(浏览器视图由 DSH 提供)**<br/><sub>网页 tab 是 DSH 自己的 `ui-sidebar-browser`(多开 / 后退前进刷新 / 地址栏 / 沙箱 iframe),**0.1.7 起只在 desktop profile 挂载**——Web profile 里没有这个 kind。插件保留宿主没有的那一半:**只认领有 tab 类型通过 `urlTarget` 明确声明的链接**(Ctrl/Cmd 点击始终放行),**其余一律放行给宿主**(正文链接的去向由宿主的用户设置 `linkOpening` 决定);按协议分流的三个外链接管设置项已删除,认领成功但目标类型此刻不可用时兜底到 `window.open`。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动激活任务页,宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💬 侧边对话(beta)**<br/><sub>Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。</sub><br/><div align="center"><img width="420" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🖥️ DSH 原生右侧栏 + 插件底部工作台**<br/><sub>右列是 DSH 自己的右侧栏:插件把每个 tab 类型注册成原生 tab(含接管内置「文件」页),聊天里的文件点击直接落到原生栏——**宿主自己的文档预览已覆盖的格式由宿主渲染**,插件只认领 Markdown / HTML / 可编辑代码;插件自有底部面板可与其同时展开,拖 Tab 到分栏边缘**拆分**、拖到中间**合并**,高度拖上缘调节;开合按钮在会话头右侧。</sub><br/><div align="center"><img width="420" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
| **⚙️ 声明式设置**<br/><sub>设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`,整份偏好则写在 profile 里本插件的**挂载行**上(DSH 0.1.7 起设置按 Loader entry id 寻址)。</sub><br/><div align="center"><img width="420" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 移动端**<br/><sub>窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。</sub><br/><div align="center"><img width="360" alt="移动端全宽抽屉" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
@@ -190,6 +191,24 @@ dsh registry enable dsh-external/dsh-better-sidebar
## 🆕 最近更新
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.24.1
> 🐞 **修复版**:修掉两处会让文件树「一操作就整体刷新」的缺陷——① 原生承载面在**每次会话状态写入**时都会重建 `files` 接管项的 slot 注册,宿主因此换掉 slot entry、把整个 tab body 卸载重挂(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher 并重列整棵可见树;② 目录实时刷新的重列**先把该层缓存删掉**再请求,导致行被「Loading…」占位替换后重建(构建/格式化/模型跑 bash 时整层闪空)。现在展开只请求被展开那一层、tab body 不再重建、变更只增量更新行。
### v0.23.0
> 🧭 **开发线版本(从未发布到 npm,内容随 v0.24.1 一并发布)**:文件页与文件变动页的整体 UI/UX 重构。文件页新增 **Ctrl/Cmd 与 Shift 多选**、批量条、**Git 变更着色**、**新建文件夹**、拖拽上传重做,**多选可右键「压缩并下载」**(宿主侧带进度的打包任务);「打开方式」改为宿主本机关联应用与插件自研目标**双源并存**(可用设置 `openWithPluginTargets` 强制并存),右键菜单收敛层级;文件变动页**重构为层级树**(Git 视角 + Agent 视角,目录可暂存)。性能:`fs.tree` 实测 **22.8ms → 3.8ms**(10k 条目),新增批量路由 `fs.trees`(挂载/刷新从 N+1 请求变 1 个),菜单打开不再重列目录。⚠️ **行为变更(安全相关)**:**删除工作区路径检测**,插件 fs 路由可读写宿主用户能访问的任意路径(仅受 OS 权限约束)。详见 [CHANGELOG](./CHANGELOG.md#v0230)。
### v0.24.0
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.22.1(0.1.7 线最后发布的版本)**:caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动兼容性预检整行禁用(实测 `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`)。
- 📦 **基线整体抬到 `0.2.0-rc.1`**:14 条 DSH peer 与 27 个 `@deepseek-ai/*` devDependencies 同步;`dsh.plugin.json` 的 `engines.dsh` 随之前移。
- 🔍 **实测确认是纯增量**:本插件用到的 19 个宿主包里**零个值导出被删除**;类型面只有 `ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距)、`dsh-session`(新增 `ToolCallRecovery`)与 `dsh-api-remotes`(新增 product-analytics remote)变化;会话格式仍 v4、`SUBAGENT_DESCRIPTOR_VERSION` 仍 3、`dsh/lib/bin.js` 与 `dsh-client-modules` 运行时逐字未变。因此**没有为 0.1.7 保留任何兼容分支**。
- 🧪 **挂载 lane 与 CI 钉版同步到 `0.2.0-rc.1`**;`tests/market-manifest.spec.ts` 的 peer 形状规则改为钉当前基线 tuple,并记下「caret 跨 minor 必失效」这条教训。
- ⚠️ **生态连带**:`@huanlin/dsh-plugin-better-locale`(可选集成,非本插件依赖面)的 peer 钉在 `^0.1.x` 线上,在 0.2.0 上无法加载,其 5 条 unmet peer 是本次 `pnpm peers check` 唯一残留(上游未适配,与本插件的 14 条 peer 无关)。
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.22.1
@@ -244,7 +263,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.7-rc.1,alpha dist-tag),直接解析、无需令牌
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.2.0-rc.1,走 npm `next` 通道),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm lint # eslint .(flat config:js + typescript-eslint + react-hooks recommended)
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
@@ -266,7 +285,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 侧文档
+30 -10
View File
@@ -3,7 +3,7 @@
> [!IMPORTANT]
> **Built on DSH's native sidebar API** (since v0.19.0): the right column *is* DSH's own sidebar — the plugin registers every tab type as a native tab (no right panel of its own anymore) and keeps only its self-drawn bottom workbench and the `ctx.betterSidebar` service open to every plugin.
>
> **Since v0.21.1 the host support floor is DSH `0.1.7-rc.1+`** (peer floor `^0.1.7-rc.1`; v0.22.1 *is* npm's `latest`). DSH 0.1.7 ships a complete document preview of its own, so the plugin hands every read-only preview (spreadsheets / PDF / images / Office) back to the built-in and keeps only Markdown / HTML and the editable code editor. **Hosts on 0.1.6-alpha.2 or earlier should pin `dsh-better-sidebar@0.19.1`** — the DSH-to-plugin version table is in [Installation](#-installation).
> **Since v0.24.1 the host support floor is DSH `0.2.0-rc.1+`** (peer floor `^0.2.0-rc.1`). Everything this plugin consumes from 0.2.0 is **purely additive** (zero removed exports, session format still v4, CLI and client runtime byte-identical), so this version carries no runtime compatibility branch — it only moves the support line. **Hosts on the 0.1.7 line should pin `dsh-better-sidebar@0.22.1` — a caret never spans a minor bump, and `^0.1.7-rc.1` is silently disabled by the 0.2.0 startup preflight**; the DSH-to-plugin version table is in [Installation](#-installation).
<!-- Hero -->
<div align="center">
@@ -14,7 +14,7 @@
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.1): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Bottom workbench" src="https://img.shields.io/badge/-Bottom%20workbench-4d6bfe" /> <img alt="Changes" src="https://img.shields.io/badge/-Changes-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
@@ -48,8 +48,8 @@
What this plugin adds on top of DSH's stock sidebar:
- **✏️ Editable editor**: the host's document preview is **read-only** → the plugin keeps an **editable** CodeMirror editor (save, syntax highlighting, preview toggle); Markdown / HTML also render through the plugin's own pipeline (Mermaid diagrams with safe rendering + click-to-zoom, README-level inline HTML, floating table of contents, sandboxed HTML preview)
- **🗂️ Enhanced file tree**: takes over the built-in Files page — lazy-loading tree, **expanded directories watched live and auto-refreshed**, symlink awareness, global filename search, drag-and-drop upload, hover `@file` to drop a reference into the input box
- **🌿 Changes** (no Git panel in the stock sidebar): two lenses in one tab — **Git** (diff / history / stage·commit·revert) and **This Session** (every file the model touched) — with a unified diff renderer (intra-line character highlights, syntax coloring, secret redaction)
- **🗂️ Enhanced file tree**: takes over the built-in Files page — lazy-loading tree, **expanded directories watched live and auto-refreshed**, symlink awareness, global filename search, drag-and-drop upload, hover `@file` to drop a reference into the input box; **Ctrl/Cmd multi-select and Shift range-select** (copy or delete a batch), **VS Code-style git status colors + M/A/D/U letters**, **new folder**, **multi-select “Zip and download”** (the host streams a ZIP built with no third-party dependency), and an “Open with” menu that COMBINES DSH’s own open-in-app (the host’s probed OS handlers + file-manager reveal) with the plugin’s own targets (file manager / VS Code / Cursor / Zed / custom editor URL templates, SSH remotes, pin-to-menu)
- **🌿 Changes** (no Git panel in the stock sidebar): two lenses in one tab — **Git** (diff / history / stage·commit·revert) and **This Session** (every file the model touched) — with a unified diff renderer (intra-line character highlights, syntax coloring, secret redaction); the two lenses now share one 36px header (host SegmentedControl), 28px rows, a single empty state and a sticky commit bar, and the git snapshot is shared with the file tree so staging recolors the tree too
- **🧩 Background Tasks** (absent upstream): agent topology preview + background task list (exit codes / live output / force-kill)
- **💬 Side Chat** (absent upstream, beta): Codex-style side threads — inheriting the full parent context, running independently, promotable to a top-level session
- **🖥️ Bottom workbench** (absent upstream): the right column belongs to DSH's native right sidebar; the plugin adds its own bottom workbench (drag-to-split panes, per-session persistence) that coexists with the native bar
@@ -62,15 +62,16 @@ What this plugin adds on top of DSH's stock sidebar:
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**Supported DSH versions**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.0): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **Channel and support line**: `v0.22.1` is the **stable release** (npm `latest`) and targets DSH **0.1.7-rc.1+** only. **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.1.7-rc.1` (rc.1 rides npm's `next` dist-tag). **Hosts on DSH 0.1.6-alpha.2 or earlier should pin `dsh-better-sidebar@0.19.1`** — 0.1.7's breakage (the settings-service rewrite, the icon-export renames, session format v3→v4) is large enough that this version ships no compatibility layer.
> 📌 **Channel and support line**: `v0.24.1` targets DSH **0.2.0-rc.1+** (0.2.0's first candidate rides npm's `next` dist-tag; `latest` is still 0.1.7-rc.2). **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.2.0-rc.1`. **Hosts on the 0.1.7 line should pin `dsh-better-sidebar@0.22.1`**: 0.2.0 is a host minor bump, and caret ranges such as `^0.1.7-rc.1` fail the host's startup compatibility preflight on 0.2.0, which disables the whole profile row silently.
> 🧭 **Pick the plugin version that matches your DSH**:
>
> | Your DSH | Install command | Version / peer declared |
> | --- | --- | --- |
> | **0.1.7-rc.1+** (including a later 0.1.7 stable) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.22.1**, `^0.1.7-rc.1` |
> | **0.2.0-rc.1+** (including a later 0.2.0 stable) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.24.1**, `^0.2.0-rc.1` |
> | **0.1.7-rc.1 – 0.1.7-rc.2** (and a later 0.1.7 stable; npm `latest` is still 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.22.1` | **0.22.1**, `^0.1.7-rc.1` |
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **nothing to install** — move DSH to rc.1 first, then run the row above:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
> | 0.1.6-alpha.2 and earlier, `0.1.5-rc.*` (including the 0.1.5-rc.3 that is npm's `latest`) | `dsh plugin --profile web add dsh-better-sidebar@0.19.1` | **0.19.1**, `^0.1.5-rc.1` |
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-better-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
@@ -171,9 +172,9 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
| | |
|---|---|
| **🗂️ File Workbench: Explorer**<br/><sub>Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree whose **expanded directories the host watches per directory and re-lists on change**, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / copy paths), and a hover `@file` button that references a file straight into the composer.</sub><br/><div align="center"><img width="420" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Inline Preview: Markdown · HTML**<br/><sub>The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan), **README-level inline HTML** (badge walls `<div align=center>`, `<details>` blocks nesting markdown, inline tags in table cells — DOMPurify-sanitized, `<script>` stripped, local media rewritten to the session media route) and a floating **table of contents** (appears with ≥3 headings, smooth-scroll jumping, auto-expanding folded blocks); HTML uses the plugin's own **sandboxed preview** with the two escape hatches the host does not have (`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`). **Images / PDF / spreadsheets / Office are no longer a plugin capability** — the host's own document preview renders those formats.</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🗂️ File Workbench: Explorer**<br/><sub>Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree whose **expanded directories the host watches per directory and re-lists on change**, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / new folder / Open with — the host’s probed OS handlers PLUS the plugin’s own targets (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes, pin-to-menu) / copy paths / rename / delete), **Ctrl/Cmd multi-select with Shift range-select** (batch copy paths, batch delete, **zip and download**), **git changes colored by status with M/A/D/U letters**, and a hover `@file` button that references a file straight into the composer.</sub><br/><div align="center"><img width="420" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Inline Preview: Markdown · HTML**<br/><sub>The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan), **README-level inline HTML** (badge walls `<div align=center>`, `<details>` blocks nesting markdown, inline tags in table cells — DOMPurify-sanitized, `<script>` stripped, local media rewritten to the session media route) and a floating **table of contents** (appears with ≥3 headings, smooth-scroll jumping, auto-expanding folded blocks); HTML uses the plugin's own **sandboxed preview** with the two escape hatches the host does not have (`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`). **Images / PDF / spreadsheets / Office are no longer a plugin capability** — the host's own document preview renders those formats.</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror editor**<br/><sub>An **editable** text / code editor (save, syntax highlighting, preview toggle) — the host's own document preview is **read-only**, which is exactly why the plugin keeps its catch-all viewer.</sub><br/><div align="center"><img width="420" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ Images / PDF / spreadsheets / Office (provided by DSH)**<br/><sub>These read-only formats render in DSH's own `ui-sidebar-documentpreview`: host-side Office→PDF conversion, worker-backed spreadsheet tables, image / PDF zoom viewports, and **per-directory auto-refresh**. The plugin deleted its own image / pdf / download-fallback viewers and no longer claims those extensions.</sub><br/><div align="center"><img width="420" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 Terminal (provided by DSH)**<br/><sub>The right-Sidebar terminal is provided by DSH's own `ui-sidebar-terminal`: shell picker, double-click rename, reconnect, restore after reload, theme and contrast following. The plugin no longer ships xterm + node-pty.<br/><br/>⚠️ **Model-side caveat**: the plugin's own 8 `terminal_*` tools (off by default) were the model's only **cross-call persistent** terminal; the upstream equivalent `@deepseek-ai/dsh-tool-terminal` is not mounted by any shipped bundle, so if you need that capability, add a `tool-terminal` row to your profile's `cordis.patch.yml` yourself.</sub><br/><div align="center"><img width="420" alt="Terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Changes: Git lens + This-Session lens**<br/><sub>Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).</sub><br/><div align="center"><img width="420" alt="Changes" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **💻 Terminal (provided by DSH)**<br/><sub>The right-Sidebar terminal is provided by DSH's own `ui-sidebar-terminal`: shell picker, double-click rename, reconnect, restore after reload, theme and contrast following. The plugin no longer ships xterm + node-pty.<br/><br/>⚠️ **Model-side caveat**: the plugin's own 8 `terminal_*` tools (off by default) were the model's only **cross-call persistent** terminal; the upstream equivalent `@deepseek-ai/dsh-tool-terminal` is not mounted by any shipped bundle, so if you need that capability, add a `tool-terminal` row to your profile's `cordis.patch.yml` yourself.</sub><br/><div align="center"><img width="420" alt="Terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Changes: Git lens + This-Session lens**<br/><sub>Two lenses on "what changed?": the **Git lens** keeps the full source-control surface (stage / unstage / commit (`Ctrl+Enter`) / revert, history, worktree and child-repo selectors); the **This Session** lens folds the session event log live, recording every file the model read / wrote / edited (grouped by file, kind-filtered, op-count badge). Both lenses now share one 36px header, 28px rows, a single empty state and a sticky commit bar; the **Git lens lists changes as a directory tree** (single-child chains collapsed into one row, collapsible folders with descendant counts, status letters and file icons on the leaves); the git snapshot is shared with the file tree, so staging recolors the tree as well. Clicking any change previews it in the **draggable bottom pane** with the unified diff — del red / add green / mod-blue pairing + intra-line character highlights + syntax coloring + context folding — or expands into a VSCode-style dedicated diff tab (same rendering stack).</sub><br/><div align="center"><img width="420" alt="Changes" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 External-link takeover (browser view provided by DSH)**<br/><sub>Web tabs are DSH's own `ui-sidebar-browser` (multiple tabs / back-forward-reload / address bar / sandboxed iframe), mounted **only in the desktop profile since 0.1.7** — the Web profile has no such kind. The plugin keeps the half the host does not provide: it takes over **only links a tab type explicitly claims through `urlTarget`** (Ctrl/Cmd-clicks always pass through) and **lets everything else through to the host** (whose `linkOpening` user setting decides where prose links go); the three protocol-routing settings are gone, and a claim whose target type is unavailable at open time falls back to `window.open`.</sub><br/><div align="center"><img width="420" alt="Browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 Tasks: Agent Topology + Background Jobs**<br/><sub>Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-activate the Tasks page, expanding the sidebar on wide viewports without forcing narrow full-screen drawers open (configurable).</sub><br/><div align="center"><img width="420" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
| **💬 Side Chat (beta)**<br/><sub>Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.</sub><br/><div align="center"><img width="420" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🖥️ DSH's native right sidebar + plugin bottom workbench**<br/><sub>The right column is DSH's own sidebar: the plugin registers every tab type as a native tab (including taking over the built-in Files page), so clicking a file in the chat lands there directly — **formats the host's own document preview already covers are rendered by the host**, and the plugin claims only Markdown / HTML / editable code; the plugin's own bottom panel can stay open alongside it — drag a tab to a pane edge to **split**, to the middle to **merge**, drag the top edge to resize; the toggle lives in the session header.</sub><br/><div align="center"><img width="420" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
| **⚙️ Declarative Settings**<br/><sub>The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`, while the whole preference set lives on this plugin's **mount row** in the profile (since DSH 0.1.7 settings are addressed by Loader entry id).</sub><br/><div align="center"><img width="420" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 Mobile**<br/><sub>On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.</sub><br/><div align="center"><img width="360" alt="Mobile full-width drawer" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
@@ -199,6 +200,24 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
</div>
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.0): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.24.1
> 🐞 **Fix release**: two defects made the file tree rebuild itself on every interaction — (1) the native surface re-created the `files` takeover’s slot registration on EVERY session-state write (folder toggles, tab switches, bottom-workbench drags), so the host swapped the slot entry and unmounted/remounted the whole tab body: the explorer lost its level cache, scroll position and directory watcher and re-listed the entire visible tree; (2) the live-refresh re-list deleted a level from the cache before refetching, replacing its rows with a loading placeholder (a build, a formatter or the model’s own bash made whole folders blink). Now a toggle asks only for the toggled level, the tab body survives, and disk changes update rows in place.
### v0.23.0
> 🧭 **Development-line version (never published to npm; its content ships with v0.24.1)**: the Files and Changes pages rebuilt. The Files page gains **Ctrl/Cmd and Shift multi-select**, a batch bar, **Git change coloring**, **new-folder creation**, reworked drag-and-drop upload and **"Zip and download" on a multi-selection** (a host-side archiving task with progress); "Open with" keeps the host-detected local apps and the plugin's own targets **side by side** (setting `openWithPluginTargets`), and the context menu was restructured; the Changes page is **a hierarchy tree** again (Git view + Agent view, directory staging). Performance: `fs.tree` measured **22.8ms → 3.8ms** (10k entries) with the new batch route `fs.trees` (mount/refresh: N+1 requests → one), and opening the context menu no longer re-lists directories. ⚠️ **Behavior change (security-relevant)**: the **workspace path fence was removed** — the plugin's fs routes can read and write any path the host user can reach (OS permissions only). Details in [CHANGELOG_EN](./CHANGELOG_EN.md#v0230).
### v0.24.0
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.22.1 (the last published release on the 0.1.7 line)**: a caret never spans a minor bump, so `^0.1.7-rc.1` fails the 0.2.0 host's startup compatibility preflight and the whole row is disabled (measured: `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`).
- 📦 **Baseline lifted to `0.2.0-rc.1`**: 14 DSH peers and 27 `@deepseek-ai/*` devDependencies move together; `dsh.plugin.json`'s `engines.dsh` follows.
- 🔍 **Measured as purely additive**: across the 19 host packages this plugin consumes, **zero value exports were removed**; the only type-surface changes are `ui-primitives` (optional props on `DisclosureRow` / `TextShimmer` / `Tooltip`, plus the overlay top inset), `dsh-session` (new `ToolCallRecovery`) and `dsh-api-remotes` (a new product-analytics remote). Session format stays v4, `SUBAGENT_DESCRIPTOR_VERSION` stays 3, and `dsh/lib/bin.js` plus the `dsh-client-modules` runtime are byte-identical — so **no compatibility branch for 0.1.7 is kept**.
- 🧪 **Mount lane and CI pins follow to `0.2.0-rc.1`**; the peer-shape rule in `tests/market-manifest.spec.ts` now pins the current baseline tuple and records the "a caret never spans a minor bump" lesson.
- ⚠️ **Ecosystem side effect**: the optional `@huanlin/dsh-plugin-better-locale` integration pins the `^0.1.x` line and cannot load on 0.2.0; its 5 unmet peers are the only residue in `pnpm peers check` (upstream has not adapted; unrelated to this plugin's own 14 peers).
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.22.1
@@ -275,7 +294,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
+49 -11
View File
@@ -2,7 +2,7 @@
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**。当前版本 **v0.22.1**(npm dist-tag `latest`,peer 下限 `^0.1.7-rc.1`,仅支持 DSH **0.1.7-rc.1+**)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11);**自 v0.20.0 开发线起**(**注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1**)插件**移除了自带的终端**(宿主 0.1.6 的 `ui-sidebar-terminal` 取代,见 §4.4)、**移除了终端固定(pin)**,并在 0.1.7 上**把浏览器视图与只读文件预览整体让给宿主**(`ui-sidebar-browser` / `ui-sidebar-documentpreview`,见 §4.4、§5.4)、**收敛了外链接管**(见 §4.1)、**重写了设置接入面**(`SettingsForms`,见 §8.2)、**给文件树加了实时刷新**(见 §10);同时**删除了轮尾产物行接管**(DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成只能追加的 list,替换语义不复存在)。
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**。当前版本 **v0.24.0**(peer 下限 `^0.2.0-rc.1`,仅支持 DSH **0.2.0-rc.1+**;0.1.7 线请用 v0.23.0——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 会被 0.2.0 宿主的启动预检静默禁用)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11);**自 v0.20.0 开发线起**(**注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1**)插件**移除了自带的终端**(宿主 0.1.6 的 `ui-sidebar-terminal` 取代,见 §4.4)、**移除了终端固定(pin)**,并在 0.1.7 上**把浏览器视图与只读文件预览整体让给宿主**(`ui-sidebar-browser` / `ui-sidebar-documentpreview`,见 §4.4、§5.4)、**收敛了外链接管**(见 §4.1)、**重写了设置接入面**(`SettingsForms`,见 §8.2)、**给文件树加了实时刷新**(见 §10);同时**删除了轮尾产物行接管**(DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成只能追加的 list,替换语义不复存在)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 5 tab + 3 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
@@ -13,7 +13,7 @@
从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、会话内持久化;**其中不再有终端**——宿主 0.1.6 的右侧栏终端取代了它)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`:
- `registerTab` / `registerFileViewer` 签名不变;
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台);
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台 / `'side'` 落**原生栏的第二个格**,见下表「在侧边打开」);
- `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。
行为差异(写在这里以免踩坑):
@@ -25,11 +25,12 @@
| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 |
| 跨会话打开 | 目标会话的右侧栏 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_*` 工具整体删除。这里不再有「插件终端数量上限」这类语义 |
| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 |
| 在侧边打开(`target: 'side'`,v0.22.0+) | 原生栏里的 tab **不在插件底部工作台的分栏树里**,所以「在侧边打开」不能写 `bottomSplits`(那样会落到用户没展开的底部工作台 = 点了没反应)。服务改为把这一步交给宿主:带 path 的 `editor` seed 走 `openResource(address, { preferNewPane: true, revealIfOpened: false })`——宿主先按自己的两格上限与空间规则尝试分栏,分不了才回退到当前格;`revealIfOpened: false` 允许与已打开的同名资源并存,因此对同一个文件再点一次也会新开一格。其余类型若传 `target: 'side'`,同样以 `preferNewPane` 落原生栏(组件型 tab 的 path 仍是组件种子)。path-less 的 `editor`(文件页)与 `'bottom'` 行为不变 |
| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**本插件默认贡献 4 个 guide 条目**(文件 / 文件变动 / 任务管理 / 侧边对话,恰好在上限内),**但宿主的终端条目也占一行**——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 4 条 |
| 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 |
| alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 |
@@ -585,13 +586,38 @@ 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`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 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` 里一次性收集(与 `fs.tree` 同一套词法解析——**无包含检查**,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。
```ts
// 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形)
const build = await fetch('/sidebar/api/archive.build', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, cwd, paths: ['/w/src', '/w/notes.md'], name: '报告.zip' }),
}).then(r => r.json())
// → { ok: true, value: { id: 'ar-…', entries: 5 } }
// 2) 轮询:state 为 building | ready | error;done/total 是条目进度,bytes 是已读未压缩字节
const status = await fetch('/sidebar/api/archive.status', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: build.value.id, sessionId }),
}).then(r => r.json())
// 3) 下载(仅 ready 时):同一 id 只能取一次,取完即释放
const url = `/sidebar/archive?${new URLSearchParams({ sessionId, id: build.value.id })}`
```
约束与状态码:任务表最多 **4 个并发构建**(超出时 `archive.build` 回 `bad-request`,HTTP 409),完成/失败后保留 **5 分钟**(过期即消失);`id` 只对**创建它的 session** 可用(其他 session 读是 `forbidden` 403);`/sidebar/archive` 在构建中回 **409**、构建失败回 **410**(消息即失败原因)、未知/过期/已下载回 **404**。响应头为 `content-type: application/zip` + `content-disposition: attachment; filename="<ASCII 回退>"; filename*=UTF-8''<百分号编码>`(非 latin1 文件名走 RFC 5987,ASCII 档位对旧客户端生效)。上限沿用 `src/zip.ts` 的 `ZIP_MAX_ENTRIES = 10_000` 与 `ZIP_MAX_BYTES = 256 MiB`(未压缩总量)。
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
@@ -711,6 +737,13 @@ interface OpenTabSeed {
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
* undefined = 不改,null = 显式清除 */
meta?: unknown
/** 落点。省略 / 'right' = 原生右侧栏当前停靠格(默认);'bottom' = 插件的
* 底部工作台;**'side' = 原生右侧栏的第二个格**——原生承载面提供
* `openResource(address, { preferNewPane: true, revealIfOpened: false })`:
* 先按宿主的两格上限与空间规则尝试分栏,分不了才回退到当前格;允许与
* 已打开的同名资源并存,所以「在侧边打开同一个文件」真的会新开一格。
* path-less 的 editor seed 是文件页(files),'side' 只影响带 path 的打开。*/
target?: 'right' | 'bottom' | 'side'
}
/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */
@@ -839,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[<descriptor id>]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。
- `settings.render`(可选,v0.12.0+):**自定义设置面板**——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。
@@ -879,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`)
@@ -923,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 对未知键是容忍的,既不报错也不再有任何效果。
---
@@ -969,7 +1007,7 @@ interface SettingsDescriptor {
- 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在);
- 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败;
- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点;同一文件再开一格用 `target: 'side'`)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
---
+496
View File
@@ -0,0 +1,496 @@
# 文件页 / 文件变动页 UIUX 重构(2026-09-27)
用户需求(原文要点):
- 文件页:Ctrl 多选、Shift 多选;复用 DSH 自带的"使用应用打开";像 VS Code 一样给 Git 变更上色;优化性能;优化拖拽上传体验/样式;新增新建文件夹。
- 文件变动页:彻底优化全部 UIUX 交互与功能,更简洁易用;保留 Git 视角与 Agent 视角。
- 抽取出可复用的全局组件;设计风格与样式参照新的任务管理页;保持 KISS。
本文件是本次重构的**冻结契约**:所有并行实现必须遵守下面的接口与命名,不得自行改动。
---
## 1. 设计基线:任务管理页的原生尺度
`src/client/tasks-graph.module.css:1-15` 已声明插件原生尺度,本次重构把文件页与文件变动页对齐到它:
| 维度 | 值 |
|---|---|
| 正文 | `--dsw-font-xxs-12`(12px);强调 `--dsw-font-xxs-strong-12` |
| 元信息 | `--dsw-font-xxxs-11`;微标签 10px |
| 行高 | 列表行 **28px**(原 34px);密集行 24px |
| 控件 | 28px(宿主 `Button sm` 同高) |
| 边 | 结构边 `--dsw-alias-border-l2` 1px;分隔线 `--dsw-alias-border-l1` |
| 悬停 | `--dsw-alias-interactive-bg-hover` |
| 选中 | `--dsw-alias-interactive-bg-active` + 2px 内嵌强调条(`::before`,上下内缩 4px) |
| 圆角阶梯 | 2px 徽标/内联 chip,3px 控件簇,4px 行,8px 卡片 |
| 唯一强调色 | `--dsw-alias-state-business-primary`(当前项、关系、live) |
| 状态色 | 成功 `--dsw-alias-state-success-primary`、错误 `--dsw-alias-state-error-primary`、警告 `--dsw-alias-state-warn-primary` |
| 页面留白 | 12px 横向 gutter;竖直节拍 4 / 6 / 8px |
| 头带 | 36px 高,`padding: 0 8px 0 12px`,底 `1px solid --dsw-alias-border-l1` |
| 阴影 | 流内面板**无阴影**;仅浮层(popover/window)用 `--dsw-shadow-lv2/3` |
| 皮肤契约 | `tests/theme.spec.ts` 逐条扫描 `src/client/**/*.module.css`:任何 `color:` 必须至少含一个 `var(--dsw-*|--ds-*)`,禁止字面色 |
不可触碰的既有断言(改了会红):
- `tests/theme.spec.ts` 硬编码 `src/client/tasks-graph.module.css` 里的 `.cardBar[data-running='true']::after`、`@keyframes dsh-tasks-bar-sweep`、`.barActivity`;以及 `src/client/sidebar.module.css` 里的 `.paneCard`。
- `tests/file-tree-reference-tail.spec.ts` 要求 `.explorerRef, .explorerCopied { margin-left: auto }` 留在 `sidebar.module.css`。
- `tests/panel-host-css.spec.ts` 要求 `sidebar.module.css` 的 `:global([data-dsh-panel-host])` 规则顺序不变。
- `tests/locales.spec.ts:109,118` 要求 **20 份词典 key 集合完全相等**:新增 key 必须同时进入全部词典。
---
## 2. 全局组件(`src/client/ui/`,由 Lead 冻结)
新目录 `src/client/ui/`:
```
src/client/ui/index.ts 桶导出(唯一对外入口)
src/client/ui/kit.tsx 原子组件
src/client/ui/kit.module.css 样式(token-only)
src/client/ui/git-status.ts 共享 git 状态存储 + hook
```
### 2.1 `kit.tsx` 导出面
```ts
/** 28/24px 方形图标按钮(toolbar / 行尾操作统一用它)。 */
export function IconButton(props: {
icon: ReactNode
label: string // aria-label + title 默认值
title?: string
onClick(event: MouseEvent<HTMLButtonElement>): void
size?: 'md' | 'sm' // md=28px(默认),sm=24px
active?: boolean
disabled?: boolean
danger?: boolean
className?: string
}): ReactNode
/** 过滤/切换 chip(带可选计数)。 */
export function Chip(props: {
children: ReactNode
active?: boolean
count?: number
title?: string
disabled?: boolean
onClick(): void
className?: string
}): ReactNode
/** 28px 节标题带(左侧标签 + 可选计数 + 右侧动作)。 */
export function SectionHeader(props: {
label: ReactNode
count?: number
action?: ReactNode
className?: string
children?: ReactNode
}): ReactNode
/** 空/加载/错误/警告/提示 单行态(替代 14 个重复类)。 */
export function Notice(props: {
kind: 'empty' | 'loading' | 'error' | 'warn' | 'hint'
tone?: 'page' | 'well' | 'inline' // page=16px 居中,well=4px 12px 8px(默认),inline=10px 微文案
role?: string
className?: string
children: ReactNode
}): ReactNode
/** 危险操作确认弹窗(宿主 Modal + 安静描述段)。 */
export function ConfirmDialog(props: {
open: boolean
title: string
description: string
confirmLabel: string
cancelLabel: string
danger?: boolean
busy?: boolean
onConfirm(): void
onClose(): void
}): ReactNode
/** 状态字母/词徽标(Git 变更、op 类型、diff 类型共用一套 tone)。 */
export type StatusTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
| 'conflict' | 'read' | 'write' | 'edit' | 'neutral'
export function StatusBadge(props: {
tone: StatusTone
title?: string
children: ReactNode
className?: string
}): ReactNode
```
实现约束:
- 颜色/尺寸全部来自 `kit.module.css` 的 token;`IconButton` 的 `size` 只切 class,不写内联样式。
- `ConfirmDialog` 用宿主 `Modal`(`@deepseek-ai/dsh-client-ui-primitives`),footer = 取消(`Button variant="outline"`)+ 主按钮(`variant="primary"`,`disabled={busy}`),描述段用 `.confirmDesc`。
- 现有页面的等价类(`.explorerConfirmDesc` / `.gitConfirmDesc` / `.gitEmpty` / `.gitPlaceholder` / `.gitError` / `.filterChip` / `.gitBadge` / `.opKind` …)在各自页面重构时迁移到 kit;迁移期两者可并存,但**不得修改被测试钉住的类**(见 §1)。
### 2.2 `git-status.ts` 导出面
共享的 git 状态存储:一个 `sessionId + cwd + worktree` 键一份快照、一个轮询器,供文件树(着色)与文件变动页(列表)同时消费,避免重复 `git status`。
```ts
export type GitTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' | 'copied' | 'conflict'
/** 一行 porcelain XY 的语义(纯函数,可单测)。 */
export interface GitFileStatus {
letter: string // 展示字母:X 侧优先,其次 Y,'??' → 'U'
tone: GitTone
staged: boolean
unstaged: boolean
}
export function statusOfXY(xy: string): GitFileStatus | undefined
export interface GitStatusView {
/** null = 尚未拿到首个快照。 */
snapshot: GitStatusResult | null
loading: boolean
error: boolean
/** 强制重新拉取(在飞时排队一次,不丢请求)。 */
refresh(): void
/** 绝对路径 → 状态;干净/未知 → undefined。 */
statusOf(absolutePath: string): GitFileStatus | undefined
/** 该目录(含其下任意层级)是否有变更。 */
dirHasChanges(absoluteDir: string): boolean
}
export function useGitStatus(
scope: { sessionId: string; cwd?: string },
options?: { worktree?: string; visible?: boolean; pollMs?: number },
): GitStatusView
```
实现要点:模块级 `Map<key, Slot>`;`useSyncExternalStore` 订阅;仅当存在 `visible` 订阅者时轮询(默认 2500ms,`mode:'self-scheduling'` 式单飞);`statusOf` 首次调用时按快照惰性构建 `Map<abs, GitFileStatus>` 与"含变更目录" `Set`(O(n·depth) 一次);路径拼接复用 `resolveSidebarPath`。
---
## 3. 文件页(`src/client/FileTree.tsx` / `TreePanel.tsx` / `sidebar.module.css`)
### 3.1 多选(Ctrl/Cmd 多选、Shift 连选)
- 状态:`selected: Set<string>`(绝对路径)+ `anchor: string | null`(Shift 起点),二者都是 FileTree 内部 state(不持久化)。
- 交互(**必须保持现有单击语义不变**,`tests/file-tree-rename-delete.spec.tsx`、`tests/file-tree-drop.spec.tsx` 不得改断言):
- 无修饰键单击:文件行 → 打开(并清空选择);目录行 → 展开/收起(并清空选择)。
- Ctrl/Cmd + 单击:切换该行选中态,`anchor` 设为该行。
- Shift + 单击:以 `anchor` 为起点,在**当前可见行序**(深度优先、展开态决定)上连选区间;`anchor` 不变。
- 点击树空白处 / Esc:清空选择。
- 右键:目标行未选中时先把选择收敛为该行(VS Code 语义),已选中则保留整个选区。
- 视觉:选中行 `--dsw-alias-interactive-bg-active` + 2px 内嵌强调条;与"reveal 高亮"(`--dsw-alias-state-business-tertiary`)区分。
- 批量操作条:选区非空时贴在树体顶部(不遮行):`已选择 N 项` + 复制路径 / 删除 / 取消选择。
- 删除走 `ConfirmDialog`(`deleteSelectedTitle` / `deleteSelectedDesc`),逐个 `api.fsRemove`(顺序执行,失败停下并进错误条),成功后 `pruneTree` + `onPathDeleted`。
- 复制路径写全部选中行的绝对路径(换行分隔)。
- 键盘:行保持 `role="button" tabIndex={0}`;`ArrowUp/Down` 可移动焦点(可选,若实现须不破坏现有 Enter/Space)。
### 3.2 Git 变更着色(VS Code 风格)
- 数据:`useGitStatus(scope)`(§2.2);不做第二次 `git.status` 拉取。
- 文件行:名字按 tone 上色(modified=warn、added/untracked/copied=success、deleted/conflict=error、renamed=business),行尾加 `StatusBadge` 字母(M/A/D/U/R)与 `title`=状态词。**实施更正**:初稿把 conflict 写成 business,落地时按语义改为 error(冲突是需要先处理掉的阻塞态;`StatusBadge` 的 conflict 同样落 error 家族)。
- 目录行:其下任意层级有变更时,目录名同样上色(`dirHasChanges`),不显示字母。
- Git 不可用/非仓库:完全不上色、不加徽标(静默降级)。
- 新增 i18n:`gitStatusModified|Added|Deleted|Untracked|Renamed|Conflict`。
### 3.3 新建文件夹
- 宿主新增路由 `fs.mkdir`(`src/index.ts`)+ `mkdirWorkspaceEntry`(`src/fs-operations.ts`);客户端 `api.fsMkdir(scope, path, name)` → `{ path }`。
- UI:目录行与根行右键菜单新增"新建文件夹";触发后在**该目录层级顶部**插入内联输入行(与 rename 同一套交互:Enter 提交、Esc 取消、blur 提交、IME 守卫、非法名 `newFolderInvalid`),提交成功即 `retryDir(dir)` 并打开该目录。
- 名称规则与 rename 一致:非空、非 `.`/`..`、不含路径分隔符;服务端二次校验;目标已存在 → 报错进错误条。
### 3.4 性能
必须落地的四条(有证据的痛点):
1. **行组件 memo 化**:抽出 `FileRow` / `DirRow`(`React.memo`),把每行的回调与数据做成稳定引用(`useCallback` + ref 读 `dataRef`),使"复制态/拖拽目标/选区/菜单"变化只重渲染受影响的行,而不是整棵树。
2. **搜索不再卸载树**:`TreePanel` 用 `hidden` 属性/样式切换结果面板与树(原来 `needle !== ''` 直接卸载 `FileTree`,丢失 level 缓存并在清空时全量重拉,见 `TreePanel.tsx:245`)。
3. **level 拉取加世代守卫 + AbortController**:`loadDir` 丢弃过期响应;`refreshTick` 清缓存后并发重拉(不再串行等待);tick 触发时只重拉"根 + 已展开"级别。
4. **`fs.tree` 的 `truncated` 不再被丢弃**:该级别底部渲染 `Notice kind="hint"`(新 key `filesTruncated`)。
另外:`useDirectoryWatch` 每次渲染重建 key/数组的问题一并修掉(入参用 memo 化数组)。
### 3.5 拖拽上传体验
- 目标:与任务页同一套视觉(token-only、无硬编码色)。当前 `FileTree.tsx:83-110` 的 SVG 用 `#3964FE` / `#9CE5ED`,改为 `currentColor` + CSS class 上色。
- 结构保持(`tests/file-tree-drop.spec.tsx` 断言 `uploadDropZone`、pill 文案、`busy` 抑制、内存拖拽穿透):仍是"树区高亮框 + 左侧聊天邀请卡",但:
- 高亮框:2px 虚线 `--dsw-alias-border-l2` + 8px 圆角 + 极轻强调色内衬,去掉大范围遮罩阴影(改为 `--dsw-alias-bg-base` 半透明蒙层);
- pill:28px 高、`--dsw-alias-bg-layer-1`、1px `-l2` 边、`--dsw-font-xxxs-11`;
- 悬浮到目录行时,目录行整体高亮(现有 `explorerRowDropTarget` 换成新风格)。
- 上传进度卡(`UploadOverlay.tsx` / `.uploadOverlay*`):同样对齐新尺度(28px 行、12px 文案、token 色)。
### 3.6 使用应用打开:复用 DSH 自带能力
- **删除**插件自研的 open-with 全链路:配置(`sshHost` / `customEditors` / `pinned`)、`src/client/open-with.ts`、`src/client/open-with-settings.tsx`、宿主 `src/open-external.ts` 与 `open.external` 路由、`api.openExternal`、EditorHost 的 openWith props、`builtins/tabs.tsx` 的 `settings.render`(OpenWithSettings),以及对应 20 份词典里的 `openWith*` key 与相关测试。
- **改为**消费 DSH 自带的 open-in-app(见 §4):文件行显示宿主探测到的系统关联应用;目录行显示宿主应用目录(VS Code / Cursor / Zed / Finder…)+ 文件管理器显示;宿主不可用时整段隐藏(与宿主自身行为一致)。
新增/改用的 i18n:`openInApp`(打开方式)、`openInAppDefault`(用默认应用打开)、`revealInFileManager`(在文件管理器中显示)、`openInAppFailed`、`openInAppEmpty`。
---
## 4. 宿主接入层(由 Lead 冻结并先行落地)
### 4.1 `src/client/open-in-app.ts`(新)
```ts
/** 一个可打开当前路径的应用(文件=系统关联;目录=宿主应用目录)。 */
export interface OpenInAppEntry {
id: string
name: string
icon: string | null // 文件:PNG/SVG data URL;目录:宿主图标路由
isDefault: boolean
}
/** 宿主 open-in-app 能力的客户端封装(不可用时全部降级为 no-op / null)。 */
export interface OpenInApp {
/** 宿主是否能把路径交给原生桌面;null = 尚在探测。 */
available(): boolean | null
/** 目录可用的应用目录(宿主 menu 顺序)。 */
directoryApps(): Promise<readonly OpenInAppEntry[]>
/** 某个文件当前注册的系统应用;失败 → null。 */
fileApps(path: string): Promise<readonly OpenInAppEntry[] | null>
/** 用默认应用或指定应用打开(文件或目录)。 */
open(path: string, application?: string): Promise<boolean>
/** 在系统文件管理器中显示。 */
reveal(path: string): Promise<boolean>
}
/** 从客户端 ctx 构造(内部使用 ctx.remote.session.* 与宿主 /open-in-app/* 路由)。 */
export function createOpenInApp(ctx: { get(name: string): unknown }): OpenInApp
```
宿主 API(DSH 0.1.7-rc.x 自带,**不得自研替代**):
- Remote:`ctx.get('remote').session.canOpenWorkspacePath()` / `workspacePathApplications({path})` / `openWorkspacePath({path, action?:'reveal', application?})`(返回值 `{ok, value}`)。
- HTTP(同源、带 cookie):`GET <prefix>/open-in-app/apps` → `{apps: string[]}`;`GET <prefix>/open-in-app/icon/<id>` 图标;`POST <prefix>/open-in-app/open` `{app, path}`(**仅目录**)。
- 应用名字/图标:宿主 `open-in-app` 命名空间(`app.vscode` 等)经 locale bind 读取,失败回退到 id 本身;图标走宿主 icon 路由。
### 4.2 FileTree 的接线(冻结 prop)
`FileTree` 新增可选 prop:
```ts
/** 宿主 open-in-app 句柄(EditorHost 构造并注入;缺省时不渲染该段)。 */
openInApp?: OpenInApp
```
右键菜单"打开方式"段的新语义:
- 文件行:`fileApps(path)` 异步拉取 → 默认应用(`openInAppDefault`,点击 `open(path)`)+ 其余应用各一行(`open(path, id)`)+ 分隔线 + `revealInFileManager`;无应用 → `openInAppEmpty`(禁用行);宿主不可用 → 整段隐藏。
- 目录行:`directoryApps()` → 每应用一行(`open(path, id)`)+ `revealInFileManager`。
- 失败 → 树顶部错误条(`openInAppFailed`,带路径)。
### 4.3 `fs.mkdir`
- 宿主:`src/fs-operations.ts` 新增 `mkdirWorkspaceEntry({cwd, path, name, fence})`(fence 与存在性校验同 rename),`src/index.ts` 注册 `'fs.mkdir'`。
- 客户端:`api.fsMkdir(scope, path, name)`。
- smoke 测试的路由清单(`tests/smoke.spec.ts:84-90`)**不含方法索引**,无需改;但要为 `mkdirWorkspaceEntry` 增单测。
---
## 5. 文件变动页(`src/client/changes/*`)
目标:**更简洁易用**,保留 Git 与 Agent 双视角。重构后结构:
```
ChangesTab
├─ header (36px): 宿主 SegmentedControl[Git | 本轮文件] + 右侧:当前视角的刷新按钮
├─ Git 视角 (GitLens)
│ ├─ 顶部一行:分支/仓库/工作树选择(仅在有多个候选时出现)+ 状态摘要
│ ├─ 变更列表:更改(n) / 已暂存(n) 两组(SectionHeader + 单一 ChangeRow 渲染器)
│ └─ 提交条(吸底):Input + 提交按钮 + 单条状态/错误行
│ └─ 历史:SectionHeader + log 行 + 加载更多(空态明确)
└─ Agent 视角 (SessionLens):Chip 过滤 + 按文件分组行 + 预览
└─ DiffPane(共享预览,保留现有能力:redact / md 阅读 / html 渲染 / pdf / 展开为 diff 页签 / 拖拽高度)
```
必须修掉的既有缺陷(来自 §6 证据,逐条给通过/拒绝检查):
1. 干净的仓库只出现**一处**空态(不再两组各印一次 `noChanges`);历史区有自己的空态与失败态。
2. 单一错误通道:暂存/取消暂存失败必须被捕获并显示(不再 unhandled rejection);分支切换错误不再显示在提交框下面,而是统一状态行。
3. 手动刷新不再静默丢弃(在飞时排队一次);仓库切换只触发**一次**完整刷新;工作树清单带上 `repoRoot`。
4. 工作树不再在用户未操作时被自动切走(仅在首次挂载且用户未选择时自动选一次)。
5. 预览按 `callId` 从最新 ops 重新派生(running → 结算后自动更新);恢复高度时同时应用上限 clamp。
6. 轮询与共享:git 状态改由 `useGitStatus` 提供(同 session 的文件树共用一份),session ops 仍由本页轮询,但**仅在本视角可见时**轮询。
7. `DiffPane` 与 `DiffTab` 的重复加载逻辑抽成一个共享 hook(`useGitDiffTarget`),行为不变(两者都要继续工作)。
8. 视觉:全部对齐 §1 的尺度(行 28px、12px 正文、4px 行圆角、token-only),图标按钮/徽标/空态/确认弹窗改用 `src/client/ui/kit`。
保留能力(不得回归):Git 的暂存/取消暂存/全部操作、提交、分支切换、历史分页、放弃更改、revert、cherry-pick、复制哈希/路径、在工作树/子仓库间切换;Agent 视角的 read/write/edit 过滤、按文件分组、相对时间、字节数、运行中/出错标记;预览面板的全部渲染开关。
---
## 6. 验收(每条都要有实际证据)
| # | 检查 | 通过判据 |
|---|---|---|
| 1 | 类型/规范 | `pnpm typecheck` + `pnpm lint` 通过 |
| 2 | 单元/组件测试 | `pnpm test` 全绿(新增测试覆盖:多选、mkdir、git 着色映射、kit 组件、重构后的 changes 页) |
| 3 | 构建 | `pnpm build` 通过(含 CSS module 编译) |
| 4 | 皮肤契约 | `tests/theme.spec.ts` 绿;新增 CSS module 无字面色 |
| 5 | i18n | `tests/locales.spec.ts` 绿(20 份词典 key 集合相等) |
| 6 | 多选 | 组件测试:Ctrl 单击切换、Shift 连选区间、Esc 清空;单击语义不变(既有测试不动仍绿) |
| 7 | 着色 | 组件测试:mock `git.status` 后行名带 tone class、目录带 `dirHasChanges` 着色、非仓库无着色 |
| 8 | 新建文件夹 | host 单测(fence/已存在/非法名)+ 客户端组件测试(菜单 → 输入 → `api.fsMkdir` 调用参数) |
| 9 | open-in-app | 组件测试:宿主可用时渲染应用行、点击调 `open(path, id)`、不可用段隐藏 |
| 10 | 性能 | 组件测试或结构性验证:搜索不再卸载树(缓存保留)、level 过期响应被丢弃、`truncated` 有提示 |
| 11 | 文件变动 | 组件测试:空仓库只一处空态、暂存失败显示错误、预览随结算更新、视角切换仍保留预览 |
| 12 | 真实挂载 | `pnpm build && pnpm pack && pnpm test:mount`(e2e lane)通过 |
---
## 7. 写入范围(并行实现不得越界)
| 范围 | 归属 |
|---|---|
| `src/client/ui/**`、`docs/plans/2026-09-27-files-changes-uiux.md`、`src/client/locales.ts`(zh/en 新 key) | Lead |
| `src/client/FileTree.tsx`、`src/client/TreePanel.tsx`、`src/client/sidebar.module.css`、`src/client/UploadOverlay.tsx`、`src/client/use-dir-watch.ts`、`tests/file-tree-*.spec.tsx`、`tests/editor-host.spec.tsx` | 文件页实现者 |
| `src/client/changes/**`、`src/client/diff/**`、`tests/changes-*.spec.*`、`tests/git-view-worktree.spec.tsx`、`tests/sandbox-views.spec.tsx` | 文件变动页实现者 |
| `src/index.ts`、`src/fs-operations.ts`、`src/client/api.ts`、`src/client/open-in-app.ts`、`src/client/EditorHost.tsx`、`src/client/builtins/tabs.tsx`、`src/open-external.ts`(删)、`src/client/open-with*.ts(x)`(删) | Lead(宿主接入层,先行落地) |
| `src/client/locales-*.ts`(19 份第三方词典) | 词典传播实现者(单一写者) |
`src/client/locales.ts` 的 zh/en 由 Lead 追加;实现者若确需新 key,必须回报 Lead,不得自行添加(避免 20 份词典冲突)。
---
## 8. 实施记录(落地后的偏差与事实)
### 8.1 交付面
- **全局组件**:新增 `src/client/ui/`(`kit.tsx` + `kit.module.css` + `git-status.ts` + `index.ts`)与 `tests/ui-kit.spec.tsx`;文件页与文件变动页均已真实复用(IconButton / Chip / SectionHeader / Notice / ConfirmDialog / StatusBadge / useGitStatus)。
- **文件页**(`FileTree.tsx` / `TreePanel.tsx` / `sidebar.module.css` / `UploadOverlay.tsx` / `use-dir-watch.ts`):多选(Ctrl/Cmd 切换、Shift 按可见行序连选、Esc/空白/右键收敛)、批量条(复制路径 / 删除所选 / 取消选择)、git 着色(文件行名字上色 + M/A/D/U 徽标,目录行仅着色,非仓库静默降级)、新建文件夹(菜单 + 内联输入 + `api.fsMkdir`)、`FileRow`/`DirRow` memo、level 拉取世代守卫 + AbortController、`fs.tree` truncated 提示、搜索改为 `hidden` 停车不再卸载树、拖拽上传视觉 token 化(原 `#3964FE`/`#9CE5ED` 清零)。
- **宿主接入**:`fs.mkdir` 路由 + `mkdirWorkspaceEntry`(`tests/fs-mkdir.spec.ts` 6 例);新增 `src/client/open-in-app.ts`(宿主 Remote `canOpenWorkspacePath`/`workspacePathApplications`/`openWorkspacePath` + 宿主路由 `/open-in-app/apps|icon|open` 的客户端适配,`tests/open-in-app.spec.ts` 10 例)。
- **文件变动页**(`changes/**`、`diff/**`、`DiffTab.tsx`):36px 头(宿主 `SegmentedControl` + 刷新 IconButton)、单一 `ChangeRow`、SectionHeader 分组、吸底提交条 + 单一状态行、历史区独立空态、`useGitStatus` 共享状态、刷新排队、工作树只自动选一次、预览按 callId 重派生、高度恢复 clamp、session ops 轮询按视角门控、`DiffPane`/`DiffTab` 共用新 hook `src/client/diff/use-git-diff.ts`(并修掉 DiffPane 的 untracked 预览从不渲染的既有缺陷)。
### 8.2 相对冻结契约的偏差(均有理由)
1. **open-with 全链路删除**(而非并存回退):`src/open-external.ts`、`src/client/open-with.ts`、`src/client/open-with-settings.tsx`、`src/client/plugin-settings.ts`(随之成为死代码)、宿主 `open.external` 路由、`api.openExternal`、editor descriptor 的 `settings.render` 面板与 20 个 `openWith*` 词条全部移除;外部打开改为 DSH 自带 open-in-app。**取舍**:原「SSH 远端 + 自定义编辑器 URL 模板」能力随之消失——它服务的场景是 DSH 宿主在远端、浏览器在本地,此时宿主 open-in-app 本就不可用(`canOpenWorkspacePath()` 为 false,整段隐藏,与宿主自身行为一致)。若需要该能力应回到 DSH 侧或单独插件实现。
2. **`useSubmenuFlip` 一并删除**:FileTree 的「打开方式」子菜单消失后,`src/client/menu-flip.ts` + `layout.css` 的子菜单翻转规则 + `tests/menu-flip.spec.tsx` 已无消费者,按死代码清除。
3. **词典同步为 25 个新键**(非 26):原清单最后一项是插入锚点说明;zh/en 与 19 份词典最终同为 486 键。
4. **`visible` 逐层下传**:文件树的 git 轮询必须随页签可见性暂停(工作台会保留所有 tab body 挂载),因此 `EditorHost → TreePanel → FileTree` 新增可选 `visible`,`useGitStatus(..., { visible })` 在页签隐藏时退订。这是实施期补的性能修正(不在原 §3 清单里)。
5. **计划 §5 的「状态摘要」行**未单独实现:干净时用 `changesClean` 空态 + 两个 SectionHeader 计数 + 分支标签表达,避免第三处重复计数文案(KISS)。
6. **未做浏览器级视觉验证**:拖拽/多选的观感只有 token 与尺度层面的代码证据 + 行为测试;真实挂载 lane(`pnpm test:mount`)见 §8.3。
### 8.3 验证证据
- `pnpm typecheck` / `pnpm lint` / `pnpm test`(126 文件、1318 通过、9 跳过)/ `pnpm build` 全绿(§8.6 是修正后的最终数字:1322 通过)。
- 新增测试:`tests/ui-kit.spec.tsx`(14)、`tests/fs-mkdir.spec.ts`(6)、`tests/open-in-app.spec.ts`(10)、`tests/file-tree-multiselect.spec.tsx`(8)、`tests/file-tree-git-status.spec.tsx`(4)、`tests/file-tree-new-folder.spec.tsx`(7)、`tests/file-tree-open-in-app.spec.tsx`(8)、`tests/file-tree-row-memo.spec.tsx`(2)、`tests/file-tree-search-persist.spec.tsx`(3)、`tests/file-tree-dir-watch.spec.tsx`(5)、`tests/diff-git-target.spec.tsx`(4),以及 changes-tab / git-view-worktree 的大幅扩充。
- 真实挂载 lane:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount`(本机 PATH 上的 dsh 是 rc.2,脚本按基线拒绝直接运行)→ **7/7 通过**。
### 8.4 真实挂载 lane 抓到的缺陷(单测全绿也漏掉的)
`sidbar` 客户端 bundle 缺少 `remote` / `remote.session` 的 cordis inject 声明时,`ctx.get('remote').session` 会抛
`dsh-better-sidebar: cannot get property "remote.session" without inject`——**这一抛发生在渲染路径上,会把整个「文件」面板替换成错误条 + Retry**(首轮挂载 lane 的截图与 trace 均记录在案,整个 tab sweep 因此失败)。
修法两条,缺一不可:
1. `src/client/index.tsx` 的 `inject` 补上 `'remote'` 与 `'remote.session'`(嵌套属性访问也要单独声明);
2. `src/client/open-in-app.ts` 的 `remoteOf()` 加 try/catch:没有该服务组合的部署降级为「无打开方式菜单」,而不是把面板整块打崩(`tests/open-in-app.spec.ts` 新增一条「ctx 抛错时全部降级」用例钉子)。
这条正好说明为什么 §6 的验收表里 `pnpm test:mount` 不能省:单元/组件测试全部是绿的,只有真实宿主挂载才暴露了这个 inject 契约。
### 8.5 独立验证后的修正(verifier 报告 → 已全部落地)
| 编号 | 问题(verifier 证据) | 处置 |
|---|---|---|
| P1(高) | `open-in-app` 的 availability 是 tri-state(初始 `null`),但生产路径**没有任何地方调用 `probe()`**,于是 `available() !== true` 恒成立、整段菜单永不渲染——「复用 DSH 自带打开方式」实际未交付 | `FileTree` 挂载时按 handle 探测一次:已知答案(`true`/`false`)直接采用,`null` 才 `probe()`,结果进 state 驱动菜单重渲染。新增两条钉死该生命周期的测试(含**真实 adapter + 假 Host Remote** 的端到端用例)。真实挂载截图确认:菜单出现「Open with default app / Visual Studio Code / QuickTime Player / Reveal in File Manager」(宿主探测到的本机关联应用与真实图标) |
| P2(中) | `EditorHost` 新增的 `visible` 从未被传入(`builtins/tabs.tsx` 的文件 tab 未解构 `visible`)→ 隐藏页签仍在轮询 git | 文件 tab 的 component 解构并传入 `visible` |
| P3(中) | 文件树与文件变动页没有共用同一把 slot 键(GitLens 总把自动选中的 **primary** checkout 当 `worktree` 传)→ 同仓库两条 2.5s 轮询 | GitLens 只在选中 **linked** checkout 时才传 `worktree`(primary 的答案是同一份);`tests/ui-kit.spec.tsx` 增补「同键两个消费者只发一次请求」用例 |
| P4(中低) | session 视角每个 tick 重折 ops → 新数组击穿下游 memo → 每次都重新 Blob 编码每行正文 | `pull()` 在「无新事件且游标未前进」时**保留上一次 fold 的引用**,直接返回 |
| P5(低) | `noChanges` 成为死键(20 份词典各一条) | 全部删除(`tests/locales.spec.ts` 仍绿) |
| P6(低) | 冻结文档写 conflict=business,实现为 error | 以实现为准修正文档(冲突是阻塞态,归 error 家族更合理) |
| P7(低) | 新增 `fs.mkdir` 未进接入文档的 fence 路由清单 | 指南 §路由安全边界补上 `fs.mkdir`(并补齐原先漏列的 `fs.rename`/`fs.remove`) |
| P8(低) | 行有 `data-dsh-selected` 但无 `aria-pressed` | 文件/目录行按选中态输出 `aria-pressed` |
| P9(低) | 手动刷新与定时 tick 可重叠、重复追加同一 delta | `pull()` 单飞(在飞时新请求是 no-op,在飞的那次已覆盖到当下) |
### 8.6 最终验证(修正之后重跑)
- `pnpm typecheck` / `pnpm lint` / `pnpm test`:126 文件、**1322 通过**、9 跳过;`pnpm build` 通过。
- 真实挂载 lane:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → **7/7 通过**(含 tab sweep、perf lane)。
- 截图级证据(临时 Playwright 取证脚本,已删除,不留在仓库):真实宿主上文件树行显示 **M/U 字母与状态色**、右键菜单出现宿主探测到的应用列表、文件变动页显示 36px `Git | Session` 头 + 未暂存/已暂存分组 + 吸底提交条 + 历史。
- 仍属未验证:拖拽上传的视觉观感与手动多选手感(只有 token/尺度证据与行为测试)、Windows 宿主差异。
---
## 9. 第二轮:open-with 与宿主能力并存 + 打包下载 + 文件变动树(用户追加需求)
用户追加三条:
1. **打开方式 = 宿主探测到的本机关联应用 + 插件自研 open-with**(两者并存,不是二选一);
2. **文件页多选时右键新增「压缩打包并下载」**;
3. **文件变动页再次重构:更美观 + 以层级(树)方式显示更改**。
### 9.1 冻结接口
#### A. open-with 恢复并与 open-in-app 并存
- 恢复 `src/client/open-with.ts`(`OpenWithConfig` / `OpenWithTarget` / `parseOpenWithConfig` / `resolveOpenWithTargets` / `openWithUrl` / `openWithSshActive` / `newCustomEditorId` / `isValidCustomEditor`,与 main 逐字一致)、`src/client/open-with-settings.tsx`、`src/client/plugin-settings.ts`、宿主 `src/open-external.ts` 与 `open.external` 路由、`api.openExternal`、editor descriptor 的 `settings.render`,以及 20 个 `openWith*` 词条。
- `FileTree` / `TreePanel` 恢复 main 的可选 props:`openWithTargets` / `openWithPinned` / `openWithSsh` / `onOpenWith` / `onToggleOpenWithPin`,与既有 `openInApp` **并存**。
- 菜单形状(`打开方式` 段):
1. 宿主行(`openInApp` 可用时):`用默认应用打开` + 各系统关联应用;
2. 插件行:pinned 目标直达 + `在应用中打开 >` 子菜单(含图钉,main 原样);**当宿主可用时隐藏插件的 `explorer` 目标**(宿主已有「在文件管理器中显示」,避免重复);
3. 分隔线 + 宿主 `在文件管理器中显示`(宿主不可用时该行不出现,插件的 `explorer` 目标保留,保证仍能 reveal)。
- 宿主完全不可用(`available() === false` / 无 handle)时:宿主行整段隐藏,插件行照旧可用(SSH 远端场景因此保持可用)。
#### B. 压缩打包并下载
- 新增 `src/zip.ts`:`buildZip(entries, opts)` 产出 ZIP(store + `zlib.deflateRaw` 两种方法按压缩收益选择;UTF-8 名称标志位;目录条目;CRC32;无第三方依赖)。
- 新增宿主 GET 路由 `/sidebar/archive?sessionId=&cwd=&name=<archive.zip>&path=<p>&path=<p>…`:每个 `path` 经既有 workspace fence 校验,文件/目录(递归)都接受;上限(条目数 / 总字节)超限返回 JSON 错误;成功时 `content-type: application/zip` + `content-disposition: attachment`。
- 客户端 `api.archiveUrl(scope, paths, name)`(与 `downloadUrl` 同形的 GET URL)。
- 交互:多选(≥2)时右键菜单出现 `压缩并下载({count} 项)`;单选且为目录时同样出现(单文件不出,避免与「下载」重复)。
- 新词条:`zipDownload` / `zipDownloadCount` / `zipFailed`。
#### C. 文件变动页层级显示
- Git 视角的两个分组(未暂存 / 已暂存)各自渲染为**目录树**:单子目录压缩(`a/b/c` 一行显示为 `a/b`),目录行有 disclosure chevron + 文件夹图标,文件行有文件图标 + 状态字母 + 名称 + 弱化目录;行尾仍可暂存/取消暂存;默认展开。
- 纯函数 `buildChangeTree(entries)`(`src/client/changes/change-tree.ts`)产出树 + 压缩规则,独立单测。
- 视觉打磨:分组头吸顶、计数胶囊、hover 与选中态、缩进参考线、提交条与历史行间距统一到 §1 尺度。
### 9.2 分工与写入范围
| 范围 | 归属 |
|---|---|
| 计划文档、验证裁决、集成、最终门禁 | Lead |
| `src/index.ts`、`src/zip.ts`、`src/client/api.ts`、`src/client/open-with.ts`、`src/client/open-with-settings.tsx`、`src/client/plugin-settings.ts`、`src/open-external.ts`、`src/client/EditorHost.tsx`、`src/client/builtins/tabs.tsx`、相关测试 | 宿主接入实现者 |
| `src/client/FileTree.tsx`、`src/client/TreePanel.tsx`、`src/client/sidebar.module.css`、文件页测试 | 文件页实现者 |
| `src/client/changes/**`、`src/client/diff/**`、变动页测试 | 文件变动页实现者 |
| `src/client/locales-*.ts`(19 份) | 词典实现者 |
| 独立验证 | 验证者 |
### 9.3 实施记录(第二轮)
- **打开方式并存**(落地形状):菜单段无标题行 —— 宿主行(`用默认应用打开` + 系统关联应用,仅宿主可用)→ 分隔线 → 插件 pinned 直达行 + `在应用中打开 >` 子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 `在文件管理器中显示`。宿主可用时过滤插件的 `explorer` 目标;宿主不可用/无 handle 时只留插件行(含 `explorer`,reveal 不丢)。
- 自研链路逐字恢复:`src/client/open-with.ts`、`open-with-settings.tsx`、`plugin-settings.ts`、`src/open-external.ts`、`open.external` 路由、`api.openExternal`、editor descriptor 的 `settings.render`,以及 20 个 `openWith*` 词条(19 份词典从 `git show main:` 逐字取回)。
- 因菜单去掉标题行,`openInApp` 词条成为死键 → 已从 21 份词典删除(zh 507 键)。
- 子菜单需要 `useSubmenuFlip`:第一轮删除的 `src/client/menu-flip.ts` + `layout.css` 翻转规则 + `tests/menu-flip.spec.tsx` 一并恢复。
- **压缩打包下载**:新增 `src/zip.ts`(自研 ZIP 写入器,无第三方依赖)+ `src/archive-route.ts`(下载名净化 + 递归收集)+ `GET /sidebar/archive` + `api.archiveUrl`;`FileTree` 多选右键「压缩并下载(N 项)」(单选目录亦可,单文件不出),下载走 `fetch → blob → objectURL`,**路由错误(超限 / fence 拒绝 / 非 GET)现在会以 `zipFailed` 显示在错误条**。
- 上限:10 000 条目 / 256 MiB 未压缩负载(收集期即中止)。
- 实施中由系统 `unzip` 抓到的真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix → Info-ZIP 把每个文件解成 `mode 000`;改为 Unix 语义(`0o100644 * 0x10000`,注意 `<< 16` 溢出 int32)。复验:mode 644/755、UTF-8 名、`unzip -t` 无错。
- **文件变动层级树**:新增 `src/client/changes/change-tree.ts`(`buildChangeTree`:'/' 分段、单子目录链压缩成一行、目录先于文件、大小写不敏感排序、文件带 `GitFileStatus`、目录带下级变更数),两个分组各自渲染树;折叠状态按「组 + 路径」独立保存、默认展开;分组头吸顶 + 计数胶囊;`Stage all`/`Unstage all` 改为带 aria-label 的 IconButton;选中 = `interactive-bg-active` + 2px 内嵌强调条;12px/级缩进 + `-l1` 参考线;历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐。
- 契约偏差(已批准):文件行不再重复渲染目录文本(树已用父行表达层级,重复是噪声);`ChangeDir` 额外带 `changes` 计数以免每行重扫子树。
### 9.4 第二轮验证证据
- `pnpm typecheck` / `pnpm lint` / `pnpm test`(**134 文件、1422 通过、9 跳过**)/ `pnpm build` 全绿。
- 真实挂载 lane:`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → **7/7 通过**。
- 截图取证(临时 Playwright 脚本,已删除):真实宿主上确认
① 文件树多选 5 项 + 右键菜单同时含 **宿主探测应用**(Open with default app / Visual Studio Code / QuickTime Player)与 **插件 `Open with >` 子菜单** + **Zip and download (5 items)**;
② 变动页 Git 视角以树显示:`src/client/changes`(单子目录链压缩成一行,带计数胶囊 1)→ `GitLens.tsx`(M 徽标),已暂存组 `src` → `app.ts`。
- 单文件页回归:14 个 spec / 91 用例绿(含新增 `file-tree-archive.spec.tsx` 9 例:出现条件、成功链、三条失败路径、in-flight 守卫)。
- 仍未验证:真实浏览器里 ZIP 的落盘行为(jsdom spy 证据)、拖拽/多选手感、Windows 宿主差异。
### 9.5 第三轮(verifier 二轮报告后的修复,全部完成)
| 编号 | 问题 | 修法 | 判别性验证 |
|---|---|---|---|
| F1【高】 | 中文等非 latin1 下载名 → 真实宿主 **500**(`content-disposition` 直接塞原始名,Node `writeHead` 拒绝 >U+00FF) | `archive-route.ts` 新增 `contentDispositionOf`:ASCII 回退 + `filename*=UTF-8''…`(无可用 ASCII stem 时回退 `download.zip`) | 临时回退 → 真 `node:http` 用例 500;对照断言钉住 `validateHeaderValue` 必抛 |
| F2【中】 | 多选同名不同目录 → 归档条目重名(解压互相覆盖) | `disambiguateArchiveNames`:只对冲突项逐级上溯父目录,必要时回退完整路径 | 临时回退 → 条目重名断言变红 |
| F3【中低】 | `maxBytes` 在读完源文件后才判定(与注释相反);且 `budget===0` 时 0 字节 FIFO 仍会 `open()` 永久阻塞 | `prepareFile` 先 `stat`,`budget <= 0 || size > budget` 立即抛,再读 | FIFO 探针:回退后 5s 超时(阻塞),修复后 44ms 通过(Windows 跳过该探针) |
| F4【中低】 | SSH 模式下宿主不可用时 reveal 整条消失 | SSH 过滤保留 `kind === 'reveal'` 的目标,并注明理由 | 更新 open-with.spec 断言(`['explorer','vscode','cursor','custom:e2']`,reveal 恰 1 条) |
| F5【低】 | 图钉是 `<span role="button" tabIndex={-1}>` 嵌在 menuitem 按钮内(语义非法 + 键盘不可达) | 去掉 role/tabIndex,改成诚实的鼠标热区;注释写明键盘入口是设置面板 | 临时加回 role → 用例变红 |
| F6【低】 | 变更树行无 memo,空闲 tick 全量重渲染 | ① 行 `memo` + 恒定 props(图标解析搬进行内当探针)② 内容键 `entriesKey` 派生 tree ③ (Lead)共享 store 的 `sameStatus` 保住快照身份 | 三处各自临时回退 → 探针变红 |
| F7【低】 | 排序依赖运行时 locale collation | `localeCompare(other, 'en', { sensitivity: 'base' })` + 码点兜底 | 反向输入顺序不变 + 瑞典 collation 对照断言 |
| F8【低】 | POSIX 文件名里的 `\` 被静默转成层级 | `archiveName` 不再替换 `\` | 实探条目 `a\b.txt` 保持单条成员 |
| F9【低】 | 打包中重复点击静默吞掉、大选择无反馈 | `archiveBusy` state + 树顶 `Notice kind="loading" role="status"` | deferred fetch 断言忙态行出现/消失、重复点选 fetch 仍 1 次 |
| — | 新增交互:目录行支持「暂存/取消暂存该目录」 | `DirRow` hover IconButton(`gitStage`/`gitUnstage` 均接受目录 pathspec) | 断言 `gitStage(scope,'src/deep',MAIN)` 且点击不触发折叠 |
最终门禁(修复后重跑):`pnpm typecheck` / `pnpm lint` / `pnpm test`(**134 文件 / 1441 通过 / 9 跳过**)/ `pnpm build` 全绿;
`DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount` → **7/7 通过**(mount=372ms,p95 帧间隔 16ms,无宽度泄漏)。
@@ -0,0 +1,87 @@
# DSH 0.2.0-rc.1 适配记录(v0.24.0)
> 状态:已实施。支持线从 `^0.1.7-rc.1` 整体前移到 `^0.2.0-rc.1`(**单线**:0.1.7 用户停在 v0.23.0)。
> 上游发布:[dsh-v0.2.0-rc.1](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.0-rc.1)(npm dist-tag `next`;`latest` 仍是 0.1.7-rc.2)。
## 0. 结论先行
**0.2.0-rc.1 对本插件所消费的全部宿主 API 是纯增量**:19 个被用到的宿主包**零值导出删除**,会话格式与子代理描述符版本未动,CLI 与客户端运行时逐字节相同。因此本版**没有为 0.1.7 写任何运行时兼容分支**——唯一必须改的是 peer 下限(否则宿主启动预检会静默禁用整行)。
## 1. 硬阻塞:caret 跨 minor 不成立
`packages/boot/app-boot/src/plugin-compatibility.ts`(0.1.7-rc.1 起)遍历 profile 行的 `peerDependencies`,对名字为 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 的条目跑
`semver.satisfies(宿主版本, 范围, { includePrerelease: true })`,不满足就 `row.disabled = true`,只在 stderr 留一行 `disabling profile plugin …`。
实测(semver 7,`includePrerelease: true`):
| range | 对 `0.2.0-rc.1` |
| --- | --- |
| `^0.1.7-rc.1` | **false**(caret 上界展开为 `<0.2.0-0`) |
| `^0.2.0-rc.1` | true |
| `>=0.1.7-rc.1 <0.3.0` | true(显式比较器不带 `-0` 上界) |
| `>=0.1.1-rc.2 <0.2.0` | true(同上,`dsh-whale-report` 正因此仍能加载) |
**教训**:`AGENTS.md` §3.4 第 12 条(c) 那条「`includePrerelease` 让判定更宽松(`^0.1.6-alpha.2` 也能满足 `0.1.7-rc.1`)」**只在同一 minor 内成立**;跨 minor 时必须同步抬 peer 下限。用户已选择**单线**方案,不做「同时覆盖两条线」的放宽范围。
## 2. 适配前实测证据(决定了「零运行时改动」)
| 检查面 | 方法 | 结果 |
| --- | --- | --- |
| 值导出删除 | 对插件 import 的 19 个宿主包,提取 `lib/*.js` 里 `export {…}` / `exports.x=` 的名字集合做差 | **removed = none**;唯一新增 `ui-primitives` 的 `pointerModality` |
| 类型面变化 | `lib/types/**` 逐文件 hash + diff | 仅 3 处:`ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距改用 frame overlay inset)、`dsh-session`(新增 `ToolCallRecovery`;`interruptedTurnClosers` 仍在)、`dsh-api-remotes`(新增 product-analytics remote 行) |
| 会话格式 | 查 `SESSION_FORMAT_VERSION` 与 `session-format-*` 迁移包清单 | 仍 **v4**,无 `session-format-v4-to-v5` |
| 子代理描述符 | `grep SUBAGENT_DESCRIPTOR_VERSION` | 仍 **3** |
| CLI 契约 | `diff dsh/lib/bin.js` | **字节相同**(`dsh plugin` / `dsh web` / 就绪行 / `--version` 全不变) |
| 客户端运行时 | `diff dsh-client-modules/lib/index.js`、客户端包 `provide(...)` 名字、web-app 的 `dsh.client.inject` | 全部相同 |
| 测试期依赖 | 对比 `ui-primitives` 产物的裸 import 集合 | 相同 → **无需新增 devDependency**(不重演 0.1.7 §3.3 第 12 条) |
| agent-team | 四个包的 `lib/types` hash | 逐字相同 |
| web profile 组成 | `web-app/cordis.patch.yml` diff | 新增 desktop-only `product-analytics` / `desktop-product-telemetry`(web 上 `disabled`);`time-context` / `schedule` / `ui-schedule`(本就 `disabled`)移入可选 `dsh-experimental-schedule-bundle`。挂载 lane 的「宿主 tab 恰 1 条(terminal)、browser 缺席」断言不受影响 |
## 3. 实施内容
1. `package.json`:14 条 DSH peer → `^0.2.0-rc.1`;27 个 `@deepseek-ai/*` devDependencies 精确钉版 → `0.2.0-rc.1`(`@deepseek-ai/cordis` 4.0.4 / `cordis-plugin-group` 1.0.4 / `cordis-plugin-include` 1.0.9 不变);`version` → `0.24.0`。
2. `dsh.plugin.json`:`engines.dsh` → `>=0.2.0-rc.1`(零读者的元数据,但保持诚实)。
3. `scripts/e2e-common.sh`:`DSH_NPX_SPEC` 与 `DSH_EXPECT_VERSION` → `0.2.0-rc.1`。
4. `.github/workflows/ci.yml`:`npm install -g @deepseek-ai/dsh@0.2.0-rc.1` + 注释补「0.2.0 首个候选版只挂在 `next` 上」。
5. `tests/market-manifest.spec.ts`:peer 形状正则钉当前基线 tuple(`/^\^0\.2\.0-[a-z0-9.]+$/`),并把「caret 跨 minor 必失效」写进注释。
6. 文档:`README.md` / `README_EN.md`(支持线横幅、shields 徽章、安装对照表新增 0.2.0 行并保留 0.1.7 行的 v0.23.0 指引、最近更新新增 v0.24.0)、`CHANGELOG.md` / `CHANGELOG_EN.md`(v0.24.0 条目)、`AGENTS.md`(§2 钉版句、§3 改为「DSH 版本适配要点」并新增 §3.0 当前基线 + 0.1.7 历史分节)、`docs/external-plugin-guide.md`(当前版本与 peer 下限)。
7. **未改任何 `src/**` 运行时代码**——证据见 §2。
## 4. 已知残留(非本插件可解)
- `@huanlin/dsh-plugin-better-locale`(**可选**集成;没有任何源码或测试 import 它,运行时靠 `ctx.get('betterLocale')` 结构化探测)的 peer 钉在 `^0.1.0-rc.8`,其最新 0.4.3 也钉 `^0.1.5-rc.2` → 在 0.2.0 上都会被宿主预检禁用。因此 `pnpm peers check` 会报它的 **5 条 unmet peer**;本插件自身的 14 条 peer 全部满足。属上游未适配 0.2.0。
- 本机 pnpm 的 registry 是 `registry.npmmirror.com`,对新预发布滞后(本次实测:镜像上 `@deepseek-ai/dsh-attachment@0.2.0-rc.1` 不存在 → `ERR_PNPM_NO_MATCHING_VERSION`)。安装用一次性覆盖:`pnpm install --registry https://registry.npmjs.org`。
- `@deepseek-ai/dsh-experimental-agent-team-web-profile` 仍无 0.1.7 / 0.2.0 发布(停在 0.1.6-alpha.2)→ 本机实例升级时该行保持旧版,运行时兼容性属**未验证**(见 §5)。
## 5. 本机 3080 实例升级(与仓库适配分开执行)
升级前的 profile 预检(18 个依赖,按宿主同一套 semver 语义逐个判定):
| 依赖 | DSH peer | 0.2.0-rc.1 |
| --- | --- | --- |
| `dsh-better-sidebar@0.23.0` | `^0.1.7-rc.1` | **会被禁用** → 本版 v0.24.0 修复 |
| `dshmarket@1.35.0` | `^0.1.0-rc.7 \|\| ^0.1.1-rc.2`(最新 1.66.3 的 `dsh-settings` peer 仍是 `^0.1.x`) | **会被禁用**(上游未适配,无法绕过) |
| `dsh-whale-report@0.5.4` | `>=0.1.1-rc.2 <0.2.0` | 可加载(显式比较器) |
| `dsh-notification@0.1.4` | `*` | 可加载 |
| `dsh-feishu-notify` / `dsh-flowglass` / `dsh-lan-access` / `dsh-apollo` / `dsh-tauri*` | 无 DSH peer | 不被预检拦(运行时未验证) |
| `@deepseek-ai/dsh-experimental-agent-team-profile` | 无 DSH peer | 升级到 0.2.0-rc.1 |
| `@deepseek-ai/dsh-experimental-agent-team-web-profile@0.1.6-alpha.2` | 无 DSH peer | 保持旧版(无 0.2.0 发布),运行时未验证 |
### 5.1 实测踩到的坑:profile 自己的 `pnpm-workspace.yaml` overrides 必须与宿主同步
升级后 3080 出现「加载提供商目录失败: settings service is absent: mount @deepseek-ai/dsh-settings with @deepseek-ai/dsh-config-editor in the profile composition」。宿主日志给出了根因链路:
```
dsh: disabling profile plugin row "config-editor": Plugin @deepseek-ai/dsh-config-editor@0.1.7-rc.2
is incompatible with dsh 0.2.0-rc.1: peerDependencies {"@deepseek-ai/dsh-hmr":"0.1.7-rc.2",
"@deepseek-ai/dsh-app-boot":"0.1.7-rc.2"}
settings (@deepseek-ai/dsh-settings): pending (waiting for service: configEditor)
```
profile 的 `pnpm-workspace.yaml` 里有一条上一轮升级留下的 `overrides: '@deepseek-ai/dsh-settings': 0.1.7-rc.2`(它自己的注释就写着「Keep this in lockstep with the host version」)。这条 pin 让 profile 树里的 `dsh-settings` 停在 0.1.7-rc.2,而 `dsh-settings` 用**精确版本**声明 `dsh-config-editor` / `dsh-util-values` → profile 顶层出现 0.1.7-rc.2 的 `dsh-config-editor`,它**遮蔽**了宿主 bundle 自带的 0.2.0-rc.1,于是 0.2.0 的启动预检把 `config-editor` 整行禁用,`settings` 行永远等不到 `configEditor`,设置/模型页的 provider 目录随之失败。
修法:把该 override 与注释一起抬到 `0.2.0-rc.1` → `pnpm install`(profile 内)→ 重启。修复后 profile 顶层全部收敛到 0.2.0-rc.1(`dsh-atomic-write` / `dsh-brand` / `dsh-config-editor` / `dsh-util-values` / agent-team 三包),`.pnpm` 里 0.1.7-rc.2 的副本归零。
**教训(对宿主版本升级通用)**:升级 DSH 时,除了 launcher 的 npx 钉版与 profile 依赖,**profile 自己的 `overrides` / `pnpm-workspace.yaml` 里任何 `@deepseek-ai/dsh-*` pin 都必须同批抬版本**;否则 profile 树里会出现旧宿主包并遮蔽 bundle 自带的那一份,而预检只报「某行被禁用」,症状(设置服务缺失)与原因(一条 override)隔着好几层。
升级步骤(含回滚)见最终交付说明:备份 `~/.dsh/dsh-web.sh` 与 `profiles/web/package.json` → launcher 的 npx 钉版 → `0.2.0-rc.1` → 升 agent-team profile → 装 v0.24.0 tarball → `pm2 restart dsh-web` → 核对路由与 `pm2 logs` 的 `disabling profile plugin` 行。
+2 -2
View File
@@ -1,10 +1,10 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.22.1",
"version": "0.24.1",
"main": "./lib/index.js",
"description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器",
"engines": {
"dsh": ">=0.1.7-rc.1"
"dsh": ">=0.2.0-rc.1"
},
"contributes": {
"tools": [],
+43 -42
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.22.1",
"version": "0.24.1",
"description": "Better Sidebar for DSH — file tree, editor, changes, tasks and side chat in one column, separate for every conversation. Also exposes a service other plugins use to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
@@ -94,20 +94,20 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.4",
"@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
"@deepseek-ai/dsh-invariants": "^0.1.7-rc.1",
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
"@deepseek-ai/dsh-session": "^0.1.7-rc.1",
"@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
"@deepseek-ai/dsh-subagent": "^0.1.7-rc.1",
"@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
"@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
"@deepseek-ai/dsh-invariants": "^0.2.0-rc.1",
"@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
"@deepseek-ai/dsh-session": "^0.2.0-rc.1",
"@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
"@deepseek-ai/dsh-subagent": "^0.2.0-rc.1",
"@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -157,33 +157,34 @@
"@deepseek-ai/cordis": "4.0.4",
"@deepseek-ai/cordis-plugin-group": "1.0.4",
"@deepseek-ai/cordis-plugin-include": "1.0.9",
"@deepseek-ai/dsh-agent": "0.1.7-rc.1",
"@deepseek-ai/dsh-agent-loop": "0.1.7-rc.1",
"@deepseek-ai/dsh-attachment": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-locale": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-store": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.7-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.1.7-rc.1",
"@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
"@deepseek-ai/dsh-llm": "0.1.7-rc.1",
"@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.1",
"@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
"@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.1",
"@deepseek-ai/dsh-scope": "0.1.7-rc.1",
"@deepseek-ai/dsh-session": "0.1.7-rc.1",
"@deepseek-ai/dsh-session-persistence": "0.1.7-rc.1",
"@deepseek-ai/dsh-session-projection": "0.1.7-rc.1",
"@deepseek-ai/dsh-settings": "0.1.7-rc.1",
"@deepseek-ai/dsh-subagent": "0.1.7-rc.1",
"@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
"@deepseek-ai/dsh-tools": "0.1.7-rc.1",
"@deepseek-ai/dsh-user-approval": "0.1.7-rc.1",
"@deepseek-ai/dsh-util-time": "0.1.7-rc.1",
"@deepseek-ai/dsh-util-workspace-path": "0.1.7-rc.1",
"@deepseek-ai/dsh-agent": "0.2.0-rc.1",
"@deepseek-ai/dsh-agent-loop": "0.2.0-rc.1",
"@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-store": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
"@deepseek-ai/dsh-invariants": "0.2.0-rc.1",
"@deepseek-ai/dsh-llm": "0.2.0-rc.1",
"@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.1",
"@deepseek-ai/dsh-sandbox": "0.2.0-rc.1",
"@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.1",
"@deepseek-ai/dsh-scope": "0.2.0-rc.1",
"@deepseek-ai/dsh-session": "0.2.0-rc.1",
"@deepseek-ai/dsh-session-persistence": "0.2.0-rc.1",
"@deepseek-ai/dsh-session-projection": "0.2.0-rc.1",
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
"@deepseek-ai/dsh-subagent": "0.2.0-rc.1",
"@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
"@deepseek-ai/dsh-tools": "0.2.0-rc.1",
"@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
"@deepseek-ai/dsh-util-code-language": "0.2.0-rc.1",
"@deepseek-ai/dsh-util-time": "0.2.0-rc.1",
"@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.1",
"@eslint/js": "^10.0.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
+371 -362
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -42,11 +42,12 @@ e2e_require_cmd() {
# 缺省值(`dsh`)由调用方从环境变量取好传入。
#
# 回退的 npx 版本是显式钉死的:不钉就会解析 npm 的 `latest` dist-tag,而
# 0.1.7 线目前只有预发布(`latest` 仍是 0.1.5-rc.3),冒烟会静静地挂到一
# 个插件已不支持的宿主上。**必须写精确版本,不能写 `@alpha`**:0.1.7-rc.1
# 上线时 `alpha` 指的是 0.1.7-alpha.2,rc.1 走的是 `next`。钉版必须与
# package.json 的 peer 下限同步。
DSH_NPX_SPEC="${DSH_NPX_SPEC:-@deepseek-ai/dsh@0.1.7-rc.1}"
# 0.2.0 线目前只有预发布(`latest` 仍是 0.1.7-rc.2,0.2.0-rc.1 走 `next`),
# 冒烟会静静地挂到一个插件已不支持的宿主上。**必须写精确版本,不能写
# `@next`/`@alpha` 之类的 dist-tag**:0.1.7-rc.1 上线时 `alpha` 指的是
# 0.1.7-alpha.2,0.2.0-rc.1 又只挂在 `next` 上。钉版必须与 package.json 的
# peer 下限同步(caret 跨 minor 不成立:`^0.1.7-rc.1` 实测不满足 0.2.0-rc.1)。
DSH_NPX_SPEC="${DSH_NPX_SPEC:-@deepseek-ai/dsh@0.2.0-rc.1}"
e2e_resolve_dsh_cmd() {
local explicit="${DSH_CMD}"
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
@@ -68,7 +69,7 @@ e2e_resolve_dsh_cmd() {
# 己在挂什么),只告警。
e2e_check_dsh_version() {
local explicit="$1" actual expected
expected="${DSH_EXPECT_VERSION:-0.1.7-rc.1}"
expected="${DSH_EXPECT_VERSION:-0.2.0-rc.1}"
# `dsh --version` 冷启动要走 npx,给足预算;拿不到版本就只告警。
actual="$($DSH_CMD --version 2>/dev/null | tr -d '[:space:]' | head -c 64 || true)"
case "$actual" in
+385
View File
@@ -0,0 +1,385 @@
/**
* Helpers for the `/sidebar/archive` route (the file tree's "zip and
* download" action): the download-name sanitizer and the recursive entry
* walk. Kept apart from index.ts so both are directly unit-testable — the
* walk's bounds live inside the collector, and the route module is a
* cordis plugin whose setup needs a whole fake context.
*
* The walk is the security-relevant half: every row comes from an already
* fenced path (index.ts resolves each selection through
* `ensureWorkspacePath` first), and a symlink is skipped rather than followed,
* so it can neither escape the workspace nor cycle.
*/
import { randomUUID } from 'node:crypto'
import { lstat, readdir } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { SidebarError } from './wire.ts'
import { buildZip, ZIP_MAX_ENTRIES, type ZipEntry } from './zip.ts'
/** Maximum archive-name length (keeps the Content-Disposition header sane). */
export const ARCHIVE_NAME_MAX = 120
/**
* Sanitize the archive's download name: one flat file name, never a path.
* Separators, control characters, quotes and leading dots are stripped (a
* name cannot look like a traversal or hide as a dotfile); an empty result
* falls back to `archive.zip`, and a `.zip` suffix is applied once.
*/
export function archiveNameOf(raw: string | null): string {
// A bare `.zip` (or nothing at all) is the caller asking for the default:
// check BEFORE the leading-dot strip below, which would turn it into `zip`.
const trimmed = (raw ?? '').trim()
if (trimmed === '' || trimmed === '.zip') return 'archive.zip'
// Character filter (not a character-class regex): separators, quotes and
// C0/DEL control characters cannot survive into the header.
const cleaned = [...trimmed]
.filter((char) => {
const code = char.codePointAt(0)!
return char !== '\\' && char !== '/' && char !== '"' && char !== '\'' && code >= 0x20 && code !== 0x7F
})
.join('')
.trim()
.replace(/^\.+/, '')
.slice(0, ARCHIVE_NAME_MAX)
if (cleaned === '') return 'archive.zip'
return cleaned.toLowerCase().endsWith('.zip') ? cleaned : `${cleaned}.zip`
}
/**
* The `content-disposition` value for one archive download.
*
* A non-latin1 name (a Chinese folder → `报告.zip`) CANNOT go into the header
* verbatim: Node's `writeHead` validates header values and rejects anything
* above U+00FF with `Invalid character in header content` — the route turned
* that into a 500. So the name is sent twice, exactly like `/sidebar/file`
* does for a download: an ASCII-only `filename="…"` fallback for old clients
* and the RFC 5987 `filename*=UTF-8''…` form (percent-encoded, therefore pure
* ASCII) for everyone else, which wins in every current browser.
*/
export function contentDispositionOf(name: string): string {
return `attachment; filename="${asciiFallbackOf(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The ASCII-only fallback of a download name: characters a quoted header
* string cannot carry (or that no latin1 client could render) drop out, and a
* stray quote or backslash does too. A result with no stem left (`''` or a
* bare extension, as a fully non-latin1 name reduces to) becomes
* `download.zip`, so the header always names a usable file.
*/
function asciiFallbackOf(name: string): string {
const ascii = [...name]
.filter(char => {
const code = char.codePointAt(0)!
return code >= 0x20 && code <= 0x7E && char !== '"' && char !== '\\'
})
.join('')
const stem = ascii.replace(/\.zip$/i, '')
if (stem === '' || /^\.+$/.test(stem)) return 'download.zip'
return ascii
}
/**
* The in-archive name of every selection, disambiguated.
*
* The common case keeps the intuition "one selection is named by its own
* basename" — archive `/ws/src` and the entries live under `src/`. Two
* selections that would collide (multi-select `/ws/a/index.ts` and
* `/ws/b/index.ts` both wanting `index.ts`) are extended with parent
* segments until every name is unique, so an extractor cannot silently
* overwrite one member with another. A path that runs out of ancestors
* (selecting the filesystem root, or `/a` twice) falls back to the full
* '/'-joined path, which is unique by construction.
*
* @param selections - absolute (already fenced) paths, in selection order.
* @returns one '/'-separated archive name per selection, same order.
*/
export function disambiguateArchiveNames(selections: readonly string[]): string[] {
const segmentsOf = (absolute: string, depth: number): string => {
const parts: string[] = []
let current = absolute
for (let level = 0; level < depth; level += 1) {
const part = basename(current)
const parent = dirname(current)
if (part === '' || parent === current) break
parts.unshift(part)
current = parent
}
return parts.join('/')
}
const deepEnough = (absolute: string, name: string): string => {
const full = segmentsOf(absolute, Number.MAX_SAFE_INTEGER)
// Still colliding after every segment is in play (two identical paths, or
// a root-level duplicate): the full path is the best available identity.
return full === name ? absolute.replace(/^\/+/, '') : name
}
const depth = new Map<string, number>(selections.map(path => [path, 1]))
for (let round = 0; round < 64; round += 1) {
const names = selections.map(path => segmentsOf(path, depth.get(path)!))
const counts = new Map<string, number>()
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
if ([...counts.values()].every(count => count === 1)) return names
const grown = new Set<string>()
for (const [index, path] of selections.entries()) {
if ((counts.get(names[index]!) ?? 0) <= 1) continue
depth.set(path, depth.get(path)! + 1)
grown.add(path)
}
if (grown.size === 0) break
}
return selections.map(path => deepEnough(path, segmentsOf(path, depth.get(path)!)))
}
/**
* Collect one selected path into the archive: a file becomes one entry, a
* directory is walked depth-first and each level contributes its own entry
* (a trailing '/' name), files keep their layout under the directory's own
* name. Symlinks are skipped, never followed. Iterative (an explicit stack)
* because a deep tree must not blow the JS stack, and the count bound is
* enforced HERE so a runaway directory fails before its rows pile up.
*
* @param absolute - the (already fenced) absolute path of the selection.
* @param name - the in-archive name of that selection (its basename).
* @param out - the entry list appended to, in archive order.
* @param maxEntries - entry-count bound.
* @throws {SidebarError} fs-error when a row cannot be read or the bound is hit.
*/
export async function collectZipEntries(
absolute: string,
name: string,
out: ZipEntry[],
maxEntries = ZIP_MAX_ENTRIES,
): Promise<void> {
const stack: Array<{ path: string; name: string }> = [{ path: absolute, name }]
while (stack.length > 0) {
const item = stack.pop()!
if (out.length >= maxEntries) {
throw new SidebarError('fs-error', `too many entries for one archive (> ${maxEntries})`, 400)
}
const info = await lstat(item.path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${item.path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
if (info.isSymbolicLink()) {
// A link row is not a file to read and not a directory to walk: it is
// skipped (an archive of a dangling link has nothing to store). Never
// followed — no escape and no cycle.
continue
}
if (!info.isDirectory()) {
out.push({ path: item.path, name: item.name })
continue
}
out.push({ path: item.path, name: item.name, isDir: true })
let level
try {
level = await readdir(item.path, { withFileTypes: true })
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${item.path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
// Reverse order: the stack pops the first child next, so the archive reads
// top-down in directory order.
for (let index = level.length - 1; index >= 0; index -= 1) {
const child = level[index]!
stack.push({ path: join(item.path, child.name), name: `${item.name}/${child.name}` })
}
}
}
/** How many archives one host may build at the same time. */
export const ARCHIVE_MAX_BUILDING = 4
/** How long a finished (or failed) archive stays downloadable. */
export const ARCHIVE_TTL_MS = 5 * 60 * 1000
/** One archive build: the state the client polls, plus the finished bytes. */
interface ArchiveTask {
id: string
sessionId: string
name: string
state: 'building' | 'ready' | 'error'
done: number
total: number
bytes: number
error?: string
zip?: Buffer
/** When the task stops being downloadable (set when it settles). */
expiresAt: number
}
/** The status shape the client reads (never carries the bytes). */
export interface ArchiveStatus {
state: 'building' | 'ready' | 'error'
done: number
total: number
bytes: number
error?: string
}
/** Why a download/status read could not be served. */
export type ArchiveLookup =
| { ok: true; task: ArchiveTask }
| { ok: false; reason: 'missing' | 'forbidden' | 'building' | 'expired' | 'error'; message: string }
/** A resolved download: the bytes plus the sanitized name they were built for. */
export interface ArchiveDownload {
name: string
zip: Buffer
}
/** The response face {@link respondArchiveDownload} writes through. */
export interface ArchiveDownloadResponse {
writeHead(status: number, headers?: Record<string, string>): void
end(chunk?: Buffer | string): void
}
/**
* Answer one `GET /sidebar/archive?id=&sessionId=` from the task table.
*
* Split out of index.ts because this mapping IS the contract: 400 without
* both parameters, 403 for another session's task, 409 while it builds, 410
* for a failed build, 404 for unknown/expired/already-downloaded, 200 with the
* RFC 5987 disposition otherwise. Kept here so all six branches are testable
* without a live cordis mount.
* @param tasks - the plugin's task table.
* @param query - the request's `sessionId` / `id` values (null = absent).
* @param res - a minimal response face.
* @throws {SidebarError} for every non-200 branch (callers turn it into the
* JSON error envelope they already use).
*/
export function respondArchiveDownload(
tasks: ArchiveTasks,
query: { sessionId: string | null; id: string | null },
res: ArchiveDownloadResponse,
): void {
if (query.sessionId === null || query.id === null || query.id === '') {
throw new SidebarError('bad-request', 'sessionId and id are required')
}
const found = tasks.lookup(query.id, query.sessionId)
if (!found.ok || found.task.state !== 'ready' || found.task.zip === undefined) {
if (found.ok) throw new SidebarError('not-found', 'archive is gone', 404)
if (found.reason === 'building') throw new SidebarError('bad-request', found.message, 409)
if (found.reason === 'forbidden') throw new SidebarError('forbidden', found.message, 403)
if (found.reason === 'error') throw new SidebarError('fs-error', found.message, 410)
throw new SidebarError('not-found', found.message, 404)
}
const { name, zip } = found.task
// A download consumes the task: the bytes are not kept for a second fetch
// (the task table is a queue, not a store).
tasks.release(found.task.id)
res.writeHead(200, {
'content-type': 'application/zip',
// Never the raw name: a non-latin1 value (中文目录 → 报告.zip) makes Node's
// writeHead throw and turns the download into a 500.
'content-disposition': contentDispositionOf(name),
'content-length': String(zip.byteLength),
'cache-control': 'no-cache',
})
res.end(zip)
}
/**
* The host-side archive task table.
*
* A selection is collected up front (cheap: names + sizes) and then zipped in
* the BACKGROUND, so the client can show progress instead of staring at a
* spinner: `start` returns an id immediately, `status` reports
* `building → ready | error`, and `download` hands over the bytes exactly once.
* The table is bounded twice — {@link ARCHIVE_MAX_BUILDING} concurrent builds
* and {@link ARCHIVE_TTL_MS} since a finished one — and every read is
* session-scoped, so one session can never fetch another's archive.
*/
export interface ArchiveTasks {
/** Start a build; rejects with bad-request when the concurrency cap is hit. */
start(input: { sessionId: string; name: string; entries: readonly ZipEntry[] }): { id: string; entries: number }
/** Poll one task (a settled task past its TTL reads as missing). */
status(id: string, sessionId: string): ArchiveStatus
/** Look one task up for a download; `sessionId` must match the builder's. */
lookup(id: string, sessionId: string): ArchiveLookup
/** Release a task (called after its bytes were served). */
release(id: string): void
/** Number of live tasks (tests / diagnostics). */
size(): number
}
/** Create one archive task table (one per plugin mount). */
export function createArchiveTasks(): ArchiveTasks {
const tasks = new Map<string, ArchiveTask>()
/** Drop settled tasks past their TTL (called on every access). */
const sweep = (now: number): void => {
for (const [id, task] of tasks) {
if (task.state !== 'building' && task.expiresAt <= now) tasks.delete(id)
}
}
/** The task one (id, sessionId) pair names, or why it cannot be read. */
const lookup = (id: string, sessionId: string): ArchiveLookup => {
sweep(Date.now())
const task = tasks.get(id)
if (task === undefined) return { ok: false, reason: 'missing', message: 'unknown or expired archive' }
if (task.sessionId !== sessionId) return { ok: false, reason: 'forbidden', message: 'archive belongs to another session' }
if (task.state === 'building') return { ok: false, reason: 'building', message: 'archive is still being built' }
if (task.state === 'error') return { ok: false, reason: 'error', message: task.error ?? 'archive failed' }
return { ok: true, task }
}
return {
start({ sessionId, name, entries }) {
sweep(Date.now())
let building = 0
for (const task of tasks.values()) if (task.state === 'building') building += 1
if (building >= ARCHIVE_MAX_BUILDING) {
throw new SidebarError('bad-request', `too many archives are being built (max ${ARCHIVE_MAX_BUILDING})`, 409)
}
const task: ArchiveTask = {
id: `ar-${randomUUID()}`,
sessionId,
name,
state: 'building',
done: 0,
total: entries.length,
bytes: 0,
expiresAt: Number.POSITIVE_INFINITY,
}
tasks.set(task.id, task)
// Detached on purpose: the POST answers as soon as the id exists, and the
// task table (not the request) owns the error from here on.
void buildZip([...entries], {
onProgress: (progress) => {
task.done = progress.done
task.bytes = progress.bytes
},
}).then((zip) => {
task.zip = zip
task.state = 'ready'
task.done = task.total
task.expiresAt = Date.now() + ARCHIVE_TTL_MS
}, (error: unknown) => {
task.state = 'error'
task.error = error instanceof Error ? error.message : String(error)
task.expiresAt = Date.now() + ARCHIVE_TTL_MS
})
return { id: task.id, entries: entries.length }
},
status(id, sessionId) {
sweep(Date.now())
const task = tasks.get(id)
if (task === undefined) throw new SidebarError('not-found', 'unknown or expired archive', 404)
if (task.sessionId !== sessionId) throw new SidebarError('forbidden', 'archive belongs to another session', 403)
return {
state: task.state,
done: task.done,
total: task.total,
bytes: task.bytes,
...(task.error === undefined ? {} : { error: task.error }),
}
},
lookup,
release(id) {
tasks.delete(id)
},
size() {
sweep(Date.now())
return tasks.size
},
}
}
+13 -146
View File
@@ -5,157 +5,24 @@
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the changes tab's staging/discard operations change the
* very content it shows. Rendering goes through the shared {@link DiffFiles}
* renderer — the same one the changes tab's inline preview uses.
* very content it shows. Loading IS the shared `useGitDiffTarget` hook (the
* changes tab's inline preview runs the same one, so the staged-side and
* untracked fallbacks and the per-file fold cache cannot drift apart);
* rendering goes through the shared {@link DiffFiles} renderer.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMemo } from 'react'
import { IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
import { DiffFiles } from './diff/DiffFiles.tsx'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
import { useGitDiffTarget } from './diff/use-git-diff.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './paths.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
interface DiffData {
diff: string
untracked?: string
}
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
const { sessionId, cwd, diff } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
setLoading(true)
setError(null)
setData(null)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree)
if (!cancelled) setData({ diff: result.diff })
return
}
let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once: the ref
// may predate the staged-flag fix, or the change moved sides (a
// file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!diff.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition; anything else is a genuine no-text-change.
if (diff.untracked === true && !diff.staged) {
// A child-repo path is relative to diff.repoRoot, not the session
// cwd or the linked-worktree root; resolve against whichever the
// diff ref carries so the untracked fallback reads the right file.
const text = await api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, diff.path))
if (!cancelled) {
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
}
return
}
if (!cancelled) setData({ diff: '' })
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [sessionId, cwd, diff, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the ref or a refresh tick. ──────────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [diff, tick])
const foldLoader = useMemo(() => {
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
if (diff.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? diff.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree),
api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [sessionId, cwd, diff, effectiveStaged])
const scope = useMemo<SessionScope>(() => ({ sessionId, cwd }), [sessionId, cwd])
const { loading, error, diffText, untracked, refresh, resolveFold } = useGitDiffTarget(diff, scope)
return (
<div className={css.gitDiffTab}>
@@ -175,12 +42,12 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
{!loading && error === null && data !== null && (
{!loading && error === null && diffText !== null && (
<>
{data.untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
{data.diff === '' && data.untracked === undefined && (
{untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={untracked} />
: <DiffFiles diff={diffText} resolveFold={resolveFold} />}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</>
+40 -7
View File
@@ -27,15 +27,15 @@ 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'
import { openSidebarFile } from './sidebar-file.ts'
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
import { updatePluginSettings } from './plugin-settings.ts'
import { createOpenInApp } from './open-in-app.ts'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
@@ -98,12 +98,16 @@ export function EditorHost(props: {
store: SidebarStore
scope: SessionScope
tab: SidebarTab
/** Whether this tab is the active one with its panel open: a parked tab
* must not keep polling (the workbench keeps every tab body mounted). */
visible?: boolean
expanded: string[]
revealed: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
}) {
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
const visible = props.visible !== false
const path = tab.path ?? ''
const title = tab.title
// A folder window: the model's `sidebar_open` (or any caller) opens a
@@ -135,6 +139,14 @@ export function EditorHost(props: {
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
)
// The DSH-native "open with" capability (host open-in-app): one adapter per
// window, shared by every row menu below. The plugin no longer owns a
// target list, a URL vocabulary or a spawn route — the host reports which
// applications are actually installed for THIS path.
const openInApp = useMemo(() => createOpenInApp(ctx), [ctx])
// The plugin's own service (native-tab opens, "open to the side"): absent in
// stripped-down hosts, where every flow degrades to the bottom workbench.
const service = ctx.get('betterSidebar')
// The file tree's "open with" configuration (pluginSettings['editor']): a
// blob subscription, so a pin click or a settings-page edit re-renders the
// menu immediately. The parsed config also drives which targets are shown
@@ -145,6 +157,11 @@ export function EditorHost(props: {
)
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// The declarative "always show the plugin's own targets" switch (the editor
// card's pluginToggles row): a plain boolean on the SAME blob, so both the
// settings page and the tree's menu see one value. Absent/false keeps the
// host-first behavior (the tree decides what to hide).
const openWithShowPluginTargets = editorBlob.openWithPluginTargets === true
// A path-less tab shows the empty-state hint in merged mode — and in split
// mode it is the standalone explorer (tree-only, see the render below). A
// folder tab is a folder window in BOTH modes: the tree rooted at the
@@ -175,8 +192,22 @@ export function EditorHost(props: {
* The context menu's "open to the side": a fresh editor tab (uid id — the
* `'editor:' + path` convention would clash with the id safety net on a
* second side-open of the same file) in a rightward split of THIS pane.
*
* Native right-Sidebar tabs do NOT live in `bottomSplits`, so the bottom
* branch would fall through to `firstLeaf` — a pane the user has not
* expanded, i.e. "nothing happened". Those tabs instead ask the host for a
* second pane through the service (`target: 'side'` → the host's
* `preferNewPane`), which is the same gesture in the surface the user is
* actually looking at.
*/
const openFileSide = (absolute: string): void => {
// `store.tabOpen` answers from THIS session's own state map — the bottom
// workbench's splits. A natively-hosted tab (right Sidebar) is absent
// from them even while it is on screen.
if (service !== undefined && !store.tabOpen(scope.sessionId, tab.id)) {
service.openTab({ type: 'editor', path: absolute, target: 'side' }, scope)
return
}
store.reduce((state) => {
const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
const fresh: SidebarTab = {
@@ -384,7 +415,7 @@ export function EditorHost(props: {
<div className={css.editor}>
<TreePanel
full
store={store}
visible={visible}
sessionId={scope.sessionId}
cwd={folderRoot ?? scope.cwd}
expanded={expanded}
@@ -393,6 +424,8 @@ export function EditorHost(props: {
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openInApp={openInApp}
openWithShowPluginTargets={openWithShowPluginTargets}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
@@ -479,9 +512,7 @@ export function EditorHost(props: {
<div className={css.editorMain}>
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{!showEmpty && load.status === 'error' && (isOutsideWorkspaceMessage(load.message)
? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
: <div className={css.editorError}>{load.message}</div>)}
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
@@ -509,7 +540,7 @@ export function EditorHost(props: {
onPointerCancel={onResizeEnd}
/>
<TreePanel
store={store}
visible={visible}
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
@@ -518,6 +549,8 @@ export function EditorHost(props: {
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openInApp={openInApp}
openWithShowPluginTargets={openWithShowPluginTargets}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
-40
View File
@@ -1,40 +0,0 @@
/**
* The workspace-fence refusal surface. The raw wire text (`path "..." is
* outside workspace`) is never shown as-is: the editor / file-tree error
* slots render the localized reason plus a one-click global off — the click
* flips the `workspaceFence` pref through the settings route, adopts the
* returned document into the store (so every prefs reader — the changes tab's open
* guard, the settings page — flips with it), and calls `onDisabled` so the
* caller retries the failed operation immediately.
*/
import { useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { t } from './locales.ts'
import { parsePrefs } from './prefs.ts'
import css from './sidebar.module.css'
import type { SidebarStore } from './state.ts'
export function FenceErrorNotice(props: { store: SidebarStore; onDisabled: () => 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 (
<div className={css.fenceError}>
<span>{t('fenceErrorReason')}</span>
<Button variant="outline" disabled={busy} onClick={disable}>{t('fenceDisableAction')}</Button>
</div>
)
}
+1308 -367
View File
File diff suppressed because it is too large Load Diff
+100 -82
View File
@@ -1,11 +1,14 @@
/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over either the shared controlled FileTree (empty query) or the flat
* result list (relative paths; click opens through the caller's mode-aware
* open). Owns its refresh tick: the icon next to the search input clears
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
* a drag-resize handle) and provides the file context-menu open escapes.
* over the shared controlled FileTree. The results panel and the tree are
* BOTH mounted: the query only parks one of them (`hidden`), so clearing the
* search is free — the tree keeps its level cache and the live directory
* watcher (the old conditional render dropped both and refetched the whole
* visible set on every query change). Owns its refresh tick: the icon next to
* the search input clears the tree cache. EditorHost docks it as the tab's
* right panel (wrapped in a drag-resize handle) and provides the file
* context-menu open escapes.
*
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
* all funnel through here: one session at a time, shown in a full-window
@@ -20,12 +23,13 @@ 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'
import type { OpenWithTarget } from './open-with.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './paths.ts'
import { IconButton } from './ui/index.ts'
import { UploadOverlay } from './UploadOverlay.tsx'
import {
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
@@ -46,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
@@ -56,25 +58,40 @@ export function TreePanel(props: {
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
/** The "open with" menu surface (passed through to FileTree; absent →
* the whole section is hidden). */
/** The host's open-in-app handle (passed through to FileTree; absent →
* the HOST half of the "打开方式" section is hidden). */
openInApp?: OpenInApp
/** The plugin's own open-with targets (passed through to FileTree; coexists
* with `openInApp`; absent → no plugin half). */
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
/** Show the plugin's own open-with targets even when the host lists local
* applications for the path (the `openWithPluginTargets` setting; passed
* through to FileTree). */
openWithShowPluginTargets?: boolean
onReferenceFile: (path: string, isDir: boolean) => void
/** A tree rename landed (passed through to FileTree for tab retargeting). */
onPathRenamed?: (oldPath: string, newPath: string) => void
/** A tree delete landed (passed through to FileTree for tab closing). */
onPathDeleted?: (path: string, isDir: boolean) => void
/** Whether the owning tab is on screen: a parked tab stops the tree's git
* polling (the shared status store pauses when nothing visible wants it). */
visible?: boolean
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
service?: BetterSidebarService
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props
const {
sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
openWithShowPluginTargets,
onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
} = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -153,6 +170,7 @@ export function TreePanel(props: {
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
const needle = query.trim()
const searching = needle !== ''
useEffect(() => {
if (needle === '') {
setResults(null)
@@ -188,35 +206,23 @@ export function TreePanel(props: {
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
<IconButton
label={t('refresh')}
icon={<IconRefreshOutlineRegular size={14} />}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('uploadFiles')}
title={t('uploadFiles')}
/>
<IconButton
label={t('uploadFiles')}
icon={<IconUploadOutline16 size={14} />}
disabled={busy}
onClick={() => { fileInputRef.current?.click() }}
>
<IconUploadOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('uploadFolder')}
title={t('uploadFolder')}
/>
<IconButton
label={t('uploadFolder')}
icon={<IconFolderOpenRegular size={14} />}
disabled={busy}
onClick={() => { folderInputRef.current?.click() }}
>
<IconFolderOpenRegular size={14} />
</button>
/>
<input
ref={fileInputRef}
type="file"
@@ -242,53 +248,65 @@ export function TreePanel(props: {
{uploadStatus !== '' && (
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
)}
{needle === '' ? (
<FileTree
sessionId={sessionId}
cwd={cwd}
store={store}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
service={service}
/>
) : (
<div className={css.explorerBody}>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</div>
)}
{/* Search results: parked (not unmounted) while the query is empty. Its
contents render only for a live query — a parked panel must not put
state text (the loading line) into the panel's DOM. */}
<div
className={clsx(css.explorerBody, !searching && css.explorerHiddenPane)}
hidden={!searching}
>
{searching && (
<>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</>
)}
</div>
{/* The tree stays MOUNTED while searching: its level cache and the
directory watcher survive a query, so clearing the box is free. */}
<FileTree
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openInApp={openInApp}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
openWithShowPluginTargets={openWithShowPluginTargets}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
hidden={searching}
visible={visible !== false}
service={service}
/>
{upload !== null && (
<UploadOverlay
dir={upload.dir}
+1 -1
View File
@@ -39,7 +39,7 @@ export function UploadOverlay(props: {
<div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
<div className={css.uploadOverlayCard}>
<div className={css.uploadOverlayTitle} title={dir}>
<IconUploadOutline16 size={16} />
<IconUploadOutline16 size={14} />
<span>{t('uploadingTo', { dir })}</span>
</div>
<div
+62 -16
View File
@@ -27,22 +27,6 @@ export class SidebarApiError extends Error {
}
}
/**
* Whether a wire failure is the workspace fence refusing a path outside the
* session workspace (the host message reads `path "..." is outside
* workspace`). The request-trust fence answers code `forbidden` with the
* bare message 'forbidden', so the message fragment — not the code alone —
* identifies this case.
*/
export function isOutsideWorkspaceError(error: unknown): boolean {
return error instanceof SidebarApiError && isOutsideWorkspaceMessage(error.message)
}
/** Message-level variant for surfaces that stored the raw text (file-tree level errors). */
export function isOutsideWorkspaceMessage(message: string): boolean {
return message.includes('outside workspace')
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
@@ -55,6 +39,15 @@ export interface FsEntry {
broken: boolean
}
/** One level of a `fs.trees` batch: a listing, or that level's failure. */
export interface FsLevel {
path: string
entries: FsEntry[]
truncated: boolean
/** Present only when THIS level failed (the batch itself succeeded). */
error?: string
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
@@ -290,6 +283,13 @@ export const api = {
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/**
* 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) =>
@@ -307,6 +307,11 @@ export const api = {
* row unlinks the link only). The UI confirms before calling this. */
fsRemove: (scope: SessionScope, path: string) =>
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
/** Create one directory row inside `path` (single-segment name; the server
* refuses existing destinations, the workspace root, and — while the fence
* is armed — anything resolving outside the workspace). */
fsMkdir: (scope: SessionScope, path: string, name: string) =>
call<{ path: string }>('fs.mkdir', scopePayload(scope, { path, name })),
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
* `relativePath`); the host streams it under the session workspace. */
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
@@ -434,6 +439,47 @@ export function downloadUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, true)
}
/**
* Start one archive build. The host fences and walks the selection, then
* returns an id IMMEDIATELY — the zip itself is produced in the background and
* polled through {@link api.archiveStatus}. `paths` are absolute paths in the
* session's namespace (the same values `fsTree` / `downloadUrl` take).
*/
export function archiveBuild(
scope: SessionScope,
paths: readonly string[],
name: string,
): Promise<{ id: string; entries: number }> {
return call<{ id: string; entries: number }>('archive.build', scopePayload(scope, { paths: [...paths], name }))
}
/** One archive build's progress (the shape `archive.status` returns). */
export interface ArchiveBuildStatus {
state: 'building' | 'ready' | 'error'
/** Entries finished so far. */
done: number
/** Entries the archive will contain. */
total: number
/** Uncompressed bytes read so far. */
bytes: number
/** Present only when `state === 'error'`. */
error?: string
}
/** Poll one archive build (its id came from {@link archiveBuild}). */
export function archiveStatus(id: string): Promise<ArchiveBuildStatus> {
return call<ArchiveBuildStatus>('archive.status', { id })
}
/**
* Absolute URL of one FINISHED archive's bytes (the `archive.status` result
* must be `ready` first): GET /sidebar/archive?id=…&sessionId=… The session
* scope rides along because the host answers only the session that built it.
*/
export function archiveDownloadUrl(scope: SessionScope, id: string): string {
return `/sidebar/archive?${new URLSearchParams({ sessionId: scope.sessionId, id }).toString()}`
}
/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
+14 -10
View File
@@ -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,21 +67,26 @@ 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
// no local application, unless the user asks for both side by side.
pluginToggles: [{
key: 'openWithPluginTargets',
title: () => t('settingsOpenWithPluginTitle'),
desc: () => t('settingsOpenWithPluginDesc'),
}],
render: ({ pluginSettings, updatePluginSetting }) => (
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
),
},
component: ({ ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile }) => (
component: ({ ctx, store, scope, tab, visible, expanded, revealed, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
scope={scope}
tab={tab}
visible={visible}
expanded={expanded ?? []}
revealed={revealed ?? []}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
+123 -62
View File
@@ -2,27 +2,33 @@
* The unified changes tab: one tab, two lenses on "what changed?" — Git
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
* in the workbench's diff pane. The active lens and the pane height persist
* in the tab's meta, so the tab
* reopens exactly where it was left.
* A 36px header carries the host {@link SegmentedControl} and the current
* lens's refresh action; both lenses preview their selections in a shared
* resizable bottom pane ({@link DiffPane}), and git targets expand into the
* dedicated diff tab docked in the workbench's diff pane. The active lens and
* the pane height persist in the tab's meta, so the tab reopens exactly where
* it was left.
*
* The session events ride the host's `changes.ops` route (the client
* runtime exposes no event-log face): the tab pulls the delta past its
* cursor while visible, folds it into ops, and publishes the op count to a
* module-level cache the tab-strip badge reads.
* The session events ride the host's `changes.ops` route (the client runtime
* exposes no event-log face): the tab pulls the delta past its cursor and
* folds it into ops, and publishes the op count to a module-level cache the
* tab-strip badge reads. The pull is one catch-up whenever the tab becomes
* visible and then a cadence ONLY while the session lens is on screen — the
* git lens has no use for the session event log (and the shared git-status
* store owns its own poller).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutlineRegular, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import type { SidebarDiffRef } from '../state.ts'
import { IconButton, Notice } from '../ui/index.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { DiffPane, clampPaneHeight, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
@@ -51,18 +57,28 @@ interface ChangesMeta {
previewH?: number
}
/** What the user selected to preview: a git ref, or the IDENTITY of a session
* op. Holding the callId (not the op object) is what makes the pane follow
* the op as it settles: every render re-derives the op from the latest fold,
* so a running call turns into its result without the user re-clicking. */
type PreviewSelection =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; callId: string }
export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) {
const meta = (tab.meta ?? {}) as ChangesMeta
const [lens, setLens] = useState<Lens>(meta.lens === 'session' ? 'session' : 'git')
const [preview, setPreview] = useState<ChangesPreview | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
const [selection, setSelection] = useState<PreviewSelection | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(() => clampPaneHeight(
typeof meta.previewH === 'number' ? meta.previewH : PANE_HEIGHT_DEFAULT,
))
/** The header refresh action for the Git lens (the lens owns its own queue). */
const [gitRefresh, setGitRefresh] = useState(0)
// ── Session-event accumulation: one pull on mount, then a 2.5s delta
// poll while visible (paused otherwise; the next visible tick catches
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
// ── Session-event accumulation: one catch-up pull when the tab becomes
// visible, then a 2.5s delta poll while the SESSION lens is on screen.
// The cursor is the last delivered seq, so each poll ships only what the
// accumulator lacks. ───────────────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
@@ -72,13 +88,30 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
/** One delta request in flight at a time (the poller is fixed-interval, so a
* slow host would otherwise run two pulls over the same cursor and append
* the same delta twice). A pull that arrives while one is running is a
* no-op: the in-flight answer already carries everything up to now. */
const pullingRef = useRef(false)
const [opsError, setOpsError] = useState(false)
const [tick, setTick] = useState(0)
const pull = useCallback(async (): Promise<void> => {
const pull = useCallback(async (signal?: AbortSignal): Promise<void> => {
const generation = pollGen.current
if (pullingRef.current) return
pullingRef.current = true
try {
const { events, lastSeq } = await api.changesOps(scope, seqRef.current)
if (generation !== pollGen.current) return
const { events, lastSeq } = await api.changesOps(scope, seqRef.current, signal)
// An aborted poll belongs to a torn-down run: its answer must never
// publish state (the transport may deliver it anyway).
if (generation !== pollGen.current || signal?.aborted === true) return
// Nothing new: KEEP the previous fold's identity. Re-folding an
// unchanged window produced a fresh array every 2.5s, which defeated
// every downstream memo — the session lens re-encoded each op body into
// a Blob to size it, on every idle tick.
if (events.length === 0 && lastSeq <= seqRef.current && opsRef.current.length > 0) {
setOpsError(false)
return
}
if (events.length > 0) {
const merged = [...eventsRef.current, ...events]
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
@@ -92,7 +125,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
if (generation === pollGen.current && signal?.aborted !== true) setOpsError(true)
} finally {
pullingRef.current = false
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the poll target.
@@ -105,15 +140,24 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
// One pull on every input change — mount, scope change, and visibility
// flip all re-pull (a hidden tab still catches up on the flip); only the
// poll CADENCE below is gated by visibility.
useEffect(() => { void pull() }, [visible, pull])
usePolling(visible, pull, { intervalMs: 2_500 })
// One catch-up whenever the tab becomes visible (the badge count follows the
// tab being opened); the CADENCE below runs only while this lens is showing.
useEffect(() => { if (visible) void pull() }, [visible, pull])
usePolling(visible && lens === 'session', pull, { intervalMs: 2_500 })
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = opsRef.current
/** The preview target of the current selection, re-derived every render so
* an op that settled (or fell out of the window) is reflected immediately. */
const previewTarget = useMemo<ChangesPreview | null>(() => {
if (selection === null) return null
if (selection.kind === 'git') return selection
const op = ops.find(candidate => candidate.callId === selection.callId)
if (op === undefined) return null
return { kind: 'op', path: selection.path, op, prior: knownContentBefore(ops, selection.path, op) }
}, [selection, ops])
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
@@ -127,21 +171,29 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
patchMeta({ lens: next })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
/** The header's refresh action: the Git lens reloads through its own queue
* (a click while a refresh is in flight is queued, not dropped), the
* session lens re-pulls its event delta. */
const refreshCurrent = (): void => {
if (lens === 'git') setGitRefresh(value => value + 1)
else void pull()
}
/** Preview one session op (with its best-effort prior content snapshot). */
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setSelection({ kind: 'git', ref })
}
/** Preview one session op by callId (the tab re-derives it each render). */
const previewOp = (path: string, op: FileOp): void => {
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
setSelection({ kind: 'op', path, callId: op.callId })
}
/** Expand the current git preview into the dedicated diff tab, docked
* into the workbench's diff pane. */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
onOpenDiff?.(diffTabOf(preview.ref))
if (previewTarget?.kind !== 'git') return
onOpenDiff?.(diffTabOf(previewTarget.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
@@ -152,37 +204,35 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
return (
<div className={css.root}>
<div className={css.lensBar}>
<div className={css.lensSwitch} role="group" aria-label={t('changes')}>
<button
type="button"
className={css.lensButton}
data-active={lens === 'git' ? 'true' : undefined}
aria-pressed={lens === 'git'}
onClick={() => { chooseLens('git') }}
>
{t('changesGitLens')}
</button>
<button
type="button"
className={css.lensButton}
data-active={lens === 'session' ? 'true' : undefined}
aria-pressed={lens === 'session'}
onClick={() => { chooseLens('session') }}
>
{t('changesSessionLens')}
</button>
</div>
<div className={css.head}>
<SegmentedControl
id={`changes-lens-${tab.id}`}
value={lens}
options={[
{ value: 'git', label: t('changesGitLens') },
{ value: 'session', label: t('changesSessionLens') },
]}
onChange={chooseLens}
label={t('changes')}
/>
<IconButton
className={css.headAction}
icon={<IconRefreshOutlineRegular />}
label={t('refresh')}
onClick={refreshCurrent}
/>
</div>
{lens === 'git'
? (
<GitLens
ctx={ctx}
scope={scope}
store={store}
visible={visible}
refreshTick={gitRefresh}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
selectedRef={previewTarget !== null && previewTarget.kind === 'git' ? previewTarget.ref : null}
/>
)
: (
@@ -190,20 +240,31 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
selectedCallId={selection !== null && selection.kind === 'op' ? selection.callId : null}
/>
)}
{preview !== null && (
{previewTarget !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
key={previewKey(previewTarget)}
target={previewTarget}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onClose={() => { setSelection(null) }}
onExpand={expandPreview}
/>
)}
{selection !== null && selection.kind === 'op' && previewTarget === null && (
<div className={css.paneGone}>
<Notice kind="warn">{t('changesOpGone')}</Notice>
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={() => { setSelection(null) }}
/>
</div>
)}
</div>
)
}
+61 -199
View File
@@ -2,16 +2,16 @@
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* diff tab via the shell.
* through the shared {@link useGitDiffTarget} hook (refreshable, with the
* untracked full-addition fallback); op targets are pure snapshots (diff /
* read view / error text). The pane is resizable by drag (clamped; the
* height commits to the tab's persisted meta on release) and by keyboard;
* git targets can expand into a dedicated diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconRightUpOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../paths.ts'
@@ -21,7 +21,8 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { useGitDiffTarget } from '../diff/use-git-diff.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
@@ -29,6 +30,7 @@ import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
import { createFrameBatcher } from '../frame-batcher.ts'
import { Chip, IconButton, Notice, StatusBadge } from '../ui/index.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
@@ -36,6 +38,16 @@ import diffCss from '../diff/diff.module.css'
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/**
* Clamp one pane height to the pane's own bounds: never below the content
* minimum, never above 70% of the window. Both the drag handler and the
* RESTORE path (a height persisted under a taller window) run through this,
* so a saved value can never open the pane taller than the viewport allows.
*/
export function clampPaneHeight(value: number): number {
return Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
}
/** The redaction preference, persisted under the repo's sidebar storage
* prefix (see state.ts's `dsh-sidebar:v1`). */
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
@@ -84,21 +96,10 @@ export function HtmlRenderPreview(props: { src: string; title: string }) {
)
}
/** One header pill toggle — the redaction / reading / render toggles share
* the shape (on-state styling + aria-pressed). */
/** One header pill toggle — the redaction / reading / render toggles share the
* shape: the global {@link Chip} (its active state is the "on" picture). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return (
<button
type="button"
className={css.mdToggle}
data-on={props.on ? 'true' : undefined}
aria-pressed={props.on}
title={props.title}
onClick={props.onClick}
>
{props.label}
</button>
)
return <Chip active={props.on} title={props.title} onClick={props.onClick}>{props.label}</Chip>
}
/** The reading-mode body of one markdown op target: the shared MarkdownText
@@ -157,146 +158,10 @@ export interface DiffPaneProps {
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
// ── Git target loading (the shared loader: staged-side fallback, the
// untracked full-addition fallback, refresh by tick, fold cache). ─────
const gitRef = target.kind === 'git' ? target.ref : null
// The scope every git call of this target shares (repoRoot folded in when
// the ref carries one, exactly like the load effect's paneScope).
const gitScope = useMemo<SessionScope>(() => ({
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!gitRef.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ───────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
const foldLoader = useMemo(() => {
if (gitRef === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (gitRef.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? gitRef.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [gitRef, gitScope, effectiveStaged, scope])
const { loading, error, diffText, untracked, refresh, resolveFold } = useGitDiffTarget(gitRef, scope)
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
@@ -422,7 +287,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const clamp = clampPaneHeight
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
@@ -471,15 +336,15 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<span className={css.diffKind} data-kind={target.op.kind}>
<StatusBadge tone={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<span className={css.diffKind} data-kind="git">{target.ref.staged ? t('staged') : t('unstaged')}</span>
<StatusBadge tone="neutral">{target.ref.staged ? t('staged') : t('unstaged')}</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<span className={css.diffKind} data-kind="git">{target.ref.hash}</span>
<StatusBadge tone="neutral">{target.ref.hash}</StatusBadge>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
@@ -490,25 +355,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
)}
{target.kind === 'git' && (
<>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('refresh')}
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('changesOpenDiffTab')}
title={t('changesOpenDiffTab')}
onClick={refresh}
/>
<IconButton
size="sm"
icon={<IconRightUpOutlineRegular size={14} />}
label={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutlineRegular size={14} />
</button>
/>
</>
)}
{redactionHit && (
@@ -543,15 +402,12 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
onClick={() => { setRenderingPdf(value => !value) }}
/>
)}
<button
type="button"
className={css.iconButton}
aria-label={t('changesClosePreview')}
title={t('changesClosePreview')}
<IconButton
size="sm"
icon={<IconCloseOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutlineRegular size={14} />
</button>
/>
</div>
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
@@ -566,9 +422,9 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
<Notice kind="error" className={css.readError}>
{op.errorText ?? t('changesError')}
</div>
</Notice>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
@@ -582,26 +438,32 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <div className={css.priorUnknown}>{t('changesPriorUnknown')}</div>}
&& <Notice kind="hint" tone="inline">{t('changesPriorUnknown')}</Notice>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><div className={css.gitPlaceholder}>{t('loading')}</div></div>
? <div className={css.paneBody}><Notice kind="loading" tone="page">{t('loading')}</Notice></div>
: error !== null
? <div className={css.paneBody}><div className={css.gitError}>{t('diffLoadError')}: {error}</div></div>
? (
<div className={css.paneBody}>
<Notice kind="error">{t('diffLoadError')}: {error}</Notice>
</div>
)
: (
<div className={css.paneBody}>
{diffText !== null && diffText !== '' && (
{/* The untracked fallback has NO diff text (git diff never
lists the file): its content is the whole render. */}
{((diffText !== null && diffText !== '') || untracked !== undefined) && (
<DiffFiles
diff={diffText}
resolveFold={foldLoader}
diff={diffText ?? ''}
resolveFold={resolveFold}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
<Notice kind="empty" tone="page">{t('diffEmpty')}</Notice>
)}
</div>
)}
File diff suppressed because it is too large Load Diff
+21 -24
View File
@@ -7,15 +7,18 @@
* arrive pre-folded from the tab (which owns the event poll); this
* component is purely presentational.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { relativeTime, t } from '../locales.ts'
import { groupByFile, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import { Chip, Notice, StatusBadge } from '../ui/index.ts'
import css from './changes.module.css'
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
const KIND_LABEL = { read: 'changesRead', write: 'changesWrite', edit: 'changesEdit' } as const
export interface SessionLensProps {
/** The folded file operations, newest first (the tab's poll owns them). */
ops: readonly FileOp[]
@@ -41,19 +44,6 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
return map
}, [ops])
const chip = (value: OpFilter, label: string, count: number): ReactNode => (
<button
type="button"
key={value}
className={css.filterChip}
data-active={filter === value ? 'true' : undefined}
onClick={() => { setFilter(value) }}
aria-pressed={filter === value}
>
{label}{value !== 'all' ? ` ${String(count)}` : ''}
</button>
)
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
// edit) — that was per row PER RENDER, re-encoding every field on every
// poll tick. ops keeps its identity between unchanged polls (see the
@@ -71,15 +61,24 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
{chip('all', t('changesFilterAll'), ops.length)}
{chip('write', t('changesWrite'), counts.get('write') ?? 0)}
{chip('edit', t('changesEdit'), counts.get('edit') ?? 0)}
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
<Chip active={filter === 'all'} count={ops.length} onClick={() => { setFilter('all') }}>
{t('changesFilterAll')}
</Chip>
{(['write', 'edit', 'read'] as const).map(kind => (
<Chip
key={kind}
active={filter === kind}
count={counts.get(kind) ?? 0}
onClick={() => { setFilter(kind) }}
>
{t(KIND_LABEL[kind])}
</Chip>
))}
</div>
<div className={css.sessionList}>
{loadError && <div className={css.loadError}>{t('changesLoadError')}</div>}
{ops.length === 0 && !loadError && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{loadError && <Notice kind="error" role="alert">{t('changesLoadError')}</Notice>}
{ops.length === 0 && !loadError && <Notice kind="empty" tone="page">{t('changesSessionEmpty')}</Notice>}
{ops.length > 0 && filteredOps.length === 0 && <Notice kind="empty" tone="page">{t('changesFilterEmpty')}</Notice>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
@@ -93,9 +92,7 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
<StatusBadge tone={op.kind}>{t(KIND_LABEL[op.kind])}</StatusBadge>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
<span className={css.opMeta}>
+138
View File
@@ -0,0 +1,138 @@
/**
* The changes list's hierarchy.
*
* A `git status` answer is a flat list of repo-relative paths, and the Git
* lens used to render exactly that: one row per file, with the file name and
* its directory squeezed onto the same line. Nested work (a refactor touching
* `src/client/changes/*`) then read as a wall of near-identical rows. This
* module folds the list into the directory tree the lens renders — VS Code's
* SCM tree, including its single-child directory compression — so the shape
* of the change is legible at a glance.
*
* Pure — no React, no DOM. `buildChangeTree` runs once per group per fold;
* the renderer only walks the result.
*/
import type { GitStatusEntry } from '../api.ts'
import { statusOfXY, type GitFileStatus } from '../ui/index.ts'
/** One changed file (a leaf), carrying its porcelain status. */
export interface ChangeFile {
kind: 'file'
/** The file's own name (the last path segment). */
name: string
/** The repo-relative path exactly as git reported it (the row's identity). */
path: string
status: GitFileStatus
}
/** One directory row: its children plus the number of changed files beneath. */
export interface ChangeDir {
kind: 'dir'
/** The display label: one segment, or the compressed chain ('src/client'). */
name: string
/** The deepest directory this row stands for. */
path: string
children: ChangeNode[]
/** Changed files anywhere under this row (the row's count pill). */
changes: number
}
export type ChangeNode = ChangeDir | ChangeFile
/** A directory being assembled (children keyed by their own segment). */
interface DirDraft {
name: string
path: string
dirs: Map<string, DirDraft>
files: ChangeFile[]
}
/**
* Directories before files, then by name — case-insensitively.
*
* The collation is PINNED (`'en'`, `sensitivity: 'base'`) rather than left to
* the runtime's default locale: `localeCompare(other)` alone follows whatever
* locale the browser/Node happens to run under, so the same change list could
* order differently on two machines. Base sensitivity makes case and accent
* variants compare equal, and the code-point tiebreak below then decides —
* a total order that does not move when the input order changes.
*/
function compareNames(left: string, right: string): number {
const collated = left.localeCompare(right, 'en', { sensitivity: 'base' })
if (collated !== 0) return collated
if (left === right) return 0
return left < right ? -1 : 1
}
function compareNodes(left: ChangeNode, right: ChangeNode): number {
if (left.kind !== right.kind) return left.kind === 'dir' ? -1 : 1
return compareNames(left.name, right.name)
}
/** The number of changed files under a finished child list. */
function countFiles(nodes: readonly ChangeNode[]): number {
let count = 0
for (const node of nodes) count += node.kind === 'dir' ? node.changes : 1
return count
}
/** Materialize one draft directory, compressing its single-child chain. */
function finishDir(draft: DirDraft): ChangeDir {
let name = draft.name
let current = draft
// A directory with no file of its own and exactly one subdirectory is not
// worth a row: the whole chain reads as one label (`a/b/c`). Compression
// stops the moment the path branches or a file sits at this level, so a row
// always stands for a place where something actually changed.
while (current.files.length === 0 && current.dirs.size === 1) {
const only = [...current.dirs.values()][0]!
name = `${name}/${only.name}`
current = only
}
const children = finishChildren(current)
return { kind: 'dir', name, path: current.path, children, changes: countFiles(children) }
}
/** Finish a draft's children (directories first, then its own files). */
function finishChildren(draft: DirDraft): ChangeNode[] {
const nodes: ChangeNode[] = []
for (const child of draft.dirs.values()) nodes.push(finishDir(child))
nodes.push(...draft.files)
nodes.sort(compareNodes)
return nodes
}
/**
* Fold one group's changed files (unstaged or staged) into a directory tree.
* Rows without a porcelain status (clean, ignored) are skipped, a repeated
* path counts once, and an empty input yields an empty list.
* @param entries - the group's `git status` entries.
* @returns the tree's roots, ready to render in order.
*/
export function buildChangeTree(entries: readonly GitStatusEntry[]): ChangeNode[] {
const root: DirDraft = { name: '', path: '', dirs: new Map(), files: [] }
const seen = new Set<string>()
for (const entry of entries) {
const status = statusOfXY(entry.xy)
if (status === undefined) continue
const segments = entry.path.replace(/\\/g, '/').split('/').filter(segment => segment !== '' && segment !== '.')
if (segments.length === 0) continue
const normalized = segments.join('/')
if (seen.has(normalized)) continue
seen.add(normalized)
let at = root
for (let index = 0; index < segments.length - 1; index += 1) {
const name = segments[index]!
let next = at.dirs.get(name)
if (next === undefined) {
next = { name, path: segments.slice(0, index + 1).join('/'), dirs: new Map(), files: [] }
at.dirs.set(name, next)
}
at = next
}
// The FILE keeps git's own spelling (the lane previews, copies and menus
// all resolve this path); only the directory rows use the split form.
at.files.push({ kind: 'file', name: segments[segments.length - 1]!, path: entry.path, status })
}
return finishChildren(root)
}
+196 -279
View File
@@ -1,18 +1,20 @@
/* The unified changes tab: lens switcher + Git lens + session lens + the
shared preview pane. Semantic --dsw-alias tokens only (skin contract).
The git lens styles moved here from sidebar.module.css when the git panel
became a lens of this tab; the generic placeholder/empty/error trio stays
duplicated in both modules (the diff tab still owns its copy). */
/* The unified changes tab: the 36px lens header + Git lens + session lens +
the shared preview pane. Token-only by contract (`tests/theme.spec.ts` scans
every module sheet) and cut to the plugin's native scale — 12px body, 11px
meta, 28px rows/controls, 2/3/4px radii (see
docs/plans/2026-09-27-files-changes-uiux.md §1).
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
The generic chrome — icon buttons, chips, section bands, badges, empty /
loading / error lines and the confirm dialog — now lives in
`src/client/ui/kit.module.css`; this sheet keeps only layout and the
surfaces unique to this tab. */
/* ── Lens switcher ─────────────────────────────────────────────────────── */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: var(--dsw-font-xxs-12); }
.lensBar { flex: none; display: flex; align-items: center; padding: 8px 8px 4px 12px; }
.lensSwitch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; }
.lensButton { border: 0; border-radius: 6px; padding: 2px 10px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); cursor: pointer; }
.lensButton:hover { color: var(--dsw-alias-label-primary); }
.lensButton[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
/* ── Lens header ───────────────────────────────────────────────────────── */
.head { flex: none; display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 8px 0 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.headAction { margin-left: auto; }
/* ── Git lens (the former git panel) ───────────────────────────────────── */
@@ -28,311 +30,249 @@
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
/* The selector band: branch / repository / worktree pickers (only rendered
for real choices) and nothing else — the counts live in the section bands. */
.selectors { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 12px 0; }
.gitWorktreeRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 0 12px;
}
.gitWorktreeLabel {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
}
.gitBranchSelect {
.select {
flex: 1;
min-width: 0;
height: 26px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
border-radius: 3px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
/* A single branch has nothing to choose: it reads as the row's label. */
.branchLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); }
/* ── Change groups (one directory tree each) ───────────────────────────── */
/* One group is its own block so its sticky header is released exactly when the
group scrolls past (two headers in one block would stack on top of each
other). */
.group { display: flex; flex-direction: column; padding-bottom: 2px; }
/* The header band stays readable while its group scrolls under it. The extra
class keeps the opaque fill above the kit's transparent band no matter which
sheet lands first in the bundle. */
.group .groupHeader {
position: sticky;
top: 0;
z-index: 2;
background: var(--dsw-alias-bg-base);
}
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.gitRow:hover {
/* The group's change count, as a small capsule on the label's baseline. */
.countPill {
flex: none;
min-width: 16px;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-strong-11);
line-height: 16px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.gitRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
/* A band action (stage all / unstage all) stays out of the way until the band
is hovered or holds focus — a keyboard tab reveals it too. Touch devices
have no hover, so there it is always visible. */
.group .headerAction { opacity: 0; transition: opacity 120ms var(--ds-ease-in-out); }
.group .groupHeader:hover .headerAction,
.group .groupHeader:focus-within .headerAction { opacity: 1; }
@media (hover: none) {
.group .headerAction { opacity: 1; }
}
.gitRowMain {
/* The tree body: rows are flush bands, the gutter lives here. */
.tree { display: flex; flex-direction: column; padding: 0 6px; }
/* One nesting level: 12px of indent, with the guide line drawn at its edge so
the depth stays readable without a second colour. */
.treeChildren {
display: flex;
flex-direction: column;
margin-left: 11px;
border-left: 1px solid var(--dsw-alias-border-l1);
}
/* A directory row's disclosure button. It shares the `.row` band with a file
row (the band owns the hover/selected fill and the trailing action), so this
button is only the row's main hit area. */
.dirRow {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
border: none;
background: transparent;
padding: 3px 0;
padding: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
cursor: pointer;
text-align: left;
}
.dirRow:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent); outline-offset: -1px; }
.gitBadge {
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.chevron { flex: none; display: inline-flex; color: var(--dsw-alias-label-tertiary); transition: transform 140ms var(--ds-ease-in-out); }
.chevron[data-open='true'] { transform: rotate(90deg); }
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts;
untracked '?' reads as a not-yet-added file, so it shares the added tint. */
.gitBadge[data-letter='A'], .gitBadge[data-letter='?'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
.dirName { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dsw-font-xxs-12); }
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
/* One changed file: file artwork + porcelain badge + name + the trailing
action (revealed on hover/focus). */
.row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
position: relative;
min-height: 28px;
padding: 0 2px;
border-radius: 4px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogRow[data-selected='true'] {
.row:hover { background: var(--dsw-alias-interactive-bg-hover); }
/* Selected and hovered are different pictures: the selected row keeps the
active fill AND the design's 2px accent marker — a pseudo element inset 4px
(the tasks page's own recipe), so the row stays shadowless and no metric
shifts between rows. */
.row[data-selected='true'],
.opRow[data-selected='true'],
.logRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
.row[data-selected='true']::before,
.opRow[data-selected='true']::before,
.logRow[data-selected='true']::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--dsw-alias-state-business-primary);
}
.rowMain {
flex: 1;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
text-align: left;
}
.rowMain:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent); outline-offset: -1px; }
/* The row's icons are content at the plugin's own scale, not chrome. */
.rowIcon { flex: none; display: inline-flex; align-items: center; }
.rowName { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-primary); }
/* The row action is invisible until its row is hovered or holds focus (a
keyboard tab lands on it and reveals it). The three-class form outranks the
kit's own disabled rule, which would otherwise pin every button to a dim but
visible 0.4 whenever a git action is in flight. */
.row:not(:hover):not(:focus-within) .rowAction { opacity: 0; }
@media (hover: none) {
.row:not(:hover):not(:focus-within) .rowAction { opacity: 1; }
}
.gitLogRef {
/* ── Commit bar (docked to the bottom edge of the scroll area) ─────────── */
.commitBar {
position: sticky;
bottom: 0;
/* Above the sticky group headers: the commit bar is the surface the user
interacts with when they scroll to the bottom of the list. */
z-index: 3;
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-base);
}
.commitRow { display: flex; align-items: center; gap: 6px; }
.commitInput { flex: 1; min-width: 0; }
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
/* ── History ───────────────────────────────────────────────────────────── */
/* A history row: line 1 = short-hash badge + subject, line 2 = ref pills +
author·time flushed right (the badges live on the second line so they never
crowd the subject, and the time never pushes the refs around). */
.logRow {
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 10px;
margin: 0 6px;
border-radius: 4px;
cursor: pointer;
}
.logRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
.logLine1 { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.logHash {
flex: none;
padding: 1px 4px;
border-radius: 2px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-markdown-code-block-small);
}
.logLine2 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
.logRef { flex: none; padding: 0 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-brand-primary); white-space: nowrap; }
.logSubject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-primary); }
.logMeta { margin-left: auto; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
.gitLogMore {
.logMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
border-radius: 3px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
.logMore:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.logMore:disabled { opacity: 0.5; cursor: default; }
/* ── Session lens (this round's files) ─────────────────────────────────── */
.session { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 2px 8px 6px 12px; flex-wrap: wrap; }
.filterChip { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 1px 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); cursor: pointer; }
.filterChip:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
.filterChip[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); border-color: transparent; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 6px 12px; flex-wrap: wrap; }
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.loadError { margin: 4px 8px; padding: 6px 10px; border-left: 3px solid var(--dsw-alias-state-warn-primary); border-radius: 0 6px 6px 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.fileGroup { margin-bottom: 6px; }
/* The group label sits between op blocks: extra head padding separates it from
the previous file's rows, the first group starts flush. */
.filePath { display: block; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 6px 4px 2px; word-break: break-all; }
.fileGroup:first-child .filePath { padding-top: 2px; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow { position: relative; display: flex; align-items: center; gap: 6px; width: 100%; min-height: 24px; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 0 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
/* A failed op carries a quiet error inset bar (the flag chip names it). */
.opRow[data-op-error='true'] { box-shadow: inset 2px 0 0 var(--dsw-alias-state-error-primary); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* Size + relative time ride as one right-aligned cluster so the kind chips
/* Size + relative time ride as one right-aligned cluster so the kind badges
form a scannable left rail and the meta column stays flush right. */
.opMeta { margin-left: auto; display: inline-flex; align-items: baseline; gap: 8px; flex: none; min-width: 0; }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
@@ -342,10 +282,7 @@
/* ── Shared preview pane ───────────────────────────────────────────────── */
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 2px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
.htmlPane { flex: 1; min-height: 0; display: flex; }
.htmlFrame { flex: 1; width: 100%; border: none; background: var(--dsw-alias-bg-base); }
@@ -355,36 +292,18 @@
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after, .dragHandle:focus-visible::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffKind { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* The path keeps its tail (the file name is the identity; directories are
context): an RTL paragraph renders an ASCII path in reading order while
clipping its HEAD under the ellipsis. */
.diffPath { flex: 1; direction: rtl; font-family: var(--ds-font-family-code, monospace); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.iconButton:disabled { opacity: 0.4; cursor: default; }
.paneBody { flex: 1; min-height: 0; overflow-y: auto; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* An op's error body: the kit's error ink with the row's inset bar. */
.readError { border-left: 3px solid var(--dsw-alias-state-error-primary); margin: 6px 8px; padding: 6px 10px; word-break: break-word; }
/* The op preview whose callId left the loaded event window. */
.paneGone { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
/* ── Shared bits ───────────────────────────────────────────────────────── */
@@ -393,15 +312,13 @@
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention. */
.gitLink:focus-visible, .gitRowMain:focus-visible, .gitLogRow:focus-visible,
.gitCommitButton:focus-visible, .gitLogMore:focus-visible, .gitBranchSelect:focus-visible,
.lensButton:focus-visible, .filterChip:focus-visible, .iconButton:focus-visible,
.dragHandle:focus-visible {
/* Keyboard focus rings match the shared sidebar convention (the kit atoms and
the tree rows carry their own). */
.logRow:focus-visible, .logMore:focus-visible, .select:focus-visible, .dragHandle:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.gitRow { animation: none; }
.row { animation: none; }
}
+183
View File
@@ -0,0 +1,183 @@
/**
* The ONE loader behind every git diff surface: the changes tab's inline
* preview pane (`changes/DiffPane.tsx`) and the dedicated diff tab
* (`DiffTab.tsx`) used to carry byte-identical copies of three behaviors —
* the staged-side fallback (an empty requested side retries the other one),
* the untracked full-addition fallback (`git diff` never lists an untracked
* file, so its content is read through the media route), and the per-file
* fold-content cache the on-demand hunk expansion shares between sibling
* folds. They live here once so a fix in one surface cannot miss the other.
*
* The hook is inert (`loading:false`, no fetch) for a `null` ref, which is
* how the preview pane hosts non-git (session-op) targets.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import type { SidebarDiffRef } from '../state.ts'
import { resolveSidebarPath } from '../paths.ts'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts'
/** The loaded state of one git diff target. */
export interface GitDiffTarget {
/** A fetch is in flight (true only while nothing has loaded yet, or after a refresh drops the data). */
loading: boolean
error: string | null
/** The patch text; `''` = the file genuinely has no text change. */
diffText: string | null
/** Untracked-file content rendered as a full addition (undefined otherwise). */
untracked: string | undefined
/** Re-read the diff (and drop the fold cache) with the next tick. */
refresh(): void
/** The on-demand fold loader `DiffFiles` forwards to its rows (undefined without a ref). */
resolveFold: ((file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>) | undefined
}
/**
* Load one git diff target (worktree change or commit patch) and expose its
* on-demand fold expansion. `ref === null` keeps every value inert.
*/
export function useGitDiffTarget(ref: SidebarDiffRef | null, scope: SessionScope): GitDiffTarget {
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(ref !== null)
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
// Granular scope fields: the scope object's identity churns, only these
// three gate the target (the same rule the sibling call sites used).
const sessionId = scope.sessionId
const cwd = scope.cwd
const repoRoot = ref?.repoRoot
const gitScope = useMemo<SessionScope>(
() => ({ sessionId, cwd, ...(repoRoot !== undefined ? { repoRoot } : {}) }),
[sessionId, cwd, repoRoot],
)
useEffect(() => {
if (ref === null) return
let cancelled = false
const paneScope: SessionScope = { sessionId, cwd, ...(ref.repoRoot !== undefined ? { repoRoot: ref.repoRoot } : {}) }
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (ref.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, ref.hashFull, ref.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, ref.path, ref.staged, ref.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, ref.path, !ref.staged, ref.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!ref.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content. A child-repo path is
// relative to ref.repoRoot, not the session cwd or the linked-worktree
// root, so the read resolves against whichever the ref carries.
if (ref.untracked === true && !ref.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(ref.repoRoot ?? ref.worktree ?? cwd, ref.path))
if (!cancelled) {
setDiffText('')
if (text.kind === 'text') setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [ref, sessionId, cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ────────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [ref, tick])
const resolveFold = useMemo(() => {
if (ref === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (ref.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${ref.hashFull}^`, displayPath(file.oldPath), ref.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ref.hashFull, displayPath(file.newPath), ref.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? ref.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), ref.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), ref.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), ref.worktree),
api.fsRead(gitScope, resolveSidebarPath(ref.repoRoot ?? ref.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [ref, gitScope, effectiveStaged, cwd])
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
return { loading, error, diffText, untracked, refresh, resolveFold }
}
+10 -2
View File
@@ -37,8 +37,16 @@ import './layout.css'
* (rc.8+) is the client module system the chunk loader resolves its
* externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
* service access without inject. */
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection']
* service access without inject.
*
* `remote` / `remote.session` (0.1.7) are the Host Remote namespace the
* "open in app" adapter calls (`canOpenWorkspacePath` /
* `workspacePathApplications` / `openWorkspacePath`). BOTH entries are
* required: Cordis validates the nested property access
* (`ctx.get('remote').session`) against the inject list, and a missing
* entry throws `cannot get property "remote.session" without inject` —
* which, thrown from a render path, blanks the whole sidebar panel. */
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection', 'remote', 'remote.session']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
-1
View File
@@ -78,7 +78,6 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
#root [data-dsh-center-col] {
transition: none;
}
}
/* Context-menu submenu flip (viewport clamping for the portaled menus).
*
+30 -5
View File
@@ -58,6 +58,12 @@ export const ar: Record<string, string> = {
editorEmptyHint: 'اختر ملفاً من لوحة الشجرة أو من حقل المسار بالأعلى لبدء المعاينة',
openFileNewTab: 'فتح في تبويب جديد',
openFileSide: 'فتح إلى الجانب',
zipProgress: 'جارٍ الضغط {done}/{total}',
settingsOpenWithPluginTitle: 'إظهار خيارات الفتح الخاصة بالإضافة دائماً',
settingsOpenWithPluginDesc: 'افتراضياً تُعرض خيارات الفتح الخاصة بالإضافة فقط عندما لا يكتشف المضيف أي تطبيق مرتبط محلياً (مدير الملفات / VS Code / Cursor / Zed / محرّرات مخصّصة، SSH عن بُعد)؛ وبعد التفعيل تتواجد تطبيقات المضيف وأهداف الإضافة معاً',
zipDownload: 'ضغط وتنزيل',
zipDownloadCount: 'ضغط وتنزيل ({count} عناصر)',
zipFailed: 'فشل إنشاء الأرشيف: {message}',
openWithMenu: 'فتح بواسطة',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'تثبيت في القائمة',
@@ -78,6 +84,30 @@ export const ar: Record<string, string> = {
openWithSettingsFamilyDesc: 'يتحدّث هذا المحرّر لهجة URL لـ VSCode (يدعم الفتح عبر SSH البعيد)',
openWithSettingsRemove: 'إزالة',
openWithSettingsInvalidHint: 'لن تظهر المحرّرات التي تفتقر إلى الاسم أو القالب (يلزم أن يحتوي على {path} ويبدأ بـ scheme://) في القائمة',
openInAppDefault: 'فتح بالتطبيق الافتراضي',
openInAppEmpty: 'لا يوجد تطبيق يمكنه فتحه',
openInAppFailed: 'تعذّر الفتح: {path}',
revealInFileManager: 'إظهار في مدير الملفات',
filesSelected: 'تم تحديد {count}',
clearSelection: 'إلغاء التحديد',
copyPaths: 'نسخ المسارات',
deleteSelected: 'حذف المحدد',
deleteSelectedTitle: 'حذف {count} عنصراً محدداً؟',
deleteSelectedDesc: 'ستُحذف هذه الملفات والمجلدات نهائياً ولا يمكن استرجاعها.',
newFolder: 'مجلد جديد',
newFolderPlaceholder: 'اسم المجلد',
newFolderInvalid: 'اسم المجلد غير صالح',
filesTruncated: 'يحتوي هذا المجلد على عناصر كثيرة جداً؛ تُعرض العناصر الأولى فقط',
gitStatusModified: 'معدّل',
gitStatusAdded: 'مضاف',
gitStatusDeleted: 'محذوف',
gitStatusUntracked: 'غير متتبَّع',
gitStatusRenamed: 'أُعيدت تسميته',
gitStatusConflict: 'تعارض',
changesClean: 'لا توجد تغييرات في مساحة العمل',
changesNoHistory: 'لا يوجد سجل إيداعات بعد',
changesStageFailed: 'فشل الإدراج: {message}',
changesOpGone: 'لم تعد هذه العملية في سجل الجلسة',
newTab: 'تبويب جديد',
brokenSymlink: 'رابط رمزي معطوب',
close: 'إغلاق',
@@ -121,7 +151,6 @@ export const ar: Record<string, string> = {
error: 'فشل التحميل',
retry: 'إعادة المحاولة',
notRepo: 'هذا الدليل ليس مستودع git',
noChanges: 'لا تغييرات',
statusTruncated: 'التغييرات كثيرة جدًا؛ يتم عرض أول 2000 إدخال فقط',
stage: 'إدراج',
unstage: 'إلغاء الإدراج',
@@ -234,10 +263,6 @@ export const ar: Record<string, string> = {
settingsJobsDesc: 'تُفعَّل صفحة المهام كلما ظهرت مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق التفعيل)؛ في الشاشات العريضة تُوسَّع البطاقة الجانبية أيضاً، أما في الشاشات الضيقة فلا يُفرض فتح الدرج بملء الشاشة؛ عند إيقاف الخيار يصبح الفتح يدوياً',
settingsToolsTitle: 'حقن أدوات الطرفية للنموذج',
settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)',
settingsFenceTitle: 'فحص مسارات مساحة العمل',
settingsFenceDesc: 'عند التفعيل، تصل وظائف الملفات في الشريط الجانبي إلى مسارات داخل مساحة عمل الجلسة فقط (الافتراضي)؛ عند الإيقاف تصبح ملفات المضيف كلها متاحة — وتحصل سكربتات الصفحة على النفاذ نفسه طوال ذلك',
fenceErrorReason: 'هذا المسار خارج مساحة عمل الجلسة وقد حجبه الفحص',
fenceDisableAction: 'إيقاف فحص مساحة العمل',
settingsBottomTerminalTitle: 'فتح طرفية تلقائياً عند أول توسيع للوحة السفلية',
settingsBottomTerminalDesc: 'عند توسيع اللوحة السفلية لأول مرة في الجلسة، حاول فتح تبويب طرفية جديد هناك (حصة الطرفيات ما زالت تسري؛ مفعّل افتراضياً)',
settingsFontFamilyTitle: 'عائلة خط الطرفية',
+30 -5
View File
@@ -43,6 +43,12 @@ export const de: Record<string, string> = {
editorEmptyHint: 'Wählen Sie eine Datei aus dem Dateibaum rechts oder dem Pfadeingabefeld oben, um mit der Vorschau zu beginnen',
openFileNewTab: 'In neuem Tab öffnen',
openFileSide: 'Seitlich öffnen',
zipProgress: 'Archivierung läuft {done}/{total}',
settingsOpenWithPluginTitle: 'Plugin-eigene „Öffnen mit“-Ziele immer anzeigen',
settingsOpenWithPluginDesc: 'Standardmäßig werden die plugin-eigenen „Öffnen mit“-Ziele nur gezeigt, wenn der Host keine lokal verknüpfte Anwendung erkennt (Dateimanager / VS Code / Cursor / Zed / benutzerdefinierte Editoren, SSH-Remote); nach dem Aktivieren stehen Host-Apps und Plugin-Ziele nebeneinander',
zipDownload: 'Zippen und herunterladen',
zipDownloadCount: 'Zippen und herunterladen ({count} Elemente)',
zipFailed: 'Archivierung fehlgeschlagen: {message}',
openWithMenu: 'Öffnen mit',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Im Menü anheften',
@@ -63,6 +69,30 @@ export const de: Record<string, string> = {
openWithSettingsFamilyDesc: 'Dieser Editor spricht das VSCode-URL-Protokoll (unterstützt SSH-Remote-Öffnen)',
openWithSettingsRemove: 'Entfernen',
openWithSettingsInvalidHint: 'Editoren ohne Namen oder mit Vorlage ohne {path} / scheme:// erscheinen nicht im Menü',
openInAppDefault: 'Mit Standard-App öffnen',
openInAppEmpty: 'Keine App kann diese Datei öffnen',
openInAppFailed: 'Öffnen fehlgeschlagen: {path}',
revealInFileManager: 'Im Dateimanager anzeigen',
filesSelected: '{count} ausgewählt',
clearSelection: 'Auswahl aufheben',
copyPaths: 'Pfade kopieren',
deleteSelected: 'Auswahl löschen',
deleteSelectedTitle: '{count} ausgewählte Elemente löschen?',
deleteSelectedDesc: 'Diese Dateien und Ordner werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.',
newFolder: 'Neuer Ordner',
newFolderPlaceholder: 'Ordnername',
newFolderInvalid: 'Ungültiger Ordnername',
filesTruncated: 'Dieser Ordner enthält zu viele Einträge; nur die ersten werden angezeigt',
gitStatusModified: 'Geändert',
gitStatusAdded: 'Hinzugefügt',
gitStatusDeleted: 'Gelöscht',
gitStatusUntracked: 'Nicht verfolgt',
gitStatusRenamed: 'Umbenannt',
gitStatusConflict: 'Konflikt',
changesClean: 'Keine Änderungen im Arbeitsbereich',
changesNoHistory: 'Noch keine Commit-Historie',
changesStageFailed: 'Staging fehlgeschlagen: {message}',
changesOpGone: 'Dieser Vorgang ist nicht mehr im Sitzungsprotokoll',
newTab: 'Neuer Tab',
brokenSymlink: 'Defekter Symlink',
close: 'Schließen',
@@ -106,7 +136,6 @@ export const de: Record<string, string> = {
error: 'Laden fehlgeschlagen',
retry: 'Erneut versuchen',
notRepo: 'Dieses Verzeichnis ist kein Git-Repository',
noChanges: 'Keine Änderungen',
statusTruncated: 'Zu viele Änderungen; nur die ersten 2000 Einträge werden angezeigt',
stage: 'Stagen',
unstage: 'Unstagen',
@@ -219,10 +248,6 @@ export const de: Record<string, string> = {
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',
+30 -5
View File
@@ -50,6 +50,12 @@ export const fr: Record<string, string> = {
editorEmptyHint: 'Choisissez un fichier dans l’arborescence à droite ou dans le champ de chemin ci-dessus pour commencer l’aperçu',
openFileNewTab: 'Ouvrir dans un nouvel onglet',
openFileSide: 'Ouvrir de côté',
zipProgress: 'Archivage {done}/{total}',
settingsOpenWithPluginTitle: 'Toujours afficher les cibles « Ouvrir avec » du plugin',
settingsOpenWithPluginDesc: 'Par défaut, les cibles « Ouvrir avec » du plugin ne s’affichent que si l’hôte ne détecte aucune application associée localement (Explorateur de fichiers / VS Code / Cursor / Zed / éditeurs personnalisés, SSH distant) ; une fois activé, les applications de l’hôte et les cibles du plugin coexistent',
zipDownload: 'Compresser et télécharger',
zipDownloadCount: 'Compresser et télécharger ({count} éléments)',
zipFailed: 'Échec de l’archivage : {message}',
openWithMenu: 'Ouvrir avec',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Épingler au menu',
@@ -70,6 +76,30 @@ export const fr: Record<string, string> = {
openWithSettingsFamilyDesc: 'Cet éditeur utilise le protocole d’URL de VSCode (prend en charge l’ouverture à distance via SSH)',
openWithSettingsRemove: 'Supprimer',
openWithSettingsInvalidHint: 'Les éditeurs sans nom ou sans modèle (contenant {path} et commençant par scheme://) n’apparaissent pas dans le menu',
openInAppDefault: 'Ouvrir avec l’application par défaut',
openInAppEmpty: 'Aucune application ne peut l’ouvrir',
openInAppFailed: 'Impossible d’ouvrir : {path}',
revealInFileManager: 'Afficher dans le gestionnaire de fichiers',
filesSelected: '{count} élément(s) sélectionné(s)',
clearSelection: 'Effacer la sélection',
copyPaths: 'Copier les chemins',
deleteSelected: 'Supprimer la sélection',
deleteSelectedTitle: 'Supprimer {count} élément(s) sélectionné(s) ?',
deleteSelectedDesc: 'Ces fichiers et dossiers seront définitivement supprimés. Cette action est irréversible.',
newFolder: 'Nouveau dossier',
newFolderPlaceholder: 'Nom du dossier',
newFolderInvalid: 'Nom de dossier invalide',
filesTruncated: 'Ce dossier contient trop d’éléments ; seuls les premiers sont affichés',
gitStatusModified: 'Modifié',
gitStatusAdded: 'Ajouté',
gitStatusDeleted: 'Supprimé',
gitStatusUntracked: 'Non suivi',
gitStatusRenamed: 'Renommé',
gitStatusConflict: 'En conflit',
changesClean: 'Aucune modification dans le workspace',
changesNoHistory: 'Aucun commit pour l’instant',
changesStageFailed: 'Échec de l’indexation : {message}',
changesOpGone: 'Cette opération n’est plus dans le journal de la session',
newTab: 'Nouvel onglet',
brokenSymlink: 'Lien symbolique cassé',
close: 'Fermer',
@@ -113,7 +143,6 @@ export const fr: Record<string, string> = {
error: 'Échec du chargement',
retry: 'Réessayer',
notRepo: 'Ce répertoire n’est pas un dépôt git',
noChanges: 'Aucune modification',
statusTruncated: 'Trop de modifications ; seules les 2000 premières entrées sont affichées',
stage: 'Mettre en scène',
unstage: 'Annuler la mise en scène',
@@ -226,10 +255,6 @@ export const fr: Record<string, string> = {
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',
+30 -5
View File
@@ -57,6 +57,12 @@ export const hi: Record<string, string> = {
editorEmptyHint: 'पूर्वावलोकन शुरू करने के लिए दाएँ फ़ाइल ट्री या ऊपर पथ इनपुट से फ़ाइल चुनें',
openFileNewTab: 'नए टैब में खोलें',
openFileSide: 'बाजू में खोलें',
zipProgress: 'संग्रह बनाया जा रहा है {done}/{total}',
settingsOpenWithPluginTitle: 'प्लगइन के अपने खोलने के विकल्प हमेशा दिखाएँ',
settingsOpenWithPluginDesc: 'डिफ़ॉल्ट रूप से प्लगइन के अपने खोलने के विकल्प तभी दिखते हैं जब होस्ट को कोई स्थानीय संबद्ध ऐप न मिले (फ़ाइल मैनेजर / VS Code / Cursor / Zed / कस्टम एडिटर, SSH रिमोट); चालू करने पर होस्ट ऐप और प्लगइन लक्ष्य साथ-साथ रहते हैं',
zipDownload: 'ज़िप कर डाउनलोड करें',
zipDownloadCount: 'ज़िप कर डाउनलोड करें ({count} आइटम)',
zipFailed: 'संग्रह बनाना विफल: {message}',
openWithMenu: 'इसके साथ खोलें',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'मेन्यू पर पिन करें',
@@ -77,6 +83,30 @@ export const hi: Record<string, string> = {
openWithSettingsFamilyDesc: 'यह एडिटर VSCode URL बोली बोलता है (SSH-रिमोट खोलने का समर्थन)',
openWithSettingsRemove: 'हटाएं',
openWithSettingsInvalidHint: 'नाम या {path} / scheme:// के बिना टेम्पलेट वाले एडिटर मेन्यू में नहीं दिखते',
openInAppDefault: 'डिफ़ॉल्ट ऐप से खोलें',
openInAppEmpty: 'इसे खोलने के लिए कोई ऐप नहीं',
openInAppFailed: 'खोल नहीं सका: {path}',
revealInFileManager: 'फ़ाइल मैनेजर में दिखाएँ',
filesSelected: '{count} चयनित',
clearSelection: 'चयन हटाएँ',
copyPaths: 'पथ कॉपी करें',
deleteSelected: 'चयनित हटाएँ',
deleteSelectedTitle: 'चयनित {count} आइटम हटाएँ?',
deleteSelectedDesc: 'ये फ़ाइलें और फ़ोल्डर हमेशा के लिए हटा दिए जाएँगे; इन्हें वापस नहीं लाया जा सकता।',
newFolder: 'नया फ़ोल्डर',
newFolderPlaceholder: 'फ़ोल्डर नाम',
newFolderInvalid: 'फ़ोल्डर नाम अमान्य है',
filesTruncated: 'इस फ़ोल्डर में बहुत अधिक प्रविष्टियाँ हैं; केवल शुरुआती कुछ दिखाई जा रही हैं',
gitStatusModified: 'संशोधित',
gitStatusAdded: 'जोड़ा गया',
gitStatusDeleted: 'हटाया गया',
gitStatusUntracked: 'अनट्रैक्ड',
gitStatusRenamed: 'नाम बदला गया',
gitStatusConflict: 'टकराव',
changesClean: 'वर्कस्पेस में कोई बदलाव नहीं',
changesNoHistory: 'अभी कोई कमिट इतिहास नहीं',
changesStageFailed: 'स्टेज विफल: {message}',
changesOpGone: 'यह कार्रवाई अब सत्र लॉग में नहीं है',
newTab: 'नया टैब',
brokenSymlink: 'टूटा सिमलिंक',
close: 'बंद करें',
@@ -120,7 +150,6 @@ export const hi: Record<string, string> = {
error: 'लोड विफल',
retry: 'पुनः प्रयास',
notRepo: 'यह डायरेक्टरी एक git रिपॉज़िटरी नहीं है',
noChanges: 'कोई बदलाव नहीं',
statusTruncated: 'बहुत अधिक बदलाव; केवल पहली 2000 प्रविष्टियाँ दिखाई जा रही हैं',
stage: 'स्टेज',
unstage: 'अनस्टेज',
@@ -233,10 +262,6 @@ export const hi: Record<string, string> = {
settingsJobsDesc: 'वर्तमान वार्तालाप में हर नया बैकग्राउंड कार्य आने पर कार्य पृष्ठ सक्रिय करें (हर नया कार्य ट्रिगर करता है); चौड़ी स्क्रीन पर साइड कार्ड भी विस्तृत होता है, जबकि संकीर्ण स्क्रीन पर पूर्ण-स्क्रीन दराज को जबरन नहीं खोला जाता; विकल्प बंद होने पर इसे मैन्युअल रूप से खोलना होगा',
settingsToolsTitle: 'मॉडल के लिए टर्मिनल टूल इंजेक्ट करें',
settingsToolsDesc: 'सक्षम होने पर, मॉडल 8 terminal_* टूल्स से साइडबार टर्मिनल बना और चला सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsFenceTitle: 'वर्कस्पेस पाथ जाँच',
settingsFenceDesc: 'चालू होने पर साइडबार की फ़ाइल सुविधाएँ केवल सेशन वर्कस्पेस के भीतर के पाथ तक पहुँचती हैं (डिफ़ॉल्ट); बंद करने पर होस्ट की कोई भी फ़ाइल खोली जा सकती है — उस दौरान पेज के स्क्रिप्ट को भी वही पहुँच मिल जाती है',
fenceErrorReason: 'यह पाथ सेशन वर्कस्पेस के बाहर है और जाँच द्वारा अवरुद्ध किया गया',
fenceDisableAction: 'वर्कस्पेस जाँच बंद करें',
settingsBottomTerminalTitle: 'निचला पैनल पहली बार खुलने पर स्वतः टर्मिनल खोलें',
settingsBottomTerminalDesc: 'सत्र में निचला पैनल पहली बार विस्तृत होने पर वहाँ नया टर्मिनल टैब खोलने का प्रयास (टर्मिनल कोटा लागू; डिफ़ॉल्ट रूप से चालू)',
settingsFontFamilyTitle: 'टर्मिनल फ़ॉन्ट परिवार',
+30 -5
View File
@@ -55,6 +55,12 @@ export const id: Record<string, string> = {
editorEmptyHint: 'Pilih berkas dari panel pohon atau input path di atas untuk mulai pratinjau',
openFileNewTab: 'Buka di Tab Baru',
openFileSide: 'Buka di Samping',
zipProgress: 'Mengarsipkan {done}/{total}',
settingsOpenWithPluginTitle: 'Selalu tampilkan opsi buka milik plugin',
settingsOpenWithPluginDesc: 'Secara default opsi buka milik plugin hanya muncul saat host tidak mendeteksi aplikasi terkait di perangkat ini (File Manager / VS Code / Cursor / Zed / editor kustom, SSH jarak jauh); setelah diaktifkan, aplikasi host dan target plugin tampil berdampingan',
zipDownload: 'Zip dan unduh',
zipDownloadCount: 'Zip dan unduh ({count} item)',
zipFailed: 'Gagal membuat arsip: {message}',
openWithMenu: 'Buka dengan',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Sematkan ke menu',
@@ -75,6 +81,30 @@ export const id: Record<string, string> = {
openWithSettingsFamilyDesc: 'Editor ini menggunakan dialek URL VSCode (mendukung pembukaan remote SSH)',
openWithSettingsRemove: 'Hapus',
openWithSettingsInvalidHint: 'Editor dengan nama hilang atau template tanpa {path} / scheme:// tidak ditampilkan di menu',
openInAppDefault: 'Buka dengan aplikasi default',
openInAppEmpty: 'Tidak ada aplikasi yang bisa membukanya',
openInAppFailed: 'Gagal membuka: {path}',
revealInFileManager: 'Tampilkan di pengelola berkas',
filesSelected: '{count} dipilih',
clearSelection: 'Hapus pilihan',
copyPaths: 'Salin path',
deleteSelected: 'Hapus yang dipilih',
deleteSelectedTitle: 'Hapus {count} item yang dipilih?',
deleteSelectedDesc: 'Berkas dan folder ini akan dihapus permanen dan tidak bisa dikembalikan.',
newFolder: 'Folder baru',
newFolderPlaceholder: 'Nama folder',
newFolderInvalid: 'Nama folder tidak valid',
filesTruncated: 'Folder ini berisi terlalu banyak entri; hanya sebagian awal yang ditampilkan',
gitStatusModified: 'Dimodifikasi',
gitStatusAdded: 'Ditambahkan',
gitStatusDeleted: 'Dihapus',
gitStatusUntracked: 'Tidak dilacak',
gitStatusRenamed: 'Nama diubah',
gitStatusConflict: 'Konflik',
changesClean: 'Tidak ada perubahan di workspace',
changesNoHistory: 'Belum ada riwayat commit',
changesStageFailed: 'Gagal melakukan stage: {message}',
changesOpGone: 'Operasi ini tidak lagi ada di log sesi',
newTab: 'Tab baru',
brokenSymlink: 'Symlink rusak',
close: 'Tutup',
@@ -118,7 +148,6 @@ export const id: Record<string, string> = {
error: 'Gagal memuat',
retry: 'Coba lagi',
notRepo: 'Direktori ini bukan repositori git',
noChanges: 'Tidak ada perubahan',
statusTruncated: 'Terlalu banyak perubahan; hanya 2000 entri pertama yang ditampilkan',
stage: 'Stage',
unstage: 'Unstage',
@@ -231,10 +260,6 @@ export const id: Record<string, string> = {
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',
+30 -5
View File
@@ -48,6 +48,12 @@ export const it: Record<string, string> = {
editorEmptyHint: 'Scelga un file dal pannello dell’albero a destra o dal campo del percorso in alto per iniziare l’anteprima',
openFileNewTab: 'Apri in una nuova scheda',
openFileSide: 'Apri a lato',
zipProgress: 'Archiviazione {done}/{total}',
settingsOpenWithPluginTitle: 'Mostra sempre le destinazioni «Apri con» del plugin',
settingsOpenWithPluginDesc: 'Per impostazione predefinita le destinazioni «Apri con» del plugin compaiono solo quando l’host non rileva applicazioni associate in locale (Esplora risorse / VS Code / Cursor / Zed / editor personalizzati, SSH remoto); una volta attivata, le app dell’host e le destinazioni del plugin convivono',
zipDownload: 'Comprimi e scarica',
zipDownloadCount: 'Comprimi e scarica ({count} elementi)',
zipFailed: 'Creazione archivio non riuscita: {message}',
openWithMenu: 'Apri con',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Fissa al menu',
@@ -68,6 +74,30 @@ export const it: Record<string, string> = {
openWithSettingsFamilyDesc: 'Questo editor usa il protocollo URL di VSCode (supporta aperture SSH remote)',
openWithSettingsRemove: 'Rimuovi',
openWithSettingsInvalidHint: 'Gli editor con nome mancante o modello senza {path} / scheme:// non vengono mostrati nel menu',
openInAppDefault: 'Apri con l’app predefinita',
openInAppEmpty: 'Nessuna applicazione può aprirlo',
openInAppFailed: 'Impossibile aprire: {path}',
revealInFileManager: 'Mostra nel gestore file',
filesSelected: '{count} selezionati',
clearSelection: 'Annulla selezione',
copyPaths: 'Copia percorsi',
deleteSelected: 'Elimina selezione',
deleteSelectedTitle: 'Eliminare {count} elementi selezionati?',
deleteSelectedDesc: 'Questi file e cartelle saranno eliminati definitivamente. L’operazione non può essere annullata.',
newFolder: 'Nuova cartella',
newFolderPlaceholder: 'Nome cartella',
newFolderInvalid: 'Nome cartella non valido',
filesTruncated: 'Questa cartella contiene troppe voci; vengono mostrate solo le prime',
gitStatusModified: 'Modificato',
gitStatusAdded: 'Aggiunto',
gitStatusDeleted: 'Eliminato',
gitStatusUntracked: 'Non tracciato',
gitStatusRenamed: 'Rinominato',
gitStatusConflict: 'In conflitto',
changesClean: 'Nessuna modifica nel workspace',
changesNoHistory: 'Nessun commit per ora',
changesStageFailed: 'Staging non riuscito: {message}',
changesOpGone: 'Questa operazione non è più nel registro della sessione',
newTab: 'Nuova scheda',
brokenSymlink: 'Collegamento simbolico interrotto',
close: 'Chiudi',
@@ -111,7 +141,6 @@ export const it: Record<string, string> = {
error: 'Caricamento non riuscito',
retry: 'Riprova',
notRepo: 'Questa directory non è un repository git',
noChanges: 'Nessuna modifica',
statusTruncated: 'Troppe modifiche; vengono mostrate solo le prime 2000 voci',
stage: 'Prepara',
unstage: 'Rimuovi dalla preparazione',
@@ -224,10 +253,6 @@ export const it: Record<string, string> = {
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',
+30 -5
View File
@@ -57,6 +57,12 @@ export const ja: Record<string, string> = {
editorEmptyHint: '右のファイルツリーまたは上のパス入力欄からファイルを選んでプレビュー',
openFileNewTab: '新しいタブで開く',
openFileSide: '横に開く',
zipProgress: '圧縮中 {done}/{total}',
settingsOpenWithPluginTitle: 'プラグイン独自の「アプリで開く」を常に表示',
settingsOpenWithPluginDesc: '既定では、ホストがローカルの関連アプリ(エクスプローラー / VS Code / Cursor / Zed / カスタムエディター、SSH リモート)を検出できない場合にのみ、プラグイン独自の「アプリで開く」を表示します。オンにするとホストのアプリとプラグインのターゲットが併存します',
zipDownload: '圧縮してダウンロード',
zipDownloadCount: '圧縮してダウンロード({count} 件)',
zipFailed: 'アーカイブの作成に失敗しました:{message}',
openWithMenu: 'アプリで開く',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'メニューに固定',
@@ -77,6 +83,30 @@ export const ja: Record<string, string> = {
openWithSettingsFamilyDesc: 'このエディターは VSCode の URL プロトコルを使います(SSH リモートオープンをサポート)',
openWithSettingsRemove: '削除',
openWithSettingsInvalidHint: '名前またはテンプレート({path} を含み scheme:// で始まる必要あり)が未入力の編集者はメニューに表示されません',
openInAppDefault: '既定のアプリで開く',
openInAppEmpty: '開けるアプリがありません',
openInAppFailed: '開けませんでした:{path}',
revealInFileManager: 'ファイルマネージャーで表示',
filesSelected: '{count} 件を選択中',
clearSelection: '選択を解除',
copyPaths: 'パスをコピー',
deleteSelected: '選択項目を削除',
deleteSelectedTitle: '選択した {count} 件を削除しますか?',
deleteSelectedDesc: 'これらのファイルとフォルダーは完全に削除され、元に戻せません。',
newFolder: '新しいフォルダー',
newFolderPlaceholder: 'フォルダー名',
newFolderInvalid: 'フォルダー名が無効です',
filesTruncated: 'このフォルダーの項目が多すぎるため、先頭の一部のみ表示しています',
gitStatusModified: '変更あり',
gitStatusAdded: '追加',
gitStatusDeleted: '削除',
gitStatusUntracked: '未追跡',
gitStatusRenamed: '名前変更',
gitStatusConflict: '競合',
changesClean: 'ワークスペースに変更はありません',
changesNoHistory: 'コミット履歴がまだありません',
changesStageFailed: 'ステージに失敗しました:{message}',
changesOpGone: 'この操作はセッションログに残っていません',
newTab: '新しいタブ',
brokenSymlink: '無効なシンボリックリンク',
close: '閉じる',
@@ -120,7 +150,6 @@ export const ja: Record<string, string> = {
error: '読み込みに失敗',
retry: '再試行',
notRepo: 'このディレクトリは git リポジトリではありません',
noChanges: '変更はありません',
statusTruncated: '変更が多すぎるため、最初の 2000 件のみ表示しています',
stage: 'ステージ',
unstage: 'ステージ解除',
@@ -233,10 +262,6 @@ export const ja: Record<string, string> = {
settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにタスク管理ページをアクティブ化する(新規タスクごとに発火)。広い画面ではサイドカードも展開するが、狭い画面では全画面ドロワーを強制的に開かない。オフにすると手動で開く必要がある',
settingsToolsTitle: 'モデルにターミナルツールを注入',
settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)',
settingsFenceTitle: 'ワークスペースパスガード',
settingsFenceDesc: 'オンの場合、サイドバーのファイル機能はセッションワークスペース内のパスのみアクセス可能(デフォルト)。オフにするとホスト上の任意のファイルにアクセスできるが、オフの間はページ内スクリプトも同じ権限を得る',
fenceErrorReason: 'このパスはセッションワークスペースの外にあり、ワークスペースガードによりブロックされました',
fenceDisableAction: 'ワークスペースガードをオフにする',
settingsBottomTerminalTitle: '下パネル初回展開時に自動でターミナルを開く',
settingsBottomTerminalDesc: '会話ごとに下パネルを初めて展開した際、下パネルに新しいターミナルタブを自動で開く(ターミナル数上限は依然適用。デフォルトオン)',
settingsFontFamilyTitle: 'ターミナルフォント',
+30 -5
View File
@@ -49,6 +49,12 @@ export const ko: Record<string, string> = {
editorEmptyHint: '오른쪽 파일 트리나 위쪽 경로 입력란에서 파일을 선택해 미리보기를 시작하세요',
openFileNewTab: '새 Tab으로 열기',
openFileSide: '측면에 열기',
zipProgress: '압축 중 {done}/{total}',
settingsOpenWithPluginTitle: '플러그인 자체 "앱에서 열기" 항상 표시',
settingsOpenWithPluginDesc: '기본적으로 플러그인 자체의 "앱에서 열기"는 호스트가 로컬 연결 앱(파일 탐색기 / VS Code / Cursor / Zed / 사용자 지정 편집기, SSH 원격)을 감지하지 못할 때만 표시됩니다. 켜면 호스트 앱과 플러그인 대상이 함께 표시됩니다',
zipDownload: '압축하여 다운로드',
zipDownloadCount: '압축하여 다운로드({count}개 항목)',
zipFailed: '압축 파일 생성 실패: {message}',
openWithMenu: '앱에서 열기',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '메뉴에 고정',
@@ -69,6 +75,30 @@ export const ko: Record<string, string> = {
openWithSettingsFamilyDesc: '이 편집기는 VSCode의 URL 프로토콜을 사용합니다(SSH 원격 열기 지원)',
openWithSettingsRemove: '삭제',
openWithSettingsInvalidHint: '이름이나 템플릿({path} 포함, scheme:// 로 시작해야 함)을 입력하지 않은 편집기는 메뉴에 표시되지 않습니다',
openInAppDefault: '기본 앱으로 열기',
openInAppEmpty: '이 파일을 열 수 있는 앱이 없습니다',
openInAppFailed: '열 수 없습니다: {path}',
revealInFileManager: '파일 관리자에서 표시',
filesSelected: '{count}개 선택됨',
clearSelection: '선택 해제',
copyPaths: '경로 복사',
deleteSelected: '선택 항목 삭제',
deleteSelectedTitle: '선택한 {count}개 항목을 삭제할까요?',
deleteSelectedDesc: '이 파일과 폴더는 영구히 삭제되며 되돌릴 수 없습니다.',
newFolder: '새 폴더',
newFolderPlaceholder: '폴더 이름',
newFolderInvalid: '폴더 이름이 올바르지 않습니다',
filesTruncated: '이 폴더에 항목이 너무 많아 앞부분만 표시합니다',
gitStatusModified: '수정됨',
gitStatusAdded: '추가됨',
gitStatusDeleted: '삭제됨',
gitStatusUntracked: '추적 안 됨',
gitStatusRenamed: '이름 변경됨',
gitStatusConflict: '충돌',
changesClean: '워크스페이스에 변경 사항이 없습니다',
changesNoHistory: '아직 커밋 기록이 없습니다',
changesStageFailed: '스테이징 실패: {message}',
changesOpGone: '이 작업은 더 이상 세션 로그에 없습니다',
newTab: '새 탭',
brokenSymlink: '깨진 심볼릭 링크',
close: '닫기',
@@ -112,7 +142,6 @@ export const ko: Record<string, string> = {
error: '불러오기 실패',
retry: '다시 시도',
notRepo: '현재 디렉터리는 git 저장소가 아닙니다',
noChanges: '변경 사항 없음',
statusTruncated: '변경 사항이 너무 많아 처음 2000개만 표시합니다',
stage: '스테이징',
unstage: '스테이징 해제',
@@ -225,10 +254,6 @@ export const ko: Record<string, string> = {
settingsJobsDesc: '현재 대화에 새 백그라운드 작업이 생길 때마다 작업 관리 페이지를 활성화합니다(새 작업마다 트리거됨). 넓은 화면에서는 사이드 카드도 펼치지만, 좁은 화면에서는 전체 화면 드로어를 강제로 열지 않습니다. 끄면 수동으로 열어야 합니다',
settingsToolsTitle: '모델에 터미널 도구 주입',
settingsToolsDesc: '켜면 모델이 terminal_create 등 8개 도구로 사이드바 터미널을 만들고 조작할 수 있습니다(기본 꺼짐)',
settingsFenceTitle: '워크스페이스 경로 검사',
settingsFenceDesc: '켜면 사이드바의 파일 기능이 세션 워크스페이스 내 경로만 접근합니다(기본값). 끄면 호스트의 모든 파일에 접근할 수 있으며, 꺼져 있는 동안 페이지 내 스크립트도 동일한 권한을 얻습니다',
fenceErrorReason: '이 경로는 세션 워크스페이스 밖에 있어 검사에 의해 차단되었습니다',
fenceDisableAction: '워크스페이스 검사 끄기',
settingsBottomTerminalTitle: '하단 패널을 처음 펼칠 때 터미널 자동 열기',
settingsBottomTerminalDesc: '세션에서 하단 패널을 처음 펼칠 때 하단 패널에 새 터미널 탭을 자동으로 열려고 시도합니다(터미널 개수 상한은 여전히 적용됩니다. 기본 켜짐)',
settingsFontFamilyTitle: '터미널 글꼴',
+30 -5
View File
@@ -55,6 +55,12 @@ export const nl: Record<string, string> = {
editorEmptyHint: 'Kies een bestand uit het boompaneel of het pad-invoerveld hierboven om te beginnen met previewen',
openFileNewTab: 'Openen in nieuw tabblad',
openFileSide: 'Openen aan de zijkant',
zipProgress: 'Archiveren {done}/{total}',
settingsOpenWithPluginTitle: 'Eigen "Openen met"-doelen van de plugin altijd tonen',
settingsOpenWithPluginDesc: 'Standaard worden de eigen "Openen met"-doelen van de plugin alleen getoond als de host geen lokaal gekoppelde app detecteert (Bestandsbeheerder / VS Code / Cursor / Zed / aangepaste editors, SSH op afstand); na inschakelen bestaan host-apps en plugin-doelen naast elkaar',
zipDownload: 'Zippen en downloaden',
zipDownloadCount: 'Zippen en downloaden ({count} items)',
zipFailed: 'Archiveren mislukt: {message}',
openWithMenu: 'Openen met',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Vastmaken aan menu',
@@ -75,6 +81,30 @@ export const nl: Record<string, string> = {
openWithSettingsFamilyDesc: 'Deze editor spreekt het VSCode URL-dialect (ondersteunt SSH-externe openingen)',
openWithSettingsRemove: 'Verwijderen',
openWithSettingsInvalidHint: 'Editors met een ontbrekende naam of een sjabloon zonder {path} / scheme:// worden niet in het menu getoond',
openInAppDefault: 'Openen met standaardapp',
openInAppEmpty: 'Geen app kan dit openen',
openInAppFailed: 'Kan niet openen: {path}',
revealInFileManager: 'Tonen in bestandsbeheerder',
filesSelected: '{count} geselecteerd',
clearSelection: 'Selectie wissen',
copyPaths: 'Paden kopiëren',
deleteSelected: 'Selectie verwijderen',
deleteSelectedTitle: '{count} geselecteerde items verwijderen?',
deleteSelectedDesc: 'Deze bestanden en mappen worden permanent verwijderd. Dit kan niet ongedaan worden gemaakt.',
newFolder: 'Nieuwe map',
newFolderPlaceholder: 'Mapnaam',
newFolderInvalid: 'Ongeldige mapnaam',
filesTruncated: 'Deze map bevat te veel items; alleen de eerste worden getoond',
gitStatusModified: 'Gewijzigd',
gitStatusAdded: 'Toegevoegd',
gitStatusDeleted: 'Verwijderd',
gitStatusUntracked: 'Niet gevolgd',
gitStatusRenamed: 'Hernoemd',
gitStatusConflict: 'Conflict',
changesClean: 'Geen wijzigingen in de workspace',
changesNoHistory: 'Nog geen commitgeschiedenis',
changesStageFailed: 'Stagen mislukt: {message}',
changesOpGone: 'Deze bewerking staat niet meer in het sessielogboek',
newTab: 'Nieuw tabblad',
brokenSymlink: 'Gebroken symlink',
close: 'Sluiten',
@@ -118,7 +148,6 @@ export const nl: Record<string, string> = {
error: 'Laden mislukt',
retry: 'Opnieuw proberen',
notRepo: 'Deze map is geen git-repository',
noChanges: 'Geen wijzigingen',
statusTruncated: 'Te veel wijzigingen; alleen de eerste 2000 items worden weergegeven',
stage: 'Stagen',
unstage: 'Unstagen',
@@ -231,10 +260,6 @@ export const nl: Record<string, string> = {
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',
+30 -5
View File
@@ -59,6 +59,12 @@ export const pl: Record<string, string> = {
editorEmptyHint: 'Wybierz plik z panelu drzewa lub pola ścieżki powyżej, aby rozpocząć podgląd',
openFileNewTab: 'Otwórz w nowej karcie',
openFileSide: 'Otwórz z boku',
zipProgress: 'Archiwizowanie {done}/{total}',
settingsOpenWithPluginTitle: 'Zawsze pokazuj własne opcje „Otwórz za pomocą” wtyczki',
settingsOpenWithPluginDesc: 'Domyślnie własne opcje „Otwórz za pomocą” wtyczki są pokazywane tylko wtedy, gdy host nie wykryje lokalnie skojarzonej aplikacji (Menedżer plików / VS Code / Cursor / Zed / niestandardowe edytory, SSH zdalny); po włączeniu aplikacje hosta i cele wtyczki współistnieją',
zipDownload: 'Spakuj i pobierz',
zipDownloadCount: 'Spakuj i pobierz ({count} elementów)',
zipFailed: 'Tworzenie archiwum nie powiodło się: {message}',
openWithMenu: 'Otwórz za pomocą',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Przypnij do menu',
@@ -79,6 +85,30 @@ export const pl: Record<string, string> = {
openWithSettingsFamilyDesc: 'Ten edytor używa dialektu URL VSCode (obsługuje otwieranie zdalne przez SSH)',
openWithSettingsRemove: 'Usuń',
openWithSettingsInvalidHint: 'Edytory z brakującą nazwą lub szablonem bez {path} / scheme:// nie są pokazywane w menu',
openInAppDefault: 'Otwórz w domyślnej aplikacji',
openInAppEmpty: 'Brak aplikacji, która może to otworzyć',
openInAppFailed: 'Nie udało się otworzyć: {path}',
revealInFileManager: 'Pokaż w menedżerze plików',
filesSelected: 'Wybrano: {count}',
clearSelection: 'Wyczyść wybór',
copyPaths: 'Kopiuj ścieżki',
deleteSelected: 'Usuń wybrane',
deleteSelectedTitle: 'Usunąć {count} wybranych elementów?',
deleteSelectedDesc: 'Te pliki i foldery zostaną trwale usunięte. Tej operacji nie można cofnąć.',
newFolder: 'Nowy folder',
newFolderPlaceholder: 'Nazwa folderu',
newFolderInvalid: 'Nieprawidłowa nazwa folderu',
filesTruncated: 'Ten folder zawiera zbyt wiele pozycji; wyświetlane są tylko pierwsze',
gitStatusModified: 'Zmodyfikowany',
gitStatusAdded: 'Dodany',
gitStatusDeleted: 'Usunięty',
gitStatusUntracked: 'Nieśledzony',
gitStatusRenamed: 'Zmieniono nazwę',
gitStatusConflict: 'Konflikt',
changesClean: 'Brak zmian w obszarze roboczym',
changesNoHistory: 'Brak historii commitów',
changesStageFailed: 'Nie udało się wykonać stage: {message}',
changesOpGone: 'Tej operacji nie ma już w dzienniku sesji',
newTab: 'Nowa karta',
brokenSymlink: 'Uszkodzone dowiązanie symboliczne',
close: 'Zamknij',
@@ -122,7 +152,6 @@ export const pl: Record<string, string> = {
error: 'Ładowanie nie powiodło się',
retry: 'Ponów',
notRepo: 'Ten katalog nie jest repozytorium git',
noChanges: 'Brak zmian',
statusTruncated: 'Zbyt wiele zmian; pokazywane jest tylko pierwsze 2000 wpisów',
stage: 'Dodaj do indeksu',
unstage: 'Usuń z indeksu',
@@ -235,10 +264,6 @@ export const pl: Record<string, string> = {
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',
+30 -5
View File
@@ -40,6 +40,12 @@ export const pt: Record<string, string> = {
editorEmptyHint: 'Escolha um arquivo na árvore do painel ou no campo de caminho acima para começar a pré-visualizar',
openFileNewTab: 'Abrir em nova aba',
openFileSide: 'Abrir ao lado',
zipProgress: 'Compactando {done}/{total}',
settingsOpenWithPluginTitle: 'Mostrar sempre as opções "Abrir com" do plugin',
settingsOpenWithPluginDesc: 'Por padrão, as opções "Abrir com" do plugin só aparecem quando o host não detecta nenhum aplicativo associado localmente (Gerenciador de arquivos / VS Code / Cursor / Zed / editores personalizados, SSH remoto); ao ativar, os aplicativos do host e os destinos do plugin coexistem',
zipDownload: 'Compactar e baixar',
zipDownloadCount: 'Compactar e baixar ({count} itens)',
zipFailed: 'Falha ao compactar: {message}',
openWithMenu: 'Abrir com',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Fixar no menu',
@@ -60,6 +66,30 @@ export const pt: Record<string, string> = {
openWithSettingsFamilyDesc: 'Este editor fala o dialeto de URL do VSCode (suporta aberturas SSH remotas)',
openWithSettingsRemove: 'Remover',
openWithSettingsInvalidHint: 'Editores com nome ausente ou modelo sem {path} / scheme:// não aparecem no menu',
openInAppDefault: 'Abrir com o aplicativo padrão',
openInAppEmpty: 'Nenhum aplicativo pode abri-lo',
openInAppFailed: 'Não foi possível abrir: {path}',
revealInFileManager: 'Mostrar no gerenciador de arquivos',
filesSelected: '{count} selecionados',
clearSelection: 'Limpar seleção',
copyPaths: 'Copiar caminhos',
deleteSelected: 'Excluir selecionados',
deleteSelectedTitle: 'Excluir {count} itens selecionados?',
deleteSelectedDesc: 'Estes arquivos e pastas serão excluídos permanentemente. Não será possível desfazer.',
newFolder: 'Nova pasta',
newFolderPlaceholder: 'Nome da pasta',
newFolderInvalid: 'Nome de pasta inválido',
filesTruncated: 'Esta pasta tem entradas demais; apenas as primeiras são exibidas',
gitStatusModified: 'Modificado',
gitStatusAdded: 'Adicionado',
gitStatusDeleted: 'Excluído',
gitStatusUntracked: 'Não rastreado',
gitStatusRenamed: 'Renomeado',
gitStatusConflict: 'Em conflito',
changesClean: 'Nenhuma alteração no workspace',
changesNoHistory: 'Ainda não há histórico de commits',
changesStageFailed: 'Falha ao preparar: {message}',
changesOpGone: 'Esta operação não está mais no registro da sessão',
newTab: 'Nova aba',
brokenSymlink: 'Symlink quebrado',
close: 'Fechar',
@@ -103,7 +133,6 @@ export const pt: Record<string, string> = {
error: 'Falha ao carregar',
retry: 'Tentar novamente',
notRepo: 'Este diretório não é um repositório git',
noChanges: 'Sem alterações',
statusTruncated: 'Muitas alterações; exibindo apenas as primeiras 2000 entradas',
stage: 'Preparar (stage)',
unstage: 'Desfazer stage',
@@ -216,10 +245,6 @@ export const pt: Record<string, string> = {
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',
+30 -5
View File
@@ -53,6 +53,12 @@ export const ru: Record<string, string> = {
editorEmptyHint: 'Выберите файл из дерева справа или введите путь выше, чтобы начать предпросмотр',
openFileNewTab: 'Открыть в новой вкладке',
openFileSide: 'Открыть сбоку',
zipProgress: 'Архивация {done}/{total}',
settingsOpenWithPluginTitle: 'Всегда показывать собственные варианты «Открыть в приложении» плагина',
settingsOpenWithPluginDesc: 'По умолчанию собственные варианты «Открыть в приложении» плагина показываются только когда хост не обнаружил локально связанных приложений (Проводник / VS Code / Cursor / Zed / собственные редакторы, SSH-удалённо); после включения приложения хоста и цели плагина сосуществуют',
zipDownload: 'Сжать в архив и скачать',
zipDownloadCount: 'Сжать в архив и скачать ({count} элементов)',
zipFailed: 'Сбой создания архива: {message}',
openWithMenu: 'Открыть в приложении',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Закрепить в меню',
@@ -73,6 +79,30 @@ export const ru: Record<string, string> = {
openWithSettingsFamilyDesc: 'Этот редактор использует URL-протокол VSCode (поддерживает удалённое открытие по SSH)',
openWithSettingsRemove: 'Удалить',
openWithSettingsInvalidHint: 'Редакторы без имени или с шаблоном без {path} / scheme:// не появятся в меню',
openInAppDefault: 'Открыть приложением по умолчанию',
openInAppEmpty: 'Нет приложения, которое может это открыть',
openInAppFailed: 'Не удалось открыть: {path}',
revealInFileManager: 'Показать в файловом менеджере',
filesSelected: 'Выбрано: {count}',
clearSelection: 'Снять выделение',
copyPaths: 'Копировать пути',
deleteSelected: 'Удалить выбранное',
deleteSelectedTitle: 'Удалить выбранные элементы ({count})?',
deleteSelectedDesc: 'Эти файлы и папки будут удалены безвозвратно. Отменить это действие нельзя.',
newFolder: 'Новая папка',
newFolderPlaceholder: 'Имя папки',
newFolderInvalid: 'Недопустимое имя папки',
filesTruncated: 'В этой папке слишком много элементов; показаны только первые',
gitStatusModified: 'Изменён',
gitStatusAdded: 'Добавлен',
gitStatusDeleted: 'Удалён',
gitStatusUntracked: 'Не отслеживается',
gitStatusRenamed: 'Переименован',
gitStatusConflict: 'Конфликт',
changesClean: 'В рабочей области нет изменений',
changesNoHistory: 'Истории коммитов пока нет',
changesStageFailed: 'Ошибка индексирования: {message}',
changesOpGone: 'Этой операции больше нет в журнале сессии',
newTab: 'Новая вкладка',
brokenSymlink: 'Сломанная символическая ссылка',
close: 'Закрыть',
@@ -116,7 +146,6 @@ export const ru: Record<string, string> = {
error: 'Ошибка загрузки',
retry: 'Повторить',
notRepo: 'Этот каталог не является git-репозиторием',
noChanges: 'Нет изменений',
statusTruncated: 'Слишком много изменений; показаны только первые 2000 записей',
stage: 'Индексировать',
unstage: 'Убрать из индекса',
@@ -230,10 +259,6 @@ export const ru: Record<string, string> = {
settingsJobsDesc: 'При появлении новой фоновой задачи в текущей сессии автоматически активируется страница «Задачи» (срабатывает для каждой новой задачи); на широких экранах также разворачивается боковая панель, а на узких экранах полноэкранная выдвижная панель не открывается принудительно; отключите, чтобы выбирать страницу вручную',
settingsToolsTitle: 'Внедрить терминальные инструменты для модели',
settingsToolsDesc: 'Если включено, модель может через 8 инструментов (terminal_create и др.) создавать терминалы боковой панели и управлять ими (по умолчанию выключено)',
settingsFenceTitle: 'Контроль путей рабочей области',
settingsFenceDesc: 'Включено — файловые функции боковой панели достигают только путей внутри рабочей области сессии (по умолчанию); выключено — доступны любые файлы хоста, а скрипты страницы получают тот же доступ всё это время',
fenceErrorReason: 'Этот путь вне рабочей области сессии и заблокирован контролем',
fenceDisableAction: 'Отключить контроль рабочей области',
settingsBottomTerminalTitle: 'Автооткрытие терминала при первом разворачивании нижней панели',
settingsBottomTerminalDesc: 'При первом разворачивании нижней панели в сессии попытаться автоматически открыть новую вкладку терминала в ней (лимит терминалов всё равно применяется; по умолчанию включено)',
settingsFontFamilyTitle: 'Шрифт терминала',
+30 -5
View File
@@ -40,6 +40,12 @@ export const sv: Record<string, string> = {
editorEmptyHint: 'Välj en fil från trädpanelen eller sökvägsinmatningen ovan för att börja förhandsgranska',
openFileNewTab: 'Öppna i ny flik',
openFileSide: 'Öppna vid sidan',
zipProgress: 'Arkiverar {done}/{total}',
settingsOpenWithPluginTitle: 'Visa alltid pluginets egna "Öppna med"-mål',
settingsOpenWithPluginDesc: 'Som standard visas pluginets egna "Öppna med"-mål bara när värden inte hittar någon lokalt associerad app (Filhanterare / VS Code / Cursor / Zed / anpassade editorer, SSH på distans); när det slås på finns värdens appar och pluginets mål sida vid sida',
zipDownload: 'Zippa och ladda ner',
zipDownloadCount: 'Zippa och ladda ner ({count} objekt)',
zipFailed: 'Kunde inte skapa arkivet: {message}',
openWithMenu: 'Öppna med',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Fäst i meny',
@@ -60,6 +66,30 @@ export const sv: Record<string, string> = {
openWithSettingsFamilyDesc: 'Denna editor talar VSCode:s URL-dialekt (stöder SSH-fjärröppning)',
openWithSettingsRemove: 'Ta bort',
openWithSettingsInvalidHint: 'Editorer med saknat namn eller en mall utan {path} / scheme:// visas inte i menyn',
openInAppDefault: 'Öppna med standardapp',
openInAppEmpty: 'Ingen app kan öppna den',
openInAppFailed: 'Kunde inte öppna: {path}',
revealInFileManager: 'Visa i filhanteraren',
filesSelected: '{count} markerade',
clearSelection: 'Rensa markering',
copyPaths: 'Kopiera sökvägar',
deleteSelected: 'Ta bort markerade',
deleteSelectedTitle: 'Ta bort {count} markerade objekt?',
deleteSelectedDesc: 'Filerna och mapparna tas bort permanent. Det går inte att ångra.',
newFolder: 'Ny mapp',
newFolderPlaceholder: 'Mappnamn',
newFolderInvalid: 'Ogiltigt mappnamn',
filesTruncated: 'Mappen har för många poster; bara de första visas',
gitStatusModified: 'Ändrad',
gitStatusAdded: 'Tillagd',
gitStatusDeleted: 'Borttagen',
gitStatusUntracked: 'Spåras inte',
gitStatusRenamed: 'Omdöpt',
gitStatusConflict: 'Konflikt',
changesClean: 'Inga ändringar i arbetsytan',
changesNoHistory: 'Ingen commit-historik ännu',
changesStageFailed: 'Kunde inte köa: {message}',
changesOpGone: 'Åtgärden finns inte längre i sessionsloggen',
newTab: 'Ny flik',
brokenSymlink: 'Bruten symbolisk länk',
close: 'Stäng',
@@ -103,7 +133,6 @@ export const sv: Record<string, string> = {
error: 'Kunde inte ladda',
retry: 'Försök igen',
notRepo: 'Denna katalog är inte ett Git-arkiv',
noChanges: 'Inga ändringar',
statusTruncated: 'För många ändringar; endast de första 2000 posterna visas',
stage: 'Köa',
unstage: 'Avköa',
@@ -216,10 +245,6 @@ export const sv: Record<string, string> = {
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',
+30 -5
View File
@@ -57,6 +57,12 @@ export const th: Record<string, string> = {
editorEmptyHint: 'เลือกไฟล์จากพาเนลต้นไม้ไฟล์หรือช่องกรอกพาธด้านบนเพื่อเริ่มพรีวิว',
openFileNewTab: 'เปิดใน Tab ใหม่',
openFileSide: 'เปิดด้านข้าง',
zipProgress: 'กำลังบีบอัด {done}/{total}',
settingsOpenWithPluginTitle: 'แสดงตัวเลือก "เปิดด้วย" ของปลั๊กอินเสมอ',
settingsOpenWithPluginDesc: 'ตามค่าเริ่มต้น ตัวเลือก "เปิดด้วย" ของปลั๊กอินจะแสดงเฉพาะเมื่อโฮสต์ตรวจไม่พบแอปที่เชื่อมโยงในเครื่อง (ตัวจัดการไฟล์ / VS Code / Cursor / Zed / ตัวแก้ไขที่กำหนดเอง, SSH ระยะไกล); เมื่อเปิดใช้ แอปของโฮสต์และเป้าหมายของปลั๊กอินจะอยู่ร่วมกัน',
zipDownload: 'บีบอัดและดาวน์โหลด',
zipDownloadCount: 'บีบอัดและดาวน์โหลด ({count} รายการ)',
zipFailed: 'สร้างไฟล์บีบอัดไม่สำเร็จ: {message}',
openWithMenu: 'เปิดด้วย',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'ปักหมุดในเมนู',
@@ -77,6 +83,30 @@ export const th: Record<string, string> = {
openWithSettingsFamilyDesc: 'ตัวแก้ไขนี้ใช้โปรโตคอล URL ของ VSCode (รองรับการเปิดจากระยะไกลผ่าน SSH)',
openWithSettingsRemove: 'ลบ',
openWithSettingsInvalidHint: 'ตัวแก้ไขที่ไม่มีชื่อหรือเทมเพลตที่ไม่มี {path} / scheme:// จะไม่ปรากฏในเมนู',
openInAppDefault: 'เปิดด้วยแอปเริ่มต้น',
openInAppEmpty: 'ไม่มีแอปที่เปิดไฟล์นี้ได้',
openInAppFailed: 'เปิดไม่สำเร็จ: {path}',
revealInFileManager: 'แสดงในตัวจัดการไฟล์',
filesSelected: 'เลือกแล้ว {count} รายการ',
clearSelection: 'ล้างการเลือก',
copyPaths: 'คัดลอกพาธ',
deleteSelected: 'ลบรายการที่เลือก',
deleteSelectedTitle: 'ลบ {count} รายการที่เลือกหรือไม่',
deleteSelectedDesc: 'ไฟล์และโฟลเดอร์เหล่านี้จะถูกลบอย่างถาวรและกู้คืนไม่ได้',
newFolder: 'โฟลเดอร์ใหม่',
newFolderPlaceholder: 'ชื่อโฟลเดอร์',
newFolderInvalid: 'ชื่อโฟลเดอร์ไม่ถูกต้อง',
filesTruncated: 'โฟลเดอร์นี้มีรายการมากเกินไป จึงแสดงเฉพาะส่วนแรก',
gitStatusModified: 'แก้ไขแล้ว',
gitStatusAdded: 'เพิ่มแล้ว',
gitStatusDeleted: 'ลบแล้ว',
gitStatusUntracked: 'ยังไม่ติดตาม',
gitStatusRenamed: 'เปลี่ยนชื่อแล้ว',
gitStatusConflict: 'มีความขัดแย้ง',
changesClean: 'ไม่มีการเปลี่ยนแปลงในเวิร์กสเปซ',
changesNoHistory: 'ยังไม่มีประวัติการ commit',
changesStageFailed: 'stage ไม่สำเร็จ: {message}',
changesOpGone: 'การดำเนินการนี้ไม่อยู่ในบันทึกเซสชันแล้ว',
newTab: 'Tab ใหม่',
brokenSymlink: 'Symlink เสีย',
close: 'ปิด',
@@ -120,7 +150,6 @@ export const th: Record<string, string> = {
error: 'โหลดล้มเหลว',
retry: 'ลองอีกครั้ง',
notRepo: 'ไดเรกทอรีนี้ไม่ใช่ git repository',
noChanges: 'ไม่มีการเปลี่ยนแปลง',
statusTruncated: 'การเปลี่ยนแปลงมากเกินไป แสดงเพียง 2000 รายการแรก',
stage: 'Stage',
unstage: 'Unstage',
@@ -233,10 +262,6 @@ export const th: Record<string, string> = {
settingsJobsDesc: 'เมื่อมีงานเบื้องหลังใหม่ปรากฏในแชทปัจจุบัน ระบบจะเลือกหน้า Tasks โดยอัตโนมัติ (ทุกงานใหม่จะทริกเกอร์); ในมุมมองแบบกว้าง ระบบจะขยายการ์ดด้านข้างด้วย ส่วนในมุมมองแบบแคบ ระบบจะไม่บังคับเปิดลิ้นชักเต็มหน้าจอ; ปิดเพื่อเลือกหน้าด้วยตนเอง',
settingsToolsTitle: 'ฉีดเครื่องมือเทอร์มินัลสำหรับโมเดล',
settingsToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถสร้างและควบคุมเทอร์มินัลแถบด้านข้างผ่านเครื่องมือ terminal_* 8 ตัว (ปิดเป็นค่าเริ่มต้น)',
settingsFenceTitle: 'การตรวจสอบเส้นทางเวิร์กสเปซ',
settingsFenceDesc: 'เมื่อเปิด ฟีเจอร์ไฟล์ของแถบข้างเข้าถึงได้เฉพาะเส้นทางในเวิร์กสเปซของเซสชัน (ค่าเริ่มต้น) เมื่อปิด จะเข้าถึงไฟล์ใดก็ได้บนโฮสต์ — สคริปต์ในหน้าก็ได้ขอบเขตเดียวกันตลอดช่วงนั้น',
fenceErrorReason: 'เส้นทางนี้อยู่นอกเวิร์กสเปซของเซสชันและถูกบล็อกโดยการตรวจสอบ',
fenceDisableAction: 'ปิดการตรวจสอบเวิร์กสเปซ',
settingsBottomTerminalTitle: 'เปิดเทอร์มินัลอัตโนมัติเมื่อพาเนลล่างขยายครั้งแรก',
settingsBottomTerminalDesc: 'เมื่อพาเนลล่างถูกขยายครั้งแรกในเซสชัน ลองเปิด tab เทอร์มินัลใหม่ที่นั่น (โควต้าเทอร์มินัลยังคงมีผล; เปิดเป็นค่าเริ่มต้น)',
settingsFontFamilyTitle: 'ฟอนต์เทอร์มินัล',
+30 -5
View File
@@ -57,6 +57,12 @@ export const tr: Record<string, string> = {
editorEmptyHint: 'Önizlemeye başlamak için sağdaki dosya ağacından veya yukarıdaki yol girişinden bir dosya seçin',
openFileNewTab: 'Yeni sekmede aç',
openFileSide: 'Yana aç',
zipProgress: 'Arşivleniyor {done}/{total}',
settingsOpenWithPluginTitle: 'Eklentinin kendi "Birlikte aç" seçeneklerini her zaman göster',
settingsOpenWithPluginDesc: 'Varsayılan olarak eklentinin kendi "Birlikte aç" seçenekleri yalnızca ana makine yerel bir ilişkili uygulama algılamadığında gösterilir (Dosya yöneticisi / VS Code / Cursor / Zed / özel düzenleyiciler, uzak SSH); açıldığında ana makine uygulamaları ile eklenti hedefleri bir arada bulunur',
zipDownload: 'Sıkıştır ve indir',
zipDownloadCount: 'Sıkıştır ve indir ({count} öğe)',
zipFailed: 'Arşiv oluşturulamadı: {message}',
openWithMenu: 'Birlikte aç',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Menüye sabitle',
@@ -77,6 +83,30 @@ export const tr: Record<string, string> = {
openWithSettingsFamilyDesc: 'Bu düzenleyici VSCode URL dilini konuşur (SSH uzak açmayı destekler)',
openWithSettingsRemove: 'Kaldır',
openWithSettingsInvalidHint: 'Adı eksik veya {path}/scheme:// içermeyen şablonlu düzenleyiciler menüde gösterilmez',
openInAppDefault: 'Varsayılan uygulamayla aç',
openInAppEmpty: 'Bunu açabilecek bir uygulama yok',
openInAppFailed: 'Açılamadı: {path}',
revealInFileManager: 'Dosya yöneticisinde göster',
filesSelected: '{count} öğe seçildi',
clearSelection: 'Seçimi temizle',
copyPaths: 'Yolları kopyala',
deleteSelected: 'Seçilenleri sil',
deleteSelectedTitle: 'Seçili {count} öğe silinsin mi?',
deleteSelectedDesc: 'Bu dosyalar ve klasörler kalıcı olarak silinecek. Bu işlem geri alınamaz.',
newFolder: 'Yeni klasör',
newFolderPlaceholder: 'Klasör adı',
newFolderInvalid: 'Klasör adı geçersiz',
filesTruncated: 'Bu klasörde çok fazla öğe var; yalnızca ilk kısmı gösteriliyor',
gitStatusModified: 'Değiştirildi',
gitStatusAdded: 'Eklendi',
gitStatusDeleted: 'Silindi',
gitStatusUntracked: 'İzlenmiyor',
gitStatusRenamed: 'Yeniden adlandırıldı',
gitStatusConflict: 'Çakışma',
changesClean: 'Çalışma alanında değişiklik yok',
changesNoHistory: 'Henüz commit geçmişi yok',
changesStageFailed: 'Sahneleme başarısız: {message}',
changesOpGone: 'Bu işlem artık oturum günlüğünde değil',
newTab: 'Yeni sekme',
brokenSymlink: 'Bozuk sembolik bağ',
close: 'Kapat',
@@ -120,7 +150,6 @@ export const tr: Record<string, string> = {
error: 'Yükleme başarısız',
retry: 'Yeniden dene',
notRepo: 'Bu dizin bir git deposu değil',
noChanges: 'Değişiklik yok',
statusTruncated: 'Çok fazla değişiklik var; yalnızca ilk 2000 giriş gösteriliyor',
stage: 'Sahnele',
unstage: 'Sahnelemeden kaldır',
@@ -233,10 +262,6 @@ export const tr: Record<string, string> = {
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',
+30 -5
View File
@@ -57,6 +57,12 @@ export const vi: Record<string, string> = {
editorEmptyHint: 'Chọn tệp từ cây tệp bên phải hoặc ô nhập đường dẫn phía trên để xem trước',
openFileNewTab: 'Mở trong Tab mới',
openFileSide: 'Mở ở bên cạnh',
zipProgress: 'Đang nén {done}/{total}',
settingsOpenWithPluginTitle: 'Luôn hiển thị tùy chọn "Mở bằng" của plugin',
settingsOpenWithPluginDesc: 'Mặc định, tùy chọn "Mở bằng" của plugin chỉ hiển thị khi máy chủ không phát hiện ứng dụng liên kết cục bộ (Trình quản lý tệp / VS Code / Cursor / Zed / trình soạn thảo tùy chỉnh, SSH từ xa); khi bật, ứng dụng của máy chủ và mục tiêu của plugin cùng tồn tại',
zipDownload: 'Nén và tải xuống',
zipDownloadCount: 'Nén và tải xuống ({count} mục)',
zipFailed: 'Tạo tệp nén thất bại: {message}',
openWithMenu: 'Mở bằng',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Ghim vào menu',
@@ -77,6 +83,30 @@ export const vi: Record<string, string> = {
openWithSettingsFamilyDesc: 'Trình soạn thảo này dùng giao thức URL của VSCode (hỗ trợ mở từ xa qua SSH)',
openWithSettingsRemove: 'Xóa',
openWithSettingsInvalidHint: 'Trình soạn thảo thiếu tên hoặc mẫu (cần chứa {path} và bắt đầu bằng scheme://) sẽ không xuất hiện trong menu',
openInAppDefault: 'Mở bằng ứng dụng mặc định',
openInAppEmpty: 'Không có ứng dụng nào mở được tệp này',
openInAppFailed: 'Không mở được: {path}',
revealInFileManager: 'Hiển thị trong trình quản lý tệp',
filesSelected: 'Đã chọn {count}',
clearSelection: 'Bỏ chọn',
copyPaths: 'Sao chép đường dẫn',
deleteSelected: 'Xóa mục đã chọn',
deleteSelectedTitle: 'Xóa {count} mục đã chọn?',
deleteSelectedDesc: 'Các tệp và thư mục này sẽ bị xóa vĩnh viễn, không thể khôi phục.',
newFolder: 'Thư mục mới',
newFolderPlaceholder: 'Tên thư mục',
newFolderInvalid: 'Tên thư mục không hợp lệ',
filesTruncated: 'Thư mục này có quá nhiều mục; chỉ hiển thị phần đầu',
gitStatusModified: 'Đã sửa',
gitStatusAdded: 'Đã thêm',
gitStatusDeleted: 'Đã xóa',
gitStatusUntracked: 'Chưa theo dõi',
gitStatusRenamed: 'Đã đổi tên',
gitStatusConflict: 'Xung đột',
changesClean: 'Không có thay đổi trong workspace',
changesNoHistory: 'Chưa có lịch sử commit',
changesStageFailed: 'Stage thất bại: {message}',
changesOpGone: 'Thao tác này không còn trong nhật ký phiên',
newTab: 'Tab mới',
brokenSymlink: 'Symlink hỏng',
close: 'Đóng',
@@ -120,7 +150,6 @@ export const vi: Record<string, string> = {
error: 'Tải thất bại',
retry: 'Thử lại',
notRepo: 'Thư mục hiện tại không phải repo git',
noChanges: 'Không có thay đổi',
statusTruncated: 'Quá nhiều thay đổi; chỉ hiển thị 2000 mục đầu tiên',
stage: 'Stage',
unstage: 'Unstage',
@@ -233,10 +262,6 @@ export const vi: Record<string, string> = {
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',
+30 -5
View File
@@ -72,6 +72,12 @@ export const zhHK: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
zipProgress: '正在壓縮 {done}/{total}',
settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式',
settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存',
zipDownload: '壓縮並下載',
zipDownloadCount: '壓縮並下載({count} 項)',
zipFailed: '壓縮失敗:{message}',
openWithMenu: '在應用程式中開啟',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到選單',
@@ -92,6 +98,30 @@ export const zhHK: Record<string, string> = {
openWithSettingsFamilyDesc: '該編輯器使用 VSCode 的 URL 協議(支援 SSH 遠端開啟)',
openWithSettingsRemove: '刪除',
openWithSettingsInvalidHint: '名稱或範本(需含 {path} 且以 scheme:// 開頭)未填寫的編輯器不會出現在選單中',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用於開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
revealInFileManager: '在檔案總管中顯示',
filesSelected: '已選取 {count} 項',
clearSelection: '取消選取',
copyPaths: '複製路徑',
deleteSelected: '刪除所選',
deleteSelectedTitle: '刪除選取的 {count} 項?',
deleteSelectedDesc: '這些檔案與資料夾將被永久刪除,無法復原。',
newFolder: '新增資料夾',
newFolderPlaceholder: '資料夾名稱',
newFolderInvalid: '資料夾名稱無效',
filesTruncated: '此目錄項目過多,僅顯示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已刪除',
gitStatusUntracked: '未追蹤',
gitStatusRenamed: '已重新命名',
gitStatusConflict: '有衝突',
changesClean: '工作區沒有變更',
changesNoHistory: '還沒有提交歷史',
changesStageFailed: '暫存失敗:{message}',
changesOpGone: '此操作已不在工作階段記錄中',
newTab: '新增標籤',
brokenSymlink: '失效的符號連結',
close: '關閉',
@@ -135,7 +165,6 @@ export const zhHK: Record<string, string> = {
error: '載入失敗',
retry: '重試',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
@@ -248,10 +277,6 @@ export const zhHK: Record<string, string> = {
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力',
fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截',
fenceDisableAction: '關閉工作區檢測',
settingsBottomTerminalTitle: '底部面板首次展開自動開終端',
settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)',
settingsFontFamilyTitle: '終端字體',
+30 -5
View File
@@ -72,6 +72,12 @@ export const zhMO: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
zipProgress: '正在壓縮 {done}/{total}',
settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式',
settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存',
zipDownload: '壓縮並下載',
zipDownloadCount: '壓縮並下載({count} 項)',
zipFailed: '壓縮失敗:{message}',
openWithMenu: '在應用程式中開啟',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到選單',
@@ -92,6 +98,30 @@ export const zhMO: Record<string, string> = {
openWithSettingsFamilyDesc: '該編輯器使用 VSCode 的 URL 協議(支援 SSH 遠端開啟)',
openWithSettingsRemove: '刪除',
openWithSettingsInvalidHint: '名稱或範本(需含 {path} 且以 scheme:// 開頭)未填寫的編輯器不會出現在選單中',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用於開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
revealInFileManager: '在檔案總管中顯示',
filesSelected: '已選取 {count} 項',
clearSelection: '取消選取',
copyPaths: '複製路徑',
deleteSelected: '刪除所選',
deleteSelectedTitle: '刪除選取的 {count} 項?',
deleteSelectedDesc: '這些檔案與資料夾將被永久刪除,無法復原。',
newFolder: '新增資料夾',
newFolderPlaceholder: '資料夾名稱',
newFolderInvalid: '資料夾名稱無效',
filesTruncated: '此目錄項目過多,僅顯示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已刪除',
gitStatusUntracked: '未追蹤',
gitStatusRenamed: '已重新命名',
gitStatusConflict: '有衝突',
changesClean: '工作區沒有變更',
changesNoHistory: '還沒有提交歷史',
changesStageFailed: '暫存失敗:{message}',
changesOpGone: '此操作已不在工作階段記錄中',
newTab: '新增標籤',
brokenSymlink: '失效的符號連結',
close: '關閉',
@@ -135,7 +165,6 @@ export const zhMO: Record<string, string> = {
error: '載入失敗',
retry: '重試',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
@@ -248,10 +277,6 @@ export const zhMO: Record<string, string> = {
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力',
fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截',
fenceDisableAction: '關閉工作區檢測',
settingsBottomTerminalTitle: '底部面板首次展開自動開終端',
settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)',
settingsFontFamilyTitle: '終端字體',
+30 -5
View File
@@ -72,6 +72,12 @@ export const zhTW: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
zipProgress: '正在壓縮 {done}/{total}',
settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式',
settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存',
zipDownload: '壓縮並下載',
zipDownloadCount: '壓縮並下載({count} 項)',
zipFailed: '壓縮失敗:{message}',
openWithMenu: '在應用程式中開啟',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到選單',
@@ -92,6 +98,30 @@ export const zhTW: Record<string, string> = {
openWithSettingsFamilyDesc: '該編輯器使用 VSCode 的 URL 協議(支援 SSH 遠端開啟)',
openWithSettingsRemove: '刪除',
openWithSettingsInvalidHint: '名稱或範本(需含 {path} 且以 scheme:// 開頭)未填寫的編輯器不會出現在選單中',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用來開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
revealInFileManager: '在檔案總管中顯示',
filesSelected: '已選取 {count} 項',
clearSelection: '取消選取',
copyPaths: '複製路徑',
deleteSelected: '刪除選取項目',
deleteSelectedTitle: '刪除選取的 {count} 個項目?',
deleteSelectedDesc: '這些檔案與資料夾將被永久刪除,無法復原。',
newFolder: '新增資料夾',
newFolderPlaceholder: '資料夾名稱',
newFolderInvalid: '資料夾名稱無效',
filesTruncated: '此目錄項目過多,僅顯示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已刪除',
gitStatusUntracked: '未追蹤',
gitStatusRenamed: '已重新命名',
gitStatusConflict: '有衝突',
changesClean: '工作區沒有變更',
changesNoHistory: '還沒有提交歷史',
changesStageFailed: '暫存失敗:{message}',
changesOpGone: '此操作已不在作業階段記錄中',
newTab: '新增標籤',
brokenSymlink: '失效的符號連結',
close: '關閉',
@@ -135,7 +165,6 @@ export const zhTW: Record<string, string> = {
error: '載入失敗',
retry: '重試',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
@@ -248,10 +277,6 @@ export const zhTW: Record<string, string> = {
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力',
fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截',
fenceDisableAction: '關閉工作區檢測',
settingsBottomTerminalTitle: '底部面板首次展開自動開終端',
settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)',
settingsFontFamilyTitle: '終端字體',
+76 -11
View File
@@ -69,6 +69,12 @@ export const zh = {
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
zipProgress: '正在打包 {done}/{total}',
settingsOpenWithPluginTitle: '始终显示插件自研的打开方式',
settingsOpenWithPluginDesc: '默认只在宿主探测不到本机关联应用时显示插件自研的打开方式(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器、SSH 远端);打开后宿主应用与插件目标并存',
zipDownload: '压缩并下载',
zipDownloadCount: '压缩并下载({count} 项)',
zipFailed: '打包失败:{message}',
openWithMenu: '在应用中打开',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到菜单',
@@ -89,6 +95,30 @@ export const zh = {
openWithSettingsFamilyDesc: '该编辑器使用 VSCode 的 URL 协议(支持 SSH 远端打开)',
openWithSettingsRemove: '删除',
openWithSettingsInvalidHint: '名称或模板(需含 {path} 且以 scheme:// 开头)未填写的编辑器不会出现在菜单中',
openInAppDefault: '用默认应用打开',
openInAppEmpty: '没有可用于打开它的应用',
openInAppFailed: '无法打开:{path}',
revealInFileManager: '在文件管理器中显示',
filesSelected: '已选择 {count} 项',
clearSelection: '取消选择',
copyPaths: '复制路径',
deleteSelected: '删除所选',
deleteSelectedTitle: '删除选中的 {count} 项?',
deleteSelectedDesc: '这些文件与文件夹将被永久删除,无法恢复。',
newFolder: '新建文件夹',
newFolderPlaceholder: '文件夹名称',
newFolderInvalid: '文件夹名称无效',
filesTruncated: '该目录条目过多,仅显示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已删除',
gitStatusUntracked: '未跟踪',
gitStatusRenamed: '已重命名',
gitStatusConflict: '有冲突',
changesClean: '工作区没有变更',
changesNoHistory: '还没有提交历史',
changesStageFailed: '暂存失败:{message}',
changesOpGone: '该操作已不在会话记录中',
newTab: '新建标签页',
brokenSymlink: '失效的软链接',
close: '关闭',
@@ -131,7 +161,6 @@ export const zh = {
error: '加载失败',
retry: '重试',
notRepo: '当前目录不是 git 仓库',
noChanges: '没有变更',
statusTruncated: '变更过多,仅显示前 2000 条',
stage: '暂存',
unstage: '取消暂存',
@@ -245,10 +274,6 @@ export const zh = {
settingsJobsDesc: '当前会话出现新的后台任务时,自动激活任务管理页(每个新任务都会触发);宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsFenceTitle: '工作区路径检测',
settingsFenceDesc: '开启后,侧栏的文件功能仅能访问会话工作区内的路径(默认);关闭后可访问主机上任意文件——关闭期间页面内脚本也将获得同等访问能力',
fenceErrorReason: '此路径在会话工作区之外,已被工作区检测拦截',
fenceDisableAction: '关闭工作区检测',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
settingsFontFamilyTitle: '终端字体',
@@ -554,6 +579,12 @@ export const en: Record<keyof typeof zh, string> = {
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
openFileNewTab: 'Open in New Tab',
openFileSide: 'Open to the Side',
zipProgress: 'Archiving {done}/{total}',
settingsOpenWithPluginTitle: 'Always show the plugin\u2019s own Open with targets',
settingsOpenWithPluginDesc: 'By default the plugin\u2019s own targets (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes) show only when the host reports no local associated application; turn this on to keep both side by side',
zipDownload: 'Zip and download',
zipDownloadCount: 'Zip and download ({count} items)',
zipFailed: 'Archive failed: {message}',
openWithMenu: 'Open with',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Pin to menu',
@@ -574,6 +605,30 @@ export const en: Record<keyof typeof zh, string> = {
openWithSettingsFamilyDesc: 'This editor speaks the VSCode URL dialect (supports SSH-remote opens)',
openWithSettingsRemove: 'Remove',
openWithSettingsInvalidHint: 'Editors with a missing name or a template without {path} / scheme:// are not shown in the menu',
openInAppDefault: 'Open with default app',
openInAppEmpty: 'No application can open it',
openInAppFailed: 'Could not open: {path}',
revealInFileManager: 'Reveal in File Manager',
filesSelected: '{count} selected',
clearSelection: 'Clear selection',
copyPaths: 'Copy paths',
deleteSelected: 'Delete selected',
deleteSelectedTitle: 'Delete {count} selected items?',
deleteSelectedDesc: 'These files and folders will be permanently deleted. This cannot be undone.',
newFolder: 'New folder',
newFolderPlaceholder: 'Folder name',
newFolderInvalid: 'Invalid folder name',
filesTruncated: 'This folder has too many entries; only the first ones are shown',
gitStatusModified: 'Modified',
gitStatusAdded: 'Added',
gitStatusDeleted: 'Deleted',
gitStatusUntracked: 'Untracked',
gitStatusRenamed: 'Renamed',
gitStatusConflict: 'Conflicted',
changesClean: 'No changes in the working tree',
changesNoHistory: 'No commits yet',
changesStageFailed: 'Staging failed: {message}',
changesOpGone: 'This operation is no longer in the session log',
newTab: 'New tab',
brokenSymlink: 'Broken symlink',
close: 'Close',
@@ -616,7 +671,6 @@ export const en: Record<keyof typeof zh, string> = {
error: 'Failed to load',
retry: 'Retry',
notRepo: 'This directory is not a git repository',
noChanges: 'No changes',
statusTruncated: 'Too many changes; showing the first 2,000 entries',
stage: 'Stage',
unstage: 'Unstage',
@@ -730,10 +784,6 @@ export const en: Record<keyof typeof zh, string> = {
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',
@@ -1069,6 +1119,21 @@ export function attachLocale(service: {
* @returns the translated text, or undefined when unavailable.
*/
export function chatT(key: string, params?: Record<string, string | number>): string | undefined {
return hostT('chat', key, params)
}
/**
* Translate a key in one of the HOST's own locale namespaces. Same contract
* and same deliberately-untyped seam as {@link chatT} — the plugin reads host
* wording it would otherwise have to duplicate across 20 dictionaries (the
* chat activity line, the open-in-app application names).
*
* @param ns - host namespace (e.g. `'open-in-app'`).
* @param key - full namespace key (e.g. `'app.vscode'`).
* @param params - `{name}` placeholders to interpolate (the host does this).
* @returns the translated text, or undefined when unavailable.
*/
export function hostT(ns: string, key: string, params?: Record<string, string | number>): string | undefined {
const service = localeService
if (service === undefined) return undefined
try {
@@ -1076,7 +1141,7 @@ export function chatT(key: string, params?: Record<string, string | number>): st
// reference (`const bind = service.bind; bind('chat')`) throws
// "Cannot read properties of undefined (reading 'bound')" — caught by the
// real-host mount lane, invisible to closure fakes in unit tests.
const translate = service.bind?.('chat')
const translate = service.bind?.(ns)
if (typeof translate !== 'function') return undefined
const text = translate(key, params)
return typeof text === 'string' && text !== '' && text !== key ? text : undefined
+9
View File
@@ -207,6 +207,15 @@ 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.
// (Restored after the v0.22.1 merge: main's branch rewrote this function
// without the call, and taking that file wholesale silently disabled every
// folder toggle — the records then have no store to read from or write to,
// so `toggleExpanded` returns without touching anything. Only the real-host
// lane could see it; the jsdom specs bind the records themselves.)
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
+16 -11
View File
@@ -26,8 +26,8 @@ import type { NativeTabRecords } from './tab-adapter.tsx'
/** One open the surface could not place yet. */
type Pending =
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean }
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean }
| { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean; preferNewPane: boolean }
| { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean; preferNewPane: boolean }
/**
* The observation "which session's seat is on screen": DSH 0.1.7 publishes it
@@ -42,14 +42,14 @@ export interface MountedSessions {
/** The controller face this module uses (a structural slice of `ISidebarRight`). */
interface NativeController {
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
close(tabId: string): void
/** The mounted-seat observation (0.1.7 `ISidebarRight.mounted`). */
mounted?: MountedSessions
/** Not part of `ISidebarRight`: the concrete controller's per-session writes. */
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void
openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void
closeIn?(sessionId: string, tabId: string): void
}
@@ -134,7 +134,11 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
if (api === undefined) return false
const onScreen = mountedSessionId(ctx) === entry.sessionId
if (entry.kind === 'tab') {
const options = { params: entry.params, revealIfOpened: entry.revealIfOpened }
const options = {
params: entry.params,
revealIfOpened: entry.revealIfOpened,
...(entry.preferNewPane ? { preferNewPane: true } : {}),
}
if (onScreen) {
api.openTab(entry.tabKind, options)
return true
@@ -148,6 +152,7 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
const options = {
...(entry.line === undefined ? {} : { params: { line: entry.line } }),
revealIfOpened: entry.revealIfOpened,
...(entry.preferNewPane ? { preferNewPane: true } : {}),
}
if (onScreen) {
api.openResource(entry.address, options)
@@ -185,11 +190,11 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na
}
const unsubscribeList = ctx.sessions.list.subscribe(onListChange)
return {
openTab({ sessionId, kind, params, revealIfOpened }) {
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened })
openTab({ sessionId, kind, params, revealIfOpened, preferNewPane }) {
enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened, preferNewPane: preferNewPane === true })
},
openResource({ sessionId, address, line, revealIfOpened }) {
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened })
openResource({ sessionId, address, line, revealIfOpened, preferNewPane }) {
enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened, preferNewPane: preferNewPane === true })
},
fileAddress(sessionId, cwd, path) {
return fileAddressFor(sessionId, cwd, path)
+77 -8
View File
@@ -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<string, View>()
@@ -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
+214
View File
@@ -0,0 +1,214 @@
/**
* The client half of "open with an application" — a thin adapter over DSH's
* OWN open-in-app capability, replacing the plugin's former home-grown
* vocabulary (URL templates, SSH hosts, custom editors, a spawn route).
*
* Two host facilities back this, and the adapter simply picks the right one:
*
* - The Host Remote (`ctx.remote.session`): `canOpenWorkspacePath()` reports
* whether this deployment can hand a path to a native desktop,
* `workspacePathApplications({path})` lists the OS handlers REGISTERED for
* one file, and `openWorkspacePath({path, action?, application?})` opens a
* path or reveals it in the file manager. The host validates the path and
* the application id before spawning anything.
* - The host's webServer routes: `GET /open-in-app/apps` is the probed
* application CATALOG (menu order) and `POST /open-in-app/open` launches
* one catalog app on a DIRECTORY — the "open this workspace in VS Code"
* gesture. Catalog ids are not file-handler ids, so `open()` tries the
* Remote first and falls back to the catalog route.
*
* Everything degrades to "no control" when the host says the desktop is
* unavailable: the caller hides the section instead of offering dead rows.
*/
import { hostT } from './locales.ts'
/** The host route paths (see `@deepseek-ai/dsh-host-open-in-app/shared`). */
const APPS_PATH = '/open-in-app/apps'
const ICON_PATH = '/open-in-app/icon'
const OPEN_PATH = '/open-in-app/open'
/** One application that can open the path. */
export interface OpenInAppEntry {
/** Host-side id: an OS handler id for files, a catalog id for directories. */
id: string
/** Display name (the OS reports handlers; the catalog id is localized). */
name: string
/** PNG/SVG data URL for a handler, an icon route for a catalog app, else null. */
icon: string | null
/** Whether the OS reports it as the default handler (files only). */
isDefault: boolean
}
/** The adapter the pages consume. */
export interface OpenInApp {
/** Whether the host can open paths on a desktop; null until probed. */
available(): boolean | null
/** Probe availability once (shared across callers). */
probe(): Promise<boolean>
/** The installed-application catalog, in host menu order (directories). */
directoryApps(): Promise<readonly OpenInAppEntry[]>
/** The OS handlers registered for one file, or null when the query failed. */
fileApps(path: string): Promise<readonly OpenInAppEntry[] | null>
/** Open a path with its default handler, or with one explicit application. */
open(path: string, application?: string): Promise<boolean>
/** Reveal a path in the OS file manager. */
reveal(path: string): Promise<boolean>
}
/** The slice of `ctx.remote.session` this module calls (structurally typed). */
interface SessionRemote {
canOpenWorkspacePath(): Promise<{ ok: boolean; value?: boolean }>
workspacePathApplications(request: { path: string }): Promise<{ ok: boolean; value?: readonly RawFileApplication[] }>
openWorkspacePath(request: { path: string; action?: 'reveal'; application?: string }): Promise<{ ok: boolean }>
}
/** The host's `NativeFileApplication` shape. */
interface RawFileApplication {
id: string
name: string
default: boolean
icon: string | null
}
/** A ctx that can resolve client services (the cordis client root). */
interface CtxLike {
get(name: string): unknown
}
/** The host's `open-in-app` locale namespace, read through the plugin's bridge. */
function appLabel(id: string): string {
const text = hostT('open-in-app', `app.${id}`)
if (text !== undefined) return text
// The namespace may be absent (host older than the capability): fall back to
// a readable form of the id itself.
return id.charAt(0).toUpperCase() + id.slice(1)
}
/**
* The remote namespace, or undefined when the client assembly lacks it.
*
* Cordis throws when a service (or a nested property of one) is read without
* a matching `inject` entry — the client bundle declares `remote` and
* `remote.session` for exactly this call, but a composition that mounts the
* sidebar without the Host Remote assembly must degrade to "no control"
* instead of taking a render down with it, so the read is guarded.
*/
function remoteOf(ctx: CtxLike): SessionRemote | undefined {
try {
const remote = ctx.get('remote') as { session?: SessionRemote } | undefined | null
const session = remote?.session
return session !== undefined && typeof session.openWorkspacePath === 'function' ? session : undefined
} catch {
return undefined
}
}
/** Build the adapter for one client ctx. */
export function createOpenInApp(ctx: CtxLike): OpenInApp {
const remote = remoteOf(ctx)
/** Tri-state availability; one shared probe per page. */
let availability: boolean | null = null
let probing: Promise<boolean> | null = null
const probe = async (): Promise<boolean> => {
if (availability !== null) return availability
if (remote === undefined) {
availability = false
return false
}
probing ??= remote.canOpenWorkspacePath()
.then(result => {
availability = result.ok && result.value === true
return availability
})
.catch(() => {
availability = false
return false
})
.finally(() => { probing = null })
return probing
}
const directoryApps = async (): Promise<readonly OpenInAppEntry[]> => {
if (!await probe()) return []
try {
const response = await fetch(APPS_PATH)
if (!response.ok) return []
const payload = await response.json() as { apps?: unknown }
const ids = Array.isArray(payload.apps) ? payload.apps.filter((id): id is string => typeof id === 'string') : []
return ids.map(id => ({
id,
name: appLabel(id),
icon: `${ICON_PATH}/${encodeURIComponent(id)}`,
isDefault: false,
}))
} catch {
return []
}
}
const fileApps = async (path: string): Promise<readonly OpenInAppEntry[] | null> => {
if (remote === undefined || !await probe()) return null
try {
const result = await remote.workspacePathApplications({ path })
if (!result.ok || result.value === undefined) return null
return result.value.map(app => ({
id: app.id,
name: app.name,
icon: app.icon,
isDefault: app.default,
}))
} catch {
return null
}
}
const openViaRemote = async (path: string, application: string | undefined): Promise<boolean> => {
if (remote === undefined) return false
try {
const result = await remote.openWorkspacePath({
path,
...(application !== undefined ? { application } : {}),
})
return result.ok
} catch {
return false
}
}
const open = async (path: string, application?: string): Promise<boolean> => {
if (await openViaRemote(path, application)) return true
// A catalog id is not a file-handler id: the host's directory route is the
// one that launches "VS Code on this folder".
if (application === undefined) return false
try {
const response = await fetch(OPEN_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: application, path }),
})
return response.ok
} catch {
return false
}
}
const reveal = async (path: string): Promise<boolean> => {
if (remote === undefined) return false
try {
const result = await remote.openWorkspacePath({ path, action: 'reveal' })
return result.ok
} catch {
return false
}
}
return {
available: () => availability,
probe,
directoryApps,
fileApps,
open,
reveal,
}
}
+10 -4
View File
@@ -137,9 +137,15 @@ function customIdOf(id: string): string {
/**
* The menu-visible open targets, in order (built-ins then custom editors).
* In SSH mode the local-only targets (the OS file manager, Zed, custom
* editors without the VSCode dialect) are dropped — they cannot reach a
* remote path. Unknown pinned ids are pruned here too.
* In SSH mode the local-only URL openers (Zed, custom editors without the
* VSCode dialect) are dropped — they cannot reach a remote path. The
* `explorer` target is NOT dropped: it reveals a path in the file manager of
* the machine that owns the workspace, which is exactly where the host half
* runs, and it is the only reveal left when the DSH host's own open-in-app
* capability is unavailable (older host, no desktop session). A headless
* remote cannot raise a file manager anyway — that is the same silent
* no-op the other external launches already have. Unknown pinned ids are
* pruned here too.
*/
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
const ssh = config.sshHost.trim() !== ''
@@ -156,7 +162,7 @@ export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[]
localOnly: !editor.isVscodeFamily,
})),
]
return targets.filter(target => !(ssh && target.localOnly))
return targets.filter(target => !(ssh && target.localOnly && target.kind !== 'reveal'))
}
/** The SSH hint appended to a target's label in remote mode. */
-3
View File
@@ -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:
+20 -6
View File
@@ -416,8 +416,14 @@ export interface OpenTabSeed {
* the plugin's content is registered there as native tab types; `'bottom'`
* is the plugin's own bottom workbench. Only the plugin's own flows pass
* `'bottom'` (the bottom panel's + menu, the auto-terminal).
*
* `'side'` also means the right Sidebar, but it lands in a SECOND pane
* there (`preferNewPane`, the host's own split): that is the "open to the
* side" action, which must not fall back to the bottom workbench a native
* tab never lives in. A path-less editor seed is the file explorer page, so
* `'side'` only changes where a path seed lands.
*/
target?: 'right' | 'bottom'
target?: 'right' | 'bottom' | 'side'
}
/**
@@ -449,9 +455,9 @@ export interface NativeTabParams {
*/
export interface SidebarSurface {
/** Open a page type in one session's native surface. */
openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void
openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean; preferNewPane?: boolean }): void
/** Open a resource address in one session's native surface. */
openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void
openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean; preferNewPane?: boolean }): void
/** The file address of one path (the native surface owns the grammar). */
fileAddress(sessionId: string, cwd: string | undefined, path: string): string
/** Close one native tab; the closed record's type/title, or undefined when the id is not native. */
@@ -633,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.1'
export const SIDEBAR_SERVICE_VERSION = '0.24.1'
/**
* Monotonic capability list consumers use to gate new API usage (features
@@ -900,6 +906,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// included) as navigation params, which the tab adapter merges onto the
// synthetic record's `tab.path` for the registered component.
if (surface !== undefined && seed.target !== 'bottom') {
// "Open to the side" asks the host for a NEW pane instead of reusing
// the pane the acting tab lives in. `revealIfOpened: false` permits a
// duplicate of an already-open resource, so the split really happens
// (the host's `preferNewPane` falls back to the target pane when no
// split is available — that fallback is the host's rule, not ours).
const side = seed.target === 'side'
const state = store.getSnapshot().state
// The descriptor's own factory mints what a view needs beyond the seed:
// the side chat's thread bootstrap / reattach meta, the terminal's
@@ -926,7 +938,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
surface.openResource({
sessionId: targetSessionId,
address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path),
revealIfOpened: true,
revealIfOpened: side ? false : true,
...(side ? { preferNewPane: true } : {}),
})
} else {
// The path-less editor window IS the file explorer.
@@ -946,7 +959,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
...(seed.diff === undefined ? {} : { diff: seed.diff }),
...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }),
},
revealIfOpened,
revealIfOpened: side ? false : revealIfOpened,
...(side ? { preferNewPane: true } : {}),
})
}
// The native surface reports one open event, not create-vs-focus, so a
+175 -95
View File
@@ -696,11 +696,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding: 4px 8px 8px;
}
/* One tree row: the app's session-row cell (34px, radius 8, inset, hover
fill, 22px indent step, 14px ink) with a mount fade so expanding a
/* One tree row: the plugin's native list rhythm (28px, radius 4, inset,
hover fill, 22px indent step, 12px ink) with a mount fade so expanding a
directory reveals its children gently (stable keys keep existing rows from
replaying it). */
.explorerRow {
position: relative;
display: flex;
align-items: center;
gap: 6px;
@@ -710,16 +711,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
box-sizing: border-box;
width: 100%;
max-width: 100%;
height: 34px;
height: 28px;
padding: 0 8px;
border: none;
background: transparent;
font: var(--dsw-font-s-14);
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
white-space: nowrap;
border-radius: 8px;
border-radius: 4px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
@@ -727,6 +728,41 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
background: var(--dsw-alias-interactive-bg-hover);
}
/* A multi-selected row: the native selected fill plus the design's 2px
inset accent bar (a pseudo element, so metrics never shift between rows).
Distinct from the reveal tint below — a reveal is "here it is", a
selection is "this is the batch's member". */
.explorerRowSelected {
background: var(--dsw-alias-interactive-bg-active);
}
.explorerRowSelected::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--dsw-alias-state-business-primary);
}
/* Git-changed file names (VS Code's ink): the row keeps one class and the
tone arrives as a data attribute, so the mapping lives in one place. */
.explorerGitName[data-git-tone='modified'] { color: var(--dsw-alias-state-warn-primary); }
.explorerGitName[data-git-tone='added'] { color: var(--dsw-alias-state-success-primary); }
.explorerGitName[data-git-tone='untracked'] { color: var(--dsw-alias-state-success-primary); }
.explorerGitName[data-git-tone='deleted'] { color: var(--dsw-alias-state-error-primary); }
.explorerGitName[data-git-tone='renamed'] { color: var(--dsw-alias-state-business-primary); }
.explorerGitName[data-git-tone='copied'] { color: var(--dsw-alias-state-success-primary); }
.explorerGitName[data-git-tone='conflict'] { color: var(--dsw-alias-state-error-primary); }
/* A directory with a change anywhere below it: tinted, but never lettered
(the letter belongs to the file that actually changed). */
.explorerDirChanged {
color: var(--dsw-alias-state-business-primary);
}
/* A "Show in folder" reveal highlight: the target row stays tinted until
the transient reveal set is cleared (a reload starts unhighlighted).
The DSH business-blue surface (the same source the host's nav items use
@@ -752,7 +788,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
.explorerDir {
font: var(--dsw-font-s-strong-14);
font: var(--dsw-font-xxs-strong-12);
}
.explorerHidden {
@@ -780,9 +816,9 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
white-space: nowrap;
}
/* ── Inline rename editor ─────────────────────────────────────────────────
The row swaps its name span for a plain input (same row metrics: 34px
height, 6px gap, xxs type); only tokens, no hardcoded colors. */
/* ── Inline rename / new-folder editor ───────────────────────────────────
The level's top (or a row) swaps in a plain input (same row metrics: 28px
height, 6px gap, 12px type); only tokens, no hardcoded colors. */
.explorerRenaming {
cursor: text;
}
@@ -791,10 +827,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
height: 24px;
height: 22px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-state-business-primary);
border-radius: 6px;
border-radius: 3px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
@@ -828,33 +864,61 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-state-error-primary);
}
.explorerActionErrorClose {
flex: none;
display: inline-flex;
/* The multi-selection bar: the LAST child of the tree's scroll container, so
it docks at the panel's bottom edge (sticky) and its appearance never
pushes the rows around the way a top bar did. Sticky (not absolute) keeps
it in flow: scrolled to the bottom it sits below the last row and covers
nothing, while mid-scroll it stays reachable. It WRAPS instead of
overflowing, so a narrow panel gets a second line of chips rather than a
clipped control or a horizontal scrollbar. 28px rhythm, token-only (§1). */
.explorerSelectionBar {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
gap: 6px;
box-sizing: border-box;
width: 100%;
min-height: 28px;
margin-top: 4px;
padding: 2px 8px;
border-radius: 4px;
background: var(--dsw-alias-bg-layer-2);
}
.explorerActionErrorClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
.explorerSelectionText {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-secondary);
}
/* The delete-confirmation modal's description paragraph (the git lens's
.gitConfirmDesc sibling — same quiet single-question look). */
.explorerConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
.explorerSelectionActions {
flex: none;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
max-width: 72%;
}
/* The leading glyph of one "open in app" menu entry (the host's icon route
or a data URL). Fixed box: the menu's own icon slot sizing. */
.explorerAppIcon {
display: block;
width: 14px;
height: 14px;
object-fit: contain;
}
/* The tree parked while the search results panel owns the surface (the tree
must stay MOUNTED: its level cache is the whole point). */
.explorerHiddenPane {
display: none;
}
/* The hover-revealed @-reference button at the row's far right (and the
@@ -916,65 +980,92 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
text-align: center;
}
/* Drag a file/folder over a tree row: the target directory row gets a filled
highlight (drops land inside it; file rows target their parent directory).
The zone frame around the whole tree is the portaled .uploadDropZone. */
/* Drag a file/folder over a tree row: the target directory row takes the
accent-tinted fill and a dashed business edge (drops land inside it; file
rows target their parent directory). The zone frame around the whole tree
is the portaled .uploadDropZone. */
.explorerRowDropTarget {
background: var(--dsw-alias-interactive-bg-hover);
outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent);
background: var(--dsw-alias-interactive-bg-hover-accent);
outline: 1px dashed var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
/* The drop mask: one pointer-inert scrim over the whole viewport, portaled
to document.body BELOW the zone frame — the panel-host float layer is
z-1000 (DSH's own page mask sits there too), so 1001/1002 keep this
transient surface on top. The mask is deliberately light: the conversation
column beneath it still takes drops into the chat natively, and the user
never loses sight of the tree. */
.uploadDropMask {
position: fixed;
inset: 0;
z-index: 1001;
pointer-events: none;
background: color-mix(in srgb, var(--dsw-alias-bg-base) 62%, transparent);
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The sidebar's drop surface: a pointer-inert frame portaled to
document.body, fixed over the explorer body's viewport rect at z-1001 —
above DSH's own whole-page drop mask (z-1000, see InputBar's
document-level intake). The giant box-shadow spread IS the full-viewport
mask (same token DSH uses for its drop mask): everything dims except the
zone rect, teaching the zone split — the dimmed conversation column still
takes drops into the chat natively, the clear frame marks the tree as the
workspace-upload zone. Deliberate exception to the "panel stays below the
DSH float stack" rule: transient and pointer-events none, so the drop
always lands on the row beneath. The hint pill docks at the TOP edge of
the zone (right under the search row, the first thing the eye meets),
leaving the rows — the actual drop targets — aimable. */
document.body, fixed over the explorer body's viewport rect. The hint pill
docks at the TOP edge of the zone (right under the search row, the first
thing the eye meets), leaving the rows — the actual drop targets —
aimable. */
.uploadDropZone {
position: fixed;
z-index: 1001;
z-index: 1002;
pointer-events: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px;
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
border-radius: 10px;
box-shadow: 0 0 0 200vmax var(--dsw-alias-bg-mask-drop);
border: 2px dashed var(--dsw-alias-border-l2);
border-radius: 8px;
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 4%, transparent);
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The zone's drop hero, docked at the TOP edge (right under the search
row, the first thing the eye meets): the colorful tray illustration over
the hint pill — stacked so the rows below stay aimable. */
row, the first thing the eye meets): the tray illustration over the hint
pill — stacked so the rows below stay aimable. */
.uploadDropHero {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
gap: 8px;
max-width: 100%;
padding-top: 8px;
}
/* The drop art's two inks (the SVG strokes/fills take them via
currentColor): the accent for the rising arrow and the front card, a quiet
tint for the tray body and the back card, and the surface ink for the
motifs cut out of the cards. */
.uploadDropArtPrimary {
color: var(--dsw-alias-state-business-primary);
}
.uploadDropArtAccent {
color: var(--dsw-alias-state-business-tertiary);
}
.uploadDropArtCut {
color: var(--dsw-alias-bg-layer-1);
}
.uploadDropZonePill {
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
height: 28px;
max-width: 100%;
padding: 6px 12px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
background: var(--dsw-alias-bg-layer-1);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
font: var(--dsw-font-xxxs-11);
}
.uploadDropZoneText {
@@ -991,7 +1082,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
top: 0;
left: 0;
bottom: 0;
z-index: 1002;
z-index: 1003;
pointer-events: none;
display: flex;
align-items: center;
@@ -1000,19 +1091,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The invitation's content: the colorful photo-cards illustration over the
label — the drop overlay is this flow's one brand moment, so it gets the
color the rest of the UI never does (palette borrowed from DSH's native
drop illustration so the two zones read as one family). */
/* The invitation's content: the photo-cards illustration over the label. */
.uploadDropChatCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
gap: 10px;
max-width: 100%;
text-align: center;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-strong-14);
font: var(--dsw-font-xxs-strong-12);
}
/* Full-window upload progress: blurred scrim + card (same mask/backdrop
@@ -1033,12 +1121,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayCard {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 280px;
gap: 10px;
min-width: 240px;
max-width: min(420px, calc(100% - 48px));
padding: 20px 24px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 24px;
padding: 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}
@@ -1046,8 +1134,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayTitle {
display: flex;
align-items: center;
gap: 8px;
font: var(--dsw-font-s-strong-14);
gap: 6px;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
@@ -1078,7 +1166,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayStatus {
min-height: 1em;
font: var(--dsw-font-xxs-12);
font: var(--dsw-font-xxxs-11);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
@@ -1089,9 +1177,9 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayCancel {
align-self: flex-end;
height: 28px;
padding: 0 14px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
@@ -1254,13 +1342,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.editorSearchResult {
display: block;
box-sizing: border-box;
width: 100%;
padding: 4px 8px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: 6px;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
line-height: 28px;
text-align: left;
cursor: pointer;
overflow: hidden;
@@ -1355,18 +1446,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;
@@ -2085,11 +2164,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
}
/* The file tree "open with" menu rows: the label area is a BLOCK-level flex
row that fills the primitives `.itemLabel` span (which is itself `flex: 1`
inside the row), so the trailing control — the pushpin on submenu rows,
the chevron on the submenu parent — docks at the row's far right, exactly
like the trailing check on selected rows of every other menu. */
/* The file tree's plugin "open with" menu rows: the label area is a
BLOCK-level flex row that fills the primitives `.itemLabel` span (which is
itself `flex: 1` inside the row), so the trailing control — the pushpin on
submenu rows, the chevron on the submenu parent — docks at the row's far
right, exactly like the trailing check on selected rows of every other
menu. */
.openWithLabel {
display: flex;
align-items: center;
+327
View File
@@ -0,0 +1,327 @@
/**
* The plugin's ONE git-status store.
*
* Before this module the git status was fetched by the changes tab alone
* (a 2 s poll inside `changes/GitLens.tsx`) while the file tree had no git
* data at all — so a VS Code-style "color the changed rows" feature would
* have added a SECOND poller of the same `git status --porcelain` call. The
* store keeps one snapshot per `sessionId + cwd + worktree`, one poller per
* key while at least one VISIBLE consumer is subscribed, and one path index
* built lazily per snapshot, so the tree (coloring) and the changes page
* (list) read the same answer.
*
* The path index is keyed case-insensitively with '/' separators: git reports
* repo-root-relative POSIX paths while the tree rows carry host paths (a
* Windows host mixes separators), and a color lookup must not depend on that
* difference. The cost is that two paths differing only in case share an
* entry — invisible in the tree, which lists one of them anyway.
*
* Coalescing: a `refresh()` that lands while a fetch is in flight is queued
* once and re-run when it settles — never dropped (the old `GitLens.refresh`
* returned early, so a click during the poll silently did nothing).
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react'
import { api, type GitStatusResult } from '../api.ts'
/** The semantic class of one changed path (drives the row's ink). */
export type GitTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' | 'copied' | 'conflict'
/** One path's git state, derived from its two-letter porcelain code. */
export interface GitFileStatus {
/** Display letter: the index side wins, else the worktree side ('??' → 'U'). */
letter: string
tone: GitTone
/** The index (staged) side carries a change. */
staged: boolean
/** The worktree (unstaged) side carries a change. */
unstaged: boolean
}
/** The porcelain letter → tone rule (both sides share one vocabulary). */
function toneOfLetter(letter: string): GitTone | undefined {
switch (letter) {
case 'M': return 'modified'
case 'T': return 'modified'
case 'A': return 'added'
case 'D': return 'deleted'
case 'R': return 'renamed'
case 'C': return 'copied'
case 'U': return 'conflict'
default: return undefined
}
}
/**
* Derive one path's status from its porcelain `XY` code, or `undefined` for a
* clean (`' '`) or ignored (`'!!'`) entry. Untracked (`??`) reports 'U' —
* git's own letter for "not in the index" would be a blank.
*/
export function statusOfXY(xy: string): GitFileStatus | undefined {
if (xy === '??') return { letter: 'U', tone: 'untracked', staged: false, unstaged: true }
if (xy.length < 2 || xy === '!!') return undefined
const x = xy[0]!
const y = xy[1]!
if (x === ' ' && y === ' ') return undefined
const staged = x !== ' ' && x !== '?'
const unstaged = y !== ' ' && y !== '?'
// A conflict (any unmerged combination) outranks a plain modification: the
// user must act on it first, so it owns the row's ink.
const conflict = x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
const tone = conflict
? 'conflict'
: toneOfLetter(x !== ' ' && x !== '?' ? x : y) ?? 'modified'
return {
letter: x !== ' ' && x !== '?' ? x : y,
tone,
staged,
unstaged,
}
}
/** The store's normalized path key (case-insensitive, '/' separators). */
function normPath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
}
/** Join a repo-root-relative git path onto the repo root. */
function joinRoot(root: string, rel: string): string {
const base = root.replace(/[\\/]+$/, '')
return `${base}/${rel.replace(/^[\\/]+/, '')}`
}
/**
* Whether two status snapshots carry the same information. Compared field by
* field (never by serializing the whole object): the entries are the payload
* and their order is stable, so a length + pairwise compare is enough.
*/
function sameStatus(a: GitStatusResult, b: GitStatusResult): boolean {
if (a === b) return true
if (a.isRepo !== b.isRepo || a.branch !== b.branch || a.root !== b.root
|| a.truncated !== b.truncated || a.entries.length !== b.entries.length) return false
if (a.repositories?.length !== b.repositories?.length) return false
if (a.repositories !== undefined && b.repositories !== undefined
&& a.repositories.some((root, index) => root !== b.repositories![index])) return false
return a.entries.every((entry, index) => {
const other = b.entries[index]!
return entry.path === other.path && entry.xy === other.xy
})
}
interface Index {
/** Normalized absolute path → status (files only). */
files: Map<string, GitFileStatus>
/** Normalized absolute directories that contain at least one changed file. */
dirs: Set<string>
}
/** Build the lookup index for one snapshot (once per snapshot, O(rows × depth)). */
function buildIndex(snapshot: GitStatusResult): Index {
const files = new Map<string, GitFileStatus>()
const dirs = new Set<string>()
const root = snapshot.root
if (!snapshot.isRepo || root === undefined) return { files, dirs }
for (const entry of snapshot.entries) {
const status = statusOfXY(entry.xy)
if (status === undefined) continue
const absolute = normPath(joinRoot(root, entry.path))
files.set(absolute, status)
let at = absolute.lastIndexOf('/')
while (at > 0) {
const dir = absolute.slice(0, at)
if (dirs.has(dir)) break
dirs.add(dir)
at = dir.lastIndexOf('/')
}
}
return { files, dirs }
}
interface Slot {
key: string
sessionId: string
cwd: string | undefined
worktree: string | undefined
snapshot: GitStatusResult | null
loading: boolean
error: boolean
version: number
/** Bumped per fetch so a late response from an older fetch is dropped. */
generation: number
running: boolean
pending: boolean
visible: number
pollMs: number
timer: number | undefined
listeners: Set<() => void>
index: Index | null
/** The snapshot the current index was built from (identity compare). */
indexSource: GitStatusResult | null
}
const slots = new Map<string, Slot>()
function slotOf(sessionId: string, cwd: string | undefined, worktree: string | undefined): Slot {
const key = `${sessionId}\u0000${cwd ?? ''}\u0000${worktree ?? ''}`
const existing = slots.get(key)
if (existing !== undefined) return existing
const slot: Slot = {
key, sessionId, cwd, worktree,
snapshot: null, loading: false, error: false, version: 0, generation: 0,
running: false, pending: false, visible: 0, pollMs: 2_500, timer: undefined,
listeners: new Set(),
index: null, indexSource: null,
}
slots.set(key, slot)
return slot
}
function notify(slot: Slot): void {
slot.version += 1
for (const listener of slot.listeners) listener()
}
/** Fetch one snapshot; a concurrent call just marks the slot dirty. */
function fetchSlot(slot: Slot): void {
if (slot.running) {
slot.pending = true
return
}
slot.running = true
const generation = ++slot.generation
slot.loading = slot.snapshot === null
notify(slot)
const scope = { sessionId: slot.sessionId, ...(slot.cwd !== undefined ? { cwd: slot.cwd } : {}) }
api.gitStatus(scope, slot.worktree)
.then((snapshot) => {
if (generation !== slot.generation) return
// Idle polls return the same answer as a NEW object every 2.5s. Keeping
// the previous identity when nothing changed is what lets every
// downstream memo (the changes tree, the file tree's row model, the
// icon index) hold across ticks — without it the whole list re-rendered
// and re-resolved icons on every poll.
if (slot.snapshot !== null && sameStatus(slot.snapshot, snapshot)) {
slot.error = false
return
}
slot.snapshot = snapshot
slot.error = false
})
.catch(() => {
if (generation !== slot.generation) return
slot.error = true
})
.finally(() => {
if (generation !== slot.generation) return
slot.running = false
slot.loading = false
notify(slot)
if (slot.pending) {
slot.pending = false
fetchSlot(slot)
}
})
}
function startTimer(slot: Slot): void {
if (slot.timer !== undefined) return
slot.timer = window.setInterval(() => { fetchSlot(slot) }, slot.pollMs)
}
function stopTimer(slot: Slot): void {
if (slot.timer === undefined) return
window.clearInterval(slot.timer)
slot.timer = undefined
}
function retain(slot: Slot): void {
slot.visible += 1
if (slot.visible === 1) {
if (slot.snapshot === null) fetchSlot(slot)
startTimer(slot)
}
}
function release(slot: Slot): void {
slot.visible = Math.max(0, slot.visible - 1)
if (slot.visible === 0) {
stopTimer(slot)
// No consumer left: drop the snapshot so a later mount re-reads the truth
// instead of coloring rows from a stale answer.
slot.snapshot = null
slot.index = null
slot.indexSource = null
notify(slot)
}
}
/** The index for the current snapshot, rebuilt only when the snapshot changed. */
function indexOf(slot: Slot): Index {
if (slot.index === null || slot.indexSource !== slot.snapshot) {
slot.index = slot.snapshot === null ? { files: new Map(), dirs: new Set() } : buildIndex(slot.snapshot)
slot.indexSource = slot.snapshot
}
return slot.index
}
/**
* Force a re-read of one session's git status (after stage/commit/discard,
* or from a manual refresh). Queues one fetch when another is in flight.
* Every live key of that session is refreshed, so the tree and the changes
* page never disagree.
*/
export function invalidateGitStatus(sessionId: string): void {
for (const slot of slots.values()) {
if (slot.sessionId === sessionId) fetchSlot(slot)
}
}
/** The consumer-facing view of one key's status. */
export interface GitStatusView {
/** The last snapshot, or null before the first answer. */
snapshot: GitStatusResult | null
loading: boolean
error: boolean
/** Re-read now (queued when a fetch is already in flight). */
refresh(): void
/** The status of one absolute path, or undefined when clean/unknown. */
statusOf(absolutePath: string): GitFileStatus | undefined
/** Whether `absoluteDir` or anything under it has a change. */
dirHasChanges(absoluteDir: string): boolean
}
/**
* Subscribe to the shared git status of one session (optionally of one
* linked worktree). The poll runs while `visible` is true, at `pollMs`.
*/
export function useGitStatus(
scope: { sessionId: string; cwd?: string },
options: { worktree?: string; visible?: boolean; pollMs?: number } = {},
): GitStatusView {
const { worktree, visible = true, pollMs = 2_500 } = options
const slot = slotOf(scope.sessionId, scope.cwd, worktree)
const subscribe = useCallback((listener: () => void) => {
slot.listeners.add(listener)
return () => { slot.listeners.delete(listener) }
}, [slot])
useSyncExternalStore(subscribe, () => slot.version)
useEffect(() => {
slot.pollMs = pollMs
if (!visible) return undefined
retain(slot)
return () => { release(slot) }
}, [slot, visible, pollMs])
const refresh = useCallback(() => { fetchSlot(slot) }, [slot])
const statusOf = useCallback(
(absolutePath: string) => indexOf(slot).files.get(normPath(absolutePath)),
[slot],
)
const dirHasChanges = useCallback(
(absoluteDir: string) => indexOf(slot).dirs.has(normPath(absoluteDir)),
[slot],
)
return { snapshot: slot.snapshot, loading: slot.loading, error: slot.error, refresh, statusOf, dirHasChanges }
}
+13
View File
@@ -0,0 +1,13 @@
/**
* The global UI kit's public face. Pages import from HERE (`./ui/index.ts`)
* so the component set is one reviewable surface; the modules behind it stay
* free to split further without touching call sites.
*/
export {
Chip, ConfirmDialog, IconButton, Notice, SectionHeader, StatusBadge,
type StatusTone,
} from './kit.tsx'
export {
invalidateGitStatus, statusOfXY, useGitStatus,
type GitFileStatus, type GitStatusView, type GitTone,
} from './git-status.ts'
+219
View File
@@ -0,0 +1,219 @@
/*
* The global kit's sheet. Token-only by contract: `tests/theme.spec.ts`
* scans every `src/client/**\/*.module.css` and rejects a `color:`
* declaration whose value does not dereference a `--dsw-*` / `--ds-*` token.
*
* Scale (see docs/plans/2026-09-27-files-changes-uiux.md §1): 12px body,
* 11px meta, 28px controls, 2/3/4/8px radii — the tasks page's native scale,
* which the files and changes pages are being cut to.
*/
/* ── IconButton ─────────────────────────────────────────────────────────── */
.iconButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
border-radius: 3px;
}
.iconButtonMd {
width: 28px;
height: 28px;
}
.iconButtonSm {
width: 24px;
height: 24px;
border-radius: 3px;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
.iconButton:disabled {
opacity: 0.4;
cursor: default;
}
.iconButtonActive {
color: var(--dsw-alias-state-business-primary);
}
.iconButtonDanger {
color: var(--dsw-alias-state-error-primary);
}
/* ── Chip ───────────────────────────────────────────────────────────────── */
.chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: none;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-11);
white-space: nowrap;
cursor: pointer;
}
.chip:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.chip:disabled {
opacity: 0.4;
cursor: default;
}
/* Rest / hover / selected are three different pictures: the selected chip
keeps a filled accent-tinted surface AND the accent edge, so a hovered
neighbour never reads as selected. */
.chipActive,
.chipActive:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
border-color: var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-label-primary);
}
.chipLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.chipCount {
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
.chipActive .chipCount {
color: var(--dsw-alias-label-secondary);
}
/* ── SectionHeader ──────────────────────────────────────────────────────── */
.sectionHeader {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 28px;
padding: 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-secondary);
}
.sectionLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sectionCount {
flex: none;
font: var(--dsw-font-xxxs-11);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
}
/* The band's one trailing action is pushed to the right edge. */
.sectionAction {
margin-left: auto;
display: inline-flex;
align-items: center;
flex: none;
}
/* ── Notice ─────────────────────────────────────────────────────────────── */
.notice {
box-sizing: border-box;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
}
.noticeWell {
padding: 4px 12px 8px;
}
.noticePage {
padding: 16px;
text-align: center;
}
.noticeInline {
padding: 0;
font-size: 10px;
line-height: 1.45;
}
.noticeError {
color: var(--dsw-alias-state-error-primary);
}
.noticeWarn {
color: var(--dsw-alias-state-warn-primary);
}
.noticeHint {
color: var(--dsw-alias-label-tertiary);
}
/* ── ConfirmDialog ──────────────────────────────────────────────────────── */
.confirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.confirmDesc[data-danger='true'] {
color: var(--dsw-alias-label-primary);
}
/* ── StatusBadge ────────────────────────────────────────────────────────── */
.statusBadge {
flex: none;
padding: 0 4px;
border-radius: 2px;
font: var(--dsw-font-xxxs-strong-11);
font-family: var(--ds-font-family-code, monospace);
color: var(--dsw-alias-label-secondary);
}
.statusBadge[data-tone='modified'] { color: var(--dsw-alias-state-warn-primary); }
.statusBadge[data-tone='added'] { color: var(--dsw-alias-state-success-primary); }
.statusBadge[data-tone='untracked'] { color: var(--dsw-alias-state-success-primary); }
.statusBadge[data-tone='deleted'] { color: var(--dsw-alias-state-error-primary); }
.statusBadge[data-tone='renamed'] { color: var(--dsw-alias-state-business-primary); }
.statusBadge[data-tone='conflict'] { color: var(--dsw-alias-state-error-primary); }
.statusBadge[data-tone='read'] { color: var(--dsw-alias-state-success-primary); }
.statusBadge[data-tone='write'] { color: var(--dsw-alias-state-business-primary); }
.statusBadge[data-tone='edit'] { color: var(--dsw-alias-state-warn-primary); }
.statusBadge[data-tone='neutral'] { color: var(--dsw-alias-label-tertiary); }
+188
View File
@@ -0,0 +1,188 @@
/**
* The plugin's GLOBAL UI atoms — the one place the files page, the changes
* page, the tasks page and every future page take their row chrome, badges,
* state lines and confirmations from.
*
* Why they exist: the repo carried 14 duplicated empty/error/hint classes
* across four stylesheets (two of them byte-identical), two copies of the
* Cancel/Confirm modal, five icon-button recipes (including a class-NAME
* collision: `.iconButton` was a 28px circle in one sheet and a 24px square
* in another) and five chip/toggle recipes. These components own those
* pictures ONCE, cut to the plugin's native scale (see
* `docs/plans/2026-09-27-files-changes-uiux.md` §1): 12px body, 11px meta,
* 28px controls/rows, 2/3/4/8px radii, one accent.
*
* Skin contract: every paint comes from `kit.module.css` and resolves to a
* `--dsw-*` / `--ds-*` token (`tests/theme.spec.ts` scans the sheet).
*/
import { type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './kit.module.css'
/**
* A 28px (`md`) or 24px (`sm`) square glyph button — the ONE icon button of
* the plugin. Callers that need a pressed state pass `active`; the danger
* tone only recolors the glyph/hover, never the geometry.
*/
export function IconButton(props: {
icon: ReactNode
/** Accessible name; also the default hover title. */
label: string
title?: string
onClick(event: MouseEvent<HTMLButtonElement>): void
size?: 'md' | 'sm'
active?: boolean
disabled?: boolean
danger?: boolean
className?: string | undefined
}): ReactNode {
const { icon, label, title, onClick, size = 'md', active, disabled, danger } = props
return (
<button
type="button"
className={clsx(css.iconButton, size === 'sm' ? css.iconButtonSm : css.iconButtonMd,
active === true && css.iconButtonActive, danger === true && css.iconButtonDanger, props.className)}
aria-label={label}
title={title ?? label}
disabled={disabled}
aria-pressed={active}
onClick={(event) => { onClick(event) }}
>
{icon}
</button>
)
}
/**
* A small toggle chip (filters, list switches). `count` renders as a trailing
* figure in the meta rung; the chip's rest / hover / selected states are three
* different pictures (a bare fill swap reads as hover).
*/
export function Chip(props: {
children: ReactNode
active?: boolean
count?: number
title?: string
disabled?: boolean
onClick(): void
className?: string | undefined
}): ReactNode {
return (
<button
type="button"
className={clsx(css.chip, props.active === true && css.chipActive, props.className)}
data-active={props.active === true ? 'true' : undefined}
aria-pressed={props.active === true}
title={props.title}
disabled={props.disabled}
onClick={() => { props.onClick() }}
>
<span className={css.chipLabel}>{props.children}</span>
{props.count !== undefined && <span className={css.chipCount}>{props.count}</span>}
</button>
)
}
/**
* A 28px section band: a label (with an optional count) that may carry ONE
* trailing action. The hairline under it is the page's only section divider.
*/
export function SectionHeader(props: {
label: ReactNode
count?: number
action?: ReactNode
className?: string | undefined
children?: ReactNode
}): ReactNode {
return (
<div className={clsx(css.sectionHeader, props.className)}>
<span className={css.sectionLabel}>{props.label}</span>
{props.count !== undefined && <span className={css.sectionCount}>{props.count}</span>}
{props.children}
{props.action !== undefined && <span className={css.sectionAction}>{props.action}</span>}
</div>
)
}
/**
* The plugin's state lines: empty / loading / error / warn / hint.
* `tone` picks the geometry (`page` = centered 16px block, `well` = the
* compact 4/12/8 line inside a list, `inline` = 10px micro copy).
*/
export function Notice(props: {
kind: 'empty' | 'loading' | 'error' | 'warn' | 'hint'
tone?: 'page' | 'well' | 'inline'
role?: string
className?: string | undefined
children: ReactNode
}): ReactNode {
const tone = props.tone ?? 'well'
return (
<div
className={clsx(css.notice, tone === 'page' ? css.noticePage : tone === 'inline' ? css.noticeInline : css.noticeWell,
props.kind === 'error' && css.noticeError, props.kind === 'warn' && css.noticeWarn,
(props.kind === 'hint' || props.kind === 'loading') && css.noticeHint, props.className)}
data-kind={props.kind}
role={props.role ?? (props.kind === 'error' ? 'alert' : undefined)}
>
{props.children}
</div>
)
}
/**
* The ONE confirmation dialog: a quiet single-question body and a
* Cancel/Confirm footer, with `busy` locking both actions while the action
* runs. Destructive callers pass `danger` so the primary button reads as one.
*/
export function ConfirmDialog(props: {
open: boolean
title: string
description: string
confirmLabel: string
cancelLabel: string
danger?: boolean
busy?: boolean
onConfirm(): void
onClose(): void
}): ReactNode {
return (
<Modal
open={props.open}
onClose={props.onClose}
title={props.title}
closeLabel={props.cancelLabel}
footer={(
<>
<Button variant="outline" disabled={props.busy} onClick={props.onClose}>{props.cancelLabel}</Button>
<Button variant="primary" disabled={props.busy} onClick={props.onConfirm}>{props.confirmLabel}</Button>
</>
)}
>
<p className={css.confirmDesc} data-danger={props.danger === true ? 'true' : undefined}>{props.description}</p>
</Modal>
)
}
/**
* The status vocabulary shared by git rows, session-op rows and diff kinds.
* `neutral` is the fallback for a kind the caller has no tone for.
*/
export type StatusTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
| 'conflict' | 'read' | 'write' | 'edit' | 'neutral'
/** A compact status badge: a git letter, an op kind, a diff tag. */
export function StatusBadge(props: {
tone: StatusTone
title?: string
className?: string | undefined
children: ReactNode
}): ReactNode {
return (
<span className={clsx(css.statusBadge, props.className)} data-tone={props.tone} title={props.title}>
{props.children}
</span>
)
}
+13 -5
View File
@@ -13,7 +13,7 @@
* that one cached level and re-lists it. Folders the reader collapsed are
* un-watched, so a long session does not accumulate handles.
*/
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
/** One server frame: a stale directory, a watch verdict, or a refusal. */
interface FsWatchFrame {
@@ -47,15 +47,23 @@ const RETRY_MAX_MS = 15_000
export function useDirectoryWatch(options: DirectoryWatchOptions): void {
const { sessionId, root, dirs, onStale } = options
// The expanded set is a fresh array on every render, so neither the socket
// effect nor the staleness callback may depend on their identity.
const wantedRef = useRef<string[]>([])
wantedRef.current = root === undefined ? [...dirs] : [root, ...dirs]
// effect nor the staleness callback may depend on their identity. The
// CONTENT key (NUL-joined paths — no path can contain one) is what the
// arrays are derived from, so a re-render with the same expanded set
// rebuilds neither the wanted list nor the effect dependency.
const dirsKey = dirs.join('\u0000')
const wanted = useMemo(() => {
const listed = dirsKey === '' ? [] : dirsKey.split('\u0000')
return root === undefined ? listed : [root, ...listed]
}, [root, dirsKey])
const wantedRef = useRef(wanted)
wantedRef.current = wanted
const staleRef = useRef(onStale)
staleRef.current = onStale
/** Set by the socket effect; reconciles the host's watch set with the tree's. */
const reconcileRef = useRef<() => void>(() => {})
// One string per expanded-set change: a stable dependency for the effect below.
const wantedKey = wantedRef.current.join('\u0000')
const wantedKey = useMemo(() => wanted.join('\u0000'), [wanted])
useEffect(() => {
if (sessionId === undefined) return
+9 -2
View File
@@ -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<SidebarPrefs>` (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<SidebarPrefs> = z.object({
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
@@ -85,7 +93,6 @@ export const PrefsSchema: z<SidebarPrefs> = 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(),
+91 -53
View File
@@ -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<string | Uint8Array>
/** 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<boolean> {
* 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,16 +175,58 @@ 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 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
/** @deprecated IGNORED — containment was removed. Do not pass it. */
fence?: boolean
}
/**
* Create one directory inside an existing tree row: `<path>/<name>`.
* 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 row may
* be any directory the host user can write.
*
* @throws SidebarError with a wire code for shape and existence failures.
*/
export async function mkdirWorkspaceEntry(input: WorkspaceMkdirInput): Promise<{ path: string }> {
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)
const destination = await ensureWorkspaceWritePath(cwd, join(absolute, name))
if (await pathExists(destination)) {
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
}
try {
await mkdir(destination)
} 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
}
@@ -198,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)
}
@@ -214,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 }
}
+146 -54
View File
@@ -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 `"<absoluteDir>\0<cap>"`.
*
* 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<string, { at: number; listing: SidebarFsListing }>()
/** 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<SidebarFsListing> {
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<SidebarFsListing> {
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<SidebarFsListing> {
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<void> {
@@ -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(/\/$/, '')
+13 -1
View File
@@ -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()
+135 -36
View File
@@ -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,9 +28,9 @@ 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 { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
import { mkdirWorkspaceEntry, renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
@@ -38,6 +38,8 @@ import { isTrustedApiRequest } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import { createDirectoryWatchers, type DirectoryWatchers } from './fs-watch.ts'
import { launchExternal } from './open-external.ts'
import { archiveNameOf, collectZipEntries, createArchiveTasks, disambiguateArchiveNames, respondArchiveDownload, type ArchiveTasks } from './archive-route.ts'
import type { ZipEntry } from './zip.ts'
import * as git from './git.ts'
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
@@ -66,10 +68,25 @@ export type {
FileViewerProps,
FileFetchStrategy,
} from './client/service.ts'
// The archive walk + name helpers are exercised directly by tests/zip.spec.ts:
// their bounds live inside the collector, so the suite needs them observable
// without creating 10 000 rows first.
export { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts'
/** 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']
@@ -230,24 +247,12 @@ export interface SidebarSettingsFace {
update(patch: Record<string, unknown>, 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<string, unknown>).workspaceFence !== false
}
function buildApi(
ctx: Context,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
assistantLive: AssistantLiveBuffer,
archiveTasks: ArchiveTasks,
): Record<string, ApiMethod> {
const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => {
const sessionId = requireString(payload, 'sessionId')
@@ -286,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<SidebarFsLevel> => {
// 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
@@ -304,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 {
@@ -322,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
@@ -333,7 +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 rules as rename.
'fs.mkdir': async (payload) => {
const { cwd } = await cwdOf(payload)
return mkdirWorkspaceEntry({
cwd,
path: requireString(payload, 'path'),
name: requireString(payload, 'name'),
})
},
// The tree row's delete (permanent — the host has no trash): recursive
@@ -343,7 +387,6 @@ function buildApi(
return removeWorkspaceEntry({
cwd,
path: requireString(payload, 'path'),
fence: fenceEnabledOf(getSettings),
})
},
'git.worktrees': async (payload) => {
@@ -532,6 +575,31 @@ function buildApi(
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
},
// Archive builds: collect the selection (fenced + disambiguated) and hand
// the zipping to the background task table, so the tree can show progress
// through `archive.status` and download the finished bytes from
// GET /sidebar/archive?id=.
'archive.build': async (payload) => {
const { sessionId, cwd } = await cwdOf(payload)
const record = payload as { paths?: unknown; name?: 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')
const name = archiveNameOf(typeof record?.name === 'string' ? record.name : null)
const selected: string[] = []
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[] = []
const names = disambiguateArchiveNames(selected)
for (const [index, absolute] of selected.entries()) {
await collectZipEntries(absolute, names[index]!, entries)
}
return archiveTasks.start({ sessionId, name, entries })
},
'archive.status': (payload) =>
archiveTasks.status(requireString(payload, 'id'), requireString(payload, 'sessionId')),
// Side Chat: create a side-thread child seeded with the parent's full
// log up to now, deliver follow-ups (cold-resuming when the thread's
// agent is gone), abort a running thread, and release a thread's agent.
@@ -820,7 +888,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// effect releases the listener on fiber disposal.
const assistantLive = createAssistantLiveBuffer(ctx)
ctx.effect(() => () => { assistantLive.dispose() }, 'dsh-better-sidebar: live assistant stream buffer')
const api = buildApi(ctx, resolved, () => settingsFace, assistantLive)
// One archive task table per mount: the `archive.build` / `archive.status`
// API methods write it, the GET /sidebar/archive route reads it.
const archiveTasks = createArchiveTasks()
const api = buildApi(ctx, resolved, () => settingsFace, assistantLive, archiveTasks)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
@@ -885,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) {
@@ -900,6 +970,38 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// (see bundle-route.ts / src/client/chunk-loader.ts).
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
// ── Archive routes (zip one selection, watch it build, download it) ─────
// The file tree's "zip and download" action is a three-step flow: POST
// `archive.build` fences every `path` through the SAME workspace fence as
// fs.tree / /sidebar/file, walks the directories (a symlink is skipped, never
// followed — no escape and no cycle) and returns an id IMMEDIATELY; the
// background build reports progress through `archive.status`; and
// GET /sidebar/archive serves the finished bytes once, with the RFC 5987
// disposition. `archiveTasks` owns the concurrency cap and the TTL.
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: '/sidebar/archive',
handler: async (req, res) => {
if (!fence(req)) {
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
return
}
if (req.method !== 'GET') {
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
respondArchiveDownload(archiveTasks, {
sessionId: url.searchParams.get('sessionId'),
id: url.searchParams.get('id'),
}, res)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/archive route')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
@@ -921,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)
@@ -942,6 +1044,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
// Serves files under the session cwd for the built-in HTML previewer. The
// URL is path-encoded (see html-route.ts) so the previewed page's relative
@@ -980,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)
@@ -1029,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',
@@ -1040,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')
@@ -1066,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<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
@@ -1097,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))
@@ -1111,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,
@@ -1119,7 +1219,6 @@ async function handleFsWatchFrame(
watchers: DirectoryWatchers,
sessionId: string,
data: unknown,
fenceEnabled: () => boolean,
): Promise<void> {
let frame: FsWatchFrame
try {
@@ -1131,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
+53 -70
View File
@@ -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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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))
}
-15
View File
@@ -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: '',
+300
View File
@@ -0,0 +1,300 @@
/**
* Archive builder for the file tree's "zip and download" action.
*
* One self-contained ZIP writer: local file headers + central directory +
* EOCD, CRC-32 computed locally, `store` (method 0) or `zlib`'s raw DEFLATE
* (method 8) picked per entry by whichever is smaller, names written as
* UTF-8 (general-purpose bit 11) and DOS timestamps taken from one `Date`
* for the whole archive. No third-party dependency, no filesystem walking —
* the caller supplies the entries, this module only reads their bytes.
*
* The entries are bounded by `maxEntries` / `maxBytes`: the count is checked
* up front, and every source is stat'ed against the remaining byte budget
* BEFORE it is read, so a runaway selection fails with a clear error instead
* of filling memory with an archive nobody asked for.
*/
import { readFile, stat } from 'node:fs/promises'
import { deflateRaw } from 'node:zlib'
import { promisify } from 'node:util'
import { SidebarError } from './wire.ts'
/** One archive member: a file to read, or a directory to record. */
export interface ZipEntry {
/** Absolute filesystem path (source of the bytes). Ignored for a directory
* entry (`isDir: true`), which only contributes its own header row. */
path: string
/** In-archive name: '/'-separated and relative. A directory name must end
* with '/'. */
name: string
/** Write a directory entry (zero-length name + trailing '/') instead of
* reading the path as a file. */
isDir?: boolean
}
/** One progress report: entries finished, entries total, bytes read so far. */
export interface ZipProgress {
/** Entries fully processed (payload read, compressed, hashed). */
done: number
/** Entries the archive will contain (known up front: the entries array). */
total: number
/** Uncompressed bytes accumulated so far. */
bytes: number
}
/** The archive bounds and the optional progress hook. */
export interface ZipOptions {
/** Total uncompressed byte bound; exceeding it throws fs-error. */
maxBytes?: number
/** Entry-count bound (directory entries included); exceeding it throws
* fs-error. */
maxEntries?: number
/** Called once per finished entry, in archive order (never for a failed
* one). A throw from the hook aborts the build. */
onProgress?: (progress: ZipProgress) => void
}
/** Default total uncompressed payload bound of one archive (256 MiB). */
export const ZIP_MAX_BYTES = 256 * 1024 * 1024
/** Default entry-count bound of one archive. */
export const ZIP_MAX_ENTRIES = 10_000
/** DOS epoch (1980-01-01) — the smallest representable DOS timestamp. */
const DOS_YEAR_BASE = 1980
/** CRC-32 (IEEE 802.3, the ZIP polynomial), table built once per process. */
const CRC_TABLE = ((): Uint32Array => {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i += 1) {
let value = i
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0 ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1)
}
table[i] = value >>> 0
}
return table
})()
/** CRC-32 of one buffer. */
export function crc32(data: Uint8Array): number {
let crc = 0xFFFFFFFF
for (let i = 0; i < data.length; i += 1) {
crc = CRC_TABLE[(crc ^ data[i]!) & 0xFF]! ^ (crc >>> 8)
}
return (crc ^ 0xFFFFFFFF) >>> 0
}
/** One prepared member: the bytes to store plus their header fields. */
interface ZipMember {
name: Buffer
data: Buffer
crc: number
method: 0 | 8
/** Uncompressed size (the local/central header field). */
size: number
}
const deflateRawAsync = promisify(deflateRaw)
/** DOS date + time words for one instant (clamped to the 1980 epoch). */
function dosDateTime(date: Date): { time: number; date: number } {
const year = Math.max(date.getFullYear(), DOS_YEAR_BASE)
const month = date.getMonth() + 1
const day = date.getDate()
const hours = date.getHours()
const minutes = date.getMinutes()
const seconds = date.getSeconds()
return {
time: (hours << 11) | (minutes << 5) | (seconds >> 1),
date: ((year - DOS_YEAR_BASE) << 9) | (month << 5) | day,
}
}
/**
* Normalize an entry name to a '/'-separated relative archive path.
*
* A backslash is NOT a separator here: on POSIX (and on the host half, which
* builds the names) `a\b.txt` is one legal file name, so converting it would
* silently split one file into a directory. Windows separators never reach
* this point — the walk composes names from `basename`/`join` results.
* Leading slashes are dropped (names are archive-relative by definition) and
* `..` segments are refused, so a name can never traverse.
*/
function archiveName(raw: string, isDir: boolean): string {
const normalized = raw.replace(/^\/+/, '')
const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.')
if (raw === '..' || segments.length === 0 || segments.includes('..')) {
throw new SidebarError('bad-request', `invalid archive entry name "${raw}"`)
}
const name = segments.join('/')
return isDir ? `${name}/` : name
}
/** The absolute filesystem path shape the builder accepts. */
function requireSourcePath(raw: string): string {
if (raw === '' || raw.includes('\0')) {
throw new SidebarError('bad-request', 'archive entry path is required')
}
return raw
}
/**
* Read + compress one member (deflate only when it actually shrinks).
* @param limit - the archive's total uncompressed ceiling (for the message).
* @param budget - uncompressed bytes still available; the source is stat'ed
* FIRST and refused when it cannot fit, so an oversized archive never pulls
* the payload into memory (let alone deflates it) before failing.
*/
async function prepareFile(name: string, path: string, limit: number, budget: number): Promise<ZipMember> {
let size: number
try {
size = (await stat(path)).size
} catch (error) {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
// `budget <= 0` refuses even a zero-size source. That is not pedantry: a
// FIFO stats as 0 bytes and reading one BLOCKS until a writer shows up, so
// an exhausted budget must never reach open().
if (budget <= 0 || size > budget) {
throw new SidebarError('fs-error', `archive exceeds the ${limit} byte limit`, 400)
}
let data: Buffer
try {
data = await readFile(path)
} catch (error) {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
const crc = crc32(data)
const deflated = await deflateRawAsync(data)
const useDeflate = deflated.byteLength < data.byteLength
return {
name: Buffer.from(name, 'utf8'),
data: useDeflate ? deflated : data,
crc,
method: useDeflate ? 8 : 0,
size: data.byteLength,
}
}
/** One directory member (zero payload, name already carries the '/'). */
function directoryMember(name: string): ZipMember {
return { name: Buffer.from(name, 'utf8'), data: Buffer.alloc(0), crc: 0, method: 0, size: 0 }
}
/** Write one local file header + payload into `out`. */
function writeLocalHeader(out: Buffer[], member: ZipMember, time: number, date: number): void {
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034B50, 0)
header.writeUInt16LE(20, 4) // version needed: 2.0 (deflate)
header.writeUInt16LE(0x0800, 6) // general purpose: UTF-8 names (bit 11)
header.writeUInt16LE(member.method, 8)
header.writeUInt16LE(time, 10)
header.writeUInt16LE(date, 12)
header.writeUInt32LE(member.crc, 14)
header.writeUInt32LE(member.data.byteLength, 18)
header.writeUInt32LE(member.size, 22)
header.writeUInt16LE(member.name.byteLength, 26)
header.writeUInt16LE(0, 28) // extra length
out.push(header, member.name, member.data)
}
/**
* Build one ZIP archive from the given entries.
* @param entries - files to read and/or directory rows to record; a directory
* row contributes its own header, its children are separate entries.
* @param opts - entry-count and total-byte bounds ({@link ZipOptions}).
* @returns the ZIP bytes.
* @throws {SidebarError} bad-request for malformed entry names/paths,
* fs-error when an entry cannot be read or a bound is exceeded.
*/
export async function buildZip(entries: readonly ZipEntry[], opts: ZipOptions = {}): Promise<Buffer> {
const maxEntries = opts.maxEntries ?? ZIP_MAX_ENTRIES
const maxBytes = opts.maxBytes ?? ZIP_MAX_BYTES
if (entries.length > maxEntries) {
throw new SidebarError('fs-error', `too many entries for one archive (${entries.length} > ${maxEntries})`, 400)
}
const now = new Date()
const stamp = dosDateTime(now)
const members: ZipMember[] = []
/** Total uncompressed payload (the bound is checked against it). */
let totalBytes = 0
/** Entries finished so far (the progress hook's `done`). */
let done = 0
const report = (): void => {
opts.onProgress?.({ done, total: entries.length, bytes: totalBytes })
}
for (const entry of entries) {
if (entry.isDir === true) {
members.push(directoryMember(archiveName(entry.name, true)))
done += 1
report()
continue
}
const path = requireSourcePath(entry.path)
// The bound is the archive's total UNCOMPRESSED payload: that is what the
// reader materializes, and it makes the limit independent of how well the
// input happens to compress.
const member = await prepareFile(archiveName(entry.name, false), path, maxBytes, maxBytes - totalBytes)
// A source can grow AFTER its stat (sparse files, concurrent writers), so
// the ceiling is re-checked against what was actually read.
if (totalBytes + member.size > maxBytes) {
throw new SidebarError('fs-error', `archive exceeds the ${maxBytes} byte limit`, 400)
}
totalBytes += member.size
members.push(member)
done += 1
report()
}
const out: Buffer[] = []
const offsets: number[] = []
let offset = 0
for (const member of members) {
offsets.push(offset)
writeLocalHeader(out, member, stamp.time, stamp.date)
offset += 30 + member.name.byteLength + member.data.byteLength
}
const directoryStart = offset
members.forEach((member, index) => {
const isDir = member.name.toString('utf8').endsWith('/')
const header = Buffer.alloc(46)
header.writeUInt32LE(0x02014B50, 0)
header.writeUInt16LE(0x031E, 4) // version made by: 3.0 / Unix
header.writeUInt16LE(20, 6) // version needed: 2.0
header.writeUInt16LE(0x0800, 8) // general purpose: UTF-8 names (bit 11)
header.writeUInt16LE(member.method, 10)
header.writeUInt16LE(stamp.time, 12)
header.writeUInt16LE(stamp.date, 14)
header.writeUInt32LE(member.crc, 16)
header.writeUInt32LE(member.data.byteLength, 20)
header.writeUInt32LE(member.size, 24)
header.writeUInt16LE(member.name.byteLength, 28)
header.writeUInt16LE(0, 30) // extra length
header.writeUInt16LE(0, 32) // comment length
header.writeUInt16LE(0, 34) // disk number
header.writeUInt16LE(0, 36) // internal attributes
// "version made by" says Unix, so the high word is the Unix mode and the
// low word carries the MS-DOS attributes. Both halves matter: without the
// mode, Info-ZIP extracts every file as mode 000 (unreadable). The mode is
// scaled with `* 0x10000` rather than `<< 16`: the shift is a signed int32
// operation and `0o100644 << 16` is negative, which writeUInt32LE rejects.
const mode = isDir ? 0o40755 : 0o100644
header.writeUInt32LE(mode * 0x10000 + (isDir ? 0x10 : 0), 38)
header.writeUInt32LE(offsets[index]!, 42)
out.push(header, member.name)
offset += 46 + member.name.byteLength
})
const eocd = Buffer.alloc(22)
eocd.writeUInt32LE(0x06054B50, 0)
eocd.writeUInt16LE(0, 4) // this disk
eocd.writeUInt16LE(0, 6) // disk with the central directory
eocd.writeUInt16LE(members.length, 8)
eocd.writeUInt16LE(members.length, 10)
eocd.writeUInt32LE(offset - directoryStart, 12)
eocd.writeUInt32LE(directoryStart, 16)
eocd.writeUInt16LE(0, 20) // comment length
out.push(eocd)
return Buffer.concat(out)
}
+10 -4
View File
@@ -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,12 +152,17 @@ 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()
// The plugin-owned row (its value lives in pluginSettings['editor'], which
// EditorHost reads as `openWithPluginTargets`): a plain switch, default
// off = the host-first behavior.
const pluginToggles = service.getTab('editor')?.settings?.pluginToggles ?? []
expect(pluginToggles.map(t => t.key)).toEqual(['openWithPluginTargets'])
expect(pluginToggles[0]?.title).toBeDefined()
expect(pluginToggles[0]?.desc).toBeDefined()
expect(pluginToggles[0]?.type).toBeUndefined()
})
it('every built-in tab carries the settings-surface icon', () => {
+133 -3
View File
@@ -1,13 +1,16 @@
/**
* Changes tab session-lens data layer: event-log folding into file
* operations, plus a sanity pass over the shared diff/highlight engines
* (their full behavior suites live in the standalone dsh-file-trace plugin).
* Changes tab data layer: event-log folding into file operations, the
* directory tree the Git lens renders its groups through, and a sanity pass
* over the shared diff/highlight engines (their full behavior suites live in
* the standalone dsh-file-trace plugin).
*/
import { describe, expect, it } from 'vitest'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { extractFileOps, groupByFile, knownContentBefore, parseReadContent, parseReadLines } from '../src/client/changes/ops.ts'
import { buildChangeTree, type ChangeNode } from '../src/client/changes/change-tree.ts'
import { diffLines, buildDiffSegments, coalesceInline, diffInline } from '../src/client/diff/rows.ts'
import { langOfPath, scanLine } from '../src/client/diff/highlight.ts'
import type { GitStatusEntry } from '../src/client/api.ts'
import type { SidebarSessionEvent } from '../src/context-types.ts'
/** One synthetic session event. */
@@ -157,6 +160,133 @@ describe('parseReadLines', () => {
})
})
describe('change tree (the Git lens reads its groups through it)', () => {
/** One `git status` row from a path and its porcelain code. */
const entry = (path: string, xy = ' M'): GitStatusEntry => ({ path, xy })
/** A compact, assertion-friendly projection of one tree. */
const shape = (nodes: readonly ChangeNode[]): unknown[] => nodes.map(node => node.kind === 'dir'
? { dir: node.name, path: node.path, changes: node.changes, children: shape(node.children) }
: { file: node.name, path: node.path, letter: node.status.letter, tone: node.status.tone })
it('keeps a single root-level file as one leaf', () => {
expect(shape(buildChangeTree([entry('readme.md')]))).toEqual([
{ file: 'readme.md', path: 'readme.md', letter: 'M', tone: 'modified' },
])
})
it('nests a deep path and compresses its single-child chain into one row', () => {
const tree = buildChangeTree([entry('src/client/changes/a.ts')])
expect(shape(tree)).toEqual([
{
dir: 'src/client/changes',
path: 'src/client/changes',
changes: 1,
children: [{ file: 'a.ts', path: 'src/client/changes/a.ts', letter: 'M', tone: 'modified' }],
},
])
})
it('stops compressing where the path branches or a directory holds its own file', () => {
// Two child directories: 'src' keeps its own row.
expect(shape(buildChangeTree([entry('src/a/x.ts'), entry('src/b/y.ts')]))).toEqual([
{
dir: 'src',
path: 'src',
changes: 2,
children: [
{ dir: 'a', path: 'src/a', changes: 1, children: [{ file: 'x.ts', path: 'src/a/x.ts', letter: 'M', tone: 'modified' }] },
{ dir: 'b', path: 'src/b', changes: 1, children: [{ file: 'y.ts', path: 'src/b/y.ts', letter: 'M', tone: 'modified' }] },
],
},
])
// A file AT this level: 'a' keeps its own row (and its file leads the child).
expect(shape(buildChangeTree([entry('a/x.ts'), entry('a/b/y.ts')]))).toEqual([
{
dir: 'a',
path: 'a',
changes: 2,
children: [
{ dir: 'b', path: 'a/b', changes: 1, children: [{ file: 'y.ts', path: 'a/b/y.ts', letter: 'M', tone: 'modified' }] },
{ file: 'x.ts', path: 'a/x.ts', letter: 'M', tone: 'modified' },
],
},
])
})
it('orders directories before files, each by name case-insensitively', () => {
const tree = buildChangeTree([
entry('Zed.ts'), entry('B.ts'), entry('a.ts'), entry('zdir/f.ts'), entry('Adir/f.ts'),
])
expect(shape(tree).map(node => (node as { dir?: string; file?: string }).dir ?? (node as { file: string }).file))
.toEqual(['Adir', 'zdir', 'a.ts', 'B.ts', 'Zed.ts'])
})
it('mixes root-level files with nested paths and keeps same-named files apart', () => {
expect(shape(buildChangeTree([
entry('b/index.ts'), entry('index.ts'), entry('a/index.ts'),
]))).toEqual([
{ dir: 'a', path: 'a', changes: 1, children: [{ file: 'index.ts', path: 'a/index.ts', letter: 'M', tone: 'modified' }] },
{ dir: 'b', path: 'b', changes: 1, children: [{ file: 'index.ts', path: 'b/index.ts', letter: 'M', tone: 'modified' }] },
{ file: 'index.ts', path: 'index.ts', letter: 'M', tone: 'modified' },
])
})
it('carries every file porcelain status and counts the files under each row', () => {
const tree = buildChangeTree([
entry('src/a.ts', ' M'), entry('src/new.ts', '??'), entry('src/gone.ts', ' D'), entry('src/kept.ts', ' '),
])
expect(shape(tree)).toEqual([
{
dir: 'src',
path: 'src',
changes: 3,
children: [
{ file: 'a.ts', path: 'src/a.ts', letter: 'M', tone: 'modified' },
{ file: 'gone.ts', path: 'src/gone.ts', letter: 'D', tone: 'deleted' },
{ file: 'new.ts', path: 'src/new.ts', letter: 'U', tone: 'untracked' },
],
},
])
})
it('pins the collation, so the order cannot move with the runtime locale', () => {
// Mixed case + accents + non-letters: the comparator runs an explicit
// 'en' base collation and breaks ties by code point. A bare
// `localeCompare(other)` would follow the machine's ICU default and could
// reorder the same change list on another machine.
const names = ['b.ts', 'A.ts', 'ä.ts', 'Z.ts', 'a.ts', 'Ä.ts', '_x.ts', '1.ts']
const order = (list: readonly string[]): string[] =>
buildChangeTree(list.map(path => entry(path))).map(node => node.name)
expect(order(names)).toEqual(['_x.ts', '1.ts', 'A.ts', 'a.ts', 'Ä.ts', 'ä.ts', 'b.ts', 'Z.ts'])
// …and it is a TOTAL order: reversing the input cannot move a row.
expect(order([...names].reverse())).toEqual(order(names))
const dirs = ['Zdir/f.ts', 'adir/f.ts', 'Ädir/f.ts', '_dir/f.ts']
const dirOrder = (list: readonly string[]): string[] =>
buildChangeTree(list.map(path => entry(path))).map(node => node.name)
expect(dirOrder(dirs)).toEqual(['_dir', 'adir', 'Ädir', 'Zdir'])
expect(dirOrder([...dirs].reverse())).toEqual(dirOrder(dirs))
// Why the pin is load-bearing: under Swedish collation 'ä' sorts AFTER
// 'z', so an unpinned comparator would order this very list differently on
// a machine whose runtime locale happens to be Swedish.
expect(['z.ts', 'ä.ts'].sort((left, right) => left.localeCompare(right, 'sv', { sensitivity: 'base' })))
.toEqual(['z.ts', 'ä.ts'])
})
it('returns no nodes for an empty or all-clean list', () => {
expect(buildChangeTree([])).toEqual([])
expect(buildChangeTree([entry('a.ts', ' '), entry('b.ts', '!!')])).toEqual([])
})
it('counts a repeated path once', () => {
expect(shape(buildChangeTree([entry('a.ts'), entry('a.ts', '??')]))).toEqual([
{ file: 'a.ts', path: 'a.ts', letter: 'M', tone: 'modified' },
])
})
})
describe('ported diff/highlight engines (sanity)', () => {
it('diffLines pairs a rewrite as mod and folds context into hunks', () => {
const rows = diffLines('hello world', 'hello dsh')
+266 -24
View File
@@ -1,18 +1,23 @@
/**
* The unified changes tab shell: the lens switcher swaps the Git lens for
* the session lens, a Git-lens file row opens the shared preview pane
* (loaded through the mocked git API, rendered by the shared DiffFiles
* stack), and the session ops ride the mocked `changes.ops` poll — the
* event poll, the op fold and the badge cache all live in the tab.
* The unified changes tab shell: the 36px header's lens switcher swaps the
* Git lens for the session lens, a Git-lens file row opens the shared preview
* pane (loaded through the mocked git API, rendered by the shared DiffFiles
* stack), and the session ops ride the mocked `changes.ops` poll — the event
* poll, the op fold and the badge cache all live in the tab.
*
* The copy assertions below pin the zh strings (the test environment's
* navigator may be a real Node one with an en locale), so a bare substring
* check cannot accidentally pass on a different message.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, 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 { ChangesTab, opCountOf } from '../src/client/changes/ChangesTab.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
import { t } from '../src/client/locales.ts'
import type { Context } from '../src/context-types.ts'
import type { SidebarTab } from '../src/client/state.ts'
@@ -42,7 +47,7 @@ function mount(root: Root, tab: SidebarTab = { id: 'git', type: 'git', title: 'C
store: createSidebarStore(),
scope: { sessionId: 'session', cwd: MAIN },
tab,
visible: false,
visible: true,
onOpenFile: () => {},
onOpenDiff: () => {},
}))
@@ -60,11 +65,21 @@ function mockGit(entries: Array<{ path: string; xy: string }>): void {
vi.spyOn(api, 'gitLog').mockResolvedValue([])
}
/** One lens tab of the header's segmented control. */
function lensTab(container: HTMLElement, label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')]
.find(tab => tab.textContent === label)
}
async function flushEffects(): Promise<void> {
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })
}
beforeEach(() => {
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
@@ -91,10 +106,17 @@ describe('ChangesTab', () => {
mount(root)
await flushEffects()
// Git lens: the file row from the mocked status (badge letter + path).
const row = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('src/a.ts'))
expect(row).toBeDefined()
// Git lens: the change reads as a TREE — the directory row owns the
// path segment, the file row its own name, its porcelain letter and the
// host's file artwork.
const dir = container.querySelector<HTMLButtonElement>('button[data-dir="src"]')
expect(dir).not.toBeNull()
expect(dir!.getAttribute('aria-expanded')).toBe('true')
const row = container.querySelector<HTMLButtonElement>('button[data-path="src/a.ts"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('a.ts')
expect(row!.textContent).toContain('M')
expect(row!.querySelector('svg')).not.toBeNull()
// Clicking the row previews inline (no diff tab minted): the shared
// renderer draws the added row.
@@ -103,11 +125,9 @@ describe('ChangesTab', () => {
expect(container.textContent).toContain('new')
// The preview survives a lens switch (it is a dock, not lens state).
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
expect(sessionButton).toBeDefined()
await act(async () => { sessionButton!.click() })
const sessionTab = lensTab(container, t('changesSessionLens'))
expect(sessionTab).toBeDefined()
await act(async () => { sessionTab!.click() })
expect(container.textContent).toContain('src/a.ts')
} finally {
act(() => { root.unmount() })
@@ -127,13 +147,13 @@ describe('ChangesTab', () => {
mount(root)
await flushEffects()
// The tab pulled once on mount and published the op count.
// Opening the tab pulls once and publishes the op count (the tab-strip
// badge reads this cache).
expect(ops).toHaveBeenCalled()
expect(opCountOf('session')).toBe(2)
const group = container.querySelector('[role="group"]')
const buttons = [...group!.querySelectorAll<HTMLButtonElement>('button')]
await act(async () => { buttons[1]!.click() })
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
// Two files grouped, newest first; the write chip carries its count.
expect(container.textContent).toContain('src/b.ts')
expect(container.textContent).toContain('src/a.ts')
@@ -168,10 +188,8 @@ describe('ChangesTab', () => {
await flushEffects()
// Session lens: open the read op's preview.
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
await act(async () => { sessionButton!.click() })
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="read"]')
expect(row).toBeDefined()
expect(container.textContent).toContain('notes.md')
@@ -201,4 +219,228 @@ describe('ChangesTab', () => {
container.remove()
}
})
it('renders exactly one clean empty state, and the history has its own', async () => {
mockGit([])
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// A clean repository says it ONCE: both sections (unstaged/staged) used
// to print their own "no changes" line under the same message.
const empties = [...container.querySelectorAll<HTMLElement>('[data-kind="empty"]')]
.map(node => node.textContent)
expect(empties).toEqual([t('changesClean'), t('changesNoHistory')])
// The two section bands are not rendered at all when nothing changed.
expect(container.querySelectorAll('[data-path]')).toHaveLength(0)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('reports a failed stage action in the commit bar and catches it (no unhandled rejection)', async () => {
mockGit([{ path: 'src/a.ts', xy: ' M' }])
vi.spyOn(api, 'gitStage').mockRejectedValue(new Error('index.lock exists'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
const stage = container.querySelector<HTMLButtonElement>(`button[aria-label="${t('stage')}"]`)
expect(stage).toBeDefined()
await act(async () => { stage!.click() })
await flushEffects()
const alert = [...container.querySelectorAll<HTMLElement>('[role="alert"]')]
.find(node => (node.textContent ?? '').includes(t('changesStageFailed', { message: 'index.lock exists' })))
expect(alert).toBeDefined()
// The ONE status line sits in the commit bar, directly under its input row.
const input = container.querySelector(`input[placeholder="${t('commitPlaceholder')}"]`)
expect(input).not.toBeNull()
expect(alert!.previousElementSibling?.contains(input)).toBe(true)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('re-derives the previewed op from the latest fold when it settles', async () => {
mockGit([])
vi.spyOn(api, 'changesOps')
// The call starts running: no result yet, so the write has no payload.
.mockResolvedValueOnce({
events: [{ type: 'tool/call', seq: 1, time: 1, data: { name: 'write', callId: 'w1', arguments: JSON.stringify({ file_path: 'notes.txt' }) } }],
lastSeq: 1,
})
// The result lands in a later delta and settles the same callId.
.mockResolvedValueOnce({
events: [{ type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'w1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'settled-body' }] }] } } }],
lastSeq: 2,
})
.mockResolvedValue({ events: [], lastSeq: 2 })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="write"]')
expect(row).toBeDefined()
await act(async () => { row!.click() })
await flushEffects()
expect(container.textContent).toContain(t('changesPriorUnknown'))
expect(container.textContent).not.toContain('settled-body')
// The header's refresh pulls the delta; the SAME preview must pick the
// settled payload up without the user re-clicking the row.
const refresh = container.querySelector<HTMLButtonElement>(`button[aria-label="${t('refresh')}"]`)
expect(refresh).not.toBeNull()
await act(async () => { refresh!.click() })
await flushEffects()
expect(container.textContent).toContain('settled-body')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('shows a history failure instead of claiming there are no commits', async () => {
mockGit([])
vi.spyOn(api, 'gitLog').mockRejectedValue(new Error('bad object'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
expect(container.textContent).toContain(t('historyLoadError'))
expect(container.textContent).not.toContain(t('changesNoHistory'))
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('keeps the preview redaction toggle working (a secret file masks by default)', async () => {
mockGit([])
localStorage.removeItem('dsh-sidebar:v1:redaction')
vi.spyOn(api, 'changesOps').mockResolvedValue({
events: [
{ type: 'tool/call', seq: 1, time: 1, data: { name: 'read', callId: 'r1', arguments: JSON.stringify({ file_path: 'C:/repo/main/.env' }) } },
{ type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'r1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: '<content>1: OPENAI_API_KEY=sk-abcdefghijklmnop</content>' }] }] } } },
],
lastSeq: 2,
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="read"]')
await act(async () => { row!.click() })
await flushEffects()
// Redaction is on by default: the secret never reaches the DOM.
expect(container.textContent).toContain('[REDACTED]')
expect(container.textContent).not.toContain('sk-abcdefghijklmnop')
// The kit toggle (a Chip) turns it off, and only then does the raw value
// render; toggling back masks it again.
const byLabel = (label: string): HTMLButtonElement | undefined =>
[...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => (button.textContent ?? '') === label)
const on = byLabel(t('changesRedactOnLabel'))
expect(on).toBeDefined()
await act(async () => { on!.click() })
await flushEffects()
expect(container.textContent).toContain('sk-abcdefghijklmnop')
const off = byLabel(t('changesRedactOffLabel'))
expect(off).toBeDefined()
await act(async () => { off!.click() })
await flushEffects()
expect(container.textContent).not.toContain('sk-abcdefghijklmnop')
} finally {
act(() => { root.unmount() })
container.remove()
localStorage.removeItem('dsh-sidebar:v1:redaction')
}
})
it('clamps the restored preview height to the pane bounds', async () => {
mockGit([{ path: 'src/a.ts', xy: ' M' }])
vi.spyOn(api, 'gitDiff').mockResolvedValue({
diff: 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n',
})
/** Open the pane with one persisted height and read back the rendered one. */
const paneHeightFor = async (previewH: number): Promise<string | undefined> => {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root, { id: 'git', type: 'git', title: 'Changes', meta: { previewH } })
await flushEffects()
const row = container.querySelector<HTMLButtonElement>('button[data-path="src/a.ts"]')
expect(row).not.toBeNull()
await act(async () => { row!.click() })
await flushEffects()
return container.querySelector<HTMLElement>('[role="separator"]')?.parentElement?.style.height
} finally {
act(() => { root.unmount() })
container.remove()
}
}
// A height saved under a taller window cannot open past the pane's cap...
expect(await paneHeightFor(5000)).toBe(`${String(Math.round(window.innerHeight * 0.7))}px`)
// ...and an unusably small one is raised to the content minimum.
expect(await paneHeightFor(50)).toBe('140px')
})
it('polls the session events only while the session lens is on screen', async () => {
vi.useFakeTimers()
mockGit([])
const ops = vi.spyOn(api, 'changesOps').mockResolvedValue({ events: [], lastSeq: 0 })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// Opening the tab is one catch-up pull (the badge count follows it).
expect(ops).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
// The git lens has no use for the session event log: no cadence at all.
expect(ops).toHaveBeenCalledTimes(1)
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
// On screen: the 2.5s delta cadence runs.
expect(ops.mock.calls.length).toBeGreaterThan(1)
} finally {
act(() => { root.unmount() })
container.remove()
vi.useRealTimers()
}
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* The shared git-diff loader (`diff/use-git-diff.ts`) sits behind BOTH diff
* surfaces: the changes tab's inline preview pane and the dedicated diff tab.
* These tests drive each surface through the hook — the staged-side fallback,
* the untracked full-addition fallback, and the per-file fold cache two
* sibling folds share — so a future change to one surface cannot silently
* change the other.
*/
// @vitest-environment jsdom
import { 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 { DiffPane } from '../src/client/changes/DiffPane.tsx'
import { DiffTab } from '../src/client/DiffTab.tsx'
import { api } from '../src/client/api.ts'
import type { SessionScope } from '../src/client/api.ts'
import type { SidebarDiffRef } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const SCOPE: SessionScope = { sessionId: 's1', cwd: '/p' }
const STAGED_PATCH = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+staged-side',
].join('\n')
/** A three-hunk patch of one file: two gap folds (lines 2..11 and 13..29). */
const TWO_GAP_PATCH = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+new',
'@@ -12 +12 @@',
'-tail',
'+tail2',
'@@ -30 +30 @@',
'-end',
'+end2',
].join('\n')
const CONTENT = Array.from({ length: 40 }, (_value, index) => `line-${String(index + 1)}`).join('\n')
async function flushEffects(): Promise<void> {
for (let round = 0; round < 4; round += 1) await act(async () => { await Promise.resolve() })
}
afterEach(() => { vi.restoreAllMocks() })
describe('useGitDiffTarget through the inline preview pane (DiffPane)', () => {
it('falls back to the other side when the requested side has no diff', async () => {
const worktree: SidebarDiffRef = { kind: 'worktree', path: 'src/a.ts', staged: false }
const gitDiff = vi.spyOn(api, 'gitDiff').mockImplementation(async (_scope, _path, staged) => ({
diff: staged ? STAGED_PATCH : '',
}))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffPane, {
target: { kind: 'git', ref: worktree },
scope: SCOPE,
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}))
})
await flushEffects()
// The requested (unstaged) side came back empty, so the pane retried the
// staged side and rendered THAT patch.
expect(gitDiff).toHaveBeenNthCalledWith(1, expect.anything(), 'src/a.ts', false, undefined)
expect(gitDiff).toHaveBeenNthCalledWith(2, expect.anything(), 'src/a.ts', true, undefined)
expect(container.textContent).toContain('staged-side')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('renders an untracked file as a full-file addition from its content', async () => {
const worktree: SidebarDiffRef = { kind: 'worktree', path: 'src/new.ts', staged: false, untracked: true }
vi.spyOn(api, 'gitDiff').mockResolvedValue({ diff: '' })
const read = vi.spyOn(api, 'fsRead').mockResolvedValue({ kind: 'text', content: 'brand-new-line', truncated: false })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffPane, {
target: { kind: 'git', ref: worktree },
scope: SCOPE,
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}))
})
await flushEffects()
expect(read).toHaveBeenCalledWith(expect.anything(), '/p/src/new.ts')
expect(container.textContent).toContain('brand-new-line')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
describe('useGitDiffTarget through the dedicated diff tab (DiffTab)', () => {
it('loads a commit patch into the shared renderer', async () => {
vi.spyOn(api, 'gitCommitDiff').mockResolvedValue({ diff: STAGED_PATCH })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffTab, {
sessionId: 's1',
cwd: '/p',
diff: { kind: 'commit', hash: 'abc1234', hashFull: 'a'.repeat(40), subject: 'subject' },
}))
})
await flushEffects()
expect(container.textContent).toContain('staged-side')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('fetches each side of a file ONCE for two sibling fold expansions', async () => {
vi.spyOn(api, 'gitDiff').mockResolvedValue({ diff: TWO_GAP_PATCH })
const show = vi.spyOn(api, 'gitShow').mockResolvedValue({ content: CONTENT })
const read = vi.spyOn(api, 'fsRead').mockResolvedValue({ kind: 'text', content: CONTENT, truncated: false })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffTab, {
sessionId: 's1',
cwd: '/p',
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
}))
})
await flushEffects()
const firstFold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(firstFold).not.toBeNull()
await act(async () => { firstFold!.click() })
expect(show).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('line-5')
// The second gap is the SAME file: the cache serves it without a fetch.
// (An expanded fold keeps its marker in the DOM, so the second one is
// the second marker in document order.)
const folds = container.querySelectorAll<HTMLElement>('[data-expandable="true"]')
expect(folds).toHaveLength(2)
await act(async () => { folds[1]!.click() })
expect(show).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('line-20')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
+377
View File
@@ -0,0 +1,377 @@
/**
* Expand-refresh lane: a folder toggle must not rebuild the tree.
*
* Reported symptom: "expanding a folder makes the whole tree refresh over and
* over". Two independent defects produced it, and neither is visible in jsdom:
*
* 1. the plugin's native surface re-created the `files` takeover's slot
* registration on EVERY store write, and the store notifies on each
* expand/collapse. Replacing a slot registration replaces the host's slot
* entry, so the native tab BODY was unmounted and remounted: the explorer
* lost its level cache, its scroll position and its directory watcher, and
* re-listed the whole visible set every time a folder was toggled;
* 2. a live-refresh notice dropped the cached level before re-listing it, so
* the rows of a directory that changed on disk were replaced by a loading
* placeholder and rebuilt.
*
* This lane is the real-browser counterpart of the two unit guards
* (tests/native-surface.spec.ts, tests/file-tree-batch-load.spec.tsx): jsdom
* cannot see the host's slot machinery, so only a mounted `dsh web` can prove
* the body survives a toggle. It drives the plugin's own Files tab in DSH's
* native right Sidebar over a workspace with a 40-child directory and records,
* per phase, every `fs.trees`/`fs.tree` request with its payload paths, every
* `/sidebar/ws/fs-watch` frame, the tree-body replacements and an rAF census of
* the live rows.
*
* The gate:
* A1 the first request after a toggle names the TOGGLED level only
* (asking for [root, ...expanded] is the empty-cache signature of a
* fresh mount);
* A2 the tree body survives every toggle (no unmount/remount, no reopened
* watch socket);
* A3 rows that are on screen never drop to zero — not on a toggle and not
* when a directory changes behind the plugin.
*
* Phases wait for QUIESCENCE (no new request, frame or DOM batch for a quiet
* window) rather than a fixed sleep, so a slow machine widens the window
* instead of racing it.
*
* NOTE: this lane seeds its own workspace + session, and DSH renders the
* session that is current — so it runs LAST (the `zz-` prefix) for the same
* reason the mount lane runs first: a later lane's seeding must not change what
* an earlier lane's tree shows.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
/** This lane's own workspace: a 40-child directory plus a small sibling. */
const WORKSPACE_PATH = process.env.DSH_E2E_EXPAND_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-expand-probe')
/** The big directory's name and its child count (the reader's scenario). */
const BIG = 'big'
const BIG_CHILDREN = 40
/** Quiescence: how long nothing may happen before a phase is read. */
const QUIET_MS = 500
/** Minimum reaction window: the watcher debounce (150ms) plus the round trip. */
const MIN_PHASE_MS = 800
const QUIET_MAX_MS = 4_000
let api: APIRequestContext
function seedWorkspace(): void {
// The lane owns this directory and every run starts from the same tree: a
// leftover file from an earlier run would already be listed, so the
// behind-the-plugin write below would change nothing on screen.
rmSync(WORKSPACE_PATH, { recursive: true, force: true })
mkdirSync(join(WORKSPACE_PATH, BIG), { recursive: true })
mkdirSync(join(WORKSPACE_PATH, 'bravo'), { recursive: true })
for (let i = 0; i < BIG_CHILDREN; i++) {
writeFileSync(join(WORKSPACE_PATH, BIG, `file-${String(i).padStart(2, '0')}.txt`), `child ${i}\n`)
}
writeFileSync(join(WORKSPACE_PATH, 'bravo', 'z.txt'), 'z\n')
writeFileSync(join(WORKSPACE_PATH, 'top.txt'), 'top\n')
}
test.beforeAll(async () => {
api = await createHostApi()
seedWorkspace()
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
})
test.afterAll(async () => { await api?.dispose() })
async function dismissOnboarding(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch { /* a build without onboarding proceeds straight to the tree */ }
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try { await button.click({ timeout: 4_000 }); dismissed = true; await page.waitForTimeout(1_000) } catch { /* masked */ }
}
if (!dismissed) break
}
}
/**
* Install the census: follow the pane's tree body (through a replacement, so a
* remount is recorded without zeroing the measurement), count — every frame —
* the live rows under the big directory, and log every document-level batch
* that removed a native tab host or a whole run of rows.
*/
const INSTALL_CENSUS = `(() => {
const bodies = () => [...document.querySelectorAll('[class*="explorerBody"]')]
const findTree = () => bodies().find(el => el.getBoundingClientRect().height > 0
&& [...el.querySelectorAll('[class*="explorerName"]')].some(span => span.textContent === '${BIG}'))
const first = findTree()
if (first === undefined) return 'no tree body'
first.setAttribute('data-expand-probe', 'target')
const state = {
changes: [], batches: [], added: 0, removed: 0,
replacedBodies: 0, killedRows: 0, marks: [], minBig: Infinity, sawBig: 0, emptyFrames: 0,
}
let seq = 0
const seen = new Map()
const census = () => {
const now = Math.round(performance.now())
const live = bodies().filter(el => el.getBoundingClientRect().height > 0 || el.querySelector('[class*="explorerRow"]'))
for (const el of live) if (!seen.has(el)) { seq += 1; seen.set(el, seq); state.marks.push({ t: now, ev: 'tree-body-mount', seq }) }
for (const [el, id] of [...seen]) if (!el.isConnected) { seen.delete(el); state.marks.push({ t: now, ev: 'tree-body-unmount', seq: id }) }
const current = first.isConnected ? first : findTree()
const bigRows = current === undefined ? -1 : current.querySelectorAll('[title*="/${BIG}/"]').length
const rows = current === undefined ? -1 : current.querySelectorAll('[class*="explorerRow"]').length
state.changes.push({ t: now, alive: first.isConnected ? 1 : 0, bodies: live.length, rows, bigRows })
// The blanking detector: the low only counts from the moment rows are up
// (an empty level before its first listing is not a blank frame). Collapsing
// the folder legitimately empties it, so only the phases that keep it open
// assert on this.
if (bigRows > 0) state.sawBig = 1
if (state.sawBig === 1 && bigRows >= 0) state.minBig = Math.min(state.minBig, bigRows)
if (state.sawBig === 1 && bigRows === 0) state.emptyFrames += 1
}
new MutationObserver(records => {
let added = 0, removed = 0, killedRows = 0, killedBodies = 0
for (const record of records) {
added += record.addedNodes.length
removed += record.removedNodes.length
for (const node of record.removedNodes) {
if (!(node instanceof Element)) continue
const cls = typeof node.className === 'string' ? node.className : ''
if (cls.includes('nativeTabHost')) killedBodies += 1
killedRows += node.querySelectorAll('[class*="explorerRow"]').length
}
}
state.added += added
state.removed += removed
state.killedRows += killedRows
state.replacedBodies += killedBodies
// A placeholder that is not itself a row marks the frame a level was blanked.
if (killedRows > 0) state.emptyFrames += 1
state.batches.push({ t: Math.round(performance.now()), added, removed, killedRows, killedBodies })
census()
}).observe(document.body, { childList: true, subtree: true })
const loop = () => { census(); requestAnimationFrame(loop) }
requestAnimationFrame(loop)
census()
window.__census = { state }
return 'installed'
})()`
test('a folder toggle re-lists that folder and nothing else', async ({ page }) => {
// ── Node-side recorders (armed before navigation: the socket opens on mount) ──
interface Req { t: number; kind: string; paths: string[] }
const requests: Req[] = []
interface Sock { id: number; sent: string[]; recv: string[] }
const sockets: Sock[] = []
const t0 = Date.now()
const rel = (): number => Date.now() - t0
page.on('request', (request) => {
const url = request.url()
const kind = url.includes('/sidebar/api/fs.trees') ? 'fs.trees'
: url.includes('/sidebar/api/fs.tree') ? 'fs.tree'
: /\/sidebar\/api\/git\./.test(url) ? 'git' : null
if (kind === null) return
let paths: string[] = []
const body = request.postData()
if (body !== null) {
try {
const parsed = JSON.parse(body) as { paths?: string[]; path?: string }
paths = parsed.paths ?? (parsed.path === undefined ? [] : [parsed.path])
} catch { /* non-JSON payload: leave the paths empty */ }
}
requests.push({ t: rel(), kind, paths })
})
page.on('websocket', (ws) => {
if (!ws.url().includes('/sidebar/ws/fs-watch')) return
const sock: Sock = { id: sockets.length + 1, sent: [], recv: [] }
sockets.push(sock)
ws.on('framesent', (frame) => sock.sent.push(String(frame.payload)))
ws.on('framereceived', (frame) => sock.recv.push(String(frame.payload)))
})
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await sendFirstMessage(page)
const pane = page.locator('[data-sidebar-right-panel]')
await page.locator('[data-sidebar-right-expand]').first().click()
await expect(pane).toBeVisible({ timeout: 90_000 })
const addTab = page.locator('[data-dockkit-add-tab]').first()
if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click()
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
const dirRow = (name: string) => pane.locator(`[class*="explorerDir"]:has([class*="explorerName"]:text-is("${name}"))`)
await expect(dirRow(BIG), 'the seeded big directory must be listed').toHaveCount(1, { timeout: 60_000 })
await expect(
pane.locator('[role="button"][title$="top.txt"]:visible'),
'the root level must be listed before the toggle',
).toHaveCount(1, { timeout: 60_000 })
const installed = await page.evaluate(INSTALL_CENSUS)
expect(installed, 'the tree body must exist for the census').toBe('installed')
/** A phase's readings, all read at once at the end of the phase. */
interface Phase {
phase: string
requests: string[]
sockets: number
wsFrames: number
added: number
removed: number
killedRows: number
treeBodyReplaced: number
emptyFrames: number
minBigRows: number
transitions: unknown[]
}
const phases: Phase[] = []
/** Counters the quiescence wait compares: requests, frames and DOM batches. */
const activity = (): number =>
requests.length + sockets.reduce((sum, s) => sum + s.sent.length + s.recv.length, 0) + domBatch
let domBatch = 0
const runPhase = async (phase: string, action: (() => Promise<void>) | null): Promise<Phase> => {
const reqBase = requests.length
const sockBase = sockets.length
await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: Record<string, unknown> } }).__census
if (probe === undefined) return
Object.assign(probe.state, {
changes: [], batches: [], marks: [], added: 0, removed: 0, replacedBodies: 0, killedRows: 0,
minBig: Infinity, sawBig: 0, emptyFrames: 0,
})
})
if (action !== null) await action()
// Wait for quiescence instead of a fixed sleep: the phase ends when nothing
// (network, socket, DOM) has moved for QUIET_MS, capped so a watcher loop
// cannot hang the lane — a loop is exactly what the gate must catch.
const started = Date.now()
let last = activity()
let quietSince = Date.now()
while (Date.now() - started < QUIET_MAX_MS) {
await page.waitForTimeout(50)
const seen = await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: { batches: unknown[] } } }).__census
return probe?.state.batches.length ?? 0
})
domBatch = seen
const now = activity()
if (now !== last) { last = now; quietSince = Date.now(); continue }
if (Date.now() - started >= MIN_PHASE_MS && Date.now() - quietSince >= QUIET_MS) break
}
const sample = await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: Record<string, unknown> } }).__census
if (probe === undefined) return null
const s = probe.state as unknown as {
changes: Array<{ t: number; alive: number; bodies: number; rows: number; bigRows: number }>
marks: Array<{ t: number; ev: string; seq: number }>
added: number; removed: number; replacedBodies: number; killedRows: number
minBig: number; emptyFrames: number
}
// Collapse the rAF census to its transitions (what changed on screen).
const transitions: typeof s.changes = []
let lastKey = ''
for (const entry of s.changes) {
const key = `${entry.alive}/${entry.bodies}/${entry.rows}/${entry.bigRows}`
if (key === lastKey) continue
lastKey = key
transitions.push(entry)
}
return {
added: s.added, removed: s.removed, killedRows: s.killedRows, emptyFrames: s.emptyFrames,
minBig: s.minBig === Infinity ? -1 : s.minBig,
remounts: s.marks.filter(mark => mark.ev === 'tree-body-unmount').length,
replacedBodies: s.replacedBodies,
transitions,
}
})
const phaseRequests = requests.slice(reqBase).filter(entry => entry.kind !== 'git')
.map(entry => `${entry.kind}[${entry.paths.map(path => path.split('/').pop()).join(',')}]`)
const phaseFrames = sockets.slice(sockBase)
.flatMap(sock => [...sock.sent.map(frame => `sent#${sock.id} ${frame}`), ...sock.recv.map(frame => `recv#${sock.id} ${frame}`)])
const result: Phase = {
phase,
requests: phaseRequests,
sockets: sockets.length - sockBase,
wsFrames: phaseFrames.length,
added: sample?.added ?? -1,
removed: sample?.removed ?? -1,
killedRows: sample?.killedRows ?? -1,
treeBodyReplaced: (sample?.replacedBodies ?? 0) > 0 || (sample?.remounts ?? 0) > 0 ? 1 : 0,
emptyFrames: sample?.emptyFrames ?? -1,
minBigRows: sample?.minBig ?? -1,
transitions: sample?.transitions ?? [],
}
phases.push(result)
console.log(`EXPAND_PHASE ${JSON.stringify(result)}`)
return result
}
// A: the reported gesture — expanding the 40-child directory.
const expand = await runPhase(`A-expand-${BIG}`, async () => {
await dirRow(BIG).click({ position: { x: 8, y: 8 } })
})
// B: idle with the directory open — a watcher feedback loop would show up
// here as requests arriving with nobody touching anything.
const idle = await runPhase('B-idle', null)
// C: a directory written BEHIND the plugin (a build, a formatter, the
// model's own bash): the host notices and the tree re-lists exactly that
// level, in place.
const landed = `landed-${Date.now()}.txt`
const external = await runPhase('C-external-write', async () => {
writeFileSync(join(WORKSPACE_PATH, BIG, landed), 'landed\n')
})
// D: expanding a second directory while the first stays open.
const sibling = await runPhase('D-expand-bravo', async () => {
await dirRow('bravo').click({ position: { x: 8, y: 8 } })
})
// E: collapsing the first one back.
const collapse = await runPhase(`E-collapse-${BIG}`, async () => {
await dirRow(BIG).click({ position: { x: 8, y: 8 } })
})
console.log(`EXPAND_SUMMARY ${JSON.stringify(phases.map(phase => ({
phase: phase.phase,
requests: phase.requests,
newSockets: phase.sockets,
wsFrames: phase.wsFrames,
domAdded: phase.added,
domRemoved: phase.removed,
rowsKilledWithSubtrees: phase.killedRows,
treeBodyReplaced: phase.treeBodyReplaced,
minBigRows: phase.minBigRows,
emptyFramesAfterRowsUp: phase.emptyFrames,
rowTransitions: phase.transitions,
})), null, 1)}`)
// A1: the toggle's own request names the toggled level only.
expect(
expand.requests[0],
'expanding a folder must ask for that folder, not the whole visible set',
).toBe(`fs.trees[${BIG}]`)
expect(sibling.requests[0], 'expanding a sibling must ask for the sibling').toBe('fs.trees[bravo]')
// A2: the tree body survives every toggle. A replaced body unmounts the
// explorer — level cache, scroll position and directory watcher with it —
// and a reopened socket re-announces its whole watch set.
for (const phase of [expand, sibling, collapse]) {
expect(phase.treeBodyReplaced, `${phase.phase}: the tab body must not be replaced`).toBe(0)
expect(phase.sockets, `${phase.phase}: the watch socket must not be re-opened`).toBe(0)
}
// A3: rows on screen never drop to zero.
expect(expand.emptyFrames, 'the expand must not blank the level').toBe(0)
expect(external.emptyFrames, 'a live refresh must not blank the level').toBe(0)
expect(external.minBigRows, 'the live refresh must keep the level on screen').toBe(BIG_CHILDREN)
// …and neither the idle folder nor the collapse re-lists anything.
expect(idle.requests, 'an idle open folder must not re-list').toEqual([])
expect(collapse.requests, 'collapsing must not re-list anything').toEqual([])
expect(collapse.killedRows, 'collapsing only removes the level it closes').toBe(0)
})
+226 -1
View File
@@ -9,7 +9,7 @@
* only, no editor chrome); file tabs keep the full chrome in both modes.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createElement, useEffect, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
@@ -18,6 +18,34 @@ import { EditorHost } from '../src/client/EditorHost.tsx'
import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The tree mounts inside EditorHost in these scenarios, so the api seam must
// answer without a host (the sibling FileTree specs mock it the same way).
vi.mock('../src/client/api.ts', () => ({
api: {
// 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: [] }),
archiveBuild: async () => ({ id: 'ar-1', entries: 1 }),
archiveStatus: async () => ({ state: 'ready', done: 1, total: 1, bytes: 1 }),
openExternal: async () => ({ started: true }),
},
archiveDownloadUrl: () => '/sidebar/archive?id=ar-1',
downloadUrl: () => '/sidebar/file',
mediaUrl: () => '/sidebar/file',
archiveUrl: () => '/sidebar/archive',
isOutsideWorkspaceMessage: () => false,
}))
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
@@ -86,6 +114,41 @@ function mountHost(ctx: Context, store: ReturnType<typeof createSidebarStore>, t
}
}
/**
* Mount the host AND let the docked tree finish its async level load (the
* context-menu scenarios need the file row in the DOM). The scope carries the
* session cwd — without a root the docked tree has nothing to list.
*/
async function mountHostWithTreeWithCwd(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: () => SidebarTab): Promise<{
container: HTMLDivElement
unmount: () => void
}> {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'editor-home-session', cwd: '/tmp' },
tab: tab(),
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into the controlled path input (native setter) and press Enter. */
function typeAndCommit(input: HTMLInputElement, value: string): void {
act(() => {
@@ -333,3 +396,165 @@ describe('EditorHost (files window)', () => {
}
})
})
/**
* "Open to the Side" from a NATIVE tab must reach the host's own split, not
* the plugin's bottom workbench: a native right-Sidebar tab is absent from
* `bottomSplits`, so the old code fell through to `firstLeaf` — a pane the
* user had not expanded, i.e. "the menu item does nothing".
*/
describe('EditorHost "open to the side"', () => {
/** A service whose openTab calls are recorded (surface undefined = native). */
function spyService(store: ReturnType<typeof createSidebarStore>): {
service: ReturnType<typeof createBetterSidebarService>
opens: Array<{ type: string; path?: string; target?: string }>
} {
const service = createBetterSidebarService(store)
const opens: Array<{ type: string; path?: string; target?: string }> = []
const real = service.openTab.bind(service)
service.openTab = ((seed, scope) => {
opens.push({ type: seed.type, ...(seed.path === undefined ? {} : { path: seed.path }), ...(seed.target === undefined ? {} : { target: seed.target }) })
real(seed, scope)
}) as typeof service.openTab
return { service, opens }
}
/** The fake client ctx (service + session feed); cwd matches api.fsTree. */
function fakeCtx(service: ReturnType<typeof createBetterSidebarService>): Context {
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
return {
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
} as unknown as Context
}
/** Mount a native-hosting editor window whose tab is NOT in bottomSplits. */
async function mountNative(ctx: Context, store: ReturnType<typeof createSidebarStore>, service: ReturnType<typeof createBetterSidebarService>): Promise<{
container: HTMLDivElement
unmount: () => void
}> {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
// The tab record the native seat hands the component: an editor window for
// /tmp/a.ts whose id never enters the plugin's own layout.
const nativeTab: SidebarTab = {
id: 'native-editor-1',
type: 'editor',
title: 'a.ts',
path: '/tmp/a.ts',
meta: { treeOpen: true },
}
await act(async () => {
root.render(createElement(EditorHost, {
ctx,
store,
// The tree needs a root: the real seat hands the session cwd in scope.
scope: { sessionId: 'editor-home-session', cwd: '/tmp' },
visible: true,
tab: nativeTab,
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
await Promise.resolve()
await Promise.resolve()
})
void service
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
function clickOpenToSide(container: HTMLDivElement): void {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
if (row === undefined) {
throw new Error(`file row not found; names=${[...container.querySelectorAll('[class*="explorerName"]')].map(el => el.textContent).join('|')}; html=${container.innerHTML.slice(0, 400)}`)
}
act(() => {
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }))
})
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(el => el.textContent?.trim() === 'Open to the Side')
if (item === undefined) throw new Error('side-open menu item not found')
act(() => { item.click() })
}
function tabCount(store: ReturnType<typeof createSidebarStore>): number {
return allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs).length
}
it('asks the service for a side open and leaves the bottom workbench untouched', async () => {
const store = createSidebarStore()
const { service, opens } = spyService(store)
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
// A native surface is what makes the service native-hosted at all: the
// fake records what the service asks of the host (the real one forwards to
// `ctx.sidebarRight`).
const placed: Array<{ address: string; preferNewPane?: boolean; revealIfOpened?: boolean }> = []
service.setSurface({
openTab: () => {},
openResource: (input) => { placed.push({ address: input.address, ...(input.preferNewPane === undefined ? {} : { preferNewPane: input.preferNewPane }), revealIfOpened: input.revealIfOpened }) },
fileAddress: (sessionId, cwd, path) => `dsh-resource://file/${sessionId}${path}`,
close: () => undefined,
update: () => false,
activate: () => false,
has: () => false,
})
const ctx = fakeCtx(service)
const { container, unmount } = await mountNative(ctx, store, service)
try {
const before = tabCount(store)
clickOpenToSide(container)
// 1) EditorHost routed the gesture to the service with the side target…
expect(opens).toContainEqual({ type: 'editor', path: '/tmp/a.ts', target: 'side' })
// 2) …and the service asked the HOST for a second pane, permitting a
// duplicate resource so a split really happens.
expect(placed).toHaveLength(1)
expect(placed[0]).toMatchObject({ address: expect.stringContaining('/tmp/a.ts'), preferNewPane: true, revealIfOpened: false })
// 3) The bottom workbench is untouched: the open went to the host.
expect(tabCount(store)).toBe(before)
} finally {
unmount()
}
})
it('keeps the bottom-workbench split for a tab that lives there', async () => {
const store = createSidebarStore()
const { service } = spyService(store)
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
const ctx = fakeCtx(service)
const opens: Array<{ target?: string }> = []
const real = service.openTab.bind(service)
service.openTab = ((seed, scope) => { opens.push({ ...(seed.target === undefined ? {} : { target: seed.target }) }); real(seed, scope) }) as typeof service.openTab
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
service.openTab({
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
})
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
// The tree needs a root: the real seat hands the session cwd in scope.
const treeCtx = ctx as unknown as { betterSidebar: unknown }
const { container, unmount } = await mountHostWithTreeWithCwd(treeCtx as unknown as Context, store, fileTab)
try {
const before = tabCount(store)
clickOpenToSide(container)
// A bottom-workbench tab keeps the plugin's own split: a NEW tab in the
// same pane family, and no side open was requested.
expect(opens.every(open => open.target === undefined)).toBe(true)
expect(tabCount(store)).toBe(before + 1)
} finally {
unmount()
}
})
})
+388
View File
@@ -0,0 +1,388 @@
/**
* FileTree "zip and download" over the host's ASYNC archive route.
*
* The menu row starts a job (`api.archiveBuild`), the tree polls
* `api.archiveStatus` while it builds and shows the `zipProgress` line
* (`done/total` + percentage), then a `ready` status downloads
* `api.archiveDownloadUrl(id)` as a blob and saves it through a hidden
* `<a download={name}>`. Every failure path — build rejection, a status
* `error` state, a status rejection, the download's HTTP envelope — lands in
* the error strip with `zipFailed`, and a repeated pick while one job runs
* never starts a second job.
*
* Appearance gates stay as they were: ≥2 selected rows archive together, a
* LONE directory archives its own subtree, a lone FILE shows no archive row
* (the plain download row already covers it).
*/
// @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 menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { archiveBuild, archiveStatus, fetchMock } = vi.hoisted(() => ({
archiveBuild: vi.fn(),
archiveStatus: vi.fn(),
fetchMock: vi.fn(),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
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 })),
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the archive row).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
// The archive API is a set of standalone module exports (like downloadUrl),
// not methods on the `api` object.
archiveBuild,
archiveStatus,
archiveDownloadUrl: (_scope: unknown, id: string) => `/sidebar/archive/${id}`,
isOutsideWorkspaceMessage: () => false,
}))
/** jsdom implements neither object-URL helper; these ARE the download probe. */
const createdUrls: Blob[] = []
const revokedUrls: string[] = []
beforeAll(() => {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: (blob: Blob) => {
createdUrls.push(blob)
return `blob:mock-${createdUrls.length}`
},
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: (url: string) => { revokedUrls.push(url) },
})
})
/** The poll cadence the tree uses; advancing less than this must not poll. */
const POLL_MS = 250
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function click(el: Element, init: MouseEventInit = {}): void {
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })) })
}
function rightClick(el: Element): void {
act(() => {
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }))
})
}
function menuLabels(): string[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(item => item.textContent ?? '')
}
function clickMenuitem(label: string): void {
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem not found: ${label}`)
act(() => { item.click() })
}
/** The progress line (a `Notice kind="loading"` live region). */
function progressLine(container: HTMLElement): HTMLElement | null {
return container.querySelector<HTMLElement>('[data-kind="loading"]')
}
/** Flush the pending promise chain (build / status / download). */
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
/** Let the poll timer fire and its promise chain settle. */
async function poll(): Promise<void> {
await act(async () => {
vi.advanceTimersByTime(POLL_MS)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
/** A `ready` status row. */
function readyStatus(done: number, total: number): {
state: 'ready'; done: number; total: number; bytes: number
} {
return { state: 'ready', done, total, bytes: 128 }
}
let harness: Harness
/** The anchors whose click() ran, as `href|download`. */
const downloads: string[] = []
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement): void {
downloads.push(`${this.getAttribute('href') ?? ''}|${this.download}`)
})
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: async () => new Blob(['zip-bytes'], { type: 'application/zip' }),
} as unknown as Response)
archiveBuild.mockReset()
archiveBuild.mockResolvedValue({ id: 'job-1', entries: 2 })
archiveStatus.mockReset()
archiveStatus.mockResolvedValue(readyStatus(2, 2))
downloads.length = 0
createdUrls.length = 0
revokedUrls.length = 0
clickSpy.mockClear()
})
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('FileTree zip and download', () => {
it('starts a job, shows done/total progress, then downloads the ready archive', async () => {
// The first status is held open so the "build phase" (total known, no
// progress yet) is observable: the poller runs its first tick immediately.
let releaseStatus: (status: { state: 'building'; done: number; total: number; bytes: number }) => void = () => {}
archiveStatus
.mockImplementationOnce(async () => await new Promise<{ state: 'building'; done: number; total: number; bytes: number }>(resolve => { releaseStatus = resolve }))
.mockResolvedValueOnce(readyStatus(2, 2))
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
rightClick(rowByName(harness.container, 'b.ts'))
expect(menuLabels()).toContain('Zip and download (2 items)')
clickMenuitem('Zip and download (2 items)')
await flush()
// The job was started for the whole selection…
expect(archiveBuild).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp/a.ts', '/tmp/b.ts'], 'archive.zip')
// …the build phase shows the total it already knows…
expect(progressLine(harness.container)?.textContent).toBe('Archiving 0/2 · 0%')
// …the first poll advances the line…
await act(async () => {
releaseStatus({ state: 'building', done: 1, total: 2, bytes: 0 })
await Promise.resolve()
await Promise.resolve()
})
expect(progressLine(harness.container)?.textContent).toBe('Archiving 1/2 · 50%')
// …and the next poll reports ready: the download runs and the line clears.
await poll()
await flush()
expect(fetchMock).toHaveBeenCalledWith('/sidebar/archive/job-1')
expect(downloads).toEqual(['blob:mock-1|archive.zip'])
expect(progressLine(harness.container)).toBeNull()
// The object URL is released on the next task.
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
expect(revokedUrls).toEqual(['blob:mock-1'])
})
it('archives a lone directory under its own name', async () => {
archiveBuild.mockResolvedValue({ id: 'job-dir', entries: 3 })
harness = await mountTree()
rightClick(rowByName(harness.container, 'sub'))
expect(menuLabels()).toContain('Zip and download')
clickMenuitem('Zip and download')
await flush()
await poll()
await flush()
expect(archiveBuild).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp/sub'], 'sub.zip')
expect(downloads).toEqual(['blob:mock-1|sub.zip'])
})
it('archives the workspace root row (a lone directory too)', async () => {
harness = await mountTree()
rightClick(rowByName(harness.container, 'tmp'))
clickMenuitem('Zip and download')
await flush()
await poll()
await flush()
expect(archiveBuild).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp'], 'tmp.zip')
})
it('offers nothing for a lone FILE (the download row already covers it)', async () => {
harness = await mountTree()
rightClick(rowByName(harness.container, 'a.ts'))
expect(menuLabels()).toContain('Download')
expect(menuLabels().some(label => label.startsWith('Zip and download'))).toBe(false)
expect(archiveBuild).not.toHaveBeenCalled()
})
it('reports a build failure in the error strip', async () => {
archiveBuild.mockRejectedValue(new Error('build refused'))
harness = await mountTree()
rightClick(rowByName(harness.container, 'sub'))
clickMenuitem('Zip and download')
await flush()
expect(harness.container.querySelector('[role="alert"]')?.textContent)
.toContain('Archive failed: build refused')
expect(progressLine(harness.container)).toBeNull()
expect(archiveStatus).not.toHaveBeenCalled()
expect(downloads).toEqual([])
})
it('reports a status error state with the route message', async () => {
archiveStatus.mockResolvedValue({ state: 'error', done: 1, total: 2, bytes: 0, error: 'archive too large' })
harness = await mountTree()
rightClick(rowByName(harness.container, 'sub'))
clickMenuitem('Zip and download')
await flush()
await poll()
expect(harness.container.querySelector('[role="alert"]')?.textContent)
.toContain('Archive failed: archive too large')
expect(progressLine(harness.container)).toBeNull()
expect(downloads).toEqual([])
})
it('reports a status request that rejects', async () => {
archiveStatus.mockRejectedValue(new Error('status offline'))
harness = await mountTree()
rightClick(rowByName(harness.container, 'sub'))
clickMenuitem('Zip and download')
await flush()
await poll()
expect(harness.container.querySelector('[role="alert"]')?.textContent)
.toContain('Archive failed: status offline')
expect(downloads).toEqual([])
})
it('reports a download refused by the route envelope', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 413,
json: async () => ({ ok: false, error: { code: 'bad-request', message: 'archive would exceed 64 MB' } }),
} as unknown as Response)
harness = await mountTree()
rightClick(rowByName(harness.container, 'sub'))
clickMenuitem('Zip and download')
await flush()
await poll()
await flush()
expect(harness.container.querySelector('[role="alert"]')?.textContent)
.toContain('Archive failed: archive would exceed 64 MB')
expect(downloads).toEqual([])
expect(createdUrls).toEqual([])
})
it('falls back to the status when the download carries no JSON body', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 500,
json: async () => { throw new Error('not json') },
} as unknown as Response)
harness = await mountTree()
rightClick(rowByName(harness.container, 'sub'))
clickMenuitem('Zip and download')
await flush()
await poll()
await flush()
expect(harness.container.querySelector('[role="alert"]')?.textContent)
.toContain('Archive failed: HTTP 500')
expect(downloads).toEqual([])
})
it('builds once while a job is in flight (double click guard)', async () => {
let release: (job: { id: string; entries: number }) => void = () => {}
archiveBuild.mockImplementation(async () => await new Promise<{ id: string; entries: number }>(resolve => { release = resolve }))
// Stay in the building state until the test says otherwise.
archiveStatus.mockResolvedValue({ state: 'building', done: 0, total: 1, bytes: 0 })
harness = await mountTree()
const pick = (): void => {
rightClick(rowByName(harness.container, 'sub'))
clickMenuitem('Zip and download')
}
pick()
await flush()
expect(archiveBuild).toHaveBeenCalledTimes(1)
// The row is still reachable and a second pick must not start a second job.
pick()
await flush()
expect(archiveBuild).toHaveBeenCalledTimes(1)
expect(archiveStatus).not.toHaveBeenCalled()
await act(async () => { release({ id: 'job-2', entries: 1 }) })
await flush()
expect(progressLine(harness.container)?.textContent).toBe('Archiving 0/1 · 0%')
await poll()
// Still building: the line stays and the guard still holds.
expect(progressLine(harness.container)).not.toBeNull()
pick()
await flush()
expect(archiveBuild).toHaveBeenCalledTimes(1)
// Finish the job: the download runs, the line clears, the guard releases.
archiveStatus.mockResolvedValue(readyStatus(1, 1))
await poll()
await flush()
expect(downloads).toEqual(['blob:mock-1|sub.zip'])
archiveBuild.mockResolvedValue({ id: 'job-3', entries: 1 })
pick()
await flush()
expect(archiveBuild).toHaveBeenCalledTimes(2)
await poll()
await flush()
})
})
+274
View File
@@ -0,0 +1,274 @@
/**
* 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<void> {
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 the stale level on screen until the fresh listing lands (no blank frame)', async () => {
harness = mountTree(DIRS)
await flush()
const socket = FakeSocket.instances[0]
if (socket === undefined) throw new Error('no watch socket opened')
act(() => { socket.open() })
// Hold the re-list open and answer with DIFFERENT content, so the swap is
// observable in both directions.
let release: (() => void) | undefined
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => {
await new Promise<void>((resolve) => { release = resolve })
return {
levels: paths.map(path => path === '/tmp/d3'
? { path, entries: [{ name: 'd3-fresh.ts', path: `${path}/d3-fresh.ts`, isDir: false }], truncated: false }
: levelOf(path)),
}
})
act(() => { socket.frame({ dir: '/tmp/d3' }) })
await flush()
// While the answer is in flight the level keeps the rows it had: dropping
// the cached level first replaced the whole folder with a loading row and
// rebuilt it — the "the tree blinks on every disk change" report.
expect(harness.container.textContent, 'the stale rows stay up during the re-list').toContain('d3-file.ts')
expect(harness.container.textContent, 'no placeholder replaces the level').not.toContain('Loading…')
release?.()
await flush()
// …and the fresh listing still lands.
expect(harness.container.textContent).toContain('d3-fresh.ts')
expect(harness.container.textContent).not.toContain('d3-file.ts')
})
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<HTMLElement>('[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')
})
})
+158
View File
@@ -0,0 +1,158 @@
/**
* The client half of the file tree's live refresh: one socket per session,
* the expanded set announced as `watch` frames, directories that left the
* set unwatched, and a `dir`-only frame routed to the staleness callback
* while a refusal (`ok: false`) is NOT (a fenced/unwatchable directory must
* not re-list forever).
*
* The identity guard: the hook derives its wanted set from the CONTENT of
* `dirs` (a NUL-joined key), so a caller re-rendering with a fresh array of
* the same directories reconciles nothing.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { useDirectoryWatch } from '../src/client/use-dir-watch.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
/** The smallest WebSocket the hook talks to: `send` records the frames. */
class FakeSocket {
static instances: FakeSocket[] = []
static readonly OPEN = 1
static readonly CONNECTING = 0
readonly url: string
readyState = FakeSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: (() => void) | null = null
closed = false
constructor(url: string) {
this.url = url
FakeSocket.instances.push(this)
}
send(frame: string): void { this.sent.push(frame) }
close(): void { this.closed = true; this.readyState = 3 }
/** The host's connection acknowledgement. */
open(): void { this.readyState = FakeSocket.OPEN; this.onopen?.() }
/** One host frame (a watch confirmation, a refusal, or a stale notice). */
frame(payload: unknown): void { this.onmessage?.({ data: JSON.stringify(payload) }) }
/** The frames the hook sent, as parsed objects. */
frames(): { op: string; path: string }[] {
return this.sent.map(frame => JSON.parse(frame) as { op: string; path: string })
}
}
function Probe(props: { dirs: string[]; onStale: (dir: string) => void }): ReactNode {
useDirectoryWatch({ sessionId: 's1', root: '/tmp', dirs: props.dirs, onStale: props.onStale })
return null
}
interface Harness {
rerender: (dirs: string[]) => void
socket: () => FakeSocket
unmount: () => void
}
function mountProbe(onStale: (dir: string) => void): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const render = (dirs: string[]): void => { root.render(createElement(Probe, { dirs, onStale })) }
act(() => { render(['/tmp/a']) })
return {
rerender: (dirs: string[]) => { act(() => { render(dirs) }) },
socket: () => {
const socket = FakeSocket.instances[0]
if (socket === undefined) throw new Error('no socket opened')
return socket
},
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
beforeEach(() => {
FakeSocket.instances = []
vi.stubGlobal('WebSocket', FakeSocket)
})
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('useDirectoryWatch', () => {
it('announces the root plus the expanded set once the socket opens', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
expect(socket.url).toContain('/sidebar/ws/fs-watch')
expect(socket.url).toContain('sessionId=s1')
// Nothing is sent before the connection opens.
expect(socket.sent).toEqual([])
act(() => { socket.open() })
expect(socket.frames()).toEqual([
{ op: 'watch', path: '/tmp' },
{ op: 'watch', path: '/tmp/a' },
])
harness.unmount()
})
it('sends nothing when a re-render repeats the same directories in a fresh array', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
act(() => { socket.open() })
const before = socket.sent.length
harness.rerender(['/tmp/a'])
expect(FakeSocket.instances).toHaveLength(1)
expect(socket.sent.length).toBe(before)
harness.unmount()
})
it('watches the new directory and unwatches the one the host confirmed', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
act(() => { socket.open() })
// The host confirms both watches with its own resolved paths.
act(() => {
socket.frame({ dir: '/tmp', ok: true })
socket.frame({ dir: '/tmp/a', ok: true })
})
harness.rerender(['/tmp/b'])
expect(socket.frames().slice(-3)).toEqual([
{ op: 'watch', path: '/tmp' },
{ op: 'watch', path: '/tmp/b' },
{ op: 'unwatch', path: '/tmp/a' },
])
harness.unmount()
})
it('routes a stale notice to the callback and ignores a refusal', () => {
const onStale = vi.fn()
const harness = mountProbe(onStale)
const socket = harness.socket()
act(() => { socket.open() })
// A refusal (fenced path / watcher cap) must NOT re-list.
act(() => { socket.frame({ dir: '/tmp/a', ok: false }) })
expect(onStale).not.toHaveBeenCalled()
act(() => { socket.frame({ dir: '/tmp/a' }) })
expect(onStale).toHaveBeenCalledWith('/tmp/a')
// A malformed frame is ignored outright.
act(() => { socket.frame({ nope: 1 }) })
expect(onStale).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('closes the socket on unmount', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
act(() => { socket.open() })
harness.unmount()
expect(socket.closed).toBe(true)
})
})
+6 -5
View File
@@ -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,12 +29,15 @@ 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).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
}))
@@ -57,7 +59,6 @@ async function mountTree(busy = false): Promise<Harness> {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
+141
View File
@@ -0,0 +1,141 @@
/**
* FileTree git ink (VS Code style): the tree reads the plugin's SHARED
* `useGitStatus` store — a changed file's name takes its tone class and a
* letter badge whose tooltip is the status word, a directory with a change
* anywhere below it is tinted without a letter, a clean file stays plain, and
* a non-repo (or unavailable git) degrades silently to plain rows.
*/
// @vitest-environment jsdom
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 type { GitStatusResult } from '../src/client/api.ts'
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 badge tooltip is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { gitStatus } = vi.hoisted(() => ({ gitStatus: vi.fn() }))
vi.mock('../src/client/api.ts', () => ({
api: {
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 })),
}),
gitStatus,
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
const REPO: GitStatusResult = {
isRepo: true,
root: '/tmp',
entries: [
{ path: 'a.ts', xy: ' M' },
// A change strictly BELOW /tmp/sub: the directory is tinted, the file
// itself is not rendered (its level is collapsed).
{ path: 'sub/inner.ts', xy: '??' },
],
}
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 'git-spec',
cwd: '/tmp',
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
// Settle the shared store's fetch (it starts in FileTree's mount effect).
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name. */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function nameSpan(row: HTMLElement): HTMLElement {
return row.querySelector<HTMLElement>('[class*="explorerName"]')!
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
gitStatus.mockReset()
})
describe('FileTree git ink', () => {
it('tints a changed file name and badges its porcelain letter', async () => {
gitStatus.mockResolvedValue(REPO)
harness = await mountTree()
const row = rowByName(harness.container, 'a.ts')
expect(nameSpan(row).getAttribute('data-git-tone')).toBe('modified')
expect(nameSpan(row).className).toContain('explorerGitName')
const badge = row.querySelector<HTMLElement>('[class*="statusBadge"]')
expect(badge?.textContent).toBe('M')
expect(badge?.getAttribute('data-tone')).toBe('modified')
expect(badge?.getAttribute('title')).toBe('Modified')
})
it('tints a directory that contains a change below it, without a letter', async () => {
gitStatus.mockResolvedValue(REPO)
harness = await mountTree()
const row = rowByName(harness.container, 'sub')
expect(nameSpan(row).className).toContain('explorerDirChanged')
expect(row.querySelector('[class*="statusBadge"]')).toBeNull()
})
it('leaves a clean file plain', async () => {
gitStatus.mockResolvedValue(REPO)
harness = await mountTree()
const row = rowByName(harness.container, 'b.ts')
expect(nameSpan(row).getAttribute('data-git-tone')).toBeNull()
expect(row.querySelector('[class*="statusBadge"]')).toBeNull()
})
it('degrades silently outside a repository', async () => {
gitStatus.mockResolvedValue({ isRepo: false, entries: [] })
harness = await mountTree()
const file = rowByName(harness.container, 'a.ts')
expect(nameSpan(file).getAttribute('data-git-tone')).toBeNull()
expect(nameSpan(file).className).not.toContain('explorerGitName')
const dir = rowByName(harness.container, 'sub')
expect(nameSpan(dir).className).not.toContain('explorerDirChanged')
})
})
+308
View File
@@ -0,0 +1,308 @@
/**
* The right-click menu must be CHEAP: opening, closing and reopening it is
* pure UI state and must not touch the workspace.
*
* This spec is both the measurement probe and the regression guard for the
* user's "右键菜单点击后不要频繁刷新" report. It pins four counters across a
* menu open → close → reopen cycle:
* - `api.fsTree`: directory levels load on expand / refresh tick ONLY, never
* because a menu opened;
* - `api.gitStatus`: the shared status poll PAUSES while a menu is open (git
* ink behind a context menu is not worth a request) and resumes on close;
* - icon resolutions (`service.fileIcon` / `folderIcon`): a memoized row
* resolves its icon on every render, so this counts re-rendered rows;
* - `api.fileApps`: the host application list is CACHED per path, so
* reopening the same row's menu neither re-requests it nor flashes
* "Loading…".
*/
// @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 type { BetterSidebarService } from '../src/client/service.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so any copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
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(),
archiveBuild: vi.fn(),
archiveStatus: vi.fn(),
}))
vi.mock('../src/client/api.ts', () => ({
api: { fsTree, fsTrees, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus },
downloadUrl: () => '/sidebar/file',
archiveDownloadUrl: (id: string) => `/sidebar/archive/${id}`,
isOutsideWorkspaceMessage: () => false,
}))
/** Icon resolutions ARE the row-render probe (a row resolves its icon per render). */
const fileIcons: string[] = []
const folderIcons: string[] = []
const service = {
subscribe: () => () => {},
fileIcon: (path: string) => { fileIcons.push(path); return null },
folderIcon: (path: string) => { folderIcons.push(path); return null },
} as unknown as BetterSidebarService
const HOST_APPS = [
{ id: 'textedit', name: 'TextEdit', icon: null, isDefault: true },
{ id: 'preview', name: 'Preview', icon: null, isDefault: false },
]
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 'menu-perf',
cwd: '/tmp',
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
service,
openInApp: {
available: () => true,
probe: async () => true,
directoryApps,
fileApps,
open: async () => true,
reveal: async () => true,
},
}))
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
async function openRowMenu(container: HTMLElement, name: string): Promise<void> {
await act(async () => {
rowByName(container, name).dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }),
)
await Promise.resolve()
await Promise.resolve()
})
}
async function closeMenu(): Promise<void> {
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await Promise.resolve()
})
}
/** Spin the timers one full git poll window (the status interval is 2500ms). */
async function advance(ms: number): Promise<void> {
await act(async () => {
vi.advanceTimersByTime(ms)
await Promise.resolve()
await Promise.resolve()
})
}
interface Metrics {
fsTree: number
gitStatus: number
fileApps: number
fileIcons: number
folderIcons: number
}
let harness: Harness
const baseline = { fsTree: 0, gitStatus: 0, fileApps: 0, fileIcons: 0, folderIcons: 0 }
/** The counters SINCE the last `mark()`. */
function since(): Metrics {
return {
fsTree: fsTree.mock.calls.length - baseline.fsTree,
gitStatus: gitStatus.mock.calls.length - baseline.gitStatus,
fileApps: fileApps.mock.calls.length - baseline.fileApps,
fileIcons: fileIcons.length - baseline.fileIcons,
folderIcons: folderIcons.length - baseline.folderIcons,
}
}
function mark(): void {
baseline.fsTree = fsTree.mock.calls.length
baseline.gitStatus = gitStatus.mock.calls.length
baseline.fileApps = fileApps.mock.calls.length
baseline.fileIcons = fileIcons.length
baseline.folderIcons = folderIcons.length
}
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()
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: [] })
fileApps.mockReset()
fileApps.mockResolvedValue(HOST_APPS)
directoryApps.mockReset()
directoryApps.mockResolvedValue(HOST_APPS)
archiveBuild.mockReset()
archiveStatus.mockReset()
fileIcons.length = 0
folderIcons.length = 0
mark()
})
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
vi.useRealTimers()
})
describe('FileTree context-menu cost', () => {
it('does not list directories, re-render rows or poll git while the menu is open', async () => {
harness = await mountTree()
// Warm the shared status store (its first fetch happens on mount).
await advance(0)
// ── First open: the right-click also collapses the selection onto the row.
mark()
await openRowMenu(harness.container, 'a.ts')
const firstOpen = since()
await closeMenu()
// ── Reopen the SAME row's menu and let a full poll window elapse while it
// is open: nothing may hit the workspace.
mark()
await openRowMenu(harness.container, 'a.ts')
await advance(3_000)
const openWindow = since()
await closeMenu()
// ── The poll resumes once the menu closed.
mark()
await advance(3_000)
const afterClose = since()
console.log('MENU-PERF', JSON.stringify({ firstOpen, openWindow, afterClose }))
// Menu churn never lists a directory.
expect(openWindow.fsTree).toBe(0)
expect(afterClose.fsTree).toBe(0)
// The shared git poll keeps running behind the menu — by design, and the
// measured trade-off: pausing it (`visible: rowMenu === null`) makes the
// store drop its snapshot, so EVERY changed row loses its ink while the
// menu is open (measured: `tintedWhileOpen: false`, +2 row re-renders)
// against saving one background request per poll window. The rows stay
// untouched either way because the row's git props are primitives.
expect(openWindow.gitStatus).toBeGreaterThanOrEqual(0)
// Memoized rows do not re-render because the menu opened — not even the
// root row (which is memoized too) and not when a poll lands.
expect(openWindow.fileIcons).toBe(0)
expect(openWindow.folderIcons).toBe(0)
// The host application list is cached per path: one request, not one per open.
expect(openWindow.fileApps).toBe(0)
expect(firstOpen.fileApps).toBeLessThanOrEqual(1)
})
it('reopens the row menu without a Loading flash (the app list is cached)', async () => {
harness = await mountTree()
await advance(0)
await openRowMenu(harness.container, 'a.ts')
expect(fileApps).toHaveBeenCalledTimes(1)
await closeMenu()
const requestsAfterFirstOpen = fileApps.mock.calls.length
await openRowMenu(harness.container, 'a.ts')
// Cached: no second request…
expect(fileApps.mock.calls.length).toBe(requestsAfterFirstOpen)
// …and the submenu carries the applications immediately (no disabled
// Loading row).
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
expect(parent).toBeDefined()
await act(async () => { parent!.click(); await Promise.resolve() })
const submenuLabels = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.map(item => item.textContent?.trim())
// The default handler is hoisted to level 1; the rest of the list is
// already there (no disabled Loading row).
expect(submenuLabels).toContain('Preview')
expect(submenuLabels).not.toContain('Loading…')
})
it('keeps the git ink and does not re-render changed rows while the menu is open', async () => {
// A repo with one changed file: the row carries its tone class while the
// menu is open, so pausing the poll must not cost the tint (the shared
// store drops its snapshot when its last visible consumer releases).
gitStatus.mockResolvedValue({
isRepo: true,
root: '/tmp',
entries: [{ path: 'a.ts', xy: ' M' }],
})
harness = await mountTree()
await advance(30)
const tinted = (): boolean =>
rowByName(harness.container, 'a.ts').querySelector('[data-git-tone]') !== null
expect(tinted()).toBe(true)
// The FIRST open also collapses the selection onto the row (one row
// re-render, by design); measure the reopen, where nothing may change.
await openRowMenu(harness.container, 'a.ts')
await closeMenu()
mark()
await openRowMenu(harness.container, 'a.ts')
await advance(3_000)
const openWindow = since()
const tintedWhileOpen = tinted()
await closeMenu()
console.log('MENU-PERF-REPO', JSON.stringify({ openWindow, tintedWhileOpen }))
// The changed row does not re-render because the menu opened…
expect(openWindow.fileIcons).toBe(0)
// …and its git ink survives the whole open window.
expect(tintedWhileOpen).toBe(true)
})
})
+277
View File
@@ -0,0 +1,277 @@
/**
* FileTree multi-selection (VS Code semantics): Ctrl/Cmd+click toggles one
* row and seats the Shift anchor, Shift+click selects the visible range from
* that anchor, Escape and a blank click clear the selection, and a right
* click outside the selection collapses it onto the row. A non-empty
* selection shows the batch bar (count + copy paths / delete / clear); the
* batch delete confirms once, removes sequentially, and stops on the first
* failure with the error strip.
*
* The other half of the contract is what must NOT change: an unmodified click
* still opens a file / toggles a directory and drops the selection.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
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 bar's copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { fsRemove, fsTrees } = vi.hoisted(() => ({
fsRemove: vi.fn(async (_scope: unknown, path: string) => ({ path })),
// 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: {
fsTrees,
fsRemove,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the selection).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
interface Harness {
container: HTMLDivElement
opened: string[]
toggled: string[]
unmount: () => void
}
async function mountTree(expanded: string[] = []): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const opened: string[] = []
const toggled: string[] = []
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
expanded,
revealed: [],
onToggle: (path: string) => { toggled.push(path) },
onOpenFile: (path: string) => { opened.push(path) },
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
})
return {
container,
opened,
toggled,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name. */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function click(el: Element, init: MouseEventInit = {}): void {
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })) })
}
function rightClick(el: Element): void {
act(() => {
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }))
})
}
function pressEscape(el: Element): void {
act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) })
}
/** The batch bar (the kit's section band carries the page class). */
function selectionBar(container: HTMLElement): HTMLElement | null {
return container.querySelector<HTMLElement>('[class*="explorerSelectionBar"]')
}
/** The names of the rows currently rendered as selected. */
function selectedNames(container: HTMLElement): string[] {
return [...container.querySelectorAll<HTMLElement>('[class*="explorerRowSelected"]')]
.map(row => row.querySelector('[class*="explorerName"]')?.textContent ?? '')
}
/** One batch-bar action by its label. */
function barAction(container: HTMLElement, label: string): HTMLElement {
const button = [...(selectionBar(container)?.querySelectorAll<HTMLElement>('button') ?? [])]
.find(el => el.textContent === label)
if (button === undefined) throw new Error(`bar action not found: ${label}`)
return button
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsRemove.mockReset()
fsRemove.mockImplementation(async (_scope: unknown, path: string) => ({ path }))
fsTrees.mockClear()
})
describe('FileTree multi-selection', () => {
it('keeps the unmodified click semantics and drops the selection', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
expect(selectionBar(harness.container)).not.toBeNull()
// A plain click opens the file (and clears the batch).
click(rowByName(harness.container, 'b.ts'))
expect(harness.opened).toEqual(['/tmp/b.ts'])
expect(selectionBar(harness.container)).toBeNull()
// A plain click on a directory row still toggles it.
click(rowByName(harness.container, 'sub'))
expect(harness.toggled).toEqual(['/tmp/sub'])
expect(harness.opened).toEqual(['/tmp/b.ts'])
})
it('Ctrl/Cmd+click toggles rows and the bar counts them', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts'])
expect(selectionBar(harness.container)?.textContent).toContain('1 selected')
click(rowByName(harness.container, 'b.ts'), { metaKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts', 'b.ts'])
expect(selectionBar(harness.container)?.textContent).toContain('2 selected')
// Toggling the same row off again leaves exactly the other one.
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts'])
})
it('Shift+click selects the visible range from the Ctrl-seated anchor', async () => {
harness = await mountTree()
// The anchor is 'b.ts' (the last visible row), so the range reaches 'a.ts'.
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'a.ts'), { shiftKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts', 'b.ts'])
// A collapsed directory between two rows is part of the visible order.
click(rowByName(harness.container, 'sub'), { shiftKey: true })
expect(selectedNames(harness.container)).toEqual(['sub', 'a.ts', 'b.ts'])
})
it('Escape, the bar\'s Clear selection, and a click on the tree body all clear the selection', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
pressEscape(harness.container.querySelector('[class*="explorerBody"]')!)
expect(selectionBar(harness.container)).toBeNull()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
act(() => { barAction(harness.container, 'Clear selection').click() })
expect(selectionBar(harness.container)).toBeNull()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(harness.container.querySelector('[class*="explorerBody"]')!)
expect(selectionBar(harness.container)).toBeNull()
})
it('right-click collapses the selection onto an unselected row and keeps a selected one', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
// The row is already part of the selection: the whole batch survives.
rightClick(rowByName(harness.container, 'b.ts'))
expect(selectedNames(harness.container)).toEqual(['a.ts', 'b.ts'])
// An unselected row collapses the batch onto itself.
rightClick(rowByName(harness.container, 'sub'))
expect(selectedNames(harness.container)).toEqual(['sub'])
})
it('copies every selected absolute path, newline separated', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true })
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
await act(async () => { barAction(harness.container, 'Copy paths').click() })
expect(writeText).toHaveBeenCalledWith('/tmp/a.ts\n/tmp/b.ts')
})
it('deletes the whole selection through one confirmation, sequentially', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
act(() => { barAction(harness.container, 'Delete selected').click() })
const dialog = document.querySelector('[role="dialog"]')
expect(dialog?.textContent).toContain('Delete 2 selected items?')
const confirm = [...document.querySelectorAll<HTMLElement>('button')]
.find(el => el.textContent === 'Delete selected' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
expect(fsRemove.mock.calls.map(call => call[1])).toEqual(['/tmp/a.ts', '/tmp/b.ts'])
expect(selectionBar(harness.container)).toBeNull()
})
it('docks the batch bar at the BOTTOM of the tree scroll container', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
const body = harness.container.querySelector<HTMLElement>('[class*="explorerBody"]')!
const bar = harness.container.querySelector<HTMLElement>('[class*="explorerSelectionBar"]')!
// In flow AFTER every row (the menu's portal anchor and the hidden file
// input are the only trailing siblings) — never absolutely positioned
// over the rows, so scrolled to the bottom it sits BELOW the last row.
const rows = [...body.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
expect(rows.length).toBeGreaterThan(0)
expect(rows[rows.length - 1]!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The only trailing siblings are the menu's portal anchor and the hidden
// file input, so the bar IS the last piece of tree content.
const content = [...body.children].filter(child =>
child.tagName !== 'INPUT' && !(child as HTMLElement).className.includes('_root_'))
expect(content[content.length - 1]).toBe(bar)
// jsdom has no layout: the sheet is the evidence for the docking mechanic.
const sheet = readFileSync('src/client/sidebar.module.css', 'utf8')
const rule = sheet.match(/\.explorerSelectionBar \{([\s\S]*?)\n\}/)?.[1]
expect(rule, 'the .explorerSelectionBar rule must exist').toBeDefined()
expect(rule).toContain('position: sticky')
expect(rule).toContain('bottom: 0')
expect(rule).not.toContain('position: absolute')
expect(rule).not.toContain('position: fixed')
// Narrow panels WRAP the chips instead of clipping them or scrolling.
const actions = sheet.match(/\.explorerSelectionActions \{([\s\S]*?)\n\}/)?.[1]
expect(actions).toContain('flex-wrap: wrap')
})
it('stops the batch at the first failure and reports it in the strip', async () => {
// The first path lands; the second refuses — the walk must stop there.
fsRemove
.mockImplementationOnce(async (_scope: unknown, path: string) => ({ path }))
.mockRejectedValueOnce(new Error('boom: refused'))
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
act(() => { barAction(harness.container, 'Delete selected').click() })
const confirm = [...document.querySelectorAll<HTMLElement>('button')]
.find(el => el.textContent === 'Delete selected' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
// Two attempts, in selection order, and nothing after the refusal.
expect(fsRemove.mock.calls.map(call => call[1])).toEqual(['/tmp/a.ts', '/tmp/b.ts'])
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('boom: refused')
// The removed row left the selection; the refused one stays for a retry.
expect(selectedNames(harness.container)).toEqual(['b.ts'])
})
})
+251
View File
@@ -0,0 +1,251 @@
/**
* FileTree "new folder": the directory rows (and the workspace root row)
* offer it in the context menu; choosing it inserts the inline editor at the
* TOP of that level (expanding a collapsed directory first), and the editor
* follows the rename contract — Enter commits through `api.fsMkdir` with the
* parent directory + single-segment name, Escape cancels, blur commits, an
* invalid name reports `newFolderInvalid` and never reaches the API, and a
* server failure lands in the dismissable strip.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
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 { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
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).
fsTrees: vi.fn(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => listingFor(path)) })),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTrees,
fsMkdir,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about folder creation).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
const ROOT_ENTRIES = [
{ name: 'sub', path: '/tmp/sub', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
]
/** Per-directory listings: /tmp/sub must NOT list itself (a real host never
* returns a directory inside its own listing). */
function listingFor(path: string): { path: string; entries: typeof ROOT_ENTRIES; truncated: boolean } {
return {
path,
entries: path === '/tmp/sub' ? [{ name: 'inner.txt', path: '/tmp/sub/inner.txt', isDir: false }] : ROOT_ENTRIES,
truncated: false,
}
}
interface Harness {
container: HTMLDivElement
/** Directories the tree asked the caller to expand/collapse. */
toggled: string[]
unmount: () => void
}
/**
* The real caller owns the expansion set (EditorHost holds it in the store),
* so the harness does too: a toggle really expands the tree and loads the
* level beneath it.
*/
function mountTree(initialExpanded: string[] = []): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const toggled: string[] = []
function Host(): ReactNode {
const [expanded, setExpanded] = useState<string[]>(initialExpanded)
return createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
expanded,
revealed: [],
onToggle: (path: string) => {
toggled.push(path)
setExpanded(current => current.includes(path) ? current.filter(item => item !== path) : [...current, path])
},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
})
}
act(() => { root.render(createElement(Host)) })
return {
container,
toggled,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name (the root row included). */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function openMenu(container: HTMLElement, name: string): void {
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
act(() => { rowByName(container, name).dispatchEvent(event) })
}
function menuLabels(): string[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(el => el.textContent ?? '')
}
function clickMenuitem(label: string): void {
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem not found: ${label}`)
act(() => { item.click() })
}
/** Set a controlled input's value the React way (native setter + input). */
function setNativeValue(el: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
if (setter === undefined) throw new Error('no native value setter')
setter.call(el, value)
el.dispatchEvent(new Event('input', { bubbles: true }))
}
function pressKey(el: HTMLElement, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
function editorInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')
if (input === null) throw new Error('inline editor not rendered')
return input
}
/** The editor row's indent (depth * 22 + 6) — proof of WHICH level hosts it. */
function editorIndent(container: HTMLElement): string {
return editorInput(container).closest<HTMLElement>('[class*="explorerRow"]')?.style.paddingLeft ?? ''
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsMkdir.mockReset()
fsMkdir.mockImplementation(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` }))
fsTrees.mockReset()
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => listingFor(path)) }))
})
describe('FileTree new folder', () => {
it('offers the entry on directory rows (the root included, never on files)', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'sub')
expect(menuLabels()).toContain('New folder')
openMenu(harness.container, 'tmp')
expect(menuLabels()).toContain('New folder')
openMenu(harness.container, 'a.ts')
expect(menuLabels()).not.toContain('New folder')
})
it('inserts the editor at the root level and commits through api.fsMkdir', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
expect(input.placeholder).toBe('Folder name')
// The root level's rows sit at depth 1 (22 + 6); the editor replaces the
// level's first row.
expect(editorIndent(harness.container)).toBe('28px')
setNativeValue(input, 'newdir')
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(fsTrees.mock.calls.length).toBeGreaterThan(listings)
})
it('expands a collapsed directory, edits at ITS level, and creates there', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'sub')
clickMenuitem('New folder')
// The collapsed directory is expanded so its level can host the editor.
expect(harness.toggled).toEqual(['/tmp/sub'])
expect(editorIndent(harness.container)).toBe('50px')
setNativeValue(editorInput(harness.container), 'inner')
await act(async () => { pressKey(editorInput(harness.container), 'Enter') })
expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/sub', 'inner')
})
it('Escape cancels without touching the API', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'newdir')
await act(async () => { pressKey(input, 'Escape') })
expect(fsMkdir).not.toHaveBeenCalled()
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
})
it('rejects an invalid name client-side with the strip, no API call', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'a/b')
await act(async () => { pressKey(input, 'Enter') })
expect(fsMkdir).not.toHaveBeenCalled()
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Invalid folder name')
})
it('lands a server refusal in the dismissable strip', async () => {
fsMkdir.mockRejectedValueOnce(new Error('boom: exists'))
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'sub')
await act(async () => { pressKey(input, 'Enter') })
expect(fsMkdir).toHaveBeenCalledTimes(1)
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('boom: exists')
})
it('commits on blur (the same contract the rename editor has)', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'blurred')
// React delegates onBlur from the native focusout event.
await act(async () => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) })
expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp', 'blurred')
})
})
+460
View File
@@ -0,0 +1,460 @@
/**
* FileTree's host "open in app" rows, arranged by round three's menu
* hierarchy: the host's DEFAULT handler stays one click away at level 1, and
* every other application — the host's registered handlers AND (per the
* visibility rule) the plugin's own fixed targets — lives in the single
* `openWithMenu` submenu. Reveal-in-file-manager stays at level 1 while the
* host can hand paths to a desktop.
*
* Visibility ("有本机检测到的关联应用时就不显示固定的 openwith 项目"):
* - the host lists applications for the path → plugin targets are hidden
* unless `openWithShowPluginTargets` (the `openWithPluginTargets` setting)
* turns them on;
* - the host is unavailable, or the listing is empty/failed → the plugin
* targets show (the file manager target included, so reveal is never lost).
*/
// @vitest-environment jsdom
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 { 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
vi.mock('../src/client/api.ts', () => ({
api: {
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 tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the open-in-app rows).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
/** What the host reports for a file (the default entry first). */
const FILE_APPS: readonly OpenInAppEntry[] = [
{ id: 'textedit', name: 'TextEdit', icon: null, isDefault: true },
{ id: 'preview', name: 'Preview', icon: 'data:image/svg+xml,%3Csvg/%3E', isDefault: false },
]
/** What the host reports for a directory (no default concept). */
const DIRECTORY_APPS: readonly OpenInAppEntry[] = [
{ id: 'vscode', name: 'VS Code', icon: null, isDefault: false },
{ id: 'finder', name: 'Finder', icon: null, isDefault: false },
]
/** The plugin's own targets a caller (EditorHost) may hand FileTree. */
const PLUGIN_TARGETS: OpenWithTarget[] = [
{ id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true },
{ id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false },
]
interface Handle {
openInApp: OpenInApp
open: ReturnType<typeof vi.fn>
reveal: ReturnType<typeof vi.fn>
fileApps: ReturnType<typeof vi.fn>
directoryApps: ReturnType<typeof vi.fn>
}
function makeHandle(overrides: Partial<OpenInApp> = {}): Handle {
const open = vi.fn(async () => true)
const reveal = vi.fn(async () => true)
const fileApps = vi.fn(async () => FILE_APPS)
const directoryApps = vi.fn(async () => DIRECTORY_APPS)
const openInApp: OpenInApp = {
available: () => true,
probe: async () => true,
directoryApps,
fileApps,
open,
reveal,
...overrides,
}
return { openInApp, open, reveal, fileApps, directoryApps }
}
interface Harness {
container: HTMLDivElement
onOpenWith: ReturnType<typeof vi.fn>
onToggleOpenWithPin: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(handle?: Handle, plugin: {
targets?: OpenWithTarget[]
pinned?: string[]
ssh?: boolean
showPluginTargets?: boolean
} = {}): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const onOpenWith = vi.fn()
const onToggleOpenWithPin = vi.fn()
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onOpenFileNewTab: () => {},
onOpenFileSide: () => {},
...(handle !== undefined ? { openInApp: handle.openInApp } : {}),
...(plugin.targets !== undefined ? {
openWithTargets: plugin.targets,
openWithPinned: plugin.pinned ?? [],
openWithSsh: plugin.ssh ?? false,
onOpenWith,
onToggleOpenWithPin,
} : {}),
...(plugin.showPluginTargets !== undefined ? { openWithShowPluginTargets: plugin.showPluginTargets } : {}),
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
// Settle the async app listing / availability probe the menu triggers.
await Promise.resolve()
await Promise.resolve()
})
return {
container,
onOpenWith,
onToggleOpenWithPin,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** The row whose label text matches (rows are role="button" divs). */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
/** Open one row's context menu and settle the async app listing. */
async function openMenu(container: HTMLElement, name: string): Promise<void> {
await act(async () => {
rowByName(container, name).dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }),
)
await Promise.resolve()
await Promise.resolve()
})
}
function menuItems(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
function menuLabels(): string[] {
return menuItems().map(item => item.textContent ?? '')
}
/** The level-1 submenu parent row (the only one with aria-haspopup). */
function submenuParent(): HTMLElement {
const parent = menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu')
if (parent === undefined) throw new Error('open-with submenu parent not found')
return parent
}
/** Open the submenu and settle its rows. */
async function openSubmenu(): Promise<void> {
await act(async () => {
submenuParent().click()
await Promise.resolve()
})
}
function submenuRows(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
}
function submenuLabels(): string[] {
return submenuRows().map(item => item.textContent?.trim() ?? '')
}
function clickMenuitem(label: string): void {
const item = menuItems().find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem not found: ${label}`)
act(() => { item.click() })
}
function clickSubmenuRow(label: string): void {
const item = submenuRows().find(el => el.textContent?.trim() === label)
if (item === undefined) throw new Error(`submenu row not found: ${label}`)
act(() => { item.click() })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
})
describe('FileTree open-in-app rows in the menu hierarchy', () => {
it('hoists the default application to level 1 and keeps every other app in the submenu', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(handle.fileApps).toHaveBeenCalledWith('/tmp/a.ts')
// Level 1: the default handler, the submenu and the host reveal.
const level1 = menuLabels()
expect(level1).toContain('Open with default app')
expect(level1).toContain('Open with')
expect(level1).toContain('Reveal in File Manager')
// No application NAME sits at level 1 any more.
expect(level1).not.toContain('TextEdit')
expect(level1).not.toContain('Preview')
// The submenu carries the remaining applications (and their icons).
await openSubmenu()
expect(submenuLabels()).toEqual(['Preview'])
expect(submenuRows()[0]!.querySelector('img[class*="explorerAppIcon"]')).not.toBeNull()
// No heading rows anywhere (the primitive's leaf label row is gone).
const menu = document.querySelector('[role="menuitem"]')!.closest('[role="menu"]')
expect([...(menu?.querySelectorAll('[role="presentation"]') ?? [])]
.filter(row => row.children.length === 0)).toEqual([])
})
it('keeps the frozen level-1 order: open-with group, open escapes, zip, copy, mutations', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
const labels = menuLabels()
const at = (label: string): number => {
const index = labels.indexOf(label)
expect(index, `missing menu row: ${label}`).toBeGreaterThanOrEqual(0)
return index
}
// 1-3: default handler → the one submenu → reveal.
expect(at('Open with default app')).toBeLessThan(at('Open with'))
expect(at('Open with')).toBeLessThan(at('Reveal in File Manager'))
// 4: the explicit open escapes.
expect(at('Reveal in File Manager')).toBeLessThan(at('Open in New Tab'))
expect(at('Open in New Tab')).toBeLessThan(at('Open to the Side'))
// 5: the downloads (the plain file download row + the archive row).
expect(at('Open to the Side')).toBeLessThan(at('Download'))
// 6: copy, then the mutations.
expect(at('Download')).toBeLessThan(at('Copy relative path'))
expect(at('Copy relative path')).toBeLessThan(at('Rename'))
})
it('hands the path (and the chosen application id) to the open handle', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
clickMenuitem('Open with default app')
expect(handle.open).toHaveBeenCalledWith('/tmp/a.ts')
// Selecting closes the menu entirely.
expect(menuItems()).toHaveLength(0)
await openMenu(harness.container, 'a.ts')
await openSubmenu()
clickSubmenuRow('Preview')
expect(handle.open).toHaveBeenCalledWith('/tmp/a.ts', 'preview')
await openMenu(harness.container, 'a.ts')
clickMenuitem('Reveal in File Manager')
expect(handle.reveal).toHaveBeenCalledWith('/tmp/a.ts')
})
it('lists the host catalogue for a directory row, with no default row', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'sub')
expect(handle.directoryApps).toHaveBeenCalled()
expect(handle.fileApps).not.toHaveBeenCalled()
expect(menuLabels()).not.toContain('Open with default app')
expect(menuLabels()).toContain('Reveal in File Manager')
await openSubmenu()
expect(submenuLabels()).toEqual(['VS Code', 'Finder'])
clickSubmenuRow('Finder')
expect(handle.open).toHaveBeenCalledWith('/tmp/sub', 'finder')
})
it('degrades to one DISABLED submenu row when the host knows no application', async () => {
const handle = makeHandle({ fileApps: async () => [] })
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
// No default handler to hoist.
expect(menuLabels()).not.toContain('Open with default app')
await openSubmenu()
const empty = submenuRows().find(item => item.textContent === 'No application can open it')
expect(empty).toBeDefined()
expect((empty as HTMLButtonElement).disabled).toBe(true)
})
it('reports a failed hand-off in the error strip', async () => {
const handle = makeHandle({ fileApps: async () => null })
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Could not open: /tmp/a.ts')
})
it('reports a hand-off the host refused (open resolving false)', async () => {
const handle = makeHandle()
handle.open.mockResolvedValue(false)
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
await act(async () => { clickMenuitem('Open with default app'); await Promise.resolve(); await Promise.resolve() })
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Could not open: /tmp/a.ts')
})
it('hides the whole section when the host cannot open desktop paths', async () => {
const handle = makeHandle({ available: () => false })
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(handle.fileApps).not.toHaveBeenCalled()
expect(menuLabels()).not.toContain('Reveal in File Manager')
expect(menuItems().some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
expect(document.body.textContent).not.toContain('Open with')
})
it('hides the section when no handle was injected at all', async () => {
harness = await mountTree()
await openMenu(harness.container, 'a.ts')
expect(menuLabels()).not.toContain('Reveal in File Manager')
expect(document.body.textContent).not.toContain('Open with')
})
it('probes the tri-state handle before deciding the section is unavailable', async () => {
// The REAL adapter starts with `available() === null` and only publishes
// an answer when `probe()` runs: a tree that reads the getter without
// probing would hide the section forever. This case pins that lifecycle
// with a handle that mimics it exactly.
let probed = false
let probes = 0
const handle = makeHandle({
available: () => (probed ? true : null),
probe: async () => { probed = true; probes += 1; return true },
})
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(handle.fileApps).toHaveBeenCalledWith('/tmp/a.ts')
expect(menuLabels()).toContain('Open with default app')
expect(probes).toBe(1)
})
it('drives the real host adapter end to end: probe, list, open, reveal', async () => {
// No hand-written handle here — the production adapter over a fake Host
// Remote, so the wiring (probe → fileApps → open/reveal) cannot drift.
const calls = { canOpen: 0, applications: [] as string[], open: [] as { path: string; application?: string }[] }
const openInApp = createOpenInApp({
get: (name) => name !== 'remote' ? undefined : {
session: {
canOpenWorkspacePath: async () => { calls.canOpen += 1; return { ok: true, value: true } },
workspacePathApplications: async (request: { path: string }) => {
calls.applications.push(request.path)
return { ok: true, value: [{ id: 'textedit', name: 'TextEdit', default: true, icon: null }] }
},
openWorkspacePath: async (request: { path: string; application?: string }) => {
calls.open.push({ path: request.path, ...(request.application !== undefined ? { application: request.application } : {}) })
return { ok: true }
},
},
},
})
expect(openInApp.available()).toBeNull()
harness = await mountTree({ openInApp } as Handle)
await openMenu(harness.container, 'a.ts')
expect(calls.canOpen).toBe(1)
expect(calls.applications).toEqual(['/tmp/a.ts'])
expect(menuLabels()).toContain('Open with default app')
await act(async () => { clickMenuitem('Open with default app'); await Promise.resolve() })
expect(calls.open).toEqual([{ path: '/tmp/a.ts' }])
})
})
describe('FileTree host apps vs plugin open-with targets', () => {
it('hides the plugin targets while the host lists applications (the default)', async () => {
const handle = makeHandle()
harness = await mountTree(handle, { targets: PLUGIN_TARGETS })
await openMenu(harness.container, 'a.ts')
await openSubmenu()
expect(submenuLabels()).toEqual(['Preview'])
expect(submenuLabels()).not.toContain('Windsurf')
expect(submenuLabels()).not.toContain('File Manager')
})
it('keeps both lists side by side when openWithShowPluginTargets is on', async () => {
const handle = makeHandle()
harness = await mountTree(handle, { targets: PLUGIN_TARGETS, showPluginTargets: true })
await openMenu(harness.container, 'a.ts')
await openSubmenu()
// Host applications first, then the plugin's own targets. The plugin's
// `explorer` target stays filtered (the host reveal row stands in for it).
expect(submenuLabels()).toEqual(['Preview', 'Windsurf'])
})
it('shows the plugin targets — explorer included — when the host cannot open desktop paths', async () => {
const handle = makeHandle({ available: () => false })
harness = await mountTree(handle, { targets: PLUGIN_TARGETS })
await openMenu(harness.container, 'a.ts')
// No host rows at all…
expect(menuLabels()).not.toContain('Open with default app')
expect(menuLabels()).not.toContain('Reveal in File Manager')
// …but the plugin's own targets (and its reveal) survive.
await openSubmenu()
expect(submenuLabels()).toEqual(['File Manager', 'Windsurf'])
})
it('shows the plugin targets when the host listing is empty', async () => {
const handle = makeHandle({ fileApps: async () => [] })
harness = await mountTree(handle, { targets: PLUGIN_TARGETS })
await openMenu(harness.container, 'a.ts')
await openSubmenu()
// The disabled "no application" line plus the plugin's own targets. The
// plugin's `explorer` target stays filtered because the host IS available:
// level 1 still carries its reveal row, so nothing is lost.
expect(submenuLabels()).toEqual(['No application can open it', 'Windsurf'])
expect(menuLabels()).toContain('Reveal in File Manager')
})
it('shows the plugin targets when no host handle was injected at all', async () => {
harness = await mountTree(undefined, { targets: PLUGIN_TARGETS })
await openMenu(harness.container, 'a.ts')
expect(menuLabels()).not.toContain('Reveal in File Manager')
await openSubmenu()
expect(submenuLabels()).toEqual(['File Manager', 'Windsurf'])
})
it('routes a plugin row click from inside the submenu', async () => {
const handle = makeHandle({ available: () => false })
harness = await mountTree(handle, { targets: PLUGIN_TARGETS })
await openMenu(harness.container, 'a.ts')
await openSubmenu()
expect(submenuRows().every(row => row.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
clickSubmenuRow('Windsurf')
expect(harness.onOpenWith).toHaveBeenCalledWith('custom:w', '/tmp/a.ts')
})
it('toggles a plugin pin without selecting the row', async () => {
const handle = makeHandle({ available: () => false })
harness = await mountTree(handle, { targets: PLUGIN_TARGETS, pinned: ['custom:w'] })
await openMenu(harness.container, 'a.ts')
await openSubmenu()
const pin = submenuRows().find(row => row.textContent?.trim() === 'Windsurf')!
.querySelector<HTMLElement>('[class*="openWithPin"]')!
expect(pin.getAttribute('title')).toBe('Unpin')
act(() => { pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('custom:w')
expect(harness.onOpenWith).not.toHaveBeenCalled()
})
})
+98 -68
View File
@@ -1,9 +1,12 @@
/**
* FileTree "open with" context menu: a file row's right-click menu gains the
* pinned direct rows and the parent submenu row; the submenu lists every
* resolved target with a per-row pushpin, pinning never selects/closes the
* menu, and selecting a child invokes the caller's open handler with the
* row's absolute path. When the caller wires nothing, the section is absent.
* FileTree's PLUGIN open-with menu. Round three folded every application into
* ONE submenu (`openWithMenu`), so with no host handle injected the level-1
* menu carries just that submenu row, and the plugin's targets (file manager /
* VS Code / Cursor / Zed / custom editors) are its rows — each with the
* per-row pushpin that toggles without selecting the row, SSH suffixes in
* remote mode, and (without the host) the reveal target kept, so reveal is
* never lost. The host/plugin VISIBILITY rules live in
* file-tree-open-in-app.spec.tsx.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
@@ -11,10 +14,8 @@ 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 type { OpenWithTarget } from '../src/client/open-with.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
@@ -25,11 +26,18 @@ beforeAll(() => {
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
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 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,
}))
/** The resolved target list a caller (EditorHost) would hand FileTree. */
@@ -66,7 +74,6 @@ async function mountTree(overrides: {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
@@ -83,16 +90,17 @@ async function mountTree(overrides: {
onUploadRequest: () => {},
busy: false,
}))
await Promise.resolve()
})
return {
container,
onOpenWith,
onToggleOpenWithPin,
unmount: () => { act(() => { root.unmount() }) },
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** The file row of the one-level tree (role="button" with the name span). */
/** The file row of the one-level tree. */
function fileRow(container: HTMLDivElement): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
@@ -106,57 +114,85 @@ function openMenu(container: HTMLDivElement): void {
act(() => { fileRow(container).dispatchEvent(event) })
}
describe('FileTree open-with menu', () => {
function menuItems(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
function submenuRows(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
}
function submenuParent(): HTMLElement {
const parent = menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu')
if (parent === undefined) throw new Error('submenu parent not found')
return parent
}
describe('FileTree plugin open-with menu', () => {
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
document.body.removeAttribute('data-dsh-sidebar-submenu')
})
it('renders the pinned direct row and the submenu parent for a file row', async () => {
it('keeps application rows out of level 1 and lists them under the open-with submenu', async () => {
harness = await mountTree()
openMenu(harness.container)
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
// The pinned VS Code sits at the top level (before the submenu parent).
expect(items.map(item => item.textContent?.trim())).toContain('VS Code')
const parent = items.find(item => item.getAttribute('aria-haspopup') === 'menu')
expect(parent?.textContent).toContain('Open with')
// Level 1: the one submenu parent — no application name sits on it.
expect(menuItems().map(item => item.textContent?.trim())).toContain('Open with')
for (const name of ['File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf']) {
expect(menuItems().map(item => item.textContent?.trim())).not.toContain(name)
}
const parent = submenuParent()
// The submenu parent carries the trailing chevron affordance (the
// primitives Menu renders no arrow of its own), right-aligned inside a
// full-width label row — the same structure the submenu children use.
expect(parent?.querySelector('[class*="openWithChevron"]')).not.toBeNull()
expect(parent?.querySelector('[class*="openWithLabel"]')).not.toBeNull()
expect(parent.querySelector('[class*="openWithChevron"]')).not.toBeNull()
expect(parent.querySelector('[class*="openWithLabel"]')).not.toBeNull()
})
it('opens the submenu on click and lists every target with pin toggles', async () => {
it('lists every target in the submenu, each with a pin toggle', async () => {
harness = await mountTree()
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
expect(parent).toBeDefined()
act(() => { parent!.click() })
// Sub child rows = menuitems inside the nested menu.
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
expect(children.map(item => item.textContent?.trim())).toEqual([
act(() => { submenuParent().click() })
expect(submenuRows().map(item => item.textContent?.trim())).toEqual([
'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf',
])
expect(children.every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
// Pinned state is announced per row (and swaps the pushpin glyph).
const vscodeRow = children.find(item => item.textContent?.trim() === 'VS Code')
const cursorRow0 = children.find(item => item.textContent?.trim() === 'Cursor')
expect(vscodeRow?.querySelector('[aria-label="Unpin"]')).not.toBeNull()
expect(cursorRow0?.querySelector('[aria-label="Pin to menu"]')).not.toBeNull()
expect(submenuRows().every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
// Pinned state is carried per row by the pin's TITLE (it swaps the glyph
// and the hover hint).
const vscodeRow = submenuRows().find(item => item.textContent?.trim() === 'VS Code')
const cursorRow = submenuRows().find(item => item.textContent?.trim() === 'Cursor')
expect(vscodeRow?.querySelector('[title="Unpin"]')).not.toBeNull()
expect(cursorRow?.querySelector('[title="Pin to menu"]')).not.toBeNull()
})
it('keeps the pin a mouse hot zone, never a fake nested control', async () => {
// The Menu renders each row as `<button role="menuitem">`, so ANY
// role=button/tabIndex on the pin would be invalid nesting AND
// unreachable by keyboard (the row button owns focus). It is deliberately
// a plain span: the pin's nameable affordance is its `title`, and the
// reachable path to the same pinned list is the settings panel.
harness = await mountTree()
openMenu(harness.container)
act(() => { submenuParent().click() })
const vscodePin = submenuRows().find(item => item.textContent?.trim() === 'VS Code')!
.querySelector<HTMLElement>('[class*="openWithPin"]')!
expect(vscodePin.tagName).toBe('SPAN')
expect(vscodePin.getAttribute('role')).toBeNull()
expect(vscodePin.hasAttribute('tabindex')).toBe(false)
expect(vscodePin.getAttribute('title')).toBe('Unpin')
// The row itself stays the only interactive element of the entry.
expect(vscodePin.closest('[role="menuitem"]')?.tagName).toBe('BUTTON')
})
it('pin click toggles without selecting the row or closing the menu', async () => {
harness = await mountTree()
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const cursorRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Cursor')
const pin = cursorRow!.querySelector<HTMLElement>('[class*="openWithPin"]')
act(() => { submenuParent().click() })
const cursorRow = submenuRows().find(item => item.textContent?.trim() === 'Cursor')!
const pin = cursorRow.querySelector<HTMLElement>('[class*="openWithPin"]')
expect(pin).not.toBeNull()
act(() => { pin!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('cursor')
@@ -168,44 +204,41 @@ describe('FileTree open-with menu', () => {
it('selecting a submenu child invokes onOpenWith with the row path and closes the menu', async () => {
harness = await mountTree()
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Zed')
act(() => { zedRow!.click() })
act(() => { submenuParent().click() })
const zedRow = submenuRows().find(item => item.textContent?.trim() === 'Zed')!
act(() => { zedRow.click() })
expect(harness.onOpenWith).toHaveBeenCalledWith('zed', '/tmp/a.ts')
// Selecting closes the row menu entirely.
expect(document.querySelector('[role="menu"] [role="menu"]')).toBeNull()
expect(document.querySelector('[role="menuitem"]')).toBeNull()
expect(menuItems()).toHaveLength(0)
})
it('appends the SSH hint to VSCode-family labels in remote mode', async () => {
it('appends the SSH hint to VSCode-family labels in remote mode (reveal stays)', async () => {
// The SSH-resolved list keeps `explorer` (see resolveOpenWithTargets):
// reveal runs on the machine that owns the workspace, and it is the only
// reveal left when the DSH host's open-in-app capability is unavailable.
// Dropping it here would test a list the caller never produces.
harness = await mountTree({
openWithSsh: true,
openWithTargets: targets.filter(target => !target.localOnly),
openWithTargets: targets.filter(target => !target.localOnly || target.kind === 'reveal'),
openWithPinned: [],
})
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
expect(children.map(item => item.textContent?.trim())).toEqual([
'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
act(() => { submenuParent().click() })
expect(submenuRows().map(item => item.textContent?.trim())).toEqual([
'File Manager', 'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
])
})
it('hides the whole section when the caller wires no targets', async () => {
it('hides the submenu when the caller wires no targets (and no host handle)', async () => {
harness = await mountTree({ openWithTargets: undefined })
openMenu(harness.container)
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(items.some(item => item.textContent?.includes('Open with'))).toBe(false)
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
expect(menuItems().some(item => item.textContent?.includes('Open with'))).toBe(false)
expect(menuItems().some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
})
})
describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
describe('FileTree plugin open-with menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
let harness: Harness
@@ -221,13 +254,10 @@ describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
// room in jsdom's default viewport.
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Zed')
act(() => { zedRow!.click() })
expect(document.querySelector('[role="menuitem"]')).toBeNull()
act(() => { submenuParent().click() })
const zedRow = submenuRows().find(item => item.textContent?.trim() === 'Zed')!
act(() => { zedRow.click() })
expect(menuItems()).toHaveLength(0)
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
+6 -5
View File
@@ -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,14 +29,17 @@ 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,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about rename/delete).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
@@ -60,7 +62,6 @@ async function mountTree(): Promise<Harness> {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
+6 -5
View File
@@ -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,12 +27,15 @@ 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).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
}))
@@ -56,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: () => {},
+130
View File
@@ -0,0 +1,130 @@
/**
* FileTree row memoization (the tree's performance contract): rows are
* `React.memo` components fed stable callbacks and per-row booleans, so an
* interaction that touches ONE row must re-render that row only — not the
* whole tree. The evidence is the icon resolver: it runs inside the row
* component, so counting its calls counts the rows that re-rendered.
*/
// @vitest-environment jsdom
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 { FileTree } from '../src/client/FileTree.tsx'
import type { BetterSidebarService } from '../src/client/service.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so any copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
vi.mock('../src/client/api.ts', () => ({
api: {
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 })),
}),
// 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).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
/** The icon resolutions per render: one per row that actually re-rendered. */
const fileIcons: string[] = []
const folderIcons: string[] = []
/** A minimal registry service: the counters ARE the re-render probe. */
const service = {
subscribe: () => () => {},
fileIcon: (path: string) => { fileIcons.push(path); return null },
folderIcon: (path: string) => { folderIcons.push(path); return null },
} as unknown as BetterSidebarService
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 'memo-spec',
cwd: '/tmp',
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
service,
}))
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name. */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function fireDragOver(el: Element): void {
const event = new Event('dragover', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], dropEffect: '' } })
act(() => { el.dispatchEvent(event) })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fileIcons.length = 0
folderIcons.length = 0
})
describe('FileTree row memoization', () => {
it('re-renders only the toggled row on a Ctrl+click selection change', async () => {
harness = await mountTree()
fileIcons.length = 0
act(() => {
rowByName(harness.container, 'a.ts')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }))
})
// The other three file rows kept their last render (memo bailed out).
expect(fileIcons).toEqual(['/tmp/a.ts'])
})
it('re-renders only the hovered directory row while a drag retargets', async () => {
harness = await mountTree()
folderIcons.length = 0
fileIcons.length = 0
fireDragOver(rowByName(harness.container, 'sub'))
// Every parent render resolves the ROOT row's icon; the memoized dir row
// resolves its own exactly once (its unhovered render was cached), and
// the file rows (whose drop target is the untouched /tmp) never re-render.
expect(folderIcons.filter(path => path === '/tmp/sub')).toEqual(['/tmp/sub'])
expect(fileIcons).toEqual([])
})
})
+186
View File
@@ -0,0 +1,186 @@
/**
* The files panel's search surface and level-cache life-cycle:
*
* - Typing a query parks the tree (`hidden`) instead of unmounting it, so
* clearing the box restores the SAME level cache — `fs.tree` is not called
* again (the old conditional render dropped the tree and refetched the
* whole visible set on every query change).
* - The refresh affordance wipes the cache and reloads concurrently, and a
* response from the previous generation is DROPPED even when it lands last.
* - A level the host truncated says so instead of silently showing a partial
* directory.
*/
// @vitest-environment jsdom
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 { TreePanel } from '../src/client/TreePanel.tsx'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so the panel's copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
interface Listing {
path: string
entries: { name: string; path: string; isDir: boolean }[]
truncated: boolean
}
/** The default host answer: one file at /tmp. */
function defaultListing(path: string): Listing {
return { path, entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], truncated: false }
}
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: {
fsTrees,
fsSearch,
// 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,
}))
interface Harness {
container: HTMLDivElement
unmount: () => void
}
function mountPanel(): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TreePanel, {
sessionId: 's1',
cwd: '/tmp',
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
}))
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/**
* The tree surface: the body that carries row-name spans (the search results
* panel is a body too, but its rows are plain buttons with no name span).
* Finding it while parked is itself the "still mounted" probe.
*/
function treeBody(container: HTMLElement): HTMLElement {
const body = [...container.querySelectorAll<HTMLElement>('div[class*="explorerBody"]')]
.find(el => el.querySelector('[class*="explorerName"]') !== null)
if (body === undefined) throw new Error('tree body not mounted')
return body
}
function resultsBody(container: HTMLElement): HTMLElement {
const tree = treeBody(container)
const body = [...container.querySelectorAll<HTMLElement>('div[class*="explorerBody"]')]
.find(el => el !== tree)
if (body === undefined) throw new Error('results body not mounted')
return body
}
/** Type a query the React way and let the 300ms debounce elapse. */
async function search(container: HTMLElement, query: string): Promise<void> {
const input = container.querySelector<HTMLInputElement>('input[class*="editorSearchInput"]')!
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
setter.call(input, query)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => { await new Promise<void>(resolve => { window.setTimeout(resolve, 350) }) })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsTrees.mockReset()
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => defaultListing(path)) }))
fsSearch.mockClear()
})
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(fsTrees).toHaveBeenCalledTimes(1)
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false)
expect(harness.container.textContent).toContain('a.ts')
await search(harness.container, 'a')
expect(fsSearch).toHaveBeenCalledTimes(1)
// The tree is still in the document — just parked.
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(true)
expect(resultsBody(harness.container).hasAttribute('hidden')).toBe(false)
expect(resultsBody(harness.container).textContent).toContain('a.ts')
await search(harness.container, '')
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(fsTrees).toHaveBeenCalledTimes(1)
})
})
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: (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).
expect(harness.container.textContent).not.toContain('a.ts')
// Manual refresh: cache wiped, new generation, concurrent re-list.
const refresh = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Refresh"]')!
await act(async () => {
refresh.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
await Promise.resolve()
})
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({ levels: [{ path: '/tmp', entries: [{ name: 'stale.ts', path: '/tmp/stale.ts', isDir: false }], truncated: false }] })
await Promise.resolve()
await Promise.resolve()
})
expect(harness.container.textContent).not.toContain('stale.ts')
expect(harness.container.textContent).toContain('a.ts')
})
it('surfaces a truncated level instead of hiding it', async () => {
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 () => {})
const notice = [...harness.container.querySelectorAll<HTMLElement>('[data-kind="hint"]')]
.find(el => el.textContent?.includes('too many entries'))
expect(notice).toBeDefined()
})
})
+88
View File
@@ -0,0 +1,88 @@
/**
* The host's "new folder" mutation (`fs.mkdir` route → mkdirWorkspaceEntry):
* 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, 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
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-mkdir-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
/** The wire code of a rejected call (the route maps it to an HTTP status). */
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run()
} catch (error) {
return (error as { code?: string }).code ?? 'no-code'
}
return 'resolved'
}
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' })
// 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(root, 'top'))
expect(await readdir(root)).toEqual(['top'])
})
it('refuses a name that is not a single path segment', async () => {
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'a/b' }))).toBe('bad-request')
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: '..' }))).toBe('bad-request')
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: '' }))).toBe('bad-request')
})
it('refuses an existing destination with the same conflict code as rename', async () => {
await mkdir(join(root, 'taken'))
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'taken' }))).toBe('fs-error')
})
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')
// ⚠️ 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('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'))
// 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 })
}
})
})
+33 -10
View File
@@ -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 })
+104 -3
View File
@@ -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' })
})
})
+23
View File
@@ -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> | void): Promise<void>
const settle = (ms: number): Promise<void> => 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[] = []
+529 -22
View File
@@ -3,6 +3,11 @@
* and history must always come from the same checkout. A stale history row is
* especially dangerous because its revert/cherry-pick action targets the
* currently selected checkout.
*
* The status itself now comes from the shared git-status store (one snapshot
* per session+cwd+worktree, one poller), so these tests observe the selection
* through the rows it renders and through the scope each git call receives —
* including the `repoRoot` a container's worktree listing must carry.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -12,12 +17,17 @@ import { act } from 'react-dom/test-utils'
import { GitLens } from '../src/client/changes/GitLens.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
import type { BetterSidebarService } from '../src/client/service.ts'
import { t } from '../src/client/locales.ts'
import type { Context } from '../src/context-types.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const MAIN = 'C:/repo/main'
const AGENT = 'C:/repo/agent'
/** A second linked checkout some tests introduce on a later inventory pass. */
const AGENT_2 = 'C:/repo/agent-2'
const inventories: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
@@ -25,6 +35,7 @@ const inventories: GitWorktree[] = [
]
function statusFor(target?: string): GitStatusResult {
if (target === AGENT_2) return { isRepo: true, branch: 'agent-2', entries: [{ path: 'agent-2-change.ts', xy: ' M' }] }
return target === AGENT
? { isRepo: true, branch: 'agent', entries: [{ path: 'agent-change.ts', xy: ' M' }] }
: { isRepo: true, branch: 'main', entries: [{ path: 'main-change.ts', xy: ' M' }] }
@@ -51,8 +62,35 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
}
async function flushEffects(): Promise<void> {
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })
// Each round drains one promise hop: the refresh chain (listing → branch/log
// → state) is several hops deep, and a shallow flush would assert on rows
// that are still in flight.
for (let round = 0; round < 5; round += 1) await act(async () => { await Promise.resolve() })
}
/** Mount one GitLens. Visible by default: a hidden tab owns no live git data
* — the shared status store only polls/loads while a consumer is on screen. */
function mountGit(
root: Root,
options: {
scope?: { sessionId: string; cwd?: string }
refreshTick?: number
visible?: boolean
ctx?: Context
} = {},
): void {
act(() => {
root.render(createElement(GitLens, {
scope: options.scope ?? { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: options.visible ?? true,
refreshTick: options.refreshTick ?? 0,
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
}))
})
}
afterEach(() => { vi.restoreAllMocks() })
@@ -71,16 +109,7 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
document.body.append(container)
const root: Root = createRoot(container)
try {
await act(async () => {
root.render(createElement(GitLens, {
scope: { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: false,
}))
})
mountGit(root)
await flushEffects()
const selects = container.querySelectorAll<HTMLSelectElement>('select')
@@ -130,16 +159,7 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
document.body.append(container)
const root: Root = createRoot(container)
try {
await act(async () => {
root.render(createElement(GitLens, {
scope: { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: false,
}))
})
mountGit(root)
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
@@ -164,4 +184,491 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
container.remove()
}
})
it('lists a selected child repository with its repoRoot, running one full refresh', async () => {
const WS = 'C:/ws'
const REPO_A = 'C:/ws/a'
const REPO_B = 'C:/ws/b'
const repoBWorktrees: GitWorktree[] = [
{ path: REPO_B, branch: 'b-main', current: true, changes: 0 },
{ path: 'C:/ws/b-agent', branch: 'b-agent', current: false, changes: 2 },
]
const worktrees = vi.spyOn(api, 'gitWorktrees').mockImplementation(async (scope) => (
scope.repoRoot === REPO_B ? repoBWorktrees : []
))
const status = vi.spyOn(api, 'gitStatus').mockImplementation(async (scope) => (
scope.cwd === REPO_B
? { isRepo: true, branch: 'b-main', entries: [], root: REPO_B, repositories: [REPO_B] }
: { isRepo: true, branch: 'a-main', entries: [], root: REPO_A, repositories: [REPO_A, REPO_B] }
))
vi.spyOn(api, 'gitBranch').mockImplementation(async (scope) => (
scope.repoRoot === REPO_B ? { current: 'b-main', names: ['b-main'] } : { current: 'a-main', names: ['a-main'] }
))
vi.spyOn(api, 'gitLog').mockResolvedValue([])
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root, { scope: { sessionId: 'session', cwd: WS } })
await flushEffects()
// The workspace container's two child repositories are the repo choices.
const repoSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
expect([...repoSelect.options].map(option => option.textContent)).toEqual(['a', 'b'])
const listingsBefore = worktrees.mock.calls.length
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(repoSelect, REPO_B)
repoSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
// The listing carries the selected repoRoot — a container's own listing
// is empty, so the child checkout's linked worktrees only appear when the
// selection rides along.
expect(worktrees.mock.calls.at(-1)![0]).toMatchObject({ sessionId: 'session', cwd: WS, repoRoot: REPO_B })
// Exactly ONE full refresh for the switch (not a burst).
expect(worktrees.mock.calls.length).toBe(listingsBefore + 1)
// The shared status follows the same selection.
expect(status.mock.calls.some(([scope]) => scope.cwd === REPO_B)).toBe(true)
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[1]!
expect(worktreeSelect.value).toBe(REPO_B)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('never yanks the view off the auto-selected checkout on a later re-list', async () => {
const LATER: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
{ path: AGENT, branch: 'agent', current: false, changes: 0 },
{ path: AGENT_2, branch: 'agent-2', current: false, changes: 5 },
]
vi.spyOn(api, 'gitWorktrees')
.mockResolvedValueOnce(inventories)
.mockResolvedValue(LATER)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
expect(worktreeSelect.value).toBe(AGENT)
// A later inventory pass prefers the brand-new dirty checkout; the user
// never chose a checkout, but the ONE automatic pass already ran.
mountGit(root, { refreshTick: 1 })
await flushEffects()
expect(worktreeSelect.value).toBe(AGENT)
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).not.toContain('agent-2-change.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('queues a manual refresh that lands while a refresh is in flight', async () => {
const held = deferred<GitWorktree[]>()
const worktrees = vi.spyOn(api, 'gitWorktrees')
.mockReturnValueOnce(held.promise)
.mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
// The header's refresh action lands while the mount pass is in flight.
mountGit(root, { refreshTick: 1 })
await flushEffects()
// Queued, not dropped and not started in parallel.
expect(worktrees).toHaveBeenCalledTimes(1)
held.resolve([])
await flushEffects()
// The queued pass ran and its listing landed: the dirty linked checkout
// is selected and its rows are the ones on screen.
expect(worktrees).toHaveBeenCalledTimes(2)
expect(container.textContent).toContain('agent-change.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('stays quiet while hidden and catches up when the tab becomes visible', async () => {
const worktrees = vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
const status = vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
// Mounted in a background pane: no fetch at all (no git processes for a
// tab nobody is looking at).
mountGit(root, { visible: false })
await flushEffects()
expect(worktrees).not.toHaveBeenCalled()
expect(status).not.toHaveBeenCalled()
// Bringing the tab on screen loads the lens through one pass.
mountGit(root, { visible: true })
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
expect(status).toHaveBeenCalled()
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).toContain('Agent checkout commit')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('hands the inventory poll its AbortSignal and stops it when hidden', async () => {
vi.useFakeTimers()
const worktrees = vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
// The 30s inventory cadence fires and carries the poller's signal.
await act(async () => { await vi.advanceTimersByTimeAsync(30_000) })
await flushEffects()
expect(worktrees.mock.calls.length).toBe(2)
const signal = worktrees.mock.calls[1]![1]
expect(signal).toBeInstanceOf(AbortSignal)
// Hiding the tab tears the poll down and aborts its signal: a slow host
// answer can no longer publish state.
mountGit(root, { visible: false })
await flushEffects()
expect((signal as AbortSignal).aborted).toBe(true)
const callsWhileHidden = worktrees.mock.calls.length
await act(async () => { await vi.advanceTimersByTimeAsync(120_000) })
expect(worktrees.mock.calls.length).toBe(callsWhileHidden)
} finally {
act(() => { root.unmount() })
container.remove()
vi.useRealTimers()
}
})
it('keeps branch switch and history failures out of the commit bar', async () => {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([
{ path: MAIN, branch: 'main', current: true, changes: 1 },
])
vi.spyOn(api, 'gitStatus').mockResolvedValue({
isRepo: true, branch: 'main', entries: [{ path: 'src/a.ts', xy: ' M' }],
})
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main', 'feature'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
vi.spyOn(api, 'gitCheckout').mockRejectedValue(new Error('dirty worktree'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const branchSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(branchSelect, 'feature')
branchSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
const banner = [...container.querySelectorAll<HTMLElement>('[role="alert"]')]
.find(node => (node.textContent ?? '').includes(`${t('checkoutError')}: dirty worktree`))
expect(banner).toBeDefined()
const input = container.querySelector(`input[placeholder="${t('commitPlaceholder')}"]`)
expect(input).not.toBeNull()
// The commit bar owns only its own status line: the branch failure is
// rendered ABOVE it, never under the commit input.
expect(banner!.compareDocumentPosition(input!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
describe('GitLens (changes tab, git lens) change tree', () => {
/** Mount with one changed-file inventory; every test picks its own entries. */
async function mountTree(
container: HTMLElement,
root: Root,
entries: Array<{ path: string; xy: string }>,
): Promise<void> {
const changes = entries.filter(row => row.xy !== ' ').length
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([{ path: MAIN, branch: 'main', current: true, changes }])
vi.spyOn(api, 'gitStatus').mockResolvedValue({ isRepo: true, branch: 'main', entries })
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
mountGit(root)
await flushEffects()
}
function makeRoot(): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.append(container)
return { container, root: createRoot(container) }
}
it('folds a directory row per group, independently of the same path on the other side', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/a.ts', xy: ' M' }, // unstaged side
{ path: 'src/b.ts', xy: 'M ' }, // staged side
])
const dirs = () => [...container.querySelectorAll<HTMLButtonElement>('button[data-dir="src"]')]
// One 'src' row per group: a path with changes on both sides appears in
// both trees.
expect(dirs()).toHaveLength(2)
expect(dirs()[0]!.getAttribute('aria-expanded')).toBe('true')
expect(dirs()[0]!.querySelector('svg')).not.toBeNull()
expect(container.querySelector('button[data-path="src/a.ts"]')).not.toBeNull()
expect(container.querySelector('button[data-path="src/a.ts"]')!.querySelector('svg')).not.toBeNull()
await act(async () => { dirs()[0]!.click() })
// Folded: the unstaged subtree is gone...
expect(dirs()[0]!.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('button[data-path="src/a.ts"]')).toBeNull()
// ...while the staged group's own 'src' row stays open and keeps its file.
expect(dirs()[1]!.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('button[data-path="src/b.ts"]')).not.toBeNull()
await act(async () => { dirs()[0]!.click() })
expect(container.querySelector('button[data-path="src/a.ts"]')).not.toBeNull()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('compresses a single-child directory chain into one labelled row', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/client/changes/a.ts', xy: ' M' },
{ path: 'src/client/changes/b.ts', xy: ' M' },
])
// Three levels, one child each: one row, one label, two changes.
const row = container.querySelector<HTMLButtonElement>('button[data-dir="src/client/changes"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('src/client/changes')
expect(container.querySelector('[data-group="unstaged"] [data-count]')?.textContent).toBe('2')
// Both files hang under that one row.
expect(container.querySelector('button[data-path="src/client/changes/a.ts"]')).not.toBeNull()
expect(container.querySelector('button[data-path="src/client/changes/b.ts"]')).not.toBeNull()
// Nothing on the staged side: only the empty band, no tree rows.
expect(container.querySelectorAll('[data-group="staged"] [data-path]')).toHaveLength(0)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('stages a file from a nested tree row and re-reads the shared status', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [{ path: 'src/deep/a.ts', xy: ' M' }])
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
const statusSpy = vi.mocked(api.gitStatus)
const statusCallsBefore = statusSpy.mock.calls.length
const action = container.querySelector<HTMLButtonElement>(`button[aria-label="${t('stage')}"]`)
expect(action).not.toBeNull()
await act(async () => { action!.click() })
await flushEffects()
// The auto-selected primary checkout rides along as the worktree target.
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), 'src/deep/a.ts', MAIN)
expect(statusSpy.mock.calls.length).toBeGreaterThan(statusCallsBefore)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('bands each group with its count and a labelled stage-all action', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/a.ts', xy: ' M' },
{ path: 'docs/b.md', xy: ' M' },
{ path: 'src/c.ts', xy: 'M ' },
])
const unstaged = container.querySelector<HTMLElement>('[data-group="unstaged"]')!
const staged = container.querySelector<HTMLElement>('[data-group="staged"]')!
expect(unstaged.textContent).toContain(t('unstaged'))
expect(unstaged.querySelector('[data-count]')?.textContent).toBe('2')
expect(staged.querySelector('[data-count]')?.textContent).toBe('1')
// The band's own action is the one that is NOT inside a row (directory
// rows carry the same "Stage all" label for their own subtree).
const stageAll = [...unstaged.querySelectorAll<HTMLButtonElement>(`button[aria-label="${t('stageAll')}"]`)]
.find(button => button.closest('[data-row]') === null)
const unstageAll = [...staged.querySelectorAll<HTMLButtonElement>(`button[aria-label="${t('unstageAll')}"]`)]
.find(button => button.closest('[data-row]') === null)
expect(stageAll).not.toBeUndefined()
expect(unstageAll).not.toBeUndefined()
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
await act(async () => { stageAll!.click() })
await flushEffects()
// "Stage all" stages the whole worktree: no path argument.
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), undefined, MAIN)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('stages and unstages a whole directory from its row action', async () => {
const { container, root } = makeRoot()
try {
await mountTree(container, root, [
{ path: 'src/deep/a.ts', xy: ' M' }, // unstaged → the 'src/deep' row
{ path: 'docs/b.md', xy: 'M ' }, // staged → the 'docs' row
])
const stage = vi.spyOn(api, 'gitStage').mockResolvedValue({ ok: true })
const unstage = vi.spyOn(api, 'gitUnstage').mockResolvedValue({ ok: true })
const dirAction = (path: string, label: string): HTMLButtonElement => {
const row = container.querySelector<HTMLElement>(`[data-row="${path}"]`)
expect(row).not.toBeNull()
const button = row!.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
expect(button).not.toBeNull()
return button!
}
const unstagedDir = dirAction('src/deep', t('stageAll'))
await act(async () => { unstagedDir.click() })
await flushEffects()
// The directory path stages the whole subtree (`git add -A -- <dir>`).
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), 'src/deep', MAIN)
const stagedDir = dirAction('docs', t('unstageAll'))
await act(async () => { stagedDir.click() })
await flushEffects()
expect(unstage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session' }), 'docs', MAIN)
// Neither click folded its row: the action is a sibling of the
// disclosure button (and stops propagation anyway).
expect(container.querySelector('button[data-dir="src/deep"]')!.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('button[data-path="src/deep/a.ts"]')).not.toBeNull()
expect(container.querySelector('button[data-dir="docs"]')!.getAttribute('aria-expanded')).toBe('true')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('resolves no icon for a status poll whose change list did not change', async () => {
vi.useFakeTimers()
const fileIcons: string[] = []
const folderIcons: string[] = []
/** The icon resolvers are the re-render probe: they run inside the rows,
* so one entry per call means one row actually re-rendered. */
const service = {
subscribe: () => () => {},
fileIcon: (path: string) => { fileIcons.push(path); return null },
folderIcon: (path: string, open: boolean) => { folderIcons.push(`${path}:${String(open)}`); return null },
} as unknown as BetterSidebarService
const ctx = { get: (name: string) => (name === 'betterSidebar' ? service : undefined) } as unknown as Context
const { container, root } = makeRoot()
try {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([{ path: MAIN, branch: 'main', current: true, changes: 2 }])
// Every poll answers with a fresh object and a fresh entries array (the
// store's real shape). The FIRST answer carries no repository list and
// the later ones do — a field OUTSIDE the change list moves, so the store
// cannot preserve the snapshot identity and hands the lens a new array
// whose content is equal. Only the CONTENT key plus the memoized rows
// hold there; identity alone would rebuild the tree and re-render rows.
let poll = 0
vi.spyOn(api, 'gitStatus').mockImplementation(async () => {
poll += 1
return {
isRepo: true,
branch: 'main',
...(poll === 1 ? {} : { repositories: [MAIN] }),
// One compressed directory row (folder icon) + its two file rows.
entries: [{ path: 'src/deep/a.ts', xy: ' M' }, { path: 'src/deep/b.ts', xy: ' M' }],
}
})
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
mountGit(root, { ctx })
await flushEffects()
expect(fileIcons).toEqual(['src/deep/a.ts', 'src/deep/b.ts'])
expect(folderIcons).toEqual(['src/deep:true'])
fileIcons.length = 0
folderIcons.length = 0
// Tick 1: a NEW snapshot object with an equal-content change list.
await act(async () => { await vi.advanceTimersByTimeAsync(2_600) })
await flushEffects()
expect(fileIcons).toEqual([])
expect(folderIcons).toEqual([])
fileIcons.length = 0
folderIcons.length = 0
// Tick 2: a byte-identical answer (the store reuses the snapshot object).
await act(async () => { await vi.advanceTimersByTimeAsync(2_600) })
await flushEffects()
expect(fileIcons).toEqual([])
expect(folderIcons).toEqual([])
// The probe measured stability, not loss: every row is still there.
expect(container.querySelectorAll('[data-path]')).toHaveLength(2)
expect(container.querySelectorAll('button[data-dir="src/deep"]')).toHaveLength(1)
} finally {
act(() => { root.unmount() })
container.remove()
vi.useRealTimers()
}
})
})
+12 -2
View File
@@ -123,15 +123,25 @@ describe('DSH community-market manifest compatibility', () => {
// and DISABLES the whole profile row when it fails — one stderr line in a
// log nobody reads, no dialog, and `peerDependenciesMeta.optional` grants
// no exemption. An exact pin (the style devDependencies use) or a
// comparator carrying no prerelease (`^0.1.7`) would therefore make the
// comparator carrying no prerelease (`^0.2.0`) would therefore make the
// plugin silently stop loading, so the published ranges must stay carets
// on the baseline tuple. `pnpm peers check` cannot see this: it validates
// the resolved dev tree, not the range shape a host will read.
//
// A caret NEVER spans a minor bump, even with `includePrerelease` on:
// `^0.1.7-rc.1` expands to `>=0.1.7-rc.1 <0.2.0-0`, so it rejects
// `0.2.0-rc.1` (measured: false) and a 0.2.0 host would disable the row
// instead of loading the plugin. The support line therefore moves WITH the
// baseline and is never widened to "cover both lines". (An explicit
// comparator behaves differently — `>=0.1.1-rc.2 <0.2.0` does admit
// `0.2.0-rc.1`, because only carets get the `-0` upper bound — which is why
// the assertion below pins the baseline tuple instead of accepting any
// caret that looks plausible.)
const peers = pkg.peerDependencies ?? {}
const dshPeers = Object.keys(peers).filter(name => name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-'))
expect(dshPeers.length).toBeGreaterThan(10)
for (const name of dshPeers) {
expect(peers[name], name).toMatch(/^\^0\.1\.7-[a-z0-9.]+$/)
expect(peers[name], name).toMatch(/^\^0\.2\.0-[a-z0-9.]+$/)
}
})
})
+141 -2
View File
@@ -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 })
@@ -327,6 +399,73 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
dispose()
})
it('re-registers NOTHING on a store change (a folder toggle must not replace the explorer body)', () => {
// Regression (reported as "expanding a folder makes the whole tree
// refresh"): the surface kept its registrations in step by diffing them on
// every service AND store notification, and the built-in `files` takeover
// is keyed by its KIND — never by a descriptor id — so the "drop what is
// no longer wanted" loop read it as a stray registration and disposed it on
// EVERY state write. Disposing and re-creating a slot registration replaces
// the host's slot entry, which unmounts the tab body it draws: toggling a
// folder tore down the explorer (level cache, scroll position and the
// directory watcher all live in that component) and remounted it, so the
// tree blanked, re-requested [root, ...expanded] and rebuilt every row.
// The store notifies on each toggle, so this churned on every click.
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: () => 'Files', component: () => null })
service.registerTab({ id: 'git', title: () => 'Changes', component: () => null })
const records = createNativeTabRecords()
/** Every slot registration, in order (the unregister keys land in `disposed`). */
const registered: string[] = []
const disposed: string[] = []
const registry = {
register: (definition: { id: string; kind: string; title: (address: string) => string }) =>
() => { disposed.push(definition.id) },
}
const ctx = {
inject: (_deps: readonly string[], callback: (injected: { get: (name: string) => unknown }) => void) => {
callback({ get: () => registry })
return { dispose: () => { /* the surface owns the rest */ } }
},
get: () => registry,
slots: {
inject: (_key: string, callback: () => () => void) => callback(),
register: (options: { name: string; key?: string }) => {
const key = options.key ?? options.name
registered.push(key)
return () => { disposed.push(key) }
},
},
}
const dispose = registerNativeSurface({ ctx: ctx as never, store, service, records })
const mounted = [...registered]
/** The takeover's slot registrations: its body + its chip title, one pair. */
const filesSlots = (): number => registered.filter(key => key === 'dsh-better-sidebar:files').length
expect(filesSlots(), 'the takeover registers its body + chip slots').toBe(2)
expect(disposed).toEqual([])
// A folder toggle: only state, no registry and no settings change. The
// native surface must not touch a single registration.
store.reduce(state => toggleExpanded(state, '/work/big'))
store.reduce(state => toggleExpanded(state, '/work/big/child'))
// Any other state write behaves the same (the workbench height, a tab
// switch, the selection — all of them notify the same subscribers).
store.update(state => { state.bottomOpen = !state.bottomOpen })
expect({ registered, disposed }).toEqual({ registered: mounted, disposed: [] })
// The takeover still follows the editor type's own switch: disabling the
// editor releases it, re-enabling brings it back exactly once.
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
expect(disposed).toContain('dsh-better-sidebar:files')
expect(registered).toEqual(mounted)
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: true } })
expect(filesSlots(), 'the re-enabled takeover registers again — exactly one pair').toBe(4)
dispose()
})
it('claims exactly the extensions DSH has no preview for (the nine restored formats included)', () => {
// DSH 0.1.7 handed every read-only preview to `ui-sidebar-documentpreview`,
// and `canOpen` returning false is what gives the address away. The host's
+171
View File
@@ -0,0 +1,171 @@
/**
* The client adapter over DSH's open-in-app capability: which host facility
* each gesture uses, the availability gate, and the degradation path when the
* deployment has no desktop or no Remote namespace.
*
* The host's own behaviour is NOT re-tested here — only the adapter's
* routing (Remote vs catalog route), its caching, and its fail-closed
* answers.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOpenInApp, type OpenInAppEntry } from '../src/client/open-in-app.ts'
interface RemoteCalls {
canOpen: number
applications: { path: string }[]
open: { path: string; action?: 'reveal'; application?: string }[]
}
/** A fake `ctx.remote.session` recording every call. */
function fakeCtx(options: {
canOpen?: boolean
apps?: readonly { id: string; name: string; default: boolean; icon: string | null }[]
openResult?: boolean
revealResult?: boolean
noRemote?: boolean
} = {}): { ctx: { get(name: string): unknown }; calls: RemoteCalls } {
const calls: RemoteCalls = { canOpen: 0, applications: [], open: [] }
const session = {
canOpenWorkspacePath: async () => {
calls.canOpen += 1
return { ok: true, value: options.canOpen ?? true }
},
workspacePathApplications: async (request: { path: string }) => {
calls.applications.push(request)
if (options.apps === undefined) return { ok: false }
return { ok: true, value: options.apps }
},
openWorkspacePath: async (request: { path: string; action?: 'reveal'; application?: string }) => {
calls.open.push(request)
if (request.action === 'reveal') return { ok: options.revealResult ?? true }
return { ok: options.openResult ?? true }
},
}
return { ctx: { get: (name) => (name === 'remote' ? { session } : undefined) }, calls }
}
/** Install a fetch stub answering the host's catalog/launch routes. */
function stubFetch(options: { apps?: readonly string[]; appsStatus?: number; openStatus?: number } = {}) {
const calls: { url: string; body?: unknown }[] = []
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), body: init?.body === undefined ? undefined : JSON.parse(String(init.body)) })
if (String(url).includes('/open-in-app/apps')) {
const status = options.appsStatus ?? 200
return new Response(JSON.stringify({ apps: options.apps ?? [] }), { status })
}
return new Response('{}', { status: options.openStatus ?? 200 })
})
return calls
}
afterEach(() => {
vi.unstubAllGlobals()
})
/** The adapter built over a ctx that cannot reach the Remote at all. */
function withoutRemote() {
return createOpenInApp({ get: () => undefined })
}
describe('createOpenInApp', () => {
it('reports unavailable and degrades every gesture when the Remote is absent', async () => {
const adapter = withoutRemote()
expect(adapter.available()).toBeNull()
expect(await adapter.probe()).toBe(false)
expect(adapter.available()).toBe(false)
expect(await adapter.fileApps('/tmp/a.ts')).toBeNull()
expect(await adapter.open('/tmp/a.ts')).toBe(false)
expect(await adapter.reveal('/tmp/a.ts')).toBe(false)
})
it('probes availability once and shares the answer', async () => {
const { ctx, calls } = fakeCtx({ canOpen: true })
const adapter = createOpenInApp(ctx)
expect(await adapter.probe()).toBe(true)
expect(await adapter.probe()).toBe(true)
expect(adapter.available()).toBe(true)
expect(calls.canOpen).toBe(1)
})
it('maps the OS handlers of one file, default included', async () => {
const { ctx, calls } = fakeCtx({
apps: [{ id: '/Applications/VSCode.app', name: 'Visual Studio Code', default: true, icon: 'data:image/png;base64,AA' }],
})
const adapter = createOpenInApp(ctx)
const apps = await adapter.fileApps('/tmp/a.ts')
expect(apps).toEqual([{ id: '/Applications/VSCode.app', name: 'Visual Studio Code', icon: 'data:image/png;base64,AA', isDefault: true }])
expect(calls.applications).toEqual([{ path: '/tmp/a.ts' }])
})
it('answers null for a failed handler query, never an empty list', async () => {
const { ctx } = fakeCtx({ apps: undefined })
expect(await createOpenInApp(ctx).fileApps('/tmp/a.ts')).toBeNull()
})
it('opens a path through the Remote, default or explicit application', async () => {
const { ctx, calls } = fakeCtx()
const adapter = createOpenInApp(ctx)
expect(await adapter.open('/tmp/a.ts')).toBe(true)
expect(await adapter.open('/tmp/a.ts', '/Applications/VSCode.app')).toBe(true)
expect(calls.open).toEqual([
{ path: '/tmp/a.ts' },
{ path: '/tmp/a.ts', application: '/Applications/VSCode.app' },
])
})
it('falls back to the host catalog route when a catalog id is not a file handler', async () => {
const fetchCalls = stubFetch()
// The Remote refuses the catalog id (it is not registered for this path).
const { ctx, calls } = fakeCtx({ openResult: false })
const adapter = createOpenInApp(ctx)
expect(await adapter.open('/tmp/ws', 'vscode')).toBe(true)
expect(calls.open).toEqual([{ path: '/tmp/ws', application: 'vscode' }])
expect(fetchCalls).toEqual([{ url: '/open-in-app/open', body: { app: 'vscode', path: '/tmp/ws' } }])
})
it('reports a launch failure instead of pretending success', async () => {
stubFetch({ openStatus: 400 })
const { ctx } = fakeCtx({ openResult: false })
expect(await createOpenInApp(ctx).open('/tmp/ws', 'vscode')).toBe(false)
})
it('reveals through the Remote action', async () => {
const { ctx, calls } = fakeCtx()
expect(await createOpenInApp(ctx).reveal('/tmp/a.ts')).toBe(true)
expect(calls.open).toEqual([{ path: '/tmp/a.ts', action: 'reveal' }])
})
it('lists the directory catalog from the host route, with icon URLs', async () => {
const fetchCalls = stubFetch({ apps: ['vscode', 'finder'] })
const { ctx } = fakeCtx()
const apps: readonly OpenInAppEntry[] = await createOpenInApp(ctx).directoryApps()
expect(apps.map(app => app.id)).toEqual(['vscode', 'finder'])
expect(apps[0]?.icon).toBe('/open-in-app/icon/vscode')
// Without the host locale namespace the label falls back to the id itself.
expect(apps[0]?.name).toBe('Vscode')
expect(fetchCalls[0]?.url).toBe('/open-in-app/apps')
})
it('answers an empty catalog when the host route fails or the desktop is gone', async () => {
stubFetch({ appsStatus: 500 })
const { ctx } = fakeCtx()
expect(await createOpenInApp(ctx).directoryApps()).toEqual([])
const offline = fakeCtx({ canOpen: false })
expect(await createOpenInApp(offline.ctx).directoryApps()).toEqual([])
})
})
describe('createOpenInApp (host inject guard)', () => {
it('treats a ctx that THROWS on service access as unavailable instead of crashing the caller', async () => {
// Cordis raises `cannot get property "remote.session" without inject`
// when the bundle forgets the inject entry; a render path must survive it.
const adapter = createOpenInApp({
get: () => { throw new Error('cannot get property "remote.session" without inject') },
})
expect(await adapter.probe()).toBe(false)
expect(await adapter.fileApps('/tmp/a.ts')).toBeNull()
expect(await adapter.open('/tmp/a.ts')).toBe(false)
expect(await adapter.reveal('/tmp/a.ts')).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More