summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 11:09:17 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit5db9e71ba32dd6f0a0292eaa341d1d8ba805483c (patch)
treefeaf11471f49fdba5122922a960889ea4cb4f549 /src
parent6b46d0797003585a99f8e5b99b3e8cff635ebebc (diff)
downloadpardes-5db9e71ba32dd6f0a0292eaa341d1d8ba805483c.tar.gz
pardes-5db9e71ba32dd6f0a0292eaa341d1d8ba805483c.zip
A remembered place is clamped to the file as it is now, its selection's two ends kept
A Recent row or a jumplist entry for a line the file no longer has failed the look; now it opens at the last line. The selection's anchor is remembered too, so a reopen restores both ends where they fit, and Recent and Jumplist share the (closed) marker. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/Output.zig19
-rw-r--r--src/look.zig44
-rw-r--r--src/ninep/ctl.zig40
-rw-r--r--src/pardes.zig20
-rw-r--r--src/recent.zig40
5 files changed, 139 insertions, 24 deletions
diff --git a/src/Output.zig b/src/Output.zig
index 3b8e6a7a..e6d6f46c 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -555,13 +555,20 @@ pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void {
for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output == null and std.mem.eql(u8, f.path, e.path)) {
is_open = true;
};
- const state: []const u8 = if (is_open) "open" else "closed";
- if (!look_rows)
- try w.print("{s} {s}\n", .{ state, e.path })
- else if (e.line == 0)
- try w.print("{s} {s}\n", .{ e.path, state })
- else
+ const state: []const u8 = if (is_open) "open" else "(closed)";
+ if (!look_rows) {
+ try w.print("{s} {s}\n", .{ if (is_open) "open" else "closed", e.path });
+ } else if (e.line == 0) {
+ try w.print("{s} {s}\n", .{ e.path, state });
+ } else if (e.aline != 0 and (e.aline != e.line or e.acol != e.col)) {
+ // A selection: both its ends, in order, `path:L:C-L2:C2`.
+ const a_first = e.aline < e.line or (e.aline == e.line and e.acol < e.col);
+ const s = if (a_first) [2]u32{ e.aline, e.acol } else [2]u32{ e.line, e.col };
+ const t = if (a_first) [2]u32{ e.line, e.col } else [2]u32{ e.aline, e.acol };
+ try w.print("{s}:{d}:{d}-{d}:{d} {s}\n", .{ e.path, s[0], s[1], t[0], t[1], state });
+ } else {
try w.print("{s}:{d}:{d} {s}\n", .{ e.path, e.line, e.col, state });
+ }
}
}
diff --git a/src/look.zig b/src/look.zig
index ca9aa80a..0baa9afc 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1381,8 +1381,52 @@ fn spotAt(text: []const u8, q: u32) struct { line: usize, col: usize } {
return .{ .line = line, .col = upto.len - start + 1 };
}
+/// A remembered place in `path` (Recent, the jumplist): the file opened or
+/// focused, then `at` clamped to it as it is now, a line past the end its
+/// last line, never a miss; a selection's two ends restored where they
+/// still fit.
+pub fn reopenAt(p: *Pardes, from: usize, path: []const u8, at: Spot) void {
+ lookAt(p, from, path);
+ const id = p.active;
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ if (!std.mem.eql(u8, f.path, path)) return;
+ const n = @max(1, panes.File.nlines(p.gpa, f));
+ const clamp = struct {
+ fn spot(text: []const u8, lines: usize, line: usize, col: usize) [2]usize {
+ const l = std.math.clamp(line, 1, lines);
+ const width = modal.lineSlice(text, l - 1).len + 1;
+ return .{ l, std.math.clamp(col, 1, width) };
+ }
+ };
+ const head = clamp.spot(f.content, n, at.line, at.col);
+ var to: Spot = .{ .line = head[0], .col = head[1] };
+ if (at.end_line != 0) {
+ const end = clamp.spot(f.content, n, at.end_line, at.end_col);
+ to.end_line = end[0];
+ to.end_col = end[1];
+ }
+ clearNavigationSelection(p, pane);
+ focusPaneLine(p, id, to, .center);
+}
+
+/// Whether `pane` is the +Recent list, whose rows are remembered places.
+fn isRecentList(pane: *const Pane) bool {
+ const f = pane.file orelse return false;
+ const o = f.output orelse return false;
+ return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent });
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const pane = p.panes[id] orelse return;
+ // A +Recent row is a remembered place: reopened and clamped to the file
+ // as it is now, never a miss for a line the file no longer has.
+ if (isRecentList(pane)) {
+ const row = std.mem.trim(u8, operand, " \t\r\n");
+ const loc = row[0 .. std.mem.indexOfScalar(u8, row, ' ') orelse row.len];
+ const pl = parsePathLine(loc);
+ if (pl.path.len > 0 and pl.path[0] == '/' and pl.at.line != 0) return reopenAt(p, id, pl.path, pl.at);
+ }
p.noteHaptic(.look);
noteLookSource(p, id);
const command = std.mem.trim(u8, operand, " \t\r\n");
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 477a91fe..889ee7be 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -2141,8 +2141,8 @@ test "a closed file is found again: Recent and /recent list it closed, a look at
const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
const at = std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}:3:", .{a})).?;
const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
- try testing.expect(std.mem.endsWith(u8, row, " closed"));
- try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " closed".len]})).reply.status);
+ try testing.expect(std.mem.endsWith(u8, row, " (closed)"));
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " (closed)".len]})).reply.status);
p.sync();
const back = p.panes[p.active].?;
try testing.expectEqualStrings(a, back.file.?.path);
@@ -2163,6 +2163,42 @@ test "a closed file is found again: Recent and /recent list it closed, a look at
try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path);
}
+test "a remembered place is clamped to the file as it is now, its selection's two ends kept where they fit" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\nthree\nfour\nfive\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt:2\n", .{dir}));
+ p.sync();
+ const c = p.panes[p.active].?;
+ // A selection over lines 2 to 4, then closed.
+ @import("pane.zig").setDot(c, .{ .q0 = 4, .q1 = 19 });
+ try testing.expect(c.body.vsel.active);
+ _ = wr(p, Node.of(c.serial, .ctl), "Del\n");
+ p.sync();
+ // The file shrinks to two lines while it is closed.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\n" });
+ _ = wr(p, root_exec, "Recent\n");
+ const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ const at = std.mem.indexOf(u8, rows.file.?.content, "c.txt:").?;
+ const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
+ // Both ends are in the row, a range.
+ try testing.expect(std.mem.indexOfScalar(u8, row, '-') != null);
+ var full: [4400]u8 = undefined;
+ const loc = try std.fmt.bufPrint(&full, "{s}/{s}\n", .{ dir, row[0 .. std.mem.indexOfScalar(u8, row, ' ').?] });
+ // A look at it opens the file, never a miss, clamped to its last line.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), loc).reply.status);
+ p.sync();
+ const back = p.panes[p.active].?;
+ try testing.expect(std.mem.endsWith(u8, back.file.?.path, "/c.txt"));
+ try testing.expect(back.body.cur_row <= 1);
+ try testing.expect(back.body.vsel.active);
+}
+
test "size is monotonic: growing is never refused, and a size once taken is taken again" {
const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 });
defer p.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index 0950d13a..5fdcfe3c 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4700,10 +4700,18 @@ pub const Pardes = struct {
}
/// A file pane's path and dot, for the recent list (0 when none).
- fn recentDot(pane: *const Pane) ?struct { path: []const u8, line: u32, col: u32 } {
+ fn recentDot(pane: *const Pane) ?struct { path: []const u8, line: u32, col: u32, aline: u32 = 0, acol: u32 = 0 } {
const f = pane.file orelse return null;
if (f.output != null) return null;
- return .{ .path = f.path, .line = @intCast(@max(0, pane.body.cur_row) + 1), .col = @intCast(@max(0, pane.body.cur_col) + 1) };
+ // The selection's anchor too, so a reopen restores both its ends.
+ const sel = pane.body.vsel.active;
+ return .{
+ .path = f.path,
+ .line = @intCast(@max(0, pane.body.cur_row) + 1),
+ .col = @intCast(@max(0, pane.body.cur_col) + 1),
+ .aline = if (sel) @intCast(@max(0, pane.body.vsel.row) + 1) else 0,
+ .acol = if (sel) @intCast(@max(0, pane.body.vsel.col) + 1) else 0,
+ };
}
/// The recent entry a closed jump names, by its path's hash.
@@ -4714,7 +4722,7 @@ pub const Pardes = struct {
pub fn deinit(p: *Pardes) void {
// Every file open now is closed with its dot, and the list kept.
- for (p.panes) |slot| if (slot) |pane| if (recentDot(pane)) |d| p.recent.closed(p.recentGpa(), d.path, d.line, d.col);
+ for (p.panes) |slot| if (slot) |pane| if (recentDot(pane)) |d| p.recent.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol);
p.saveRecent();
p.recent.deinit(p.recentGpa());
look.cancelLookHover(p);
@@ -4827,7 +4835,7 @@ pub const Pardes = struct {
fn retirePane(p: *Pardes, pane: *Pane) void {
// Closed, a file is remembered with its dot: Recent and a jump back
// to it reopen it there.
- if (recentDot(pane)) |d| p.recent.closed(p.recentGpa(), d.path, d.line, d.col);
+ if (recentDot(pane)) |d| p.recent.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol);
const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf();
for (p.panes, 0..) |slot, id| if (slot == pane) {
if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
@@ -6694,10 +6702,8 @@ pub const Pardes = struct {
// places it, and the entry is that pane from here on.
if (j.serial == 0) {
const e = p.recentByHash(j.path_hash) orelse return;
- var where: [recent_path_cap + 32]u8 = undefined;
- const loc = std.fmt.bufPrint(&where, "{s}:{d}:{d}", .{ e.path, @max(1, j.line), @max(1, j.col) }) catch return;
const from = p.active;
- look.lookAt(p, from, loc);
+ look.reopenAt(p, from, e.path, .{ .line = @max(1, j.line), .col = @max(1, j.col) });
const opened = p.panes[p.active] orelse return;
if (p.active == from) return;
p.jumps[@intCast(next)].pane = @intCast(p.active);
diff --git a/src/recent.zig b/src/recent.zig
index b6a1a66a..9f18d737 100644
--- a/src/recent.zig
+++ b/src/recent.zig
@@ -15,6 +15,9 @@ pub const Entry = struct {
/// Where its dot was when it last closed (1-based; 0 none).
line: u32 = 0,
col: u32 = 0,
+ /// The selection's other end then (its anchor), 0 when there was none.
+ aline: u32 = 0,
+ acol: u32 = 0,
};
pub const List = struct {
@@ -53,6 +56,11 @@ pub const List = struct {
/// `path` closed with its dot at `line`:`col`: where a reopen lands.
/// Its place in the list is its opening's.
pub fn closed(l: *List, gpa: std.mem.Allocator, path: []const u8, line: u32, col: u32) void {
+ l.closedSel(gpa, path, line, col, 0, 0);
+ }
+
+ /// `closed`, with the selection's anchor too (0 0 for none).
+ pub fn closedSel(l: *List, gpa: std.mem.Allocator, path: []const u8, line: u32, col: u32, aline: u32, acol: u32) void {
const i = l.find(path) orelse found: {
l.opened(gpa, path);
// Out of memory for it: nothing to note the dot on.
@@ -60,6 +68,8 @@ pub const List = struct {
};
l.entries.items[i].line = line;
l.entries.items[i].col = col;
+ l.entries.items[i].aline = aline;
+ l.entries.items[i].acol = acol;
l.dirty = true;
}
@@ -67,9 +77,10 @@ pub const List = struct {
return l.entries.items[l.find(path) orelse return null];
}
- /// One line a file, `<line> <col> <path>`, most recent first.
+ /// One line a file, `<line> <col> <aline> <acol> <path>`, most recent
+ /// first.
pub fn write(l: *const List, w: *std.Io.Writer) !void {
- for (l.entries.items) |e| try w.print("{d} {d} {s}\n", .{ e.line, e.col, e.path });
+ for (l.entries.items) |e| try w.print("{d} {d} {d} {d} {s}\n", .{ e.line, e.col, e.aline, e.acol, e.path });
}
/// The lines `write` wrote; a line that is not one is passed over.
@@ -77,13 +88,21 @@ pub const List = struct {
var lines = std.mem.splitScalar(u8, bytes, '\n');
while (lines.next()) |line| {
if (l.entries.items.len >= cap) break;
- var it = std.mem.splitScalar(u8, line, ' ');
- const ln = std.fmt.parseInt(u32, it.next() orelse continue, 10) catch continue;
- const col = std.fmt.parseInt(u32, it.next() orelse continue, 10) catch continue;
- const path = it.rest();
+ // Numbers, then the path (which starts with `/`): two of them in
+ // a list written before the anchor was kept, four since.
+ var nums: [4]u32 = @splat(0);
+ var n: usize = 0;
+ var rest = line;
+ while (rest.len > 0 and rest[0] != '/' and n < nums.len) : (n += 1) {
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse break;
+ nums[n] = std.fmt.parseInt(u32, rest[0..sp], 10) catch break;
+ rest = rest[sp + 1 ..];
+ }
+ if (n != 2 and n != 4) continue;
+ const path = rest;
if (path.len == 0 or path[0] != '/' or l.find(path) != null) continue;
const owned = gpa.dupe(u8, path) catch return;
- l.entries.append(gpa, .{ .path = owned, .line = ln, .col = col }) catch {
+ l.entries.append(gpa, .{ .path = owned, .line = nums[0], .col = nums[1], .aline = nums[2], .acol = nums[3] }) catch {
gpa.free(owned);
return;
};
@@ -105,12 +124,15 @@ test "the list keeps a path once, most recent first, its dot from closing, cappe
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try l.write(&w);
- try std.testing.expectEqualStrings("7 3 /a\n0 0 /b\n", w.buffered());
+ try std.testing.expectEqualStrings("7 3 0 0 /a\n0 0 0 0 /b\n", w.buffered());
var again: List = .{};
defer again.deinit(gpa);
again.read(gpa, w.buffered());
again.read(gpa, "junk\n");
- try std.testing.expectEqual(@as(usize, 2), again.entries.items.len);
+ // A list written before the anchor was kept still reads.
+ again.read(gpa, "4 2 /old\n");
+ try std.testing.expectEqual(@as(u32, 4), again.get("/old").?.line);
+ try std.testing.expectEqual(@as(usize, 3), again.entries.items.len);
for (0..cap + 5) |i| {
var name: [16]u8 = undefined;
l.opened(gpa, std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable);