Files
apes-sidebar/src/client/changes/ops.ts
T
Menghuan1918 24a3813c63 feat!: 合并 Git 面板与文件追踪为统一「文件变动」tab(双视角 + 统一 diff 栈)
在 PR #471(file-trace)基础上的演进:

- 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角
  tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree·
  子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件
  日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对
  时间);file-trace tab 移除(未随任何版本发布)。
- 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/
  DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff
  共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级
  高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文
  hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色
  色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用
  该栈(git diff tab 从此也有高亮)。
- 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再
  直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为
  独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章
  按状态字母着色;选中行高亮。
- i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件
  变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。
- 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/
  ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式
  从 sidebar.module.css 迁至 changes.module.css。
- 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified
  转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、
  diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原
  filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。
- 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单
  7 tab、mount e2e 菜单标题同步。
2026-08-31 01:31:22 +08:00

231 lines
9.2 KiB
TypeScript

/**
* File-operation extraction for the changes tab's session lens: folds the
* session's append-only event log (`tool/call` + `tool/result` pairs) into
* one op per file-touching call — the same semantics as the standalone
* dsh-file-trace plugin's Chat-view extraction, reading the authoritative
* event log instead (the repo's stated preference for cross-feature session
* data). Pure — no React, no DOM.
*/
import type { SidebarSessionEvent } from '../../context-types.ts'
/** The file-touching operation kinds the tracer distinguishes. */
export type FileOpKind = 'read' | 'write' | 'edit'
/** One extracted file operation. */
export interface FileOp {
/** Stable identity: the originating tool-call id. */
readonly callId: string
readonly kind: FileOpKind
/** Workspace-relative or absolute path exactly as the model spelled it. */
readonly path: string
/** Unix epoch ms of the call; the result time when only that is known. */
readonly time: number
/** True while the call has no result yet. */
readonly running: boolean
/** True when the result reported an error. */
readonly isError: boolean
/** The result's error text when isError; presented instead of the payload. */
readonly errorText?: string
/** For 'edit': the model's exact replacement payload. */
readonly edit?: { oldString: string; newString: string }
/** For 'write': the full new content. */
readonly content?: string
/** For 'read': the file content returned by the tool result. */
readonly read?: string
}
/** Tool names mapped to each op kind; unknown names are ignored. */
const READ_TOOLS = new Set(['read', 'view', 'see'])
const WRITE_TOOLS = new Set(['write', 'create'])
const EDIT_TOOLS = new Set(['edit', 'str_replace', 'str-replace-editor', 'multi-edit'])
/** Classify one tool name; undefined when the tool touches no file. */
function kindOf(name: string): FileOpKind | undefined {
if (READ_TOOLS.has(name)) return 'read'
if (WRITE_TOOLS.has(name)) return 'write'
if (EDIT_TOOLS.has(name)) return 'edit'
return undefined
}
/**
* Parse one tool-call arguments JSON body defensively: the payload is
* model-emitted wire data, so every field is checked before use.
*/
function parseArgs(argsRaw: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(argsRaw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return parsed as Record<string, unknown>
} catch {
return {}
}
}
/** Extract the path field common to every file tool's arguments. */
function pathOf(args: Record<string, unknown>): string | undefined {
for (const key of ['file_path', 'path', 'filePath']) {
const value = args[key]
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
/** The 'tool/result' message envelope inside a session event's data. */
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
}
/** One 'tool-result' content block (inner blocks carry the text). */
interface ToolResultBlockLike {
type?: unknown
content?: unknown
isError?: unknown
}
/** The finalized plain text of one tool result (inner text blocks joined). */
function resultText(message: ToolResultMessageLike): string | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') parts.push(textItem.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool result reported an error (the inner block's isError flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
}
/**
* Fold a session event log into the file operations it contains, newest
* first. A `tool/call` seeds a running op (payload from the model's
* arguments); its `tool/result` settles it (read content, error text).
* Calls dispatched through a host tool such as run_code appear as their own
* `tool/call` rows in the log, so nested file calls fold in naturally.
* @param events - the session's append-only event log (oldest → newest).
* @returns the ordered operation list.
*/
export function extractFileOps(events: readonly SidebarSessionEvent[]): FileOp[] {
const byCall = new Map<string, FileOp>()
for (const event of events) {
if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (typeof data.name !== 'string' || typeof data.callId !== 'string') continue
const kind = kindOf(data.name)
if (kind === undefined) continue
const args = parseArgs(typeof data.arguments === 'string' ? data.arguments : '')
const path = pathOf(args)
if (path === undefined) continue
const base: FileOp = { callId: data.callId, kind, path, time: event.time, running: true, isError: false }
if (kind === 'edit') {
const oldString = args.old_string
const newString = args.new_string
byCall.set(data.callId, typeof oldString === 'string' && typeof newString === 'string'
? { ...base, edit: { oldString, newString } }
: base)
continue
}
if (kind === 'write') {
const content = args.content
byCall.set(data.callId, typeof content === 'string' && content.length > 0 ? { ...base, content } : base)
continue
}
byCall.set(data.callId, base)
} else if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
if (message === undefined) continue
const callId = message.source?.callId
if (typeof callId !== 'string') continue
const op = byCall.get(callId)
if (op === undefined) continue
const text = resultText(message)
const isError = resultIsError(message)
const patch: { running: boolean; isError: boolean; errorText?: string; read?: string; content?: string } = { running: false, isError }
if (text !== undefined && text.length > 0) {
if (isError) patch.errorText = text
else if (op.kind === 'read') patch.read = text
else if (op.kind === 'write' && op.content === undefined) patch.content = text
}
byCall.set(callId, { ...op, ...patch })
}
}
return [...byCall.values()].sort((a, b) => b.time - a.time)
}
/**
* Group operations by path, newest op first per file, files ordered by their
* most recent operation.
*/
export function groupByFile(ops: readonly FileOp[]): Map<string, FileOp[]> {
const groups = new Map<string, FileOp[]>()
for (const op of ops) {
const list = groups.get(op.path) ?? []
list.push(op)
groups.set(op.path, list)
}
const ordered = [...groups.entries()].sort((a, b) => b[1][0]!.time - a[1][0]!.time)
return new Map(ordered)
}
/**
* The last content known for a path before the given operation, synthesized
* from earlier ops: a write's payload is authoritative, an edit implies its
* old side. Best effort — a write with no known prior content diffs against
* nothing (all-added).
*/
export function knownContentBefore(ops: readonly FileOp[], path: string, before: FileOp): string | undefined {
const ofFile = ops.filter(op => op.path === path && op.time <= before.time && op !== before)
for (let i = ofFile.length - 1; i >= 0; i -= 1) {
const op = ofFile[i]!
if (op.kind === 'write' && op.content !== undefined) return op.content
if (op.kind === 'edit' && op.edit !== undefined && i === ofFile.length - 1) {
return op.edit.oldString
}
}
return undefined
}
/** One parsed read line: the file's own line number and its content text. */
export interface ReadLine { readonly line: number; readonly text: string }
/**
* Parse a DSH read result into file lines with their real line numbers:
* drops the <content> envelope and "(Showing lines ...)" note; recovers the
* "<n>: " prefix as the line number, falling back to sequential counting.
*/
export function parseReadLines(raw: string): ReadLine[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
const result: ReadLine[] = []
let fallback = 1
for (const line of body.split('\n')) {
if (/^\s*\(Showing lines .*\)\s*$/.test(line)) continue
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
result.push({ line: Number(match[1]), text: match[2] ?? '' })
fallback = Number(match[1]) + 1
} else {
result.push({ line: fallback, text: line })
fallback += 1
}
}
return result
}