在 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 菜单标题同步。
231 lines
9.2 KiB
TypeScript
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
|
|
}
|