Skip to content

差异查看器 ​

editor.DiffView 显示两个文本之间的改动,如同 Pierre 的 diff 和代码评审工具那样:并排或上下排列,高亮行内改动的单词,折叠改动之间未变的行,新文本显示之处可以编辑。它的每一侧都是一个代码编辑器,带有编辑器的高亮、选区和命令,且只为可见之处布局和绘制。

go
import "github.com/egoist/mygo/ui/editor"

lang := editor.LanguageFor("main.go")
before, after := editor.NewBuffer(original), editor.NewBuffer(edited)
before.SetLanguage(lang)
after.SetLanguage(lang)
app.diff = &editor.DiffState{Old: before, New: after, Editable: true}

// 在视图里:
editor.DiffView(c, app.diff).Grow(1).FillWidth()

DiffState 是查看器的状态,跨帧保存:它比较的缓冲区、它的选项,以及它为自己保存的内容,如展开的行和滚动位置。选项的零值即默认值,也就是 Pierre diff 的默认值。

差异跟随缓冲区:任一侧的编辑立即显示,查看器只重新比较其周围的行。New 也可以是某个编辑器的缓冲区,比如文件的缓冲区,这样差异就会随键入实时显示其改动。

布局 ​

  • Layout 可以在左侧显示旧文本、右侧显示新文本,两侧行对齐(DiffSplit)、同步滚动;或者只显示新文本,删除的行显示在原位置上方(DiffUnified)。
  • Inline 高亮改动行内部改了什么:按单词,并把相隔一个字符的词连起来,使一个短语读起来是一处改动(DiffInlineWordAlt,默认值)、逐个单词、从首个单词到最后一个单词、按字符,或不高亮。
  • Indicators 标记改动行的方式:在装订线边缘画竖条,删除处画条纹(DiffBars)、在其前加 + 和 −(DiffClassic),或仅靠背景色。NoBackground 去掉改动行的着色,NoLineNumbers 去掉行号。
  • ContextLines 是每处改动周围显示的未变行数,默认为 3。其余的行折叠成"N 行未修改"的分隔条,其按钮每次点击展开 ExpansionLines 行(默认 100),按住 Shift 展开全部;ExpandUnchanged、ExpandAll 和 CollapseAll 展开或折叠全部。
  • Wrap 折行长行,两侧的各行与其对应行保持对齐;FitContent 让查看器与其行等高,像在滚动视图中展示评审的文件那样。
  • Font、LineHeight、TabSize 和 Theme 与代码编辑器相同。改动用主题的 Added、Deleted 和 Modified 颜色着色,并在感知均匀的色彩空间中与背景混合,使它们在浅色和深色下看起来一致。

行的比较采用 git 的 histogram diff 算法,它无法拆分的部分用 Myers 算法;改动会滑动到 git 缩进启发式所指定的位置,因此新增一个函数时显示为整个函数,而不是它的最后几行加上下一个函数的开头。改动行会与最相似的行配对以便高亮单词,而不是按顺序配对。

编辑与评审 ​

设置 Editable 后,新的一侧像代码编辑器一样编辑:输入、多个光标、撤销、查找和所有命令,差异跟随每一次键入。Cmd+F8 和 Shift+Cmd+F8 跳到下一处和上一处改动(在其他地方是 Alt+F5 和 Shift+Alt+F5),GoToHunk 跳到指定的一处。

  • HunkActions 在指针下方或光标所在的改动上显示 Revert(撤销)和 Keep(保留):撤销把旧行放回 New,保留把新行变成 Old 的内容,就像逐条评审 agent 的改动那样。应用也可以调用 Revert 和 Accept 做同样的事;Hunks 列出各改动,Stats 统计新增和删除的行数。
  • LineSelection 允许点击、拖动或 Shift+点击行号来选择行,由 OnLineSelection 报告,SelectedLines 和 SetSelectedLines 读取和设置,就像评审中指向某些行的链接那样。
  • OnGutterButton 在指针所在行的装订线上显示一个 +(比如用来添加评论),点击时带着该行被调用。
  • Annotations 在行下方留出空间,放置 RenderAnnotation 在那里构建的内容,比如评审的评论,每条按其要求的高度显示;分栏视图的另一侧则把空间留空,使两侧行保持对齐。
go
app.diff.HunkActions = true
app.diff.OnGutterButton = func(r editor.DiffLineRange) {
	app.comments = append(app.comments, comment{side: r.Side, line: r.End})
}
app.diff.Annotations = app.diff.Annotations[:0]
for i, cm := range app.comments {
	app.diff.Annotations = append(app.diff.Annotations, editor.DiffAnnotation{Side: cm.side, Line: cm.line, Height: 72, Key: i})
}
app.diff.RenderAnnotation = func(c *ui.Context, a editor.DiffAnnotation) {
	app.commentView(c, a.Key.(int))
}

