feat(sidebar): 菜单层级/性能 + 选中条吸底 + 打包进度 + 修复「在侧边打开」

用户实测反馈 5 条:

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

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

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

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

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

验证:typecheck / lint / test(135 文件 1461 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
新增/更新测试:menu-perf(3)、archive(10)、open-in-app(18)、open-with(10)、multiselect(9)、
zip(37)、service(64)、editor-host(11)、builtins(23)、locales(20)
This commit is contained in:
Menghuan1918
2026-09-28 20:11:46 +08:00
parent 13d4bae321
commit ee1fe05067
41 changed files with 2228 additions and 597 deletions
+8
View File
@@ -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', () => {
+223 -1
View File
@@ -9,7 +9,7 @@
* only, no editor chrome); file tabs keep the full chrome in both modes.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createElement, useEffect, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
@@ -18,6 +18,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<typeof createSidebarStore>, t
}
}
/**
* Mount the host AND let the docked tree finish its async level load (the
* context-menu scenarios need the file row in the DOM). The scope carries the
* session cwd — without a root the docked tree has nothing to list.
*/
async function mountHostWithTreeWithCwd(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: () => SidebarTab): Promise<{
container: HTMLDivElement
unmount: () => void
}> {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'editor-home-session', cwd: '/tmp' },
tab: tab(),
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into the controlled path input (native setter) and press Enter. */
function typeAndCommit(input: HTMLInputElement, value: string): void {
act(() => {
@@ -333,3 +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<typeof createSidebarStore>): {
service: ReturnType<typeof createBetterSidebarService>
opens: Array<{ type: string; path?: string; target?: string }>
} {
const service = createBetterSidebarService(store)
const opens: Array<{ type: string; path?: string; target?: string }> = []
const real = service.openTab.bind(service)
service.openTab = ((seed, scope) => {
opens.push({ type: seed.type, ...(seed.path === undefined ? {} : { path: seed.path }), ...(seed.target === undefined ? {} : { target: seed.target }) })
real(seed, scope)
}) as typeof service.openTab
return { service, opens }
}
/** The fake client ctx (service + session feed); cwd matches api.fsTree. */
function fakeCtx(service: ReturnType<typeof createBetterSidebarService>): Context {
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
return {
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
} as unknown as Context
}
/** Mount a native-hosting editor window whose tab is NOT in bottomSplits. */
async function mountNative(ctx: Context, store: ReturnType<typeof createSidebarStore>, service: ReturnType<typeof createBetterSidebarService>): Promise<{
container: HTMLDivElement
unmount: () => void
}> {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
// The tab record the native seat hands the component: an editor window for
// /tmp/a.ts whose id never enters the plugin's own layout.
const nativeTab: SidebarTab = {
id: 'native-editor-1',
type: 'editor',
title: 'a.ts',
path: '/tmp/a.ts',
meta: { treeOpen: true },
}
await act(async () => {
root.render(createElement(EditorHost, {
ctx,
store,
// The tree needs a root: the real seat hands the session cwd in scope.
scope: { sessionId: 'editor-home-session', cwd: '/tmp' },
visible: true,
tab: nativeTab,
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
await Promise.resolve()
await Promise.resolve()
})
void service
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
function clickOpenToSide(container: HTMLDivElement): void {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
if (row === undefined) {
throw new Error(`file row not found; names=${[...container.querySelectorAll('[class*="explorerName"]')].map(el => el.textContent).join('|')}; html=${container.innerHTML.slice(0, 400)}`)
}
act(() => {
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }))
})
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(el => el.textContent?.trim() === 'Open to the Side')
if (item === undefined) throw new Error('side-open menu item not found')
act(() => { item.click() })
}
function tabCount(store: ReturnType<typeof createSidebarStore>): number {
return allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs).length
}
it('asks the service for a side open and leaves the bottom workbench untouched', async () => {
const store = createSidebarStore()
const { service, opens } = spyService(store)
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
// A native surface is what makes the service native-hosted at all: the
// fake records what the service asks of the host (the real one forwards to
// `ctx.sidebarRight`).
const placed: Array<{ address: string; preferNewPane?: boolean; revealIfOpened?: boolean }> = []
service.setSurface({
openTab: () => {},
openResource: (input) => { placed.push({ address: input.address, ...(input.preferNewPane === undefined ? {} : { preferNewPane: input.preferNewPane }), revealIfOpened: input.revealIfOpened }) },
fileAddress: (sessionId, cwd, path) => `dsh-resource://file/${sessionId}${path}`,
close: () => undefined,
update: () => false,
activate: () => false,
has: () => false,
})
const ctx = fakeCtx(service)
const { container, unmount } = await mountNative(ctx, store, service)
try {
const before = tabCount(store)
clickOpenToSide(container)
// 1) EditorHost routed the gesture to the service with the side target…
expect(opens).toContainEqual({ type: 'editor', path: '/tmp/a.ts', target: 'side' })
// 2) …and the service asked the HOST for a second pane, permitting a
// duplicate resource so a split really happens.
expect(placed).toHaveLength(1)
expect(placed[0]).toMatchObject({ address: expect.stringContaining('/tmp/a.ts'), preferNewPane: true, revealIfOpened: false })
// 3) The bottom workbench is untouched: the open went to the host.
expect(tabCount(store)).toBe(before)
} finally {
unmount()
}
})
it('keeps the bottom-workbench split for a tab that lives there', async () => {
const store = createSidebarStore()
const { service } = spyService(store)
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
const ctx = fakeCtx(service)
const opens: Array<{ target?: string }> = []
const real = service.openTab.bind(service)
service.openTab = ((seed, scope) => { opens.push({ ...(seed.target === undefined ? {} : { target: seed.target }) }); real(seed, scope) }) as typeof service.openTab
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
service.openTab({
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
})
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.bottomSplits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
// The tree needs a root: the real seat hands the session cwd in scope.
const treeCtx = ctx as unknown as { betterSidebar: unknown }
const { container, unmount } = await mountHostWithTreeWithCwd(treeCtx as unknown as Context, store, fileTab)
try {
const before = tabCount(store)
clickOpenToSide(container)
// A bottom-workbench tab keeps the plugin's own split: a NEW tab in the
// same pane family, and no side open was requested.
expect(opens.every(open => open.target === undefined)).toBe(true)
expect(tabCount(store)).toBe(before + 1)
} finally {
unmount()
}
})
})
+186 -151
View File
@@ -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
* `<a download={name}>`, revoked on the next task;
* - failure: a non-2xx JSON envelope (`{error:{message}}`), a non-JSON body
* (`HTTP <status>`) 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
* `<a download={name}>`. Every failure path — build rejection, a status
* `error` state, a status rejection, the download's HTTP envelope — lands in
* the error strip with `zipFailed`, and a repeated pick while one job runs
* never starts a second job.
*
* Appearance gates stay as they were: ≥2 selected rows archive together, a
* LONE directory archives its own subtree, a lone FILE shows no archive row
* (the plain download row already covers it).
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -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<Harness> {
}
}
/** One tree row by its displayed name (the root row included). */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
@@ -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<void> {
/** The progress line (a `Notice kind="loading"` live region). */
function progressLine(container: HTMLElement): HTMLElement | null {
return container.querySelector<HTMLElement>('[data-kind="loading"]')
}
/** Flush the pending promise chain (build / status / download). */
async function flush(): Promise<void> {
await act(async () => {
for (let tick = 0; tick < 3; tick += 1) {
await Promise.resolve()
await new Promise<void>(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<void> {
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<Response>(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<Response>(resolve => { release = resolve }))
harness = await mountTree()
const busyLine = (): HTMLElement | null => harness.container.querySelector<HTMLElement>('[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<Response>(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()
})
})
+304
View File
@@ -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<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 'menu-perf',
cwd: '/tmp',
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<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
async function openRowMenu(container: HTMLElement, name: string): Promise<void> {
await act(async () => {
rowByName(container, name).dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }),
)
await Promise.resolve()
await Promise.resolve()
})
}
async function closeMenu(): Promise<void> {
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await Promise.resolve()
})
}
/** Spin the timers one full git poll window (the status interval is 2500ms). */
async function advance(ms: number): Promise<void> {
await act(async () => {
vi.advanceTimersByTime(ms)
await Promise.resolve()
await Promise.resolve()
})
}
interface Metrics {
fsTree: number
gitStatus: number
fileApps: number
fileIcons: number
folderIcons: number
}
let harness: Harness
const baseline = { fsTree: 0, gitStatus: 0, fileApps: 0, fileIcons: 0, folderIcons: 0 }
/** The counters SINCE the last `mark()`. */
function since(): Metrics {
return {
fsTree: fsTree.mock.calls.length - baseline.fsTree,
gitStatus: gitStatus.mock.calls.length - baseline.gitStatus,
fileApps: fileApps.mock.calls.length - baseline.fileApps,
fileIcons: fileIcons.length - baseline.fileIcons,
folderIcons: folderIcons.length - baseline.folderIcons,
}
}
function mark(): void {
baseline.fsTree = fsTree.mock.calls.length
baseline.gitStatus = gitStatus.mock.calls.length
baseline.fileApps = fileApps.mock.calls.length
baseline.fileIcons = fileIcons.length
baseline.folderIcons = folderIcons.length
}
beforeEach(() => {
vi.useFakeTimers()
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<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
expect(parent).toBeDefined()
await act(async () => { parent!.click(); await Promise.resolve() })
const submenuLabels = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.map(item => item.textContent?.trim())
// The default handler is hoisted to level 1; the rest of the list is
// already there (no disabled Loading row).
expect(submenuLabels).toContain('Preview')
expect(submenuLabels).not.toContain('Loading…')
})
it('keeps the git ink and does not re-render changed rows while the menu is open', async () => {
// A repo with one changed file: the row carries its tone class while the
// menu is open, so pausing the poll must not cost the tint (the shared
// store drops its snapshot when its last visible consumer releases).
gitStatus.mockResolvedValue({
isRepo: true,
root: '/tmp',
entries: [{ path: 'a.ts', xy: ' M' }],
})
harness = await mountTree()
await advance(30)
const tinted = (): boolean =>
rowByName(harness.container, 'a.ts').querySelector('[data-git-tone]') !== null
expect(tinted()).toBe(true)
// The FIRST open also collapses the selection onto the row (one row
// re-render, by design); measure the reopen, where nothing may change.
await openRowMenu(harness.container, 'a.ts')
await closeMenu()
mark()
await openRowMenu(harness.container, 'a.ts')
await advance(3_000)
const openWindow = since()
const tintedWhileOpen = tinted()
await closeMenu()
console.log('MENU-PERF-REPO', JSON.stringify({ openWindow, tintedWhileOpen }))
// The changed row does not re-render because the menu opened…
expect(openWindow.fileIcons).toBe(0)
// …and its git ink survives the whole open window.
expect(tintedWhileOpen).toBe(true)
})
})
+30
View File
@@ -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<HTMLElement>('[class*="explorerBody"]')!
const bar = harness.container.querySelector<HTMLElement>('[class*="explorerSelectionBar"]')!
// In flow AFTER every row (the menu's portal anchor and the hidden file
// input are the only trailing siblings) — never absolutely positioned
// over the rows, so scrolled to the bottom it sits BELOW the last row.
const rows = [...body.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
expect(rows.length).toBeGreaterThan(0)
expect(rows[rows.length - 1]!.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The only trailing siblings are the menu's portal anchor and the hidden
// file input, so the bar IS the last piece of tree content.
const content = [...body.children].filter(child =>
child.tagName !== 'INPUT' && !(child as HTMLElement).className.includes('_root_'))
expect(content[content.length - 1]).toBe(bar)
// jsdom has no layout: the sheet is the evidence for the docking mechanic.
const sheet = readFileSync('src/client/sidebar.module.css', 'utf8')
const rule = sheet.match(/\.explorerSelectionBar \{([\s\S]*?)\n\}/)?.[1]
expect(rule, 'the .explorerSelectionBar rule must exist').toBeDefined()
expect(rule).toContain('position: sticky')
expect(rule).toContain('bottom: 0')
expect(rule).not.toContain('position: absolute')
expect(rule).not.toContain('position: fixed')
// Narrow panels WRAP the chips instead of clipping them or scrolling.
const actions = sheet.match(/\.explorerSelectionActions \{([\s\S]*?)\n\}/)?.[1]
expect(actions).toContain('flex-wrap: wrap')
})
it('stops the batch at the first failure and reports it in the strip', async () => {
// The first path lands; the second refuses — the walk must stop there.
fsRemove
+152 -90
View File
@@ -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<typeof vi.fn>
@@ -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<Harness> {
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<void> {
await act(async () => {
submenuParent().click()
await Promise.resolve()
})
}
function submenuRows(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
}
function submenuLabels(): string[] {
return submenuRows().map(item => item.textContent?.trim() ?? '')
}
function clickMenuitem(label: string): void {
const item = menuItems().find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem not found: ${label}`)
act(() => { item.click() })
}
function clickSubmenuRow(label: string): void {
const item = submenuRows().find(el => el.textContent?.trim() === label)
if (item === undefined) throw new Error(`submenu row not found: ${label}`)
act(() => { item.click() })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
})
describe('FileTree open-in-app 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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Windsurf')!
await openSubmenu()
const pin = submenuRows().find(row => row.textContent?.trim() === 'Windsurf')!
.querySelector<HTMLElement>('[class*="openWithPin"]')!
expect(pin.getAttribute('title')).toBe('Unpin')
act(() => { pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('custom:w')
expect(harness.onOpenWith).not.toHaveBeenCalled()
+16 -14
View File
@@ -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)
+60
View File
@@ -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<typeof createSidebarStore>
service: ReturnType<typeof createBetterSidebarService>
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<typeof createSidebarStore>): void => {
+413 -75
View File
@@ -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/<method> 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<ReturnType<typeof post>>; status: Awaited<ReturnType<typeof waitForReady>>; download: Awaited<ReturnType<typeof get>> }> {
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<string, string>; 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<void>(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()
}
})
})