verifier 二轮报告(F1–F9)全部修复,每条都有「临时回退即变红」的判别性验证: - F1【高】中文等非 latin1 下载名 → 真实宿主 500:content-disposition 改用 RFC 5987(ASCII 回退 + filename*=UTF-8'');补真 node:http 用例与根因对照断言 - F2【中】多选同名文件归档重名(解压互相覆盖):冲突项逐级上溯父目录消歧 - F3【中低】maxBytes 在读完之后才判定;budget<=0 的 0 字节 FIFO 会永久阻塞: 改为先 stat、读取前拒绝(FIFO 探针验证) - F4【中低】SSH 模式 + 宿主不可用时 reveal 整条消失:保留 kind==='reveal' 目标 - F5【低】图钉嵌套交互语义非法且键盘不可达:改为诚实的鼠标热区,键盘入口归设置面板 - F6【低】变更树空闲 tick 全量重渲染:行 memo + 恒定 props + 内容键派生 + 共享 store 的 sameStatus 保住快照身份 - F7【低】排序随运行时 locale 漂移:显式 'en' collation + 码点兜底 - F8【低】POSIX 文件名的反斜杠被当成层级:不再替换 - F9【低】打包中重复点击静默、无反馈:busy 行(role=status)+ in-flight 守卫 - 新增:目录行「暂存/取消暂存该目录」(git pathspec 语义,不触发折叠) 验证:typecheck / lint / test(134 文件 1441 通过)/ build 全绿; DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过
40 KiB
文件页 / 文件变动页 UIUX 重构(2026-09-27)
用户需求(原文要点):
- 文件页:Ctrl 多选、Shift 多选;复用 DSH 自带的"使用应用打开";像 VS Code 一样给 Git 变更上色;优化性能;优化拖拽上传体验/样式;新增新建文件夹。
- 文件变动页:彻底优化全部 UIUX 交互与功能,更简洁易用;保留 Git 视角与 Agent 视角。
- 抽取出可复用的全局组件;设计风格与样式参照新的任务管理页;保持 KISS。
本文件是本次重构的冻结契约:所有并行实现必须遵守下面的接口与命名,不得自行改动。
1. 设计基线:任务管理页的原生尺度
src/client/tasks-graph.module.css:1-15 已声明插件原生尺度,本次重构把文件页与文件变动页对齐到它:
| 维度 | 值 |
|---|---|
| 正文 | --dsw-font-xxs-12(12px);强调 --dsw-font-xxs-strong-12 |
| 元信息 | --dsw-font-xxxs-11;微标签 10px |
| 行高 | 列表行 28px(原 34px);密集行 24px |
| 控件 | 28px(宿主 Button sm 同高) |
| 边 | 结构边 --dsw-alias-border-l2 1px;分隔线 --dsw-alias-border-l1 |
| 悬停 | --dsw-alias-interactive-bg-hover |
| 选中 | --dsw-alias-interactive-bg-active + 2px 内嵌强调条(::before,上下内缩 4px) |
| 圆角阶梯 | 2px 徽标/内联 chip,3px 控件簇,4px 行,8px 卡片 |
| 唯一强调色 | --dsw-alias-state-business-primary(当前项、关系、live) |
| 状态色 | 成功 --dsw-alias-state-success-primary、错误 --dsw-alias-state-error-primary、警告 --dsw-alias-state-warn-primary |
| 页面留白 | 12px 横向 gutter;竖直节拍 4 / 6 / 8px |
| 头带 | 36px 高,padding: 0 8px 0 12px,底 1px solid --dsw-alias-border-l1 |
| 阴影 | 流内面板无阴影;仅浮层(popover/window)用 --dsw-shadow-lv2/3 |
| 皮肤契约 | tests/theme.spec.ts 逐条扫描 src/client/**/*.module.css:任何 color: 必须至少含一个 `var(--dsw-* |
不可触碰的既有断言(改了会红):
tests/theme.spec.ts硬编码src/client/tasks-graph.module.css里的.cardBar[data-running='true']::after、@keyframes dsh-tasks-bar-sweep、.barActivity;以及src/client/sidebar.module.css里的.paneCard。tests/file-tree-reference-tail.spec.ts要求.explorerRef, .explorerCopied { margin-left: auto }留在sidebar.module.css。tests/panel-host-css.spec.ts要求sidebar.module.css的:global([data-dsh-panel-host])规则顺序不变。tests/locales.spec.ts:109,118要求 20 份词典 key 集合完全相等:新增 key 必须同时进入全部词典。
2. 全局组件(src/client/ui/,由 Lead 冻结)
新目录 src/client/ui/:
src/client/ui/index.ts 桶导出(唯一对外入口)
src/client/ui/kit.tsx 原子组件
src/client/ui/kit.module.css 样式(token-only)
src/client/ui/git-status.ts 共享 git 状态存储 + hook
2.1 kit.tsx 导出面
/** 28/24px 方形图标按钮(toolbar / 行尾操作统一用它)。 */
export function IconButton(props: {
icon: ReactNode
label: string // aria-label + title 默认值
title?: string
onClick(event: MouseEvent<HTMLButtonElement>): void
size?: 'md' | 'sm' // md=28px(默认),sm=24px
active?: boolean
disabled?: boolean
danger?: boolean
className?: string
}): ReactNode
/** 过滤/切换 chip(带可选计数)。 */
export function Chip(props: {
children: ReactNode
active?: boolean
count?: number
title?: string
disabled?: boolean
onClick(): void
className?: string
}): ReactNode
/** 28px 节标题带(左侧标签 + 可选计数 + 右侧动作)。 */
export function SectionHeader(props: {
label: ReactNode
count?: number
action?: ReactNode
className?: string
children?: ReactNode
}): ReactNode
/** 空/加载/错误/警告/提示 单行态(替代 14 个重复类)。 */
export function Notice(props: {
kind: 'empty' | 'loading' | 'error' | 'warn' | 'hint'
tone?: 'page' | 'well' | 'inline' // page=16px 居中,well=4px 12px 8px(默认),inline=10px 微文案
role?: string
className?: string
children: ReactNode
}): ReactNode
/** 危险操作确认弹窗(宿主 Modal + 安静描述段)。 */
export function ConfirmDialog(props: {
open: boolean
title: string
description: string
confirmLabel: string
cancelLabel: string
danger?: boolean
busy?: boolean
onConfirm(): void
onClose(): void
}): ReactNode
/** 状态字母/词徽标(Git 变更、op 类型、diff 类型共用一套 tone)。 */
export type StatusTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
| 'conflict' | 'read' | 'write' | 'edit' | 'neutral'
export function StatusBadge(props: {
tone: StatusTone
title?: string
children: ReactNode
className?: string
}): ReactNode
实现约束:
- 颜色/尺寸全部来自
kit.module.css的 token;IconButton的size只切 class,不写内联样式。 ConfirmDialog用宿主Modal(@deepseek-ai/dsh-client-ui-primitives),footer = 取消(Button variant="outline")+ 主按钮(variant="primary",disabled={busy}),描述段用.confirmDesc。- 现有页面的等价类(
.explorerConfirmDesc/.gitConfirmDesc/.gitEmpty/.gitPlaceholder/.gitError/.filterChip/.gitBadge/.opKind…)在各自页面重构时迁移到 kit;迁移期两者可并存,但不得修改被测试钉住的类(见 §1)。
2.2 git-status.ts 导出面
共享的 git 状态存储:一个 sessionId + cwd + worktree 键一份快照、一个轮询器,供文件树(着色)与文件变动页(列表)同时消费,避免重复 git status。
export type GitTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' | 'copied' | 'conflict'
/** 一行 porcelain XY 的语义(纯函数,可单测)。 */
export interface GitFileStatus {
letter: string // 展示字母:X 侧优先,其次 Y,'??' → 'U'
tone: GitTone
staged: boolean
unstaged: boolean
}
export function statusOfXY(xy: string): GitFileStatus | undefined
export interface GitStatusView {
/** null = 尚未拿到首个快照。 */
snapshot: GitStatusResult | null
loading: boolean
error: boolean
/** 强制重新拉取(在飞时排队一次,不丢请求)。 */
refresh(): void
/** 绝对路径 → 状态;干净/未知 → undefined。 */
statusOf(absolutePath: string): GitFileStatus | undefined
/** 该目录(含其下任意层级)是否有变更。 */
dirHasChanges(absoluteDir: string): boolean
}
export function useGitStatus(
scope: { sessionId: string; cwd?: string },
options?: { worktree?: string; visible?: boolean; pollMs?: number },
): GitStatusView
实现要点:模块级 Map<key, Slot>;useSyncExternalStore 订阅;仅当存在 visible 订阅者时轮询(默认 2500ms,mode:'self-scheduling' 式单飞);statusOf 首次调用时按快照惰性构建 Map<abs, GitFileStatus> 与"含变更目录" Set(O(n·depth) 一次);路径拼接复用 resolveSidebarPath。
3. 文件页(src/client/FileTree.tsx / TreePanel.tsx / sidebar.module.css)
3.1 多选(Ctrl/Cmd 多选、Shift 连选)
- 状态:
selected: Set<string>(绝对路径)+anchor: string | null(Shift 起点),二者都是 FileTree 内部 state(不持久化)。 - 交互(必须保持现有单击语义不变,
tests/file-tree-rename-delete.spec.tsx、tests/file-tree-drop.spec.tsx不得改断言):- 无修饰键单击:文件行 → 打开(并清空选择);目录行 → 展开/收起(并清空选择)。
- Ctrl/Cmd + 单击:切换该行选中态,
anchor设为该行。 - Shift + 单击:以
anchor为起点,在当前可见行序(深度优先、展开态决定)上连选区间;anchor不变。 - 点击树空白处 / Esc:清空选择。
- 右键:目标行未选中时先把选择收敛为该行(VS Code 语义),已选中则保留整个选区。
- 视觉:选中行
--dsw-alias-interactive-bg-active+ 2px 内嵌强调条;与"reveal 高亮"(--dsw-alias-state-business-tertiary)区分。 - 批量操作条:选区非空时贴在树体顶部(不遮行):
已选择 N 项+ 复制路径 / 删除 / 取消选择。- 删除走
ConfirmDialog(deleteSelectedTitle/deleteSelectedDesc),逐个api.fsRemove(顺序执行,失败停下并进错误条),成功后pruneTree+onPathDeleted。 - 复制路径写全部选中行的绝对路径(换行分隔)。
- 删除走
- 键盘:行保持
role="button" tabIndex={0};ArrowUp/Down可移动焦点(可选,若实现须不破坏现有 Enter/Space)。
3.2 Git 变更着色(VS Code 风格)
- 数据:
useGitStatus(scope)(§2.2);不做第二次git.status拉取。 - 文件行:名字按 tone 上色(modified=warn、added/untracked/copied=success、deleted/conflict=error、renamed=business),行尾加
StatusBadge字母(M/A/D/U/R)与title=状态词。实施更正:初稿把 conflict 写成 business,落地时按语义改为 error(冲突是需要先处理掉的阻塞态;StatusBadge的 conflict 同样落 error 家族)。 - 目录行:其下任意层级有变更时,目录名同样上色(
dirHasChanges),不显示字母。 - Git 不可用/非仓库:完全不上色、不加徽标(静默降级)。
- 新增 i18n:
gitStatusModified|Added|Deleted|Untracked|Renamed|Conflict。
3.3 新建文件夹
- 宿主新增路由
fs.mkdir(src/index.ts)+mkdirWorkspaceEntry(src/fs-operations.ts);客户端api.fsMkdir(scope, path, name)→{ path }。 - UI:目录行与根行右键菜单新增"新建文件夹";触发后在该目录层级顶部插入内联输入行(与 rename 同一套交互:Enter 提交、Esc 取消、blur 提交、IME 守卫、非法名
newFolderInvalid),提交成功即retryDir(dir)并打开该目录。 - 名称规则与 rename 一致:非空、非
./..、不含路径分隔符;服务端二次校验;目标已存在 → 报错进错误条。
3.4 性能
必须落地的四条(有证据的痛点):
- 行组件 memo 化:抽出
FileRow/DirRow(React.memo),把每行的回调与数据做成稳定引用(useCallback+ ref 读dataRef),使"复制态/拖拽目标/选区/菜单"变化只重渲染受影响的行,而不是整棵树。 - 搜索不再卸载树:
TreePanel用hidden属性/样式切换结果面板与树(原来needle !== ''直接卸载FileTree,丢失 level 缓存并在清空时全量重拉,见TreePanel.tsx:245)。 - level 拉取加世代守卫 + AbortController:
loadDir丢弃过期响应;refreshTick清缓存后并发重拉(不再串行等待);tick 触发时只重拉"根 + 已展开"级别。 fs.tree的truncated不再被丢弃:该级别底部渲染Notice kind="hint"(新 keyfilesTruncated)。
另外:useDirectoryWatch 每次渲染重建 key/数组的问题一并修掉(入参用 memo 化数组)。
3.5 拖拽上传体验
- 目标:与任务页同一套视觉(token-only、无硬编码色)。当前
FileTree.tsx:83-110的 SVG 用#3964FE/#9CE5ED,改为currentColor+ CSS class 上色。 - 结构保持(
tests/file-tree-drop.spec.tsx断言uploadDropZone、pill 文案、busy抑制、内存拖拽穿透):仍是"树区高亮框 + 左侧聊天邀请卡",但:- 高亮框:2px 虚线
--dsw-alias-border-l2+ 8px 圆角 + 极轻强调色内衬,去掉大范围遮罩阴影(改为--dsw-alias-bg-base半透明蒙层); - pill:28px 高、
--dsw-alias-bg-layer-1、1px-l2边、--dsw-font-xxxs-11; - 悬浮到目录行时,目录行整体高亮(现有
explorerRowDropTarget换成新风格)。
- 高亮框:2px 虚线
- 上传进度卡(
UploadOverlay.tsx/.uploadOverlay*):同样对齐新尺度(28px 行、12px 文案、token 色)。
3.6 使用应用打开:复用 DSH 自带能力
- 删除插件自研的 open-with 全链路:配置(
sshHost/customEditors/pinned)、src/client/open-with.ts、src/client/open-with-settings.tsx、宿主src/open-external.ts与open.external路由、api.openExternal、EditorHost 的 openWith props、builtins/tabs.tsx的settings.render(OpenWithSettings),以及对应 20 份词典里的openWith*key 与相关测试。 - 改为消费 DSH 自带的 open-in-app(见 §4):文件行显示宿主探测到的系统关联应用;目录行显示宿主应用目录(VS Code / Cursor / Zed / Finder…)+ 文件管理器显示;宿主不可用时整段隐藏(与宿主自身行为一致)。
新增/改用的 i18n:openInApp(打开方式)、openInAppDefault(用默认应用打开)、revealInFileManager(在文件管理器中显示)、openInAppFailed、openInAppEmpty。
4. 宿主接入层(由 Lead 冻结并先行落地)
4.1 src/client/open-in-app.ts(新)
/** 一个可打开当前路径的应用(文件=系统关联;目录=宿主应用目录)。 */
export interface OpenInAppEntry {
id: string
name: string
icon: string | null // 文件:PNG/SVG data URL;目录:宿主图标路由
isDefault: boolean
}
/** 宿主 open-in-app 能力的客户端封装(不可用时全部降级为 no-op / null)。 */
export interface OpenInApp {
/** 宿主是否能把路径交给原生桌面;null = 尚在探测。 */
available(): boolean | null
/** 目录可用的应用目录(宿主 menu 顺序)。 */
directoryApps(): Promise<readonly OpenInAppEntry[]>
/** 某个文件当前注册的系统应用;失败 → null。 */
fileApps(path: string): Promise<readonly OpenInAppEntry[] | null>
/** 用默认应用或指定应用打开(文件或目录)。 */
open(path: string, application?: string): Promise<boolean>
/** 在系统文件管理器中显示。 */
reveal(path: string): Promise<boolean>
}
/** 从客户端 ctx 构造(内部使用 ctx.remote.session.* 与宿主 /open-in-app/* 路由)。 */
export function createOpenInApp(ctx: { get(name: string): unknown }): OpenInApp
宿主 API(DSH 0.1.7-rc.x 自带,不得自研替代):
- Remote:
ctx.get('remote').session.canOpenWorkspacePath()/workspacePathApplications({path})/openWorkspacePath({path, action?:'reveal', application?})(返回值{ok, value})。 - HTTP(同源、带 cookie):
GET <prefix>/open-in-app/apps→{apps: string[]};GET <prefix>/open-in-app/icon/<id>图标;POST <prefix>/open-in-app/open{app, path}(仅目录)。 - 应用名字/图标:宿主
open-in-app命名空间(app.vscode等)经 locale bind 读取,失败回退到 id 本身;图标走宿主 icon 路由。
4.2 FileTree 的接线(冻结 prop)
FileTree 新增可选 prop:
/** 宿主 open-in-app 句柄(EditorHost 构造并注入;缺省时不渲染该段)。 */
openInApp?: OpenInApp
右键菜单"打开方式"段的新语义:
- 文件行:
fileApps(path)异步拉取 → 默认应用(openInAppDefault,点击open(path))+ 其余应用各一行(open(path, id))+ 分隔线 +revealInFileManager;无应用 →openInAppEmpty(禁用行);宿主不可用 → 整段隐藏。 - 目录行:
directoryApps()→ 每应用一行(open(path, id))+revealInFileManager。 - 失败 → 树顶部错误条(
openInAppFailed,带路径)。
4.3 fs.mkdir
- 宿主:
src/fs-operations.ts新增mkdirWorkspaceEntry({cwd, path, name, fence})(fence 与存在性校验同 rename),src/index.ts注册'fs.mkdir'。 - 客户端:
api.fsMkdir(scope, path, name)。 - smoke 测试的路由清单(
tests/smoke.spec.ts:84-90)不含方法索引,无需改;但要为mkdirWorkspaceEntry增单测。
5. 文件变动页(src/client/changes/*)
目标:更简洁易用,保留 Git 与 Agent 双视角。重构后结构:
ChangesTab
├─ header (36px): 宿主 SegmentedControl[Git | 本轮文件] + 右侧:当前视角的刷新按钮
├─ Git 视角 (GitLens)
│ ├─ 顶部一行:分支/仓库/工作树选择(仅在有多个候选时出现)+ 状态摘要
│ ├─ 变更列表:更改(n) / 已暂存(n) 两组(SectionHeader + 单一 ChangeRow 渲染器)
│ └─ 提交条(吸底):Input + 提交按钮 + 单条状态/错误行
│ └─ 历史:SectionHeader + log 行 + 加载更多(空态明确)
└─ Agent 视角 (SessionLens):Chip 过滤 + 按文件分组行 + 预览
└─ DiffPane(共享预览,保留现有能力:redact / md 阅读 / html 渲染 / pdf / 展开为 diff 页签 / 拖拽高度)
必须修掉的既有缺陷(来自 §6 证据,逐条给通过/拒绝检查):
- 干净的仓库只出现一处空态(不再两组各印一次
noChanges);历史区有自己的空态与失败态。 - 单一错误通道:暂存/取消暂存失败必须被捕获并显示(不再 unhandled rejection);分支切换错误不再显示在提交框下面,而是统一状态行。
- 手动刷新不再静默丢弃(在飞时排队一次);仓库切换只触发一次完整刷新;工作树清单带上
repoRoot。 - 工作树不再在用户未操作时被自动切走(仅在首次挂载且用户未选择时自动选一次)。
- 预览按
callId从最新 ops 重新派生(running → 结算后自动更新);恢复高度时同时应用上限 clamp。 - 轮询与共享:git 状态改由
useGitStatus提供(同 session 的文件树共用一份),session ops 仍由本页轮询,但仅在本视角可见时轮询。 DiffPane与DiffTab的重复加载逻辑抽成一个共享 hook(useGitDiffTarget),行为不变(两者都要继续工作)。- 视觉:全部对齐 §1 的尺度(行 28px、12px 正文、4px 行圆角、token-only),图标按钮/徽标/空态/确认弹窗改用
src/client/ui/kit。
保留能力(不得回归):Git 的暂存/取消暂存/全部操作、提交、分支切换、历史分页、放弃更改、revert、cherry-pick、复制哈希/路径、在工作树/子仓库间切换;Agent 视角的 read/write/edit 过滤、按文件分组、相对时间、字节数、运行中/出错标记;预览面板的全部渲染开关。
6. 验收(每条都要有实际证据)
| # | 检查 | 通过判据 |
|---|---|---|
| 1 | 类型/规范 | pnpm typecheck + pnpm lint 通过 |
| 2 | 单元/组件测试 | pnpm test 全绿(新增测试覆盖:多选、mkdir、git 着色映射、kit 组件、重构后的 changes 页) |
| 3 | 构建 | pnpm build 通过(含 CSS module 编译) |
| 4 | 皮肤契约 | tests/theme.spec.ts 绿;新增 CSS module 无字面色 |
| 5 | i18n | tests/locales.spec.ts 绿(20 份词典 key 集合相等) |
| 6 | 多选 | 组件测试:Ctrl 单击切换、Shift 连选区间、Esc 清空;单击语义不变(既有测试不动仍绿) |
| 7 | 着色 | 组件测试:mock git.status 后行名带 tone class、目录带 dirHasChanges 着色、非仓库无着色 |
| 8 | 新建文件夹 | host 单测(fence/已存在/非法名)+ 客户端组件测试(菜单 → 输入 → api.fsMkdir 调用参数) |
| 9 | open-in-app | 组件测试:宿主可用时渲染应用行、点击调 open(path, id)、不可用段隐藏 |
| 10 | 性能 | 组件测试或结构性验证:搜索不再卸载树(缓存保留)、level 过期响应被丢弃、truncated 有提示 |
| 11 | 文件变动 | 组件测试:空仓库只一处空态、暂存失败显示错误、预览随结算更新、视角切换仍保留预览 |
| 12 | 真实挂载 | pnpm build && pnpm pack && pnpm test:mount(e2e lane)通过 |
7. 写入范围(并行实现不得越界)
| 范围 | 归属 |
|---|---|
src/client/ui/**、docs/plans/2026-09-27-files-changes-uiux.md、src/client/locales.ts(zh/en 新 key) |
Lead |
src/client/FileTree.tsx、src/client/TreePanel.tsx、src/client/sidebar.module.css、src/client/UploadOverlay.tsx、src/client/use-dir-watch.ts、tests/file-tree-*.spec.tsx、tests/editor-host.spec.tsx |
文件页实现者 |
src/client/changes/**、src/client/diff/**、tests/changes-*.spec.*、tests/git-view-worktree.spec.tsx、tests/sandbox-views.spec.tsx |
文件变动页实现者 |
src/index.ts、src/fs-operations.ts、src/client/api.ts、src/client/open-in-app.ts、src/client/EditorHost.tsx、src/client/builtins/tabs.tsx、src/open-external.ts(删)、src/client/open-with*.ts(x)(删) |
Lead(宿主接入层,先行落地) |
src/client/locales-*.ts(19 份第三方词典) |
词典传播实现者(单一写者) |
src/client/locales.ts 的 zh/en 由 Lead 追加;实现者若确需新 key,必须回报 Lead,不得自行添加(避免 20 份词典冲突)。
8. 实施记录(落地后的偏差与事实)
8.1 交付面
- 全局组件:新增
src/client/ui/(kit.tsx+kit.module.css+git-status.ts+index.ts)与tests/ui-kit.spec.tsx;文件页与文件变动页均已真实复用(IconButton / Chip / SectionHeader / Notice / ConfirmDialog / StatusBadge / useGitStatus)。 - 文件页(
FileTree.tsx/TreePanel.tsx/sidebar.module.css/UploadOverlay.tsx/use-dir-watch.ts):多选(Ctrl/Cmd 切换、Shift 按可见行序连选、Esc/空白/右键收敛)、批量条(复制路径 / 删除所选 / 取消选择)、git 着色(文件行名字上色 + M/A/D/U 徽标,目录行仅着色,非仓库静默降级)、新建文件夹(菜单 + 内联输入 +api.fsMkdir)、FileRow/DirRowmemo、level 拉取世代守卫 + AbortController、fs.treetruncated 提示、搜索改为hidden停车不再卸载树、拖拽上传视觉 token 化(原#3964FE/#9CE5ED清零)。 - 宿主接入:
fs.mkdir路由 +mkdirWorkspaceEntry(tests/fs-mkdir.spec.ts6 例);新增src/client/open-in-app.ts(宿主 RemotecanOpenWorkspacePath/workspacePathApplications/openWorkspacePath+ 宿主路由/open-in-app/apps|icon|open的客户端适配,tests/open-in-app.spec.ts10 例)。 - 文件变动页(
changes/**、diff/**、DiffTab.tsx):36px 头(宿主SegmentedControl+ 刷新 IconButton)、单一ChangeRow、SectionHeader 分组、吸底提交条 + 单一状态行、历史区独立空态、useGitStatus共享状态、刷新排队、工作树只自动选一次、预览按 callId 重派生、高度恢复 clamp、session ops 轮询按视角门控、DiffPane/DiffTab共用新 hooksrc/client/diff/use-git-diff.ts(并修掉 DiffPane 的 untracked 预览从不渲染的既有缺陷)。
8.2 相对冻结契约的偏差(均有理由)
- open-with 全链路删除(而非并存回退):
src/open-external.ts、src/client/open-with.ts、src/client/open-with-settings.tsx、src/client/plugin-settings.ts(随之成为死代码)、宿主open.external路由、api.openExternal、editor descriptor 的settings.render面板与 20 个openWith*词条全部移除;外部打开改为 DSH 自带 open-in-app。取舍:原「SSH 远端 + 自定义编辑器 URL 模板」能力随之消失——它服务的场景是 DSH 宿主在远端、浏览器在本地,此时宿主 open-in-app 本就不可用(canOpenWorkspacePath()为 false,整段隐藏,与宿主自身行为一致)。若需要该能力应回到 DSH 侧或单独插件实现。 useSubmenuFlip一并删除:FileTree 的「打开方式」子菜单消失后,src/client/menu-flip.ts+layout.css的子菜单翻转规则 +tests/menu-flip.spec.tsx已无消费者,按死代码清除。- 词典同步为 25 个新键(非 26):原清单最后一项是插入锚点说明;zh/en 与 19 份词典最终同为 486 键。
visible逐层下传:文件树的 git 轮询必须随页签可见性暂停(工作台会保留所有 tab body 挂载),因此EditorHost → TreePanel → FileTree新增可选visible,useGitStatus(..., { visible })在页签隐藏时退订。这是实施期补的性能修正(不在原 §3 清单里)。- 计划 §5 的「状态摘要」行未单独实现:干净时用
changesClean空态 + 两个 SectionHeader 计数 + 分支标签表达,避免第三处重复计数文案(KISS)。 - 未做浏览器级视觉验证:拖拽/多选的观感只有 token 与尺度层面的代码证据 + 行为测试;真实挂载 lane(
pnpm test:mount)见 §8.3。
8.3 验证证据
pnpm typecheck/pnpm lint/pnpm test(126 文件、1318 通过、9 跳过)/pnpm build全绿(§8.6 是修正后的最终数字:1322 通过)。- 新增测试:
tests/ui-kit.spec.tsx(14)、tests/fs-mkdir.spec.ts(6)、tests/open-in-app.spec.ts(10)、tests/file-tree-multiselect.spec.tsx(8)、tests/file-tree-git-status.spec.tsx(4)、tests/file-tree-new-folder.spec.tsx(7)、tests/file-tree-open-in-app.spec.tsx(8)、tests/file-tree-row-memo.spec.tsx(2)、tests/file-tree-search-persist.spec.tsx(3)、tests/file-tree-dir-watch.spec.tsx(5)、tests/diff-git-target.spec.tsx(4),以及 changes-tab / git-view-worktree 的大幅扩充。 - 真实挂载 lane:
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount(本机 PATH 上的 dsh 是 rc.2,脚本按基线拒绝直接运行)→ 7/7 通过。
8.4 真实挂载 lane 抓到的缺陷(单测全绿也漏掉的)
sidbar 客户端 bundle 缺少 remote / remote.session 的 cordis inject 声明时,ctx.get('remote').session 会抛
dsh-better-sidebar: cannot get property "remote.session" without inject——这一抛发生在渲染路径上,会把整个「文件」面板替换成错误条 + Retry(首轮挂载 lane 的截图与 trace 均记录在案,整个 tab sweep 因此失败)。
修法两条,缺一不可:
src/client/index.tsx的inject补上'remote'与'remote.session'(嵌套属性访问也要单独声明);src/client/open-in-app.ts的remoteOf()加 try/catch:没有该服务组合的部署降级为「无打开方式菜单」,而不是把面板整块打崩(tests/open-in-app.spec.ts新增一条「ctx 抛错时全部降级」用例钉子)。
这条正好说明为什么 §6 的验收表里 pnpm test:mount 不能省:单元/组件测试全部是绿的,只有真实宿主挂载才暴露了这个 inject 契约。
8.5 独立验证后的修正(verifier 报告 → 已全部落地)
| 编号 | 问题(verifier 证据) | 处置 |
|---|---|---|
| P1(高) | open-in-app 的 availability 是 tri-state(初始 null),但生产路径没有任何地方调用 probe(),于是 available() !== true 恒成立、整段菜单永不渲染——「复用 DSH 自带打开方式」实际未交付 |
FileTree 挂载时按 handle 探测一次:已知答案(true/false)直接采用,null 才 probe(),结果进 state 驱动菜单重渲染。新增两条钉死该生命周期的测试(含真实 adapter + 假 Host Remote 的端到端用例)。真实挂载截图确认:菜单出现「Open with default app / Visual Studio Code / QuickTime Player / Reveal in File Manager」(宿主探测到的本机关联应用与真实图标) |
| P2(中) | EditorHost 新增的 visible 从未被传入(builtins/tabs.tsx 的文件 tab 未解构 visible)→ 隐藏页签仍在轮询 git |
文件 tab 的 component 解构并传入 visible |
| P3(中) | 文件树与文件变动页没有共用同一把 slot 键(GitLens 总把自动选中的 primary checkout 当 worktree 传)→ 同仓库两条 2.5s 轮询 |
GitLens 只在选中 linked checkout 时才传 worktree(primary 的答案是同一份);tests/ui-kit.spec.tsx 增补「同键两个消费者只发一次请求」用例 |
| P4(中低) | session 视角每个 tick 重折 ops → 新数组击穿下游 memo → 每次都重新 Blob 编码每行正文 | pull() 在「无新事件且游标未前进」时保留上一次 fold 的引用,直接返回 |
| P5(低) | noChanges 成为死键(20 份词典各一条) |
全部删除(tests/locales.spec.ts 仍绿) |
| P6(低) | 冻结文档写 conflict=business,实现为 error | 以实现为准修正文档(冲突是阻塞态,归 error 家族更合理) |
| P7(低) | 新增 fs.mkdir 未进接入文档的 fence 路由清单 |
指南 §路由安全边界补上 fs.mkdir(并补齐原先漏列的 fs.rename/fs.remove) |
| P8(低) | 行有 data-dsh-selected 但无 aria-pressed |
文件/目录行按选中态输出 aria-pressed |
| P9(低) | 手动刷新与定时 tick 可重叠、重复追加同一 delta | pull() 单飞(在飞时新请求是 no-op,在飞的那次已覆盖到当下) |
8.6 最终验证(修正之后重跑)
pnpm typecheck/pnpm lint/pnpm test:126 文件、1322 通过、9 跳过;pnpm build通过。- 真实挂载 lane:
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount→ 7/7 通过(含 tab sweep、perf lane)。 - 截图级证据(临时 Playwright 取证脚本,已删除,不留在仓库):真实宿主上文件树行显示 M/U 字母与状态色、右键菜单出现宿主探测到的应用列表、文件变动页显示 36px
Git | Session头 + 未暂存/已暂存分组 + 吸底提交条 + 历史。 - 仍属未验证:拖拽上传的视觉观感与手动多选手感(只有 token/尺度证据与行为测试)、Windows 宿主差异。
9. 第二轮:open-with 与宿主能力并存 + 打包下载 + 文件变动树(用户追加需求)
用户追加三条:
- 打开方式 = 宿主探测到的本机关联应用 + 插件自研 open-with(两者并存,不是二选一);
- 文件页多选时右键新增「压缩打包并下载」;
- 文件变动页再次重构:更美观 + 以层级(树)方式显示更改。
9.1 冻结接口
A. open-with 恢复并与 open-in-app 并存
- 恢复
src/client/open-with.ts(OpenWithConfig/OpenWithTarget/parseOpenWithConfig/resolveOpenWithTargets/openWithUrl/openWithSshActive/newCustomEditorId/isValidCustomEditor,与 main 逐字一致)、src/client/open-with-settings.tsx、src/client/plugin-settings.ts、宿主src/open-external.ts与open.external路由、api.openExternal、editor descriptor 的settings.render,以及 20 个openWith*词条。 FileTree/TreePanel恢复 main 的可选 props:openWithTargets/openWithPinned/openWithSsh/onOpenWith/onToggleOpenWithPin,与既有openInApp并存。- 菜单形状(
打开方式段):- 宿主行(
openInApp可用时):用默认应用打开+ 各系统关联应用; - 插件行:pinned 目标直达 +
在应用中打开 >子菜单(含图钉,main 原样);当宿主可用时隐藏插件的explorer目标(宿主已有「在文件管理器中显示」,避免重复); - 分隔线 + 宿主
在文件管理器中显示(宿主不可用时该行不出现,插件的explorer目标保留,保证仍能 reveal)。
- 宿主行(
- 宿主完全不可用(
available() === false/ 无 handle)时:宿主行整段隐藏,插件行照旧可用(SSH 远端场景因此保持可用)。
B. 压缩打包并下载
- 新增
src/zip.ts:buildZip(entries, opts)产出 ZIP(store +zlib.deflateRaw两种方法按压缩收益选择;UTF-8 名称标志位;目录条目;CRC32;无第三方依赖)。 - 新增宿主 GET 路由
/sidebar/archive?sessionId=&cwd=&name=<archive.zip>&path=<p>&path=<p>…:每个path经既有 workspace fence 校验,文件/目录(递归)都接受;上限(条目数 / 总字节)超限返回 JSON 错误;成功时content-type: application/zip+content-disposition: attachment。 - 客户端
api.archiveUrl(scope, paths, name)(与downloadUrl同形的 GET URL)。 - 交互:多选(≥2)时右键菜单出现
压缩并下载({count} 项);单选且为目录时同样出现(单文件不出,避免与「下载」重复)。 - 新词条:
zipDownload/zipDownloadCount/zipFailed。
C. 文件变动页层级显示
- Git 视角的两个分组(未暂存 / 已暂存)各自渲染为目录树:单子目录压缩(
a/b/c一行显示为a/b),目录行有 disclosure chevron + 文件夹图标,文件行有文件图标 + 状态字母 + 名称 + 弱化目录;行尾仍可暂存/取消暂存;默认展开。 - 纯函数
buildChangeTree(entries)(src/client/changes/change-tree.ts)产出树 + 压缩规则,独立单测。 - 视觉打磨:分组头吸顶、计数胶囊、hover 与选中态、缩进参考线、提交条与历史行间距统一到 §1 尺度。
9.2 分工与写入范围
| 范围 | 归属 |
|---|---|
| 计划文档、验证裁决、集成、最终门禁 | Lead |
src/index.ts、src/zip.ts、src/client/api.ts、src/client/open-with.ts、src/client/open-with-settings.tsx、src/client/plugin-settings.ts、src/open-external.ts、src/client/EditorHost.tsx、src/client/builtins/tabs.tsx、相关测试 |
宿主接入实现者 |
src/client/FileTree.tsx、src/client/TreePanel.tsx、src/client/sidebar.module.css、文件页测试 |
文件页实现者 |
src/client/changes/**、src/client/diff/**、变动页测试 |
文件变动页实现者 |
src/client/locales-*.ts(19 份) |
词典实现者 |
| 独立验证 | 验证者 |
9.3 实施记录(第二轮)
- 打开方式并存(落地形状):菜单段无标题行 —— 宿主行(
用默认应用打开+ 系统关联应用,仅宿主可用)→ 分隔线 → 插件 pinned 直达行 +在应用中打开 >子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主在文件管理器中显示。宿主可用时过滤插件的explorer目标;宿主不可用/无 handle 时只留插件行(含explorer,reveal 不丢)。- 自研链路逐字恢复:
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 份词典从git show main:逐字取回)。 - 因菜单去掉标题行,
openInApp词条成为死键 → 已从 21 份词典删除(zh 507 键)。 - 子菜单需要
useSubmenuFlip:第一轮删除的src/client/menu-flip.ts+layout.css翻转规则 +tests/menu-flip.spec.tsx一并恢复。
- 自研链路逐字恢复:
- 压缩打包下载:新增
src/zip.ts(自研 ZIP 写入器,无第三方依赖)+src/archive-route.ts(下载名净化 + 递归收集)+GET /sidebar/archive+api.archiveUrl;FileTree多选右键「压缩并下载(N 项)」(单选目录亦可,单文件不出),下载走fetch → blob → objectURL,路由错误(超限 / fence 拒绝 / 非 GET)现在会以zipFailed显示在错误条。- 上限:10 000 条目 / 256 MiB 未压缩负载(收集期即中止)。
- 实施中由系统
unzip抓到的真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix → Info-ZIP 把每个文件解成mode 000;改为 Unix 语义(0o100644 * 0x10000,注意<< 16溢出 int32)。复验:mode 644/755、UTF-8 名、unzip -t无错。
- 文件变动层级树:新增
src/client/changes/change-tree.ts(buildChangeTree:'/' 分段、单子目录链压缩成一行、目录先于文件、大小写不敏感排序、文件带GitFileStatus、目录带下级变更数),两个分组各自渲染树;折叠状态按「组 + 路径」独立保存、默认展开;分组头吸顶 + 计数胶囊;Stage all/Unstage all改为带 aria-label 的 IconButton;选中 =interactive-bg-active+ 2px 内嵌强调条;12px/级缩进 +-l1参考线;历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐。- 契约偏差(已批准):文件行不再重复渲染目录文本(树已用父行表达层级,重复是噪声);
ChangeDir额外带changes计数以免每行重扫子树。
- 契约偏差(已批准):文件行不再重复渲染目录文本(树已用父行表达层级,重复是噪声);
9.4 第二轮验证证据
pnpm typecheck/pnpm lint/pnpm test(134 文件、1422 通过、9 跳过)/pnpm build全绿。- 真实挂载 lane:
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount→ 7/7 通过。 - 截图取证(临时 Playwright 脚本,已删除):真实宿主上确认
① 文件树多选 5 项 + 右键菜单同时含 宿主探测应用(Open with default app / Visual Studio Code / QuickTime Player)与 插件
Open with >子菜单 + Zip and download (5 items); ② 变动页 Git 视角以树显示:src/client/changes(单子目录链压缩成一行,带计数胶囊 1)→GitLens.tsx(M 徽标),已暂存组src→app.ts。 - 单文件页回归:14 个 spec / 91 用例绿(含新增
file-tree-archive.spec.tsx9 例:出现条件、成功链、三条失败路径、in-flight 守卫)。 - 仍未验证:真实浏览器里 ZIP 的落盘行为(jsdom spy 证据)、拖拽/多选手感、Windows 宿主差异。
9.5 第三轮(verifier 二轮报告后的修复,全部完成)
| 编号 | 问题 | 修法 | 判别性验证 |
|---|---|---|---|
| F1【高】 | 中文等非 latin1 下载名 → 真实宿主 500(content-disposition 直接塞原始名,Node writeHead 拒绝 >U+00FF) |
archive-route.ts 新增 contentDispositionOf:ASCII 回退 + filename*=UTF-8''…(无可用 ASCII stem 时回退 download.zip) |
临时回退 → 真 node:http 用例 500;对照断言钉住 validateHeaderValue 必抛 |
| F2【中】 | 多选同名不同目录 → 归档条目重名(解压互相覆盖) | disambiguateArchiveNames:只对冲突项逐级上溯父目录,必要时回退完整路径 |
临时回退 → 条目重名断言变红 |
| F3【中低】 | maxBytes 在读完源文件后才判定(与注释相反);且 budget===0 时 0 字节 FIFO 仍会 open() 永久阻塞 |
prepareFile 先 stat,`budget <= 0 |
|
| F4【中低】 | SSH 模式下宿主不可用时 reveal 整条消失 | SSH 过滤保留 kind === 'reveal' 的目标,并注明理由 |
更新 open-with.spec 断言(['explorer','vscode','cursor','custom:e2'],reveal 恰 1 条) |
| F5【低】 | 图钉是 <span role="button" tabIndex={-1}> 嵌在 menuitem 按钮内(语义非法 + 键盘不可达) |
去掉 role/tabIndex,改成诚实的鼠标热区;注释写明键盘入口是设置面板 | 临时加回 role → 用例变红 |
| F6【低】 | 变更树行无 memo,空闲 tick 全量重渲染 | ① 行 memo + 恒定 props(图标解析搬进行内当探针)② 内容键 entriesKey 派生 tree ③ (Lead)共享 store 的 sameStatus 保住快照身份 |
三处各自临时回退 → 探针变红 |
| F7【低】 | 排序依赖运行时 locale collation | localeCompare(other, 'en', { sensitivity: 'base' }) + 码点兜底 |
反向输入顺序不变 + 瑞典 collation 对照断言 |
| F8【低】 | POSIX 文件名里的 \ 被静默转成层级 |
archiveName 不再替换 \ |
实探条目 a\b.txt 保持单条成员 |
| F9【低】 | 打包中重复点击静默吞掉、大选择无反馈 | archiveBusy state + 树顶 Notice kind="loading" role="status" |
deferred fetch 断言忙态行出现/消失、重复点选 fetch 仍 1 次 |
| — | 新增交互:目录行支持「暂存/取消暂存该目录」 | DirRow hover IconButton(gitStage/gitUnstage 均接受目录 pathspec) |
断言 gitStage(scope,'src/deep',MAIN) 且点击不触发折叠 |
最终门禁(修复后重跑):pnpm typecheck / pnpm lint / pnpm test(134 文件 / 1441 通过 / 9 跳过)/ pnpm build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount → 7/7 通过(mount=372ms,p95 帧间隔 16ms,无宽度泄漏)。