# 文件预览与 Git 细节打磨技术方案(换行/下载、并排行内切换、提交正文与筛选、新建本地分支) - 状态:待实施 - 日期:2026-11-03 - 需求来源:[提案:看代码、看差异、交接仓库不再卡手 —— 工作区文件预览与 Git 细节打磨](../proposals/2026-11-02-file-viewer-and-git-ux-polish.md)(§8 两条评审定案已采纳:Diff 工具条用右上角浮动胶囊、Git 历史支持按提交信息搜索) - 规模:小~中(约 2~3 人日;**零数据库迁移**;**零新增 npm 依赖**,全部用已加载的 Monaco API 与既有 git 子进程) ## 1. 需求背景 ### 1.1 现状核对(代码可查,行号为 2026-11-03 主干) | 场景 | 现状 | 代码证据 | |---|---|---| | Monaco 文本查看器换行 | `wordWrap: 'off'` 硬编码在创建参数里,创建后无任何 `updateOptions` 通路;`useMonacoViewer` 的 options 签名(6 个参数)中没有 wordWrap | `desktop/src/composables/useMonacoViewer.ts:53`、`useMonacoViewer.ts:6-14` | | `FileViewer` 文本态操作栏 | 只有 Markdown 的「预览/源码」切换(右上角悬浮)+ 左上角悬浮「刷新」;无换行开关、无复制全文、无下载、无引用到对话 | `desktop/src/components/center/FileViewer.vue:88-96` | | `FileViewer` 图片态操作栏 | 整个分支只有 `img` + meta 文本,**一个操作按钮都没有**(刷新/下载都做不了) | `FileViewer.vue:10-13` | | 下载能力 | `WorkspaceFileProvider.downloadFile?` 已在契约中:CLOUD(`:525`)与 LOCAL(`:312`)实现,project/无会话 provider 不实现;CSV 预览已用 `v-if="provider?.downloadFile"` 同款守卫 | `workspace-file-provider.ts:104,312,525,364`、`CsvTableView.vue:11` | | 「添加到对话」通道 | `add-file-to-chat` 事件链已贯通:TaskView `handleAddFileToChat` → `insertFileReference`;CenterTabContainer 已挂在 FileManagerPanel / GitManagerPanel 上并带 `affectsCurrentSession` 守卫;**FileViewer 尚未接入** | `TaskView.vue:347-349`、`CenterTabContainer.vue:47,64,146-149` | | 「引用到对话」(文本引用) | ChatInputHandle 有 `addReplyQuote(text)`:追加引用 chip 到输入框上方(`capReplyQuote` 截断);TaskView 已有 `quoteReplyToChat(text)` 封装 | `ChatInput.vue:1262`、`TaskView.vue:314-318,351-353` | | Diff 查看器视图模式 | `renderSideBySide: true` 硬编码;无并排/行内切换、无变更跳转 | `desktop/src/components/center/FileDiffViewer.vue:132-147` | | Diff 跳转所需 API | `getLineChanges()` + `revealLineInCenter` 已在 `revealFirstDiffChange` 使用;`onDidUpdateDiff` 监听已有(reveal 首处变更后自毁) | `FileDiffViewer.vue:118-128,161-167` | | Git 历史·提交正文 | `LOG_FORMAT = '%H%x00%h%x00%s%x00%an%x00%aI%x00%D'` 只取标题 `%s`;`getCommitFiles` 仅执行 `--name-status` / `--numstat` 两次 `git show`,返回 `{hash, files}` 无正文 | `backend-ts/src/file/workspace-git.service.ts:169,399-439`、`desktop/electron/gitStatus.cjs:772-787,796-833` | | Git 历史·路径过滤 | 后端 `getLog` 与 Electron `getGitLog` **均已支持** `path` 参数,CLOUD 路由也已透传;**前端 `useGitManager.loadLog` 从未传**,界面上也没有输入框 | `workspace-git.service.ts:380-381`、`gitStatus.cjs:777`、`file.routes.ts:329-337`、`useGitManager.ts:119-141` | | Git 历史·关键词搜索 | 前后端(CLOUD 与 Electron 两侧)**都没有**:`getLog` opts 只有 `{limit, offset, path}` | `workspace-git.service.ts:371-375`、`workspace-git-provider.ts:33`、`electron.d.ts:304` | | Git 历史·引用到对话 | 历史行只有「复制 hash」(`copyHash`),无添加到对话入口 | `GitHistoryView.vue:97-101` | | 新建本地分支 | `resolveSwitchTarget` 在 `create && !isRemoteRef` 时直接抛「仅支持从远端分支创建跟踪分支」;CLOUD 与 Electron 两侧同逻辑 | `backend-ts/src/file/git-write-operation.service.ts:732-735`、`desktop/electron/gitOperations.cjs:401-403` | | 分支名校验 | 前端 `isValidBranchName` 首字符类已排除 `-`;后端写侧 `BRANCH_NAME_PATTERN = /^[A-Za-z0-9._/-]+$/` **不拒**首字符 `-` | `desktop/src/utils/git-manager.ts:144-150`、`git-write-operation.service.ts:27` | | 同名分支冲突路径 | `git switch -c` 遇同名分支非零退出,后端 `classify` 兜底产出「切换分支失败:fatal: a branch named … already exists」,Electron `describeGitFailure` 同样裸透传 git 原文 | `git-write-operation.service.ts:285-288,667-679`、`gitOperations.cjs:482-489,61-70` | ### 1.2 要解决的问题 1. **长行内容看不了、带走难**:Agent 产出单行几千字符的 JSON/日志时只能拖横向滚动条;中心 Tab 打开的文本/图片文件想下载,得去右栏文件树里翻出来右键,图片态甚至完全没有操作入口。 2. **窄屏 Diff 没法看**:三栏全开时中间区域仅 600~700px,写死的左右并排把每边挤到 300px;Monaco 原生支持的行内视图与变更跳转没有被暴露。 3. **Git 历史与对话断联、检索缺失**:提交正文看不到、按提交信息搜不了、按文件路径过滤后端支持但前端没接;看到问题提交只能复制 hash 手打说明;也无法基于当前 HEAD 直接建本地分支。 ### 1.3 关键事实(设计建立在其上) - `useMonacoViewer` 已把 `onEditor` 回调、`isDark` watch 等扩展点留好,加 `wordWrap` 参数与一条 watch 即可,不影响现有唯一调用方 `FileViewer.vue`。 - `FileDiffViewer` 处于 `CenterTabContainer` 的 `KeepAlive(max: 20)` 内(`CenterTabContainer.vue:3,12-18`),组件可长期驻留——inline 偏好读 `localStorage` 一次 + watch 切换即可,不需要响应系统主题式的全局同步。 - `FileDiffViewer` 根元素已是 `position: relative`(`FileDiffViewer.vue:267`),右上角浮动胶囊可直接绝对定位,不新增包裹层。 - `useGitManager.loadLog` 的分页语义是「追加式」:`logOffset` 累加返回条数、`logExhausted = 条数 < LOG_PAGE_SIZE(50)`(`useGitManager.ts:9,119-141`)——加筛选参数后「刷新(reset=true)」天然重置 offset,「加载更多」沿用同参数即可,无需改分页机制。 - `toggleCommit` 已有 `commitFilesSeq` 乱序防护与 `commitFiles` Map 缓存(`useGitManager.ts:153-178`),提交正文用同一次请求带回、按 hash 存进第二个 Map,不新增请求时序。 - 写侧分支校验与切换已收敛在 `resolveSwitchTarget`(CLOUD :712-740 / Electron gitOperations.cjs:384-407 两份同规则实现),`switchBranch` 的 stash 生命周期(dirty → stash → switch → 失败恢复 → 成功恢复)两条链路都已就绪——新建本地分支只需要放开目标解析这一处。 - 后端 `classify`(`git-write-operation.service.ts:667-679`)按 output 关键字归类 git 失败;「分支已存在」目前落进兜底透传 git 原文,需要在 switch 调用点专判。 - 业务错误信封:`BusinessException` 一律 HTTP 200 + `code≠0`,桌面 api 拦截器自动 `ElMessage.error`;Electron 侧 `run` 操作返回 `{success:false, error}` 由 `useGitOperations`/`GitManagerPanel` toast(`GitManagerPanel.vue:416-418` 的 `handleSwitchBranch` 即此通道)。 - `@element-plus/icons-vue` 已是依赖(GitHistoryView 已引 ArrowRight/ArrowDown),新增图标不再加包。 - 本方案**不新增任何内置 Agent 工具、不新增 LLM 调用场景**,不触发 AGENTS.md 中 toolDisplay.ts / no-progress-notice.ts / LlmCallScene 三处同步义务。 ## 2. 需求描述 ### 2.1 要做的(六项) 1. **`FileViewer` 顶部工具条补完(文本 / Markdown / 图片)**:把散落的左上「刷新」与右上「预览/源码」收敛为一个右上角常驻浮动工具条,从左到右依次为:`[预览|源码]`(仅 Markdown)、`[换行]`(仅源码态,切换 `wordWrap: 'on'|'off'`,偏好写入 `localStorage` 下次沿用)、`[复制]`(复制全文,title=复制全文)、`[引用]`(引用到对话,把当前文件路径插入聊天输入框)、`[下载]`(`provider.downloadFile` 存在时显示)、`[刷新]`。图片态提供 `[引用][下载][刷新]` 三个按钮。 2. **`FileDiffViewer` 右上角浮动胶囊**:胶囊内依次为 `[并排|行内]` 切换、`共 N 处变更` 计数、`↑`/`↓` 上一处/下一处变更跳转(循环、纯删除 hunk 落到 original 编辑器行号)。窄屏首开默认行内、宽屏默认并排,用户切换后以 `localStorage` 为准。 3. **Git 历史展示提交正文**:`getCommitFiles`(CLOUD 与 Electron 两侧)增加第三次 `git show -s --format=%B`,返回 `body`(去除首行标题后的正文,trim,可为空串);展开提交时正文以 `
` 展示在文件清单上方,空则不渲染。列表接口 `getLog` 不变。
4. **Git 历史双筛选**:历史视图顶部固定筛选条,两个输入框并排——「按文件路径筛选(如 src/app.ts)」与「按提交信息搜索」;任一留空即忽略该条件,输入 300ms 防抖后触发 `loadLog(true)` 并重置分页;「加载更多」沿用当前筛选值。路径过滤接后端**已有**的 `path` 参数;关键词为**新增** `keyword` 参数(`--grep= --fixed-strings -i`,trim 后截断到 100 字符),CLOUD 路由、Electron IPC、类型声明、provider、useGitManager 五处同步透传。
5. **Git 历史「添加到对话」**:提交行 hover 显示图标按钮,点击把 `提交 abc1234(标题)` 以引用 chip 插入聊天输入框(走 TaskView 既有 `quoteReplyToChat` → `addReplyQuote` 链路,新增 `add-commit-to-chat` 事件透传);展开后的单文件行 hover 显示同款按钮,把文件路径以 `@file` 引用插入(复用既有 `add-file-to-chat` 链路,与 GitChangeList 同语义)。
6. **基于当前 HEAD 新建本地分支**:「分支」视图本地分支分组头部加「+ 新建分支」按钮,展开行内输入行(输入框 + 实时校验提示 + 创建/取消);校验用既有 `isValidBranchName`,通过后 `git switch -c `(放开 `resolveSwitchTarget` 对本地 `create=true` 的限制,CLOUD 与 Electron 同步);同名分支由 git 非零退出天然拦截,专判映射为「分支已存在,请换一个名字」;创建路径显式拒绝首字符 `-` 的分支名。远端「跟踪并切换」行为逐位不变。

### 2.2 明确不做的

- **不把 `FileViewer` 变成在线编辑器**:`readOnly: true` / `domReadOnly: true` 不动,改文件走 Agent 或文件管理器上传/重命名。
- **不做 Agent 结构化 Git 工具链**(2026-10-19 已否决):不碰 `ToolRegistry`、不加内置工具、不给 Agent 暴露任何 git 能力;本方案全部是给人类用户的 Git 管理器面板与预览器。
- **不做 rebase / cherry-pick / merge / 分支重命名 / 分支删除**:只加「新建本地分支」一种写操作。
- **不做提交列表接口的正文返回**:`getLog` 的 LOG_FORMAT 与返回结构不变,正文只在展开单条提交的 `getCommitFiles` 里取(保证列表载荷轻量)。
- **不做 diff 视图模式的会话级/全局状态同步**:偏好只存 `localStorage`,多开 Tab 各自读取,不做 Pinia 共享、不做跨 Tab 广播。
- **不做历史列表虚拟滚动 / 无限流优化**:维持「加载更多」分页现状。
- **不做 CSV/XLSX/ZIP/DOCX/媒体预览组件的改动**:那批组件工具栏已完备(0.0.354 交付),本方案只补文本/图片/Diff 三类老预览器。
- **不做 mao-cli / admin / mao-agent / 安卓壳改动**:Git 管理器与中心预览器均为桌面端专属;admin 无对应页面。
- **不改 LOCAL 的远程跟踪语义**:`create=true + /` 仍走 `git switch -c  --track /`,该分支代码逐行保留。
- **零数据库迁移**、不动 `DEPLOY.md`、不加 Playwright 用例(见 §7.5)。

## 3. 技术选型(已定案)

| # | 决策点 | 定案 | 理由 |
|---|---|---|---|
| D1 | FileViewer 工具条形态 | **右上角单一常驻浮动条**,左上刷新按钮取消、并入工具条;按钮为文字小按钮(沿用 `.mode-btn` 视觉),text 短标签 + title 全称 | 提案 §2.1 定案「收敛为紧凑工具条」;两个悬浮点(左上/右上)合并为一个视觉锚点,图片态复用同一容器与样式;不占文档流,Markdown 渲染区不受推挤 |
| D2 | localStorage 键名 | `aw-file-word-wrap`('1'/'0')、`aw-diff-inline`('1'/'0') | 修正提案的 `mao:diff-inline` 命名:仓内既有键均为 `aw-` 前缀(`aw-theme-mode`),保持一致 |
| D3 | Diff inline 初始值 | `stored != null ? stored === '1' : window.innerWidth < 640`——**无存储时按视口宽窄给默认值,有过切换后永久以存储为准** | 提案公式(`innerWidth < 640 || stored === '1'`)会让窄屏用户永远切不回并排;「存储优先、视口兜底」语义正确 |
| D4 | 变更计数与跳转实现 | 把现有 `onDidUpdateDiff` **一次性自毁监听改为常驻监听**:每次回调刷新 `hunkCount`,首处 reveal 用 `revealedFirst` 布尔只做一次;跳转游标 `hunkIndex` 为模块内变量不入响应式,`(i+Δ+n)%n` 循环,`modifiedStartLineNumber > 0` 走 modified 编辑器、否则走 original | 一次监听同时供计数与 reveal,比双监听少一处生命周期;getLineChanges 返回顺序即文档顺序,循环跳转无需排序 |
| D5 | 提交正文契约 | `getCommitFiles` 返回增加 **`body: string`(必有字段,去除首行标题后 trim,空串表示无正文)**;执行 `git show -s --format=%B ` 第三次调用 | 展开时才请求、列表接口不动;「必有字段 + 空串」比 `body?: string` 少一层 undefined 分叉,前后端与 Electron 三侧判空口径统一 |
| D6 | 筛选状态归属 | `logPathFilter` / `logKeyword` 两个 ref 放 `useGitManager`,暴露 `setLogFilters(patch)`(内部 300ms 防抖 trailing + `loadLog(true)`);GitHistoryView 保持纯展示组件,props 进、update 事件出;仓库切换重置块内清空两筛选与正文缓存 | 与现有 `logEntries/logOffset/logExhausted` 同居一处的既有归属;`onScopeDispose` 清防抖定时器 |
| D7 | keyword 搜索参数 | `getLog` opts 增 `keyword?: string | null`:trim → `slice(0, 100)` → 非空时在 `--` 之前追加 `--grep= --fixed-strings -i`;空值不产生参数(行为与现状逐字节一致);`--grep` 与 `-- ` 天然 AND | `--fixed-strings` 让 `fix.*(` 等元字符按字面匹配不炸命令,`-i` 大小写不敏感;与提案 §8 定案 2 一致;截断而非报错——搜索框 maxlength=100 前端已拦,后端截断是纵深防御 |
| D8 | 新建分支冲突与注入防护 | **不做同名预检**:依赖 `git switch -c` 非零退出,在 switch 失败分支专判 `already exists`(不区分大小写)→ 抛 `PARAM_INVALID「分支已存在,请换一个名字」`(Electron 同文案 operationError),优先于 classify/describeGitFailure 兜底;`create && !isRemoteRef` 路径显式拒绝首字符 `-`(PARAM_INVALID「分支名无效」),**普通切换路径校验逐字节不动** | 少一次 `show-ref` 往返且与提案 §6 风险表一致;`git switch -c -x` 的名字落在 git option 解析位上语义不可控,且前端 `isValidBranchName` 首字符本就拒 `-`,后端 create 路径补齐同规则堵注入面 |
| D9 | 历史「添加到对话」链路 | 提交行 → 新增 `add-commit-to-chat: [text]` 事件(GitHistoryView → GitManagerPanel → CenterTabContainer → TaskView)→ 既有 `quoteReplyToChat`(addReplyQuote 引用 chip);文件行 → 复用既有 `add-file-to-chat: [path]` 链路(GitManagerPanel 已声明该 emit,CenterTabContainer 已转发) | ChatInputHandle 没有「插入纯文本」方法,只有 `insertFileReference` / `addReplyQuote` / `insertFileRangeQuote`(`TaskView.vue:314-318`)——提交说明用引用 chip 语义准确、零新 handle 方法;文件行与 GitChangeList 的「添加到聊天」完全同构 |
| D10 | 新建分支前端交互 | 分组头部「+ 新建分支」点击展开行内输入行(非 ElMessageBox);`isValidBranchName` 不通过或为空时禁用「创建」并显示校验文案;点击「创建」emit `create-branch` 后立即收起输入行,结果提示走既有 `run('switch', name, {create:true})` 错误通道,成功后 `handleRefreshAll()` | 仓内面板交互惯例是行内元素 + popconfirm/toast,不引弹窗;失败提示与「跟踪并切换」同通道,成功后分支列表/状态自动刷新 |

## 4. 实现步骤

### 4.1 后端 `backend-ts/src/file/workspace-git.service.ts`

#### 4.1.1 `getLog` 增加 keyword(:371-391)

```ts
async getLog(
  sessionWorkspace: string,
  repoPath?: string | null,
  opts?: { limit?: number; offset?: number; path?: string | null; keyword?: string | null },
): Promise {
  const repoRoot = await this.resolveRepoRoot(sessionWorkspace, repoPath);
  const limit = clampInt(opts?.limit, LOG_DEFAULT_LIMIT, 1, LOG_MAX_LIMIT);
  const offset = clampInt(opts?.offset, 0, 0, Number.MAX_SAFE_INTEGER);
  const args = ['log', `--format=${LOG_FORMAT}`, '-n', String(limit), '--skip', String(offset)];
  // 提交信息关键词:--fixed-strings 字面匹配(正则元字符不解释)、-i 大小写不敏感;
  // 必须插在 `--` 之前;空值不产生参数,行为与无 keyword 逐字节一致。
  const keyword = opts?.keyword?.trim().slice(0, 100);
  if (keyword) args.push(`--grep=${keyword}`, '--fixed-strings', '-i');
  const pathFilter = opts?.path?.trim();
  if (pathFilter) args.push('--', pathFilter);
  // …其余不变
}
```

同步更新方法 JSDoc:`keyword 可选(--grep 字面匹配,截断 100 字符)`。

#### 4.1.2 `GitCommitFilesDTO` 增加 `body`(:111)

```ts
export interface GitCommitFilesDTO {
  hash: string;
  files: GitChangedFileDTO[];
  /** 提交正文:`git show -s --format=%B` 去除首行标题后的剩余部分(trim,空串表示无正文)。 */
  body: string;
}
```

#### 4.1.3 `getCommitFiles` 返回正文(:399-439)

在 numstat 处理之后、return 之前加:

```ts
const message = await this.runGitOk(repoRoot, ['show', '-s', '--format=%B', normalizedHash]);
const body = message == null ? '' : message.split('\n').slice(1).join('\n').trim();
return { hash: normalizedHash, files: [...files.values()], body };
```

`%B` 是完整原始消息(含结尾换行);单行标题的提交 slice(1) 为空数组、trim 后为空串,天然满足「无正文不展示」。

### 4.2 后端 `backend-ts/src/file/git-write-operation.service.ts`

#### 4.2.1 `resolveSwitchTarget` 放开本地新建(:712-740)

`if (create || isRemoteRef)` 块重构为三分支(远端跟踪行为逐位等价):

```ts
if (isRemoteRef) {
  // 形如 /:建跟踪分支(create 与否均同,原行为)
  const local = name.slice(slashIdx + 1);
  return { args: ['switch', '-c', local, '--track', name], localBranch: local };
}
if (create) {
  // 基于当前 HEAD 新建本地分支:名字直接拼进 argv,首字符 `-` 会被 git 当 option 解析,显式拒绝
  if (name.startsWith('-')) {
    throw new BusinessException(ErrorCode.PARAM_INVALID, '分支名无效');
  }
  return { args: ['switch', '-c', name], localBranch: name };
}
return { args: ['switch', name], localBranch: name };
```

行为对照:`isRemoteRef`(无论 create)→ 跟踪分支(原样);`!isRemoteRef && create` → 原 throw「仅支持从远端分支创建跟踪分支」,改为本地新建;其余 → 纯切换(原样)。同步更新函数 JSDoc。

#### 4.2.2 `switchBranch` 专判同名分支(:285-288)

```ts
if (!switched.success) {
  if (stashOid != null) await this.restoreStash(repo, stashOid, stashRef);
  if (/already exists/i.test(switched.output)) {
    throw new BusinessException(ErrorCode.PARAM_INVALID, '分支已存在,请换一个名字');
  }
  throw new BusinessException(ErrorCode.INTERNAL_ERROR, classify(switched, '切换分支失败'));
}
```

专判放在 stash 恢复**之后**(现场恢复不受影响)、classify 之前;`PARAM_INVALID` 语义为用户输入问题(HTTP 200 业务信封,前端 toast 直显)。

### 4.3 后端 `backend-ts/src/file/file.routes.ts`

`GET /v1/files/workspace-git-log`(:329-337)opts 增加一行:`keyword: queryOptStr(request, 'keyword')`。路由其余不动。

### 4.4 桌面 `desktop/src/composables/useMonacoViewer.ts`

options 增加 `wordWrap?: Ref<'on' | 'off'>`:

- 创建参数 `wordWrap: 'off'`(:53)改为 `wordWrap: options.wordWrap?.value ?? 'off'`;
- 新增 watch:

```ts
watch(options.wordWrap, (v) => {
  editor?.updateOptions({ wordWrap: v })
})
```

现有唯一调用方 FileViewer 不传该参数时行为不变(undefined watch 不触发)。

### 4.5 桌面 `desktop/src/components/center/FileViewer.vue`

#### 4.5.1 模板

`state === 'ready'` 分支(:88-109):删除左上角 `.refresh-btn`(:93-95),右上角 `.view-mode-toggle`(:89-92)改为统一的 `.viewer-toolbar`:

```vue
``` `state === 'image'` 分支(:10-13)顶部插入同容器三按钮:`[引用][下载][刷新]`(无换行/复制)。`.viewer-toolbar` 样式:`position: absolute; top: 8px; right: 8px; display: flex; gap: 2px; z-index: 10;`,`.tool-btn` 复用现 `.mode-btn` 全套视觉(改名或共用类均可,实施时二选一、不留重复 CSS)。 #### 4.5.2 脚本 ```ts const emit = defineEmits<{ 'add-file-to-chat': [filePath: string] }>() const wordWrapOn = ref(localStorage.getItem('aw-file-word-wrap') === '1') const wordWrapRef = computed(() => (wordWrapOn.value ? 'on' : 'off') as 'on' | 'off') function toggleWordWrap() { wordWrapOn.value = !wordWrapOn.value localStorage.setItem('aw-file-word-wrap', wordWrapOn.value ? '1' : '0') } useMonacoViewer({ /* …现有参数 */ wordWrap: wordWrapRef }) async function copyAll() { const ok = await copyText(content.value) if (ok) ElMessage.success('已复制全文') else ElMessage.error('复制失败,请手动选择复制') } async function downloadFile() { const name = props.filePath.split(/[/\\]/).pop() || props.filePath await props.provider?.downloadFile?.(props.filePath, name) } ``` 新增 import:`copyText`(`../../utils/clipboard`,GitHistoryView 同款)、`ElMessage`。 #### 4.5.3 事件接线(`CenterTabContainer.vue`) `FileViewer` 标签(:5-11)加 `@add-file-to-chat="onAddFileToChat"`——复用现有 `onAddFileToChat`(:146-149,含 `affectsCurrentSession` 守卫:从「我的工作区」打开的非本会话目录文件不会插进当前会话输入框)。TaskView 侧零改动(`@add-file-to-chat="handleAddFileToChat"` 已在 :33/:69 绑好)。 ### 4.6 桌面 `desktop/src/components/center/FileDiffViewer.vue` #### 4.6.1 纯函数抽离(新增 `desktop/src/utils/diff-view-mode.ts`) ```ts /** Diff 行内模式初始值:有过切换以 localStorage 为准('1'=行内),否则按视口宽窄兜底。 */ export function resolveInitialDiffInlineMode(stored: string | null, viewportWidth: number): boolean { return stored != null ? stored === '1' : viewportWidth < 640 } /** 变更跳转游标:循环步进(Δ=±1),总数为 0 时原样返回 -1。 */ export function nextHunkIndex(current: number, total: number, delta: 1 | -1): number { if (total <= 0) return -1 return (current + delta + total) % total } ``` 抽成纯函数是为了 jsdom 单测不加载 Monaco(对齐仓内 `unified-patch.ts` 等纯工具先例)。 #### 4.6.2 组件状态 ```ts const inlineMode = ref(resolveInitialDiffInlineMode(localStorage.getItem('aw-diff-inline'), window.innerWidth)) const hunkCount = ref(0) let hunkIndex = -1 // 跳转游标,不渲染,不入响应式 function toggleInline(inline: boolean) { inlineMode.value = inline localStorage.setItem('aw-diff-inline', inline ? '1' : '0') diffEditor?.updateOptions({ renderSideBySide: !inline }) } function jumpHunk(delta: 1 | -1) { if (!diffEditor) return const changes = diffEditor.getLineChanges() if (!changes?.length) return hunkIndex = nextHunkIndex(hunkIndex, changes.length, delta) const target = changes[hunkIndex] if (target.modifiedStartLineNumber > 0) { diffEditor.getModifiedEditor().revealLineInCenter(target.modifiedStartLineNumber) } else if (target.originalStartLineNumber > 0) { diffEditor.getOriginalEditor().revealLineInCenter(target.originalStartLineNumber) } } ``` #### 4.6.3 创建参数与监听改造 - `ensureDiffEditor`(:132-147):`renderSideBySide: true` → `renderSideBySide: !inlineMode.value`。 - `setDiffModels`(:161-167)监听改造(一次性自毁 → 常驻计数 + 单次 reveal): ```ts diffUpdateDisposable?.dispose() revealedFirst = false hunkIndex = -1 diffUpdateDisposable = editor.onDidUpdateDiff(() => { hunkCount.value = editor.getLineChanges()?.length ?? 0 if (!revealedFirst) { revealedFirst = true revealFirstDiffChange() } }) // Diff may already be ready synchronously for small files hunkCount.value = editor.getLineChanges()?.length ?? 0 revealFirstDiffChange() ``` `revealedFirst` 为模块内布尔(每次 setDiffModels 重置);disposeDiffEditor 不变(监听随其销毁)。 #### 4.6.4 胶囊模板与样式 放在 `