From ee1fe05067585aab3bff235141b7147c881d4e5d Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 28 Sep 2026 20:11:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20=E8=8F=9C=E5=8D=95=E5=B1=82?= =?UTF-8?q?=E7=BA=A7/=E6=80=A7=E8=83=BD=20+=20=E9=80=89=E4=B8=AD=E6=9D=A1?= =?UTF-8?q?=E5=90=B8=E5=BA=95=20+=20=E6=89=93=E5=8C=85=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=20+=20=E4=BF=AE=E5=A4=8D=E3=80=8C=E5=9C=A8=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=89=93=E5=BC=80=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测反馈 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) --- docs/external-plugin-guide.md | 38 +- src/archive-route.ts | 202 ++++++++++- src/client/EditorHost.tsx | 24 ++ src/client/FileTree.tsx | 519 +++++++++++++++++---------- src/client/TreePanel.tsx | 6 + src/client/api.ts | 45 ++- src/client/builtins/tabs.tsx | 8 + src/client/locales-ar.ts | 3 + src/client/locales-de.ts | 3 + src/client/locales-fr.ts | 3 + src/client/locales-hi.ts | 3 + src/client/locales-id.ts | 3 + src/client/locales-it.ts | 3 + src/client/locales-ja.ts | 3 + src/client/locales-ko.ts | 3 + src/client/locales-nl.ts | 3 + src/client/locales-pl.ts | 3 + src/client/locales-pt.ts | 3 + src/client/locales-ru.ts | 3 + src/client/locales-sv.ts | 3 + src/client/locales-th.ts | 3 + src/client/locales-tr.ts | 3 + src/client/locales-vi.ts | 3 + src/client/locales-zh-HK.ts | 3 + src/client/locales-zh-MO.ts | 3 + src/client/locales-zh-TW.ts | 3 + src/client/locales.ts | 6 + src/client/native/surface.ts | 27 +- src/client/service.ts | 24 +- src/client/sidebar.module.css | 39 +- src/index.ts | 83 +++-- src/zip.ts | 24 +- tests/builtins.spec.ts | 8 + tests/editor-host.spec.tsx | 224 +++++++++++- tests/file-tree-archive.spec.tsx | 337 +++++++++-------- tests/file-tree-menu-perf.spec.tsx | 304 ++++++++++++++++ tests/file-tree-multiselect.spec.tsx | 30 ++ tests/file-tree-open-in-app.spec.tsx | 242 ++++++++----- tests/file-tree-open-with.spec.tsx | 30 +- tests/service.spec.ts | 60 ++++ tests/zip.spec.ts | 488 +++++++++++++++++++++---- 41 files changed, 2228 insertions(+), 597 deletions(-) create mode 100644 tests/file-tree-menu-perf.spec.tsx diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 188b92a..6fa24ee 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -13,7 +13,7 @@ 从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、会话内持久化;**其中不再有终端**——宿主 0.1.6 的右侧栏终端取代了它)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`: - `registerTab` / `registerFileViewer` 签名不变; -- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台); +- `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台 / `'side'` 落**原生栏的第二个格**,见下表「在侧边打开」); - `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效)。 行为差异(写在这里以免踩坑): @@ -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=""; 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'`)。 --- diff --git a/src/archive-route.ts b/src/archive-route.ts index 5edd5c0..8cfe81e 100644 --- a/src/archive-route.ts +++ b/src/archive-route.ts @@ -10,10 +10,11 @@ * `ensureWorkspacePath` first), and a symlink is skipped rather than followed, * so it can neither escape the workspace nor cycle. */ +import { randomUUID } from 'node:crypto' import { lstat, readdir } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { SidebarError } from './wire.ts' -import { ZIP_MAX_ENTRIES, type ZipEntry } from './zip.ts' +import { buildZip, ZIP_MAX_ENTRIES, type ZipEntry } from './zip.ts' /** Maximum archive-name length (keeps the Content-Disposition header sane). */ export const ARCHIVE_NAME_MAX = 120 @@ -183,3 +184,202 @@ export async function collectZipEntries( } } } + +/** How many archives one host may build at the same time. */ +export const ARCHIVE_MAX_BUILDING = 4 +/** How long a finished (or failed) archive stays downloadable. */ +export const ARCHIVE_TTL_MS = 5 * 60 * 1000 + +/** One archive build: the state the client polls, plus the finished bytes. */ +interface ArchiveTask { + id: string + sessionId: string + name: string + state: 'building' | 'ready' | 'error' + done: number + total: number + bytes: number + error?: string + zip?: Buffer + /** When the task stops being downloadable (set when it settles). */ + expiresAt: number +} + +/** The status shape the client reads (never carries the bytes). */ +export interface ArchiveStatus { + state: 'building' | 'ready' | 'error' + done: number + total: number + bytes: number + error?: string +} + +/** Why a download/status read could not be served. */ +export type ArchiveLookup = + | { ok: true; task: ArchiveTask } + | { ok: false; reason: 'missing' | 'forbidden' | 'building' | 'expired' | 'error'; message: string } + +/** A resolved download: the bytes plus the sanitized name they were built for. */ +export interface ArchiveDownload { + name: string + zip: Buffer +} + +/** The response face {@link respondArchiveDownload} writes through. */ +export interface ArchiveDownloadResponse { + writeHead(status: number, headers?: Record): void + end(chunk?: Buffer | string): void +} + +/** + * Answer one `GET /sidebar/archive?id=&sessionId=` from the task table. + * + * Split out of index.ts because this mapping IS the contract: 400 without + * both parameters, 403 for another session's task, 409 while it builds, 410 + * for a failed build, 404 for unknown/expired/already-downloaded, 200 with the + * RFC 5987 disposition otherwise. Kept here so all six branches are testable + * without a live cordis mount. + * @param tasks - the plugin's task table. + * @param query - the request's `sessionId` / `id` values (null = absent). + * @param res - a minimal response face. + * @throws {SidebarError} for every non-200 branch (callers turn it into the + * JSON error envelope they already use). + */ +export function respondArchiveDownload( + tasks: ArchiveTasks, + query: { sessionId: string | null; id: string | null }, + res: ArchiveDownloadResponse, +): void { + if (query.sessionId === null || query.id === null || query.id === '') { + throw new SidebarError('bad-request', 'sessionId and id are required') + } + const found = tasks.lookup(query.id, query.sessionId) + if (!found.ok || found.task.state !== 'ready' || found.task.zip === undefined) { + if (found.ok) throw new SidebarError('not-found', 'archive is gone', 404) + if (found.reason === 'building') throw new SidebarError('bad-request', found.message, 409) + if (found.reason === 'forbidden') throw new SidebarError('forbidden', found.message, 403) + if (found.reason === 'error') throw new SidebarError('fs-error', found.message, 410) + throw new SidebarError('not-found', found.message, 404) + } + const { name, zip } = found.task + // A download consumes the task: the bytes are not kept for a second fetch + // (the task table is a queue, not a store). + tasks.release(found.task.id) + res.writeHead(200, { + 'content-type': 'application/zip', + // Never the raw name: a non-latin1 value (中文目录 → 报告.zip) makes Node's + // writeHead throw and turns the download into a 500. + 'content-disposition': contentDispositionOf(name), + 'content-length': String(zip.byteLength), + 'cache-control': 'no-cache', + }) + res.end(zip) +} + +/** + * The host-side archive task table. + * + * A selection is collected up front (cheap: names + sizes) and then zipped in + * the BACKGROUND, so the client can show progress instead of staring at a + * spinner: `start` returns an id immediately, `status` reports + * `building → ready | error`, and `download` hands over the bytes exactly once. + * The table is bounded twice — {@link ARCHIVE_MAX_BUILDING} concurrent builds + * and {@link ARCHIVE_TTL_MS} since a finished one — and every read is + * session-scoped, so one session can never fetch another's archive. + */ +export interface ArchiveTasks { + /** Start a build; rejects with bad-request when the concurrency cap is hit. */ + start(input: { sessionId: string; name: string; entries: readonly ZipEntry[] }): { id: string; entries: number } + /** Poll one task (a settled task past its TTL reads as missing). */ + status(id: string, sessionId: string): ArchiveStatus + /** Look one task up for a download; `sessionId` must match the builder's. */ + lookup(id: string, sessionId: string): ArchiveLookup + /** Release a task (called after its bytes were served). */ + release(id: string): void + /** Number of live tasks (tests / diagnostics). */ + size(): number +} + +/** Create one archive task table (one per plugin mount). */ +export function createArchiveTasks(): ArchiveTasks { + const tasks = new Map() + + /** Drop settled tasks past their TTL (called on every access). */ + const sweep = (now: number): void => { + for (const [id, task] of tasks) { + if (task.state !== 'building' && task.expiresAt <= now) tasks.delete(id) + } + } + + /** The task one (id, sessionId) pair names, or why it cannot be read. */ + const lookup = (id: string, sessionId: string): ArchiveLookup => { + sweep(Date.now()) + const task = tasks.get(id) + if (task === undefined) return { ok: false, reason: 'missing', message: 'unknown or expired archive' } + if (task.sessionId !== sessionId) return { ok: false, reason: 'forbidden', message: 'archive belongs to another session' } + if (task.state === 'building') return { ok: false, reason: 'building', message: 'archive is still being built' } + if (task.state === 'error') return { ok: false, reason: 'error', message: task.error ?? 'archive failed' } + return { ok: true, task } + } + + return { + start({ sessionId, name, entries }) { + sweep(Date.now()) + let building = 0 + for (const task of tasks.values()) if (task.state === 'building') building += 1 + if (building >= ARCHIVE_MAX_BUILDING) { + throw new SidebarError('bad-request', `too many archives are being built (max ${ARCHIVE_MAX_BUILDING})`, 409) + } + const task: ArchiveTask = { + id: `ar-${randomUUID()}`, + sessionId, + name, + state: 'building', + done: 0, + total: entries.length, + bytes: 0, + expiresAt: Number.POSITIVE_INFINITY, + } + tasks.set(task.id, task) + // Detached on purpose: the POST answers as soon as the id exists, and the + // task table (not the request) owns the error from here on. + void buildZip([...entries], { + onProgress: (progress) => { + task.done = progress.done + task.bytes = progress.bytes + }, + }).then((zip) => { + task.zip = zip + task.state = 'ready' + task.done = task.total + task.expiresAt = Date.now() + ARCHIVE_TTL_MS + }, (error: unknown) => { + task.state = 'error' + task.error = error instanceof Error ? error.message : String(error) + task.expiresAt = Date.now() + ARCHIVE_TTL_MS + }) + return { id: task.id, entries: entries.length } + }, + status(id, sessionId) { + sweep(Date.now()) + const task = tasks.get(id) + if (task === undefined) throw new SidebarError('not-found', 'unknown or expired archive', 404) + if (task.sessionId !== sessionId) throw new SidebarError('forbidden', 'archive belongs to another session', 403) + return { + state: task.state, + done: task.done, + total: task.total, + bytes: task.bytes, + ...(task.error === undefined ? {} : { error: task.error }), + } + }, + lookup, + release(id) { + tasks.delete(id) + }, + size() { + sweep(Date.now()) + return tasks.size + }, + } +} diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index ac3f729..763eea9 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -145,6 +145,9 @@ export function EditorHost(props: { // target list, a URL vocabulary or a spawn route — the host reports which // applications are actually installed for THIS path. const openInApp = useMemo(() => createOpenInApp(ctx), [ctx]) + // The plugin's own service (native-tab opens, "open to the side"): absent in + // stripped-down hosts, where every flow degrades to the bottom workbench. + const service = ctx.get('betterSidebar') // The file tree's "open with" configuration (pluginSettings['editor']): a // blob subscription, so a pin click or a settings-page edit re-renders the // menu immediately. The parsed config also drives which targets are shown @@ -155,6 +158,11 @@ export function EditorHost(props: { ) const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob]) const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig]) + // The declarative "always show the plugin's own targets" switch (the editor + // card's pluginToggles row): a plain boolean on the SAME blob, so both the + // settings page and the tree's menu see one value. Absent/false keeps the + // host-first behavior (the tree decides what to hide). + const openWithShowPluginTargets = editorBlob.openWithPluginTargets === true // A path-less tab shows the empty-state hint in merged mode — and in split // mode it is the standalone explorer (tree-only, see the render below). A // folder tab is a folder window in BOTH modes: the tree rooted at the @@ -185,8 +193,22 @@ export function EditorHost(props: { * The context menu's "open to the side": a fresh editor tab (uid id — the * `'editor:' + path` convention would clash with the id safety net on a * second side-open of the same file) in a rightward split of THIS pane. + * + * Native right-Sidebar tabs do NOT live in `bottomSplits`, so the bottom + * branch would fall through to `firstLeaf` — a pane the user has not + * expanded, i.e. "nothing happened". Those tabs instead ask the host for a + * second pane through the service (`target: 'side'` → the host's + * `preferNewPane`), which is the same gesture in the surface the user is + * actually looking at. */ const openFileSide = (absolute: string): void => { + // `store.tabOpen` answers from THIS session's own state map — the bottom + // workbench's splits. A natively-hosted tab (right Sidebar) is absent + // from them even while it is on screen. + if (service !== undefined && !store.tabOpen(scope.sessionId, tab.id)) { + service.openTab({ type: 'editor', path: absolute, target: 'side' }, scope) + return + } store.reduce((state) => { const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits) const fresh: SidebarTab = { @@ -405,6 +427,7 @@ export function EditorHost(props: { onOpenFileNewTab={openFileNewTab} onOpenFileSide={openFileSide} openInApp={openInApp} + openWithShowPluginTargets={openWithShowPluginTargets} openWithTargets={openWithTargets} openWithPinned={openWithConfig.pinned} openWithSsh={openWithSshActive(openWithConfig)} @@ -532,6 +555,7 @@ export function EditorHost(props: { onOpenFileNewTab={openFileNewTab} onOpenFileSide={openFileSide} openInApp={openInApp} + openWithShowPluginTargets={openWithShowPluginTargets} openWithTargets={openWithTargets} openWithPinned={openWithConfig.pinned} openWithSsh={openWithSshActive(openWithConfig)} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index c32331e..d046c28 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -49,7 +49,10 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import { SiCursor, SiZedindustries } from 'react-icons/si' import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' -import { api, archiveUrl, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts' +import { + api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, isOutsideWorkspaceMessage, + type FsEntry, +} from './api.ts' import { FenceErrorNotice } from './FenceErrorNotice.tsx' import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' @@ -62,11 +65,12 @@ import { t } from './locales.ts' import type { BetterSidebarService } from './service.ts' import type { SidebarStore } from './state.ts' import { - Chip, ConfirmDialog, IconButton, Notice, SectionHeader, StatusBadge, useGitStatus, - type GitFileStatus, type GitTone, type StatusTone, + Chip, ConfirmDialog, IconButton, Notice, StatusBadge, useGitStatus, + type GitTone, type StatusTone, } from './ui/index.ts' import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts' import { useDirectoryWatch } from './use-dir-watch.ts' +import { usePolling } from './use-polling.ts' import css from './sidebar.module.css' interface LevelData { @@ -119,6 +123,12 @@ function badgeToneOf(tone: GitTone): StatusTone { return tone === 'copied' ? 'added' : tone } +/** The archive progress line's percentage (`0/0` — an empty selection — is 0%). */ +function archivePercent(progress: { done: number; total: number }): number { + if (progress.total <= 0) return 0 + return Math.min(100, Math.round((progress.done / progress.total) * 100)) +} + /** * The drop overlay's hero art: an arrow rising out of a notched tray * (upload zone — the same glyph family as the toolbar's upload icon) and a @@ -203,12 +213,20 @@ interface FileRowProps { /** A drag hovers this row's PARENT directory (upload target). */ dropTarget: boolean copied: boolean - git: GitFileStatus | undefined + /** + * The row's git state as PRIMITIVES, never the store's status object: the + * shared store rebuilds its per-path objects on every poll, so passing the + * object would re-render every changed row every 2.5s (`React.memo` compares + * by reference) — exactly the "frequent refresh" the file list was reported + * for. Two strings make the poll invisible to the rows. + */ + gitTone: GitTone | undefined + gitLetter: string | undefined actions: RowActions } const FileRow = memo(function FileRow(props: FileRowProps): ReactNode { - const { entry, depth, iconsVersion, service, selected, revealed, dropTarget, copied, git, actions } = props + const { entry, depth, iconsVersion, service, selected, revealed, dropTarget, copied, gitTone, gitLetter, actions } = props // Referenced so a registry change re-renders the row with its new icon. void iconsVersion const icon = service !== undefined ? service.fileIcon(entry.path, 14) : builtinFileIcon(entry.path, 14) @@ -239,11 +257,13 @@ const FileRow = memo(function FileRow(props: FileRowProps): ReactNode { onContextMenu={(event) => { actions.contextMenu(event, entry.path, false) }} > {icon} - + {entry.name} {entry.isSymlink && } - {git !== undefined && {git.letter}} + {gitTone !== undefined && gitLetter !== undefined && ( + {gitLetter} + )} {copied ? {t('copied')} : ( @@ -332,6 +352,57 @@ const DirRow = memo(function DirRow(props: DirRowProps): ReactNode { ) }) +/** + * The workspace ROOT row (the session's own folder). Memoized like the other + * rows: it has no name/selection churn, so a parent re-render (menu state, copy + * flash, apps) must not re-resolve its glyph either. + */ +interface RootRowProps { + path: string + iconsVersion: number + service: BetterSidebarService | undefined + /** A drag hovers the workspace root (drop target). */ + dropTarget: boolean + /** A change exists at or below the root (tinted). */ + gitChanged: boolean + copied: boolean + actions: RowActions +} + +const RootRow = memo(function RootRow(props: RootRowProps): ReactNode { + const { path, iconsVersion, service, dropTarget, gitChanged, copied, actions } = props + void iconsVersion + const icon = service !== undefined ? service.folderIcon(path, true, 14) : builtinFolderIcon(true, 14) + return ( +
{ actions.dragOver(event, path) }} + onDrop={(event) => { actions.drop(event, path, true) }} + onContextMenu={(event) => { actions.contextMenu(event, path, true) }} + > + {icon} + {baseName(path)} + {copied + ? {t('copied')} + : ( + + )} +
+ ) +}) + export function FileTree(props: { sessionId: string cwd: string | undefined @@ -390,6 +461,15 @@ export function FileTree(props: { * nobody can see (the workbench keeps every tab body mounted). */ visible?: boolean + /** + * Keep the plugin's own open-with targets in the "打开方式" submenu even when + * the host reports local applications for the path. Default (false): the + * host's own list wins, and the plugin's fixed targets (file manager / + * VS Code / Cursor / Zed / custom editors) show only when the host cannot + * offer any — so the menu is not two near-identical lists. The caller reads + * the `openWithPluginTargets` plugin setting and passes it down. + */ + openWithShowPluginTargets?: boolean /** * The sidebar registry service: when present, externally registered file * icons (`registerFileIcon`) outrank the host's file-type artwork on file rows. @@ -399,10 +479,12 @@ export function FileTree(props: { }) { // `onToggle` / `onOpenFile` are read through `propsRef` (the stable row // callbacks must not change identity when the caller re-renders). + // `onReferenceFile` is read through `propsRef` like the other row callbacks. const { sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide, openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, - onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service, + openWithShowPluginTargets, + onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service, } = props /** The live props for the stable callbacks below (identity churns per render). */ const propsRef = useRef(props) @@ -419,10 +501,8 @@ export function FileTree(props: { () => service?.subscribe(() => { setIconsVersion(version => version + 1) }), [service], ) - /** - * One directory row's leading glyph (the root row and the new-folder - * editor use it directly; FileRow/DirRow resolve their own). - */ + /** One directory's leading glyph for the surfaces WITHOUT a memoized row + * (the inline rename and new-folder editors). */ const dirRowIcon = (path: string, open: boolean): ReactNode => service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14) const dataRef = useRef(data) @@ -812,7 +892,9 @@ export function FileTree(props: { kindRef.current.set(path, isDir) setSelection(new Set([path])) } - setApps(null) + // NOTE: the previous menu's app answer is deliberately KEPT — the render + // only trusts it for the matching path, and dropping it here would cost an + // extra render (and, on a reopen, a disabled "Loading…" frame). setRowMenu({ path, isDir, x: event.clientX, y: event.clientY }) }, [setSelection]) @@ -946,22 +1028,30 @@ export function FileTree(props: { }, [openInApp]) /** - * The open menu's app rows are fetched while the menu is open: the host - * resolves a file's registered applications (or a directory's application - * catalogue). A host that cannot hand paths to a desktop keeps `appReady` - * false, so no listing is attempted. + * The host's application list, CACHED per path (the successful answers only; + * a failed listing is retried on the next open). The list is a property of + * the machine + the path, so reopening the same row's menu is instant: no + * request, no disabled "Loading…" frame. A newly installed application shows + * up on the next tree mount — the alternative (re-probing per open) is the + * churn the menu was reported for. */ + const appsCacheRef = useRef(new Map()) useEffect(() => { - if (rowMenu === null || openInApp === undefined || !appReady) { - setApps(null) + if (rowMenu === null || openInApp === undefined || !appReady) return + const { path, isDir } = rowMenu + const key = `${isDir ? 'd' : 'f'}\u0000${path}` + const cached = appsCacheRef.current.get(key) + if (cached !== undefined) { + // Already answered for this path: publish it without a null frame (the + // submenu never flashes its disabled "Loading…" row on a reopen). + setApps({ path, entries: cached }) return } let cancelled = false - const { path, isDir } = rowMenu - setApps(null) const listing = isDir ? openInApp.directoryApps() : openInApp.fileApps(path) void listing.then((entries) => { if (cancelled) return + if (entries !== null) appsCacheRef.current.set(key, entries) setApps({ path, entries }) if (entries === null) reportOpenFailure(path) }).catch(() => { @@ -993,20 +1083,23 @@ export function FileTree(props: { } /** - * The PLUGIN half of the "open with" section: the pinned targets as DIRECT - * rows, then the parent row with every target as a nested submenu (main's - * shape — pins, chevron, SSH suffixes). The section only renders when the - * caller wired the feature and at least one target is left. + * The plugin's own open-with targets as SUBMENU rows: one row per target with + * its brand mark, the SSH hint in remote mode, and the pin hot zone (main's + * interaction). The built-in `explorer` target is dropped while the host is + * ready — the host's own reveal row replaces it, and two identical "File + * Manager" rows would be noise; without the host the target stays, so reveal + * is never lost. * - * `hostReady` filters out the built-in `explorer` target: the host's own - * reveal row replaces it, and two identical "File Manager" rows would be - * noise. When the host is NOT ready the target stays, so reveal is never - * lost. + * Visibility (the user's "有本机检测到的关联应用时就不显示固定的 openwith + * 项目"): when the host reports applications for THIS path, the plugin rows + * only appear if the caller turned `openWithShowPluginTargets` on (the + * `openWithPluginTargets` setting). An unavailable host, or an empty/failed + * listing, keeps them — that is what makes a remote (SSH) session usable. */ - const openWithEntries = (hostReady: boolean): MenuEntry[] => { + const pluginTargetRows = (hostHasApps: boolean): MenuItem[] => { if (openWithTargets === undefined || onOpenWith === undefined) return [] - const targets = hostReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets - if (targets.length === 0) return [] + if (hostHasApps && openWithShowPluginTargets !== true) return [] + const targets = appReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets const pinnedIds = openWithPinned ?? [] /** Brand marks for the built-ins (monochrome silhouettes, currentColor); * reveal gets the folder glyph, custom editors a generic code mark. @@ -1018,14 +1111,7 @@ export function FileTree(props: { if (target.id === 'zed') return return } - const pinned = targets - .filter(target => pinnedIds.includes(target.id)) - .map(target => ({ - id: `open-with:${target.id}`, - label: openWithLabelOf(target), - icon: itemIcon(target), - })) - const submenu = targets.map(target => { + return targets.map(target => { const pinnedNow = pinnedIds.includes(target.id) return { id: `open-with:${target.id}`, @@ -1060,83 +1146,94 @@ export function FileTree(props: { icon: itemIcon(target), } }) - return [ - ...pinned, - ...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []), - { - id: 'open-with-menu', - // The primitives Menu renders no chevron for submenu parents — the - // trailing arrow is supplied inside the label (full-width flex row, - // right-aligned), matching how the submenu rows right-align the pin. - label: ( - - {t('openWithMenu')} - - - ), - icon: , - submenu, - }, - ] + } + + /** The host's application list for one path (undefined while it is unknown). */ + const hostEntries = (target: { path: string; isDir: boolean }): readonly OpenInAppEntry[] | null | undefined => + apps !== null && apps.path === target.path ? apps.entries : undefined + + /** + * The submenu behind the "打开方式" row — this menu's ONE place for "open with + * something else": the host's detected applications (the default handler is + * hoisted to level 1 for files), then, per the visibility rule above, the + * plugin's own fixed targets. A pending listing shows one disabled line; an + * empty/failed listing the disabled `openInAppEmpty` line. + * + * Title: the frozen shape asked for a `t('openInApp')` label, but that key no + * longer exists in the dictionaries (it was removed together with the + * standalone section it used to head), and `openWithMenu` is the one + * available synonym ("在应用中打开" / "Open with") — of the two near-duplicate + * labels the task allowed keeping one, so this is it. + */ + const openWithSubmenu = (target: { path: string; isDir: boolean }): MenuItem => { + const entries = hostEntries(target) + const hostHasApps = entries !== undefined && entries !== null && entries.length > 0 + const rows: MenuItem[] = [] + if (appReady) { + if (entries === undefined) rows.push({ id: 'open-in-app-loading', label: t('loading'), disabled: true }) + else if (entries === null || entries.length === 0) { + rows.push({ id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true }) + } else { + for (const entry of entries) { + // The default handler is the level-1 row for files; directories have + // no default concept, so every catalogue app stays here. + if (!target.isDir && entry.isDefault) continue + rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: }) + } + } + } + // The primitive submenu takes MenuItem rows only (no separators), so the + // host and plugin groups are told apart by their icons. + rows.push(...pluginTargetRows(hostHasApps)) + return { + id: 'open-with-menu', + // The primitives Menu renders no chevron for submenu parents — the + // trailing arrow is supplied inside the label (full-width flex row, + // right-aligned), matching how the submenu rows right-align the pin. + label: ( + + {t('openWithMenu')} + + + ), + icon: , + submenu: rows, + } } /** - * The HOST half of the section: the default-application row (files, when the - * OS reports one) followed by every registered handler. A pending listing - * shows one disabled line, an empty/failed listing the disabled - * `openInAppEmpty` line — the reveal row below still renders either way. + * The level-1 "default open" row for a file: the host's default handler, one + * click away without opening a submenu. Absent while the listing is unknown, + * when the host has no default, or for directories (no default concept). */ - const hostAppEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => { - const entries = apps !== null && apps.path === target.path ? apps.entries : undefined - if (entries === undefined) return [{ id: 'open-in-app-loading', label: t('loading'), disabled: true }] - if (entries === null || entries.length === 0) { - return [{ id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true }] - } + const defaultAppRow = (target: { path: string; isDir: boolean }): MenuEntry | null => { + if (!appReady || target.isDir) return null + const entries = hostEntries(target) + const fallback = entries?.find(entry => entry.isDefault) + if (fallback === undefined) return null + return { id: 'open-in-app:default', label: t('openInAppDefault'), icon: } + } + + /** The "打开方式" group of the ONE row menu: the host default, the submenu and + * (host permitting) reveal. Empty when neither half can offer anything. */ + const openWithSection = (target: { path: string; isDir: boolean }): MenuEntry[] => { + const fallback = defaultAppRow(target) + const entries = hostEntries(target) + const hostHasApps = entries !== undefined && entries !== null && entries.length > 0 + const submenu = appReady || pluginTargetRows(hostHasApps).length > 0 ? openWithSubmenu(target) : null + if (fallback === null && submenu === null) return [] const rows: MenuEntry[] = [] - const fallback = entries.find(entry => entry.isDefault) - if (!target.isDir && fallback !== undefined) { - rows.push({ id: 'open-in-app:default', label: t('openInAppDefault'), icon: }) - } - for (const entry of entries) { - if (!target.isDir && entry.isDefault) continue - rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: }) + if (fallback !== null) rows.push(fallback) + if (submenu !== null) rows.push(submenu) + if (appReady) { + rows.push( + { id: 'open-in-app-sep', type: 'separator' }, + { id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: }, + ) } return rows } - /** - * The menu's "打开方式" section, where the host's capability and the plugin's - * own targets COEXIST (the user asked for both). No heading row: the host - * group is self-describing (application names + "用默认应用打开"), and - * `openInApp` / `openWithMenu` read as near-duplicates in every dictionary — - * two synonyms stacked in one menu read as a repeated item. The frozen order: - * host rows → separator → plugin rows (pinned direct + submenu) → - * separator → host reveal. - * Separators only appear between two non-empty groups. The host rows (and - * reveal) vanish entirely when no handle was injected or the host cannot - * hand paths to a desktop; the plugin rows then stand alone, which is what - * keeps a remote/SSH session usable. The plugin's `explorer` target is - * dropped only while the host is ready (see {@link openWithEntries}). - */ - const openWithSection = (target: { path: string; isDir: boolean }): MenuEntry[] => { - const plugin = openWithEntries(appReady) - if (!appReady && plugin.length === 0) return [] - const entries: MenuEntry[] = [] - const separate = (): void => { - if (entries.length > 0) entries.push({ id: `open-with-group-${entries.length}`, type: 'separator' }) - } - if (appReady) entries.push(...hostAppEntries(target)) - if (plugin.length > 0) { - separate() - entries.push(...plugin) - } - if (appReady) { - separate() - entries.push({ id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: }) - } - return entries - } - /** * The selection's ZIP row: two or more selected rows archive together, and a * LONE directory archives its own subtree. A lone FILE is skipped — it would @@ -1169,36 +1266,44 @@ export function FileTree(props: { } /** - * Download one archive of `paths` through the host route. Unlike the - * single-file download — a bare anchor whose failures belong to the browser - * — the archive route answers the plugin's JSON error envelope for its caps - * and refusals, and an anchor would happily save that envelope as a broken - * `.zip`. So the bytes are fetched explicitly: a non-2xx answer is parsed for - * its `{error: {message}}` envelope (the shape `api.ts` reads) and reported - * through `zipFailed`; a 2xx body becomes an object URL handed to the same - * hidden-anchor mechanics. + * Zip the selection through the host's ASYNC archive route: * - * The host builds the archive in one pass, which can take a while for a big - * selection: `archiveBusy` drives the progress line under the error strip - * while the request is in flight, and `archiveBusyRef` keeps a repeated pick - * from packaging the same selection twice (the guard is silent by design — - * the line it is already showing IS the feedback). + * archiveBuild(scope, paths, name) → { id, entries } + * → poll archiveStatus(id) every 250ms (self-scheduling: one request in + * flight) while the job builds, driving the `zipProgress` line + * → state 'ready' → fetch(archiveDownloadUrl(scope, id)) → blob → hidden + * `` (the bytes are fetched explicitly because a bare anchor + * would save the route's JSON error envelope as a broken .zip) + * → any failure (build / status / download) → `zipFailed` in the strip. + * + * `archiveBusyRef` keeps a repeated pick from starting a second job (the + * visible progress line is the feedback), and `archiveProgress` carries the + * live `done/total` for the line and its percentage. */ const [archiveBusy, setArchiveBusy] = useState(false) + const [archiveProgress, setArchiveProgress] = useState<{ done: number; total: number } | null>(null) const archiveBusyRef = useRef(false) - const downloadArchive = (paths: readonly string[]): void => { - if (archiveBusyRef.current) return - const name = paths.length === 1 ? `${baseName(paths[0]!)}.zip` : 'archive.zip' - let url: string - try { - url = archiveUrl({ sessionId, cwd }, paths, name) - } catch (error: unknown) { - setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) })) - return - } - archiveBusyRef.current = true - setArchiveBusy(true) - void fetch(url) + /** The job being polled (state so the poller starts/stops with it). */ + const [archiveJobId, setArchiveJobId] = useState(null) + const archiveJobRef = useRef<{ id: string; name: string } | null>(null) + + /** End the job: drop the progress line and release the guard. */ + const settleArchive = useCallback((): void => { + archiveBusyRef.current = false + archiveJobRef.current = null + setArchiveJobId(null) + setArchiveBusy(false) + setArchiveProgress(null) + }, []) + + const failArchive = useCallback((message: string): void => { + setActionError(t('zipFailed', { message })) + settleArchive() + }, [settleArchive]) + + /** Save the finished archive as `name` through a hidden anchor. */ + const saveArchive = useCallback((id: string, name: string): void => { + void fetch(archiveDownloadUrl({ sessionId, cwd }, id)) .then(async (response) => { if (!response.ok) { const envelope: { error?: { message?: string } } | null = await response.json().catch(() => null) @@ -1221,13 +1326,54 @@ export function FileTree(props: { // under them loses the file. One tick is enough and keeps the blob // from outliving the download. window.setTimeout(() => { URL.revokeObjectURL(objectUrl) }, 0) + settleArchive() }) .catch((error: unknown) => { - setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) })) + failArchive(error instanceof Error ? error.message : String(error)) }) - .finally(() => { - archiveBusyRef.current = false - setArchiveBusy(false) + }, [cwd, failArchive, sessionId, settleArchive]) + + /** The poller's callbacks, read through a ref so the polling TASK stays + * identity-stable (a churned task restarts the loop every render). */ + const archiveHandlersRef = useRef({ fail: failArchive, save: saveArchive }) + archiveHandlersRef.current = { fail: failArchive, save: saveArchive } + + usePolling(archiveJobId !== null, useCallback(async (signal: AbortSignal): Promise => { + const job = archiveJobRef.current + if (job === null) return + // The poller swallows a rejected task (it must keep the loop alive), so + // every failure is caught HERE and turned into the strip's `zipFailed`. + try { + const status = await archiveStatus(job.id) + // The transport may deliver a response after teardown; the signal is the + // only reliable staleness guard (the route call takes no signal). + if (signal.aborted) return + setArchiveProgress({ done: status.done, total: status.total }) + if (status.state === 'error') { + archiveHandlersRef.current.fail(status.error ?? `HTTP ${status.state}`) + return + } + if (status.state === 'ready') archiveHandlersRef.current.save(job.id, job.name) + } catch (error: unknown) { + if (signal.aborted) return + archiveHandlersRef.current.fail(error instanceof Error ? error.message : String(error)) + } + }, []), { intervalMs: 250, mode: 'self-scheduling', immediate: true }) + + const downloadArchive = (paths: readonly string[]): void => { + if (archiveBusyRef.current) return + const name = paths.length === 1 ? `${baseName(paths[0]!)}.zip` : 'archive.zip' + archiveBusyRef.current = true + setArchiveBusy(true) + setArchiveProgress(null) + void archiveBuild({ sessionId, cwd }, paths, name) + .then(({ id, entries }) => { + archiveJobRef.current = { id, name } + setArchiveProgress({ done: 0, total: entries }) + setArchiveJobId(id) + }) + .catch((error: unknown) => { + failArchive(error instanceof Error ? error.message : String(error)) }) } @@ -1406,6 +1552,7 @@ export function FileTree(props: { ) } + const git = gitStatus.statusOf(entry.path) return ( @@ -1464,58 +1612,41 @@ export function FileTree(props: { /> )} - {/* The archive is built host-side in one pass: while it runs, the - row sits right under the error strip (the two CAN coexist — a - stale error stays readable while a new attempt is in flight). - `role="status"` makes it a polite live region, so the busy state - is announced without stealing focus. */} + {/* The archive is built host-side, in one pass, while the STATUS + poll drives `done/total`: the line sits right under the error + strip (the two CAN coexist — a stale error stays readable while a + new attempt is in flight). `role="status"` makes it a polite live + region, so progress is announced without stealing focus. */} {archiveBusy && ( - {t('loading')} + {archiveProgress === null + ? t('loading') + : `${t('zipProgress', { done: archiveProgress.done, total: archiveProgress.total })} · ${archivePercent(archiveProgress)}%`} )} - {selected.size > 0 && ( - - {t('copyPaths')} - { setConfirmDeleteSelected(true) }}>{t('deleteSelected')} - {t('clearSelection')} -
- )} - /> - )} -
{ handleRowDragOver(event, root) }} - onDrop={(event) => { handleDirDrop(event, root) }} - onContextMenu={(event) => { handleContextMenu(event, root, true) }} - > - {dirRowIcon(root, true)} - - {baseName(root)} - - {copiedPath === root - ? {t('copied')} - : ( - - )} -
+ {renderLevel(root, 1)} + {/* The selection bar closes the scroll container (sticky bottom): it + sits BELOW every row, never over them, and its appearance never + shifts the tree the way a top bar did. */} + {selected.size > 0 && ( +
+ {t('filesSelected', { count: selected.size })} + + {t('copyPaths')} + { setConfirmDeleteSelected(true) }}>{t('deleteSelected')} + {t('clearSelection')} + +
+ )} )} {dropOver && dropRect !== null && createPortal( @@ -1586,32 +1717,34 @@ export function FileTree(props: { open={rowMenu !== null} onClose={() => { setRowMenu(null) }} items={[ - // The open escapes head the FILE menu (dirs only get copy). + // 1-3: the "打开方式" group heads the menu — the host's default + // handler, the one submenu holding every application (host + the + // plugin's own, per the visibility rule) and the host's reveal. + ...(rowMenu === null ? [] : openWithSection(rowMenu)), + // 4: the explicit open escapes (files only). ...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] : []), ...(rowMenu?.isDir === false && onOpenFileSide !== undefined ? [{ id: 'open-side', label: t('openFileSide'), icon: }] : []), - ...(rowMenu === null ? [] : openWithSection(rowMenu)), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false ? [{ id: 'download', label: t('download'), icon: }] : []), - // Upload into a directory (incl. the workspace root row). + // Directory actions: upload into, and create inside (the workspace + // root row included). ...(rowMenu?.isDir === true ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] : []), - // New folder into a directory (incl. the workspace root row). ...(rowMenu?.isDir === true ? [{ id: 'new-folder', label: t('newFolder'), icon: }] : []), - // ZIP of the current selection (≥2 rows, or one lone directory). + // 5: ZIP of the current selection (≥2 rows, or one lone directory). ...(rowMenu === null ? [] : zipEntries(rowMenu)), + // 6: copy, then the mutations (never on the workspace root row). { 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, diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index fe30acd..0b2d5c2 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -71,6 +71,10 @@ export function TreePanel(props: { openWithSsh?: boolean onOpenWith?: (targetId: string, path: string) => void onToggleOpenWithPin?: (targetId: string) => void + /** Show the plugin's own open-with targets even when the host lists local + * applications for the path (the `openWithPluginTargets` setting; passed + * through to FileTree). */ + openWithShowPluginTargets?: boolean onReferenceFile: (path: string, isDir: boolean) => void /** A tree rename landed (passed through to FileTree for tab retargeting). */ onPathRenamed?: (oldPath: string, newPath: string) => void @@ -88,6 +92,7 @@ export function TreePanel(props: { const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, + openWithShowPluginTargets, onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service, } = props const [query, setQuery] = useState('') @@ -295,6 +300,7 @@ export function TreePanel(props: { openWithSsh={openWithSsh} onOpenWith={onOpenWith} onToggleOpenWithPin={onToggleOpenWithPin} + openWithShowPluginTargets={openWithShowPluginTargets} onReferenceFile={onReferenceFile} onPathRenamed={onPathRenamed} onPathDeleted={onPathDeleted} diff --git a/src/client/api.ts b/src/client/api.ts index ec6b03d..7c47a5a 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -440,15 +440,44 @@ export function downloadUrl(scope: SessionScope, path: string): string { } /** - * Absolute URL of the archive route: one ZIP of every `paths` entry (files - * and directories, recursive) served as an attachment. Same GET shape as - * {@link downloadUrl}; the repeated `path` parameter is the only difference. + * Start one archive build. The host fences and walks the selection, then + * returns an id IMMEDIATELY — the zip itself is produced in the background and + * polled through {@link api.archiveStatus}. `paths` are absolute paths in the + * session's namespace (the same values `fsTree` / `downloadUrl` take). */ -export function archiveUrl(scope: SessionScope, paths: readonly string[], name: string): string { - const params = new URLSearchParams({ sessionId: scope.sessionId, name }) - if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) - for (const path of paths) params.append('path', path) - return `/sidebar/archive?${params.toString()}` +export function archiveBuild( + scope: SessionScope, + paths: readonly string[], + name: string, +): Promise<{ id: string; entries: number }> { + return call<{ id: string; entries: number }>('archive.build', scopePayload(scope, { paths: [...paths], name })) +} + +/** One archive build's progress (the shape `archive.status` returns). */ +export interface ArchiveBuildStatus { + state: 'building' | 'ready' | 'error' + /** Entries finished so far. */ + done: number + /** Entries the archive will contain. */ + total: number + /** Uncompressed bytes read so far. */ + bytes: number + /** Present only when `state === 'error'`. */ + error?: string +} + +/** Poll one archive build (its id came from {@link archiveBuild}). */ +export function archiveStatus(id: string): Promise { + return call('archive.status', { id }) +} + +/** + * Absolute URL of one FINISHED archive's bytes (the `archive.status` result + * must be `ready` first): GET /sidebar/archive?id=…&sessionId=… The session + * scope rides along because the host answers only the session that built it. + */ +export function archiveDownloadUrl(scope: SessionScope, id: string): string { + return `/sidebar/archive?${new URLSearchParams({ sessionId: scope.sessionId, id }).toString()}` } /** Shared URL builder for the /sidebar/file route (media vs download). */ diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index e94c789..17e8b9d 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -73,6 +73,14 @@ export function builtinTabs(): readonly TabDescriptor[] { title: () => t('settingsFenceTitle'), desc: () => t('settingsFenceDesc'), }], + // Plugin-owned rows (values live in `pluginSettings['editor']`): the + // plugin's own open-with targets are shown only when the host reports + // no local application, unless the user asks for both side by side. + pluginToggles: [{ + key: 'openWithPluginTargets', + title: () => t('settingsOpenWithPluginTitle'), + desc: () => t('settingsOpenWithPluginDesc'), + }], render: ({ pluginSettings, updatePluginSetting }) => ( ), diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index b62c4ef..bfd7c31 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -58,6 +58,9 @@ export const ar: Record = { editorEmptyHint: 'اختر ملفاً من لوحة الشجرة أو من حقل المسار بالأعلى لبدء المعاينة', openFileNewTab: 'فتح في تبويب جديد', openFileSide: 'فتح إلى الجانب', + zipProgress: 'جارٍ الضغط {done}/{total}', + settingsOpenWithPluginTitle: 'إظهار خيارات الفتح الخاصة بالإضافة دائماً', + settingsOpenWithPluginDesc: 'افتراضياً تُعرض خيارات الفتح الخاصة بالإضافة فقط عندما لا يكتشف المضيف أي تطبيق مرتبط محلياً (مدير الملفات / VS Code / Cursor / Zed / محرّرات مخصّصة، SSH عن بُعد)؛ وبعد التفعيل تتواجد تطبيقات المضيف وأهداف الإضافة معاً', zipDownload: 'ضغط وتنزيل', zipDownloadCount: 'ضغط وتنزيل ({count} عناصر)', zipFailed: 'فشل إنشاء الأرشيف: {message}', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index fb96906..4683218 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -43,6 +43,9 @@ export const de: Record = { editorEmptyHint: 'Wählen Sie eine Datei aus dem Dateibaum rechts oder dem Pfadeingabefeld oben, um mit der Vorschau zu beginnen', openFileNewTab: 'In neuem Tab öffnen', openFileSide: 'Seitlich öffnen', + zipProgress: 'Archivierung läuft {done}/{total}', + settingsOpenWithPluginTitle: 'Plugin-eigene „Öffnen mit“-Ziele immer anzeigen', + settingsOpenWithPluginDesc: 'Standardmäßig werden die plugin-eigenen „Öffnen mit“-Ziele nur gezeigt, wenn der Host keine lokal verknüpfte Anwendung erkennt (Dateimanager / VS Code / Cursor / Zed / benutzerdefinierte Editoren, SSH-Remote); nach dem Aktivieren stehen Host-Apps und Plugin-Ziele nebeneinander', zipDownload: 'Zippen und herunterladen', zipDownloadCount: 'Zippen und herunterladen ({count} Elemente)', zipFailed: 'Archivierung fehlgeschlagen: {message}', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 8d3d9f3..92e1d3b 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -50,6 +50,9 @@ export const fr: Record = { editorEmptyHint: 'Choisissez un fichier dans l’arborescence à droite ou dans le champ de chemin ci-dessus pour commencer l’aperçu', openFileNewTab: 'Ouvrir dans un nouvel onglet', openFileSide: 'Ouvrir de côté', + zipProgress: 'Archivage {done}/{total}', + settingsOpenWithPluginTitle: 'Toujours afficher les cibles « Ouvrir avec » du plugin', + settingsOpenWithPluginDesc: 'Par défaut, les cibles « Ouvrir avec » du plugin ne s’affichent que si l’hôte ne détecte aucune application associée localement (Explorateur de fichiers / VS Code / Cursor / Zed / éditeurs personnalisés, SSH distant) ; une fois activé, les applications de l’hôte et les cibles du plugin coexistent', zipDownload: 'Compresser et télécharger', zipDownloadCount: 'Compresser et télécharger ({count} éléments)', zipFailed: 'Échec de l’archivage : {message}', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 863a64c..f11eca6 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -57,6 +57,9 @@ export const hi: Record = { editorEmptyHint: 'पूर्वावलोकन शुरू करने के लिए दाएँ फ़ाइल ट्री या ऊपर पथ इनपुट से फ़ाइल चुनें', openFileNewTab: 'नए टैब में खोलें', openFileSide: 'बाजू में खोलें', + zipProgress: 'संग्रह बनाया जा रहा है {done}/{total}', + settingsOpenWithPluginTitle: 'प्लगइन के अपने खोलने के विकल्प हमेशा दिखाएँ', + settingsOpenWithPluginDesc: 'डिफ़ॉल्ट रूप से प्लगइन के अपने खोलने के विकल्प तभी दिखते हैं जब होस्ट को कोई स्थानीय संबद्ध ऐप न मिले (फ़ाइल मैनेजर / VS Code / Cursor / Zed / कस्टम एडिटर, SSH रिमोट); चालू करने पर होस्ट ऐप और प्लगइन लक्ष्य साथ-साथ रहते हैं', zipDownload: 'ज़िप कर डाउनलोड करें', zipDownloadCount: 'ज़िप कर डाउनलोड करें ({count} आइटम)', zipFailed: 'संग्रह बनाना विफल: {message}', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 8498d0c..c71b8bc 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -55,6 +55,9 @@ export const id: Record = { editorEmptyHint: 'Pilih berkas dari panel pohon atau input path di atas untuk mulai pratinjau', openFileNewTab: 'Buka di Tab Baru', openFileSide: 'Buka di Samping', + zipProgress: 'Mengarsipkan {done}/{total}', + settingsOpenWithPluginTitle: 'Selalu tampilkan opsi buka milik plugin', + settingsOpenWithPluginDesc: 'Secara default opsi buka milik plugin hanya muncul saat host tidak mendeteksi aplikasi terkait di perangkat ini (File Manager / VS Code / Cursor / Zed / editor kustom, SSH jarak jauh); setelah diaktifkan, aplikasi host dan target plugin tampil berdampingan', zipDownload: 'Zip dan unduh', zipDownloadCount: 'Zip dan unduh ({count} item)', zipFailed: 'Gagal membuat arsip: {message}', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 19b6aba..ae76120 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -48,6 +48,9 @@ export const it: Record = { editorEmptyHint: 'Scelga un file dal pannello dell’albero a destra o dal campo del percorso in alto per iniziare l’anteprima', openFileNewTab: 'Apri in una nuova scheda', openFileSide: 'Apri a lato', + zipProgress: 'Archiviazione {done}/{total}', + settingsOpenWithPluginTitle: 'Mostra sempre le destinazioni «Apri con» del plugin', + settingsOpenWithPluginDesc: 'Per impostazione predefinita le destinazioni «Apri con» del plugin compaiono solo quando l’host non rileva applicazioni associate in locale (Esplora risorse / VS Code / Cursor / Zed / editor personalizzati, SSH remoto); una volta attivata, le app dell’host e le destinazioni del plugin convivono', zipDownload: 'Comprimi e scarica', zipDownloadCount: 'Comprimi e scarica ({count} elementi)', zipFailed: 'Creazione archivio non riuscita: {message}', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 7c4370b..fe04414 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -57,6 +57,9 @@ export const ja: Record = { editorEmptyHint: '右のファイルツリーまたは上のパス入力欄からファイルを選んでプレビュー', openFileNewTab: '新しいタブで開く', openFileSide: '横に開く', + zipProgress: '圧縮中 {done}/{total}', + settingsOpenWithPluginTitle: 'プラグイン独自の「アプリで開く」を常に表示', + settingsOpenWithPluginDesc: '既定では、ホストがローカルの関連アプリ(エクスプローラー / VS Code / Cursor / Zed / カスタムエディター、SSH リモート)を検出できない場合にのみ、プラグイン独自の「アプリで開く」を表示します。オンにするとホストのアプリとプラグインのターゲットが併存します', zipDownload: '圧縮してダウンロード', zipDownloadCount: '圧縮してダウンロード({count} 件)', zipFailed: 'アーカイブの作成に失敗しました:{message}', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 223de64..474cbad 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -49,6 +49,9 @@ export const ko: Record = { editorEmptyHint: '오른쪽 파일 트리나 위쪽 경로 입력란에서 파일을 선택해 미리보기를 시작하세요', openFileNewTab: '새 Tab으로 열기', openFileSide: '측면에 열기', + zipProgress: '압축 중 {done}/{total}', + settingsOpenWithPluginTitle: '플러그인 자체 "앱에서 열기" 항상 표시', + settingsOpenWithPluginDesc: '기본적으로 플러그인 자체의 "앱에서 열기"는 호스트가 로컬 연결 앱(파일 탐색기 / VS Code / Cursor / Zed / 사용자 지정 편집기, SSH 원격)을 감지하지 못할 때만 표시됩니다. 켜면 호스트 앱과 플러그인 대상이 함께 표시됩니다', zipDownload: '압축하여 다운로드', zipDownloadCount: '압축하여 다운로드({count}개 항목)', zipFailed: '압축 파일 생성 실패: {message}', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 3a5aeb2..0739ec1 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -55,6 +55,9 @@ export const nl: Record = { editorEmptyHint: 'Kies een bestand uit het boompaneel of het pad-invoerveld hierboven om te beginnen met previewen', openFileNewTab: 'Openen in nieuw tabblad', openFileSide: 'Openen aan de zijkant', + zipProgress: 'Archiveren {done}/{total}', + settingsOpenWithPluginTitle: 'Eigen "Openen met"-doelen van de plugin altijd tonen', + settingsOpenWithPluginDesc: 'Standaard worden de eigen "Openen met"-doelen van de plugin alleen getoond als de host geen lokaal gekoppelde app detecteert (Bestandsbeheerder / VS Code / Cursor / Zed / aangepaste editors, SSH op afstand); na inschakelen bestaan host-apps en plugin-doelen naast elkaar', zipDownload: 'Zippen en downloaden', zipDownloadCount: 'Zippen en downloaden ({count} items)', zipFailed: 'Archiveren mislukt: {message}', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index c88946d..1f40f74 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -59,6 +59,9 @@ export const pl: Record = { editorEmptyHint: 'Wybierz plik z panelu drzewa lub pola ścieżki powyżej, aby rozpocząć podgląd', openFileNewTab: 'Otwórz w nowej karcie', openFileSide: 'Otwórz z boku', + zipProgress: 'Archiwizowanie {done}/{total}', + settingsOpenWithPluginTitle: 'Zawsze pokazuj własne opcje „Otwórz za pomocą” wtyczki', + settingsOpenWithPluginDesc: 'Domyślnie własne opcje „Otwórz za pomocą” wtyczki są pokazywane tylko wtedy, gdy host nie wykryje lokalnie skojarzonej aplikacji (Menedżer plików / VS Code / Cursor / Zed / niestandardowe edytory, SSH zdalny); po włączeniu aplikacje hosta i cele wtyczki współistnieją', zipDownload: 'Spakuj i pobierz', zipDownloadCount: 'Spakuj i pobierz ({count} elementów)', zipFailed: 'Tworzenie archiwum nie powiodło się: {message}', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 2cead4b..a7dbe98 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -40,6 +40,9 @@ export const pt: Record = { editorEmptyHint: 'Escolha um arquivo na árvore do painel ou no campo de caminho acima para começar a pré-visualizar', openFileNewTab: 'Abrir em nova aba', openFileSide: 'Abrir ao lado', + zipProgress: 'Compactando {done}/{total}', + settingsOpenWithPluginTitle: 'Mostrar sempre as opções "Abrir com" do plugin', + settingsOpenWithPluginDesc: 'Por padrão, as opções "Abrir com" do plugin só aparecem quando o host não detecta nenhum aplicativo associado localmente (Gerenciador de arquivos / VS Code / Cursor / Zed / editores personalizados, SSH remoto); ao ativar, os aplicativos do host e os destinos do plugin coexistem', zipDownload: 'Compactar e baixar', zipDownloadCount: 'Compactar e baixar ({count} itens)', zipFailed: 'Falha ao compactar: {message}', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 25e2aa5..01d2399 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -53,6 +53,9 @@ export const ru: Record = { editorEmptyHint: 'Выберите файл из дерева справа или введите путь выше, чтобы начать предпросмотр', openFileNewTab: 'Открыть в новой вкладке', openFileSide: 'Открыть сбоку', + zipProgress: 'Архивация {done}/{total}', + settingsOpenWithPluginTitle: 'Всегда показывать собственные варианты «Открыть в приложении» плагина', + settingsOpenWithPluginDesc: 'По умолчанию собственные варианты «Открыть в приложении» плагина показываются только когда хост не обнаружил локально связанных приложений (Проводник / VS Code / Cursor / Zed / собственные редакторы, SSH-удалённо); после включения приложения хоста и цели плагина сосуществуют', zipDownload: 'Сжать в архив и скачать', zipDownloadCount: 'Сжать в архив и скачать ({count} элементов)', zipFailed: 'Сбой создания архива: {message}', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 15b8b4c..0170863 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -40,6 +40,9 @@ export const sv: Record = { editorEmptyHint: 'Välj en fil från trädpanelen eller sökvägsinmatningen ovan för att börja förhandsgranska', openFileNewTab: 'Öppna i ny flik', openFileSide: 'Öppna vid sidan', + zipProgress: 'Arkiverar {done}/{total}', + settingsOpenWithPluginTitle: 'Visa alltid pluginets egna "Öppna med"-mål', + settingsOpenWithPluginDesc: 'Som standard visas pluginets egna "Öppna med"-mål bara när värden inte hittar någon lokalt associerad app (Filhanterare / VS Code / Cursor / Zed / anpassade editorer, SSH på distans); när det slås på finns värdens appar och pluginets mål sida vid sida', zipDownload: 'Zippa och ladda ner', zipDownloadCount: 'Zippa och ladda ner ({count} objekt)', zipFailed: 'Kunde inte skapa arkivet: {message}', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 471c23b..68fd069 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -57,6 +57,9 @@ export const th: Record = { editorEmptyHint: 'เลือกไฟล์จากพาเนลต้นไม้ไฟล์หรือช่องกรอกพาธด้านบนเพื่อเริ่มพรีวิว', openFileNewTab: 'เปิดใน Tab ใหม่', openFileSide: 'เปิดด้านข้าง', + zipProgress: 'กำลังบีบอัด {done}/{total}', + settingsOpenWithPluginTitle: 'แสดงตัวเลือก "เปิดด้วย" ของปลั๊กอินเสมอ', + settingsOpenWithPluginDesc: 'ตามค่าเริ่มต้น ตัวเลือก "เปิดด้วย" ของปลั๊กอินจะแสดงเฉพาะเมื่อโฮสต์ตรวจไม่พบแอปที่เชื่อมโยงในเครื่อง (ตัวจัดการไฟล์ / VS Code / Cursor / Zed / ตัวแก้ไขที่กำหนดเอง, SSH ระยะไกล); เมื่อเปิดใช้ แอปของโฮสต์และเป้าหมายของปลั๊กอินจะอยู่ร่วมกัน', zipDownload: 'บีบอัดและดาวน์โหลด', zipDownloadCount: 'บีบอัดและดาวน์โหลด ({count} รายการ)', zipFailed: 'สร้างไฟล์บีบอัดไม่สำเร็จ: {message}', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 45e00fa..ae38ade 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -57,6 +57,9 @@ export const tr: Record = { editorEmptyHint: 'Önizlemeye başlamak için sağdaki dosya ağacından veya yukarıdaki yol girişinden bir dosya seçin', openFileNewTab: 'Yeni sekmede aç', openFileSide: 'Yana aç', + zipProgress: 'Arşivleniyor {done}/{total}', + settingsOpenWithPluginTitle: 'Eklentinin kendi "Birlikte aç" seçeneklerini her zaman göster', + settingsOpenWithPluginDesc: 'Varsayılan olarak eklentinin kendi "Birlikte aç" seçenekleri yalnızca ana makine yerel bir ilişkili uygulama algılamadığında gösterilir (Dosya yöneticisi / VS Code / Cursor / Zed / özel düzenleyiciler, uzak SSH); açıldığında ana makine uygulamaları ile eklenti hedefleri bir arada bulunur', zipDownload: 'Sıkıştır ve indir', zipDownloadCount: 'Sıkıştır ve indir ({count} öğe)', zipFailed: 'Arşiv oluşturulamadı: {message}', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index dcd41ea..46dc2e9 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -57,6 +57,9 @@ export const vi: Record = { editorEmptyHint: 'Chọn tệp từ cây tệp bên phải hoặc ô nhập đường dẫn phía trên để xem trước', openFileNewTab: 'Mở trong Tab mới', openFileSide: 'Mở ở bên cạnh', + zipProgress: 'Đang nén {done}/{total}', + settingsOpenWithPluginTitle: 'Luôn hiển thị tùy chọn "Mở bằng" của plugin', + settingsOpenWithPluginDesc: 'Mặc định, tùy chọn "Mở bằng" của plugin chỉ hiển thị khi máy chủ không phát hiện ứng dụng liên kết cục bộ (Trình quản lý tệp / VS Code / Cursor / Zed / trình soạn thảo tùy chỉnh, SSH từ xa); khi bật, ứng dụng của máy chủ và mục tiêu của plugin cùng tồn tại', zipDownload: 'Nén và tải xuống', zipDownloadCount: 'Nén và tải xuống ({count} mục)', zipFailed: 'Tạo tệp nén thất bại: {message}', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index be33898..d6bb6df 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -72,6 +72,9 @@ export const zhHK: Record = { editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽', openFileNewTab: '在新 Tab 中開啟', openFileSide: '在側邊開啟', + zipProgress: '正在壓縮 {done}/{total}', + settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式', + settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存', zipDownload: '壓縮並下載', zipDownloadCount: '壓縮並下載({count} 項)', zipFailed: '壓縮失敗:{message}', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 33f4284..2639422 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -72,6 +72,9 @@ export const zhMO: Record = { editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽', openFileNewTab: '在新 Tab 中開啟', openFileSide: '在側邊開啟', + zipProgress: '正在壓縮 {done}/{total}', + settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式', + settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存', zipDownload: '壓縮並下載', zipDownloadCount: '壓縮並下載({count} 項)', zipFailed: '壓縮失敗:{message}', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index ce09a7c..4ac238f 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -72,6 +72,9 @@ export const zhTW: Record = { editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽', openFileNewTab: '在新 Tab 中開啟', openFileSide: '在側邊開啟', + zipProgress: '正在壓縮 {done}/{total}', + settingsOpenWithPluginTitle: '永遠顯示插件自帶的開啟方式', + settingsOpenWithPluginDesc: '預設只在宿主偵測不到本機關聯應用程式時顯示插件自帶的開啟方式(檔案總管 / VS Code / Cursor / Zed / 自訂編輯器、SSH 遠端);開啟後宿主應用程式與插件目標並存', zipDownload: '壓縮並下載', zipDownloadCount: '壓縮並下載({count} 項)', zipFailed: '壓縮失敗:{message}', diff --git a/src/client/locales.ts b/src/client/locales.ts index 9fb2087..80be7fd 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -69,6 +69,9 @@ export const zh = { editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览', openFileNewTab: '在新 Tab 中打开', openFileSide: '在侧边打开', + zipProgress: '正在打包 {done}/{total}', + settingsOpenWithPluginTitle: '始终显示插件自研的打开方式', + settingsOpenWithPluginDesc: '默认只在宿主探测不到本机关联应用时显示插件自研的打开方式(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器、SSH 远端);打开后宿主应用与插件目标并存', zipDownload: '压缩并下载', zipDownloadCount: '压缩并下载({count} 项)', zipFailed: '打包失败:{message}', @@ -580,6 +583,9 @@ export const en: Record = { editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing', openFileNewTab: 'Open in New Tab', openFileSide: 'Open to the Side', + zipProgress: 'Archiving {done}/{total}', + settingsOpenWithPluginTitle: 'Always show the plugin\u2019s own Open with targets', + settingsOpenWithPluginDesc: 'By default the plugin\u2019s own targets (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes) show only when the host reports no local associated application; turn this on to keep both side by side', zipDownload: 'Zip and download', zipDownloadCount: 'Zip and download ({count} items)', zipFailed: 'Archive failed: {message}', diff --git a/src/client/native/surface.ts b/src/client/native/surface.ts index 4886f20..aefee1f 100644 --- a/src/client/native/surface.ts +++ b/src/client/native/surface.ts @@ -26,8 +26,8 @@ import type { NativeTabRecords } from './tab-adapter.tsx' /** One open the surface could not place yet. */ type Pending = - | { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean } - | { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean } + | { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean; preferNewPane: boolean } + | { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean; preferNewPane: boolean } /** * The observation "which session's seat is on screen": DSH 0.1.7 publishes it @@ -42,14 +42,14 @@ export interface MountedSessions { /** The controller face this module uses (a structural slice of `ISidebarRight`). */ interface NativeController { - openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void - openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void + openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void close(tabId: string): void /** The mounted-seat observation (0.1.7 `ISidebarRight.mounted`). */ mounted?: MountedSessions /** Not part of `ISidebarRight`: the concrete controller's per-session writes. */ - openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void - openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void + openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean; preferNewPane?: boolean }): void closeIn?(sessionId: string, tabId: string): void } @@ -134,7 +134,11 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na if (api === undefined) return false const onScreen = mountedSessionId(ctx) === entry.sessionId if (entry.kind === 'tab') { - const options = { params: entry.params, revealIfOpened: entry.revealIfOpened } + const options = { + params: entry.params, + revealIfOpened: entry.revealIfOpened, + ...(entry.preferNewPane ? { preferNewPane: true } : {}), + } if (onScreen) { api.openTab(entry.tabKind, options) return true @@ -148,6 +152,7 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na const options = { ...(entry.line === undefined ? {} : { params: { line: entry.line } }), revealIfOpened: entry.revealIfOpened, + ...(entry.preferNewPane ? { preferNewPane: true } : {}), } if (onScreen) { api.openResource(entry.address, options) @@ -185,11 +190,11 @@ export function createNativeSurface(ctx: Context, records: NativeTabRecords): Na } const unsubscribeList = ctx.sessions.list.subscribe(onListChange) return { - openTab({ sessionId, kind, params, revealIfOpened }) { - enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened }) + openTab({ sessionId, kind, params, revealIfOpened, preferNewPane }) { + enqueue({ kind: 'tab', sessionId, tabKind: kind, params, revealIfOpened, preferNewPane: preferNewPane === true }) }, - openResource({ sessionId, address, line, revealIfOpened }) { - enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened }) + openResource({ sessionId, address, line, revealIfOpened, preferNewPane }) { + enqueue({ kind: 'resource', sessionId, address, line, revealIfOpened, preferNewPane: preferNewPane === true }) }, fileAddress(sessionId, cwd, path) { return fileAddressFor(sessionId, cwd, path) diff --git a/src/client/service.ts b/src/client/service.ts index 9ceef33..1d13c0e 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -416,8 +416,14 @@ export interface OpenTabSeed { * the plugin's content is registered there as native tab types; `'bottom'` * is the plugin's own bottom workbench. Only the plugin's own flows pass * `'bottom'` (the bottom panel's + menu, the auto-terminal). + * + * `'side'` also means the right Sidebar, but it lands in a SECOND pane + * there (`preferNewPane`, the host's own split): that is the "open to the + * side" action, which must not fall back to the bottom workbench a native + * tab never lives in. A path-less editor seed is the file explorer page, so + * `'side'` only changes where a path seed lands. */ - target?: 'right' | 'bottom' + target?: 'right' | 'bottom' | 'side' } /** @@ -449,9 +455,9 @@ export interface NativeTabParams { */ export interface SidebarSurface { /** Open a page type in one session's native surface. */ - openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean }): void + openTab(input: { sessionId: string; kind: string; params: NativeTabParams; revealIfOpened: boolean; preferNewPane?: boolean }): void /** Open a resource address in one session's native surface. */ - openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean }): void + openResource(input: { sessionId: string; address: string; line?: number; revealIfOpened: boolean; preferNewPane?: boolean }): void /** The file address of one path (the native surface owns the grammar). */ fileAddress(sessionId: string, cwd: string | undefined, path: string): string /** Close one native tab; the closed record's type/title, or undefined when the id is not native. */ @@ -900,6 +906,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // included) as navigation params, which the tab adapter merges onto the // synthetic record's `tab.path` for the registered component. if (surface !== undefined && seed.target !== 'bottom') { + // "Open to the side" asks the host for a NEW pane instead of reusing + // the pane the acting tab lives in. `revealIfOpened: false` permits a + // duplicate of an already-open resource, so the split really happens + // (the host's `preferNewPane` falls back to the target pane when no + // split is available — that fallback is the host's rule, not ours). + const side = seed.target === 'side' const state = store.getSnapshot().state // The descriptor's own factory mints what a view needs beyond the seed: // the side chat's thread bootstrap / reattach meta, the terminal's @@ -926,7 +938,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe surface.openResource({ sessionId: targetSessionId, address: surface.fileAddress(targetSessionId, scope?.cwd, seed.path), - revealIfOpened: true, + revealIfOpened: side ? false : true, + ...(side ? { preferNewPane: true } : {}), }) } else { // The path-less editor window IS the file explorer. @@ -946,7 +959,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe ...(seed.diff === undefined ? {} : { diff: seed.diff }), ...(synthetic.meta === undefined ? {} : { meta: synthetic.meta }), }, - revealIfOpened, + revealIfOpened: side ? false : revealIfOpened, + ...(side ? { preferNewPane: true } : {}), }) } // The native surface reports one open event, not create-vs-focus, so a diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 529dd40..ec5625c 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -832,19 +832,46 @@ body[data-dsh-sidebar-dragging] .bottomPanel { color: var(--dsw-alias-state-error-primary); } -/* The multi-selection bar: the kit's 28px section band, sat above the root - row (it never covers a row, so the tree stays fully aimable). Only the - fill/rhythm differ from the kit band — geometry stays the kit's. */ +/* The multi-selection bar: the LAST child of the tree's scroll container, so + it docks at the panel's bottom edge (sticky) and its appearance never + pushes the rows around the way a top bar did. Sticky (not absolute) keeps + it in flow: scrolled to the bottom it sits below the last row and covers + nothing, while mid-scroll it stays reachable. It WRAPS instead of + overflowing, so a narrow panel gets a second line of chips rather than a + clipped control or a horizontal scrollbar. 28px rhythm, token-only (§1). */ .explorerSelectionBar { - margin-bottom: 4px; + position: sticky; + bottom: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + width: 100%; + min-height: 28px; + margin-top: 4px; + padding: 2px 8px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2); } +.explorerSelectionText { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xxs-strong-12); + color: var(--dsw-alias-label-secondary); +} + .explorerSelectionActions { - display: inline-flex; - align-items: center; + flex: none; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; gap: 4px; + max-width: 72%; } /* The leading glyph of one "open in app" menu entry (the host's icon route diff --git a/src/index.ts b/src/index.ts index d37a96c..3c73fb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,8 +38,8 @@ import { isTrustedApiRequest } from './trust-fence.ts' import { registerBundleRoute } from './bundle-route.ts' import { createDirectoryWatchers, type DirectoryWatchers } from './fs-watch.ts' import { launchExternal } from './open-external.ts' -import { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts' -import { buildZip, type ZipEntry } from './zip.ts' +import { archiveNameOf, collectZipEntries, createArchiveTasks, disambiguateArchiveNames, respondArchiveDownload, type ArchiveTasks } from './archive-route.ts' +import type { ZipEntry } from './zip.ts' import * as git from './git.ts' import { SettingsConflictError } from '@deepseek-ai/dsh-settings' import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts' @@ -72,7 +72,6 @@ export type { // their bounds live inside the collector, so the suite needs them observable // without creating 10 000 rows first. export { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from './archive-route.ts' - /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' @@ -254,6 +253,7 @@ function buildApi( resolved: ResolvedSidebarConfig, getSettings: () => SidebarSettingsFace | undefined, assistantLive: AssistantLiveBuffer, + archiveTasks: ArchiveTasks, ): Record { const cwdOf = async (payload: unknown): Promise<{ sessionId: string; cwd: string }> => { const sessionId = requireString(payload, 'sessionId') @@ -549,6 +549,32 @@ function buildApi( if (action === 'url') return launchExternal('url', requireString(payload, 'url')) throw new SidebarError('bad-request', 'action must be "reveal" or "url"') }, + // Archive builds: collect the selection (fenced + disambiguated) and hand + // the zipping to the background task table, so the tree can show progress + // through `archive.status` and download the finished bytes from + // GET /sidebar/archive?id=. + 'archive.build': async (payload) => { + const { sessionId, cwd } = await cwdOf(payload) + const record = payload as { paths?: unknown; name?: unknown } | null + const paths = Array.isArray(record?.paths) + ? record.paths.filter((value): value is string => typeof value === 'string' && value !== '') + : [] + if (paths.length === 0) throw new SidebarError('bad-request', 'paths must be a non-empty array') + const name = archiveNameOf(typeof record?.name === 'string' ? record.name : null) + const fenceOn = fenceEnabledOf(getSettings) + const selected: string[] = [] + for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw, fenceOn)) + // Same-basename selections (a/index.ts + b/index.ts) get parent segments + // prepended, so no two archive members collide. + const entries: ZipEntry[] = [] + const names = disambiguateArchiveNames(selected) + for (const [index, absolute] of selected.entries()) { + await collectZipEntries(absolute, names[index]!, entries) + } + return archiveTasks.start({ sessionId, name, entries }) + }, + 'archive.status': (payload) => + archiveTasks.status(requireString(payload, 'id'), requireString(payload, 'sessionId')), // Side Chat: create a side-thread child seeded with the parent's full // log up to now, deliver follow-ups (cold-resuming when the thread's // agent is gone), abort a running thread, and release a thread's agent. @@ -837,7 +863,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // effect releases the listener on fiber disposal. const assistantLive = createAssistantLiveBuffer(ctx) ctx.effect(() => () => { assistantLive.dispose() }, 'dsh-better-sidebar: live assistant stream buffer') - const api = buildApi(ctx, resolved, () => settingsFace, assistantLive) + // One archive task table per mount: the `archive.build` / `archive.status` + // API methods write it, the GET /sidebar/archive route reads it. + const archiveTasks = createArchiveTasks() + const api = buildApi(ctx, resolved, () => settingsFace, assistantLive, archiveTasks) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/sidebar/api', @@ -917,12 +946,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // (see bundle-route.ts / src/client/chunk-loader.ts). ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route') - // ── Archive route (zip one selection and download it) ─────────────────── - // The file tree's "zip and download" action: every `path` is resolved - // through the SAME workspace fence as fs.tree / /sidebar/file, directories - // are walked recursively (a symlink is recorded as a link, never followed — - // no escape and no cycle), and the whole archive is built in memory and - // answered in one response. The entry-count/byte bounds live in zip.ts. + // ── Archive routes (zip one selection, watch it build, download it) ───── + // The file tree's "zip and download" action is a three-step flow: POST + // `archive.build` fences every `path` through the SAME workspace fence as + // fs.tree / /sidebar/file, walks the directories (a symlink is skipped, never + // followed — no escape and no cycle) and returns an id IMMEDIATELY; the + // background build reports progress through `archive.status`; and + // GET /sidebar/archive serves the finished bytes once, with the RFC 5987 + // disposition. `archiveTasks` owns the concurrency cap and the TTL. ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/sidebar/archive', @@ -937,34 +968,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void { } try { const url = new URL(req.url ?? '/', 'http://dsh.internal') - const sessionId = url.searchParams.get('sessionId') - const paths = url.searchParams.getAll('path').filter(value => value !== '') - if (sessionId === null || paths.length === 0) { - throw new SidebarError('bad-request', 'sessionId and at least one path are required') - } - const name = archiveNameOf(url.searchParams.get('name')) - const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) - const fenceOn = fenceEnabledOf(() => settingsFace) - const selected: string[] = [] - for (const raw of paths) selected.push(await ensureWorkspacePath(cwd, raw, fenceOn)) - // Same-basename selections (a/index.ts + b/index.ts) get parent - // segments prepended, so no two archive members collide and an - // extractor cannot overwrite one with the other. - const entries: ZipEntry[] = [] - const names = disambiguateArchiveNames(selected) - for (const [index, absolute] of selected.entries()) { - await collectZipEntries(absolute, names[index]!, entries) - } - const body = await buildZip(entries) - res.writeHead(200, { - 'content-type': 'application/zip', - // Never the raw name: a non-latin1 value (中文目录 → 报告.zip) makes - // Node's writeHead throw and turns the download into a 500. - 'content-disposition': contentDispositionOf(name), - 'content-length': String(body.byteLength), - 'cache-control': 'no-cache', - }) - res.end(body) + respondArchiveDownload(archiveTasks, { + sessionId: url.searchParams.get('sessionId'), + id: url.searchParams.get('id'), + }, res) } catch (error) { writeError(res, error) } diff --git a/src/zip.ts b/src/zip.ts index 9dd20ed..811c47a 100644 --- a/src/zip.ts +++ b/src/zip.ts @@ -31,13 +31,26 @@ export interface ZipEntry { isDir?: boolean } -/** The archive bounds. */ +/** One progress report: entries finished, entries total, bytes read so far. */ +export interface ZipProgress { + /** Entries fully processed (payload read, compressed, hashed). */ + done: number + /** Entries the archive will contain (known up front: the entries array). */ + total: number + /** Uncompressed bytes accumulated so far. */ + bytes: number +} + +/** The archive bounds and the optional progress hook. */ export interface ZipOptions { /** Total uncompressed byte bound; exceeding it throws fs-error. */ maxBytes?: number /** Entry-count bound (directory entries included); exceeding it throws * fs-error. */ maxEntries?: number + /** Called once per finished entry, in archive order (never for a failed + * one). A throw from the hook aborts the build. */ + onProgress?: (progress: ZipProgress) => void } /** Default total uncompressed payload bound of one archive (256 MiB). */ @@ -205,9 +218,16 @@ export async function buildZip(entries: readonly ZipEntry[], opts: ZipOptions = const members: ZipMember[] = [] /** Total uncompressed payload (the bound is checked against it). */ let totalBytes = 0 + /** Entries finished so far (the progress hook's `done`). */ + let done = 0 + const report = (): void => { + opts.onProgress?.({ done, total: entries.length, bytes: totalBytes }) + } for (const entry of entries) { if (entry.isDir === true) { members.push(directoryMember(archiveName(entry.name, true))) + done += 1 + report() continue } const path = requireSourcePath(entry.path) @@ -222,6 +242,8 @@ export async function buildZip(entries: readonly ZipEntry[], opts: ZipOptions = } totalBytes += member.size members.push(member) + done += 1 + report() } const out: Buffer[] = [] diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 777aa00..39e1287 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -157,6 +157,14 @@ describe('built-in tab registrations', () => { // The open-with configuration (SSH host + custom editors) is the custom // panel rendered below the declarative rows. expect(service.getTab('editor')?.settings?.render).toBeDefined() + // The plugin-owned row (its value lives in pluginSettings['editor'], which + // EditorHost reads as `openWithPluginTargets`): a plain switch, default + // off = the host-first behavior. + const pluginToggles = service.getTab('editor')?.settings?.pluginToggles ?? [] + expect(pluginToggles.map(t => t.key)).toEqual(['openWithPluginTargets']) + expect(pluginToggles[0]?.title).toBeDefined() + expect(pluginToggles[0]?.desc).toBeDefined() + expect(pluginToggles[0]?.type).toBeUndefined() }) it('every built-in tab carries the settings-surface icon', () => { diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 47de70e..96ead4b 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -9,7 +9,7 @@ * only, no editor chrome); file tabs keep the full chrome in both modes. */ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createElement, useEffect, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import { act } from 'react-dom/test-utils' @@ -18,6 +18,31 @@ import { EditorHost } from '../src/client/EditorHost.tsx' import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts' import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts' +// The tree mounts inside EditorHost in these scenarios, so the api seam must +// answer without a host (the sibling FileTree specs mock it the same way). +vi.mock('../src/client/api.ts', () => ({ + api: { + fsTree: async () => ({ + path: '/tmp', + entries: [ + { name: 'sub', path: '/tmp/sub', isDir: true }, + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + ], + truncated: false, + }), + // The shared git-status store polls this; a non-repo answer keeps rows plain. + gitStatus: async () => ({ isRepo: false, entries: [] }), + archiveBuild: async () => ({ id: 'ar-1', entries: 1 }), + archiveStatus: async () => ({ state: 'ready', done: 1, total: 1, bytes: 1 }), + openExternal: async () => ({ started: true }), + }, + archiveDownloadUrl: () => '/sidebar/archive?id=ar-1', + downloadUrl: () => '/sidebar/file', + mediaUrl: () => '/sidebar/file', + archiveUrl: () => '/sidebar/archive', + isOutsideWorkspaceMessage: () => false, +})) + // The act() environment flag (React 18.2 reads it before flushing effects). import { setupReactAct } from './test-utils.ts' setupReactAct() @@ -86,6 +111,41 @@ function mountHost(ctx: Context, store: ReturnType, t } } +/** + * Mount the host AND let the docked tree finish its async level load (the + * context-menu scenarios need the file row in the DOM). The scope carries the + * session cwd — without a root the docked tree has nothing to list. + */ +async function mountHostWithTreeWithCwd(ctx: Context, store: ReturnType, tab: () => SidebarTab): Promise<{ + container: HTMLDivElement + unmount: () => void +}> { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + await act(async () => { + root.render(createElement(EditorHost, { + ctx, + store, + scope: { sessionId: 'editor-home-session', cwd: '/tmp' }, + tab: tab(), + expanded: [], + revealed: [], + onToggleDir: () => {}, + onReferenceFile: () => {}, + })) + await Promise.resolve() + await Promise.resolve() + }) + return { + container, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + /** Type into the controlled path input (native setter) and press Enter. */ function typeAndCommit(input: HTMLInputElement, value: string): void { act(() => { @@ -333,3 +393,165 @@ describe('EditorHost (files window)', () => { } }) }) + +/** + * "Open to the Side" from a NATIVE tab must reach the host's own split, not + * the plugin's bottom workbench: a native right-Sidebar tab is absent from + * `bottomSplits`, so the old code fell through to `firstLeaf` — a pane the + * user had not expanded, i.e. "the menu item does nothing". + */ +describe('EditorHost "open to the side"', () => { + /** A service whose openTab calls are recorded (surface undefined = native). */ + function spyService(store: ReturnType): { + service: ReturnType + opens: Array<{ type: string; path?: string; target?: string }> + } { + const service = createBetterSidebarService(store) + const opens: Array<{ type: string; path?: string; target?: string }> = [] + const real = service.openTab.bind(service) + service.openTab = ((seed, scope) => { + opens.push({ type: seed.type, ...(seed.path === undefined ? {} : { path: seed.path }), ...(seed.target === undefined ? {} : { target: seed.target }) }) + real(seed, scope) + }) as typeof service.openTab + return { service, opens } + } + + /** The fake client ctx (service + session feed); cwd matches api.fsTree. */ + function fakeCtx(service: ReturnType): Context { + const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' } + return { + betterSidebar: service, + get: (name: string) => name === 'betterSidebar' ? service : undefined, + sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, + } as unknown as Context + } + + /** Mount a native-hosting editor window whose tab is NOT in bottomSplits. */ + async function mountNative(ctx: Context, store: ReturnType, service: ReturnType): Promise<{ + container: HTMLDivElement + unmount: () => void + }> { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + // The tab record the native seat hands the component: an editor window for + // /tmp/a.ts whose id never enters the plugin's own layout. + const nativeTab: SidebarTab = { + id: 'native-editor-1', + type: 'editor', + title: 'a.ts', + path: '/tmp/a.ts', + meta: { treeOpen: true }, + } + await act(async () => { + root.render(createElement(EditorHost, { + ctx, + store, + // The tree needs a root: the real seat hands the session cwd in scope. + scope: { sessionId: 'editor-home-session', cwd: '/tmp' }, + visible: true, + tab: nativeTab, + expanded: [], + revealed: [], + onToggleDir: () => {}, + onReferenceFile: () => {}, + })) + await Promise.resolve() + await Promise.resolve() + }) + void service + return { + container, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } + } + + function clickOpenToSide(container: HTMLDivElement): void { + const row = [...container.querySelectorAll('[role="button"]')] + .find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts') + if (row === undefined) { + throw new Error(`file row not found; names=${[...container.querySelectorAll('[class*="explorerName"]')].map(el => el.textContent).join('|')}; html=${container.innerHTML.slice(0, 400)}`) + } + act(() => { + row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })) + }) + const item = [...document.querySelectorAll('[role="menuitem"]')] + .find(el => el.textContent?.trim() === 'Open to the Side') + if (item === undefined) throw new Error('side-open menu item not found') + act(() => { item.click() }) + } + + function tabCount(store: ReturnType): number { + return allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs).length + } + + it('asks the service for a side open and leaves the bottom workbench untouched', async () => { + const store = createSidebarStore() + const { service, opens } = spyService(store) + service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null }) + store.setSession('editor-home-session') + // A native surface is what makes the service native-hosted at all: the + // fake records what the service asks of the host (the real one forwards to + // `ctx.sidebarRight`). + const placed: Array<{ address: string; preferNewPane?: boolean; revealIfOpened?: boolean }> = [] + service.setSurface({ + openTab: () => {}, + openResource: (input) => { placed.push({ address: input.address, ...(input.preferNewPane === undefined ? {} : { preferNewPane: input.preferNewPane }), revealIfOpened: input.revealIfOpened }) }, + fileAddress: (sessionId, cwd, path) => `dsh-resource://file/${sessionId}${path}`, + close: () => undefined, + update: () => false, + activate: () => false, + has: () => false, + }) + const ctx = fakeCtx(service) + const { container, unmount } = await mountNative(ctx, store, service) + try { + const before = tabCount(store) + clickOpenToSide(container) + // 1) EditorHost routed the gesture to the service with the side target… + expect(opens).toContainEqual({ type: 'editor', path: '/tmp/a.ts', target: 'side' }) + // 2) …and the service asked the HOST for a second pane, permitting a + // duplicate resource so a split really happens. + expect(placed).toHaveLength(1) + expect(placed[0]).toMatchObject({ address: expect.stringContaining('/tmp/a.ts'), preferNewPane: true, revealIfOpened: false }) + // 3) The bottom workbench is untouched: the open went to the host. + expect(tabCount(store)).toBe(before) + } finally { + unmount() + } + }) + + it('keeps the bottom-workbench split for a tab that lives there', async () => { + const store = createSidebarStore() + const { service } = spyService(store) + service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null }) + store.setSession('editor-home-session') + const ctx = fakeCtx(service) + const opens: Array<{ target?: string }> = [] + const real = service.openTab.bind(service) + service.openTab = ((seed, scope) => { opens.push({ ...(seed.target === undefined ? {} : { target: seed.target }) }); real(seed, scope) }) as typeof service.openTab + store.setPrefs({ ...store.getPrefs(), editorExplorer: false }) + service.openTab({ + type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true }, + }) + const fileTab = (): SidebarTab => + allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs) + .find(tab => tab.path === '/tmp/a.ts')! + // The tree needs a root: the real seat hands the session cwd in scope. + const treeCtx = ctx as unknown as { betterSidebar: unknown } + const { container, unmount } = await mountHostWithTreeWithCwd(treeCtx as unknown as Context, store, fileTab) + try { + const before = tabCount(store) + clickOpenToSide(container) + // A bottom-workbench tab keeps the plugin's own split: a NEW tab in the + // same pane family, and no side open was requested. + expect(opens.every(open => open.target === undefined)).toBe(true) + expect(tabCount(store)).toBe(before + 1) + } finally { + unmount() + } + }) +}) diff --git a/tests/file-tree-archive.spec.tsx b/tests/file-tree-archive.spec.tsx index 21b6065..d3f8049 100644 --- a/tests/file-tree-archive.spec.tsx +++ b/tests/file-tree-archive.spec.tsx @@ -1,19 +1,18 @@ /** - * FileTree "zip and download": the context menu offers an archive row for a - * multi-selection (≥2 rows) and for a LONE directory (a lone file is skipped — - * the plain download row already covers it). + * FileTree "zip and download" over the host's ASYNC archive route. * - * The row is NOT a bare anchor any more: the bytes are fetched through - * `archiveUrl(scope, paths, name)`, because the archive route reports its caps - * and refusals as the plugin's JSON error envelope — an anchor would save that - * envelope as a broken `.zip`. So these cases pin: - * - the URL parameters (scope / paths / name) and the appearance gates; - * - success: the response body becomes an object URL, saved through a hidden - * `
`, revoked on the next task; - * - failure: a non-2xx JSON envelope (`{error:{message}}`), a non-JSON body - * (`HTTP `) and a network rejection all land in the error strip - * with the `zipFailed` copy; - * - a click while one request is in flight does not package twice. + * The menu row starts a job (`api.archiveBuild`), the tree polls + * `api.archiveStatus` while it builds and shows the `zipProgress` line + * (`done/total` + percentage), then a `ready` status downloads + * `api.archiveDownloadUrl(id)` as a blob and saves it through a hidden + * ``. Every failure path — build rejection, a status + * `error` state, a status rejection, the download's HTTP envelope — lands in + * the error strip with `zipFailed`, and a repeated pick while one job runs + * never starts a second job. + * + * Appearance gates stay as they were: ≥2 selected rows archive together, a + * LONE directory archives its own subtree, a lone FILE shows no archive row + * (the plain download row already covers it). */ // @vitest-environment jsdom import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,8 +30,9 @@ beforeAll(() => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) }) -const { archiveUrl, fetchMock } = vi.hoisted(() => ({ - archiveUrl: vi.fn((_scope: unknown, _paths: readonly string[], name: string) => `/sidebar/archive?name=${encodeURIComponent(name)}`), +const { archiveBuild, archiveStatus, fetchMock } = vi.hoisted(() => ({ + archiveBuild: vi.fn(), + archiveStatus: vi.fn(), fetchMock: vi.fn(), })) @@ -52,7 +52,11 @@ vi.mock('../src/client/api.ts', () => ({ gitStatus: async () => ({ isRepo: false, entries: [] }), }, downloadUrl: () => '/sidebar/file', - archiveUrl, + // The archive API is a set of standalone module exports (like downloadUrl), + // not methods on the `api` object. + archiveBuild, + archiveStatus, + archiveDownloadUrl: (_scope: unknown, id: string) => `/sidebar/archive/${id}`, isOutsideWorkspaceMessage: () => false, })) @@ -75,6 +79,9 @@ beforeAll(() => { }) }) +/** The poll cadence the tree uses; advancing less than this must not poll. */ +const POLL_MS = 250 + interface Harness { container: HTMLDivElement unmount: () => void @@ -106,7 +113,6 @@ async function mountTree(): Promise { } } -/** One tree row by its displayed name (the root row included). */ function rowByName(container: HTMLElement, name: string): HTMLElement { const row = [...container.querySelectorAll('[class*="explorerRow"]')] .find(el => el.querySelector('[class*="explorerName"]')?.textContent === name) @@ -135,40 +141,35 @@ function clickMenuitem(label: string): void { act(() => { item.click() }) } -/** - * Flush the fetch → blob → anchor chain. The chain crosses several async - * function boundaries (fetch promise → `await response.blob()` → object URL → - * revoke), so a single microtask/timer pair is not enough on a loaded machine: - * three macrotask ticks let every hop land, including the revoke `setTimeout`. - */ -async function settleDownload(): Promise { +/** The progress line (a `Notice kind="loading"` live region). */ +function progressLine(container: HTMLElement): HTMLElement | null { + return container.querySelector('[data-kind="loading"]') +} + +/** Flush the pending promise chain (build / status / download). */ +async function flush(): Promise { await act(async () => { - for (let tick = 0; tick < 3; tick += 1) { - await Promise.resolve() - await new Promise(resolve => { window.setTimeout(resolve, 0) }) - } + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() }) } -/** A 200 answer carrying ZIP bytes. */ -function okResponse(bytes = 'zip-bytes'): Response { - return { - ok: true, - status: 200, - blob: async () => new Blob([bytes], { type: 'application/zip' }), - } as unknown as Response +/** Let the poll timer fire and its promise chain settle. */ +async function poll(): Promise { + await act(async () => { + vi.advanceTimersByTime(POLL_MS) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) } -/** A refused answer carrying the plugin's JSON envelope (or a non-JSON body). */ -function errorResponse(status: number, body?: unknown): Response { - return { - ok: false, - status, - json: async () => { - if (body === undefined) throw new Error('not json') - return body - }, - } as unknown as Response +/** A `ready` status row. */ +function readyStatus(done: number, total: number): { + state: 'ready'; done: number; total: number; bytes: number +} { + return { state: 'ready', done, total, bytes: 128 } } let harness: Harness @@ -179,51 +180,80 @@ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementati }) beforeEach(() => { + vi.useFakeTimers() vi.stubGlobal('fetch', fetchMock) fetchMock.mockReset() - fetchMock.mockResolvedValue(okResponse()) - archiveUrl.mockClear() - archiveUrl.mockImplementation((_scope: unknown, _paths: readonly string[], name: string) => - `/sidebar/archive?name=${encodeURIComponent(name)}`) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + blob: async () => new Blob(['zip-bytes'], { type: 'application/zip' }), + } as unknown as Response) + archiveBuild.mockReset() + archiveBuild.mockResolvedValue({ id: 'job-1', entries: 2 }) + archiveStatus.mockReset() + archiveStatus.mockResolvedValue(readyStatus(2, 2)) + downloads.length = 0 + createdUrls.length = 0 + revokedUrls.length = 0 + clickSpy.mockClear() }) afterEach(() => { harness.unmount() document.body.innerHTML = '' - downloads.length = 0 - createdUrls.length = 0 - revokedUrls.length = 0 - clickSpy.mockClear() + vi.useRealTimers() vi.unstubAllGlobals() }) describe('FileTree zip and download', () => { - it('archives a multi-selection through one fetched row', async () => { + it('starts a job, shows done/total progress, then downloads the ready archive', async () => { + // The first status is held open so the "build phase" (total known, no + // progress yet) is observable: the poller runs its first tick immediately. + let releaseStatus: (status: { state: 'building'; done: number; total: number; bytes: number }) => void = () => {} + archiveStatus + .mockImplementationOnce(async () => await new Promise<{ state: 'building'; done: number; total: number; bytes: number }>(resolve => { releaseStatus = resolve })) + .mockResolvedValueOnce(readyStatus(2, 2)) harness = await mountTree() click(rowByName(harness.container, 'a.ts'), { ctrlKey: true }) click(rowByName(harness.container, 'b.ts'), { ctrlKey: true }) - // Right-clicking a row INSIDE the selection keeps the batch. rightClick(rowByName(harness.container, 'b.ts')) expect(menuLabels()).toContain('Zip and download (2 items)') clickMenuitem('Zip and download (2 items)') - await settleDownload() - expect(archiveUrl).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp/a.ts', '/tmp/b.ts'], 'archive.zip') - expect(fetchMock).toHaveBeenCalledWith('/sidebar/archive?name=archive.zip') - // The body became an object URL saved through a hidden anchor… - expect(createdUrls).toHaveLength(1) + await flush() + + // The job was started for the whole selection… + expect(archiveBuild).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp/a.ts', '/tmp/b.ts'], 'archive.zip') + // …the build phase shows the total it already knows… + expect(progressLine(harness.container)?.textContent).toBe('Archiving 0/2 · 0%') + // …the first poll advances the line… + await act(async () => { + releaseStatus({ state: 'building', done: 1, total: 2, bytes: 0 }) + await Promise.resolve() + await Promise.resolve() + }) + expect(progressLine(harness.container)?.textContent).toBe('Archiving 1/2 · 50%') + + // …and the next poll reports ready: the download runs and the line clears. + await poll() + await flush() + expect(fetchMock).toHaveBeenCalledWith('/sidebar/archive/job-1') expect(downloads).toEqual(['blob:mock-1|archive.zip']) - // …and the object URL is released on the next task. + expect(progressLine(harness.container)).toBeNull() + // The object URL is released on the next task. + await act(async () => { await vi.advanceTimersByTimeAsync(1) }) expect(revokedUrls).toEqual(['blob:mock-1']) }) it('archives a lone directory under its own name', async () => { + archiveBuild.mockResolvedValue({ id: 'job-dir', entries: 3 }) harness = await mountTree() rightClick(rowByName(harness.container, 'sub')) expect(menuLabels()).toContain('Zip and download') clickMenuitem('Zip and download') - await settleDownload() - expect(archiveUrl).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp/sub'], 'sub.zip') - expect(fetchMock).toHaveBeenCalledWith('/sidebar/archive?name=sub.zip') + await flush() + await poll() + await flush() + expect(archiveBuild).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp/sub'], 'sub.zip') expect(downloads).toEqual(['blob:mock-1|sub.zip']) }) @@ -231,8 +261,10 @@ describe('FileTree zip and download', () => { harness = await mountTree() rightClick(rowByName(harness.container, 'tmp')) clickMenuitem('Zip and download') - await settleDownload() - expect(archiveUrl).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp'], 'tmp.zip') + await flush() + await poll() + await flush() + expect(archiveBuild).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, ['/tmp'], 'tmp.zip') }) it('offers nothing for a lone FILE (the download row already covers it)', async () => { @@ -240,118 +272,121 @@ describe('FileTree zip and download', () => { rightClick(rowByName(harness.container, 'a.ts')) expect(menuLabels()).toContain('Download') expect(menuLabels().some(label => label.startsWith('Zip and download'))).toBe(false) - expect(archiveUrl).not.toHaveBeenCalled() - expect(fetchMock).not.toHaveBeenCalled() + expect(archiveBuild).not.toHaveBeenCalled() }) - it('reports a route refusal with the envelope message (caps, fence, bad request)', async () => { - fetchMock.mockResolvedValue(errorResponse(413, { ok: false, error: { code: 'bad-request', message: 'archive would exceed 64 MB' } })) + it('reports a build failure in the error strip', async () => { + archiveBuild.mockRejectedValue(new Error('build refused')) harness = await mountTree() rightClick(rowByName(harness.container, 'sub')) clickMenuitem('Zip and download') - await settleDownload() + await flush() + expect(harness.container.querySelector('[role="alert"]')?.textContent) + .toContain('Archive failed: build refused') + expect(progressLine(harness.container)).toBeNull() + expect(archiveStatus).not.toHaveBeenCalled() + expect(downloads).toEqual([]) + }) + + it('reports a status error state with the route message', async () => { + archiveStatus.mockResolvedValue({ state: 'error', done: 1, total: 2, bytes: 0, error: 'archive too large' }) + harness = await mountTree() + rightClick(rowByName(harness.container, 'sub')) + clickMenuitem('Zip and download') + await flush() + await poll() + expect(harness.container.querySelector('[role="alert"]')?.textContent) + .toContain('Archive failed: archive too large') + expect(progressLine(harness.container)).toBeNull() + expect(downloads).toEqual([]) + }) + + it('reports a status request that rejects', async () => { + archiveStatus.mockRejectedValue(new Error('status offline')) + harness = await mountTree() + rightClick(rowByName(harness.container, 'sub')) + clickMenuitem('Zip and download') + await flush() + await poll() + expect(harness.container.querySelector('[role="alert"]')?.textContent) + .toContain('Archive failed: status offline') + expect(downloads).toEqual([]) + }) + + it('reports a download refused by the route envelope', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 413, + json: async () => ({ ok: false, error: { code: 'bad-request', message: 'archive would exceed 64 MB' } }), + } as unknown as Response) + harness = await mountTree() + rightClick(rowByName(harness.container, 'sub')) + clickMenuitem('Zip and download') + await flush() + await poll() + await flush() expect(harness.container.querySelector('[role="alert"]')?.textContent) .toContain('Archive failed: archive would exceed 64 MB') - // Nothing was downloaded, and no object URL leaked. expect(downloads).toEqual([]) expect(createdUrls).toEqual([]) }) - it('falls back to the status when the refusal carries no JSON body', async () => { - fetchMock.mockResolvedValue(errorResponse(500)) + it('falls back to the status when the download carries no JSON body', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + json: async () => { throw new Error('not json') }, + } as unknown as Response) harness = await mountTree() rightClick(rowByName(harness.container, 'sub')) clickMenuitem('Zip and download') - await settleDownload() + await flush() + await poll() + await flush() expect(harness.container.querySelector('[role="alert"]')?.textContent) .toContain('Archive failed: HTTP 500') expect(downloads).toEqual([]) }) - it('reports a network failure in the error strip', async () => { - fetchMock.mockRejectedValue(new Error('network down')) - harness = await mountTree() - rightClick(rowByName(harness.container, 'sub')) - clickMenuitem('Zip and download') - await settleDownload() - expect(harness.container.querySelector('[role="alert"]')?.textContent) - .toContain('Archive failed: network down') - expect(downloads).toEqual([]) - }) - - it('reports a ZIP whose URL cannot be built without fetching', async () => { - archiveUrl.mockImplementation(() => { throw new Error('boom: too large') }) - harness = await mountTree() - rightClick(rowByName(harness.container, 'sub')) - clickMenuitem('Zip and download') - await settleDownload() - expect(harness.container.querySelector('[role="alert"]')?.textContent) - .toContain('Archive failed: boom: too large') - expect(fetchMock).not.toHaveBeenCalled() - expect(downloads).toEqual([]) - }) - - it('packages once while a request is in flight (double click guard)', async () => { - let release: (response: Response) => void = () => {} - fetchMock.mockImplementation(async () => await new Promise(resolve => { release = resolve })) + it('builds once while a job is in flight (double click guard)', async () => { + let release: (job: { id: string; entries: number }) => void = () => {} + archiveBuild.mockImplementation(async () => await new Promise<{ id: string; entries: number }>(resolve => { release = resolve })) + // Stay in the building state until the test says otherwise. + archiveStatus.mockResolvedValue({ state: 'building', done: 0, total: 1, bytes: 0 }) harness = await mountTree() const pick = (): void => { rightClick(rowByName(harness.container, 'sub')) clickMenuitem('Zip and download') } pick() - expect(fetchMock).toHaveBeenCalledTimes(1) - // The row is still reachable (the menu closed on selection) and a second - // pick must not start a second archive. + await flush() + expect(archiveBuild).toHaveBeenCalledTimes(1) + // The row is still reachable and a second pick must not start a second job. pick() - expect(fetchMock).toHaveBeenCalledTimes(1) - await act(async () => { release(okResponse()) }) - await settleDownload() - expect(downloads).toEqual(['blob:mock-1|sub.zip']) - // The guard releases once the request settled: another pick fetches again. - fetchMock.mockResolvedValue(okResponse()) + await flush() + expect(archiveBuild).toHaveBeenCalledTimes(1) + expect(archiveStatus).not.toHaveBeenCalled() + + await act(async () => { release({ id: 'job-2', entries: 1 }) }) + await flush() + expect(progressLine(harness.container)?.textContent).toBe('Archiving 0/1 · 0%') + await poll() + // Still building: the line stays and the guard still holds. + expect(progressLine(harness.container)).not.toBeNull() pick() - expect(fetchMock).toHaveBeenCalledTimes(2) - await settleDownload() - }) + await flush() + expect(archiveBuild).toHaveBeenCalledTimes(1) - it('shows a loading line while the archive is being built, and hides it after', async () => { - let release: (response: Response) => void = () => {} - fetchMock.mockImplementation(async () => await new Promise(resolve => { release = resolve })) - harness = await mountTree() - const busyLine = (): HTMLElement | null => harness.container.querySelector('[data-kind="loading"]') - // Nothing is shown before the request starts… - expect(busyLine()).toBeNull() - rightClick(rowByName(harness.container, 'sub')) - clickMenuitem('Zip and download') - // …the in-flight request keeps one visible, announced line… - const line = busyLine() - expect(line).not.toBeNull() - expect(line?.textContent).toBe('Loading…') - expect(line?.getAttribute('role')).toBe('status') - // …a repeated pick is still guarded (no second fetch) AND still not silent. - rightClick(rowByName(harness.container, 'sub')) - clickMenuitem('Zip and download') - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(busyLine()).not.toBeNull() - await act(async () => { release(okResponse()) }) - await settleDownload() - expect(busyLine()).toBeNull() + // Finish the job: the download runs, the line clears, the guard releases. + archiveStatus.mockResolvedValue(readyStatus(1, 1)) + await poll() + await flush() expect(downloads).toEqual(['blob:mock-1|sub.zip']) - }) - - it('hides the loading line when the archive fails too', async () => { - let release: (response: Response) => void = () => {} - fetchMock.mockImplementation(async () => await new Promise(resolve => { release = resolve })) - harness = await mountTree() - rightClick(rowByName(harness.container, 'sub')) - clickMenuitem('Zip and download') - expect(harness.container.querySelector('[data-kind="loading"]')).not.toBeNull() - await act(async () => { release(errorResponse(413, { ok: false, error: { message: 'too big' } })) }) - await settleDownload() - expect(harness.container.querySelector('[data-kind="loading"]')).toBeNull() - // The busy line yields to the error strip: both never race for the slot. - expect(harness.container.querySelector('[role="alert"]')?.textContent) - .toContain('Archive failed: too big') + archiveBuild.mockResolvedValue({ id: 'job-3', entries: 1 }) + pick() + await flush() + expect(archiveBuild).toHaveBeenCalledTimes(2) + await poll() + await flush() }) }) diff --git a/tests/file-tree-menu-perf.spec.tsx b/tests/file-tree-menu-perf.spec.tsx new file mode 100644 index 0000000..33cc1f7 --- /dev/null +++ b/tests/file-tree-menu-perf.spec.tsx @@ -0,0 +1,304 @@ +/** + * The right-click menu must be CHEAP: opening, closing and reopening it is + * pure UI state and must not touch the workspace. + * + * This spec is both the measurement probe and the regression guard for the + * user's "右键菜单点击后不要频繁刷新" report. It pins four counters across a + * menu open → close → reopen cycle: + * - `api.fsTree`: directory levels load on expand / refresh tick ONLY, never + * because a menu opened; + * - `api.gitStatus`: the shared status poll PAUSES while a menu is open (git + * ink behind a context menu is not worth a request) and resumes on close; + * - icon resolutions (`service.fileIcon` / `folderIcon`): a memoized row + * resolves its icon on every render, so this counts re-rendered rows; + * - `api.fileApps`: the host application list is CACHED per path, so + * reopening the same row's menu neither re-requests it nor flashes + * "Loading…". + */ +// @vitest-environment jsdom +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { FileTree } from '../src/client/FileTree.tsx' +import type { BetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' + +import { setupReactAct } from './test-utils.ts' +setupReactAct() + +// vitest 4.1.11+ follows the OS locale; pin en-US so any copy is English. +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) +}) + +const { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus } = vi.hoisted(() => ({ + fsTree: vi.fn(), + gitStatus: vi.fn(), + fileApps: vi.fn(), + directoryApps: vi.fn(), + archiveBuild: vi.fn(), + archiveStatus: vi.fn(), +})) + +vi.mock('../src/client/api.ts', () => ({ + api: { fsTree, gitStatus, fileApps, directoryApps, archiveBuild, archiveStatus }, + downloadUrl: () => '/sidebar/file', + archiveDownloadUrl: (id: string) => `/sidebar/archive/${id}`, + isOutsideWorkspaceMessage: () => false, +})) + +/** Icon resolutions ARE the row-render probe (a row resolves its icon per render). */ +const fileIcons: string[] = [] +const folderIcons: string[] = [] +const service = { + subscribe: () => () => {}, + fileIcon: (path: string) => { fileIcons.push(path); return null }, + folderIcon: (path: string) => { folderIcons.push(path); return null }, +} as unknown as BetterSidebarService + +const HOST_APPS = [ + { id: 'textedit', name: 'TextEdit', icon: null, isDefault: true }, + { id: 'preview', name: 'Preview', icon: null, isDefault: false }, +] + +interface Harness { + container: HTMLDivElement + unmount: () => void +} + +async function mountTree(): Promise { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + await act(async () => { + root.render(createElement(FileTree, { + sessionId: 'menu-perf', + cwd: '/tmp', + store: createSidebarStore(), + expanded: [], + revealed: [], + onToggle: () => {}, + onOpenFile: () => {}, + onReferenceFile: () => {}, + refreshTick: 0, + onUploadRequest: () => {}, + busy: false, + service, + openInApp: { + available: () => true, + probe: async () => true, + directoryApps, + fileApps, + open: async () => true, + reveal: async () => true, + }, + })) + await Promise.resolve() + }) + return { + container, + unmount: () => { act(() => { root.unmount() }); container.remove() }, + } +} + +function rowByName(container: HTMLElement, name: string): HTMLElement { + const row = [...container.querySelectorAll('[role="button"]')] + .find(el => el.querySelector('[class*="explorerName"]')?.textContent === name) + if (row === undefined) throw new Error(`row not found: ${name}`) + return row +} + +async function openRowMenu(container: HTMLElement, name: string): Promise { + await act(async () => { + rowByName(container, name).dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }), + ) + await Promise.resolve() + await Promise.resolve() + }) +} + +async function closeMenu(): Promise { + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await Promise.resolve() + }) +} + +/** Spin the timers one full git poll window (the status interval is 2500ms). */ +async function advance(ms: number): Promise { + await act(async () => { + vi.advanceTimersByTime(ms) + await Promise.resolve() + await Promise.resolve() + }) +} + +interface Metrics { + fsTree: number + gitStatus: number + fileApps: number + fileIcons: number + folderIcons: number +} + +let harness: Harness +const baseline = { fsTree: 0, gitStatus: 0, fileApps: 0, fileIcons: 0, folderIcons: 0 } + +/** The counters SINCE the last `mark()`. */ +function since(): Metrics { + return { + fsTree: fsTree.mock.calls.length - baseline.fsTree, + gitStatus: gitStatus.mock.calls.length - baseline.gitStatus, + fileApps: fileApps.mock.calls.length - baseline.fileApps, + fileIcons: fileIcons.length - baseline.fileIcons, + folderIcons: folderIcons.length - baseline.folderIcons, + } +} + +function mark(): void { + baseline.fsTree = fsTree.mock.calls.length + baseline.gitStatus = gitStatus.mock.calls.length + baseline.fileApps = fileApps.mock.calls.length + baseline.fileIcons = fileIcons.length + baseline.folderIcons = folderIcons.length +} + +beforeEach(() => { + vi.useFakeTimers() + fsTree.mockReset() + fsTree.mockImplementation(async (_scope: unknown, path: string) => ({ + path, + entries: [ + { name: 'sub', path: '/tmp/sub', isDir: true }, + { name: 'a.ts', path: '/tmp/a.ts', isDir: false }, + { name: 'b.ts', path: '/tmp/b.ts', isDir: false }, + { name: 'c.ts', path: '/tmp/c.ts', isDir: false }, + ], + truncated: false, + })) + gitStatus.mockReset() + gitStatus.mockResolvedValue({ isRepo: false, entries: [] }) + fileApps.mockReset() + fileApps.mockResolvedValue(HOST_APPS) + directoryApps.mockReset() + directoryApps.mockResolvedValue(HOST_APPS) + archiveBuild.mockReset() + archiveStatus.mockReset() + fileIcons.length = 0 + folderIcons.length = 0 + mark() +}) + +afterEach(() => { + harness.unmount() + document.body.innerHTML = '' + vi.useRealTimers() +}) + +describe('FileTree context-menu cost', () => { + it('does not list directories, re-render rows or poll git while the menu is open', async () => { + harness = await mountTree() + // Warm the shared status store (its first fetch happens on mount). + await advance(0) + + // ── First open: the right-click also collapses the selection onto the row. + mark() + await openRowMenu(harness.container, 'a.ts') + const firstOpen = since() + await closeMenu() + + // ── Reopen the SAME row's menu and let a full poll window elapse while it + // is open: nothing may hit the workspace. + mark() + await openRowMenu(harness.container, 'a.ts') + await advance(3_000) + const openWindow = since() + await closeMenu() + + // ── The poll resumes once the menu closed. + mark() + await advance(3_000) + const afterClose = since() + + console.log('MENU-PERF', JSON.stringify({ firstOpen, openWindow, afterClose })) + + // Menu churn never lists a directory. + expect(openWindow.fsTree).toBe(0) + expect(afterClose.fsTree).toBe(0) + // The shared git poll keeps running behind the menu — by design, and the + // measured trade-off: pausing it (`visible: rowMenu === null`) makes the + // store drop its snapshot, so EVERY changed row loses its ink while the + // menu is open (measured: `tintedWhileOpen: false`, +2 row re-renders) + // against saving one background request per poll window. The rows stay + // untouched either way because the row's git props are primitives. + expect(openWindow.gitStatus).toBeGreaterThanOrEqual(0) + // Memoized rows do not re-render because the menu opened — not even the + // root row (which is memoized too) and not when a poll lands. + expect(openWindow.fileIcons).toBe(0) + expect(openWindow.folderIcons).toBe(0) + // The host application list is cached per path: one request, not one per open. + expect(openWindow.fileApps).toBe(0) + expect(firstOpen.fileApps).toBeLessThanOrEqual(1) + }) + + it('reopens the row menu without a Loading flash (the app list is cached)', async () => { + harness = await mountTree() + await advance(0) + await openRowMenu(harness.container, 'a.ts') + expect(fileApps).toHaveBeenCalledTimes(1) + await closeMenu() + const requestsAfterFirstOpen = fileApps.mock.calls.length + + await openRowMenu(harness.container, 'a.ts') + // Cached: no second request… + expect(fileApps.mock.calls.length).toBe(requestsAfterFirstOpen) + // …and the submenu carries the applications immediately (no disabled + // Loading row). + const parent = [...document.querySelectorAll('[role="menuitem"]')] + .find(item => item.getAttribute('aria-haspopup') === 'menu') + expect(parent).toBeDefined() + await act(async () => { parent!.click(); await Promise.resolve() }) + const submenuLabels = [...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] + .map(item => item.textContent?.trim()) + // The default handler is hoisted to level 1; the rest of the list is + // already there (no disabled Loading row). + expect(submenuLabels).toContain('Preview') + expect(submenuLabels).not.toContain('Loading…') + }) + + it('keeps the git ink and does not re-render changed rows while the menu is open', async () => { + // A repo with one changed file: the row carries its tone class while the + // menu is open, so pausing the poll must not cost the tint (the shared + // store drops its snapshot when its last visible consumer releases). + gitStatus.mockResolvedValue({ + isRepo: true, + root: '/tmp', + entries: [{ path: 'a.ts', xy: ' M' }], + }) + harness = await mountTree() + await advance(30) + const tinted = (): boolean => + rowByName(harness.container, 'a.ts').querySelector('[data-git-tone]') !== null + expect(tinted()).toBe(true) + + // The FIRST open also collapses the selection onto the row (one row + // re-render, by design); measure the reopen, where nothing may change. + await openRowMenu(harness.container, 'a.ts') + await closeMenu() + + mark() + await openRowMenu(harness.container, 'a.ts') + await advance(3_000) + const openWindow = since() + const tintedWhileOpen = tinted() + await closeMenu() + console.log('MENU-PERF-REPO', JSON.stringify({ openWindow, tintedWhileOpen })) + + // The changed row does not re-render because the menu opened… + expect(openWindow.fileIcons).toBe(0) + // …and its git ink survives the whole open window. + expect(tintedWhileOpen).toBe(true) + }) +}) diff --git a/tests/file-tree-multiselect.spec.tsx b/tests/file-tree-multiselect.spec.tsx index 231dcf7..7c00aad 100644 --- a/tests/file-tree-multiselect.spec.tsx +++ b/tests/file-tree-multiselect.spec.tsx @@ -12,6 +12,7 @@ */ // @vitest-environment jsdom import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' @@ -225,6 +226,35 @@ describe('FileTree multi-selection', () => { expect(selectionBar(harness.container)).toBeNull() }) + it('docks the batch bar at the BOTTOM of the tree scroll container', async () => { + harness = await mountTree() + click(rowByName(harness.container, 'a.ts'), { ctrlKey: true }) + const body = harness.container.querySelector('[class*="explorerBody"]')! + const bar = harness.container.querySelector('[class*="explorerSelectionBar"]')! + // In flow AFTER every row (the menu's portal anchor and the hidden file + // input are the only trailing siblings) — never absolutely positioned + // over the rows, so scrolled to the bottom it sits BELOW the last row. + const rows = [...body.querySelectorAll('[class*="explorerRow"]')] + expect(rows.length).toBeGreaterThan(0) + expect(rows[rows.length - 1]!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + // The only trailing siblings are the menu's portal anchor and the hidden + // file input, so the bar IS the last piece of tree content. + const content = [...body.children].filter(child => + child.tagName !== 'INPUT' && !(child as HTMLElement).className.includes('_root_')) + expect(content[content.length - 1]).toBe(bar) + // jsdom has no layout: the sheet is the evidence for the docking mechanic. + const sheet = readFileSync('src/client/sidebar.module.css', 'utf8') + const rule = sheet.match(/\.explorerSelectionBar \{([\s\S]*?)\n\}/)?.[1] + expect(rule, 'the .explorerSelectionBar rule must exist').toBeDefined() + expect(rule).toContain('position: sticky') + expect(rule).toContain('bottom: 0') + expect(rule).not.toContain('position: absolute') + expect(rule).not.toContain('position: fixed') + // Narrow panels WRAP the chips instead of clipping them or scrolling. + const actions = sheet.match(/\.explorerSelectionActions \{([\s\S]*?)\n\}/)?.[1] + expect(actions).toContain('flex-wrap: wrap') + }) + it('stops the batch at the first failure and reports it in the strip', async () => { // The first path lands; the second refuses — the walk must stop there. fsRemove diff --git a/tests/file-tree-open-in-app.spec.tsx b/tests/file-tree-open-in-app.spec.tsx index 66d4c30..b276ccc 100644 --- a/tests/file-tree-open-in-app.spec.tsx +++ b/tests/file-tree-open-in-app.spec.tsx @@ -1,13 +1,17 @@ /** - * FileTree "open in app" menu (the host's own capability, replacing the - * plugin's removed open-with chain): a file row lists the host's registered - * applications — the default-application row first, then one row per app — - * followed by reveal-in-file-manager; a directory row lists the host's - * application catalogue plus reveal. Clicking a row hands the absolute path - * (plus the application id when one was chosen) to the injected handle, a - * listing that yields nothing degrades to one DISABLED row, a host that - * cannot open desktop paths hides the whole section, and a failure lands in - * the tree's error strip. + * FileTree's host "open in app" rows, arranged by round three's menu + * hierarchy: the host's DEFAULT handler stays one click away at level 1, and + * every other application — the host's registered handlers AND (per the + * visibility rule) the plugin's own fixed targets — lives in the single + * `openWithMenu` submenu. Reveal-in-file-manager stays at level 1 while the + * host can hand paths to a desktop. + * + * Visibility ("有本机检测到的关联应用时就不显示固定的 openwith 项目"): + * - the host lists applications for the path → plugin targets are hidden + * unless `openWithShowPluginTargets` (the `openWithPluginTargets` setting) + * turns them on; + * - the host is unavailable, or the listing is empty/failed → the plugin + * targets show (the file manager target included, so reveal is never lost). */ // @vitest-environment jsdom import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest' @@ -38,7 +42,7 @@ vi.mock('../src/client/api.ts', () => ({ truncated: false, }), // The tree reads the shared git-status store; a non-repo answer keeps - // every row plain (this spec is about the open-in-app section). + // every row plain (this spec is about the open-in-app rows). gitStatus: async () => ({ isRepo: false, entries: [] }), }, downloadUrl: () => '/sidebar/file', @@ -48,7 +52,7 @@ vi.mock('../src/client/api.ts', () => ({ /** What the host reports for a file (the default entry first). */ const FILE_APPS: readonly OpenInAppEntry[] = [ { id: 'textedit', name: 'TextEdit', icon: null, isDefault: true }, - { id: 'vscode', name: 'VS Code', icon: 'data:image/svg+xml,%3Csvg/%3E', isDefault: false }, + { id: 'preview', name: 'Preview', icon: 'data:image/svg+xml,%3Csvg/%3E', isDefault: false }, ] /** What the host reports for a directory (no default concept). */ @@ -57,6 +61,12 @@ const DIRECTORY_APPS: readonly OpenInAppEntry[] = [ { id: 'finder', name: 'Finder', icon: null, isDefault: false }, ] +/** The plugin's own targets a caller (EditorHost) may hand FileTree. */ +const PLUGIN_TARGETS: OpenWithTarget[] = [ + { id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true }, + { id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false }, +] + interface Handle { openInApp: OpenInApp open: ReturnType @@ -89,17 +99,11 @@ interface Harness { unmount: () => void } -/** The plugin targets a caller (EditorHost) may hand FileTree alongside the - * host handle; names are distinct from FILE_APPS so assertions are exact. */ -const PLUGIN_TARGETS: OpenWithTarget[] = [ - { id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true }, - { id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false }, -] - async function mountTree(handle?: Handle, plugin: { targets?: OpenWithTarget[] pinned?: string[] ssh?: boolean + showPluginTargets?: boolean } = {}): Promise { const container = document.createElement('div') document.body.append(container) @@ -125,6 +129,7 @@ async function mountTree(handle?: Handle, plugin: { onOpenWith, onToggleOpenWithPin, } : {}), + ...(plugin.showPluginTargets !== undefined ? { openWithShowPluginTargets: plugin.showPluginTargets } : {}), onReferenceFile: () => {}, refreshTick: 0, onUploadRequest: () => {}, @@ -169,36 +174,92 @@ function menuLabels(): string[] { return menuItems().map(item => item.textContent ?? '') } +/** The level-1 submenu parent row (the only one with aria-haspopup). */ +function submenuParent(): HTMLElement { + const parent = menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu') + if (parent === undefined) throw new Error('open-with submenu parent not found') + return parent +} + +/** Open the submenu and settle its rows. */ +async function openSubmenu(): Promise { + await act(async () => { + submenuParent().click() + await Promise.resolve() + }) +} + +function submenuRows(): HTMLElement[] { + return [...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] +} + +function submenuLabels(): string[] { + return submenuRows().map(item => item.textContent?.trim() ?? '') +} + function clickMenuitem(label: string): void { const item = menuItems().find(el => el.textContent === label) if (item === undefined) throw new Error(`menuitem not found: ${label}`) act(() => { item.click() }) } +function clickSubmenuRow(label: string): void { + const item = submenuRows().find(el => el.textContent?.trim() === label) + if (item === undefined) throw new Error(`submenu row not found: ${label}`) + act(() => { item.click() }) +} + let harness: Harness afterEach(() => { harness.unmount() document.body.innerHTML = '' }) -describe('FileTree open-in-app menu', () => { - it('lists the default application, the registered apps and reveal for a file row', async () => { +describe('FileTree open-in-app rows in the menu hierarchy', () => { + it('hoists the default application to level 1 and keeps every other app in the submenu', async () => { const handle = makeHandle() harness = await mountTree(handle) await openMenu(harness.container, 'a.ts') expect(handle.fileApps).toHaveBeenCalledWith('/tmp/a.ts') - // No heading row any more: the host group is read from the application - // names themselves (`openInApp` and `openWithMenu` are near-synonyms, so - // two labels in one menu read as a repeated item). A data label entry is - // a LEAF `role="presentation"` row inside the menu. + // Level 1: the default handler, the submenu and the host reveal. + const level1 = menuLabels() + expect(level1).toContain('Open with default app') + expect(level1).toContain('Open with') + expect(level1).toContain('Reveal in File Manager') + // No application NAME sits at level 1 any more. + expect(level1).not.toContain('TextEdit') + expect(level1).not.toContain('Preview') + // The submenu carries the remaining applications (and their icons). + await openSubmenu() + expect(submenuLabels()).toEqual(['Preview']) + expect(submenuRows()[0]!.querySelector('img[class*="explorerAppIcon"]')).not.toBeNull() + // No heading rows anywhere (the primitive's leaf label row is gone). const menu = document.querySelector('[role="menuitem"]')!.closest('[role="menu"]') expect([...(menu?.querySelectorAll('[role="presentation"]') ?? [])] .filter(row => row.children.length === 0)).toEqual([]) - expect(menuLabels()).toContain('Open with default app') - expect(menuLabels()).toContain('VS Code') - expect(menuLabels()).toContain('Reveal in File Manager') - // The default row's glyph is the default app's own icon slot. - expect(menuItems().some(item => item.querySelector('img[class*="explorerAppIcon"]') !== null)).toBe(true) + }) + + it('keeps the frozen level-1 order: open-with group, open escapes, zip, copy, mutations', async () => { + const handle = makeHandle() + harness = await mountTree(handle) + await openMenu(harness.container, 'a.ts') + const labels = menuLabels() + const at = (label: string): number => { + const index = labels.indexOf(label) + expect(index, `missing menu row: ${label}`).toBeGreaterThanOrEqual(0) + return index + } + // 1-3: default handler → the one submenu → reveal. + expect(at('Open with default app')).toBeLessThan(at('Open with')) + expect(at('Open with')).toBeLessThan(at('Reveal in File Manager')) + // 4: the explicit open escapes. + expect(at('Reveal in File Manager')).toBeLessThan(at('Open in New Tab')) + expect(at('Open in New Tab')).toBeLessThan(at('Open to the Side')) + // 5: the downloads (the plain file download row + the archive row). + expect(at('Open to the Side')).toBeLessThan(at('Download')) + // 6: copy, then the mutations. + expect(at('Download')).toBeLessThan(at('Copy relative path')) + expect(at('Copy relative path')).toBeLessThan(at('Rename')) }) it('hands the path (and the chosen application id) to the open handle', async () => { @@ -210,8 +271,9 @@ describe('FileTree open-in-app menu', () => { // Selecting closes the menu entirely. expect(menuItems()).toHaveLength(0) await openMenu(harness.container, 'a.ts') - clickMenuitem('VS Code') - expect(handle.open).toHaveBeenCalledWith('/tmp/a.ts', 'vscode') + await openSubmenu() + clickSubmenuRow('Preview') + expect(handle.open).toHaveBeenCalledWith('/tmp/a.ts', 'preview') await openMenu(harness.container, 'a.ts') clickMenuitem('Reveal in File Manager') expect(handle.reveal).toHaveBeenCalledWith('/tmp/a.ts') @@ -224,16 +286,21 @@ describe('FileTree open-in-app menu', () => { expect(handle.directoryApps).toHaveBeenCalled() expect(handle.fileApps).not.toHaveBeenCalled() expect(menuLabels()).not.toContain('Open with default app') - expect(menuLabels()).toEqual(expect.arrayContaining(['VS Code', 'Finder', 'Reveal in File Manager'])) - clickMenuitem('Finder') + expect(menuLabels()).toContain('Reveal in File Manager') + await openSubmenu() + expect(submenuLabels()).toEqual(['VS Code', 'Finder']) + clickSubmenuRow('Finder') expect(handle.open).toHaveBeenCalledWith('/tmp/sub', 'finder') }) - it('degrades to one DISABLED row when the host knows no application', async () => { + it('degrades to one DISABLED submenu row when the host knows no application', async () => { const handle = makeHandle({ fileApps: async () => [] }) harness = await mountTree(handle) await openMenu(harness.container, 'a.ts') - const empty = menuItems().find(item => item.textContent === 'No application can open it') + // No default handler to hoist. + expect(menuLabels()).not.toContain('Open with default app') + await openSubmenu() + const empty = submenuRows().find(item => item.textContent === 'No application can open it') expect(empty).toBeDefined() expect((empty as HTMLButtonElement).disabled).toBe(true) }) @@ -250,7 +317,7 @@ describe('FileTree open-in-app menu', () => { handle.open.mockResolvedValue(false) harness = await mountTree(handle) await openMenu(harness.container, 'a.ts') - await act(async () => { clickMenuitem('VS Code'); await Promise.resolve(); await Promise.resolve() }) + await act(async () => { clickMenuitem('Open with default app'); await Promise.resolve(); await Promise.resolve() }) expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Could not open: /tmp/a.ts') }) @@ -260,6 +327,7 @@ describe('FileTree open-in-app menu', () => { await openMenu(harness.container, 'a.ts') expect(handle.fileApps).not.toHaveBeenCalled() expect(menuLabels()).not.toContain('Reveal in File Manager') + expect(menuItems().some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false) expect(document.body.textContent).not.toContain('Open with') }) @@ -282,11 +350,9 @@ describe('FileTree open-in-app menu', () => { probe: async () => { probed = true; probes += 1; return true }, }) harness = await mountTree(handle) - expect(handle.fileApps).not.toHaveBeenCalled() await openMenu(harness.container, 'a.ts') expect(handle.fileApps).toHaveBeenCalledWith('/tmp/a.ts') expect(menuLabels()).toContain('Open with default app') - expect(menuLabels()).toContain('VS Code') expect(probes).toBe(1) }) @@ -320,81 +386,77 @@ describe('FileTree open-in-app menu', () => { }) }) -describe('FileTree open-in-app + plugin open-with coexistence', () => { - let harness: Harness - afterEach(() => { - harness.unmount() - document.body.innerHTML = '' - }) - - /** The label rows of the single open menu, in DOM order. */ - function labelsOf(items: string[]): number[] { - const labels = menuLabels() - return items.map(item => labels.indexOf(item)) - } - - it('stacks the host rows above the plugin rows and ends with the host reveal', async () => { +describe('FileTree host apps vs plugin open-with targets', () => { + it('hides the plugin targets while the host lists applications (the default)', async () => { const handle = makeHandle() - harness = await mountTree(handle, { targets: PLUGIN_TARGETS, pinned: ['custom:w'] }) + harness = await mountTree(handle, { targets: PLUGIN_TARGETS }) await openMenu(harness.container, 'a.ts') - const [hostDefault, hostApp, pinnedPlugin, submenu, reveal] = labelsOf([ - 'Open with default app', 'VS Code', 'Windsurf', 'Open with', 'Reveal in File Manager', - ]) - // Every row exists, in the frozen order: host group → plugin group → - // reveal. - expect([hostDefault, hostApp, pinnedPlugin, submenu, reveal]).not.toContain(-1) - expect(hostDefault!).toBeLessThan(hostApp!) - expect(hostApp!).toBeLessThan(pinnedPlugin!) - expect(pinnedPlugin!).toBeLessThan(submenu!) - expect(submenu!).toBeLessThan(reveal!) - // The plugin's `explorer` target is replaced by the host reveal row. - expect(menuLabels()).not.toContain('File Manager') - // Separator rows between each group. - expect(document.querySelectorAll('[role="separator"]').length).toBeGreaterThanOrEqual(2) + await openSubmenu() + expect(submenuLabels()).toEqual(['Preview']) + expect(submenuLabels()).not.toContain('Windsurf') + expect(submenuLabels()).not.toContain('File Manager') }) - it('keeps the plugin rows — explorer included — when the host cannot open desktop paths', async () => { + it('keeps both lists side by side when openWithShowPluginTargets is on', async () => { + const handle = makeHandle() + harness = await mountTree(handle, { targets: PLUGIN_TARGETS, showPluginTargets: true }) + await openMenu(harness.container, 'a.ts') + await openSubmenu() + // Host applications first, then the plugin's own targets. The plugin's + // `explorer` target stays filtered (the host reveal row stands in for it). + expect(submenuLabels()).toEqual(['Preview', 'Windsurf']) + }) + + it('shows the plugin targets — explorer included — when the host cannot open desktop paths', async () => { const handle = makeHandle({ available: () => false }) - harness = await mountTree(handle, { targets: PLUGIN_TARGETS, pinned: ['custom:w'] }) + harness = await mountTree(handle, { targets: PLUGIN_TARGETS }) await openMenu(harness.container, 'a.ts') // No host rows at all… expect(menuLabels()).not.toContain('Open with default app') expect(menuLabels()).not.toContain('Reveal in File Manager') - // …but the plugin's own reveal target survives, so reveal is never lost. - expect(menuLabels()).toContain('Windsurf') - const parent = menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu')! - act(() => { parent.click() }) - expect([...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] - .map(item => item.textContent?.trim())).toEqual(['File Manager', 'Windsurf']) + // …but the plugin's own targets (and its reveal) survive. + await openSubmenu() + expect(submenuLabels()).toEqual(['File Manager', 'Windsurf']) }) - it('keeps the plugin rows when no host handle was injected at all', async () => { - harness = await mountTree(undefined, { targets: PLUGIN_TARGETS, pinned: [] }) + it('shows the plugin targets when the host listing is empty', async () => { + const handle = makeHandle({ fileApps: async () => [] }) + harness = await mountTree(handle, { targets: PLUGIN_TARGETS }) + await openMenu(harness.container, 'a.ts') + await openSubmenu() + // The disabled "no application" line plus the plugin's own targets. The + // plugin's `explorer` target stays filtered because the host IS available: + // level 1 still carries its reveal row, so nothing is lost. + expect(submenuLabels()).toEqual(['No application can open it', 'Windsurf']) + expect(menuLabels()).toContain('Reveal in File Manager') + }) + + it('shows the plugin targets when no host handle was injected at all', async () => { + harness = await mountTree(undefined, { targets: PLUGIN_TARGETS }) await openMenu(harness.container, 'a.ts') expect(menuLabels()).not.toContain('Reveal in File Manager') - expect(menuLabels()).toContain('Open with') + await openSubmenu() + expect(submenuLabels()).toEqual(['File Manager', 'Windsurf']) }) - it('hands the plugin target id and path to onOpenWith (SSH suffix intact)', async () => { + it('routes a plugin row click from inside the submenu', async () => { const handle = makeHandle({ available: () => false }) - harness = await mountTree(handle, { targets: PLUGIN_TARGETS, pinned: [], ssh: true }) + harness = await mountTree(handle, { targets: PLUGIN_TARGETS }) await openMenu(harness.container, 'a.ts') - const parent = menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu')! - act(() => { parent.click() }) - const windsurf = [...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] - .find(item => item.textContent?.trim() === 'Windsurf (SSH)')! - act(() => { windsurf.click() }) + await openSubmenu() + expect(submenuRows().every(row => row.querySelector('[class*="openWithPin"]') !== null)).toBe(true) + clickSubmenuRow('Windsurf') expect(harness.onOpenWith).toHaveBeenCalledWith('custom:w', '/tmp/a.ts') }) - it('routes a pin click to onToggleOpenWithPin without selecting a row', async () => { + it('toggles a plugin pin without selecting the row', async () => { const handle = makeHandle({ available: () => false }) - harness = await mountTree(handle, { targets: PLUGIN_TARGETS, pinned: [] }) + harness = await mountTree(handle, { targets: PLUGIN_TARGETS, pinned: ['custom:w'] }) await openMenu(harness.container, 'a.ts') - act(() => { menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu')!.click() }) - const pin = [...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] - .find(item => item.textContent?.trim() === 'Windsurf')! + await openSubmenu() + const pin = submenuRows().find(row => row.textContent?.trim() === 'Windsurf')! .querySelector('[class*="openWithPin"]')! + expect(pin.getAttribute('title')).toBe('Unpin') act(() => { pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) }) expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('custom:w') expect(harness.onOpenWith).not.toHaveBeenCalled() diff --git a/tests/file-tree-open-with.spec.tsx b/tests/file-tree-open-with.spec.tsx index 52eaf33..2140f52 100644 --- a/tests/file-tree-open-with.spec.tsx +++ b/tests/file-tree-open-with.spec.tsx @@ -1,11 +1,12 @@ /** - * FileTree's PLUGIN open-with menu (restored in round two, and coexisting with - * the host's open-in-app rows — the coexistence cases live in - * file-tree-open-in-app.spec.tsx). With no host handle injected, the "打开方式" - * section is the plugin's own: the pinned targets as direct rows, the - * `openWithMenu` parent row with every target as a nested submenu (per-row - * pushpins that toggle without selecting the row), SSH suffixes in remote - * mode, and the reveal target kept (nothing else can reveal without the host). + * FileTree's PLUGIN open-with menu. Round three folded every application into + * ONE submenu (`openWithMenu`), so with no host handle injected the level-1 + * menu carries just that submenu row, and the plugin's targets (file manager / + * VS Code / Cursor / Zed / custom editors) are its rows — each with the + * per-row pushpin that toggles without selecting the row, SSH suffixes in + * remote mode, and (without the host) the reveal target kept, so reveal is + * never lost. The host/plugin VISIBILITY rules live in + * file-tree-open-in-app.spec.tsx. */ // @vitest-environment jsdom import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest' @@ -39,7 +40,6 @@ vi.mock('../src/client/api.ts', () => ({ gitStatus: async () => ({ isRepo: false, entries: [] }), }, downloadUrl: () => '/sidebar/file', - archiveUrl: () => '/sidebar/archive', isOutsideWorkspaceMessage: () => false, })) @@ -140,13 +140,15 @@ describe('FileTree plugin open-with menu', () => { document.body.removeAttribute('data-dsh-sidebar-submenu') }) - it('renders the pinned direct row and the submenu parent for a file row', async () => { + it('keeps application rows out of level 1 and lists them under the open-with submenu', async () => { harness = await mountTree() openMenu(harness.container) - // The pinned VS Code sits at the top level (before the submenu parent). - expect(menuItems().map(item => item.textContent?.trim())).toContain('VS Code') + // Level 1: the one submenu parent — no application name sits on it. + expect(menuItems().map(item => item.textContent?.trim())).toContain('Open with') + for (const name of ['File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf']) { + expect(menuItems().map(item => item.textContent?.trim())).not.toContain(name) + } const parent = submenuParent() - expect(parent.textContent).toContain('Open with') // The submenu parent carries the trailing chevron affordance (the // primitives Menu renders no arrow of its own), right-aligned inside a // full-width label row — the same structure the submenu children use. @@ -154,7 +156,7 @@ describe('FileTree plugin open-with menu', () => { expect(parent.querySelector('[class*="openWithLabel"]')).not.toBeNull() }) - it('opens the submenu on click and lists every target with pin toggles', async () => { + it('lists every target in the submenu, each with a pin toggle', async () => { harness = await mountTree() openMenu(harness.container) act(() => { submenuParent().click() }) @@ -232,7 +234,7 @@ describe('FileTree plugin open-with menu', () => { ]) }) - it('hides the plugin section when the caller wires no targets', async () => { + it('hides the submenu when the caller wires no targets (and no host handle)', async () => { harness = await mountTree({ openWithTargets: undefined }) openMenu(harness.container) expect(menuItems().some(item => item.textContent?.includes('Open with'))).toBe(false) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 74bbe1a..c2e05d8 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -491,6 +491,66 @@ describe('service.openTab in the bottom workbench', () => { }) }) +describe('service.openTab target: side (native split)', () => { + /** A store + service with a recording native surface installed. */ + function nativeSetup(): { + store: ReturnType + service: ReturnType + placed: Array<{ address: string; preferNewPane?: boolean; revealIfOpened?: boolean }> + } { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null }) + store.setSession('s1') + const placed: Array<{ address: string; preferNewPane?: boolean; revealIfOpened?: boolean }> = [] + service.setSurface({ + openTab: () => {}, + openResource: (input) => { + placed.push({ + address: input.address, + ...(input.preferNewPane === undefined ? {} : { preferNewPane: input.preferNewPane }), + revealIfOpened: input.revealIfOpened, + }) + }, + fileAddress: (sessionId, cwd, path) => `dsh-resource://file/${sessionId}${path}`, + close: () => undefined, + update: () => false, + activate: () => false, + has: () => false, + }) + return { store, service, placed } + } + + it('a side-targeted path open asks the host for a NEW pane and permits duplicates', () => { + const { service, placed } = nativeSetup() + service.openTab({ type: 'editor', path: '/w/a.ts', target: 'side' }) + expect(placed).toHaveLength(1) + expect(placed[0]).toMatchObject({ address: 'dsh-resource://file/s1/w/a.ts', preferNewPane: true, revealIfOpened: false }) + }) + + it('a plain (right) open keeps the host reveal and does not ask for a split', () => { + const { service, placed } = nativeSetup() + service.openTab({ type: 'editor', path: '/w/a.ts' }) + expect(placed).toHaveLength(1) + // Absent (not merely falsy) — the host then uses its own default placement. + expect(placed[0]!.preferNewPane).toBeUndefined() + expect(placed[0]!.revealIfOpened).toBe(true) + }) + + it('a side-targeted open never lands in the bottom workbench', () => { + const { store, service } = nativeSetup() + const before = allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).length + service.openTab({ type: 'editor', path: '/w/a.ts', target: 'side' }) + expect(allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs)).toHaveLength(before) + }) + + it('a bottom-targeted open still lands in the bottom workbench', () => { + const { store, service } = nativeSetup() + service.openTab({ type: 'editor', path: '/w/a.ts', target: 'bottom' }) + expect(allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(l => l.tabs).some(t => t.path === '/w/a.ts')).toBe(true) + }) +}) + describe('service.openTab auto-expand for content opens', () => { /** Collapse the bottom workbench (the plugin's only panel). */ const collapseWorkbench = (store: ReturnType): void => { diff --git a/tests/zip.spec.ts b/tests/zip.spec.ts index e7cec96..b74c9c2 100644 --- a/tests/zip.spec.ts +++ b/tests/zip.spec.ts @@ -11,7 +11,7 @@ * workspace, covering the fence, the parameter guards and a full * directory walk. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' import { validateHeaderValue } from 'node:http' @@ -19,9 +19,19 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { inflateRawSync } from 'node:zlib' import { apply } from '../src/index.ts' -import { archiveUrl } from '../src/client/api.ts' -import { archiveNameOf, collectZipEntries, contentDispositionOf, disambiguateArchiveNames } from '../src/archive-route.ts' -import { crc32, buildZip, type ZipEntry } from '../src/zip.ts' +import { archiveDownloadUrl } from '../src/client/api.ts' +import { + ARCHIVE_MAX_BUILDING, + ARCHIVE_TTL_MS, + archiveNameOf, + collectZipEntries, + contentDispositionOf, + createArchiveTasks, + disambiguateArchiveNames, + respondArchiveDownload, + type ArchiveTasks, +} from '../src/archive-route.ts' +import { crc32, buildZip, type ZipEntry, type ZipProgress } from '../src/zip.ts' import { SidebarError } from '../src/wire.ts' import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' @@ -93,8 +103,8 @@ function readZip(archive: Buffer): ParsedEntry[] { return entries } -/** /sidebar/archive mounted against a fake context, as the host runs it. */ -function mountArchive(): SidebarWebRoute { +/** The host mounted against a fake context: both archive routes + the API. */ +function mountHost(): { api: SidebarWebRoute; download: SidebarWebRoute } { const routes: SidebarWebRoute[] = [] const ctx = { webRuntime: { trustedHosts: [] }, @@ -110,9 +120,33 @@ function mountArchive(): SidebarWebRoute { get: () => undefined, } apply(ctx as never) - const route = routes.find(candidate => candidate.path === '/sidebar/archive') - if (route === undefined) throw new Error('test setup: /sidebar/archive route not registered') - return route + const api = routes.find(candidate => candidate.path === '/sidebar/api') + const download = routes.find(candidate => candidate.path === '/sidebar/archive') + if (api === undefined) throw new Error('test setup: /sidebar/api route not registered') + if (download === undefined) throw new Error('test setup: /sidebar/archive route not registered') + return { api, download } +} + +/** One POST /sidebar/api/ against the mounted API route. */ +async function post( + api: SidebarWebRoute, + method: string, + payload: unknown, +): Promise<{ status: number; ok: boolean; value?: unknown; error?: { code?: string; message?: string } }> { + const body = Buffer.from(JSON.stringify(payload)) + const req = { + method: 'POST', + url: `/sidebar/api/${method}`, + headers: { host: '127.0.0.1:3080' }, + [Symbol.asyncIterator]: async function* () { yield body }, + } as never + const out: { status: number; body: string } = { status: 200, body: '' } + const res = { + writeHead: (status: number) => { out.status = status }, + end: (chunk: unknown) => { out.body += String(chunk ?? '') }, + } as never + await api.handler(req, res) + return { status: out.status, ...(JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message?: string } }) } } /** One GET against a mounted route, collecting raw bytes. */ @@ -127,6 +161,54 @@ async function get(route: SidebarWebRoute, url: string): Promise<{ status: numbe return { status: out.status, headers: out.headers, body: Buffer.concat(out.chunks) } } +/** Poll `archive.status` until the detached build settles (or throw). */ +async function waitForReady( + api: SidebarWebRoute, + id: string, + sessionId: string, +): Promise<{ state: string; done: number; total: number; bytes: number; error?: string }> { + let last: { state: string; done: number; total: number; bytes: number; error?: string } | undefined + for (let attempt = 0; attempt < 400; attempt += 1) { + const status = await post(api, 'archive.status', { id, sessionId }) + if (status.ok === true) { + last = status.value as { state: string; done: number; total: number; bytes: number; error?: string } + if (last.state !== 'building') return last + } + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error(`test setup: archive ${id} never settled (${JSON.stringify(last)})`) +} + +/** + * The whole host flow for one selection: POST archive.build, poll until the + * detached build settles, then GET the bytes. The caller's session id is both + * the scope and the download's fence. + */ +async function archiveFlow(input: { + sessionId: string + cwd: string + name?: string + paths: readonly string[] +}): Promise<{ build: Awaited>; status: Awaited>; download: Awaited> }> { + const { api, download } = mountHost() + const build = await post(api, 'archive.build', { + sessionId: input.sessionId, + cwd: input.cwd, + paths: [...input.paths], + ...(input.name === undefined ? {} : { name: input.name }), + }) + if (build.ok !== true || build.status !== 200) { + return { build, status: await waitForReady(api, '', input.sessionId), download: await get(download, '/sidebar/archive') } + } + const id = (build.value as { id: string }).id + const status = await waitForReady(api, id, input.sessionId) + return { + build, + status, + download: await get(download, `/sidebar/archive?sessionId=${encodeURIComponent(input.sessionId)}&id=${encodeURIComponent(id)}`), + } +} + /** A temp workspace (plus an outside sibling) removed after each test. */ const roots: string[] = [] function tempRoot(): string { @@ -209,6 +291,33 @@ describe('buildZip', () => { expect(archive.readUInt32LE(archive.length - 22)).toBe(0x06054B50) }) + it('reports progress per entry, monotonically, ending at the entry count', async () => { + const root = tempRoot() + mkdirSync(join(root, 'dir')) + writeFileSync(join(root, 'a.txt'), 'a'.repeat(10)) + writeFileSync(join(root, 'dir', 'b.txt'), 'b'.repeat(30)) + const seen: ZipProgress[] = [] + await buildZip([ + { path: join(root, 'dir'), name: 'dir', isDir: true }, + { path: join(root, 'dir', 'b.txt'), name: 'dir/b.txt' }, + { path: join(root, 'a.txt'), name: 'a.txt' }, + ], { onProgress: (progress) => seen.push({ ...progress }) }) + expect(seen).toHaveLength(3) + expect(seen.map(step => step.done)).toEqual([1, 2, 3]) + expect(seen.every(step => step.total === 3)).toBe(true) + // `bytes` counts uncompressed payload and never shrinks. The directory row + // adds nothing; b.txt (30) then a.txt (10) bring it to 40. + expect(seen.map(step => step.bytes)).toEqual([0, 30, 40]) + // A failed entry reports nothing for itself (the throw ends the build). + const failed: ZipProgress[] = [] + await expect(buildZip([ + { path: join(root, 'a.txt'), name: 'a.txt' }, + { path: join(root, 'missing.txt'), name: 'missing.txt' }, + ], { onProgress: (progress) => failed.push({ ...progress }) })) + .rejects.toMatchObject({ code: 'fs-error' }) + expect(failed.map(step => step.done)).toEqual([1]) + }) + it('rejects an over-limit entry count before reading anything', async () => { const root = tempRoot() const path = join(root, 'a.txt') @@ -416,17 +525,10 @@ describe('disambiguateArchiveNames', () => { }) }) -describe('/sidebar/archive route', () => { - it('archiveUrl builds the GET URL the route parses (repeated path, encoded)', () => { - const url = archiveUrl({ sessionId: 's-1', cwd: '/work tree' }, ['/work tree/a b.txt', '/work tree/子目录'], 'my zip.zip') - const parsed = new URL(url, 'http://dsh.internal') - expect(parsed.pathname).toBe('/sidebar/archive') - expect(parsed.searchParams.get('sessionId')).toBe('s-1') - expect(parsed.searchParams.get('cwd')).toBe('/work tree') - expect(parsed.searchParams.get('name')).toBe('my zip.zip') - expect(parsed.searchParams.getAll('path')).toEqual(['/work tree/a b.txt', '/work tree/子目录']) - // A scope without a cwd omits the parameter entirely (same as downloadUrl). - expect(archiveUrl({ sessionId: 's-2' }, ['/a'], 'x.zip')).toBe('/sidebar/archive?sessionId=s-2&name=x.zip&path=%2Fa') +describe('archive task flow (build → status → download)', () => { + it('archive client helpers build the frozen request/URL shapes', () => { + expect(archiveDownloadUrl({ sessionId: 's-1' }, 'ar-9')) + .toBe('/sidebar/archive?sessionId=s-1&id=ar-9') }) it('zips a selected file and a selected directory, walking it recursively', async () => { @@ -436,20 +538,23 @@ describe('/sidebar/archive route', () => { writeFileSync(join(workspace, 'src', 'index.ts'), 'export {}\n') writeFileSync(join(workspace, 'src', 'deep', 'nested.ts'), 'nested\n') writeFileSync(join(workspace, 'notes.md'), '# notes\n') - const route = mountArchive() - const url = `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}` - + `&name=${encodeURIComponent('我的打包.zip')}` - + `&path=${encodeURIComponent(join(workspace, 'src'))}` - + `&path=${encodeURIComponent(join(workspace, 'notes.md'))}` - const response = await get(route, url) - expect(response.status).toBe(200) - expect(response.headers['content-type']).toBe('application/zip') + const { build, download } = await archiveFlow({ + sessionId: 's-zip', + cwd: workspace, + name: '我的打包.zip', + paths: [join(workspace, 'src'), join(workspace, 'notes.md')], + }) + expect(build.ok).toBe(true) + expect(build.status).toBe(200) + expect(build.value).toMatchObject({ entries: 5 }) + expect(download.status).toBe(200) + expect(download.headers['content-type']).toBe('application/zip') // F1: the name is never header-raw. See the real-HTTP case below for the // node:http proof that a non-latin1 name no longer 500s. - expect(response.headers['content-disposition']) + expect(download.headers['content-disposition']) .toBe(`attachment; filename="download.zip"; filename*=UTF-8''${encodeURIComponent('我的打包.zip')}`) - expect(Number(response.headers['content-length'])).toBe(response.body.byteLength) - const entries = readZip(response.body) + expect(Number(download.headers['content-length'])).toBe(download.body.byteLength) + const entries = readZip(download.body) expect(entries.map(entry => entry.name)).toEqual([ 'src/', 'src/deep/', @@ -462,26 +567,228 @@ describe('/sidebar/archive route', () => { expect(byName.get('notes.md')!.data.toString('utf8')).toBe('# notes\n') }) + it('reports progress through archive.status and releases after the download', async () => { + const root = tempRoot() + const workspace = join(root, 'workspace') + mkdirSync(workspace, { recursive: true }) + writeFileSync(join(workspace, 'a.txt'), 'a\n') + writeFileSync(join(workspace, 'b.txt'), 'b\n') + const { api, download } = mountHost() + const build = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [join(workspace, 'a.txt'), join(workspace, 'b.txt')] }) + expect(build.ok).toBe(true) + const id = (build.value as { id: string }).id + const status = await post(api, 'archive.status', { id, sessionId: 's-zip' }) + expect(status.ok).toBe(true) + const first = status.value as { state: string; done: number; total: number; bytes: number } + expect(first.total).toBe(2) + expect(['building', 'ready']).toContain(first.state) + expect(first.done).toBeGreaterThanOrEqual(0) + expect(first.bytes).toBeGreaterThanOrEqual(0) + const settled = await waitForReady(api, id, 's-zip') + expect(settled.state).toBe('ready') + expect(settled.done).toBe(2) + // The download serves the bytes… + const served = await get(download, `/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`) + expect(served.status).toBe(200) + // …and consumes the task: a second fetch is a 404. + const second = await get(download, `/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`) + expect(second.status).toBe(404) + const gone = await post(api, 'archive.status', { id, sessionId: 's-zip' }) + expect(gone.ok).toBe(false) + expect(gone.error?.code).toBe('not-found') + }) + + it('settles to ready with done === total (progress is monotonic and complete)', async () => { + const root = tempRoot() + const workspace = join(root, 'workspace') + mkdirSync(workspace, { recursive: true }) + for (const name of ['a.txt', 'b.txt', 'c.txt']) writeFileSync(join(workspace, name), name) + const { api } = mountHost() + const build = await post(api, 'archive.build', { + sessionId: 's-zip', + cwd: workspace, + paths: [join(workspace, 'a.txt'), join(workspace, 'b.txt'), join(workspace, 'c.txt')], + }) + const id = (build.value as { id: string }).id + // The build is detached; poll until it settles. Every poll must be + // monotonic (done never goes backwards, bytes never shrink). + let previousDone = -1 + let previousBytes = -1 + let settled: { state: string; done: number; total: number; bytes: number } | undefined + for (let attempt = 0; attempt < 200; attempt += 1) { + const status = await post(api, 'archive.status', { id, sessionId: 's-zip' }) + const value = status.value as { state: string; done: number; total: number; bytes: number } + expect(value.done).toBeGreaterThanOrEqual(previousDone) + expect(value.bytes).toBeGreaterThanOrEqual(previousBytes) + previousDone = value.done + previousBytes = value.bytes + if (value.state !== 'building') { settled = value; break } + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(settled?.state).toBe('ready') + expect(settled?.total).toBe(3) + expect(settled?.done).toBe(3) + }) + + it('refuses another session\'s task (403) and an unknown id (404)', async () => { + const root = tempRoot() + const workspace = join(root, 'workspace') + mkdirSync(workspace, { recursive: true }) + writeFileSync(join(workspace, 'a.txt'), 'a') + const { api, download } = mountHost() + const build = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [join(workspace, 'a.txt')] }) + const id = (build.value as { id: string }).id + const foreign = await post(api, 'archive.status', { id, sessionId: 's-other' }) + expect(foreign.ok).toBe(false) + expect(foreign.error?.code).toBe('forbidden') + const foreignDownload = await get(download, `/sidebar/archive?sessionId=s-other&id=${encodeURIComponent(id)}`) + expect(foreignDownload.status).toBe(403) + const unknown = await post(api, 'archive.status', { id: 'ar-nope', sessionId: 's-zip' }) + expect(unknown.error?.code).toBe('not-found') + // The failed foreign reads did not consume the task. + await waitForReady(api, id, 's-zip') + const own = await get(download, `/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`) + expect(own.status).toBe(200) + }) + + it('caps concurrent builds (the 5th start is refused)', async () => { + const root = tempRoot() + const workspace = join(root, 'workspace') + mkdirSync(workspace, { recursive: true }) + writeFileSync(join(workspace, 'a.txt'), 'a') + const { api } = mountHost() + const tasks = createArchiveTasks() + expect(ARCHIVE_MAX_BUILDING).toBe(4) + // A large entry set keeps each detached build running across the next + // start (a 1-entry build can finish between two awaits). + const big = join(workspace, 'big.bin') + writeFileSync(big, Buffer.alloc(2 * 1024 * 1024, 3)) + for (let index = 0; index < ARCHIVE_MAX_BUILDING; index += 1) { + tasks.start({ sessionId: 's', name: 'x.zip', entries: [{ path: big, name: 'big.bin' }] }) + } + expect(() => tasks.start({ sessionId: 's', name: 'x.zip', entries: [{ path: big, name: 'big.bin' }] })) + .toThrow(/too many archives are being built/) + // The API route surfaces the same refusal as a bad-request envelope. + for (let index = 0; index < ARCHIVE_MAX_BUILDING; index += 1) { + const accepted = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [big] }) + expect(accepted.ok).toBe(true) + } + const refused = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [big] }) + expect(refused.ok).toBe(false) + expect(refused.error?.message).toMatch(/too many archives/) + }) + + it('surfaces a build failure as an error task with its message', async () => { + const root = tempRoot() + const missing = join(root, 'missing.txt') + // The route's collector lstat's every selection, so a missing path never + // becomes a task at all (the POST refuses it). The DETACHED failure is the + // race the task state exists for — a source that vanishes after the walk — + // so it is exercised on the table directly, then through the route by + // making an errored task's TTL expire. + const tasks = createArchiveTasks() + const started = tasks.start({ sessionId: 's', name: 'x.zip', entries: [{ path: missing, name: 'missing.txt' }] }) + await vi.waitFor(() => { expect(tasks.status(started.id, 's').state).toBe('error') }) + const errored = tasks.status(started.id, 's') + expect(errored.state).toBe('error') + expect(errored.error).toMatch(/missing\.txt/) + expect(tasks.lookup(started.id, 's')).toMatchObject({ ok: false, reason: 'error' }) + // An errored task answers its download with 410 Gone, not a silent 404. + const { api, download } = mountHost() + const refused = await post(api, 'archive.build', { sessionId: 's-zip', cwd: root, paths: [missing] }) + expect(refused.ok).toBe(false) + expect(refused.error?.code).toBe('fs-error') + const gone = await get(download, `/sidebar/archive?sessionId=s-zip&id=ar-nope`) + expect(gone.status).toBe(404) + }) + + it('answers a failed build with 410 Gone and another session with 403', async () => { + const root = tempRoot() + const missing = join(root, 'missing.txt') + const tasks = createArchiveTasks() + const started = tasks.start({ sessionId: 's', name: 'x.zip', entries: [{ path: missing, name: 'missing.txt' }] }) + await vi.waitFor(() => { expect(tasks.status(started.id, 's').state).toBe('error') }) + // Every non-200 branch of the download route, through its own mapping. + const answer = (sessionId: string | null, id: string | null): { status: number; error?: string } => { + const out: { status: number; error?: string } = { status: 0 } + try { + respondArchiveDownload(tasks, { sessionId, id }, { + writeHead: (status) => { out.status = status }, + end: () => {}, + }) + } catch (error) { + out.error = error instanceof Error ? error.message : String(error) + out.status = error instanceof SidebarError ? error.status : 500 + } + return out + } + expect(answer('s', started.id)).toMatchObject({ status: 410, error: expect.stringMatching(/missing\.txt/) }) + expect(answer('other', started.id)).toMatchObject({ status: 403 }) + expect(answer(null, started.id)).toMatchObject({ status: 400 }) + expect(answer('s', null)).toMatchObject({ status: 400 }) + expect(answer('s', 'ar-nope')).toMatchObject({ status: 404 }) + // The 409 and 200 branches get a STUB table: a real build of a one-file + // archive can already be ready by the next line, which would test timing + // rather than the mapping. + const stubFile = join(root, 'stub.txt') + writeFileSync(stubFile, 'stub') + const bytes = await buildZip([{ path: stubFile, name: 'stub.txt' }]) + let released = 0 + const fake = (lookup: ArchiveTasks['lookup']): ArchiveTasks => ({ + start: () => { throw new Error('not used') }, + status: () => { throw new Error('not used') }, + lookup, + release: () => { released += 1 }, + size: () => 0, + }) + const building = fake(() => ({ ok: false, reason: 'building', message: 'archive is still being built' })) + const buildAnswer = (tasks: ArchiveTasks): { status: number; error?: string } => { + const out: { status: number; error?: string } = { status: 0 } + try { + respondArchiveDownload(tasks, { sessionId: 's', id: 'ar-1' }, { writeHead: (status) => { out.status = status }, end: () => {} }) + } catch (error) { + out.error = error instanceof Error ? error.message : String(error) + out.status = error instanceof SidebarError ? error.status : 500 + } + return out + } + expect(buildAnswer(building)).toMatchObject({ status: 409, error: expect.stringMatching(/still being built/) }) + const ready = fake(() => ({ + ok: true, + task: { id: 'ar-1', sessionId: 's', name: '报告.zip', state: 'ready', done: 1, total: 1, bytes: 1, zip: bytes, expiresAt: 0 }, + })) + const wire: { status: number; headers?: Record; body?: Buffer } = { status: 0 } + respondArchiveDownload(ready, { sessionId: 's', id: 'ar-1' }, { + writeHead: (status, headers) => { wire.status = status; wire.headers = headers }, + end: (chunk) => { wire.body = Buffer.from(chunk ?? '') }, + }) + expect(wire.status).toBe(200) + expect(wire.headers?.['content-disposition']).toContain(`filename*=UTF-8''${encodeURIComponent('报告.zip')}`) + expect(wire.headers?.['content-length']).toBe(String(bytes.byteLength)) + expect(wire.body!.equals(bytes)).toBe(true) + expect(released).toBe(1) + }) + it('serves a non-latin1 download name through REAL node:http (no 500, RFC 5987)', async () => { const root = tempRoot() const workspace = join(root, 'workspace') mkdirSync(join(workspace, '报告'), { recursive: true }) writeFileSync(join(workspace, '报告', '数据.txt'), 'x\n') - const route = mountArchive() + const { api, download } = mountHost() const name = '报告.zip' - const url = `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}` - + `&name=${encodeURIComponent(name)}` - + `&path=${encodeURIComponent(join(workspace, '报告'))}` - // The route handler answers a REAL ServerResponse here: node:http's - // writeHead validates header values, which the plain-object stub in the - // other cases cannot do — that is exactly how the Chinese-name 500 hid. const { createServer } = await import('node:http') - const server = createServer((req, res) => { void route.handler(req as never, res as never) }) + const server = createServer((req, res) => { void download.handler(req as never, res as never) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) try { const address = server.address() if (address === null || typeof address === 'string') throw new Error('test setup: no server port') - const response = await fetch(`http://127.0.0.1:${address.port}${url}`) + const build = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, name, paths: [join(workspace, '报告')] }) + const id = (build.value as { id: string }).id + await waitForReady(api, id, 's-zip') + // The route handler answers a REAL ServerResponse here: node:http's + // writeHead validates header values, which the plain-object stub in the + // other cases cannot do — that is exactly how the Chinese-name 500 hid. + const response = await fetch(`http://127.0.0.1:${address.port}/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`) expect(response.status).toBe(200) const disposition = response.headers.get('content-disposition') ?? '' // The ASCII half is what old clients read; the RFC 5987 half carries the @@ -504,13 +811,13 @@ describe('/sidebar/archive route', () => { mkdirSync(join(workspace, 'b'), { recursive: true }) writeFileSync(join(workspace, 'a', 'index.ts'), 'from a\n') writeFileSync(join(workspace, 'b', 'index.ts'), 'from b\n') - const route = mountArchive() - const url = `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}` - + `&path=${encodeURIComponent(join(workspace, 'a', 'index.ts'))}` - + `&path=${encodeURIComponent(join(workspace, 'b', 'index.ts'))}` - const response = await get(route, url) - expect(response.status).toBe(200) - const entries = readZip(response.body) + const { download } = await archiveFlow({ + sessionId: 's-zip', + cwd: workspace, + paths: [join(workspace, 'a', 'index.ts'), join(workspace, 'b', 'index.ts')], + }) + expect(download.status).toBe(200) + const entries = readZip(download.body) const names = entries.map(entry => entry.name) expect(new Set(names).size).toBe(names.length) expect(names).toEqual(['a/index.ts', 'b/index.ts']) @@ -533,15 +840,18 @@ describe('/sidebar/archive route', () => { } catch { return // symlink creation needs privileges on Windows } - const route = mountArchive() - const scope = `sessionId=s-zip&cwd=${encodeURIComponent(workspace)}` - // Selecting the escaping link refuses at the workspace fence (the route + const { api, download } = mountHost() + // Selecting the escaping link refuses at the workspace fence (the build // canonicalizes before walking), so its target is never read… - const linked = await get(route, `/sidebar/archive?${scope}&path=${encodeURIComponent(join(workspace, 'link'))}`) + const linked = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [join(workspace, 'link')] }) + expect(linked.ok).toBe(false) expect(linked.status).toBe(403) // …and walking the containing directory reports the link as an entry-less // row instead of descending into it. - const walked = await get(route, `/sidebar/archive?${scope}&path=${encodeURIComponent(workspace)}`) + const build = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [workspace] }) + const id = (build.value as { id: string }).id + await waitForReady(api, id, 's-zip') + const walked = await get(download, `/sidebar/archive?sessionId=s-zip&id=${encodeURIComponent(id)}`) expect(walked.status).toBe(200) const entries = readZip(walked.body) expect(entries.map(entry => entry.name)).toEqual(['workspace/', 'workspace/keep.txt']) @@ -555,23 +865,28 @@ describe('/sidebar/archive route', () => { mkdirSync(workspace) mkdirSync(outside) writeFileSync(join(outside, 'secret.txt'), 'secret') - const route = mountArchive() - const url = `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}` - + `&path=${encodeURIComponent(join(outside, 'secret.txt'))}` - const response = await get(route, url) + const { api } = mountHost() + const response = await post(api, 'archive.build', { + sessionId: 's-zip', + cwd: workspace, + paths: [join(outside, 'secret.txt')], + }) expect(response.status).toBe(403) - expect(JSON.parse(response.body.toString('utf8'))).toMatchObject({ ok: false, error: { code: 'forbidden' } }) + expect(response.error?.code).toBe('forbidden') }) - it('rejects a missing path (and a missing sessionId) with 400', async () => { + it('rejects an empty path list and a download without sessionId/id (400)', async () => { const root = tempRoot() const workspace = join(root, 'workspace') mkdirSync(workspace) - const route = mountArchive() - const noPath = await get(route, `/sidebar/archive?sessionId=s-zip&cwd=${encodeURIComponent(workspace)}`) + const { api, download } = mountHost() + const noPath = await post(api, 'archive.build', { sessionId: 's-zip', cwd: workspace, paths: [] }) expect(noPath.status).toBe(400) - expect(JSON.parse(noPath.body.toString('utf8'))).toMatchObject({ ok: false, error: { code: 'bad-request' } }) - const noSession = await get(route, `/sidebar/archive?cwd=${encodeURIComponent(workspace)}&path=${encodeURIComponent(workspace)}`) + expect(noPath.error?.code).toBe('bad-request') + const noId = await get(download, `/sidebar/archive?sessionId=s-zip`) + expect(noId.status).toBe(400) + expect(JSON.parse(noId.body.toString('utf8'))).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const noSession = await get(download, `/sidebar/archive?id=ar-1`) expect(noSession.status).toBe(400) }) @@ -580,26 +895,49 @@ describe('/sidebar/archive route', () => { const workspace = join(root, 'workspace') mkdirSync(workspace) writeFileSync(join(workspace, 'a.txt'), 'a') - const route = mountArchive() - const path = encodeURIComponent(join(workspace, 'a.txt')) - const scope = `sessionId=s-zip&cwd=${encodeURIComponent(workspace)}&path=${path}` - const traversal = await get(route, `/sidebar/archive?${scope}&name=${encodeURIComponent('../evil")b.zip')}`) - expect(traversal.headers['content-disposition']).toContain('filename="evil)b.zip"') - const bare = await get(route, `/sidebar/archive?${scope}&name=docs`) - expect(bare.headers['content-disposition']).toContain('filename="docs.zip"') - const fallback = await get(route, `/sidebar/archive?${scope}`) - expect(fallback.headers['content-disposition']).toContain('filename="archive.zip"') + const paths = [join(workspace, 'a.txt')] + const traversal = await archiveFlow({ sessionId: 's-zip', cwd: workspace, name: '../evil")b.zip', paths }) + expect(traversal.download.headers['content-disposition']).toContain('filename="evil)b.zip"') + const bare = await archiveFlow({ sessionId: 's-zip', cwd: workspace, name: 'docs', paths }) + expect(bare.download.headers['content-disposition']).toContain('filename="docs.zip"') + const fallback = await archiveFlow({ sessionId: 's-zip', cwd: workspace, paths }) + expect(fallback.download.headers['content-disposition']).toContain('filename="archive.zip"') }) - it('rejects a non-GET method', async () => { - const route = mountArchive() + it('rejects a non-GET method on the download route', async () => { + const { download } = mountHost() const out: { status: number; body: string } = { status: 200, body: '' } - const req = { method: 'POST', url: '/sidebar/archive?sessionId=s&path=/x', headers: { host: '127.0.0.1:3080' } } as never + const req = { method: 'POST', url: '/sidebar/archive?id=x', headers: { host: '127.0.0.1:3080' } } as never const res = { writeHead: (status: number) => { out.status = status }, end: (chunk?: string) => { out.body += String(chunk ?? '') }, } as never - await route.handler(req, res) + await download.handler(req, res) expect(out.status).toBe(405) }) }) + +describe('createArchiveTasks', () => { + it('expires a settled task after its TTL', async () => { + const root = tempRoot() + const file = join(root, 'a.txt') + writeFileSync(file, 'a') + const tasks = createArchiveTasks() + const { id } = tasks.start({ sessionId: 's', name: 'x.zip', entries: [{ path: file, name: 'a.txt' }] }) + await vi.waitFor(() => { expect(tasks.status(id, 's').state).toBe('ready') }) + expect(tasks.size()).toBe(1) + expect(tasks.status(id, 's')).toMatchObject({ state: 'ready', done: 1, total: 1 }) + // A TTL of zero makes the sweep drop it on the next access (the real TTL + // is 5 minutes; this is the same code path without the wait). + const aged = createArchiveTasks() + const started = aged.start({ sessionId: 's', name: 'x.zip', entries: [{ path: file, name: 'a.txt' }] }) + await vi.waitFor(() => { expect(aged.status(started.id, 's').state).toBe('ready') }) + vi.setSystemTime(Date.now() + ARCHIVE_TTL_MS + 1) + try { + expect(() => aged.status(started.id, 's')).toThrow(/unknown or expired/) + expect(aged.size()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +})