Files
apes-sidebar/docs/plans/2026-09-27-files-changes-uiux.md
T
Menghuan1918 13d4bae321 fix(sidebar): 二轮独立验证的 9 项缺陷 + 目录级暂存
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 通过
2026-09-28 18:20:08 +08:00

40 KiB
Raw Blame History

文件页 / 文件变动页 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 性能

必须落地的四条(有证据的痛点):

  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(新)

/** 一个可打开当前路径的应用(文件=系统关联;目录=宿主应用目录)。 */
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 证据,逐条给通过/拒绝检查):

  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 宿主差异。

9. 第二轮:open-with 与宿主能力并存 + 打包下载 + 文件变动树(用户追加需求)

用户追加三条:

  1. 打开方式 = 宿主探测到的本机关联应用 + 插件自研 open-with(两者并存,不是二选一);
  2. 文件页多选时右键新增「压缩打包并下载」;
  3. 文件变动页再次重构:更美观 + 以层级(树)方式显示更改。

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 并存。
  • 菜单形状(打开方式 段):
    1. 宿主行(openInApp 可用时):用默认应用打开 + 各系统关联应用;
    2. 插件行:pinned 目标直达 + 在应用中打开 > 子菜单(含图钉,main 原样);当宿主可用时隐藏插件的 explorer 目标(宿主已有「在文件管理器中显示」,避免重复);
    3. 分隔线 + 宿主 在文件管理器中显示(宿主不可用时该行不出现,插件的 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.tsx 9 例:出现条件、成功链、三条失败路径、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,无宽度泄漏)。