用 Rust 和 ratatui 从零写一个终端 git diff 工具:kdiff 实践
引言
在日常开发中,git diff 是使用频率最高的命令之一,但它的纯文本输出在处理大型变更时并不友好:滚动查找、上下文缺失、改动位置不直观。于是我用 Rust 写了一个终端内的可视化 diff 工具 kdiff,目标是:不离开终端,就能像用图形化工具一样浏览 diff。
本文记录 kdiff 的技术选型与核心实现思路,涉及 ratatui 0.29 构建 TUI、similar 2.7 做 diff 算法、crossterm 处理终端事件,以及三种 diff 基准、单词级高亮等功能的实现。
一、技术选型
1.1 ratatui 0.29:声明式的 TUI 框架
ratatui 是 Rust 生态中最成熟的 TUI 库。它的核心思想是“立即模式 + 组件化”:每一帧根据当前 State 重新渲染整个界面,不需要手动管理脏区域。
kdiff 的界面分为三个区域:左侧文件列表、右侧 diff 内容区、底部状态栏/帮助栏。ratatui 的 Layout 系统让这种分栏变得很自然:
- 用
Layout::horizontal把屏幕切成左右两栏; - 文件列表用
List组件,支持高亮选中行; - diff 内容用
Paragraph配合预先计算好的Line/Span,逐行渲染带颜色的行; - 底部用一个单行
Paragraph显示快捷键提示。
ratatui 0.29 相比旧版本,Style 和 Span 的 API 更加统一,处理富文本(比如一行内多种颜色混排)时代码量明显减少,这对 diff 这种“行内多段高亮”的场景非常友好。
1.2 similar 2.7:diff 算法引擎
similar 是 Rust 的 diff 库,实现了 Myers 差分算法。它的 API 设计很贴合实际需求:
TextDiff::from_lines(old, new)按行计算 diff,得到一组DiffOp(Equal/Delete/Insert/Replace);- 每个
DiffOp可以进一步按字符/单词切分,做细粒度高亮; - 支持
grouped_ops(n)直接生成带 n 行上下文的 hunk,省去了自己写 hunk 切分的麻烦。
选择 similar 而不是自己实现 Myers,一是算法正确性有保障,二是它的流式 API 内存占用可控,处理大文件 diff 时不会一次性吃掉太多内存。
1.3 crossterm:跨平台终端事件
crossterm 负责两件事:终端的 raw mode / alternate screen 管理,以及键盘事件的捕获。kdiff 的交互(j/k 上下移动、Tab 切换文件列表与 diff 区、u/s/b 切换 diff 基准、空格折叠/展开、q 退出)全部基于 crossterm 的 KeyEvent。
ratatui 本身不处理输入,crossterm 正好补上这一环,两者是官方推荐的搭配。
二、三种 diff 基准的对比与实现
一个容易被忽视但非常实用的设计是:diff 的“基准”不止一种。kdiff 支持三种基准,一键切换:
| 基准 | 对比对象 | 对应 git 命令 | 适用场景 |
|---|---|---|---|
| 工作区 vs 暂存区 | 工作区文件 vs index | git diff |
查看还没 git add 的改动 |
| 暂存区 vs HEAD | index vs HEAD | git diff --cached |
检查即将提交的内容 |
| 工作区 vs HEAD | 工作区文件 vs HEAD | git diff HEAD |
完整 review 本次所有改动 |
实现上,kdiff 通过 git2 库(libgit2 的 Rust 绑定)获取三种状态的文件内容:
- 工作区内容:直接读文件系统;
- 暂存区内容:从 git index 中读对应 blob;
- HEAD 内容:从 HEAD commit 的 tree 中读对应 blob。
拿到两份文本后,统一丢给 similar 计算 diff。基准切换时只需要重新取“old 文本”,diff 计算逻辑完全复用。这种分层让代码很干净:数据源层负责取文本,diff 层只关心字符串对比。
三、核心功能实现思路
3.1 文件列表与 A/M/D/R/T 状态
git 的文件状态有五种:Added、Modified、Deleted、Renamed、Typechange。kdiff 左侧文件列表的每一行前面用一个字母标识状态,并配不同颜色(新增绿、修改黄、删除红),一眼就能看出变更概览。
状态来自 git2::StatusOptions 的 status 遍历,按路径去重后排序。重命名(R)需要开启 StatusOptions::renames_head_to_index 等选项才能检测到,这是个容易踩坑的细节。
3.2 统一 diff 与并排 diff
- 统一 diff:类似
git diff的默认输出,删除行和新增行交错排列,适合快速浏览小改动。 - 并排 diff:左右两列分别显示 old/new,删除只在左列、新增只在右列,适合对比大段重构。
并排 diff 的实现要点是“行对齐”:similar 给出的 DiffOp 是操作序列,需要把它展开成“行对”——Equal 操作左右都有内容,Delete 只有左列,Insert 只有右列,Replace 则左右各一行配对。配对后左右列等高,渲染时逐行画出,中间加一条分隔线。
3.3 单词级高亮
行级 diff 只能告诉你“这一行改了”,单词级高亮能精确到“这几个词改了”。实现思路是两级 diff:
- 第一级:用 similar 按行 diff,定位到发生变更的行对(old_line, new_line);
- 第二级:对变更行对再做一次字符/单词级 diff。similar 支持按
&str切分 token,可以按单词边界切分,然后对 token 序列再跑一次 diff; - 渲染时,行内未变更的 token 用普通样式,变更的 token 用反色/下划线高亮。
ratatui 的 Span 让行内多段样式混排很容易:一行 Line 由多个 Span 组成,每个 Span 可以有自己的颜色和修饰。
3.4 可折叠的上下文
diff 默认带 3 行上下文,但大文件的 diff 里上下文会占据大量屏幕。kdiff 允许按空格折叠/展开每个 hunk 的上下文区域:
- 数据结构上,每个 hunk 记录
folded: bool状态; - 渲染时,折叠的 hunk 只显示一行占位符(如
@@ 折叠 12 行 @@),展开时显示完整内容; - 折叠状态保存在 App State 里,切换文件或基准时保留,体验连贯。
四、TUI 架构:Elm 架构在终端的应用
kdiff 采用经典的 Elm 架构(Model-View-Update):
- Model:
App结构体持有全部状态——文件列表、选中索引、当前基准、diff 结果缓存、折叠状态、滚动偏移; - Update:crossterm 捕获
KeyEvent,映射为Action枚举(MoveUp/MoveDown/SwitchBase/ToggleFold/Quit…),update函数根据 Action 纯函数式地修改 Model; - View:每一帧
render(&mut frame, &app),根据 Model 从头绘制界面。
这个架构的好处是状态集中、逻辑可预测。diff 计算是纯函数(输入两份文本,输出 diff 结果),可以缓存:只有当选中文件或基准变化时才重新计算,避免每帧重复跑 Myers。
性能上还需要注意:大文件 diff 结果可能有上万行,不能一次性全部转成 ratatui 的 Line。kdiff 采用“按需渲染可视区域”的策略,只把滚动窗口内的行转成 Line,滚动时再增量生成,内存和帧率都有保障。
五、总结
kdiff 的技术栈可以概括为一句话:ratatui 管渲染,similar 管算法,crossterm 管输入,git2 管数据源。四个库各司其职,通过 Elm 架构串起来。
几个值得分享的经验:
- diff 基准是个被低估的功能:三种基准一键切换,review 体验提升明显;
- 两级 diff 做单词高亮:行级定位 + token 级精化,思路通用,也适用于其他文本对比场景;
- 按需渲染:TUI 性能优化的核心是“只画看得见的部分”,不要把几万行一次性塞给渲染器;
- 状态机先行:先把 Action 枚举和 State 转换想清楚,再写渲染代码,TUI 项目会少走很多弯路。
Rust 的所有权模型在 TUI 这种“大量共享状态”的场景下反而成了优势:编译器逼着你把状态划分清楚,运行时几乎不会遇到数据竞争。对于想练手的同学,用 Rust 写一个终端小工具,是熟悉所有权、 trait 和异步之外的另一条很好的实践路径。
- 点赞
- 收藏
- 关注作者
评论(0)