Files
apes-sidebar/tests/open-with-settings.spec.tsx
T
Menghuan1918 b011be1e98 feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树
用户追加三条需求,全部落地:

1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存)
- 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts /
  src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor
  的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文)
- 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了)
- FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」
  子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件
  explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用)
- 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除

2) 文件页多选右键「压缩并打包下载」
- 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、
  自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+
  GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、
  10000 条目 / 256 MiB 上限)+ api.archiveUrl
- FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载,
  路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条
- 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix,
  Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000,
  << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错

3) 文件变动页层级树 + 视觉打磨
- 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、
  大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数
- GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 +
  计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 =
  interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线;
  历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐

验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
2026-09-28 18:08:21 +08:00

113 lines
4.1 KiB
TypeScript

/**
* The editor's "open with" settings panel: the SSH host row and the custom
* editor list (name + URL template + VSCode-family flag, add/remove). Every
* edit commits the whole `openWith` blob through the popup's
* updatePluginSetting; removing an editor also prunes its pinned id.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { OpenWithSettings } from '../src/client/open-with-settings.tsx'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
interface Harness {
container: HTMLDivElement
update: ReturnType<typeof vi.fn>
unmount: () => void
}
function mountSettings(rawOpenWith: unknown): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const update = vi.fn()
act(() => {
root.render(createElement(OpenWithSettings, {
pluginSettings: { openWith: rawOpenWith },
updatePluginSetting: update,
}))
})
return {
container,
update,
unmount: () => { act(() => { root.unmount() }) },
}
}
const baseConfig = {
sshHost: 'dev',
customEditors: [
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
],
pinned: ['vscode', 'custom:a'],
}
describe('OpenWithSettings', () => {
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
})
it('seeds the SSH host input from the persisted blob and commits edits', () => {
harness = mountSettings(baseConfig)
const input = harness.container.querySelector<HTMLInputElement>('input:not([type])')
expect(input?.value).toBe('dev')
// The native value setter (React's value tracker must see the change).
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
setter.call(input, 'prod')
input!.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(harness.update.mock.calls[0]?.[0]).toBe('openWith')
expect(harness.update.mock.calls[0]?.[1]).toMatchObject({ sshHost: 'prod' })
})
it('adds a custom editor row and commits the extended list', () => {
harness = mountSettings(baseConfig)
const add = [...harness.container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === 'Add')
expect(add).toBeDefined()
act(() => { add!.click() })
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
expect(blob.customEditors).toHaveLength(2)
expect(blob.pinned).toEqual(['vscode', 'custom:a'])
})
it('removes an editor and prunes its pinned id', () => {
harness = mountSettings(baseConfig)
const remove = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Remove"]')
expect(remove).toBeDefined()
act(() => { remove!.click() })
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
expect(blob.customEditors).toHaveLength(0)
expect(blob.pinned).toEqual(['vscode'])
})
it('shows the validity hint when a row is incomplete', () => {
harness = mountSettings({
sshHost: '',
customEditors: [{ id: 'x', name: 'Bad', urlTemplate: 'x://file/x', isVscodeFamily: true }],
pinned: [],
})
expect(harness.container.textContent).toContain('are not shown in the menu')
})
it('edits the VSCode-family flag of one row', () => {
harness = mountSettings(baseConfig)
const checkbox = harness.container.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(checkbox?.checked).toBe(false)
act(() => { checkbox!.click() })
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: { isVscodeFamily: boolean }[] }
expect(blob.customEditors[0]?.isVscodeFamily).toBe(true)
})
})