ActiveSide 是拥有键盘(或最后拥有过键盘)的一侧:在分栏视图里旧一侧获得键盘之前都是新一侧。Cursor 和 CursorPos 返回其最新光标的偏移和位置,与代码编辑器的一样;CurrentHunk 返回光标所在的改动(此时 inside 为 true),否则返回其前的最后一处,在第一处之前为 -1,计数方式如"5 处中的第 2 处";GoToHunk 移动该光标,于是"下一处改动"可以从它继续前进。Run 像其按键那样在其编辑器中运行命令,Find 带着查询打开其查找栏——在菜单先接管按键的地方,应用的菜单项就是这样做的:

go
if ui.Button(c, "Find").Clicked() {
	app.diff.Run(editor.Find)
}
i, inside := app.diff.CurrentHunk()
n := len(app.diff.Hunks())
label := fmt.Sprintf("%d changes", n) // 在第一处之前
if i >= 0 {
	label = fmt.Sprintf("%d of %d", i+1, n)
}
ui.Text(c, label)
if ui.Button(c, "Next change").Clicked() {
	app.diff.GoToHunk(i + 1)
}
if inside && ui.Button(c, "Revert this change").Clicked() {
	app.diff.Revert(i)
}

样式 ​

Theme 为差异着色,如同为代码编辑器着色一样,包括改动行。行周围的各部分由应用自己构建,如同 Pierre 的 diff 渲染它们的方式:RenderSeparator、RenderHunkActions 和 RenderGutterButton 收到查看器放置的元素以及该部分显示与操作的内容;查看器自带的外观是 DefaultSeparator、DefaultHunkActions 和 DefaultGutterButton,渲染函数可以调用它们。RenderFindBar 和 RenderGoToLine 构建两侧编辑器的栏,与代码编辑器的一样。

go
app.diff.RenderSeparator = func(c *ui.Context, sep ui.Element, s *editor.DiffSeparator) {
	sep.Padding(4).Children(func() {
		if s.Lines == 0 { // 补丁的一个改动块
			ui.Text(c, s.Header).Font("monospace")
			return
		}
		if ui.Button(c, fmt.Sprintf("Show %d hidden lines", s.Lines)).Clicked() {
			s.ExpandAll()
		}
	})
}
app.diff.RenderHunkActions = func(c *ui.Context, bar ui.Element, a *editor.DiffHunkActions) {
	bar.Gap(4).Children(func() {
		if ui.Button(c, "Reject").Clicked() {
			a.Revert()
		}
		if ui.PrimaryButton(c, "Accept").Clicked() {
			a.Keep()
		}
	})
}
  • DiffSeparator 横跨查看器,分栏时横跨两侧,查看器滚动时还要减去滚动条轨道所占的空间(FitContent 时没有),高 32 DIP。它折叠 Lines 行未变的行,ExpandUp、ExpandDown 和 ExpandAll 分别向上、向下或全部展开,每次 Step 行;First 和 Last 标记第一处改动之前和最后一处之后的分隔条,它们只朝一个方向展开。补丁的改动块对应的分隔条不含行,带有它的 Header。
  • DiffHunkActions 位于指针或光标所在改动的右上方:Index 和 Hunk 说明是哪一处,Revert 和 Keep 执行操作。
  • 装订线按钮是指针所在行装订线上的按钮,点击时调用 OnGutterButton;它的渲染函数赋予它外观和标签。

补丁 ​

editor.ParsePatch 读取统一 diff 所改动的文件,即 git diff 和 diff -u 打印的格式,包含重命名、权限模式以及新增和删除的文件;FilePatch.Apply 把一个补丁应用到旧文件的文本上。SetPatch 不需要文件本身就能显示文件的补丁,正如 PR 的 diff 到达时那样:各改动块的行按其在文件中的行号编号,每个改动块位于其头部之下。

go
files, err := editor.ParsePatch(output) // git diff 的输出
if err != nil {
	return err
}
for _, f := range files {
	d := &editor.DiffState{}
	d.SetPatch(f)
	app.files = append(app.files, d)
}

无障碍 ​

辅助技术把每一侧看作一个文本字段,"旧文本"和"新文本",旧的一侧只读,带有其文本和选区。分隔条是一个以其折叠的行命名的组,带有向上、向下或全部展开的按钮;撤销和保留是按钮,"撤销改动"和"保留改动"。这些名称以及查看器写出的文字("4 行未修改")都使用窗口的语言(MyGo 自身的字符串)。