From a898c0cce8377150417e7d8dda8f952ffdf241ab Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 15:39:27 -0300 Subject: A right click on a diff line opens the place it names: its file, the hunk's new line, the line itself In a diff pane (a .diff/.patch file, or a command pane's diff output), the look's own expansion, which its hover shows, takes a whole line for a `diff --git`, `---`, `+++` or `@@` line wherever the pointer is on it, markers included, and for a hunk line when the pointer is on its `+`, `-` or space. Then the look opens the file (a/ b/ dropped, timestamps cut) at the line from the hunk header and the lines above it: `@@` the hunk's first new line, context and added lines their own, a removed line the new line now where it was. `diff --git` and `+++` open the new file; `---` the old one, unless the `+++` under it names another. On a hunk line's code the expansion is its words, the prefix never one of them, and looks as ever. Other panes are untouched. Paths resolve in the repository (walking up to `.git` or `.jj` from the diff's directory, or a command pane's), then that directory. A file not here opens nothing and says `Look: b/x: no such file here`, or `deleted by this diff`. A 9P look writing a whole line of the diff does the same. Co-Authored-By: Claude Opus 5.5 --- src/diff.zig | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 169 insertions(+), 8 deletions(-) (limited to 'src/diff.zig') 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); } -- cgit v1.3