diff --git a/AGENTS.md b/AGENTS.md index 7f7a89f..0f8656b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 接缝补齐标记即为规范用法。 --- diff --git a/README.md b/README.md index 9020af4..a58ed23 100644 --- a/README.md +++ b/README.md @@ -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 补齐) +
+历史版本(v0.12.0 – v0.15.2) + ### 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` 属非法形状) -
-历史版本(v0.12.0 – v0.15.2) - ### v0.15.2 自 v0.15.1 以来的全部更改: @@ -491,7 +485,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar 推荐添加QQ群(577011007)
- 微信群二维码 + 微信群二维码 QQ群二维码
diff --git a/docs/plans/2026-08-20-sidechat-tab-design.md b/docs/plans/2026-08-20-sidechat-tab-design.md index 26461ad..18bd9d3 100644 --- a/docs/plans/2026-08-20-sidechat-tab-design.md +++ b/docs/plans/2026-08-20-sidechat-tab-design.md @@ -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 标记即为规范用法。 diff --git a/docs/plans/2026-08-22-open-with-menu-design.md b/docs/plans/2026-08-22-open-with-menu-design.md index 7666581..4d560c4 100644 --- a/docs/plans/2026-08-22-open-with-menu-design.md +++ b/docs/plans/2026-08-22-open-with-menu-design.md @@ -89,6 +89,8 @@ Cursor (SSH) **实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `://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`——菜单打开期间在 `` 发布 `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`。 diff --git a/docs/plans/2026-09-04-file-tree-rename-delete-design.md b/docs/plans/2026-09-04-file-tree-rename-delete-design.md new file mode 100644 index 0000000..58c1ac7 --- /dev/null +++ b/docs/plans/2026-09-04-file-tree-rename-delete-design.md @@ -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 的非法结构)。裸 ``(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 两处 `` 接线。 + +## 二级菜单视觉关系(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 刷新机制管)。 diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 4411f58..d09b281 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -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} /> ) @@ -511,6 +524,8 @@ export function EditorHost(props: { onOpenWith={openWith} onToggleOpenWithPin={toggleOpenWithPin} onReferenceFile={onReferenceFile} + onPathRenamed={onPathRenamed} + onPathDeleted={onPathDeleted} /> )} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 9f0a7dc..d080d18 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -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 `@` 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>({}) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ 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(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 - if (target.id === 'vscode') return - if (target.id === 'cursor') return - if (target.id === 'zed') return - return + if (target.kind === 'reveal') return + if (target.id === 'vscode') return + if (target.id === 'cursor') return + if (target.id === 'zed') return + return } const pinned = openWithTargets .filter(target => pinnedIds.includes(target.id)) @@ -412,7 +485,7 @@ export function FileTree(props: { onToggleOpenWithPin?.(target.id) }} > - {pinnedNow ? : } + {pinnedNow ? : } ), @@ -433,7 +506,7 @@ export function FileTree(props: { ), - icon: , + icon: , 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 , 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 => ( +
+ {entry.isDir + ? (expandedSet.has(entry.path) ? : ) + : } + { + 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 ?? '') }} + /> +
+ ) + 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: {
{t('noSession')}
) : ( <> + {/* 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 && ( +
+ {actionError} + +
+ )}
}] + ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] : []), ...(rowMenu?.isDir === false && onOpenFileSide !== undefined - ? [{ id: 'open-side', label: t('openFileSide'), icon: }] + ? [{ id: 'open-side', label: t('openFileSide'), icon: }] : []), ...openWithEntries(), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false - ? [{ id: 'download', label: t('download'), icon: }] + ? [{ id: 'download', label: t('download'), icon: }] : []), // Upload into a directory (incl. the workspace root row). ...(rowMenu?.isDir === true - ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] + ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] + : []), + { id: 'relative', label: t('copyRelative'), icon: }, + { id: 'absolute', label: t('copyAbsolute'), icon: }, + // 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: }, + { id: 'delete', label: t('delete'), icon: , danger: true }, + ] : []), - { id: 'relative', label: t('copyRelative'), icon: }, - { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} 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={} /> + + {/* 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. */} + { setConfirmDelete(null) }} + title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })} + closeLabel={t('cancel')} + footer={( + <> + + + + )} + > +

+ {confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')} +

+
) } diff --git a/src/client/FreeWindow.tsx b/src/client/FreeWindow.tsx index 6b37a6d..6ded906 100644 --- a/src/client/FreeWindow.tsx +++ b/src/client/FreeWindow.tsx @@ -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={} diff --git a/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx index eff96bd..6d3852a 100644 --- a/src/client/MarkdownHtml.tsx +++ b/src/client/MarkdownHtml.tsx @@ -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(() => 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, diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 586031b..bbffc13 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -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(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={(