feat(sidebar): 菜单层级/性能 + 选中条吸底 + 打包进度 + 修复「在侧边打开」
用户实测反馈 5 条:
1) 文件列表性能 / 右键菜单不再刷屏(先测量后改,探针 tests/file-tree-menu-perf.spec.tsx)
- 宿主应用列表按路径缓存:N 次打开菜单只请求 1 次(原每次 1 次 + Loading 帧)
- 行上的 git 数据改为原始值(tone/letter):共享 store 每次轮询重建对象不再触达行
(探针:开窗 folderIcons 4 → 0;root 行也 memo 化)
- 菜单开关不触发目录重列(fsTree=0);实测「菜单打开期间暂停轮询」会让变更行瞬时
掉色并多两次重渲染,故按测量结果放弃该做法(数字与理由写进探针注释)
2) 「已选择 N 项」条移到底部:滚动容器最后一个内容子节点 + sticky bottom(不遮最后
一行),窄栏 flex-wrap 换行不出横向滚动条
3) 菜单层级重构:一级只留默认动作(宿主默认应用 / 打开方式 ▸ / 在文件管理器中显示 /
新 Tab / 在侧边打开 / 下载 / 压缩并下载 / 复制路径 / 重命名 / 删除;目录行另有
新建文件夹、上传到此处),应用列表整体收进「打开方式」子菜单;宿主探测到本机关联
应用时默认不显示插件自研 open-with 项目,新增设置开关 openWithPluginTargets
(editor descriptor 的 pluginToggles)可强制并存;宿主不可用时插件项目照旧
4) 压缩并下载显示进度:宿主改为可观测任务(archive.build / archive.status /
/sidebar/archive?id=,4 并发 + 5 分钟 TTL + sessionId 绑定,buildZip 逐条目
onProgress),客户端轮询 250ms 显示「正在打包 done/total · N%」,ready 后 blob 下载,
失败进 zipFailed
5) 修复「在侧边打开没有效果」:根因是它只写插件底部工作台(原生右侧栏的 tab 不在
bottomSplits 里),现按承载面分流 —— 原生 tab 走 service.openTab(target:'side') →
宿主 openResource(preferNewPane, revealIfOpened:false) 真正分栏;底部工作台行为不变
(接入 API 变更已同步 docs/external-plugin-guide.md)
验证:typecheck / lint / test(135 文件 1461 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
新增/更新测试:menu-perf(3)、archive(10)、open-in-app(18)、open-with(10)、multiselect(9)、
zip(37)、service(64)、editor-host(11)、builtins(23)、locales(20)
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、会话内持久化;**其中不再有终端**——宿主 0.1.6 的右侧栏终端取代了它)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`:
|
||||
|
||||
- `registerTab` / `registerFileViewer` 签名不变;
|
||||
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台);
|
||||
- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台 / `'side'` 落**原生栏的第二个格**,见下表「在侧边打开」);
|
||||
- `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。
|
||||
|
||||
行为差异(写在这里以免踩坑):
|
||||
@@ -30,6 +30,7 @@
|
||||
| path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) |
|
||||
| 终端(已交还宿主) | 插件**不再提供任何终端**:宿主 0.1.6 起自带 `ui-sidebar-terminal`(kind `terminal`),插件侧 PTY 栈与 `terminal_*` 工具整体删除。这里不再有「插件终端数量上限」这类语义 |
|
||||
| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 |
|
||||
| 在侧边打开(`target: 'side'`,v0.22.0+) | 原生栏里的 tab **不在插件底部工作台的分栏树里**,所以「在侧边打开」不能写 `bottomSplits`(那样会落到用户没展开的底部工作台 = 点了没反应)。服务改为把这一步交给宿主:带 path 的 `editor` seed 走 `openResource(address, { preferNewPane: true, revealIfOpened: false })`——宿主先按自己的两格上限与空间规则尝试分栏,分不了才回退到当前格;`revealIfOpened: false` 允许与已打开的同名资源并存,因此对同一个文件再点一次也会新开一格。其余类型若传 `target: 'side'`,同样以 `preferNewPane` 落原生栏(组件型 tab 的 path 仍是组件种子)。path-less 的 `editor`(文件页)与 `'bottom'` 行为不变 |
|
||||
| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**本插件默认贡献 4 个 guide 条目**(文件 / 文件变动 / 任务管理 / 侧边对话,恰好在上限内),**但宿主的终端条目也占一行**——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 4 条 |
|
||||
| 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 |
|
||||
| alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 |
|
||||
@@ -591,7 +592,31 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
|
||||
| `pty.close` / `agent-pty.close` | **已删除**(插件自带的 PTY 栈随终端一起移除;宿主 `ui-sidebar-terminal` 不通过本插件的路由暴露控制面) |
|
||||
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 `settings-conflict`);后端在 0.1.7 上就是宿主的 `SettingsForms`,见 §8.2 |
|
||||
|
||||
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
|
||||
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
|
||||
|
||||
### 6.1 打包下载(`archive.build` / `archive.status` / `/sidebar/archive`,v0.22.0+)
|
||||
|
||||
把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(同一个 workspace fence,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。
|
||||
|
||||
```ts
|
||||
// 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形)
|
||||
const build = await fetch('/sidebar/api/archive.build', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, cwd, paths: ['/w/src', '/w/notes.md'], name: '报告.zip' }),
|
||||
}).then(r => r.json())
|
||||
// → { ok: true, value: { id: 'ar-…', entries: 5 } }
|
||||
|
||||
// 2) 轮询:state 为 building | ready | error;done/total 是条目进度,bytes 是已读未压缩字节
|
||||
const status = await fetch('/sidebar/api/archive.status', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: build.value.id, sessionId }),
|
||||
}).then(r => r.json())
|
||||
|
||||
// 3) 下载(仅 ready 时):同一 id 只能取一次,取完即释放
|
||||
const url = `/sidebar/archive?${new URLSearchParams({ sessionId, id: build.value.id })}`
|
||||
```
|
||||
|
||||
约束与状态码:任务表最多 **4 个并发构建**(超出时 `archive.build` 回 `bad-request`,HTTP 409),完成/失败后保留 **5 分钟**(过期即消失);`id` 只对**创建它的 session** 可用(其他 session 读是 `forbidden` 403);`/sidebar/archive` 在构建中回 **409**、构建失败回 **410**(消息即失败原因)、未知/过期/已下载回 **404**。响应头为 `content-type: application/zip` + `content-disposition: attachment; filename="<ASCII 回退>"; filename*=UTF-8''<百分号编码>`(非 latin1 文件名走 RFC 5987,ASCII 档位对旧客户端生效)。上限沿用 `src/zip.ts` 的 `ZIP_MAX_ENTRIES = 10_000` 与 `ZIP_MAX_BYTES = 256 MiB`(未压缩总量)。
|
||||
|
||||
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
|
||||
|
||||
@@ -711,6 +736,13 @@ interface OpenTabSeed {
|
||||
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
|
||||
* undefined = 不改,null = 显式清除 */
|
||||
meta?: unknown
|
||||
/** 落点。省略 / 'right' = 原生右侧栏当前停靠格(默认);'bottom' = 插件的
|
||||
* 底部工作台;**'side' = 原生右侧栏的第二个格**——原生承载面提供
|
||||
* `openResource(address, { preferNewPane: true, revealIfOpened: false })`:
|
||||
* 先按宿主的两格上限与空间规则尝试分栏,分不了才回退到当前格;允许与
|
||||
* 已打开的同名资源并存,所以「在侧边打开同一个文件」真的会新开一格。
|
||||
* path-less 的 editor seed 是文件页(files),'side' 只影响带 path 的打开。*/
|
||||
target?: 'right' | 'bottom' | 'side'
|
||||
}
|
||||
|
||||
/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */
|
||||
@@ -969,7 +1001,7 @@ interface SettingsDescriptor {
|
||||
|
||||
- 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在);
|
||||
- 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败;
|
||||
- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
|
||||
- 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点;同一文件再开一格用 `target: 'side'`)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user