Files
apes-sidebar/tests/open-in-app.spec.ts
T
Menghuan1918 36fbc5ac7e feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取
文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
2026-09-28 00:27:31 +08:00

172 lines
7.3 KiB
TypeScript

/**
* The client adapter over DSH's open-in-app capability: which host facility
* each gesture uses, the availability gate, and the degradation path when the
* deployment has no desktop or no Remote namespace.
*
* The host's own behaviour is NOT re-tested here — only the adapter's
* routing (Remote vs catalog route), its caching, and its fail-closed
* answers.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOpenInApp, type OpenInAppEntry } from '../src/client/open-in-app.ts'
interface RemoteCalls {
canOpen: number
applications: { path: string }[]
open: { path: string; action?: 'reveal'; application?: string }[]
}
/** A fake `ctx.remote.session` recording every call. */
function fakeCtx(options: {
canOpen?: boolean
apps?: readonly { id: string; name: string; default: boolean; icon: string | null }[]
openResult?: boolean
revealResult?: boolean
noRemote?: boolean
} = {}): { ctx: { get(name: string): unknown }; calls: RemoteCalls } {
const calls: RemoteCalls = { canOpen: 0, applications: [], open: [] }
const session = {
canOpenWorkspacePath: async () => {
calls.canOpen += 1
return { ok: true, value: options.canOpen ?? true }
},
workspacePathApplications: async (request: { path: string }) => {
calls.applications.push(request)
if (options.apps === undefined) return { ok: false }
return { ok: true, value: options.apps }
},
openWorkspacePath: async (request: { path: string; action?: 'reveal'; application?: string }) => {
calls.open.push(request)
if (request.action === 'reveal') return { ok: options.revealResult ?? true }
return { ok: options.openResult ?? true }
},
}
return { ctx: { get: (name) => (name === 'remote' ? { session } : undefined) }, calls }
}
/** Install a fetch stub answering the host's catalog/launch routes. */
function stubFetch(options: { apps?: readonly string[]; appsStatus?: number; openStatus?: number } = {}) {
const calls: { url: string; body?: unknown }[] = []
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), body: init?.body === undefined ? undefined : JSON.parse(String(init.body)) })
if (String(url).includes('/open-in-app/apps')) {
const status = options.appsStatus ?? 200
return new Response(JSON.stringify({ apps: options.apps ?? [] }), { status })
}
return new Response('{}', { status: options.openStatus ?? 200 })
})
return calls
}
afterEach(() => {
vi.unstubAllGlobals()
})
/** The adapter built over a ctx that cannot reach the Remote at all. */
function withoutRemote() {
return createOpenInApp({ get: () => undefined })
}
describe('createOpenInApp', () => {
it('reports unavailable and degrades every gesture when the Remote is absent', async () => {
const adapter = withoutRemote()
expect(adapter.available()).toBeNull()
expect(await adapter.probe()).toBe(false)
expect(adapter.available()).toBe(false)
expect(await adapter.fileApps('/tmp/a.ts')).toBeNull()
expect(await adapter.open('/tmp/a.ts')).toBe(false)
expect(await adapter.reveal('/tmp/a.ts')).toBe(false)
})
it('probes availability once and shares the answer', async () => {
const { ctx, calls } = fakeCtx({ canOpen: true })
const adapter = createOpenInApp(ctx)
expect(await adapter.probe()).toBe(true)
expect(await adapter.probe()).toBe(true)
expect(adapter.available()).toBe(true)
expect(calls.canOpen).toBe(1)
})
it('maps the OS handlers of one file, default included', async () => {
const { ctx, calls } = fakeCtx({
apps: [{ id: '/Applications/VSCode.app', name: 'Visual Studio Code', default: true, icon: 'data:image/png;base64,AA' }],
})
const adapter = createOpenInApp(ctx)
const apps = await adapter.fileApps('/tmp/a.ts')
expect(apps).toEqual([{ id: '/Applications/VSCode.app', name: 'Visual Studio Code', icon: 'data:image/png;base64,AA', isDefault: true }])
expect(calls.applications).toEqual([{ path: '/tmp/a.ts' }])
})
it('answers null for a failed handler query, never an empty list', async () => {
const { ctx } = fakeCtx({ apps: undefined })
expect(await createOpenInApp(ctx).fileApps('/tmp/a.ts')).toBeNull()
})
it('opens a path through the Remote, default or explicit application', async () => {
const { ctx, calls } = fakeCtx()
const adapter = createOpenInApp(ctx)
expect(await adapter.open('/tmp/a.ts')).toBe(true)
expect(await adapter.open('/tmp/a.ts', '/Applications/VSCode.app')).toBe(true)
expect(calls.open).toEqual([
{ path: '/tmp/a.ts' },
{ path: '/tmp/a.ts', application: '/Applications/VSCode.app' },
])
})
it('falls back to the host catalog route when a catalog id is not a file handler', async () => {
const fetchCalls = stubFetch()
// The Remote refuses the catalog id (it is not registered for this path).
const { ctx, calls } = fakeCtx({ openResult: false })
const adapter = createOpenInApp(ctx)
expect(await adapter.open('/tmp/ws', 'vscode')).toBe(true)
expect(calls.open).toEqual([{ path: '/tmp/ws', application: 'vscode' }])
expect(fetchCalls).toEqual([{ url: '/open-in-app/open', body: { app: 'vscode', path: '/tmp/ws' } }])
})
it('reports a launch failure instead of pretending success', async () => {
stubFetch({ openStatus: 400 })
const { ctx } = fakeCtx({ openResult: false })
expect(await createOpenInApp(ctx).open('/tmp/ws', 'vscode')).toBe(false)
})
it('reveals through the Remote action', async () => {
const { ctx, calls } = fakeCtx()
expect(await createOpenInApp(ctx).reveal('/tmp/a.ts')).toBe(true)
expect(calls.open).toEqual([{ path: '/tmp/a.ts', action: 'reveal' }])
})
it('lists the directory catalog from the host route, with icon URLs', async () => {
const fetchCalls = stubFetch({ apps: ['vscode', 'finder'] })
const { ctx } = fakeCtx()
const apps: readonly OpenInAppEntry[] = await createOpenInApp(ctx).directoryApps()
expect(apps.map(app => app.id)).toEqual(['vscode', 'finder'])
expect(apps[0]?.icon).toBe('/open-in-app/icon/vscode')
// Without the host locale namespace the label falls back to the id itself.
expect(apps[0]?.name).toBe('Vscode')
expect(fetchCalls[0]?.url).toBe('/open-in-app/apps')
})
it('answers an empty catalog when the host route fails or the desktop is gone', async () => {
stubFetch({ appsStatus: 500 })
const { ctx } = fakeCtx()
expect(await createOpenInApp(ctx).directoryApps()).toEqual([])
const offline = fakeCtx({ canOpen: false })
expect(await createOpenInApp(offline.ctx).directoryApps()).toEqual([])
})
})
describe('createOpenInApp (host inject guard)', () => {
it('treats a ctx that THROWS on service access as unavailable instead of crashing the caller', async () => {
// Cordis raises `cannot get property "remote.session" without inject`
// when the bundle forgets the inject entry; a render path must survive it.
const adapter = createOpenInApp({
get: () => { throw new Error('cannot get property "remote.session" without inject') },
})
expect(await adapter.probe()).toBe(false)
expect(await adapter.fileApps('/tmp/a.ts')).toBeNull()
expect(await adapter.open('/tmp/a.ts')).toBe(false)
expect(await adapter.reveal('/tmp/a.ts')).toBe(false)
})
})