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)
This commit is contained in:
Menghuan1918
2026-09-28 00:27:31 +08:00
parent 606339f81e
commit 36fbc5ac7e
76 changed files with 6961 additions and 3944 deletions
+1 -1
View File
@@ -591,7 +591,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
| `pty.close` / `agent-pty.close` | **已删除**(插件自带的 PTY 栈随终端一起移除;宿主 `ui-sidebar-terminal` 不通过本插件的路由暴露控制面) |
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 `settings-conflict`);后端在 0.1.7 上就是宿主的 `SettingsForms`,见 §8.2 |
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html` 和 `/sidebar/upload` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
+409
View File
@@ -0,0 +1,409 @@
# 文件页 / 文件变动页 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-*|--ds-*)`,禁止字面色 |
不可触碰的既有断言(改了会红):
- `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` 导出面
```ts
/** 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`。
```ts
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 性能
必须落地的四条(有证据的痛点):
1. **行组件 memo 化**:抽出 `FileRow` / `DirRow`(`React.memo`),把每行的回调与数据做成稳定引用(`useCallback` + ref 读 `dataRef`),使"复制态/拖拽目标/选区/菜单"变化只重渲染受影响的行,而不是整棵树。
2. **搜索不再卸载树**:`TreePanel` 用 `hidden` 属性/样式切换结果面板与树(原来 `needle !== ''` 直接卸载 `FileTree`,丢失 level 缓存并在清空时全量重拉,见 `TreePanel.tsx:245`)。
3. **level 拉取加世代守卫 + AbortController**:`loadDir` 丢弃过期响应;`refreshTick` 清缓存后并发重拉(不再串行等待);tick 触发时只重拉"根 + 已展开"级别。
4. **`fs.tree` 的 `truncated` 不再被丢弃**:该级别底部渲染 `Notice kind="hint"`(新 key `filesTruncated`)。
另外:`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` 换成新风格)。
- 上传进度卡(`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`(新)
```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:
```ts
/** 宿主 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 证据,逐条给通过/拒绝检查):
1. 干净的仓库只出现**一处**空态(不再两组各印一次 `noChanges`);历史区有自己的空态与失败态。
2. 单一错误通道:暂存/取消暂存失败必须被捕获并显示(不再 unhandled rejection);分支切换错误不再显示在提交框下面,而是统一状态行。
3. 手动刷新不再静默丢弃(在飞时排队一次);仓库切换只触发**一次**完整刷新;工作树清单带上 `repoRoot`。
4. 工作树不再在用户未操作时被自动切走(仅在首次挂载且用户未选择时自动选一次)。
5. 预览按 `callId` 从最新 ops 重新派生(running → 结算后自动更新);恢复高度时同时应用上限 clamp。
6. 轮询与共享:git 状态改由 `useGitStatus` 提供(同 session 的文件树共用一份),session ops 仍由本页轮询,但**仅在本视角可见时**轮询。
7. `DiffPane` 与 `DiffTab` 的重复加载逻辑抽成一个共享 hook(`useGitDiffTarget`),行为不变(两者都要继续工作)。
8. 视觉:全部对齐 §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`/`DirRow` memo、level 拉取世代守卫 + AbortController、`fs.tree` truncated 提示、搜索改为 `hidden` 停车不再卸载树、拖拽上传视觉 token 化(原 `#3964FE`/`#9CE5ED` 清零)。
- **宿主接入**:`fs.mkdir` 路由 + `mkdirWorkspaceEntry`(`tests/fs-mkdir.spec.ts` 6 例);新增 `src/client/open-in-app.ts`(宿主 Remote `canOpenWorkspacePath`/`workspacePathApplications`/`openWorkspacePath` + 宿主路由 `/open-in-app/apps|icon|open` 的客户端适配,`tests/open-in-app.spec.ts` 10 例)。
- **文件变动页**(`changes/**`、`diff/**`、`DiffTab.tsx`):36px 头(宿主 `SegmentedControl` + 刷新 IconButton)、单一 `ChangeRow`、SectionHeader 分组、吸底提交条 + 单一状态行、历史区独立空态、`useGitStatus` 共享状态、刷新排队、工作树只自动选一次、预览按 callId 重派生、高度恢复 clamp、session ops 轮询按视角门控、`DiffPane`/`DiffTab` 共用新 hook `src/client/diff/use-git-diff.ts`(并修掉 DiffPane 的 untracked 预览从不渲染的既有缺陷)。
### 8.2 相对冻结契约的偏差(均有理由)
1. **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 侧或单独插件实现。
2. **`useSubmenuFlip` 一并删除**:FileTree 的「打开方式」子菜单消失后,`src/client/menu-flip.ts` + `layout.css` 的子菜单翻转规则 + `tests/menu-flip.spec.tsx` 已无消费者,按死代码清除。
3. **词典同步为 25 个新键**(非 26):原清单最后一项是插入锚点说明;zh/en 与 19 份词典最终同为 486 键。
4. **`visible` 逐层下传**:文件树的 git 轮询必须随页签可见性暂停(工作台会保留所有 tab body 挂载),因此 `EditorHost → TreePanel → FileTree` 新增可选 `visible`,`useGitStatus(..., { visible })` 在页签隐藏时退订。这是实施期补的性能修正(不在原 §3 清单里)。
5. **计划 §5 的「状态摘要」行**未单独实现:干净时用 `changesClean` 空态 + 两个 SectionHeader 计数 + 分支标签表达,避免第三处重复计数文案(KISS)。
6. **未做浏览器级视觉验证**:拖拽/多选的观感只有 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 因此失败)。
修法两条,缺一不可:
1. `src/client/index.tsx` 的 `inject` 补上 `'remote'` 与 `'remote.session'`(嵌套属性访问也要单独声明);
2. `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 宿主差异。