summaryrefslogtreecommitdiff
path: root/src/look.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 15:39:27 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commita898c0cce8377150417e7d8dda8f952ffdf241ab (patch)
tree2197f55f9c58c80c5608b84f5510141ba6771cba /src/look.zig
parentc04df904f2fa686cbe5ce0c078a96260af7b08bd (diff)
downloadpardes-a898c0cce8377150417e7d8dda8f952ffdf241ab.tar.gz
pardes-a898c0cce8377150417e7d8dda8f952ffdf241ab.zip
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 <[email protected]>
Diffstat (limited to 'src/look.zig')
-rw-r--r--src/look.zig395
1 files changed, 389 insertions, 6 deletions
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;"));
+}