diff options
Diffstat (limited to 'src/look.zig')
| -rw-r--r-- | src/look.zig | 395 |
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;")); +} |
