Merge remote-tracking branch 'origin/main' into feat/changes-md-reading

# Conflicts:
#	src/client/MarkdownHtml.tsx
This commit is contained in:
lhh010
2026-09-06 22:59:56 +08:00
52 changed files with 1647 additions and 51 deletions
+1
View File
@@ -39,6 +39,7 @@
8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。
10. **聊天文件打开漏斗是 `remote.session.openWorkspacePath`**(`ctx.workspaces.openPath` 已随 alpha 删除,`IWorkspaces` 无此方法):「聊天区文件在侧边栏打开」拦截在 `ctx.inject(['remote.session'], …)` 内以 **defineProperty 数据属性遮蔽**该命名空间方法(gateway client 的方法是 accessor 属性、异步挂载、contribution 重挂载时服务重建——inject 回调重跑即自愈);实现见 `src/client/openpath-intercept.ts`,设计见 [docs/plans/2026-08-31-openpath-intercept-alpha-design.md](docs/plans/2026-08-31-openpath-intercept-alpha-design.md)。
11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话 `Inbox` 构造器重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + `Inbox`)守护。0.1.2-rc.1 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法。
---
+4 -10
View File
@@ -267,12 +267,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
自 v0.17.1 以来的全部更改(v0.18.1-alpha.0 / v0.19.0-alpha.0 两个中间版本号未发布,内容一并并入本版):
**🔗 宿主线毕业正式版**
- **进入 DSH 0.1.2 线并毕业**:v0.18.0-alpha.0 删除 0.1.1-rc.x 兼容层、修复 alpha.1+ 侧边对话转录空白(#472);基线经 alpha.3(#497)、alpha.5(#516,`Session.events` → `snapshotEvents()`)逐步上行;聊天文件打开漏斗迁移 `remote.session.openWorkspacePath`(#494)、「Show in folder」reveal 滚动限界(#453)。本版钉版基线 **DSH 0.1.2-rc.1**(相对 alpha.5 零源码变化、纯版本升级;`dsh-client-locale` 恢复发版,全线对齐 rc.1;CI 真机挂载冒烟 14/14)
**✨ 新功能**
- 🌿 **「文件变动」统一 tab**(#475):Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读/写/编辑实时追踪)双视角合一;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠)、底部可拖拽预览面板、一键展开独立 diff tab
@@ -298,6 +292,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
- 新收录 10+ 插件:dsh-better-sidebar-icons(#441)、dsh-sidenote(#451,原 dsh-sidechat #470)、dsh-github-workbench(#410)、dsh-bilingual-reader(#379)、dsh-server-deck(#413)、dsh-md-export(#405)、dsh-code-nav(#404)、dsh-suhuang-scroll(#392)、dsh-better-overleaf(#370)等(均含 18+ 语言 i18n 补齐)
<details>
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
### v0.19.0-alpha.0
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.5`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`)。该版本号未单独发布,内容已并入 **v0.18.0** 正式版。
@@ -370,9 +367,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
- 🔧 **git 源安装修复 `unrun` devDependency**([#336](https://github.com/omdsh-dev/DSH-better-sidebar/pull/336)):tsdown 0.22 经 `unrun` 加载配置而 pnpm 11 不自动装 peer——git-hosted 安装的 `prepare` 不再报 `Failed to import module "unrun"`(npm tarball 不受影响)
- 🍃 **`ctx.effect` 严格化顺手修了 4 处**:拦截注册失败时 effect 体返回 `undefined` 改为 no-op disposer(vendored cordis 的 effect 契约要求返回 disposer,返回 `undefined` 属非法形状)
<details>
<summary><b>历史版本(v0.12.0 – v0.15.2)</b></summary>
### v0.15.2
自 v0.15.1 以来的全部更改:
@@ -491,7 +485,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
推荐添加QQ群(577011007)
<div align="center">
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/39caafc7-9629-4b13-bb2b-eac17eab5b6a" />
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/cbf211c6-e5b8-49c3-a412-7210c0b33d73" />
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
</div>
@@ -96,3 +96,5 @@
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
- **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。
- **种子缺 fork 标记 → 子会话继承父会话未领取的 inbox 消息(2026-09-05,用户实测「上下文很长时侧边对话会把之前的 User msg 先发送出去」)**:`sidechat.start` 原只传 `seed` 不传 `meta.isSeeded: true` + `inheritedEventCount`。dsh-session 契约:**只给 seed 不标 isSeeded,种子算重放历史而非继承前缀** → `Session.ownEvents()` 含整个种子 → 子会话 `Inbox` 构造器(重放 `ownEvents()` 中的 `agent/inbox/spliced`)继承父会话切割时刻**任何未领取的 inbox 输入**。两个触发窗口(均以真实 0.1.2-rc.1 包 `Session.create` + `Inbox` 实验证实):① next-turn——父会话排队中的用户消息(长回合运行中补发);② **next-step(用户实测场景:父会话运行中、用户队列已清空)**——长回合里工具结果上下文/steering/运行时快照在步骤边界之间插入 next-step、尚未领取;上下文越长回合越久窗口越宽。后果:首条 `sidechat.prompt` 的 claim 顺序为 `[幽灵消息, boundary, question]`——幽灵消息排在 boundary 之前最先发给模型(工具上下文常为「工具完成,接下来做 X」,模型先继续父会话任务),并在子会话日志落成 `user/message`。修复 = create 补 `meta.isSeeded: true` + `inheritedEventCount: SessionLogOffset(seed.length)`(宿主自己的 `session.fork`——api-session-controller——的规范调用形态;`createAgent` 工厂逐字段透传 `sessions.prepare`)。`threadOwnLogEvents` 按 end-seed 标记切割,标记位置不变,转录行为不变;冷恢复按持久化 header 切割,一致。回归测试进 `tests/sidechat-seed-validation.spec.ts`(真实 Session + Inbox 双向锁定:旧形态继承幽灵 / 新形态两队列皆空)。用户拍板:不迁移既有线程、不加 inbox.clear() 兜底(修复后新线程为无操作;旧「从未发过消息」空线程首次使用可能仍先答一次旧消息,之后自愈)。
- **「更优雅 API」复评(2026-09-05,0.1.2-rc.1 = npm latest)**:无 sidechat/sidechain 宿主 API;`ContinuableStartSpec`(`ctx.subagents.startContinuable`)仍无 seed 字段;`session/create` RPC 只建全新会话。§3 的自建结论继续成立,`AgentRegistry.create` 接缝即宿主 fork 自用的公开接缝——补齐 fork 标记即为规范用法。
@@ -89,6 +89,8 @@ Cursor (SSH)
**实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `<scheme>://vscode-remote/ssh-remote+…` 形态的 URL 在浏览器客户端同步触发 `window.location.assign`(处于用户点击链内,外部协议交给本机编辑器经 Remote-SSH 打开远端文件);reveal 与本地编辑器 URL 仍走本节宿主路由。普通浏览器可处理自定义协议;禁止/未处理 `vscode://` 的 WebView 壳客户端需各自适配(见 #517 补充信息)。
**实施偏差(2026-09,右键菜单减重与子菜单视口钳制)**:本菜单(及 TabBar / GitLens / FreeWindow 右键菜单、「+」菜单)整体切到 primitives `Menu` 的 **`compact` 密度**(行高 26px / 12px 字 / 164px 卡片 / 7px 圆角),菜单图标随槽位 16→14、子菜单图钉 20→16。同时发现宿主子菜单**不受视口钳制**(`bottom:-4px` 从父行向上生长、`left:calc(100%+10px)` 向右展开,主列表的钳制与 `max-height` 收缩均不覆盖它):树顶部右键时本子菜单伸出视口上沿,窄面板(280–400px)时伸出右沿。修复为插件侧方案 `src/client/menu-flip.ts`——菜单打开期间在 `<body>` 发布 `data-dsh-sidebar-submenu="down|left"` 方向 token(`y < vh/2` → down;`x + 400 > vw` → left,400 镜像宿主几何 218 卡片 + 10 间隙 + 165 子菜单 + 12 边距),`layout.css` 据此翻转子菜单生长方向与 `::before` 悬停桥;**属性的存续期即作用域**(仅我们的菜单打开时存在),宿主自有菜单不受影响。**不做** `max-height` + 滚动钳制:overflow 裁剪会切掉子菜单 `::before` 悬停桥(跨 10px 间隙会触发 mouseLeave 关卡)。残留见「已知限制」末两条。
## 已知限制
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
@@ -96,13 +98,18 @@ Cursor (SSH)
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
- 子菜单钳制为方向翻转而非高度裁剪:约 >15 个打开方式目标 + 矮视口 + 不利光标位仍可能溢出(compact 密度已把常规 4–8 目标场景收进安全区)。
- 翻转阈值镜像宿主 `Menu.module.css` 几何常量(218/10/165/12):宿主未来改尺寸时阈值偏保守(多翻一次方向),不会产生新越界;菜单打开期间窗口 resize 不重算 token,该次打开最坏退化为原始行为。
## 测试
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
- `tests/open-external-client.spec.ts`(#522):SSH remote URL 客户端分流(不触 fetch)、本地/reveal/http(s) 留宿主、导航抛错 reject。
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏;翻转向量(down / down left / 卸载清理)。
- `tests/menu-flip.spec.tsx`:方向 token 纯函数(四象限、边界、窄面板)+ hook 属性生命周期 + layout.css 选择器与属性名的跨文件一致性。
- `tests/tab-bar-context-menu.spec.tsx`:追加翻转向量(顶部条 down、右缘 down left、下半空 token、卸载清理)。
- `tests/e2e/mount.e2e.ts`:「+」菜单首行 computed 高度 ≤30px(compact 密度的布局级证据)。
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
@@ -0,0 +1,62 @@
# 文件树重命名 / 删除设计(2026-09-04)
> 状态:已实现。本文档记录功能设计、关键取舍与已知限制。
## 背景
文件树右键菜单此前只有打开/复制/上传下载等只读+导出操作,缺少资源管理器最基本的两个变更操作。本次补全:
- **重命名**:行内编辑(VS Code/Finder 惯例,用户已确认)——菜单点「重命名」后行内名字变输入框(预选中、Enter/失焦提交、Esc 取消)。
- **删除**:弹窗确认(用户指定)——复用 git lens 的 Cancel/Confirm Modal 范式,文件/目录文案区分,确认后永久删除。
同批修复二级菜单的视觉关系(见下节)。
## 服务端
- `src/fs-operations.ts` 新增 `renameWorkspaceEntry` / `removeWorkspaceEntry`(与 `writeWorkspaceUpload` 同层):
- **链路感知(link-aware)**:存在性与围栏校验对**完全解析后的目标**进行(fence 开启时指向工作区外的 symlink 一律拒绝),但操作本身作用于**词法行路径**——重命名/删除一个 symlink 行只改/删**链接本身**,绝不动目标(lstat 判定,link-to-dir 不会递归进目标)。与 VS Code 语义一致:行显示什么就操作什么。
- `renameWorkspaceEntry`:`name` 必须单段(非空、非 `.`/`..`、无分隔符——重命名不是移动);目标已存在直接 409 拒绝(POSIX rename 会静默覆盖);工作区根本身不可重命名;同名 no-op 直接成功(不触发 409)。返回**规范路径**(realpath 解析后的祖先链)供 tab 改址。
- `removeWorkspaceEntry`:目录递归删除、文件/symlink 解链;根拒绝。无回收站(宿主无此 API;等同 git lens discard 的永久语义)。
- `src/index.ts` 路由 `'fs.rename'`(`{path, name}`)/ `'fs.remove'`(`{path}`),走 `cwdOf` + `fenceEnabledOf(getSettings)`,与 `fs.write` 同一围栏开关。
- `src/client/api.ts`:`fsRename` / `fsRemove`。
## 客户端(FileTree)
- 菜单尾部(复制项之后):`separator + 重命名(IconEditOutline16) + 删除(IconTrashOutline16, danger)`;**根行**(`rowMenu.path === cwd`)不渲染这两项(服务端双保险)。
- 行内编辑:命中的行换渲染为编辑行(同缩进/图标/行高),**无 role="button"**(编辑器不是点击目标,也避免 button 嵌套 interactive 的非法结构)。裸 `<input>`(primitives `Input` 不转发 ref,而 focus+select 正是要点);ref 回调 `useCallback([])` 稳定身份——内联箭头每渲染重跑会把正在输入的缓冲区重新全选。Enter/失焦提交、Esc 取消;`isImeComposition` 守卫(IME 组合期的 Enter/Esc 属于输入法,不提交不取消——顺手把该守卫的入参 `isComposing` 放宽为可选,React 合成事件类型未声明该字段)。
- 删除确认:primitives `Modal`(git lens 同款 outline Cancel + primary Confirm)。
- 失败面:树顶一条可点消失的错误条(`role="alert"`),展示服务端原文(与 FenceErrorNotice 同策略);客户端单段校验失败也走这里(`renameInvalid`)。
- **落定收尾 `pruneTree(prefix)`**:清掉旧路径前缀的所有缓存层 → `onToggle` 收起 stale 的 expanded 项 → `retryDir(parent)` 单层重载(无需整树 refresh tick)。重命名已展开目录后新目录折叠展示(expanded 不迁移,VS Code 同样)。
## tab 调解(tree-mutations.ts + EditorHost)
- `retargetPathTabs(ctx, store, old, new)`:`tab.path === old` → `service.updateTab(id, {path: new, title: baseName(new)})`(编辑内容存活,后续保存落到新路径)。
- `closePathTabs(ctx, store, target)`:`tab.path === target || isWithinWorkspace(target, tab.path)` → `service.closeTab(id)`(目录连整个子树的打开文件一起关)。
- tab 枚举覆盖 `state.splits` / `state.bottomSplits` / `state.floats`(浮动窗口的 tab 同样算打开)。走 service 路径(而非直改 state)使注册的生命周期回调照常触发。
- FileTree/TreePanel 透传 `onPathRenamed?` / `onPathDeleted?`(可选,测试可不接线);EditorHost 两处 `<TreePanel>` 接线。
## 二级菜单视觉关系(layout.css,同批)
针对「二级菜单没有视觉关系」的反馈,两条纯 CSS 规则(仍以 `data-dsh-sidebar-submenu` body 属性限定作用域,宿主默认不受影响):
1. **父行持续高亮**:宿主 Menu 在子菜单打开期间给父行按钮标 `aria-expanded="true"`(宿主已渲染、稳定),用同一 hover token 重新着色——指针移入侧卡后父行不再熄灭。
2. **卡片贴近**:子菜单与父卡间距 10px → 4px,`::before` 悬停走廊同步收窄;翻转规则(left/down)的间距一并 4px。规则排序:tether 基础规则在前、翻转覆盖在后(同特异性下源序决胜)。
`tests/menu-flip.spec.tsx` 的 layout.css 契约断言扩展覆盖这三条规则。
## i18n
新增 7 key(rename / renameInvalid / delete / deleteTitle / deleteDescFile / deleteDescDir / dismiss),zh/en/ja 手写;其余 18 个已发第三语言按 `locales.spec.ts` 的 key-set-parity 门禁同步补齐。
## 测试
- `tests/fs-operations.spec.ts`:rename(文件/目录/no-op 同名/单段违规/目标已存在 409/根拒绝/缺源/symlink 行改链接不改目标)、remove(文件/目录递归/symlink 只解链目标存活/根拒绝与缺路径)。
- `tests/file-tree-rename-delete.spec.tsx`(jsdom,mock api):菜单项出现条件(文件/目录有、根无);重命名流(预填→Enter 提交→`fsRename`+`onPathRenamed`、Esc 取消、非法名走错误条);删除流(弹窗文案文件/目录区分、确认→`fsRemove`+`onPathDeleted`、取消、服务端失败进错误条)。
- 真机挂载冒烟(`pnpm test:mount`)不触达这些交互,由 CI 门禁与手动验收覆盖。
## 已知限制
- 无回收站:删除即永久(fence 开关不改变这一点,只改变越界拒绝)。
- 重命名不迁移 expanded/revealed 状态:改名已展开目录后新目录折叠、reveal 高亮不跟随(一次性视觉状态,成本/收益不划算)。
- 并发窗口竞争:存在性检查与 rename/remove 之间有微小 TOCTOU 窗口(单用户侧边栏场景可忽略;服务端错误会进错误条)。
- 「在其他编辑器里改名后本树 stale」既有行为不变(refresh tick / focus 刷新机制管)。
+15
View File
@@ -40,6 +40,7 @@ import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
@@ -225,6 +226,16 @@ export function EditorHost(props: {
})
}
// Tree mutations reconcile the OPEN tabs (both split trees, the bottom
// panel, free windows): a rename retargets its tab to the new path; a
// delete closes tabs at or under the removed path. See tree-mutations.ts.
const onPathRenamed = (oldPath: string, newPath: string): void => {
retargetPathTabs(ctx, store, oldPath, newPath)
}
const onPathDeleted = (path: string): void => {
closePathTabs(ctx, store, path)
}
// The viewer's toolbar, hoisted into THIS header: the text editor reports
// its state and registers its commands (both null/absent for viewers
// without a toolbar — image, pdf, binary download).
@@ -389,6 +400,8 @@ export function EditorHost(props: {
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
/>
</div>
)
@@ -511,6 +524,8 @@ export function EditorHost(props: {
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
/>
</div>
)}
+210 -17
View File
@@ -24,14 +24,16 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { isImeComposition } from './ime-guard.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
@@ -133,6 +135,10 @@ export function FileTree(props: {
onToggleOpenWithPin?: (targetId: string) => void
/** Insert `@<relative path>` into the composer draft (file vs directory). */
onReferenceFile: (path: string, isDir: boolean) => void
/** A rename landed (old row path → new path): the caller retargets open tabs. */
onPathRenamed?: (oldPath: string, newPath: string) => void
/** A delete landed: the caller closes tabs at or under the removed path. */
onPathDeleted?: (path: string, isDir: boolean) => void
/** Bump to wipe the level cache and reload the visible set. */
refreshTick: number
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
@@ -140,13 +146,22 @@ export function FileTree(props: {
/** True while an upload is in flight (drops are ignored). */
busy: boolean
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
// The row menu's "open with" submenu is the one submenu that can tower past
// the viewport; publish its flip geometry for layout.css while it is open.
useSubmenuFlip(rowMenu)
/** The row being renamed inline: its path plus the edit buffer. */
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null)
/** The delete awaiting the confirmation modal's yes. */
const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null)
/** The last mutation failure (dismissable strip above the tree). */
const [actionError, setActionError] = useState<string | null>(null)
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
const [dropOver, setDropOver] = useState(false)
/** The directory a drag is hovering right now (null = body, drop to root). */
@@ -268,6 +283,63 @@ export function FileTree(props: {
loadDir(dir)
}, [loadDir])
/**
* Settle the tree after one rename/delete landed at `prefix`: drop every
* cached level at or under the old path, collapse the now-stale expanded
* directories below it, and reload the immediate parent so the new shape
* shows without a full refresh tick.
*/
const pruneTree = (prefix: string): void => {
const parent = parentOf(prefix)
const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`)
for (const key of Object.keys(dataRef.current)) {
if (under(key)) delete dataRef.current[key]
}
if (parent !== prefix) delete dataRef.current[parent]
setData({ ...dataRef.current })
for (const dir of expanded) {
if (under(dir)) onToggle(dir)
}
if (parent !== prefix) retryDir(parent)
}
/** Client-side twin of the server's name rule (the server re-validates). */
const validRename = (name: string): boolean =>
name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
/** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */
const commitRename = (path: string, raw: string): void => {
setRenaming(null)
const name = raw.trim()
if (cwd === undefined || name === baseName(path) || !validRename(name)) {
if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid'))
return
}
api.fsRename({ sessionId, cwd }, path, name)
.then((result) => {
setActionError(null)
pruneTree(path)
onPathRenamed?.(path, result.path)
})
.catch((error: unknown) => {
setActionError(error instanceof Error ? error.message : String(error))
})
}
/** Run the confirmed delete: fs.remove + settle + close affected tabs. */
const performDelete = (target: { path: string; isDir: boolean }): void => {
if (cwd === undefined) return
api.fsRemove({ sessionId, cwd }, target.path)
.then(() => {
setActionError(null)
pruneTree(target.path)
onPathDeleted?.(target.path, target.isDir)
})
.catch((error: unknown) => {
setActionError(error instanceof Error ? error.message : String(error))
})
}
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
@@ -373,13 +445,14 @@ export function FileTree(props: {
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
const pinnedIds = openWithPinned ?? []
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
* reveal gets the folder glyph, custom editors a generic code mark. */
* reveal gets the folder glyph, custom editors a generic code mark.
* The compact menu's icon slot is 14px, so every mark renders at 14. */
const itemIcon = (target: OpenWithTarget): ReactNode => {
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
if (target.id === 'vscode') return <IconVscode16 size={16} />
if (target.id === 'cursor') return <SiCursor size={16} />
if (target.id === 'zed') return <SiZedindustries size={16} />
return <IconCodeOutline16 size={16} />
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
if (target.id === 'vscode') return <IconVscode16 size={14} />
if (target.id === 'cursor') return <SiCursor size={14} />
if (target.id === 'zed') return <SiZedindustries size={14} />
return <IconCodeOutline16 size={14} />
}
const pinned = openWithTargets
.filter(target => pinnedIds.includes(target.id))
@@ -412,7 +485,7 @@ export function FileTree(props: {
onToggleOpenWithPin?.(target.id)
}}
>
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
</span>
</span>
),
@@ -433,7 +506,7 @@ export function FileTree(props: {
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={16} />,
icon: <VscLinkExternal size={14} />,
submenu,
},
]
@@ -447,6 +520,58 @@ export function FileTree(props: {
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
/**
* Focus + select the rename editor once, on mount. The callback identity
* must stay STABLE (useCallback []): an inline arrow re-runs on every
* render (detach null → attach el), and focus()/select() mid-keystroke
* would reselect the whole buffer while typing. A plain <input>, not the
* primitives Input — that one forwards no ref, and focus+select is the
* entire point here.
*/
const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
if (el !== null) {
el.focus()
el.select()
}
}, [])
/** The inline rename editor replacing one row (dirs and files alike):
* same indent/icon/height for a seamless swap, Enter/blur commits,
* Escape cancels, IME composition keys never reach the handlers (the
* shared isImeComposition guard). */
const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
<div
key={entry.path}
className={clsx(css.explorerRow, css.explorerRenaming)}
style={{ paddingLeft: depth * 22 + 6 }}
>
{entry.isDir
? (expandedSet.has(entry.path) ? <VscFolderOpened size={14} /> : <VscFolder size={14} />)
: <VscFile size={14} />}
<input
ref={renameInputRef}
className={css.explorerRenameInput}
value={renaming?.value ?? ''}
aria-label={t('rename')}
spellCheck={false}
onChange={(event) => {
setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
}}
onKeyDown={(event) => {
if (isImeComposition(event)) return
if (event.key === 'Enter') {
event.preventDefault()
commitRename(entry.path, renaming?.value ?? '')
} else if (event.key === 'Escape') {
event.preventDefault()
setRenaming(null)
}
}}
onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
/>
</div>
)
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
@@ -471,6 +596,10 @@ export function FileTree(props: {
}
const entries = level.entries ?? []
return entries.map(entry => {
// The row being renamed renders as its editor (no button semantics:
// an editor is not a click target — and a nested interactive inside
// role="button" would be invalid anyway).
if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
if (entry.isDir) {
const isOpen = expandedSet.has(entry.path)
return (
@@ -551,6 +680,22 @@ export function FileTree(props: {
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
{/* The last mutation failure (rename/delete): dismissable, raw
server text — the same show-the-truth policy the fence notice
uses. Any later action or a fresh attempt clears it. */}
{actionError !== null && (
<div className={css.explorerActionError} role="alert">
<span className={css.explorerActionErrorText}>{actionError}</span>
<button
type="button"
className={css.explorerActionErrorClose}
aria-label={t('dismiss')}
onClick={() => { setActionError(null) }}
>
<IconCloseFill14 />
</button>
</div>
)}
<div
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
style={{ paddingLeft: 6 }}
@@ -650,22 +795,31 @@ export function FileTree(props: {
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
: []),
...openWithEntries(),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
: []),
// Upload into a directory (incl. the workspace root row).
...(rowMenu?.isDir === true
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
// Explorer mutations close the menu; the workspace ROOT row is the
// session itself — never renamable or deletable (server double-guards).
...(rowMenu !== null && rowMenu.path !== cwd
? [
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
]}
onSelect={(id) => {
const target = rowMenu
@@ -692,16 +846,55 @@ export function FileTree(props: {
fileInputRef.current?.click()
return
}
if (id === 'rename') {
setRenaming({ path: target.path, value: baseName(target.path) })
return
}
if (id === 'delete') {
setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
return
}
copyPath(
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
target.path,
)
}}
portal
compact
align="start"
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The delete confirmation: destructive and permanent (no host trash),
so it always lands here first — the same Cancel/Confirm shape the
git lens uses for discard/revert/cherry-pick. */}
<Modal
open={confirmDelete !== null}
onClose={() => { setConfirmDelete(null) }}
title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirmDelete(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const pending = confirmDelete
if (pending === null) return
setConfirmDelete(null)
performDelete(pending)
}}
>
{t('delete')}
</Button>
</>
)}
>
<p className={css.explorerConfirmDesc}>
{confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
</p>
</Modal>
</div>
)
}
+1
View File
@@ -279,6 +279,7 @@ export function FreeWindow(props: {
else if (id === 'close') onClose()
}}
portal
compact
align="start"
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
anchor={<span />}
+12 -5
View File
@@ -10,16 +10,18 @@
* Security posture: every HTML string (block leaves, inline text, wrapper
* open-tag attributes) goes through DOMPurify with an explicit denylist on
* top of its defaults (no script/style/iframe/forms), anchors are forced to
* open in a new tab with noopener, and local media `src` attributes are
* rewritten through the session-scoped `/sidebar/file` media route — the same
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
* open in a new tab with noopener, and local media goes through the
* session-scoped `/sidebar/file` media route — markdown-syntax image
* destinations are rewritten via `markdown-images.ts` and `src` attributes
* through `resolveLocalMediaDest` (the same trust fence).
*/
import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
import DOMPurify from 'dompurify'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { resolveLocalMediaDest } from './markdown-images.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
@@ -233,7 +235,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
// MarkdownText drops non-http(s) image destinations (chat-security
// stance), so rewrite local ones into /sidebar/file media URLs first —
// the same trust fence the sanitized HTML leaves below go through.
// Idempotent: already-absolute media URLs pass through untouched.
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
return {
kind: 'markdown',
text,
+6
View File
@@ -16,6 +16,7 @@ import {
import type { SidebarTab } from './state.ts'
import { isAgentTabId } from './state.ts'
import { isPinnedVirtualTab } from './pinned.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import { IconPinOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -90,6 +91,9 @@ export function TabBar(props: {
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following the git lens/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
// The strip sits at the panel's top, so the pin submenu must grow downward;
// the flip hook derives that from the cursor y (upper half → "down").
useSubmenuFlip(tabMenu)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// The context target's index in the render-time tab snapshot; -1 when the
@@ -265,6 +269,7 @@ export function TabBar(props: {
setMenuOpen(false)
}}
portal
compact
align="end"
anchor={(
<button
@@ -355,6 +360,7 @@ export function TabBar(props: {
}
}}
portal
compact
align="start"
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
anchor={<span />}
+5 -3
View File
@@ -338,9 +338,11 @@ export function TextEditor(props: FileViewerProps) {
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
[markdown, mode, previewMdText],
)
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
* for documents that actually contain HTML — plain markdown keeps the
* legacy single-pass render path below, byte-for-byte. */
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
* every markdown preview, so the render below always takes the split
* renderer — its markdown runs rewrite local image destinations internally
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
* pre-rewritten `previewText`) are dead in the current wiring. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
[markdown, mode, previewMdText],
+7 -1
View File
@@ -63,11 +63,15 @@ export function TreePanel(props: {
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
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
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -252,6 +256,8 @@ export function TreePanel(props: {
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
+9
View File
@@ -278,6 +278,15 @@ export const api = {
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
/** Rename one tree row within its directory (single-segment name; the
* server refuses existing destinations, the workspace root, and — while
* the fence is armed — anything resolving outside the workspace). */
fsRename: (scope: SessionScope, path: string, name: string) =>
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
/** Permanently delete one tree row (recursive for directories; a symlink
* row unlinks the link only). The UI confirms before calling this. */
fsRemove: (scope: SessionScope, path: string) =>
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
/** 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) =>
+2
View File
@@ -671,6 +671,7 @@ export function GitLens(props: GitLensProps) {
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
}}
portal
compact
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
@@ -728,6 +729,7 @@ export function GitLens(props: GitLensProps) {
}
}}
portal
compact
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
+6 -3
View File
@@ -25,9 +25,12 @@
* legacy signal engines emit without isComposing.
*/
/** The pure decision: is this keyboard event part of an IME composition? */
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
return event.isComposing || event.keyCode === 229
/** The pure decision: is this keyboard event part of an IME composition?
* `isComposing` is optional on the input: React's synthetic KeyboardEvent
* type does not declare it (the DOM event always carries it), and the
* keyCode 229 fallback covers exactly those callers. */
export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean {
return event.isComposing === true || event.keyCode === 229
}
/**
+60
View File
@@ -111,3 +111,63 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
transition: none;
}
}
/* Context-menu submenu flip (viewport clamping for the portaled menus).
*
* The ui-primitives Menu clamps its main portal list into the viewport but
* positions nested submenus with static CSS only — anchored at the parent
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
* Docked on the window's right edge, both defaults are the worst case for
* this panel: a tall "open with" submenu escapes past the viewport top when
* the row menu opens near the top, and past the right edge on narrow panels
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
*
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
* words "down" and/or "left" — for exactly the lifetime of one of OUR
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
* flip the growth direction; the attribute's absence restores the
* primitive's own geometry untouched, and host-owned menus are never
* affected because they never open while the attribute exists (each side
* closes on the other's outside pointerdown).
*
* No max-height/overflow scroll cap here on purpose: overflow clipping
* would cut the submenu's ::before hover bridge (the corridor that keeps
* itemWrap's mouseleave from closing the card mid-crossing). */
/* Visual tether, part 1 — the parent row stays lit while its submenu is
* open. The primitive only styles :hover, so once the pointer crossed into
* the side card the parent row's fill cleared and the floating card read as
* unrelated. The Menu marks the open parent row with aria-expanded="true"
* (host-rendered, stable), so the same hover token re-applies for the
* submenu's whole lifetime. */
body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
* with the ::before hover corridor narrowed to match. Ordered BEFORE the
* flip overrides below so their left/right flips still win. */
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
left: calc(100% + 4px);
}
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
left: -4px;
width: 4px;
}
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
top: -4px;
bottom: auto;
}
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
left: auto;
right: calc(100% + 4px);
}
/* The hover bridge follows the card to the left side of the parent row. */
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
left: auto;
right: -4px;
}
+7
View File
@@ -203,6 +203,13 @@ export const ar: Record<string, string> = {
addToConversation: 'إضافة إلى المحادثة',
copyRelative: 'نسخ المسار النسبي',
copyAbsolute: 'نسخ المسار المطلق',
rename: 'إعادة تسمية',
renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.',
delete: 'حذف',
deleteTitle: 'حذف «{name}»؟',
deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
dismiss: 'إغلاق',
download: 'تنزيل',
uploadFiles: 'رفع ملفات',
uploadFolder: 'رفع مجلد',
+7
View File
@@ -188,6 +188,13 @@ export const de: Record<string, string> = {
addToConversation: 'Zur Unterhaltung hinzufügen',
copyRelative: 'Relativen Pfad kopieren',
copyAbsolute: 'Absoluten Pfad kopieren',
rename: 'Umbenennen',
renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.',
delete: 'Löschen',
deleteTitle: '„{name}“ löschen?',
deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
dismiss: 'Schließen',
download: 'Herunterladen',
uploadFiles: 'Dateien hochladen',
uploadFolder: 'Ordner hochladen',
+7
View File
@@ -195,6 +195,13 @@ export const fr: Record<string, string> = {
addToConversation: 'Ajouter à la discussion',
copyRelative: 'Copier le chemin relatif',
copyAbsolute: 'Copier le chemin absolu',
rename: 'Renommer',
renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.',
delete: 'Supprimer',
deleteTitle: 'Supprimer « {name} » ?',
deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.',
deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.',
dismiss: 'Fermer',
download: 'Télécharger',
uploadFiles: 'Importer des fichiers',
uploadFolder: 'Importer un dossier',
+7
View File
@@ -202,6 +202,13 @@ export const hi: Record<string, string> = {
addToConversation: 'वार्तालाप में जोड़ें',
copyRelative: 'सापेक्ष पथ कॉपी',
copyAbsolute: 'निरपेक्ष पथ कॉपी',
rename: 'नाम बदलें',
renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।',
delete: 'हटाएँ',
deleteTitle: '“{name}” हटाएँ?',
deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
dismiss: 'बंद करें',
download: 'डाउनलोड',
uploadFiles: 'फ़ाइलें अपलोड करें',
uploadFolder: 'फ़ोल्डर अपलोड करें',
+7
View File
@@ -200,6 +200,13 @@ export const id: Record<string, string> = {
addToConversation: 'Tambahkan ke obrolan',
copyRelative: 'Salin path relatif',
copyAbsolute: 'Salin path absolut',
rename: 'Ganti nama',
renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.',
delete: 'Hapus',
deleteTitle: 'Hapus "{name}"?',
deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.',
deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.',
dismiss: 'Tutup',
download: 'Unduh',
uploadFiles: 'Unggah berkas',
uploadFolder: 'Unggah folder',
+7
View File
@@ -193,6 +193,13 @@ export const it: Record<string, string> = {
addToConversation: 'Aggiungi alla conversazione',
copyRelative: 'Copia percorso relativo',
copyAbsolute: 'Copia percorso assoluto',
rename: 'Rinomina',
renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.',
delete: 'Elimina',
deleteTitle: 'Eliminare "{name}"?',
deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.',
deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.',
dismiss: 'Chiudi',
download: 'Scarica',
uploadFiles: 'Carica file',
uploadFolder: 'Carica cartella',
+7
View File
@@ -202,6 +202,13 @@ export const ja: Record<string, string> = {
addToConversation: '会話に追加',
copyRelative: '相対パスをコピー',
copyAbsolute: '絶対パスをコピー',
rename: '名前の変更',
renameInvalid: '名前は空にできず、パス区切り文字を含められません。',
delete: '削除',
deleteTitle: '「{name}」を削除しますか?',
deleteDescFile: 'このファイルを完全に削除します。元に戻せません。',
deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。',
dismiss: '閉じる',
download: 'ダウンロード',
uploadFiles: 'ファイルをアップロード',
uploadFolder: 'フォルダーをアップロード',
+7
View File
@@ -194,6 +194,13 @@ export const ko: Record<string, string> = {
addToConversation: '대화에 추가',
copyRelative: '상대 경로 복사',
copyAbsolute: '절대 경로 복사',
rename: '이름 바꾸기',
renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.',
delete: '삭제',
deleteTitle: '"{name}"을(를) 삭제할까요?',
deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
dismiss: '닫기',
download: '다운로드',
uploadFiles: '파일 업로드',
uploadFolder: '폴더 업로드',
+7
View File
@@ -200,6 +200,13 @@ export const nl: Record<string, string> = {
addToConversation: 'Aan conversatie toevoegen',
copyRelative: 'Relatief pad kopiëren',
copyAbsolute: 'Absoluut pad kopiëren',
rename: 'Naam wijzigen',
renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.',
delete: 'Verwijderen',
deleteTitle: '"{name}" verwijderen?',
deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.',
deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.',
dismiss: 'Sluiten',
download: 'Downloaden',
uploadFiles: 'Bestanden uploaden',
uploadFolder: 'Map uploaden',
+7
View File
@@ -204,6 +204,13 @@ export const pl: Record<string, string> = {
addToConversation: 'Dodaj do rozmowy',
copyRelative: 'Kopiuj ścieżkę względną',
copyAbsolute: 'Kopiuj ścieżkę bezwzględną',
rename: 'Zmień nazwę',
renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.',
delete: 'Usuń',
deleteTitle: 'Usunąć „{name}”?',
deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.',
deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.',
dismiss: 'Zamknij',
download: 'Pobierz',
uploadFiles: 'Wgraj pliki',
uploadFolder: 'Wgraj folder',
+7
View File
@@ -185,6 +185,13 @@ export const pt: Record<string, string> = {
addToConversation: 'Adicionar à conversa',
copyRelative: 'Copiar caminho relativo',
copyAbsolute: 'Copiar caminho absoluto',
rename: 'Renomear',
renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.',
delete: 'Excluir',
deleteTitle: 'Excluir "{name}"?',
deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.',
deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.',
dismiss: 'Fechar',
download: 'Baixar',
uploadFiles: 'Enviar arquivos',
uploadFolder: 'Enviar pasta',
+7
View File
@@ -198,6 +198,13 @@ export const ru: Record<string, string> = {
addToConversation: 'Добавить в диалог',
copyRelative: 'Копировать относительный путь',
copyAbsolute: 'Копировать абсолютный путь',
rename: 'Переименовать',
renameInvalid: 'Имя не может быть пустым или содержать разделители пути.',
delete: 'Удалить',
deleteTitle: 'Удалить «{name}»?',
deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.',
deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.',
dismiss: 'Закрыть',
download: 'Скачать',
uploadFiles: 'Загрузить файлы',
uploadFolder: 'Загрузить папку',
+7
View File
@@ -185,6 +185,13 @@ export const sv: Record<string, string> = {
addToConversation: 'Lägg till i konversation',
copyRelative: 'Kopiera relativ sökväg',
copyAbsolute: 'Kopiera absolut sökväg',
rename: 'Byt namn',
renameInvalid: 'Namnet får inte vara tomt eller innehålla sökvägsavgränsare.',
delete: 'Ta bort',
deleteTitle: 'Ta bort "{name}"?',
deleteDescFile: 'Detta tar bort filen permanent. Det går inte att ångra.',
deleteDescDir: 'Detta tar bort katalogen och allt i den permanent. Det går inte att ångra.',
dismiss: 'Stäng',
download: 'Ladda ner',
uploadFiles: 'Ladda upp filer',
uploadFolder: 'Ladda upp mapp',
+7
View File
@@ -202,6 +202,13 @@ export const th: Record<string, string> = {
addToConversation: 'เพิ่มไปยังแชท',
copyRelative: 'คัดลอกพาธสัมพัทธ์',
copyAbsolute: 'คัดลอกพาธ absolute',
rename: 'เปลี่ยนชื่อ',
renameInvalid: 'ชื่อต้องไม่ว่างเปล่าและต้องไม่มีตัวคั่นเส้นทาง',
delete: 'ลบ',
deleteTitle: 'ลบ "{name}" หรือไม่',
deleteDescFile: 'การดำเนินการนี้จะลบไฟล์อย่างถาวร และไม่สามารถย้อนกลับได้',
deleteDescDir: 'การดำเนินการนี้จะลบไดเรกทอรีและเนื้อหาทั้งหมดอย่างถาวร และไม่สามารถย้อนกลับได้',
dismiss: 'ปิด',
download: 'ดาวน์โหลด',
uploadFiles: 'อัปโหลดไฟล์',
uploadFolder: 'อัปโหลดโฟลเดอร์',
+7
View File
@@ -202,6 +202,13 @@ export const tr: Record<string, string> = {
addToConversation: 'Sohbete ekle',
copyRelative: 'Göreli yolu kopyala',
copyAbsolute: 'Mutlak yolu kopyala',
rename: 'Yeniden adlandır',
renameInvalid: 'Ad boş olamaz veya yol ayırıcıları içeremez.',
delete: 'Sil',
deleteTitle: '"{name}" silinsin mi?',
deleteDescFile: 'Bu, dosyayı kalıcı olarak siler. Bu işlem geri alınamaz.',
deleteDescDir: 'Bu, dizini ve tüm içeriğini kalıcı olarak siler. Bu işlem geri alınamaz.',
dismiss: 'Kapat',
download: 'İndir',
uploadFiles: 'Dosyaları yükle',
uploadFolder: 'Klasör yükle',
+7
View File
@@ -202,6 +202,13 @@ export const vi: Record<string, string> = {
addToConversation: 'Thêm vào cuộc trò chuyện',
copyRelative: 'Sao chép đường dẫn tương đối',
copyAbsolute: 'Sao chép đường dẫn tuyệt đối',
rename: 'Đổi tên',
renameInvalid: 'Tên không được để trống hoặc chứa dấu phân tách đường dẫn.',
delete: 'Xóa',
deleteTitle: 'Xóa "{name}"?',
deleteDescFile: 'Thao tác này sẽ xóa vĩnh viễn tệp. Không thể hoàn tác.',
deleteDescDir: 'Thao tác này sẽ xóa vĩnh viễn thư mục và toàn bộ nội dung của nó. Không thể hoàn tác.',
dismiss: 'Đóng',
download: 'Tải xuống',
uploadFiles: 'Tải tệp lên',
uploadFolder: 'Tải thư mục lên',
+7
View File
@@ -217,6 +217,13 @@ export const zhHK: Record<string, string> = {
addToConversation: '新增到對話',
copyRelative: '複製相對位址',
copyAbsolute: '複製絕對位址',
rename: '重新命名',
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
delete: '刪除',
deleteTitle: '刪除「{name}」?',
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
dismiss: '關閉',
download: '下載',
uploadFiles: '上傳檔案',
uploadFolder: '上傳資料夾',
+7
View File
@@ -217,6 +217,13 @@ export const zhMO: Record<string, string> = {
addToConversation: '新增到對話',
copyRelative: '複製相對位址',
copyAbsolute: '複製絕對位址',
rename: '重新命名',
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
delete: '刪除',
deleteTitle: '刪除「{name}」?',
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
dismiss: '關閉',
download: '下載',
uploadFiles: '上傳檔案',
uploadFolder: '上傳資料夾',
+7
View File
@@ -217,6 +217,13 @@ export const zhTW: Record<string, string> = {
addToConversation: '新增到對話',
copyRelative: '複製相對位址',
copyAbsolute: '複製絕對位址',
rename: '重新命名',
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
delete: '刪除',
deleteTitle: '刪除「{name}」?',
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
dismiss: '關閉',
download: '下載',
uploadFiles: '上傳檔案',
uploadFolder: '上傳資料夾',
+14
View File
@@ -214,6 +214,13 @@ export const zh = {
addToConversation: '添加到对话',
copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址',
rename: '重命名',
renameInvalid: '名称不能为空,且不能包含路径分隔符。',
delete: '删除',
deleteTitle: '删除「{name}」?',
deleteDescFile: '将永久删除该文件,此操作不可撤销。',
deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。',
dismiss: '关闭',
download: '下载',
uploadFiles: '上传文件',
uploadFolder: '上传文件夹',
@@ -652,6 +659,13 @@ export const en: Record<keyof typeof zh, string> = {
addToConversation: 'Add to conversation',
copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path',
rename: 'Rename',
renameInvalid: 'The name cannot be empty or contain path separators.',
delete: 'Delete',
deleteTitle: 'Delete "{name}"?',
deleteDescFile: 'This permanently deletes the file. This cannot be undone.',
deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.',
dismiss: 'Dismiss',
download: 'Download',
uploadFiles: 'Upload files',
uploadFolder: 'Upload folder',
+68
View File
@@ -0,0 +1,68 @@
/**
* Submenu flip geometry for the portaled context menus.
*
* The primitives `Menu` clamps its main list into the viewport (portal mode,
* 12px margin) but positions nested submenus with pure CSS — `bottom: -4px`
* (grow UP from the parent row) and `left: calc(100% + 10px)` (open RIGHT of
* the card) — with no viewport clamp at all. This sidebar lives docked on the
* RIGHT edge of the window, so both defaults are the worst case here: a tall
* "open with" submenu pokes past the viewport top when the row menu opens
* near the top, and past the right edge whenever the panel is narrow.
*
* The host primitive is not ours to patch, so the fix is a body attribute
* written while one of OUR submenu-bearing menus is open: layout.css flips
* the submenu's growth direction per token. The attribute's lifetime IS the
* scope — only our menus set it, and layout.css rules for
* `div[role="menu"] div[role="menu"]` only apply while it exists, so
* host-owned menus are never touched.
*/
import { useEffect } from 'react'
/** body attribute carrying the flip tokens (whitespace-separated words). */
export const SUBMENU_FLIP_ATTR = 'data-dsh-sidebar-submenu'
/**
* Mirror of the primitive's submenu geometry (Menu.module.css): card
* min-width 218 + card-to-submenu gap 10 + submenu min-width 163..165 +
* viewport margin 12 ≈ 400px of right-hand room a right-growing submenu
* needs. If the cursor is within that of the right edge, open left instead.
* The estimate is conservative (the primitive's own clamp can only move the
* card further left), so a wrong guess flips left unnecessarily — never into
* a new overflow — and drifting from future host metrics degrades the same
* way, back to today's behavior at worst.
*/
const SUBMENU_RIGHT_ROOM = 400
/** Below the vertical midpoint the parent row usually has more room above. */
function isUpperHalf(y: number, vh: number): boolean {
return y < vh / 2
}
/**
* The flip tokens for a menu opened at viewport coordinates (x, y):
* `down` — open the submenu downward from the parent row (cursor in the
* upper half; the sidebar's tab strip is always up there);
* `left` — open the submenu leftward of the card (not enough right-hand
* room). Empty string when the primitive's defaults already fit.
*/
export function submenuFlipTokens(x: number, y: number, vw: number, vh: number): string {
const tokens: string[] = []
if (isUpperHalf(y, vh)) tokens.push('down')
if (x + SUBMENU_RIGHT_ROOM > vw) tokens.push('left')
return tokens.join(' ')
}
/**
* Publish the flip tokens on <body> while the given cursor-anchored menu
* state is open; clear them on close and on unmount (a menu left open by an
* unmounting panel must not leave stale geometry on the shared body).
* `menu` only needs the viewport coordinates the Menu anchored to.
*/
export function useSubmenuFlip(menu: { x: number; y: number } | null): void {
useEffect(() => {
if (menu === null) return
document.body.setAttribute(SUBMENU_FLIP_ATTR, submenuFlipTokens(menu.x, menu.y, window.innerWidth, window.innerHeight))
return () => { document.body.removeAttribute(SUBMENU_FLIP_ATTR) }
}, [menu])
}
+82 -4
View File
@@ -1038,6 +1038,83 @@ 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. */
.explorerRenaming {
cursor: text;
}
.explorerRenameInput {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
height: 24px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-state-business-primary);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.explorerRenameInput:focus {
outline: none;
border-color: var(--dsw-alias-state-business-primary);
}
/* The dismissable mutation-failure strip above the root row (rename/delete
refusals — raw server text, same policy as the fence notice). */
.explorerActionError {
display: flex;
align-items: flex-start;
gap: 6px;
box-sizing: border-box;
width: 100%;
margin-bottom: 4px;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-state-error-primary);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-1);
}
.explorerActionErrorText {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
.explorerActionErrorClose {
flex: none;
display: inline-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;
}
.explorerActionErrorClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* 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;
}
/* The hover-revealed @-reference button at the row's far right (and the
transient "copied" label that replaces it after a copy). The only auto
margin in the row absorbs the filename's spare width, so a short name
@@ -2439,15 +2516,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
/* The pushpin toggle on an "open with" submenu row: brand ink once the
target is pinned to the menu's top level. */
target is pinned to the menu's top level. Sized for the compact menu's
26px rows (16px box, 12px glyph) — proportionate without overflowing. */
.openWithPin {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
width: 16px;
height: 16px;
border-radius: 5px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
+2 -1
View File
@@ -69,6 +69,7 @@ export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: Ses
id: d.id,
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(ctx, scope, state) ?? true),
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
// 14 matches the compact + menu's icon slot (compactList .itemIcon).
icon: typeof d.icon === 'function' ? d.icon(14) : d.icon,
}))
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Open-tab reconciliation after a file-tree mutation (rename/delete).
*
* The tree owns the rows; the TABS live in the sidebar state (either split
* tree, the bottom panel, or free windows). A rename must retarget every
* tab whose `path` is the renamed file (the editor content survives and
* later saves land on the new path); a delete must close every tab at or
* under the removed path — files and anything inside a removed directory
* (a stale tab's next save would fail against a missing path). Both ride
* the SERVICE paths (`updateTab`/`closeTab`) rather than direct state
* reducers so the registered lifecycle callbacks fire like any other tab
* mutation.
*/
import type { Context } from '../context-types.ts'
import { baseName } from './FileTree.tsx'
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
import { isWithinWorkspace } from './paths.ts'
/** Every open tab that carries a file path (either split tree, the bottom
* panel, and free windows — a floating tab is as open as a docked one). */
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
const state = snapshot.state
if (state === undefined) return []
const tabs: SidebarTab[] = []
for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
for (const float of state.floats) tabs.push(float.tab)
return tabs.filter(tab => tab.path !== undefined)
}
/** Retarget tabs after `oldPath` became `newPath` (title follows the new
* base name, matching how openFile titles editor tabs). */
export function retargetPathTabs(ctx: Context, store: SidebarStore, oldPath: string, newPath: string): void {
const service = ctx.get('betterSidebar')
if (service === undefined) return
for (const tab of pathTabsOf(store.getSnapshot())) {
if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
}
}
/** Close tabs at or under the removed `target` (a directory takes its whole
* subtree of open files with it). */
export function closePathTabs(ctx: Context, store: SidebarStore, target: string): void {
const service = ctx.get('betterSidebar')
if (service === undefined) return
for (const tab of pathTabsOf(store.getSnapshot())) {
const path = tab.path
if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
}
}
+124 -2
View File
@@ -9,14 +9,22 @@
* 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).
*/
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'
import { createWriteStream } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { requireAbsolute } from './fs-tree.ts'
import { isWithin, 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. */
@@ -94,3 +102,117 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
throw error
}
}
/** Inputs of one tree-row rename. */
export interface WorkspaceRenameInput {
/** The session workspace root; the renamed entry must stay inside it. */
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). */
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. */
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 }
}
/** Whether a path exists (ENOENT → false; other failures propagate). */
async function pathExists(target: string): Promise<boolean> {
try {
await access(target)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
/**
* Rename one tree row within its directory: `path` → `<parent>/<name>`.
* The new name must be a single path segment (this is rename, not move);
* an existing destination is refused (POSIX rename would clobber it
* silently); the workspace root itself is never renamable; a symlink row
* 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.
*/
export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> {
const { cwd, path, name, fence = true } = 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)
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)
if (await pathExists(safeDestination)) {
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
}
try {
await rename(absolute, safeDestination)
} catch (error) {
throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
return { path: safeDestination }
}
/** Inputs of one tree-row delete. */
export interface WorkspaceRemoveInput {
/** The session workspace root; the removed entry must stay inside it. */
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). */
fence?: boolean
}
/**
* Delete one tree row permanently (there is no trash on the host): files are
* unlinked, directories removed recursively, a symlink row unlinks the LINK
* 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.
*/
export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
const { cwd, path, fence = true } = input
const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence)
if (real === realCwd) {
throw new SidebarError('fs-error', 'cannot remove the workspace root', 400)
}
try {
const info = await lstat(absolute)
if (info.isDirectory()) await rm(absolute, { recursive: true })
else await unlink(absolute)
} catch (error) {
throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
return { path: absolute }
}
+23 -1
View File
@@ -31,7 +31,7 @@ import {
} from './config.ts'
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { resolveSessionPath } from './session-path.ts'
import { writeWorkspaceUpload } from './fs-operations.ts'
import { 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'
@@ -369,6 +369,28 @@ function buildApi(
}
return { ok: true }
},
// The tree row's rename: single-segment name, destination-existence and
// workspace-root refusals, link-aware (renames the row, not its target).
// fs-operations.ts owns the containment and shape rules.
'fs.rename': async (payload) => {
const { cwd } = await cwdOf(payload)
return renameWorkspaceEntry({
cwd,
path: requireString(payload, 'path'),
name: requireString(payload, 'name'),
fence: fenceEnabledOf(getSettings),
})
},
// The tree row's delete (permanent — the host has no trash): recursive
// for directories, unlinks a symlink row without touching its target.
'fs.remove': async (payload) => {
const { cwd } = await cwdOf(payload)
return removeWorkspaceEntry({
cwd,
path: requireString(payload, 'path'),
fence: fenceEnabledOf(getSettings),
})
},
'git.worktrees': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const selected = selectedRepoOf(payload)
+13
View File
@@ -27,6 +27,7 @@ import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
import type {
Context,
SidebarAgentPresetsService,
@@ -238,16 +239,28 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, descriptorEvent]
// Fork-marker fields (the exact shape the host's own session.fork uses,
// api-session-controller): without `isSeeded` + `inheritedEventCount` the
// session treats the whole seed as the child's OWN events, so the child's
// Inbox constructor replays the parent's `agent/inbox/spliced` events and
// inherits whatever input sat UNCLAIMED in the parent at the click moment
// (a queued follow-up, or a tool-result context spliced into next-step
// between step boundaries of a long-running turn). The first side prompt
// would then claim and send that stale message BEFORE the boundary +
// question. The marker keeps `ownEvents()` at the end-seed boundary, so
// the inherited inbox replays to empty.
const options: CreateAgentOptions = {
sessionId: childId,
meta: {
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
parentSession: parentSession.id,
isSeeded: true,
origin: 'subagent',
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
...(agentPreset === undefined ? {} : { agentPreset }),
},
seed: seed as unknown as readonly SessionEvent[],
inheritedEventCount: SessionLogOffset(seed.length),
agentOptions: { ...parent.options },
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
+7
View File
@@ -281,6 +281,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
if (title === BUILTIN_TABS[0]) {
// Layout-level evidence for the compact density: rows render ~26px
// tall (the default density would be 40px). Guard loose at ≤30px so
// font/zoom differences across runners cannot flake it.
const rowHeight = await item.evaluate(el => el.getBoundingClientRect().height)
expect(rowHeight, '+ menu rows must render at the compact density (≤30px)').toBeLessThanOrEqual(30)
}
await item.click()
// Let the activation commit (including any lazy-chunk fetch) before the
// crash assertions run.
+42
View File
@@ -204,3 +204,45 @@ describe('FileTree open-with menu', () => {
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
})
})
describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
document.body.removeAttribute(ATTR)
})
it('publishes "down" for a top-of-tree opening and clears on selection', async () => {
harness = await mountTree()
// openMenu() right-clicks at (20, 30) — the upper half, plenty of right
// 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()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
it('adds "left" when the cursor sits within the right-hand submenu room', async () => {
harness = await mountTree()
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: window.innerWidth - 10, clientY: 30 })
act(() => { fileRow(harness.container).dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
})
it('clears the attribute on unmount with the menu still open', async () => {
harness = await mountTree()
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
harness.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+230
View File
@@ -0,0 +1,230 @@
/**
* FileTree explorer mutations: the context menu's rename (inline row editor)
* and delete (confirmation modal) — menu-item visibility (never on the
* workspace root row), the commit/cancel flows, the API calls with the row
* path, the tab-reconciliation callbacks, and the failure strip.
*/
// @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 { createSidebarStore } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
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 factories are hoisted above every declaration, so the shared
// mocks ride vi.hoisted (mockClear in afterEach keeps the implementations).
const { fsRename, fsRemove } = vi.hoisted(() => ({
fsRename: vi.fn(async () => ({ path: '/tmp/renamed.ts' })),
fsRemove: vi.fn(async () => ({ path: '/tmp/a.ts' })),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
entries: [
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
{ name: 'sub', path: '/tmp/sub', isDir: true },
],
}),
fsRename,
fsRemove,
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
interface Harness {
container: HTMLDivElement
onPathRenamed: ReturnType<typeof vi.fn>
onPathDeleted: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const onPathRenamed = vi.fn()
const onPathDeleted = vi.fn()
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onOpenFileNewTab: () => {},
onOpenFileSide: () => {},
onReferenceFile: () => {},
onPathRenamed,
onPathDeleted,
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
})
return {
container,
onPathRenamed,
onPathDeleted,
unmount: () => { act(() => { root.unmount() }) },
}
}
/** One tree row by its displayed name (the root row included; it carries no
* role="button", so the query rides the row class). */
function rowByName(container: HTMLDivElement, 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 "${name}" not found`)
return row
}
function openMenu(container: HTMLDivElement, 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(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 "${label}" not found`)
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 }))
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsRename.mockClear()
fsRemove.mockClear()
})
describe('FileTree rename/delete menu items', () => {
it('offers rename and delete on file and directory rows, never on the root', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
expect(menuLabels()).toContain('Rename')
expect(menuLabels()).toContain('Delete')
openMenu(harness.container, 'sub')
expect(menuLabels()).toContain('Rename')
// The workspace root row (cwd itself) is the session — no mutations.
openMenu(harness.container, 'tmp')
expect(menuLabels()).not.toContain('Rename')
expect(menuLabels()).not.toContain('Delete')
})
})
describe('FileTree inline rename', () => {
it('replaces the row with a prefilled editor; Enter commits via fsRename + onPathRenamed', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Rename')
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')
expect(input).not.toBeNull()
expect(input!.value).toBe('a.ts')
setNativeValue(input!, 'b.ts')
await act(async () => { pressKey(input!, 'Enter') })
expect(fsRename).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts', 'b.ts')
expect(harness.onPathRenamed).toHaveBeenCalledWith('/tmp/a.ts', '/tmp/renamed.ts')
// The editor row is gone (the commit closed it).
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
})
it('Escape cancels without touching the API', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Rename')
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')!
setNativeValue(input, 'b.ts')
await act(async () => { pressKey(input, 'Escape') })
expect(fsRename).not.toHaveBeenCalled()
expect(harness.onPathRenamed).not.toHaveBeenCalled()
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
})
it('rejects separator names client-side with the error strip, no API call', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Rename')
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')!
setNativeValue(input, 'a/b.ts')
await act(async () => { pressKey(input, 'Enter') })
expect(fsRename).not.toHaveBeenCalled()
const alert = harness.container.querySelector('[role="alert"]')
expect(alert?.textContent).toContain('path separators')
})
})
describe('FileTree delete confirmation', () => {
it('confirms through the modal, then calls fsRemove + onPathDeleted', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Delete')
const dialog = document.querySelector('[role="dialog"]')
expect(dialog?.textContent).toContain('Delete "a.ts"?')
expect(dialog?.textContent).toContain('permanently deletes the file')
const confirm = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
expect(fsRemove).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts')
expect(harness.onPathDeleted).toHaveBeenCalledWith('/tmp/a.ts', false)
expect(document.querySelector('[role="dialog"]')).toBeNull()
})
it('the directory copy names the recursive wipe', async () => {
harness = await mountTree()
openMenu(harness.container, 'sub')
clickMenuitem('Delete')
const dialog = document.querySelector('[role="dialog"]')
expect(dialog?.textContent).toContain('everything inside it')
})
it('cancel closes the modal without calling anything', async () => {
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Delete')
const cancel = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Cancel')!
act(() => { cancel.click() })
expect(fsRemove).not.toHaveBeenCalled()
expect(document.querySelector('[role="dialog"]')).toBeNull()
})
it('a server failure lands in the dismissable strip', async () => {
fsRemove.mockRejectedValueOnce(new Error('boom: refused'))
harness = await mountTree()
openMenu(harness.container, 'a.ts')
clickMenuitem('Delete')
const confirm = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
const alert = harness.container.querySelector('[role="alert"]')
expect(alert?.textContent).toContain('boom: refused')
expect(harness.onPathDeleted).not.toHaveBeenCalled()
})
})
+91 -2
View File
@@ -1,8 +1,8 @@
import { afterAll, describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { writeWorkspaceUpload } from '../src/fs-operations.ts'
import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from '../src/fs-operations.ts'
/** The test workspace root (each suite gets its own temp tree). */
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
@@ -150,3 +150,92 @@ describe('writeWorkspaceUpload', () => {
expect(readFileSync(target, 'utf8')).toBe('original')
})
})
describe('renameWorkspaceEntry', () => {
it('renames a file within its directory', async () => {
writeFileSync(join(root, 'old-name.txt'), 'x')
const { path } = await renameWorkspaceEntry({ cwd: root, path: join(root, 'old-name.txt'), name: 'new-name.txt' })
// The returned path is CANONICAL (realpath-resolved ancestors — on macOS
// the tmpdir's /var becomes /private/var), so assert the shape, not the
// lexical spelling.
expect(path.endsWith('new-name.txt')).toBe(true)
expect(existsSync(join(root, 'old-name.txt'))).toBe(false)
expect(existsSync(join(root, 'new-name.txt'))).toBe(true)
})
it('renames a directory (recursive content moves with it)', async () => {
mkdirSync(join(root, 'olddir/nested'), { recursive: true })
writeFileSync(join(root, 'olddir/nested/deep.txt'), 'x')
await renameWorkspaceEntry({ cwd: root, path: join(root, 'olddir'), name: 'newdir' })
expect(readFileSync(join(root, 'newdir/nested/deep.txt'), 'utf8')).toBe('x')
})
it('is a no-op for the same name (destination-exists refusal must not bite)', async () => {
writeFileSync(join(root, 'same.txt'), 'x')
await renameWorkspaceEntry({ cwd: root, path: join(root, 'same.txt'), name: 'same.txt' })
expect(existsSync(join(root, 'same.txt'))).toBe(true)
})
it('refuses single-segment violations (empty, dot, traversal, separators)', async () => {
writeFileSync(join(root, 'r.txt'), 'x')
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'r.txt'), name }))
.rejects.toMatchObject({ code: 'bad-request' })
}
})
it('refuses an existing destination instead of clobbering it', async () => {
writeFileSync(join(root, 'src.txt'), 'src')
writeFileSync(join(root, 'dst.txt'), 'dst')
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'src.txt'), name: 'dst.txt' }))
.rejects.toMatchObject({ code: 'fs-error', status: 409 })
expect(readFileSync(join(root, 'dst.txt'), 'utf8')).toBe('dst')
})
it('refuses the workspace root and missing sources', async () => {
await expect(renameWorkspaceEntry({ cwd: root, path: root, name: 'nope' }))
.rejects.toMatchObject({ code: 'fs-error' })
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'missing.txt'), name: 'x' }))
.rejects.toMatchObject({ code: 'fs-error' })
})
it('renames a symlink ROW, not its target', async () => {
writeFileSync(join(root, 'target.txt'), 't')
symlinkSync(join(root, 'target.txt'), join(root, 'alias.txt'))
await renameWorkspaceEntry({ cwd: root, path: join(root, 'alias.txt'), name: 'alias2.txt' })
expect(existsSync(join(root, 'alias.txt'))).toBe(false)
expect(existsSync(join(root, 'alias2.txt'))).toBe(true)
expect(readFileSync(join(root, 'target.txt'), 'utf8')).toBe('t')
})
})
describe('removeWorkspaceEntry', () => {
it('unlinks a file', async () => {
writeFileSync(join(root, 'gone.txt'), 'x')
await removeWorkspaceEntry({ cwd: root, path: join(root, 'gone.txt') })
expect(existsSync(join(root, 'gone.txt'))).toBe(false)
})
it('removes a directory recursively', async () => {
mkdirSync(join(root, 'tree/sub'), { recursive: true })
writeFileSync(join(root, 'tree/sub/leaf.txt'), 'x')
await removeWorkspaceEntry({ cwd: root, path: join(root, 'tree') })
expect(existsSync(join(root, 'tree'))).toBe(false)
})
it('unlinks a symlink row without touching (or recursing into) its target', async () => {
mkdirSync(join(root, 'realdir'), { recursive: true })
writeFileSync(join(root, 'realdir/keep.txt'), 'x')
symlinkSync(join(root, 'realdir'), join(root, 'linkdir'))
await removeWorkspaceEntry({ cwd: root, path: join(root, 'linkdir') })
expect(existsSync(join(root, 'linkdir'))).toBe(false)
expect(readFileSync(join(root, 'realdir/keep.txt'), 'utf8')).toBe('x')
})
it('refuses the workspace root and missing paths', async () => {
await expect(removeWorkspaceEntry({ cwd: root, path: root }))
.rejects.toMatchObject({ code: 'fs-error' })
await expect(removeWorkspaceEntry({ cwd: root, path: join(root, 'no-such.txt') }))
.rejects.toMatchObject({ code: 'fs-error' })
})
})
+48
View File
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
await unmount(root)
})
})
describe('MarkdownDocument (local markdown images)', () => {
it('renders markdown-syntax local images through the /sidebar/file route', async () => {
const { container, root } = await renderDocument([
'# Title',
'',
'![icon](./assets/icon.svg)',
'',
'![logo][logo]',
'',
'[logo]: ./logo.png',
].join('\n'))
const imgs = [...container.querySelectorAll('img')]
expect(imgs.length, 'both local images must render as <img>, not alt fallback').toBe(2)
expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws')
expect(imgs[0]?.getAttribute('alt')).toBe('icon')
expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws')
expect(imgs[1]?.getAttribute('alt')).toBe('logo')
await unmount(root)
})
it('renders local markdown images in HTML-mixed documents (the production path)', async () => {
const { container, root } = await renderDocument([
'<div align="center">badges</div>',
'',
'![shot](./img.png)',
].join('\n'))
const img = container.querySelector('img')
expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull()
expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws')
await unmount(root)
})
it('keeps remote image destinations untouched', async () => {
const { container, root } = await renderDocument('![r](https://example.com/x.png)')
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('https://example.com/x.png')
await unmount(root)
})
it('is idempotent for already-rewritten media URLs', async () => {
const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws'
const { container, root } = await renderDocument(`![a](${url})`)
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe(url)
await unmount(root)
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* The context-menu submenu flip geometry: the pure token computation behind
* the body attribute layout.css consumes (see src/client/menu-flip.ts for
* the contract) plus the attribute lifecycle of the hook itself.
*/
// @vitest-environment jsdom
import { createElement, useState } from 'react'
import { readFileSync } from 'node:fs'
import { act } from 'react-dom/test-utils'
import { describe, expect, it } from 'vitest'
import { renderRoot, setupReactAct } from './test-utils.ts'
import { SUBMENU_FLIP_ATTR, submenuFlipTokens, useSubmenuFlip } from '../src/client/menu-flip.ts'
setupReactAct()
describe('layout.css contract', () => {
it('consumes the exact attribute name menu-flip.ts publishes', () => {
// The two halves of the flip mechanism can drift silently: renaming the
// attribute here would leave layout.css matching nothing (submenus revert
// to overflowing) with every test still green. Tie them together. Specs
// run from the repo root (chunk-artifact.spec.ts reads lib/ the same way).
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="down"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`)
})
it('keeps the submenu visual tether rules (parent highlight + tight gap)', () => {
// Part 1 keeps the parent row lit while its submenu is open (the Menu
// marks it aria-expanded); part 2 tightens the card gap to 4px with the
// hover corridor narrowed to match. Losing either sends the submenu back
// to reading as an unrelated floating card.
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}] div[role="menu"] button[aria-expanded="true"]`)
expect(css).toContain('left: calc(100% + 4px)')
expect(css).toContain('right: calc(100% + 4px)')
expect(css).toContain('width: 4px')
})
})
describe('submenuFlipTokens', () => {
// Default geometry (1024x768): the panel is docked right, so the
// interesting split is the right-hand 400px strip and the vertical half.
it('flips down for a cursor in the upper half, keeps primitive defaults below', () => {
expect(submenuFlipTokens(500, 20, 1024, 768)).toBe('down')
expect(submenuFlipTokens(500, 700, 1024, 768)).toBe('')
})
it('flips left when the right-hand room cannot fit card + submenu', () => {
// 700 + 400 > 1024 → left; the cursor is low, so no down token.
expect(submenuFlipTokens(700, 700, 1024, 768)).toBe('left')
// Both tokens compose for a top-right opening.
expect(submenuFlipTokens(700, 20, 1024, 768)).toBe('down left')
})
it('uses the exact boundary inclusively (midpoint and width-minus-room still flip)', () => {
expect(submenuFlipTokens(500, 384, 1024, 768)).toBe('')
expect(submenuFlipTokens(500, 383, 1024, 768)).toBe('down')
expect(submenuFlipTokens(624, 700, 1024, 768)).toBe('')
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
})
it('flips left throughout a narrow panel viewport (PANEL_MIN 280)', () => {
// A 600px window: any cursor past x=200 flips; the left strip cannot
// fit a right-growing submenu beside the 218px card either way.
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
expect(submenuFlipTokens(500, 700, 600, 800)).toBe('left')
})
})
describe('useSubmenuFlip', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** Mount a probe whose menu state the spec drives via the returned setter. */
function mountFlipProbe(initial: { x: number; y: number } | null): { setMenu: (next: { x: number; y: number } | null) => void, unmount: () => void } {
let applyMenu: (next: { x: number; y: number } | null) => void = () => {}
function Probe(): React.ReactElement {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(initial)
applyMenu = setMenu
useSubmenuFlip(menu)
return <div />
}
const rendered = renderRoot(createElement(Probe))
return {
setMenu: (next) => { act(() => { applyMenu(next) }) },
unmount: rendered.unmount,
}
}
it('publishes tokens while the menu is open and clears on close', () => {
const probe = mountFlipProbe({ x: 700, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down left')
probe.setMenu(null)
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
probe.unmount()
}
})
it('tracks the position across consecutive openings', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down')
// Still open, now lower-left: the attribute stays (menu lifetime) but
// carries no token, so the primitive's own geometry applies.
probe.setMenu({ x: 100, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('')
probe.setMenu({ x: 700, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('left')
} finally {
probe.unmount()
}
})
it('clears the attribute when the owner unmounts with the menu still open', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
expect(document.body.getAttribute(ATTR)).toBe('down')
probe.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+16
View File
@@ -113,6 +113,7 @@ describe('sidechat.start', () => {
sessionId: string
meta: Record<string, unknown>
seed: readonly { type: string; data: Record<string, unknown> }[]
inheritedEventCount: number
agentOptions: { provider: string; model: string }
setup: unknown
}
@@ -120,10 +121,15 @@ describe('sidechat.start', () => {
expect(options.meta).toMatchObject({
parentSession: 'parent',
origin: 'subagent',
isSeeded: true,
delegationDepth: 1,
agentPreset: 'preset-a',
cwd: '/p',
})
// The fork-marker pair is load-bearing: without it the child's Inbox
// replays the parent's seed inbox splices and inherits whatever input sat
// unclaimed in the parent (see sidechat-seed-validation.spec.ts).
expect(options.inheritedEventCount).toBe(options.seed.length)
expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' })
// The child carries the parent's completed turns as a verbatim seed,
// closed by the durable subagent descriptor (honest catalog citizenship:
@@ -221,6 +227,16 @@ describe('sidechat.start', () => {
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
// The empty thread carries the same fork-marker pair (isSeeded +
// inheritedEventCount = seed length) as the prompted create.
const options = services.create.mock.calls[0]![0] as {
meta: Record<string, unknown>
seed: readonly { type: string }[]
inheritedEventCount: number
}
expect(options.meta.isSeeded).toBe(true)
expect(options.inheritedEventCount).toBe(options.seed.length)
// No prompt yet: the composer owns the first message; the placeholder
// label is pinned and the in-progress snapshot is parked for it.
expect(child.inject).not.toHaveBeenCalled()
+114 -1
View File
@@ -10,8 +10,9 @@
* not a mock, so the class of bug cannot silently return.
*/
import { describe, expect, it } from 'vitest'
import { Session } from '@deepseek-ai/dsh-session'
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { Inbox } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SidebarSessionEvent } from '../src/context-types.ts'
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
@@ -111,3 +112,115 @@ describe('sidechat seed against the real dsh-session validator', () => {
expect(types.at(-1)).toBe('session/end-seed')
})
})
describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () => {
/**
* Regression: the thread-create call originally passed `seed` WITHOUT the
* fork-marker pair (`meta.isSeeded: true` + `inheritedEventCount`). A seed
* without them is REPLAY history, not an inherited prefix, so the child's
* ownEvents() includes the whole seed and its Inbox constructor replays the
* parent's `agent/inbox/spliced` events — inheriting whatever input sat
* UNCLAIMED in the parent at the click moment (a queued follow-up, or a
* tool-result context spliced into next-step between step boundaries of a
* long-running turn). The first side prompt then claimed and SENT that stale
* message before the boundary + question. These tests run the REAL
* dsh-session Session and dsh-agent Inbox.
*/
const pendingMessage = (id: string, kind: string, text: string) => ({
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind },
})
const inboxOver = (session: Session): { nextTurn: number; nextStep: number } => {
const inbox = new Inbox(session, { inserted() {}, discarded() {}, claimed() {} })
return { nextTurn: inbox.nextTurn.length, nextStep: inbox.nextStep.length }
}
/** The shape the routes pass since the fix: isSeeded + inheritedEventCount. */
const forkMarkedSession = (id: string, seed: readonly SidebarSessionEvent[]): Session =>
Session.create(
id as SessionId,
seed as never,
{ version: 0, id: id as SessionId, createdAt: Date.now(), isSeeded: true },
SessionLogOffset(seed.length),
)
/** Real loop order: inbox insert → turn/start → claim deletion → step/start
* → user/message. One completed, fully claimed turn (seq 0-7). */
const completedTurnLog = (): SidebarSessionEvent[] => [
ev('agent/inbox/spliced', 0, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q', 'user', 'q')] }),
ev('turn/start', 1, { turn: 1 }),
ev('agent/inbox/spliced', 2, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
ev('step/start', 3, { turn: 1, step: 1 }),
ev('user/message', 4, pendingMessage('m-q', 'user', 'q')),
ev('assistant/message', 5, { turn: 1, step: 1, message: assistantMessage('a') }),
ev('step/end', 6, { turn: 1, step: 1 }),
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
]
/** Mid-turn with an UNCLAIMED tool-result context in next-step (seq 15). */
const midTurnLog = (): SidebarSessionEvent[] => [
...completedTurnLog(),
ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q2', 'user', 'q2')] }),
ev('turn/start', 9, { turn: 2 }),
ev('agent/inbox/spliced', 10, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
ev('step/start', 11, { turn: 2, step: 1 }),
ev('user/message', 12, pendingMessage('m-q2', 'user', 'q2')),
ev('tool/call', 13, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }),
ev('tool/result', 14, {
turn: 2, step: 1,
message: {
id: 'm-r',
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
source: { kind: 'tool', callId: 'c1' },
},
}),
ev('agent/inbox/spliced', 15, {
target: 'next-step',
start: 0,
inserted: [pendingMessage('m-ctx', 'tool', 'tool bash finished: exit 0 ... now continue the plan')],
}),
]
it('inherits the parent\'s UNCLAIMED next-step tool context without the fork markers (root cause)', () => {
// Parent mid-turn (user queue empty): the bash tool returned and spliced
// its follow-up context into next-step; the next step boundary has not
// claimed it yet. The reported long-context scenario.
const { seed } = buildSidechatInheritance(midTurnLog())
// OLD call shape (seed only): the whole seed counts as own events and the
// phantom context is inherited as pending input.
const unmarked = Session.create('session-inbox-unmarked' as SessionId, seed as never)
expect(unmarked.ownEvents().length).toBe(seed.length + 1)
expect(inboxOver(unmarked)).toEqual({ nextTurn: 0, nextStep: 1 })
})
it('inherits the parent\'s QUEUED follow-up (next-turn) without the fork markers', () => {
const queued = pendingMessage('m-queued', 'user', 'queued second message')
const log: SidebarSessionEvent[] = [
...completedTurnLog(),
ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [queued] }),
]
const { seed } = buildSidechatInheritance(log)
const unmarked = Session.create('session-inbox-unmarked-turn' as SessionId, seed as never)
expect(inboxOver(unmarked)).toEqual({ nextTurn: 1, nextStep: 0 })
})
it('the fork-marker pair (isSeeded + inheritedEventCount) leaves the inherited inbox empty', () => {
// Same mid-turn log as the root-cause case, plus the descriptor the
// routes append — exactly what sidechat.start passes to agents.create.
const { seed } = buildSidechatInheritance(midTurnLog())
const withDescriptor = [
...seed,
{
type: 'subagent/descriptor',
seq: seed.length,
time: Date.now(),
data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }),
} as unknown as SidebarSessionEvent,
]
const marked = forkMarkedSession('session-inbox-marked', withDescriptor)
// Only the end-seed marker is own; the transcript cut is unchanged.
expect(marked.ownEvents().map(event => event.type)).toEqual(['session/end-seed'])
expect(marked.snapshotEvents().map(event => event.type).at(-1)).toBe('session/end-seed')
expect(inboxOver(marked)).toEqual({ nextTurn: 0, nextStep: 0 })
})
})
+58
View File
@@ -309,3 +309,61 @@ describe('TabBar pin submenu (v0.17.0)', () => {
}
})
})
describe('TabBar context menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** jsdom's default viewport (what the hook reads via window.innerWidth/Height). */
const VW = window.innerWidth
const VH = window.innerHeight
it('publishes "down" while the menu is open and clears it on selection', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
// rightClick() dispatches at clientY 40 — the top strip, upper half.
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
act(() => { menuItems()[1]!.click() })
expect(onClose).toHaveBeenCalledWith('t2')
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
unmount()
}
})
it('flips left when the cursor sits within the right-hand submenu room', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: VW - 10, clientY: 40 })
act(() => { tabEls[1]!.dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
} finally {
unmount()
}
})
it('keeps the primitive defaults (empty tokens) for a lower-half opening', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const lowLeft = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: VH - 40 })
act(() => { tabEls[1]!.dispatchEvent(lowLeft) })
// Attribute present but empty: layout.css matches no token, so the
// host primitive's own up/right geometry applies.
expect(document.body.getAttribute(ATTR)).toBe('')
} finally {
unmount()
}
})
it('clears the attribute on unmount with the menu still open', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})