summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 22:58:16 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commitc27222be8ba77a3a6bdda16978f3001510bde055 (patch)
tree46e709eba262af80b516ade483d210400a09455f
parent4f65855f372186406f4e48708ec3d5eb9aceb6d3 (diff)
downloadpardes-c27222be8ba77a3a6bdda16978f3001510bde055.tar.gz
pardes-c27222be8ba77a3a6bdda16978f3001510bde055.zip
One diff-aware expansion for a click, a 9P whole-line look and an empty event write-back; a blank context line resolves to its path:line
The mouse expanded a diff line by its kind (a header or a hunk line's prefix to the line's address, a hunk line's code to words), but an empty look written back to event used the plain word expansion, so ML on a `+` took nothing, and a 9P look of a blank context line (` `) was refused as whitespace. look.diffClick is now the one answer: the mouse, the whole-line look and the event write-back all ask it, and a blank context line is a line like any other. A test asks all three on each kind of line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/exec.zig21
-rw-r--r--src/look.zig98
-rw-r--r--src/ninep/events.zig24
3 files changed, 122 insertions, 21 deletions
diff --git a/src/exec.zig b/src/exec.zig
index 104e3b4d..e15ad12c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -176,21 +176,14 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
// 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];
var address_buf: [4096 + 32]u8 = undefined;
- const address = look.diffAddress(pane, lines, at, &address_buf) orelse break :diff;
- const target = @import("diff.zig").targetAt(lines, at, pane.file == null).?;
- 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 => {},
- }
+ const at: usize = @intCast(row);
+ const click = look.diffClick(p, pane, at, @intCast(@max(0, col)), &address_buf) orelse break :diff;
+ const address = click.address orelse {
+ word_from = click.word_from;
+ break :diff;
+ };
+ const line = (look.diffLines(p, pane) orelse break :diff)[at];
result.text = p.scratch.allocator().dupe(u8, address) catch break :diff;
const hi = std.math.cast(i32, line.len) orelse return result;
if (hi == 0) return result;
diff --git a/src/look.zig b/src/look.zig
index 4d23582b..17ec8a1c 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1567,16 +1567,45 @@ pub fn diffAddress(pane: *const Pane, lines: []const []const u8, row: usize, buf
return target.address(buf);
}
+/// What a click at byte `col` of line `row` of a diff pane takes, the one
+/// answer for every way a click arrives (a mouse click, a 9P look of the
+/// whole line, an empty look or exec written back to `event`): a file or
+/// hunk header anywhere, or a hunk line on its `+`/`-`/` ` prefix, is the
+/// address the line names (diffAddress); a hunk line's code is words, from
+/// `word_from` on, the prefix never one of them. Null when the pane shows
+/// no diff or the line is no part of one.
+pub const DiffClick = struct { address: ?[]const u8 = null, word_from: usize = 0 };
+
+pub fn diffClick(p: *Pardes, pane: *Pane, row: usize, col: usize, buf: []u8) ?DiffClick {
+ const lines = diffLines(p, pane) orelse return null;
+ return diffClickIn(pane, lines, row, col, buf);
+}
+
+fn diffClickIn(pane: *const Pane, lines: []const []const u8, row: usize, col: usize, buf: []u8) ?DiffClick {
+ if (row >= lines.len) return null;
+ const line = lines[row];
+ const address = diffAddress(pane, lines, row, buf) orelse return null;
+ const target = diff.targetAt(lines, row, pane.file == null) orelse return null;
+ const prefixed = line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null;
+ switch (target.kind) {
+ .context, .added, .removed => if (!(col == 0 and prefixed)) return .{ .word_from = @intFromBool(prefixed) },
+ else => {},
+ }
+ return .{ .address = address };
+}
+
/// A look written over 9P of a whole line of a diff pane (the cursor's line
-/// first, then the first after it): its address, as a click there makes.
+/// first, then the first after it): what a click on its first column takes.
+/// A blank context line (` `) is a line too.
fn diffLineAddress(p: *Pardes, pane: *Pane, operand: []const u8, buf: []u8) ?[]const u8 {
const text = std.mem.trimEnd(u8, operand, "\r\n");
- if (std.mem.trim(u8, text, " \t").len == 0) return null;
+ if (text.len == 0) return null;
const lines = diffLines(p, pane) orelse return null;
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)) return diffAddress(pane, lines, i, buf);
+ if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text))
+ return (diffClickIn(pane, lines, i, 0, buf) orelse return null).address;
}
return null;
}
@@ -2230,6 +2259,69 @@ test "Look on a diff: headers and a hunk line's prefix are the whole line, hover
}
}
+test "a click, a 9P whole-line look and an empty event write-back take one expansion on every kind of diff line, a blank context line too" {
+ if (!platform_has_fs) return;
+ const diff_text =
+ \\diff --git a/src/new.zig b/src/new.zig
+ \\--- a/src/new.zig
+ \\+++ b/src/new.zig
+ \\@@ -2,3 +2,3 @@ fn x() void {
+ \\ two
+ \\-drei
+ \\+three
+ \\
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/new.zig", "one\ntwo\nthree\n\nfive\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);
+ pane.tag_expanded = false;
+ const Point = struct { row: u16, col: u16, want: []const u8, whole_line: bool = true };
+ const points = [_]Point{
+ .{ .row = 0, .col = 0, .want = "src/new.zig" }, // diff --git
+ .{ .row = 1, .col = 0, .want = "src/new.zig" }, // ---
+ .{ .row = 2, .col = 0, .want = "src/new.zig" }, // +++
+ .{ .row = 3, .col = 0, .want = "src/new.zig:2" }, // @@
+ .{ .row = 4, .col = 0, .want = "src/new.zig:2" }, // a context line's ` `
+ .{ .row = 5, .col = 0, .want = "src/new.zig:3" }, // `-`
+ .{ .row = 6, .col = 0, .want = "src/new.zig:3" }, // `+`
+ .{ .row = 7, .col = 0, .want = "src/new.zig:4" }, // a blank context line
+ .{ .row = 6, .col = 3, .want = "three", .whole_line = false }, // code: a word
+ };
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ for (points) |at| {
+ p.active = 0;
+ pane.file.?.scroll = 0;
+ pane.body.cur_row = 0;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ // The mouse's.
+ 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;
+ const pointed = edit.pointerTextSelection(p, 0, x, y, null, null) orelse return error.NothingPointed;
+ const clicked = exec.pointerOperand(p, pane, pointed.sel).text orelse return error.NothingClicked;
+ try std.testing.expectEqualStrings(at.want, clicked);
+ // An empty record written back to event, at the same byte.
+ const body = pane.file.?.content;
+ const line_start = modal.lineStartOffset(body, at.row);
+ const written = pardes.ctlfs.events.expandAt(p, pane, body, line_start + at.col, true);
+ try std.testing.expectEqualStrings(at.want, written);
+ // A 9P look of the whole line.
+ if (at.whole_line) {
+ const lines = diffLines(p, pane).?;
+ var buf: [4096 + 32]u8 = undefined;
+ const whole = diffLineAddress(p, pane, lines[at.row], &buf) orelse return error.NoWholeLineAddress;
+ try std.testing.expectEqualStrings(at.want, whole);
+ }
+ }
+}
+
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();
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 9752e287..4d1bbe64 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -685,11 +685,27 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 {
/// Writing a Look or Exec record back performs the action it names.
/// The word a click at `at` expands to: the file name or word run under it
/// on its line (look.expandedWord), else nothing.
-fn expandAt(whole: []const u8, at: usize) []const u8 {
+/// An empty range written back: what a click at `at` takes, as the mouse's
+/// does (exec.pointerOperand). In a diff pane's body that is look.diffClick's
+/// answer, the line's address or its code's words.
+pub fn expandAt(p: *Pardes, pane: *Pane, whole: []const u8, at: usize, in_body: bool) []const u8 {
const start = if (std.mem.lastIndexOfScalar(u8, whole[0..at], '\n')) |nl| nl + 1 else 0;
const end = std.mem.indexOfScalarPos(u8, whole, at, '\n') orelse whole.len;
- const b = look.expandedWord(whole[start..end], at - start) orelse return "";
- return whole[start + b.lo .. start + b.hi];
+ var word_from: usize = 0;
+ if (in_body) diff: {
+ const row = std.mem.count(u8, whole[0..start], "\n");
+ // Only when the served line is the one the diff reads there.
+ const lines = look.diffLines(p, pane) orelse break :diff;
+ if (row >= lines.len or !std.mem.eql(u8, std.mem.trimEnd(u8, lines[row], "\r"), std.mem.trimEnd(u8, whole[start..end], "\r"))) break :diff;
+ var buf: [4096 + 32]u8 = undefined;
+ const click = look.diffClick(p, pane, row, at - start, &buf) orelse break :diff;
+ if (click.address) |address| return p.scratch.allocator().dupe(u8, address) catch "";
+ word_from = click.word_from;
+ }
+ const line = whole[start..end];
+ if (at - start < word_from or word_from > line.len) return "";
+ const b = look.expandedWord(line[word_from..], at - start - word_from) orelse return "";
+ return whole[start + word_from + b.lo .. start + word_from + b.hi];
}
pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply {
@@ -730,7 +746,7 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply {
// An empty range with no text is a click at that point: expanded as
// the click would be, to the word or file name under it (acme's
// expand, look.c).
- const said = if (r.q0 == r.q1) r.text orelse expandAt(whole, lo) else whole[lo..hi];
+ const said = if (r.q0 == r.q1) r.text orelse expandAt(p, pane, whole, lo, !r.action.onTag()) else whole[lo..hi];
const text = p.scratch.allocator().dupe(u8, said) catch continue;
switch (r.action) {
// A body's click written back is done as the click would have