summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md4
-rw-r--r--src/diff.zig177
-rw-r--r--src/exec.zig46
-rw-r--r--src/look.zig395
-rw-r--r--src/pardes.zig4
5 files changed, 611 insertions, 15 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 5f53ff8a..d6b4846b 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -7,6 +7,10 @@
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.
+- A right click on a diff's `+++`/`---`/`diff --git` line opens its file,
+ on a `@@` line the new file at the hunk, and on a hunk line's `+`, `-` or
+ space the new file at that line (a removed line: where it was). Paths are
+ the repository's; a file not here says so and opens nothing.
## 0.0.8
diff --git a/src/diff.zig b/src/diff.zig
index 98942469..d129c222 100644
--- a/src/diff.zig
+++ b/src/diff.zig
@@ -1,7 +1,7 @@
//! 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.
+//! highlightDiff) and a look on a diff line (look.zig) both walk 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
@@ -243,6 +243,85 @@ pub fn looksLikeDiff(lines: []const []const u8) bool {
return false;
}
+/// A look on one line of a diff: the file and line it names.
+pub const Target = struct {
+ /// The line's own kind.
+ kind: Kind,
+ /// As written in the diff, `a/`/`b/` kept.
+ path: []const u8,
+ /// From 1; 0 for the file alone (a `---`/`+++` line).
+ line: usize,
+ /// The section deletes the file, and this is its old side.
+ old_side: bool = false,
+};
+
+/// What a look at `lines[row]` opens, or null for a line that is no part
+/// of a file section (a commit message, `index ...`). The whole line is
+/// the look's: a click on a `+++`, `---` or `@@` marker is one on its path
+/// or its numbers.
+///
+/// `diff --git a/x b/y` opens y, and `+++ y` opens y. `--- x` opens x,
+/// unless the `+++` under it names another file (a rename, a new file,
+/// `diff -u old/x new/x`): then that one, the old name being the one
+/// likely gone.
+///
+/// A removed line opens the new file at the line now standing where it
+/// was: the new line after it, or the hunk's last new line when it went
+/// from the end of the file. In a section that deletes its file there is
+/// no new file, so a removed or context line opens the old one at its old
+/// line (it is there while the diff is not applied).
+pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Target {
+ if (row >= lines.len) return null;
+ var w: Walk = .{ .continued_rows = continued_rows };
+ var r = anchorRow(lines, row);
+ var got: Line = .{ .kind = .other };
+ while (r <= row) : (r += 1) got = w.step(lines[r]);
+ const line = std.mem.trimEnd(u8, lines[row], "\r");
+ switch (got.kind) {
+ .other, .meta, .no_newline => {
+ // `diff --git a/x b/y`: the file, as its `+++` would.
+ if (got.kind == .meta and std.mem.startsWith(u8, line, "diff ") and w.git_path.len > 0)
+ return .{ .kind = got.kind, .path = w.git_path, .line = 0 };
+ return null;
+ },
+ .old_path, .new_path => {
+ const own = cutPath(line[4..]);
+ // `---` names the old file, but the new one on the `+++` under
+ // it wins when they differ: a new file (`--- /dev/null`), a
+ // rename, `diff -u old/x new/x`. The old name is the one
+ // likely gone.
+ if (got.kind == .old_path and row + 1 < lines.len and std.mem.startsWith(u8, lines[row + 1], "+++ ")) {
+ const new = cutPath(std.mem.trimEnd(u8, lines[row + 1], "\r")[4..]);
+ if (!isDevNull(new) and (isDevNull(own) or !std.mem.eql(u8, stripSide(new), stripSide(own))))
+ return .{ .kind = got.kind, .path = new, .line = 0 };
+ }
+ if (!isDevNull(own)) return .{ .kind = got.kind, .path = own, .line = 0, .old_side = got.kind == .old_path and w.deleted() };
+ if (w.path().len == 0) return null;
+ return .{ .kind = got.kind, .path = w.path(), .line = 0, .old_side = w.deleted() };
+ },
+ .hunk => {
+ if (w.path().len == 0) return null;
+ if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true };
+ return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.new) };
+ },
+ .context, .added, .removed => {
+ if (w.path().len == 0) return null;
+ if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true };
+ var at = got.new;
+ // Gone from the end of the file: nothing stands after it.
+ if (got.kind == .removed and w.new_count > 0 and at >= w.new_start + w.new_count)
+ at = w.new_start + w.new_count - 1;
+ return .{ .kind = got.kind, .path = w.path(), .line = @max(1, at) };
+ },
+ }
+}
+
+/// `a/x` and `b/x` as git writes them: the name in the repository.
+pub fn stripSide(path: []const u8) []const u8 {
+ if (path.len > 2 and (path[0] == 'a' or path[0] == 'b') and path[1] == '/') return path[2..];
+ return path;
+}
+
// ---- tests ----
const testing = std.testing;
@@ -295,6 +374,52 @@ test "diff a walk follows the hunk counts, so a removed `--` line is no header"
}, &kinds);
}
+test "diff a look on each kind of line names the new file and its line" {
+ const lines = splitLines(git_diff);
+ const expect = struct {
+ fn at(ls: []const []const u8, row: usize, path: []const u8, line: usize) !void {
+ const t = targetAt(ls, row, false) orelse return error.NoTarget;
+ try testing.expectEqualStrings(path, t.path);
+ try testing.expectEqual(line, t.line);
+ }
+ };
+ try expect.at(&lines, 0, "b/src/a.zig", 0); // diff --git
+ try testing.expect(targetAt(&lines, 1, false) == null); // index
+ try expect.at(&lines, 2, "a/src/a.zig", 0); // ---
+ try expect.at(&lines, 3, "b/src/a.zig", 0); // +++
+ try expect.at(&lines, 4, "b/src/a.zig", 10); // @@
+ try expect.at(&lines, 5, "b/src/a.zig", 10); // context
+ try expect.at(&lines, 6, "b/src/a.zig", 11); // removed: the line now there
+ try expect.at(&lines, 7, "b/src/a.zig", 11); // added
+ try expect.at(&lines, 8, "b/src/a.zig", 12);
+ try expect.at(&lines, 9, "b/src/a.zig", 13);
+ // `--- x` is a removed line, gone from past the hunk's new lines.
+ try expect.at(&lines, 10, "b/src/a.zig", 13);
+ try expect.at(&lines, 11, "b/src/a.zig", 41);
+ try expect.at(&lines, 12, "b/src/a.zig", 41);
+ try expect.at(&lines, 13, "b/src/a.zig", 42);
+ try expect.at(&lines, 14, "b/src/a.zig", 42);
+ // A removed line of a deleted file: its old line in the old file.
+ const gone = targetAt(&lines, 21, false).?;
+ try testing.expectEqualStrings("a/old.py", gone.path);
+ try testing.expectEqual(@as(usize, 2), gone.line);
+ try testing.expect(gone.old_side);
+ try testing.expect(targetAt(&lines, 1, false) == null);
+}
+
+test "diff a removed line at the end of a file opens the hunk's last new line" {
+ const lines = splitLines(
+ \\--- a/f.c
+ \\+++ b/f.c
+ \\@@ -1,3 +1,2 @@
+ \\ one
+ \\ two
+ \\-three
+ \\
+ );
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, false).?.line);
+}
+
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" ++
@@ -303,10 +428,45 @@ test "diff plain diff -u output: no a/ b/, timestamps after the names" {
"+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);
+ const t = targetAt(&lines, 5, false).?;
+ try testing.expectEqualStrings("new/x.zig", t.path);
+ try testing.expectEqual(@as(usize, 4), t.line);
+ // `--- old/x.zig` with `+++ new/x.zig` under it: the new file.
+ try testing.expectEqualStrings("new/x.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("new/x.zig", stripSide("new/x.zig"));
+ try testing.expectEqualStrings("x.zig", stripSide("b/x.zig"));
+}
+
+test "diff a --- line opens the new name when the +++ under it names another" {
+ const lines = splitLines(
+ \\diff --git a/old name.zig b/new name.zig
+ \\similarity index 90%
+ \\rename from old name.zig
+ \\rename to new name.zig
+ \\--- a/old name.zig
+ \\+++ b/new name.zig
+ \\@@ -1 +1 @@
+ \\-a
+ \\+b
+ \\
+ );
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 4, false).?.path);
+ try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 5, false).?.path);
+ try testing.expect(targetAt(&lines, 2, false) == null);
+}
+
+test "diff a new file's --- /dev/null opens the file it makes" {
+ const lines = splitLines(
+ \\--- /dev/null
+ \\+++ b/made.zig
+ \\@@ -0,0 +1 @@
+ \\+const x = 1;
+ \\
+ );
+ try testing.expectEqualStrings("b/made.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqual(@as(usize, 1), targetAt(&lines, 2, false).?.line);
+ try testing.expectEqual(@as(usize, 1), targetAt(&lines, 3, false).?.line);
}
test "diff output is told from other text by its first rows" {
@@ -330,8 +490,9 @@ test "diff hunk headers: counts left out are one, a broken one is none" {
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;" };
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, true).?.line);
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);
+ var last: Kind = .other;
+ for (lines[0..5]) |line| last = w.step(line).kind;
+ try testing.expectEqual(Kind.added, last);
}
diff --git a/src/exec.zig b/src/exec.zig
index 5e223f04..5a421f19 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -169,10 +169,54 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
result.text = edit.currentSelText(p, pane);
return result;
}
+ // In a diff, a file or hunk header, and a hunk line pointed at on its
+ // `+`/`-`/` ` prefix, are looked at whole: the place the line names
+ // (look.diffLook). A hunk line's code is words, looked at as ever:
+ // the code's, the prefix never one of them.
+ var word_from: usize = 0;
+ if (clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: {
+ const lines = look.diffLines(p, pane) orelse break :diff;
+ const at: usize = @intCast(row);
+ if (at >= lines.len) break :diff;
+ const line = lines[at];
+ const target = @import("diff.zig").targetAt(lines, at, pane.file == null) orelse break :diff;
+ const on_prefix = col == 0 and line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null;
+ switch (target.kind) {
+ .context, .added, .removed => if (!on_prefix) {
+ word_from = @intFromBool(line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null);
+ break :diff;
+ },
+ else => {},
+ }
+ result.text = line;
+ p.diff_look = .{ .serial = pane.serial, .row = at };
+ const hi = std.math.cast(i32, line.len) orelse return result;
+ if (hi == 0) return result;
+ var expanded = clicked;
+ if (pane.file != null) {
+ result.file_word = .{ .row = row, .lo = 0, .hi = hi };
+ const first = pane.wrapRow(row, 0);
+ const last = pane.wrapRow(row, hi - 1);
+ if (first.row < 0 or last.row < 0) return result;
+ expanded.r0 = first.row + @as(i32, pane.tag_rows);
+ expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, 0);
+ expanded.r1 = last.row + @as(i32, pane.tag_rows);
+ expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1);
+ } else {
+ const shown = if (raw) raw_line else line;
+ expanded.c0 = 0;
+ expanded.c1 = @as(i32, @intCast(panes.File.rawDisplayCol(shown, shown.len))) - 1;
+ }
+ result.preview = expanded;
+ result.expanded = expanded;
+ return result;
+ }
if (pane.file != null and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
const line = panes.File.sourceLine(pane, row);
const source_col: usize = @intCast(@max(0, col));
- const b = look.expandedWord(line, source_col) orelse return result;
+ if (source_col < word_from or word_from > line.len) return result;
+ const word = look.expandedWord(line[word_from..], source_col - word_from) orelse return result;
+ const b: look.ExpandedWord = .{ .lo = word.lo + word_from, .hi = word.hi + word_from };
const lo = std.math.cast(i32, b.lo) orelse return result;
const hi = std.math.cast(i32, b.hi) orelse return result;
result.text = line[b.lo..b.hi];
diff --git a/src/look.zig b/src/look.zig
index 357a9c63..76b58fbe 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -6,6 +6,7 @@ const exec = @import("exec.zig");
const config = @import("config.zig");
const pdf_enabled = @import("pardes_config").mupdf;
const fs = @import("fs.zig");
+const diff = @import("diff.zig");
const platform_has_fs = fs.platform_has_fs;
const layout = @import("layout.zig");
const animation = @import("animation.zig");
@@ -457,7 +458,7 @@ pub fn invalidateLookHover(p: *Pardes, id: usize) void {
cancelLookHover(p);
}
-const ExpandedWord = struct { lo: usize, hi: usize };
+pub const ExpandedWord = struct { lo: usize, hi: usize };
pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
if (col >= line.len) return null;
@@ -1499,6 +1500,101 @@ fn isRecentList(pane: *const Pane) bool {
return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist });
}
+// ---- a look on a diff line ----
+
+/// The lines of a pane showing a diff, a .diff or .patch file or a
+/// finished command pane whose output is one (File.DiffOutput); null for
+/// any other pane. Good until the next step.
+pub fn diffLines(p: *Pardes, pane: *Pane) ?[]const []const u8 {
+ if (pane.file) |*f| {
+ if (pane.colorAlgo() != .diff) return null;
+ return panes.File.cursorLines(p.scratch.allocator(), pane, f) catch null;
+ }
+ if (!pane.isTerminal()) return null;
+ return p.diff_output.lines(p, pane);
+}
+
+const DiffLook = union(enum) { none, done, at: []const u8 };
+
+/// A look in a diff pane at one of its lines opens the file and line that
+/// line names (diff.targetAt): the new file at a `@@` line's first new
+/// line, at a context or added line's own line, and at a removed line's
+/// place, the new line now standing where it was. `---`/`+++` open their
+/// file. The path is the repository's (walking up from the diff's
+/// directory to a `.git` or `.jj`), `a/` and `b/` dropped, else the
+/// directory's own. A file that is not there opens nothing and says so.
+///
+/// Which line: the one a click just gave (`clicked`: exec.pointerOperand
+/// expanded to it and kept its row), else, for a look written over 9P, a
+/// line the text is the whole of, the cursor's first, then the first after
+/// it. A word on a hunk line is looked at as ever.
+fn diffLook(p: *Pardes, id: usize, pane: *Pane, operand: []const u8, clicked: bool, buf: []u8) DiffLook {
+ const text = std.mem.trimEnd(u8, operand, "\r\n");
+ if (std.mem.trim(u8, text, " \t").len == 0) return .none;
+ const lines = diffLines(p, pane) orelse return .none;
+ const row: usize = row: {
+ if (p.diff_look) |at| if (at.serial == pane.serial and at.row < lines.len and
+ std.mem.eql(u8, std.mem.trimEnd(u8, lines[at.row], "\r"), text))
+ {
+ p.diff_look = null;
+ break :row at.row;
+ };
+ if (clicked) return .none;
+ const cur: usize = @intCast(@max(0, pane.body.cur_row));
+ for (0..lines.len) |k| {
+ const i = (cur + k) % lines.len;
+ if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) break :row i;
+ }
+ return .none;
+ };
+ const target = diff.targetAt(lines, row, pane.file == null) orelse return .none;
+ const base = if (pane.file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice();
+ var realbuf: [4096]u8 = undefined;
+ const found = diffFile(base, target.path, &realbuf) orelse {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ if (target.old_side)
+ missed(p, id, "{s}: deleted by this diff", .{missText(target.path, &cut)})
+ else
+ missed(p, id, "{s}: no such file here", .{missText(target.path, &cut)});
+ return .done;
+ };
+ const place = if (target.line > 0)
+ std.fmt.bufPrint(buf, "{s}:{d}", .{ found, target.line }) catch return .none
+ else
+ std.fmt.bufPrint(buf, "{s}", .{found}) catch return .none;
+ return .{ .at = place };
+}
+
+/// The file a diff names, from `base` (the diff's directory): tried in the
+/// repository `base` is in, then in `base`, as git writes it (`b/x`, the
+/// repository's `x`) and as written.
+fn diffFile(base: []const u8, path: []const u8, out: *[4096]u8) ?[]const u8 {
+ if (comptime !platform_has_fs) return null;
+ if (path.len == 0) return null;
+ if (path[0] == '/') {
+ const found = fs.resolveOs(path, out) orelse return null;
+ return if (found.dir) null else found.path;
+ }
+ var root_buf: [4096]u8 = undefined;
+ var dir: ?[]const u8 = base;
+ const root: ?[]const u8 = while (dir) |d| : (dir = std.fs.path.dirname(d)) {
+ var probe: [4096]u8 = undefined;
+ const git = std.fmt.bufPrint(&probe, "{s}/.git", .{d}) catch break null;
+ if (fs.resolveOs(git, &root_buf) != null) break d;
+ const jj = std.fmt.bufPrint(&probe, "{s}/.jj", .{d}) catch break null;
+ if (fs.resolveOs(jj, &root_buf) != null) break d;
+ } else null;
+ for ([_]?[]const u8{ root, base }) |maybe| {
+ const at = maybe orelse continue;
+ for ([_][]const u8{ diff.stripSide(path), path }) |name| {
+ var joined: [4096]u8 = undefined;
+ const full = std.fmt.bufPrint(&joined, "{s}/{s}", .{ at, name }) catch continue;
+ if (fs.resolveOs(full, out)) |found| if (!found.dir) return found.path;
+ }
+ }
+ return null;
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
const pane = p.panes[id] orelse return;
// `~/x`, `~/x:12`, `~/x:/re/`: home, before anything reads the text.
@@ -1515,12 +1611,27 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
p.noteHaptic(.look);
noteLookSource(p, id);
const command = std.mem.trim(u8, operand, " \t\r\n");
- if (config.commandWord(command)) |cmd| {
- if (exec.execute(p, id, cmd)) |dst| p.active = dst;
- return;
- }
+ // A click on a diff line (a context line ` Del` included) is a look at
+ // the place it names, never the word run.
+ var diff_buf: [4096 + 32]u8 = undefined;
+ const looked: []const u8 = looked: {
+ switch (diffLook(p, id, pane, operand_raw, true, &diff_buf)) {
+ .none => {},
+ .done => return,
+ .at => |place| break :looked place,
+ }
+ if (config.commandWord(command)) |cmd| {
+ if (exec.execute(p, id, cmd)) |dst| p.active = dst;
+ return;
+ }
+ switch (diffLook(p, id, pane, operand_raw, false, &diff_buf)) {
+ .none => break :looked operand,
+ .done => return,
+ .at => |place| break :looked place,
+ }
+ };
// The operand may borrow selected terminal/PDF text released below.
- const txt = p.scratch.allocator().dupe(u8, operand) catch return;
+ const txt = p.scratch.allocator().dupe(u8, looked) catch return;
const trimmed = std.mem.trim(u8, txt, " \t\r\n");
// acme's `file:addr` and `:addr` (look.c:450-543): an address after
// the colon, evaluated in that file from its dot (openfile, look.c:675).
@@ -1909,3 +2020,275 @@ test "a pointer resting where a look-hover found nothing does not wake the core
try std.testing.expect(frames < 20);
try std.testing.expect(p.look_hover_wait == null and p.look_hover_preview == null);
}
+
+// A diff pane on `diff_text`, saved as change.diff in a fresh directory
+// that holds `files` (path, content) and, if `repo`, a `.git`.
+const DiffFixture = struct {
+ tmp: std.testing.TmpDir,
+ dir: []const u8,
+ dir_buf: [4096]u8 = undefined,
+
+ fn init(f: *DiffFixture, repo: bool, files: []const [2][]const u8, diff_text: []const u8) !void {
+ const io = std.testing.io;
+ f.tmp = std.testing.tmpDir(.{});
+ errdefer f.tmp.cleanup();
+ if (repo) try f.tmp.dir.createDir(io, ".git", .default_dir);
+ for (files) |file| {
+ if (std.fs.path.dirname(file[0])) |sub| try f.tmp.dir.createDirPath(io, sub);
+ try f.tmp.dir.writeFile(io, .{ .sub_path = file[0], .data = file[1] });
+ }
+ try f.tmp.dir.writeFile(io, .{ .sub_path = "change.diff", .data = diff_text });
+ const len = try f.tmp.dir.realPathFile(io, "change.diff", &f.dir_buf);
+ f.dir = std.fs.path.dirname(f.dir_buf[0..len]).?;
+ }
+
+ fn open(f: *DiffFixture, p: *Pardes, diff_text: []const u8) !*Pane {
+ const pane = try p.setTestFile(diff_text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try std.fs.path.join(p.gpa, &.{ f.dir, "change.diff" });
+ return pane;
+ }
+
+ /// A right click's look at `row` of the diff pane (slot 0).
+ fn click(p: *Pardes, diff_pane: *Pane, row: usize) void {
+ p.active = 0;
+ const lines = diffLines(p, diff_pane).?;
+ p.diff_look = .{ .serial = diff_pane.serial, .row = row };
+ p.look_missed = false;
+ lookAt(p, 0, lines[row]);
+ }
+};
+
+fn expectAt(p: *Pardes, suffix: []const u8, line: usize) !void {
+ const pane = p.panes[p.active].?;
+ const f = pane.file orelse return error.NoFileOpened;
+ if (!std.mem.endsWith(u8, f.path, suffix)) {
+ std.debug.print("opened {s}, not {s}\n", .{ f.path, suffix });
+ return error.WrongFile;
+ }
+ try std.testing.expectEqual(@as(i32, @intCast(line)) - 1, pane.body.cur_row);
+}
+
+test "Look on each kind of diff line opens the new file at the line it names" {
+ if (!platform_has_fs) return;
+ const numbered = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\n" ++
+ "line 10\nline 11\nline 12\nline 13\nline 14\nline 15\n";
+ const diff_text =
+ \\diff --git a/src/a.zig b/src/a.zig
+ \\index 1111111..2222222 100644
+ \\--- a/src/a.zig
+ \\+++ b/src/a.zig
+ \\@@ -10,3 +10,4 @@ fn x() void {
+ \\ line 10
+ \\-Del
+ \\+line 11
+ \\+line 12
+ \\ line 13
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/a.zig", numbered }}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+
+ DiffFixture.click(p, pane, 4); // @@: the hunk's first new line
+ try expectAt(p, "/src/a.zig", 10);
+ DiffFixture.click(p, pane, 5); // a context line
+ try expectAt(p, "/src/a.zig", 10);
+ // A removed line (whose text is a builtin: no Del runs) opens the new
+ // line now standing where it was.
+ DiffFixture.click(p, pane, 6);
+ try expectAt(p, "/src/a.zig", 11);
+ try std.testing.expect(p.panes[0] != null);
+ DiffFixture.click(p, pane, 7); // added lines
+ try expectAt(p, "/src/a.zig", 11);
+ DiffFixture.click(p, pane, 8);
+ try expectAt(p, "/src/a.zig", 12);
+ DiffFixture.click(p, pane, 9);
+ try expectAt(p, "/src/a.zig", 13);
+ DiffFixture.click(p, pane, 3); // +++ b/path: the file, a/ b/ dropped
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/src/a.zig"));
+ DiffFixture.click(p, pane, 2); // --- a/path
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/src/a.zig"));
+ try std.testing.expect(!p.look_missed);
+
+ // No click: a 9P look writes a line, found after the cursor, or the
+ // text is on the cursor's line.
+ p.active = 0;
+ pane.body.cur_row = 0;
+ p.diff_look = null;
+ lookAt(p, 0, "+line 12");
+ try expectAt(p, "/src/a.zig", 12);
+ // A word on a hunk line is a word, looked for in the diff as ever.
+ p.active = 0;
+ pane.body.cur_row = 9;
+ lookAt(p, 0, "line");
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ // A line that is no part of a hunk is looked at as ever.
+ p.active = 0;
+ pane.body.cur_row = 1;
+ lookAt(p, 0, "1111111");
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+}
+
+test "Look on a diff line of a file that is not here says so and opens nothing" {
+ if (!platform_has_fs) return;
+ const diff_text =
+ \\diff --git a/src/elsewhere.zig b/src/elsewhere.zig
+ \\--- a/src/elsewhere.zig
+ \\+++ b/src/elsewhere.zig
+ \\@@ -1 +1 @@
+ \\-const a = 1;
+ \\+const a = 2;
+ \\diff --git a/old.zig b/old.zig
+ \\deleted file mode 100644
+ \\--- a/old.zig
+ \\+++ /dev/null
+ \\@@ -1 +0,0 @@
+ \\-const gone = 1;
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ var before: usize = 0;
+ for (p.panes) |slot| before += @intFromBool(slot != null);
+ DiffFixture.click(p, pane, 5);
+ try std.testing.expect(p.look_missed);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try std.testing.expectEqual(before, after);
+ try std.testing.expectEqualStrings("Look: b/src/elsewhere.zig: no such file here", pane.msg[0..pane.msg_len]);
+ DiffFixture.click(p, pane, 11);
+ try std.testing.expect(p.look_missed);
+ try std.testing.expectEqualStrings("Look: a/old.zig: deleted by this diff", pane.msg[0..pane.msg_len]);
+}
+
+test "Look on plain diff -u output: names as written, timestamps cut, next to the diff" {
+ if (!platform_has_fs) return;
+ const diff_text = "--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++
+ "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++
+ "@@ -1,2 +1,3 @@\n" ++
+ " const a = 1;\n" ++
+ "+const b = 2;\n" ++
+ " const c = 3;\n";
+ var fx: DiffFixture = undefined;
+ try fx.init(false, &.{.{ "new/x.zig", "const a = 1;\nconst b = 2;\nconst c = 3;\n" }}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ DiffFixture.click(p, pane, 4);
+ try expectAt(p, "/new/x.zig", 2);
+ DiffFixture.click(p, pane, 5);
+ try expectAt(p, "/new/x.zig", 3);
+}
+
+test "Look on a diff: headers and a hunk line's prefix are the whole line, hover and click alike" {
+ if (!platform_has_fs) return;
+ const numbered = "one\ntwo\nthree\nfour\nfive\nsix\n";
+ const diff_text =
+ \\diff --git a/src/old.zig b/src/new.zig
+ \\similarity index 80%
+ \\rename from src/old.zig
+ \\rename to src/new.zig
+ \\--- a/src/old.zig
+ \\+++ b/src/new.zig
+ \\@@ -2,2 +2,2 @@ fn x() void {
+ \\ two
+ \\-drei
+ \\+three
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/new.zig", numbered }}, diff_text);
+ defer fx.tmp.cleanup();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ pane.tag_expanded = false;
+ // `word`: what the pointer there expands to when it is not the whole
+ // line; `line` 0: that word is looked at in the diff, nothing opened.
+ const Point = struct { row: u16, col: u16, line: usize, word: ?[]const u8 = null };
+ const points = [_]Point{
+ .{ .row = 0, .col = 0, .line = 1 }, // `diff`: the new side
+ .{ .row = 0, .col = 35, .line = 1 }, // on b/src/new.zig
+ .{ .row = 4, .col = 1, .line = 1 }, // `---`: renamed, so the new name
+ .{ .row = 4, .col = 5, .line = 1 }, // the `a/` of the old name
+ .{ .row = 5, .col = 0, .line = 1 }, // `+++`
+ .{ .row = 5, .col = 4, .line = 1 }, // its `b/`
+ .{ .row = 5, .col = 10, .line = 1 }, // its path
+ .{ .row = 6, .col = 0, .line = 2 }, // the first `@@`
+ .{ .row = 6, .col = 9, .line = 2 }, // `+2,2`
+ .{ .row = 6, .col = 13, .line = 2 }, // the second `@@`
+ .{ .row = 6, .col = 20, .line = 2 }, // the function after it
+ .{ .row = 7, .col = 0, .line = 2 }, // a context line's ` `
+ .{ .row = 8, .col = 0, .line = 3 }, // a removed line's `-`: where it was
+ .{ .row = 9, .col = 0, .line = 3 }, // an added line's `+`
+ .{ .row = 9, .col = 3, .line = 0, .word = "three" }, // its code: a word
+ .{ .row = 7, .col = 2, .line = 0, .word = "two" },
+ };
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ for (points) |at| {
+ // Back to the diff, scrolled to its top again.
+ p.active = 0;
+ pane.file.?.scroll = 0;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + at.col;
+ const y = p.bodyTop(pane, rect) + at.row;
+ // The hover's span is the click's operand.
+ const pointed = edit.pointerTextSelection(p, 0, x, y, null, null) orelse return error.NothingPointed;
+ const operand = exec.pointerOperand(p, pane, pointed.sel);
+ const lines = diffLines(p, pane).?; // scratch: good until the next update
+ const span = operand.file_word orelse return error.NoHoverSpan;
+ try std.testing.expectEqual(@as(i32, at.row), span.row);
+ if (at.word) |word| {
+ try std.testing.expectEqualStrings(word, operand.text.?);
+ try std.testing.expectEqualStrings(word, lines[at.row][@intCast(span.lo)..@intCast(span.hi)]);
+ } else {
+ try std.testing.expectEqualStrings(lines[at.row], operand.text.?);
+ try std.testing.expectEqual(@as(i32, 0), span.lo);
+ try std.testing.expectEqual(@as(i32, @intCast(lines[at.row].len)), span.hi);
+ }
+ p.look_missed = false;
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x, .row = y } });
+ if (at.line == 0) {
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ continue;
+ }
+ try std.testing.expect(!p.look_missed);
+ if (p.active == 0) {
+ std.debug.print("a look at row {d} col {d} stayed in the diff\n", .{ at.row, at.col });
+ return error.LookStayed;
+ }
+ try expectAt(p, "/src/new.zig", at.line);
+ }
+}
+
+test "Look outside a diff pane expands and looks as ever" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("+++ b/x.zig\n@@ -1 +1 @@\n+const a = 1;\n");
+ pane.tag_expanded = false;
+ try std.testing.expect(diffLines(p, pane) == null);
+ p.sync();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + 0;
+ const pointed = edit.pointerTextSelection(p, 0, x, p.bodyTop(pane, rect) + 2, null, null).?;
+ const operand = exec.pointerOperand(p, pane, pointed.sel);
+ try std.testing.expect(operand.text == null or !std.mem.eql(u8, operand.text.?, "+const a = 1;"));
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 9fe6a1fb..04854a37 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4361,6 +4361,10 @@ pub const Pardes = struct {
chord_hold: bool = false,
/// The look just run found nothing (look.zig missed).
look_missed: bool = false,
+ /// The diff line a right click just expanded to, by row: a look at its
+ /// text goes to the place that row names, not the first equal line
+ /// (look.zig diffLook).
+ diff_look: ?struct { serial: u32, row: usize } = null,
look_hover_wait: ?look.LookHoverWait = null,
/// The place a look-hover last found nothing to open: the pointer
/// resting there does not ask again every frame (look.noteLookHover).