summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 15:39:20 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commitc04df904f2fa686cbe5ce0c078a96260af7b08bd (patch)
treeebb892b5b2f4c91505c241d1408fea1a1904a883
parentb9eefda75ab9a63fc510de781d02b92fc04ad31d (diff)
downloadpardes-c04df904f2fa686cbe5ce0c078a96260af7b08bd.tar.gz
pardes-c04df904f2fa686cbe5ce0c078a96260af7b08bd.zip
A diff reads as the code it changes: hunks in their file's language, added and removed rows tinted
A .diff or .patch pane, and a command pane whose output is a diff (`git diff`, `git show`, `diff -u`; told by a `diff --git` line, or `---`/`+++` with a `@@` under them, in its first rows), colour each hunk's code in the language of the file its section names (`+++ b/<path>`, `--- a/<path>` for a deleted file), with tree-sitter, as highlightLocations does for results. src/diff.zig walks a unified diff by its `@@` counts, so a removed line that reads `--- x` is no header. Each side of a hunk is parsed as one text (context and removed lines the old side, context and added lines the new), so a string or comment across lines colours as it does in the file; a side no line takes its colours from is not parsed. A section's hunks share a parse, in pieces of about 40 lines (a hunk past 80 is cut), only those in view, and each piece's colours are kept by its bytes, so scrolling back and a terminal's every repaint parse nothing again. Added and removed lines carry a flag in their style byte; the painter tints their rows to the pane's edge, a little way from the page toward the theme's ANSI green or red, and draws the prefix in that hue pushed to read on it. A file in no known language keeps the old line colours. A command pane is read once its command has finished: its rows are copied once (File.DiffOutput, dropped when the pane runs again), so no frame dumps the scrollback, and painted over what git printed. A running command is shown as it prints. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/tags.md14
-rw-r--r--src/CHANGELOG.md8
-rw-r--r--src/File.zig232
-rw-r--r--src/body_layer.zig1
-rw-r--r--src/diff.zig337
-rw-r--r--src/exec.zig1
-rw-r--r--src/pardes.zig4
-rw-r--r--src/syntax.zig499
-rw-r--r--test/snapshots/diffview.golden119
-rw-r--r--test/snapshots/diffview.snap27
10 files changed, 1204 insertions, 38 deletions
diff --git a/docs/tags.md b/docs/tags.md
index 93c8a6fa..4d4e9178 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -64,6 +64,20 @@ Unsaved text shows on the pane's grip, as acme's modbutton, not in the tag.
In a terminal the grip is two cells: the pane's mode (blank normal, `^`
insert, `$` tty mode), then `*` while unsaved.
+## Reviewing diffs
+
+Open a `.diff` or `.patch` file, or run `git diff` (or `git show`, `diff -u
+old new`) as a command from any tag: once the command has finished, output
+that starts as a diff does (a `diff --git` line, or a `--- ` line with `+++
+` and `@@` under it) is shown as one. Each hunk's code is coloured in the language of the file its
+section names (`+++ b/<path>`, or `--- a/<path>` for a deleted file), its
+old side (context and removed lines) and new side (context and added lines)
+each parsed as one text, so a string or comment across lines colours as it
+does in the file. Added and removed rows are tinted to the pane's edge,
+their `+`/`-` in the tint's hue (the theme's ANSI green and red where it
+has them); a file in no language pardes knows keeps the plain line colours.
+Only the hunks in view are parsed, a few dozen lines at a time, each once.
+
## Editing tags
A tag is text like a body, with the body's normal and insert modes and undo.
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 25740d16..5f53ff8a 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## Unreleased
+
+- A diff reads as the code it changes: a `.diff` or `.patch` file, and a
+ command's output that is a diff (`git diff`), colour each hunk in its
+ file's language, its old and new side each parsed whole, and tint added
+ and removed rows to the pane's edge with their `+`/`-` in the tint's hue.
+ Only the hunks in view are parsed, once each.
+
## 0.0.8
- `~` and `~/` mean the home directory wherever a path is taken: look
diff --git a/src/File.zig b/src/File.zig
index 84cec879..2046a7cd 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -10,10 +10,12 @@ const Surface = pardes.Surface;
const modal = @import("modal.zig");
const filesystem = @import("fs.zig");
const syntax = @import("syntax.zig");
+const diff = @import("diff.zig");
const tracy = @import("tracy.zig");
const dump = @import("dump.zig");
const limits = @import("memory.zig").limits;
const locations = @import("locations.zig");
+const Colors = @import("colors.zig");
const Pane = panes.Pane;
const Output = panes.Output;
const Mini = panes.mini;
@@ -1301,6 +1303,59 @@ pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16,
const SynStyle = struct { fg: [3]u8, bold: bool };
+/// How added and removed diff lines are told apart in every theme: a row
+/// ground a little way from the page toward green or red (the theme's own
+/// ANSI green and red where it has a palette), and the prefix column in
+/// that hue, pushed toward the ink until it reads on that ground. The
+/// syntax colours are drawn over the ground unchanged.
+pub const DiffTints = struct {
+ added_bg: [3]u8,
+ removed_bg: [3]u8,
+ added_fg: [3]u8,
+ removed_fg: [3]u8,
+
+ pub fn of(p: *Pardes) DiffTints {
+ const th = p.theme();
+ const page = th.bg orelse th.tag_bg;
+ const ink = th.fg orelse th.tag_fg;
+ const green: [3]u8 = if (th.palette) |pal| pal[2] else .{ 0x2e, 0xa0, 0x43 };
+ const red: [3]u8 = if (th.palette) |pal| pal[1] else .{ 0xcf, 0x22, 0x2e };
+ const added_bg = Colors.mixOklab(page, green, 0.18);
+ const removed_bg = Colors.mixOklab(page, red, 0.18);
+ return .{
+ .added_bg = added_bg,
+ .removed_bg = removed_bg,
+ .added_fg = legible(green, ink, added_bg),
+ .removed_fg = legible(red, ink, removed_bg),
+ };
+ }
+
+ fn legible(hue: [3]u8, ink: [3]u8, ground: [3]u8) [3]u8 {
+ var fg = hue;
+ var step: usize = 0;
+ while (step < 4 and Colors.contrast(fg, ground) < 3) : (step += 1) fg = Colors.mixOklab(fg, ink, 0.4);
+ return fg;
+ }
+
+ /// The row ground and prefix ink for a line's style byte, if it is an
+ /// added or removed line.
+ pub fn forStyle(t: *const DiffTints, style: u8) ?struct { bg: [3]u8, fg: [3]u8 } {
+ if (style & syntax.diff_added != 0) return .{ .bg = t.added_bg, .fg = t.added_fg };
+ if (style & syntax.diff_removed != 0) return .{ .bg = t.removed_bg, .fg = t.removed_fg };
+ return null;
+ }
+};
+
+/// A diff row's tint: its ground from `x` to the pane's edge.
+fn tintRow(s: *Surface, x: u16, y: u16, end: u16, bg: [3]u8) void {
+ var col = x;
+ while (col < end) : (col += 1) {
+ const cell = s.at(col, y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ }
+}
+
fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
return switch (sy) {
.none => null,
@@ -1320,6 +1375,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
const prefix_width = gutterWidth(pane);
const tz_recolor = tracy.zone(@src(), "synRecolor");
defer tz_recolor.end();
+ const tints: ?DiffTints = if (pane.colorAlgo() == .diff and highlights.len > 0) DiffTints.of(p) else null;
// indexed start, same as bodyText — an empty tail simply paints nothing
var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const total = nlines(p.gpa, f);
@@ -1352,6 +1408,12 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null;
const decoration = Output.decorateRow(f.output, line, metadata);
const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome();
+ // An added or removed line's row, every wrapped row of it, is tinted.
+ const tint = if (tints) |*t| if (base >= highlight_start and base - highlight_start < highlights.len)
+ t.forStyle(highlights[base - highlight_start])
+ else
+ null else null;
+ if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg);
var c: usize = 0;
var screen_c: usize = 0;
while (hs + c < limit and prefix_width + screen_c < tw) {
@@ -1361,7 +1423,11 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
if (idx >= highlight_start) {
const hidx = idx - highlight_start;
if (hidx < highlights.len) {
- if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| {
+ if (if (tint != null and hs + c == 0)
+ SynStyle{ .fg = tint.?.fg, .bold = true }
+ else
+ synStyle(p, @enumFromInt(highlights[hidx] & syntax.syn_bits))) |ss|
+ {
var fill: usize = 0;
while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) {
const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr);
@@ -1390,6 +1456,170 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
}
}
+/// What finished command panes printed, kept while it cannot change: a
+/// copy of the terminal's rows, and whether they are a diff. Taken once
+/// when a finished pane is first drawn, so neither a frame nor a look dumps
+/// the whole scrollback again; a still running command is shown as it
+/// printed. Keyed by the pane and its width (a resize rewraps every row);
+/// a rerun in the pane drops it (exec.runCommand).
+pub const DiffOutput = struct {
+ const Entry = struct {
+ serial: u32,
+ cols: u16,
+ is_diff: bool,
+ /// The rows joined by '\n'; `rows` are slices of it.
+ text: []u8,
+ rows: [][]const u8,
+ used: u64,
+ };
+ slots: [4]?Entry = @splat(null),
+ clock: u64 = 0,
+
+ pub fn reset(o: *DiffOutput, gpa: std.mem.Allocator) void {
+ for (&o.slots) |*slot| {
+ if (slot.*) |e| {
+ gpa.free(e.text);
+ gpa.free(e.rows);
+ }
+ slot.* = null;
+ }
+ }
+
+ /// A pane's command runs again: what it printed before is gone.
+ pub fn drop(o: *DiffOutput, gpa: std.mem.Allocator, serial: u32) void {
+ for (&o.slots) |*slot| if (slot.*) |e| if (e.serial == serial) {
+ gpa.free(e.text);
+ gpa.free(e.rows);
+ slot.* = null;
+ };
+ }
+
+ /// A finished command pane's rows when they are a diff, else null.
+ pub fn lines(o: *DiffOutput, p: *Pardes, pane: *Pane) ?[]const []const u8 {
+ if (pane.command == null or !pane.command_done or pane.command_pty or pane.terminal == null) return null;
+ o.clock += 1;
+ for (&o.slots) |*slot| if (slot.*) |*e| if (e.serial == pane.serial and e.cols == pane.cols) {
+ e.used = o.clock;
+ return if (e.is_diff) e.rows else null;
+ };
+ const shown = terminal.shellRows(p, pane) catch return null;
+ const is_diff = diff.looksLikeDiff(shown);
+ var total: usize = 0;
+ if (is_diff) for (shown) |row| {
+ total += row.len + 1;
+ };
+ const text = p.gpa.alloc(u8, total) catch return null;
+ const rows = p.gpa.alloc([]const u8, if (is_diff) shown.len else 0) catch {
+ p.gpa.free(text);
+ return null;
+ };
+ var at: usize = 0;
+ for (rows, 0..) |*row, i| {
+ @memcpy(text[at..][0..shown[i].len], shown[i]);
+ row.* = text[at..][0..shown[i].len];
+ at += shown[i].len;
+ text[at] = '\n';
+ at += 1;
+ }
+ var oldest: usize = 0;
+ for (&o.slots, 0..) |*slot, i| {
+ const e = slot.* orelse {
+ oldest = i;
+ break;
+ };
+ if (o.slots[oldest]) |old| if (e.used < old.used) {
+ oldest = i;
+ };
+ }
+ if (o.slots[oldest]) |old| {
+ p.gpa.free(old.text);
+ p.gpa.free(old.rows);
+ }
+ o.slots[oldest] = .{ .serial = pane.serial, .cols = pane.cols, .is_diff = is_diff, .text = text, .rows = rows, .used = o.clock };
+ return if (is_diff) rows else null;
+ }
+};
+
+/// A command pane whose output is a diff (`git diff`, `diff -u`): its
+/// rows coloured as a .diff file's are, over what the program printed.
+/// The rows are the terminal's own, so a line it wrapped goes on in the
+/// row below (syntax.highlightDiffRows).
+pub fn recolorDiffOutput(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
+ if (pane.command == null or !p.settings.colors) return;
+ const tz = tracy.zone(@src(), "diffRecolor");
+ defer tz.end();
+ const rows = p.diff_output.lines(p, pane) orelse return;
+ // The copy's rows are one text, joined as a file's lines are.
+ const flat_start = @intFromPtr(rows[0].ptr);
+ const last = rows[rows.len - 1];
+ const flat = rows[0].ptr[0 .. @intFromPtr(last.ptr) + last.len - flat_start];
+ const raw = pane.body.mode == .tty;
+ const View = struct { row: usize, at: usize };
+ const arena = p.scratch.allocator();
+ const views = arena.alloc(?View, body_h) catch return;
+ var lo: usize = std.math.maxInt(usize);
+ var hi: usize = 0;
+ var shown = std.mem.splitScalar(u8, body, '\n');
+ for (views, 0..) |*view, vi| {
+ view.* = null;
+ const line = shown.next() orelse continue;
+ const vr: i32 = @intCast(vi);
+ const w = pane.wrapAt(vr);
+ const row_i: i32 = if (raw) terminal.gridOffset(pane) + vr else w.line;
+ if (row_i < 0 or row_i >= @as(i64, @intCast(rows.len))) continue;
+ const row: usize = @intCast(row_i);
+ const from: usize = if (raw) 0 else @intCast(@max(0, w.at));
+ if (from > rows[row].len or !std.mem.startsWith(u8, rows[row][from..], line)) continue;
+ view.* = .{ .row = row, .at = from };
+ const start = @intFromPtr(rows[row].ptr) - flat_start;
+ lo = @min(lo, start);
+ hi = @max(hi, start + rows[row].len);
+ }
+ if (lo > hi) return;
+ const styles = syntax.highlightDiffRows(arena, flat, lo, hi, true) catch return;
+ const tints = DiffTints.of(p);
+ const ink = p.theme().fg orelse p.theme().tag_fg;
+ const body_y = p.bodyTop(pane, r);
+ shown = std.mem.splitScalar(u8, body, '\n');
+ for (views, 0..) |maybe, vr| {
+ const line = shown.next() orelse break;
+ const view = maybe orelse continue;
+ const base = @intFromPtr(rows[view.row].ptr) - flat_start - lo;
+ const tint = if (base < styles.len) tints.forStyle(styles[base]) else null;
+ if (tint) |t| tintRow(s, tx, body_y + @as(u16, @intCast(vr)), tx + tw, t.bg);
+ var c: usize = 0;
+ var screen_c: usize = 0;
+ while (c < line.len and screen_c < tw) {
+ const grapheme_end = modal.nextGrapheme(line, c);
+ if (grapheme_end <= c) break;
+ const cells = graphemeDisplayWidth(line[c..grapheme_end]);
+ const style = styles[base + view.at + c];
+ const prefix = view.at + c == 0 and (line[0] == '+' or line[0] == '-');
+ // On an added or removed line, what the program coloured red or
+ // green is the tint's to say now: its code takes the page's ink.
+ const paint: ?SynStyle = if (tint != null and prefix)
+ .{ .fg = tint.?.fg, .bold = true }
+ else if (synStyle(p, @enumFromInt(style & syntax.syn_bits))) |ss|
+ ss
+ else if (tint != null)
+ .{ .fg = ink, .bold = false }
+ else
+ null;
+ if (paint) |ss| {
+ var fill: usize = 0;
+ while (fill < cells and screen_c + fill < tw) : (fill += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(screen_c + fill)), body_y + @as(u16, @intCast(vr)));
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ screen_c += cells;
+ c = grapheme_end;
+ }
+ }
+}
+
pub fn drawWrapMarkers(
p: *Pardes,
s: *Surface,
diff --git a/src/body_layer.zig b/src/body_layer.zig
index cb26c1f2..8e7cd030 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -292,6 +292,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg);
} else if (pane.isTerminal() and p.settings.colors) {
panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body);
+ panes.File.recolorDiffOutput(p, s, pane, r, tx, tw, body_h, body);
}
tz_color.end();
diff --git a/src/diff.zig b/src/diff.zig
new file mode 100644
index 00000000..98942469
--- /dev/null
+++ b/src/diff.zig
@@ -0,0 +1,337 @@
+//! A unified diff (`git diff`, `diff -u`), read a line at a time: which
+//! file each section is about, what each line is, and where a hunk line
+//! stands in the old and the new file. The syntax painter (syntax.zig
+//! highlightDiff) walks it.
+//!
+//! A hunk's extent comes from the counts in its `@@` header, as patch
+//! reads it, so a removed line that itself starts `--` (`--- x`) is not
+//! taken for a file header.
+const std = @import("std");
+
+pub const Kind = enum {
+ /// Anything outside a file header and a hunk: a commit message,
+ /// `Only in ...`, a mail's headers.
+ other,
+ /// `diff ...`, `index ...`, `new file mode ...`, `rename from ...`,
+ /// `Binary files ... differ`: said of the section, naming no line.
+ meta,
+ /// `--- <old path>`
+ old_path,
+ /// `+++ <new path>`
+ new_path,
+ /// `@@ -a,b +c,d @@`
+ hunk,
+ context,
+ added,
+ removed,
+ /// `\ No newline at end of file`
+ no_newline,
+};
+
+/// One line, walked.
+pub const Line = struct {
+ kind: Kind,
+ /// Where it is in the old and the new file, from 1. A hunk header has
+ /// its first lines; a removed line the new line now standing where it
+ /// was (the one after it); an added line the old line after it.
+ old: usize = 0,
+ new: usize = 0,
+};
+
+pub const Walk = struct {
+ /// The section's paths, as written, borrowed from the lines walked:
+ /// `a/` and `b/` kept, timestamps cut.
+ old_path: []const u8 = "",
+ new_path: []const u8 = "",
+ /// `diff --git a/x b/y`'s b side, for a section with no `+++` yet
+ /// (a mode change, a rename, a binary file).
+ git_path: []const u8 = "",
+ old_left: usize = 0,
+ new_left: usize = 0,
+ old_at: usize = 0,
+ new_at: usize = 0,
+ /// The hunk's header numbers, which a look past its end clamps to.
+ new_start: usize = 0,
+ new_count: usize = 0,
+ /// Rows a terminal wrapped: inside a hunk, a row that starts with no
+ /// prefix at all continues the line above instead of ending the hunk.
+ continued_rows: bool = false,
+ last: Kind = .other,
+
+ pub fn inHunk(w: *const Walk) bool {
+ return w.old_left > 0 or w.new_left > 0;
+ }
+
+ /// The file the section is about: the new one, else (deleted) the old
+ /// one, else what its `diff --git` line names.
+ pub fn path(w: *const Walk) []const u8 {
+ if (w.new_path.len > 0 and !isDevNull(w.new_path)) return w.new_path;
+ if (w.old_path.len > 0 and !isDevNull(w.old_path)) return w.old_path;
+ return w.git_path;
+ }
+
+ /// Whether the section deletes its file (`+++ /dev/null`).
+ pub fn deleted(w: *const Walk) bool {
+ return isDevNull(w.new_path);
+ }
+
+ pub fn step(w: *Walk, line_raw: []const u8) Line {
+ const line = std.mem.trimEnd(u8, line_raw, "\r");
+ const got = w.stepInner(line);
+ w.last = got.kind;
+ return got;
+ }
+
+ fn stepInner(w: *Walk, line: []const u8) Line {
+ if (w.inHunk()) {
+ // A blank context line, its space stripped by a mailer or an
+ // editor, is still context.
+ const c: u8 = if (line.len == 0) ' ' else line[0];
+ switch (c) {
+ ' ' => if (w.old_left > 0 and w.new_left > 0) {
+ const at: Line = .{ .kind = .context, .old = w.old_at, .new = w.new_at };
+ w.old_left -= 1;
+ w.new_left -= 1;
+ w.old_at += 1;
+ w.new_at += 1;
+ return at;
+ },
+ '-' => if (w.old_left > 0) {
+ const at: Line = .{ .kind = .removed, .old = w.old_at, .new = w.new_at };
+ w.old_left -= 1;
+ w.old_at += 1;
+ return at;
+ },
+ '+' => if (w.new_left > 0) {
+ const at: Line = .{ .kind = .added, .old = w.old_at, .new = w.new_at };
+ w.new_left -= 1;
+ w.new_at += 1;
+ return at;
+ },
+ '\\' => return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at },
+ else => if (w.continued_rows and w.last != .hunk and w.last != .other)
+ return .{ .kind = w.last, .old = w.old_at, .new = w.new_at },
+ }
+ // Counts run out, or a line no hunk has: the hunk was cut short.
+ w.old_left = 0;
+ w.new_left = 0;
+ }
+ if (line.len > 0 and line[0] == '\\' and w.last != .other and w.last != .meta)
+ return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at };
+ if (std.mem.startsWith(u8, line, "diff ")) {
+ w.* = .{ .continued_rows = w.continued_rows };
+ w.git_path = gitPath(line);
+ return .{ .kind = .meta };
+ }
+ if (std.mem.startsWith(u8, line, "--- ")) {
+ // A second `---` with no `+++` between starts another section
+ // (plain `diff -u` output has no `diff` line of its own).
+ w.* = .{ .continued_rows = w.continued_rows, .git_path = w.git_path };
+ w.old_path = cutPath(line[4..]);
+ return .{ .kind = .old_path };
+ }
+ if (std.mem.startsWith(u8, line, "+++ ")) {
+ w.new_path = cutPath(line[4..]);
+ return .{ .kind = .new_path };
+ }
+ if (parseHunk(line)) |h| {
+ w.old_left = h.old_count;
+ w.new_left = h.new_count;
+ w.old_at = h.old_start;
+ w.new_at = h.new_start;
+ w.new_start = h.new_start;
+ w.new_count = h.new_count;
+ return .{ .kind = .hunk, .old = h.old_start, .new = h.new_start };
+ }
+ for (meta_words) |word| if (std.mem.startsWith(u8, line, word)) return .{ .kind = .meta };
+ return .{ .kind = .other };
+ }
+};
+
+const meta_words = [_][]const u8{
+ "index ", "new file mode ", "deleted file mode ", "old mode ", "new mode ",
+ "similarity index ", "rename from ", "rename to ", "copy from ", "copy to ",
+ "Binary files ", "dissimilarity ",
+};
+
+fn isDevNull(path: []const u8) bool {
+ return std.mem.eql(u8, path, "/dev/null");
+}
+
+/// A `---`/`+++` path with what follows it cut: `diff -u`'s tab and
+/// timestamp, and git's quotes round a name it had to escape.
+fn cutPath(rest: []const u8) []const u8 {
+ var name = rest[0 .. std.mem.indexOfScalar(u8, rest, '\t') orelse rest.len];
+ name = std.mem.trimEnd(u8, name, " \r");
+ if (name.len >= 2 and name[0] == '"' and name[name.len - 1] == '"') name = name[1 .. name.len - 1];
+ return name;
+}
+
+/// `diff --git a/x b/y`: the b side. A name with a space in it is split at
+/// the last ` b/`, which is right unless the old name has one too.
+fn gitPath(line: []const u8) []const u8 {
+ if (!std.mem.startsWith(u8, line, "diff --git ")) return "";
+ const rest = line["diff --git ".len..];
+ const at = std.mem.lastIndexOf(u8, rest, " b/") orelse return "";
+ return std.mem.trimEnd(u8, rest[at + 1 ..], " \r");
+}
+
+pub const HunkHeader = struct { old_start: usize, old_count: usize, new_start: usize, new_count: usize };
+
+/// `@@ -a[,b] +c[,d] @@ ...`, a count left out meaning one.
+pub fn parseHunk(line: []const u8) ?HunkHeader {
+ if (!std.mem.startsWith(u8, line, "@@ -")) return null;
+ var at: usize = "@@ -".len;
+ const old = range(line, &at) orelse return null;
+ if (at + 1 >= line.len or line[at] != ' ' or line[at + 1] != '+') return null;
+ at += 2;
+ const new = range(line, &at) orelse return null;
+ if (!std.mem.startsWith(u8, line[at..], " @@")) return null;
+ return .{ .old_start = old[0], .old_count = old[1], .new_start = new[0], .new_count = new[1] };
+}
+
+fn range(line: []const u8, at: *usize) ?[2]usize {
+ const start = number(line, at) orelse return null;
+ if (at.* < line.len and line[at.*] == ',') {
+ at.* += 1;
+ return .{ start, number(line, at) orelse return null };
+ }
+ return .{ start, 1 };
+}
+
+fn number(line: []const u8, at: *usize) ?usize {
+ const from = at.*;
+ var n: usize = 0;
+ while (at.* < line.len and std.ascii.isDigit(line[at.*])) : (at.* += 1)
+ n = std.math.add(usize, std.math.mul(usize, n, 10) catch return null, line[at.*] - '0') catch return null;
+ return if (at.* > from) n else null;
+}
+
+/// Where a walk that ends up at byte `pos` of `content` may safely start:
+/// the start of the nearest `diff ` line before it (no hunk line starts
+/// with a `d`), else the top.
+pub fn anchorBefore(content: []const u8, pos: usize) usize {
+ var end = @min(pos, content.len);
+ while (end > 0) {
+ const nl = std.mem.lastIndexOfScalar(u8, content[0..end], '\n') orelse break;
+ if (std.mem.startsWith(u8, content[nl + 1 ..], "diff ") and nl + 1 <= pos) return nl + 1;
+ end = nl;
+ }
+ return 0;
+}
+
+/// The same for a list of lines: the nearest `diff ` line at or above `row`.
+pub fn anchorRow(lines: []const []const u8, row: usize) usize {
+ var r = @min(row, lines.len);
+ while (r > 0) {
+ r -= 1;
+ if (std.mem.startsWith(u8, lines[r], "diff ")) return r;
+ }
+ return 0;
+}
+
+/// Whether text a command printed is a diff: a `diff --git` line, or a
+/// `--- ` line with a `+++ ` line under it, in its first rows (after
+/// `git show`'s commit header, say).
+pub fn looksLikeDiff(lines: []const []const u8) bool {
+ const n = @min(lines.len, 200);
+ for (lines[0..n], 0..) |line, i| {
+ if (std.mem.startsWith(u8, line, "diff --git ")) return true;
+ if (std.mem.startsWith(u8, line, "--- ") and i + 1 < lines.len and std.mem.startsWith(u8, lines[i + 1], "+++ ") and
+ i + 2 < lines.len and parseHunk(lines[i + 2]) != null) return true;
+ }
+ return false;
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+
+fn splitLines(comptime text: []const u8) [std.mem.count(u8, text, "\n")][]const u8 {
+ @setEvalBranchQuota(100_000);
+ var out: [std.mem.count(u8, text, "\n")][]const u8 = undefined;
+ var it = std.mem.splitScalar(u8, text, '\n');
+ for (&out) |*line| line.* = it.next().?;
+ return out;
+}
+
+const git_diff =
+ \\diff --git a/src/a.zig b/src/a.zig
+ \\index 1111111..2222222 100644
+ \\--- a/src/a.zig
+ \\+++ b/src/a.zig
+ \\@@ -10,4 +10,4 @@ pub fn main() void {
+ \\ const a = 1;
+ \\-const b = 2;
+ \\+const b = 3;
+ \\+const c = 4;
+ \\ const d = 5;
+ \\--- x
+ \\@@ -40,2 +41,2 @@
+ \\ keep();
+ \\-gone();
+ \\+added();
+ \\diff --git a/old.py b/old.py
+ \\deleted file mode 100644
+ \\--- a/old.py
+ \\+++ /dev/null
+ \\@@ -1,2 +0,0 @@
+ \\-def f():
+ \\- return 1
+ \\
+;
+
+test "diff a walk follows the hunk counts, so a removed `--` line is no header" {
+ const lines = splitLines(git_diff);
+ var w: Walk = .{};
+ var kinds: [lines.len]Kind = undefined;
+ for (lines, 0..) |line, i| kinds[i] = w.step(line).kind;
+ try testing.expectEqualSlices(Kind, &.{
+ .meta, .meta, .old_path, .new_path, .hunk,
+ .context, .removed, .added, .added, .context,
+ .removed, .hunk, .context, .removed, .added,
+ .meta, .meta, .old_path, .new_path, .hunk,
+ .removed, .removed,
+ }, &kinds);
+}
+
+test "diff plain diff -u output: no a/ b/, timestamps after the names" {
+ const lines = splitLines("--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++
+ "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++
+ "@@ -3 +3,2 @@\n" ++
+ "-const a = 1;\n" ++
+ "+const a = 2;\n" ++
+ "+const b = 3;\n");
+ try testing.expect(looksLikeDiff(&lines));
+ var w: Walk = .{};
+ for (lines) |line| _ = w.step(line);
+ try testing.expectEqualStrings("new/x.zig", w.path());
+ try testing.expectEqualStrings("old/x.zig", w.old_path);
+}
+
+test "diff output is told from other text by its first rows" {
+ const git = [_][]const u8{ "$ git diff", "diff --git a/x b/x", "index 1..2" };
+ try testing.expect(looksLikeDiff(&git));
+ const show = [_][]const u8{ "commit abc", "Author: a", "", " msg", "", "diff --git a/x b/x" };
+ try testing.expect(looksLikeDiff(&show));
+ const prose = [_][]const u8{ "--- a heading", "+++ not a path", "text" };
+ try testing.expect(!looksLikeDiff(&prose));
+ const nothing = [_][]const u8{ "ls", "a.zig b.zig" };
+ try testing.expect(!looksLikeDiff(&nothing));
+}
+
+test "diff hunk headers: counts left out are one, a broken one is none" {
+ try testing.expectEqual(HunkHeader{ .old_start = 3, .old_count = 1, .new_start = 4, .new_count = 1 }, parseHunk("@@ -3 +4 @@").?);
+ try testing.expectEqual(HunkHeader{ .old_start = 1, .old_count = 0, .new_start = 0, .new_count = 0 }, parseHunk("@@ -1,0 +0,0 @@ fn x").?);
+ try testing.expect(parseHunk("@@ -a +1 @@") == null);
+ try testing.expect(parseHunk("@@ -1 +1") == null);
+ try testing.expect(parseHunk("@@@ -1 -1 +1 @@@") == null);
+}
+
+test "diff a terminal's wrapped row stays in its line" {
+ const lines = [_][]const u8{ "--- a/x.c", "+++ b/x.c", "@@ -1,2 +1,2 @@", "+int a = 1; /* a long", "comment */", " int b;", "-int c;" };
+ var w: Walk = .{ .continued_rows = true };
+ var kinds: [lines.len]Kind = undefined;
+ for (lines, 0..) |line, k| kinds[k] = w.step(line).kind;
+ try testing.expectEqualSlices(Kind, &.{ .old_path, .new_path, .hunk, .added, .added, .context, .removed }, &kinds);
+}
diff --git a/src/exec.zig b/src/exec.zig
index d114dbf8..5e223f04 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -738,6 +738,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
};
p.gpa.free(pane.command.?);
pane.command = owned;
+ p.diff_output.drop(p.gpa, pane.serial);
pane.command_done = false;
pane.command_status = null;
pane.command_pty = true;
diff --git a/src/pardes.zig b/src/pardes.zig
index b16d39b7..9fe6a1fb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4502,6 +4502,9 @@ pub const Pardes = struct {
/// the terminal motion surface, memoized against the pane it was built
/// for — see panes.terminal.RowsCache for the lifetime rule
shell_rows: panes.terminal.RowsCache = .{},
+ /// Finished command panes' output, and whether it is a diff
+ /// (File.DiffOutput).
+ diff_output: panes.File.DiffOutput = .{},
/// The message row's entry points, called from everywhere as
/// `p.setMessage(...)`; they live with the rest of it in Messages.zig.
@@ -4752,6 +4755,7 @@ pub const Pardes = struct {
p.locations_cache.deinit(p.tree_sitter_gpa);
p.fs.deinit(gpa);
p.shell_rows.reset(gpa);
+ p.diff_output.reset(gpa);
p.scratch.deinit();
p.frame_arena.deinit();
gpa.free(p.surface.cells);
diff --git a/src/syntax.zig b/src/syntax.zig
index 8ed00bfa..b7406b04 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -5,6 +5,11 @@ const grammar_manifest = @import("grammar_manifest.zig");
const look = @import("look.zig");
const regexp = @import("regexp.zig");
const c_heap = @import("c_heap");
+const diff = @import("diff.zig");
+
+test {
+ _ = diff;
+}
pub const enabled = config.syntax_highlighting;
const zig_grammar = config.syntax_zig_grammar;
@@ -183,6 +188,7 @@ pub fn stop() void {
spec.objects = null;
spec.objects_failed = false;
}
+ dropDiffPieces();
ts_set_allocator(null, null, null, null);
syntax_started = false;
syntax_allocator = undefined;
@@ -1029,35 +1035,269 @@ fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node {
return null;
}
-pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
- const start_byte = @min(start_byte_raw, content.len);
- const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
- const source = content[start_byte..end_byte];
- const styles = try gpa.alloc(u8, source.len);
+/// A diff line's style byte carries whether the line was added or removed
+/// over its syntax style (the low bits), so the painter can tint its row
+/// and mark its prefix.
+pub const diff_added: u8 = 0x80;
+pub const diff_removed: u8 = 0x40;
+pub const syn_bits: u8 = 0x3f;
+
+/// A file section's hunks are parsed together, each side as one text, in
+/// pieces: a piece takes whole hunks until it holds `diff_piece_lines`
+/// lines, and a hunk longer than `diff_piece_cut` is cut there, counted
+/// from the section's first hunk. So a view parses about what a file's
+/// view does (its rows and a margin), however many small hunks it shows,
+/// and a 20k-line new file is parsed where it is shown, not whole. A string
+/// or comment across a cut colours as a fragment would.
+const diff_piece_lines = 40;
+const diff_piece_cut = 80;
+
+/// Painted hunk pieces, keyed by their bytes and language: scrolling back
+/// over a hunk, or a terminal repainting the same output every frame,
+/// parses nothing again. Freed by `stop`.
+const DiffPiece = struct { hash: u64, styles: []u8, used: u64 };
+var diff_pieces: [96]?DiffPiece = @splat(null);
+var diff_clock: u64 = 0;
+
+fn dropDiffPieces() void {
+ for (&diff_pieces) |*slot| {
+ if (slot.*) |piece| syntax_allocator.free(piece.styles);
+ slot.* = null;
+ }
+ diff_clock = 0;
+}
+
+/// The styles of `content[start..end)` read as a unified diff: headers
+/// muted, `@@` lines as keywords, and each hunk's code in the language of
+/// the file its section names. A hunk's old side (context and removed
+/// lines) and new side (context and added lines) are each parsed whole, so
+/// a string or comment that spans lines colours as it does in the file.
+/// Added and removed lines carry `diff_added`/`diff_removed`. A file in no
+/// language pardes knows keeps the plain colours: added lines as strings,
+/// removed ones as numbers.
+pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize) ![]u8 {
+ return highlightDiffRows(gpa, content, start_raw, end_raw, false);
+}
+
+/// The same for a terminal's rows, where a row the terminal wrapped
+/// continues the diff line above it.
+pub fn highlightDiffRows(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize, continued_rows: bool) ![]u8 {
+ const tz = tracy.zone(@src(), "highlightDiff");
+ defer tz.end();
+ const start_byte = @min(start_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_raw, content.len));
+ const styles = try gpa.alloc(u8, end_byte - start_byte);
errdefer gpa.free(styles);
@memset(styles, 0);
+ const Window = struct {
+ styles: []u8,
+ start: usize,
+ fn put(w: @This(), at: usize, bytes: []const u8) void {
+ const lo = @max(at, w.start);
+ const hi = @min(at + bytes.len, w.start + w.styles.len);
+ if (lo < hi) @memcpy(w.styles[lo - w.start .. hi - w.start], bytes[lo - at .. hi - at]);
+ }
+ fn fill(w: @This(), at: usize, len: usize, style: u8) void {
+ const lo = @max(at, w.start);
+ const hi = @min(at + len, w.start + w.styles.len);
+ if (lo < hi) @memset(w.styles[lo - w.start .. hi - w.start], style);
+ }
+ };
+ const window: Window = .{ .styles = styles, .start = start_byte };
+
+ var walk: diff.Walk = .{ .continued_rows = continued_rows };
+ // The run of hunk lines not painted yet, with each line's kind.
+ var piece_start: ?usize = null;
+ var piece_end: usize = 0;
+ var piece_path: []const u8 = "";
+ var kinds: [diff_piece_cut]diff.Kind = undefined;
+ var nkinds: usize = 0;
+ var at = diff.anchorBefore(content, start_byte);
+ while (true) {
+ const nl = std.mem.indexOfScalarPos(u8, content, at, '\n') orelse content.len;
+ const got = walk.step(content[at..nl]);
+ const body = switch (got.kind) {
+ .context, .added, .removed, .no_newline => true,
+ // The next hunk of the section goes on in the same piece.
+ .hunk => piece_start != null and nkinds < diff_piece_lines,
+ else => false,
+ };
+ if (piece_start) |ps| if (!body or nkinds == diff_piece_cut) {
+ if (piece_end >= start_byte) try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path);
+ piece_start = null;
+ nkinds = 0;
+ };
+ if (at >= end_byte and piece_start == null) break;
+ if (body and (piece_start != null or got.kind != .hunk)) {
+ if (piece_start == null) {
+ piece_start = at;
+ piece_path = walk.path();
+ }
+ kinds[nkinds] = got.kind;
+ nkinds += 1;
+ piece_end = nl;
+ } else if (nl >= start_byte) {
+ const style: Syn = switch (got.kind) {
+ .hunk => .keyword,
+ .meta, .old_path, .new_path => .comment,
+ else => .none,
+ };
+ if (style != .none) window.fill(at, nl - at, @intFromEnum(style));
+ }
+ if (nl >= content.len) break;
+ at = nl + 1;
+ }
+ if (piece_start) |ps| if (piece_end >= start_byte)
+ try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path);
+ return styles;
+}
+fn paintDiffPiece(gpa: std.mem.Allocator, window: anytype, bytes: []const u8, at: usize, kinds: []const diff.Kind, path: []const u8) !void {
+ const selected: ?Selected = if (enabled and syntax_started) (forExt(std.fs.path.extension(path)) catch null) else null;
+ if (selected) |sel| {
+ const hash = std.hash.Wyhash.hash(std.hash.Wyhash.hash(0, sel.name), bytes);
+ diff_clock += 1;
+ for (&diff_pieces) |*slot| if (slot.*) |*piece| if (piece.hash == hash and piece.styles.len == bytes.len) {
+ piece.used = diff_clock;
+ return window.put(at, piece.styles);
+ };
+ const colored = try syntax_allocator.alloc(u8, bytes.len);
+ errdefer syntax_allocator.free(colored);
+ try paintHunk(gpa, colored, bytes, kinds, sel);
+ window.put(at, colored);
+ var oldest: usize = 0;
+ for (&diff_pieces, 0..) |*slot, i| {
+ const piece = slot.* orelse {
+ oldest = i;
+ break;
+ };
+ if (diff_pieces[oldest]) |o| if (piece.used < o.used) {
+ oldest = i;
+ };
+ }
+ if (diff_pieces[oldest]) |old| syntax_allocator.free(old.styles);
+ diff_pieces[oldest] = .{ .hash = hash, .styles = colored, .used = diff_clock };
+ return;
+ }
+ // No language: each line in one colour, as diffs were always shown.
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
var offset: usize = 0;
- var lines = std.mem.splitScalar(u8, source, '\n');
- while (lines.next()) |line| {
- const syn = diffLineSyn(line);
- if (syn != .none) @memset(styles[offset .. offset + line.len], @intFromEnum(syn));
+ for (kinds) |kind| {
+ const line = lines.next() orelse break;
+ const style: u8 = switch (kind) {
+ .added => @intFromEnum(Syn.string) | diff_added,
+ .removed => @intFromEnum(Syn.number) | diff_removed,
+ .no_newline => @intFromEnum(Syn.comment),
+ .hunk => @intFromEnum(Syn.keyword),
+ else => 0,
+ };
+ if (style != 0) window.fill(at + offset, line.len, style);
offset += line.len + 1;
}
- return styles;
}
-fn diffLineSyn(line: []const u8) Syn {
- if (std.mem.startsWith(u8, line, "@@")) return .keyword;
- if (std.mem.startsWith(u8, line, "+++") or
- std.mem.startsWith(u8, line, "---") or
- std.mem.startsWith(u8, line, "diff ") or
- std.mem.startsWith(u8, line, "index ") or
- std.mem.startsWith(u8, line, "\\ No newline")) return .comment;
- if (line.len == 0) return .none;
- if (line[0] == '+') return .string;
- if (line[0] == '-') return .number;
- return .none;
+/// One piece's styles: its old side and its new side each parsed as one
+/// text, and every line's code coloured from its side, behind its
+/// one-character prefix. A `@@` line between its hunks is in neither.
+fn paintHunk(gpa: std.mem.Allocator, out: []u8, bytes: []const u8, kinds: []const diff.Kind, sel: Selected) !void {
+ @memset(out, 0);
+ const old_src = try gpa.alloc(u8, bytes.len);
+ defer gpa.free(old_src);
+ const new_src = try gpa.alloc(u8, bytes.len);
+ defer gpa.free(new_src);
+ var old_len: usize = 0;
+ var new_len: usize = 0;
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
+ for (kinds) |kind| {
+ const line = lines.next() orelse break;
+ const code = line[codeStart(line, kind)..];
+ // A row a terminal wrapped goes on the line above, as it was printed.
+ const joined = continuesRow(line, kind);
+ if (kind == .context or kind == .removed) {
+ if (joined and old_len > 0) old_len -= 1;
+ @memcpy(old_src[old_len..][0..code.len], code);
+ old_len += code.len;
+ old_src[old_len] = '\n';
+ old_len += 1;
+ }
+ if (kind == .context or kind == .added) {
+ if (joined and new_len > 0) new_len -= 1;
+ @memcpy(new_src[new_len..][0..code.len], code);
+ new_len += code.len;
+ new_src[new_len] = '\n';
+ new_len += 1;
+ }
+ }
+ // A side is parsed only when a line takes its colours from it: the old
+ // side for removed lines, the new side for added lines, and context
+ // from the new side, else from the old. A piece that only adds or only
+ // removes is one parse.
+ var removes = false;
+ var adds = false;
+ for (kinds) |kind| {
+ removes = removes or kind == .removed;
+ adds = adds or kind == .added;
+ }
+ const context_new = adds or !removes;
+ const old_colors = try gpa.alloc(u8, old_len);
+ defer gpa.free(old_colors);
+ @memset(old_colors, 0);
+ if (!context_new or removes) paint(old_colors, old_src[0..old_len], sel);
+ const new_colors = try gpa.alloc(u8, new_len);
+ defer gpa.free(new_colors);
+ @memset(new_colors, 0);
+ if (context_new) paint(new_colors, new_src[0..new_len], sel);
+
+ lines = std.mem.splitScalar(u8, bytes, '\n');
+ var offset: usize = 0;
+ var old_at: usize = 0;
+ var new_at: usize = 0;
+ for (kinds) |kind| {
+ const line = lines.next() orelse break;
+ defer offset += line.len + 1;
+ const skip = codeStart(line, kind);
+ const code_len = line.len - skip;
+ const dst = out[offset + skip ..][0..code_len];
+ if (continuesRow(line, kind)) {
+ if ((kind == .context or kind == .removed) and old_at > 0) old_at -= 1;
+ if ((kind == .context or kind == .added) and new_at > 0) new_at -= 1;
+ }
+ switch (kind) {
+ .removed => @memcpy(dst, old_colors[old_at..][0..code_len]),
+ .added => @memcpy(dst, new_colors[new_at..][0..code_len]),
+ .context => @memcpy(dst, if (context_new) new_colors[new_at..][0..code_len] else old_colors[old_at..][0..code_len]),
+ .no_newline => @memset(out[offset..][0..line.len], @intFromEnum(Syn.comment)),
+ .hunk => @memset(out[offset..][0..line.len], @intFromEnum(Syn.keyword)),
+ else => {},
+ }
+ if (kind == .context or kind == .removed) old_at += code_len + 1;
+ if (kind == .context or kind == .added) new_at += code_len + 1;
+ const flag: u8 = switch (kind) {
+ .added => diff_added,
+ .removed => diff_removed,
+ else => 0,
+ };
+ if (flag != 0) for (out[offset..][0..line.len]) |*b| {
+ b.* |= flag;
+ };
+ }
+}
+
+fn continuesRow(line: []const u8, kind: diff.Kind) bool {
+ return line.len > 0 and codeStart(line, kind) == 0 and
+ (kind == .context or kind == .added or kind == .removed);
+}
+
+/// Where a hunk line's code starts: past its prefix, which a blank context
+/// line (its space stripped) and a terminal's wrapped row have none of.
+fn codeStart(line: []const u8, kind: diff.Kind) usize {
+ if (line.len == 0) return 0;
+ return switch (kind) {
+ .context => @intFromBool(line[0] == ' '),
+ .added => @intFromBool(line[0] == '+'),
+ .removed => @intFromBool(line[0] == '-'),
+ else => 0,
+ };
}
test "syntax a results row is coloured by the file its location names" {
@@ -1268,31 +1508,215 @@ test "syntax plain Markdown keeps block styles without starting the inline parse
}
test "syntax highlightDiff colors unified diff lines by prefix" {
- const diff =
+ const diff_text =
"diff --git a/x b/x\n" ++
"--- a/x\n" ++
"+++ b/x\n" ++
- "@@ -1,3 +1,3 @@\n" ++
+ "@@ -1,2 +1,2 @@\n" ++
" context\n" ++
"-old line\n" ++
"+new line\n";
- const styles = try highlightDiff(std.testing.allocator, diff, 0, diff.len);
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "diff --git", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "--- a/x", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "+++ b/x", 0));
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -1,2", 0));
+ try std.testing.expectEqual(Syn.none, diffSynAt(styles, diff_text, " context", 0));
+ // A file in no known language: each line in one colour, and tinted.
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "-old line", 0));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+new line", 0));
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "-old line").?] & diff_removed != 0);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "+new line").? + 4] & diff_added != 0);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, " context").?] & (diff_added | diff_removed) == 0);
+}
- const byteSyn = struct {
- fn at(s: []const u8, src: []const u8, needle: []const u8) Syn {
- const i = std.mem.indexOf(u8, src, needle).?;
- return @enumFromInt(s[i]);
- }
- }.at;
+fn diffSynAt(styles: []const u8, text: []const u8, needle: []const u8, offset: usize) Syn {
+ return @enumFromInt(styles[std.mem.indexOf(u8, text, needle).? + offset] & syn_bits);
+}
- try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "diff --git"));
- try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "--- a/x"));
- try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "+++ b/x"));
- try std.testing.expectEqual(Syn.keyword, byteSyn(styles, diff, "@@ -1,3"));
- try std.testing.expectEqual(Syn.none, byteSyn(styles, diff, " context"));
- try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line"));
- try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line"));
+test "syntax a Zig hunk is coloured as Zig, each side parsed whole" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "diff --git a/src/shape.zig b/src/shape.zig\n" ++
+ "index 1111111..2222222 100644\n" ++
+ "--- a/src/shape.zig\n" ++
+ "+++ b/src/shape.zig\n" ++
+ "@@ -1,6 +1,6 @@\n" ++
+ " const Shape = struct {\n" ++
+ "- side: u8 = 7,\n" ++
+ "+ side: u16 = 42,\n" ++
+ " const note =\n" ++
+ "- \\\\old words defer\n" ++
+ "+ \\\\new words defer\n" ++
+ " ;\n" ++
+ " };\n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "struct", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "7,", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "42", 0));
+ // A multiline string, told apart only by parsing the side it is on:
+ // `defer` inside it is no keyword.
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "old words defer", 10));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "new words defer", 10));
+ // The prefix is no code: it carries the tint alone.
+ const added = std.mem.indexOf(u8, diff_text, "+ side").?;
+ try std.testing.expectEqual(diff_added, styles[added]);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "42").?] & diff_added != 0);
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "7,").?] & diff_removed != 0);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "index 111", 0));
+
+ // A window in the middle is coloured as the whole is.
+ const from = std.mem.indexOf(u8, diff_text, " const note").?;
+ const part = try highlightDiff(std.testing.allocator, diff_text, from, diff_text.len);
+ defer std.testing.allocator.free(part);
+ try std.testing.expectEqualSlices(u8, styles[from..], part);
+}
+
+test "syntax a Python hunk is coloured as Python" {
+ if (!enabled or !full_grammars) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "--- a/tool.py\n" ++
+ "+++ b/tool.py\n" ++
+ "@@ -1,3 +1,4 @@\n" ++
+ " def run(n):\n" ++
+ "- return n\n" ++
+ "+ # doubled now\n" ++
+ "+ return n * 2\n" ++
+ " \n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "def", 0));
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "return n\n", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "# doubled", 2));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2\n", 0));
+}
+
+test "syntax a deleted file's lines are coloured in its language" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "diff --git a/gone.zig b/gone.zig\n" ++
+ "deleted file mode 100644\n" ++
+ "--- a/gone.zig\n" ++
+ "+++ /dev/null\n" ++
+ "@@ -1,2 +0,0 @@\n" ++
+ "-const Gone = enum { a };\n" ++
+ "-// the end\n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "enum", 0));
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "// the end", 3));
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "enum").?] & diff_removed != 0);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "deleted file", 0));
+}
+
+test "syntax a hunk in no known language keeps the line colours" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const diff_text =
+ "--- a/notes.unknownext\n" ++
+ "+++ b/notes.unknownext\n" ++
+ "@@ -1 +1 @@\n" ++
+ "-const struct 1\n" ++
+ "+const struct 2\n";
+ const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "struct 1", 0));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "struct 2", 0));
+ try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+const struct 2", 0));
+}
+
+test "syntax git diff output in a command pane is told and coloured by its rows" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ // A command pane's rows: its command line, then git's output, with one
+ // long added line the terminal wrapped onto a second row.
+ const rows = [_][]const u8{
+ "git diff",
+ "diff --git a/main.zig b/main.zig",
+ "--- a/main.zig",
+ "+++ b/main.zig",
+ "@@ -1,2 +1,2 @@",
+ " pub fn main() void {",
+ "+ const answer: u32 = 42; // the ",
+ "wrapped rest",
+ "- return;",
+ "",
+ };
+ try std.testing.expect(diff.looksLikeDiff(&rows));
+ try std.testing.expect(!diff.looksLikeDiff(rows[5..]));
+ const text = try std.mem.join(std.testing.allocator, "\n", &rows);
+ defer std.testing.allocator.free(text);
+ const styles = try highlightDiffRows(std.testing.allocator, text, 0, text.len, true);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(Syn.none, diffSynAt(styles, text, "git diff", 0));
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, text, "fn main", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, text, "42", 0));
+ // The wrapped row stays an added line, and inside the comment.
+ try std.testing.expect(styles[std.mem.indexOf(u8, text, "wrapped rest").?] & diff_added != 0);
+ try std.testing.expectEqual(Syn.comment, diffSynAt(styles, text, "wrapped rest", 0));
+ try std.testing.expect(styles[std.mem.indexOf(u8, text, "return").?] & diff_removed != 0);
+}
+
+test "syntax a section's small hunks share one parse" {
+ if (!enabled) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const diff_text =
+ "--- a/x.zig\n+++ b/x.zig\n" ++
+ "@@ -1 +1 @@\n-const a = 1;\n+const a = 2;\n" ++
+ "@@ -10 +10 @@\n-const b = 1;\n+const b = 2;\n" ++
+ "@@ -20 +20 @@\n-const c = 1;\n+const c = 2;\n" ++
+ "--- a/y.zig\n+++ b/y.zig\n" ++
+ "@@ -1 +1 @@\n-const d = 1;\n+const d = 2;\n";
+ const styles = try highlightDiff(gpa, diff_text, 0, diff_text.len);
+ defer gpa.free(styles);
+ var cached: usize = 0;
+ for (diff_pieces) |slot| cached += @intFromBool(slot != null);
+ try std.testing.expectEqual(@as(usize, 2), cached); // x.zig's three hunks, and y.zig's
+ try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -10", 0));
+ try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2;\n@@ -20", 0));
+ try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "@@ -10").?] & (diff_added | diff_removed) == 0);
+}
+
+test "syntax a hunk painted once is kept, and a long one is parsed in pieces" {
+ if (!enabled) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ var text: std.Io.Writer.Allocating = .init(gpa);
+ defer text.deinit();
+ const n = diff_piece_cut * 20 + 10;
+ try text.writer.print("--- /dev/null\n+++ b/big.zig\n@@ -0,0 +1,{d} @@\n", .{n});
+ for (0..n) |i| try text.writer.print("+const v{d} = {d};\n", .{ i, i });
+ const content = text.written();
+ const at = std.mem.indexOf(u8, content, "+const v1000 ").?;
+ const window = try highlightDiff(gpa, content, at, at + 200);
+ defer gpa.free(window);
+ try std.testing.expectEqual(Syn.number, @as(Syn, @enumFromInt(window["+const v1000 = ".len] & syn_bits)));
+ var cached: usize = 0;
+ for (diff_pieces) |slot| if (slot) |piece| {
+ cached += 1;
+ // One piece of many, not the whole hunk.
+ try std.testing.expect(piece.styles.len < content.len / 4);
+ };
+ try std.testing.expectEqual(@as(usize, 1), cached);
+ const again = try highlightDiff(gpa, content, at, at + 200);
+ defer gpa.free(again);
+ try std.testing.expectEqualSlices(u8, window, again);
+ cached = 0;
+ for (diff_pieces) |slot| cached += @intFromBool(slot != null);
+ try std.testing.expectEqual(@as(usize, 1), cached);
}
test "syntax result fragments preserve source indentation and inline markup" {
@@ -1526,3 +1950,4 @@ test "syntax source analysis preserves declarations and injected colors" {
try std.testing.expectEqual(@as(usize, 0), unsupported.declarations.len);
try std.testing.expectEqual(@as(usize, 0), unsupported.colors.len);
}
+
diff --git a/test/snapshots/diffview.golden b/test/snapshots/diffview.golden
new file mode 100644
index 00000000..2da9f6e3
--- /dev/null
+++ b/test/snapshots/diffview.golden
@@ -0,0 +1,119 @@
+== snap diff-pane grid=100x31 cursor=7,3
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/diffview/cwd/change.diff Save Tty Collapse Del
+| 1 diff --git a/src/shape.zig b/src/shape.zig
+| 2 index 1111111..2222222 100644
+| 3 --- a/src/shape.zig
+| 4 +++ b/src/shape.zig
+| 5 @@ -1,10 +1,12 @@
+| 6 const std = @import("std");
+| 7
+| 8 pub const Shape = union(enum) {
+| 9 circle: f32,
+| 10 - square: u8 = 7,
+| 11 + square: f32,
+| 12 + triangle: struct { base: f32, height: f32 },
+| 13
+| 14 const note =
+| 15 - \\areas
+| 16 + \\areas, defer
+| 17 + \\summed
+| 18 ;
+| 19 };
+| 20
+== style diff-colours grid=100x31
+|0: 0-99 #b2c3b8,#1d2824,
+|1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738,
+|2: 0-1 d,#add38a, 2-2 d,#314738, 3-32 #becfc4,#314738, 33-44 #dfbce5,#314738, 45-99 #becfc4,#314738,
+|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-99 #b9f480,#0d1410,
+|4: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b9f480,#0d1410,
+|5: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b9f480,#0d1410,
+|6: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b9f480,#0d1410,
+|7: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #c9a7ed,#0d1410,
+|8: 0-0 d,#608269, 1-4 d,#0d1410, 5-7 #59675c,#0d1410, 8-19 #e5eee5,#0d1410, 20-26 #c9a7ed,#0d1410, 27-27 #e5eee5,#0d1410, 28-32 #b8d88a,#0d1410, 33-99 #e5eee5,#0d1410,
+|9: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410,
+|10: 0-0 d,#608269, 1-4 d,#0d1410, 5-7 #59675c,#0d1410, 8-25 #e5eee5,#0d1410, 26-30 #c9a7ed,#0d1410, 31-31 #e5eee5,#0d1410, 32-35 #c9a7ed,#0d1410, 36-99 #e5eee5,#0d1410,
+|11: 0-0 d,#608269, 1-4 d,#0d1410, 5-11 #59675c,#0d1410, 12-99 #e5eee5,#0d1410,
+|12: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #f1a39b,#302b25,b 8-11 #f1a39b,#302b25, 12-24 #e5eee5,#302b25, 25-25 #f8c082,#302b25, 26-99 #e5eee5,#302b25,
+|13: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-11 #b6ce94,#263025, 12-99 #e5eee5,#263025,
+|14: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-11 #b6ce94,#263025, 12-21 #e5eee5,#263025, 22-28 #c9a7ed,#263025, 29-99 #e5eee5,#263025,
+|15: 0-0 d,#608269, 1-3 d,#0d1410, 4-99 #59675c,#0d1410,
+|16: 0-0 d,#608269, 1-3 d,#0d1410, 4-11 #59675c,#0d1410, 12-99 #e5eee5,#0d1410,
+|17: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #f1a39b,#302b25,b 8-15 #f1a39b,#302b25, 16-99 #b8d88a,#302b25,
+|18: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-15 #b6ce94,#263025, 16-99 #b8d88a,#263025,
+|19: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-15 #b6ce94,#263025, 16-99 #b8d88a,#263025,
+|20: 0-0 d,#608269, 1-3 d,#0d1410, 4-11 #59675c,#0d1410, 12-99 #e5eee5,#0d1410,
+|21: 0-0 d,#608269, 1-3 d,#0d1410, 4-7 #59675c,#0d1410, 8-99 #e5eee5,#0d1410,
+|22: 0-0 d,#608269, 1-3 d,#0d1410, 4-99 #59675c,#0d1410,
+|23: 0-0 d,#608269, 1-99 d,#0d1410,
+|24: 0-0 d,#608269, 1-99 d,#0d1410,
+|25: 0-0 d,#608269, 1-99 d,#0d1410,
+|26: 0-0 d,#608269, 1-99 d,#0d1410,
+|27: 0-0 d,#608269, 1-99 d,#0d1410,
+|28: 0-0 d,#608269, 1-99 d,#0d1410,
+|29: 0-0 d,#608269, 1-99 d,#0d1410,
+|30: 0-0 d,#608269, 1-99 d,#0d1410,
+== snap command-pane grid=100x31 cursor=7,3
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/diffview/cwd/cmds.txt Save Tty Collapse Del
+| 1 cat change.diff Exec
+| 2
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/diffview/cwd (cat change.diff) exit 0 Kill Save Collapse Del
+| circle: f32,
+| - square: u8 = 7,
+| + square: f32,
+| + triangle: struct { base: f32, height: f32 },
+|
+| const note =
+| - \\areas
+| + \\areas, defer
+| + \\summed
+| ;
+| };
+| exit 0
+== style command-colours grid=100x31
+|0: 0-99 #b2c3b8,#1d2824,
+|1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738,
+|2: 0-1 d,#add38a, 2-2 d,#314738, 3-32 #becfc4,#314738, 33-41 #dfbce5,#314738, 42-99 #becfc4,#314738,
+|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-93 #e5eee5,#0d1410, 94-94 #e5eee5,#1d2824, 95-99 #b2c3b8,#1d2824,
+|4: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410,
+|5: 0-0 d,#608269, 1-99 d,#0d1410,
+|6: 0-0 d,#608269, 1-99 d,#0d1410,
+|7: 0-0 d,#608269, 1-99 d,#0d1410,
+|8: 0-0 d,#608269, 1-99 d,#0d1410,
+|9: 0-0 d,#608269, 1-99 d,#0d1410,
+|10: 0-0 d,#608269, 1-99 d,#0d1410,
+|11: 0-0 d,#608269, 1-99 d,#0d1410,
+|12: 0-0 d,#608269, 1-99 d,#0d1410,
+|13: 0-0 d,#608269, 1-99 d,#0d1410,
+|14: 0-0 d,#608269, 1-99 d,#0d1410,
+|15: 0-0 d,#608269, 1-99 d,#0d1410,
+|16: 0-0 d,#608269, 1-99 d,#0d1410,
+|17: 0-1 d,#496656, 2-2 d,#1d2824, 3-99 #b2c3b8,#1d2824,
+|18: 0-0 d,#152019, 1-6 d,#0d1410, 7-99 #e5eee5,#0d1410,
+|19: 0-0 d,#152019, 1-1 d,#0d1410, 2-2 #f1a39b,#302b25,b 3-6 #f1a39b,#302b25, 7-19 #e5eee5,#302b25, 20-20 #f8c082,#302b25, 21-99 #e5eee5,#302b25,
+|20: 0-0 d,#152019, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-6 #b6ce94,#263025, 7-99 #e5eee5,#263025,
+|21: 0-0 d,#152019, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-6 #b6ce94,#263025, 7-16 #e5eee5,#263025, 17-23 #c9a7ed,#263025, 24-99 #e5eee5,#263025,
+|22: 0-0 d,#152019, 1-99 d,#0d1410,
+|23: 0-0 d,#608269, 1-6 d,#0d1410, 7-99 #e5eee5,#0d1410,
+|24: 0-0 d,#608269, 1-1 d,#0d1410, 2-2 #f1a39b,#302b25,b 3-10 #f1a39b,#302b25, 11-99 #b8d88a,#302b25,
+|25: 0-0 d,#608269, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-10 #b6ce94,#263025, 11-99 #b8d88a,#263025,
+|26: 0-0 d,#608269, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-10 #b6ce94,#263025, 11-99 #b8d88a,#263025,
+|27: 0-0 d,#608269, 1-6 d,#0d1410, 7-99 #e5eee5,#0d1410,
+|28: 0-0 d,#608269, 1-2 d,#0d1410, 3-99 #e5eee5,#0d1410,
+|29: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|30: 0-0 d,#152019, 1-99 d,#0d1410,
diff --git a/test/snapshots/diffview.snap b/test/snapshots/diffview.snap
new file mode 100644
index 00000000..b5900db9
--- /dev/null
+++ b/test/snapshots/diffview.snap
@@ -0,0 +1,27 @@
+# A diff pane, for reviewing a change: each hunk's code coloured as Zig (a
+# multi-line string over three lines, `defer` inside it no keyword), added and
+# removed rows tinted to the pane's edge with their prefix in the tint's hue,
+# headers muted.
+dirmk .git
+dirmk src
+file src/shape.zig const std = @import("std");\n\npub const Shape = union(enum) {\n circle: f32,\n square: f32,\n triangle: struct { base: f32, height: f32 },\n\n const note =\n \\\\areas, defer\n \\\\summed\n ;\n};\n
+file change.diff diff --git a/src/shape.zig b/src/shape.zig\nindex 1111111..2222222 100644\n--- a/src/shape.zig\n+++ b/src/shape.zig\n@@ -1,10 +1,12 @@\n const std = @import("std");\n \n pub const Shape = union(enum) {\n circle: f32,\n- square: u8 = 7,\n+ square: f32,\n+ triangle: struct { base: f32, height: f32 },\n \n const note =\n- \\\\areas\n+ \\\\areas, defer\n+ \\\\summed\n ;\n };\n
+start 31 100 change.diff
+wait 8000 Newcol Joincol
+stable 700 20000
+snap diff-pane
+snapstyle diff-colours
+# The same diff printed by a command (`git diff`, here `cat`) into a command
+# pane: its output is told for a diff and coloured the same, over the colours
+# the program printed.
+file cmds.txt cat change.diff\n
+start 31 100 cmds.txt
+wait 8000 cmds.txt
+stable 700 20000
+press middle 7 4
+drag middle 22 4
+release middle 22 4
+wait 10000 exit 0
+stable 700 15000
+snap command-pane
+snapstyle command-colours