文件面板有个绕不开的问题:Agent 改完代码,你得看它到底改了什么。
最省事的做法是把 git diff 或者自制的差异文本直接丢给前端,前端渲染成带颜色的行。我第一版就这么写的,然后在一个 8000 行的日志文件上崩了。
不是渲染慢,是内存。300KB 的差异文本,转成 HTML 之后每个字符都包在 span 里,Chrome 直接白屏三秒。更糟的是,这份数据是从服务端整段传过来的,浏览器拿到之后做的第一件事是把其中 99% 丢掉,只显示前两千行。
所以现在的分工是:解析、截断、行号计算全在 core 里做完,传给前端的只有它要渲染的那部分。
为什么行号不能让前端算
先说不那么直观的那条。
diff 的行号不是从 1 开始数的。每个 hunk 头写了起点:
@@ -120,7 +120,9 @@
@@ -430,6 +434,8 @@
第一个 hunk 从旧文件 120 行开始,第二个从 430 行开始。中间跳过了 300 行没变化的内容。
前端拿到这些行之后,如果想用 CSS 计数器给左边(旧行号)和右边(新行号)各生成一列序号,会立刻遇到问题:CSS 计数器只能加不能跳,你没法在第二个 hunk 开头把它重置成 430。
有人会想那就在每个 hunk 开头插一个 counter-reset: line 430。能做,但要给每个 hunk 单独包一层容器,而且 + 行只增加新行号不增加旧行号,- 行反过来,ctx 行两个都加。这三种行要用三套计数器规则,还得保证它们各自断开、各自续上。
能写,但脆。改一次渲染逻辑就要重新验一遍四种组合。
所以行号在解析时就定好了,每行带两个字段:
export interface DiffRow {
type: DiffRowType;
/** 旧文件行号(hunk / add 为 null) */
old_no: number | null;
/** 新文件行号(hunk / del 为 null) */
new_no: number | null;
/** 不含行首标记(' ' / '+' / '-')的正文;hunk 行为完整 `@@ … @@` 头 */
text: string;
}
add 行的 old_no 是 null,del 行的 new_no 是 null,hunk 头两个都是 null。前端只负责画,不做任何行号运算。
截断也在服务端
两个阈值,先到者生效:
/** diff 输出行上限。 */
export const DIFF_MAX_ROWS = 2000;
/** diff 原文字节上限(256KB)。 */
export const DIFF_MAX_BYTES = 256 * 1024;
为什么要两个。
只限行数:一个文件被压缩成一行的 JSON 或 minified JS,改动一行就是 200KB,2000 行的限制在这里完全不起作用。
只限字节:一个 2000 行的文件如果每行都很短,字节数可能只有几十 KB,行数限制反而先撞上。但真正会拖死渲染的是行数(每行要一个 DOM 节点),不是字节数。
所以两个都设,各管一种极端。
截断是在逐行循环里判断的,不是先解析完再截:
for (const line of lines) {
const lineBytes = utf8Length(line) + 1; // +1 = 换行符(估算口径,稳定即可)
if (rows.length >= DIFF_MAX_ROWS || bytes + lineBytes > DIFF_MAX_BYTES) {
truncated = true;
break;
}
...
}
truncated 这个标记会一起传给前端,界面上一行小字提示「差异过长,仅显示前 2000 行」。用户知道东西被截了,不会以为文件只有这么点改动。
utf8Length 手写的那二十行
function utf8Length(text: string): number {
let bytes = 0;
for (const ch of text) {
const cp = ch.codePointAt(0) ?? 0;
if (cp <= 0x7f) bytes += 1;
else if (cp <= 0x7ff) bytes += 2;
else if (cp <= 0xffff) bytes += 3;
else bytes += 4;
}
return bytes;
}
Buffer.byteLength 一行就够了,快得多。手写的原因是 patch.ts 这个文件不能依赖任何 Node 内置模块。
理由写在文件头:本模块不依赖任何 node 内置模块,可以直接单测、后续 CLI 复用。
这个约束是我给自己加的。diff 解析是纯字符串处理,理论上浏览器里也能跑。一旦 import { Buffer } from 'node:buffer',这个文件就绑死在 Node 上,以后 CLI 想在别的环境复用就得拆。
代价是这段循环慢一些。实测在 2000 行 × 80 字符的量级上差别是毫秒级,不影响。
头部要丢,hunk 要留
git diff 的输出里,真正有用的是 hunk 那几段,前后全是噪音:
diff --git a/foo.ts b/foo.ts
index 3a2b1c4..5d6e7f8 100644
--- a/foo.ts
+++ b/foo.ts
@@ -120,7 +120,9 @@
前四行对用户毫无意义。所以有一个前缀清单,命中就丢:
const HEADER_PREFIXES: readonly string[] = [
'diff --git ',
'index ',
'new file mode ',
'similarity index ',
'dissimilarity index ',
'rename from ',
'rename to ',
'copy from ',
'copy to ',
'Binary files ',
'GIT binary patch',
];
--- 和 +++ 单独判断,因为它们在 hunk 内部也可能出现(多文件 patch 的下一个文件头)。所以进入 hunk 之后遇到这两个前缀,要把 inHunk 置回 false,否则后面那个文件的头部会被当成 - 行和 + 行,行号全乱。
\ No newline at end of file 这行也丢。它在真实 diff 里合法,但对用户没价值,留着还会让行号错位。
未识别的行静默忽略,不报错也不计数。极端 patch 形态(比如手工拼的、被编辑器改过的)总会有奇奇怪怪的行,报错不如跳过。
路径安全:四道检查,一道都不能省
文件面板要能读任意文件,所以它的路径校验是整个项目里最需要小心的地方之一。
graph TD
A["前端传来 relPath"] --> B["normalizeRelPath<br/>折叠 . 与 .."]
B -->|含非法片段| X["拒绝:outside_root"]
B --> C["join 到 root 后再判一次<br/>startsWith(root + sep)"]
C -->|不在根内| X
C --> D["lstat:条目本身是符号链接"]
D -->|是| Y["拒绝:symlink"]
D --> E["realpath(root) 与 realpath(abs)<br/>双查真实路径"]
E -->|真实路径越过根| Y
E -->|realpath 失败| Z["拒绝:not_found"]
E --> F["放行,返回绝对路径"]
第一道是归一化。a/../../etc/passwd 折叠成 ../etc/passwd,然后被第二道拦住。
第二道在拼接之后:
const rootAbs = path.resolve(root);
const abs = rel === '' ? rootAbs : path.join(rootAbs, ...rel.split('/'));
// 拼接后二次判定(normalize 已折叠 '..',此处挡「拼接异常 / 大小写敏感盘符」等边界)
if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) {
return { ok: false, code: 'outside_root', message: PATH_REJECT_MESSAGE };
}
归一化之后还要再判一次,因为归一化和拼接是两步,两步之间可能出意外。比如 Windows 上大小写不敏感,C:\Work 和 c:\work 是同一个目录但字符串不相等;又比如某个路径片段里带了盘符。
多一道字符串判断的成本接近于零,省下的是"某天发现能读到根目录外的文件"。
第三道查符号链接:
if (linkStat.isSymbolicLink()) return { ok: false, code: 'symlink', message: PATH_REJECT_MESSAGE };
第四道查真实路径:
const [realRoot, realAbs] = await Promise.all([safeRealpath(rootAbs), safeRealpath(abs)]);
if (!realRoot || !realAbs) return { ok: false, code: 'not_found', message: NOT_FOUND_MESSAGE };
if (realAbs !== realRoot && !realAbs.startsWith(realRoot + path.sep)) {
return { ok: false, code: 'symlink', message: PATH_REJECT_MESSAGE };
}
第三道和第四道看起来重复,其实不是。
第三道拦的是"这个条目本身是个链接"。比如 root/link -> /etc,lstat 能看出来。
第四道拦的是"条目不是链接,但路径中间某层是链接"。比如 root/a 是个正常目录,但 root/a/b 这个 b 是指向 /etc 的链接。你请求 a/b/passwd,lstat 只看最后一段,看不出问题;只有把整条路径 realpath 一遍,才发现解析出来是 /etc/passwd。
少任何一道都有洞。
报错不告诉你路径
拒绝的时候返回的是固定文案,不是拼出来的字符串。一共三条,分开的:
路径非法或超出根目录范围
文件已不存在,可能已被删除或移动
无权限读取该路径
三种情况区分开,用户能大概知道是路径写错了还是文件没了。但三条都不带路径。
这是刻意的。Web 面板是对外的,如果错误信息里带上绝对路径,一个能触发拒绝的人就能靠错误信息探测目录结构。这不算高危漏洞,但没必要送。
调试时确实不方便,我一开始想加个环境变量开关,本地开发打开就返回详细信息。后来没加,因为这种开关等于一条后门,而它大概率会在某个环境里被误开。宁可每次都去翻代码。
目录只读一层,排序要稳定
目录枚举只做单层,不做递归。
前端是逐层点击加载的,天然限深,后端没必要一次性把整棵树抖出来。这也是个大目录(比如 node_modules 上层)能秒开的原因。
排序规则比想象中讲究:
function compareEntry(a, b): number {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
const al = a.name.toLowerCase();
const bl = b.name.toLowerCase();
if (al !== bl) return al < bl ? -1 : 1;
if (a.name === b.name) return 0;
return a.name < b.name ? -1 : 1;
}
目录在前,然后按小写字典序,最后用原串兜底。
为什么不用 localeCompare:它在不同 Node 版本、不同 ICU 数据下结果可能不一样,中文和特殊字符的排序尤其不稳定。同一个目录,服务端排一次、前端再排一次,顺序不同,用户看到列表跳来跳去。
toLowerCase() 比较加原串兜底是确定的:README.md 和 readme.md 转小写相同,再按原始字节比,R 在 r 前面,所以顺序固定。
符号链接条目直接不返回。不是拒绝,是根本不出现。原因是链接可能指向根外,出现了就得处理,不出现最省事。
预览分五种,顺序比逻辑重要
文件预览分五态:markdown、text、image、binary、oversize。
判定顺序是这样的:
// 1. size > 1MB(MAX_SNAPSHOT_BYTES)→ oversize(不读内容,图片同样受限)
// 2. ⚠️ 必须先于 NUL 探测:PNG/JPEG 头部含 0x00,先探测会把所有真实图片判成 binary
// 3. 前 8KB 含 NUL → binary
// 4. markdown 扩展名 → markdown + 原文
// 5. 其余 → text
第二条注释是我后来加的,加的时候顺手把设计文档里的伪时序也标了错。
一开始我是按设计文档写的:先读前 8KB 判二进制,再判是不是图片。结果所有 PNG 都打不开,全显示"这是二进制文件,无法预览"。
原因是 PNG 的文件头长这样:
89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52
第 8 个字节开始就是 00。JPEG 更靠前,FF D8 FF E0 之后很快出现 00。
"前 8KB 含 NUL 就判二进制"这个规则对文本文件成立,对图片全错。
所以顺序必须是:先按扩展名认出图片,走图片分支;认不出图片的,才用 NUL 探测判二进制。
设计文档里那段伪代码的顺序是反的,注释里我写了「若按 design §5.2 伪时序『先 NUL 后图片』,任何真实图片都会被误判成 binary」。
这件事的教训不是"文档要看仔细",是:判定顺序本身是逻辑的一部分。这些判定是有依赖的,谁先谁后决定了结果,不是随手排的清单。写这类代码时顺序要单独写注释说明为什么。
顺带一个细节:这里没有复用 session/changes.ts 里的 readSnapshot,虽然它也能读文件。
因为 readSnapshot 把"二进制"和"超大"合成一个标记 oversized: true,两种情况共用一个返回值。但界面上的文案不一样:「文件过大,无法预览」和「这是二进制文件,无法预览」是两种完全不同的提示,用户需要有区分。所以预览这边单独写了一份判定。
重复的代价是两处都要维护 1MB 这个上限。我把常量从 changes.ts 里 export 出来共用,逻辑重复但常量不重复。
一个顺手做对的细节
面板里点开一个文件,如果它正好是这次会话改过的,会同时把 diff 标出来。
这个关联是靠 file_changes 表做的,跟 Git 那套自动提交用的是同一份数据。所以"Agent 改了哪些文件"这个问题只有一个答案,不会出现面板说改了三个、提交记录里两个对不上。
代价是这张表要存改前和改后的完整内容,除了超过 1MB 或含 NUL 的不存。长会话里改动文件多了,这张表会挺大。目前没做清理,会话删了之后数据跟着删。
现在的问题
一是 diff 只支持 unified 格式。如果哪天要接别的版本控制,解析器得重写。
二是没有语法高亮。2000 行的纯文本差异,读起来还是费劲。加上高亮就得引一个 tokenizer,包体积和性能都要重新算。
三是截断之后没法看完整内容。提示"仅显示前 2000 行"是真的只有前 2000 行,想看后面得一页页往下点。做成分页不难,但那样前端要记住状态,复杂度上一个台阶。
代码在 github.com/lwly/delta-…。这篇涉及:
- 路径校验、目录枚举、预览分流
packages/core/src/files/browse.ts - diff 解析与双阈值截断
packages/core/src/files/patch.ts - 会话内的文件变更聚合
packages/core/src/files/changes.ts - 快照读取与 1MB 上限
packages/core/src/session/changes.ts
前端在 packages/web/src/components/FilePanel.vue 和 FilePreview.vue,它们只负责画,不做任何解析。