文件页(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)
83 lines
3.5 KiB
TypeScript
83 lines
3.5 KiB
TypeScript
/**
|
|
* The host's "new folder" mutation (`fs.mkdir` route → mkdirWorkspaceEntry):
|
|
* shape rules, existence refusal, containment under the fence, the root row
|
|
* as a legal PARENT, and the happy path against a real temporary filesystem.
|
|
*/
|
|
import { mkdtemp, mkdir, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { mkdirWorkspaceEntry } from '../src/fs-operations.ts'
|
|
|
|
let root: string
|
|
/** The workspace root as the host will report it (macOS /tmp is a symlink). */
|
|
let realRoot: string
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'dsh-mkdir-'))
|
|
realRoot = await realpath(root)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
/** The wire code of a rejected call (the route maps it to an HTTP status). */
|
|
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
|
try {
|
|
await run()
|
|
} catch (error) {
|
|
return (error as { code?: string }).code ?? 'no-code'
|
|
}
|
|
return 'resolved'
|
|
}
|
|
|
|
describe('mkdirWorkspaceEntry', () => {
|
|
it('creates one directory inside the named row and returns its absolute path', async () => {
|
|
await mkdir(join(root, 'sub'))
|
|
const result = await mkdirWorkspaceEntry({ cwd: root, path: join(root, 'sub'), name: 'fresh' })
|
|
expect(result.path).toBe(join(realRoot, 'sub', 'fresh'))
|
|
expect(await readdir(join(root, 'sub'))).toEqual(['fresh'])
|
|
})
|
|
|
|
it('accepts the workspace root itself as the parent', async () => {
|
|
const result = await mkdirWorkspaceEntry({ cwd: root, path: root, name: 'top' })
|
|
expect(result.path).toBe(join(realRoot, 'top'))
|
|
expect(await readdir(root)).toEqual(['top'])
|
|
})
|
|
|
|
it('refuses a name that is not a single path segment', async () => {
|
|
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'a/b' }))).toBe('bad-request')
|
|
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: '..' }))).toBe('bad-request')
|
|
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: '' }))).toBe('bad-request')
|
|
})
|
|
|
|
it('refuses an existing destination with the same conflict code as rename', async () => {
|
|
await mkdir(join(root, 'taken'))
|
|
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'taken' }))).toBe('fs-error')
|
|
})
|
|
|
|
it('refuses a parent outside the workspace while the fence is armed, and allows it when disarmed', async () => {
|
|
const outside = await mkdtemp(join(tmpdir(), 'dsh-outside-'))
|
|
try {
|
|
await writeFile(join(outside, 'marker.txt'), 'x')
|
|
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'nope' }))).toBe('forbidden')
|
|
// The fence is the guard, not the path shape: disarmed, the same call lands.
|
|
const result = await mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'allowed', fence: false })
|
|
expect(result.path).toBe(join(await realpath(outside), 'allowed'))
|
|
} finally {
|
|
await rm(outside, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('refuses to create INSIDE a symlink that escapes the workspace', async () => {
|
|
const outside = await mkdtemp(join(tmpdir(), 'dsh-link-'))
|
|
try {
|
|
await symlink(outside, join(root, 'escape'))
|
|
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: join(root, 'escape'), name: 'nope' }))).toBe('forbidden')
|
|
} finally {
|
|
await rm(outside, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|