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 --- docs/fs.md | 2 + docs/tags.md | 18 ++ src/CHANGELOG.md | 4 + src/diff.zig | 177 +++++++++++++++++- src/exec.zig | 46 ++++- src/look.zig | 395 ++++++++++++++++++++++++++++++++++++++++- src/pardes.zig | 4 + test/snapshots/diffview.golden | 29 +++ test/snapshots/diffview.snap | 14 +- 9 files changed, 672 insertions(+), 17 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index 146ff17b..864bfd12 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -208,6 +208,8 @@ A line written to `look` is a right click on it: too. - a directory types `ls` into a terminal idle there, else opens one there. - a URL opens in the browser. +- in a diff pane, a whole line of the diff opens the place it names, as a + right click on its first column does ([tags.md](tags.md#reviewing-diffs)). - a plain word selects its next place after dot, wrapping (`LookWord list` on the root ctl lists every place in a `+Search` pane instead). In a terminal a word is always listed, rows spelled `@p3:12:5-9`. diff --git a/docs/tags.md b/docs/tags.md index 4d4e9178..993e8c0d 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -78,6 +78,24 @@ 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. +Then right-click to jump; the look's hover shows what the click takes. On +a `diff --git`, `---` or `+++` line, anywhere on it, markers included, that +is the whole line, and it opens its file: `diff --git a/x b/y` and `+++` +the new one, `---` the old one unless the `+++` under it names another (a +rename, a new file, `diff -u old/x new/x`), since the old name is the one +likely gone. On a `@@ -a,b +c,d @@` line it is the whole line again, and +opens the new file at line c. On a hunk line, the `+`, `-` or space in its +first column takes the whole line and opens the new file at that line; for +a removed line, at the new line now standing where it was (the one after +it, or the hunk's last when it went from the end of the file). The code +after it is words, looked at as ever. Paths are the repository's: pardes +walks up from the diff's directory (a command pane's own) to a `.git` or +`.jj` and drops git's `a/`/`b/`, else takes them from that directory as +written; `diff -u`'s timestamps are cut. A file that is not here (a branch +not checked out) opens nothing and says so: `Look: b/x.zig: no such file +here`, or `deleted by this diff` for a deleted one. A 9P `look` of a whole +line of the diff does the same. + ## 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 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). diff --git a/test/snapshots/diffview.golden b/test/snapshots/diffview.golden index 2da9f6e3..9e171fd8 100644 --- a/test/snapshots/diffview.golden +++ b/test/snapshots/diffview.golden @@ -54,6 +54,21 @@ |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 look-added-line grid=100x31 cursor=55,22 +|16: /tmp/pardes-snap/diffview/cwd/src/shape.zig Save Tty Collapse Del TreeContext +|17: 1 const std = @import("std"); +|18: 2 +|19: 3 pub const Shape = union(enum) { +|20: 4 circle: f32, +|21: 5 square: f32, +|22: 6 triangle: struct { base: f32, height: f32 }, +|23: 7 +|24: 8 const note = +|25: 9 \\areas, defer +|26: 10 \\summed +|27: 11 ; +|28: 12 }; +|29: 13 == 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 @@ -117,3 +132,17 @@ |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, +== snap command-look grid=100x31 cursor=23,10 +|3: 1 cat change.diff +|5: /tmp/pardes-snap/diffview/cwd/src/shape.zig Save Tty Collapse Del TreeContext +|6: 1 const std = @import("std"); +|7: 2 +|8: 3 pub const Shape = union(enum) { +|9: 4 circle: f32, +|10: 5 square: f32, +|11: 6 triangle: struct { base: f32, height: f32 }, +|12: 7 +|13: 8 const note = +|14: 9 \\areas, defer +|15: 10 \\summed +|16: 11 ; diff --git a/test/snapshots/diffview.snap b/test/snapshots/diffview.snap index b5900db9..23d060b7 100644 --- a/test/snapshots/diffview.snap +++ b/test/snapshots/diffview.snap @@ -1,7 +1,9 @@ # 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. +# headers muted. Then a right click on an added line's `+` opens the new file +# at that line: the path is the repository's (a `.git` above the diff), b/ +# dropped. 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 @@ -11,9 +13,13 @@ wait 8000 Newcol Joincol stable 700 20000 snap diff-pane snapstyle diff-colours +press right 8 15 +release right 8 15 +stable 700 15000 +snap look-added-line # 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. +# the program printed, and a right click on a `+` there opens the file too. file cmds.txt cat change.diff\n start 31 100 cmds.txt wait 8000 cmds.txt @@ -25,3 +31,7 @@ wait 10000 exit 0 stable 700 15000 snap command-pane snapstyle command-colours +press right 3 21 +release right 3 21 +stable 700 15000 +snap command-look -- cgit v1.3