diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 11:40:03 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 2f187232227dde2755a7f2f2ce717f3e1f685f5c (patch) | |
| tree | 0aebb2ad5831e235ab5833b514493878a00faf8c | |
| parent | f0fc0be608bb1ee96c20d9f9baaefd2543fed7a2 (diff) | |
| download | pardes-2f187232227dde2755a7f2f2ce717f3e1f685f5c.tar.gz pardes-2f187232227dde2755a7f2f2ce717f3e1f685f5c.zip | |
Recent keeps only files on disk, and its cap never drops an open one
A renamed scratch never saved and a /virtual/ pane were recorded as recent files, and at 200 the oldest entry went even when that file was open. Opens, renames and closes now record a path only when it is a file on this host, and the cap drops the oldest closed entry. A name with a newline is not written to the kept list, where it would read back as two.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | docs/fs.md | 4 | ||||
| -rw-r--r-- | src/exec.zig | 10 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 24 | ||||
| -rw-r--r-- | src/ninep/events.zig | 2 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 19 | ||||
| -rw-r--r-- | src/recent.zig | 39 |
7 files changed, 91 insertions, 9 deletions
@@ -128,7 +128,9 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /recent the files opened lately, closed ones too, most recent first, a line each: `open <path>` or `closed <path>` (read-only; `Recent` shows them in a pane, a look at a row reopening the file at its last dot; kept across sessions - in $XDG_STATE_HOME/pardes/recent, 200 files; acme has none, its dump and + in $XDG_STATE_HOME/pardes/recent, 200 files, the oldest closed one dropped + first, never an open one; only files on disk, not a name never saved nor + /virtual/; a name escaped as /index's is; acme has none, its dump and Load the nearest) /layout one line per column (16 at most; the board 6; Newcol past that fails, `no space for a column: 16 max`, ENOSPC), left to right: serial index x width current|notcurrent diff --git a/src/exec.zig b/src/exec.zig index 0e9fb6d7..fdb9bdb7 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -395,6 +395,16 @@ pub fn isDirectory(path: []const u8) bool { return stat.kind == .directory; } +/// Whether `path` is a plain file on this host, the turn out while it asks. +pub fn isFile(path: []const u8) bool { + var buf: [4096]u8 = undefined; + const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return false; + pardes.turn.yield(); + defer pardes.turn.back(); + const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false; + return stat.kind == .file; +} + /// With `BootShell replace`, a doc landing in `col` closes a lone pristine /// shell there, the boot's placeholder, and absorbVWeight hands its space to /// the doc. With `keep`, the default, the shell stays like any other pane. diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index f4a3d730..dc2d9a84 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -2304,6 +2304,30 @@ test "Back and Forward over a closed file's entries: one reopen is that file's p try testing.expectEqual(c_serial, p.panes[p.active].?.serial); } +test "Recent keeps only files on disk: not a name never saved, nor the served tree's" { + 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 = "real.txt", .data = "real\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; + const names = [_][]const u8{ try std.fmt.allocPrint(testing.allocator, "{s}/c", .{dir}), try testing.allocator.dupe(u8, "/virtual/zz"), try std.fmt.allocPrint(testing.allocator, "{s}/real.txt", .{dir}) }; + defer for (names) |n| testing.allocator.free(n); + for (names) |name| { + const made = try th.newPane(p); + _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&line, "{s}\n", .{name})); + p.sync(); + _ = wr(p, Node.of(made, .ctl), "Del\n"); + _ = wr(p, Node.of(made, .ctl), "Del\n"); + p.sync(); + } + try testing.expect(p.recent.get(names[0]) == null); + try testing.expect(p.recent.get(names[1]) == null); + try testing.expect(p.recent.get(names[2]) != null); +} + test "/recent shows a name as /index does, a newline in it escaped" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 2fe25071..add0d750 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -141,7 +141,7 @@ pub fn announce(p: *Pardes) void { pane.fs.unannounced = false; noteLog(p, .new, pane); // A file opened (a look, a Restore, a boot): first in Recent. - if (pane.file) |f| if (f.output == null) p.recent.opened(p.recentGpa(), f.path); + if (pane.file) |f| if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path); } } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 0b6f04a4..5ed3f6f4 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -687,7 +687,7 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) ! look.invalidateLookHover(p, id); p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); events.noteLog(p, .rename, pane); - if (f.output == null) p.recent.opened(p.recentGpa(), f.path); + if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path); } /// Diagnostics go to the +Errors buffer of the pane's directory. diff --git a/src/pardes.zig b/src/pardes.zig index 76ec2176..2bf594c4 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4700,6 +4700,21 @@ pub const Pardes = struct { } /// A file pane's path and dot, for the recent list (0 when none). + /// Whether `path` is one Recent keeps: a file on this host's disk, not + /// the served tree's (`/virtual/`) nor a name never saved. + pub fn recentKeeps(path: []const u8) bool { + const local = filesystem.localPath(path) orelse return false; + if (comptime !hosted) return true; + return exec.isFile(local); + } + + /// A file pane closing: its place noted in Recent, when Recent keeps it. + fn noteClosed(p: *Pardes, pane: *const Pane) void { + const d = recentDot(pane) orelse return; + if (p.recent.get(d.path) == null and !recentKeeps(d.path)) return; + p.recent.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol); + } + 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; @@ -4722,7 +4737,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.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol); + for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane); p.saveRecent(); p.recent.deinit(p.recentGpa()); look.cancelLookHover(p); @@ -4835,7 +4850,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.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol); + p.noteClosed(pane); 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 } }); diff --git a/src/recent.zig b/src/recent.zig index 9f18d737..91c0afeb 100644 --- a/src/recent.zig +++ b/src/recent.zig @@ -18,6 +18,10 @@ pub const Entry = struct { /// The selection's other end then (its anchor), 0 when there was none. aline: u32 = 0, acol: u32 = 0, + /// Open in a pane now (opened, not closed since): never the one the + /// cap drops. ponytail: a pane renamed away leaves its old path marked + /// open until the session ends; kept rather than tracked by pane. + open: bool = false, }; pub const List = struct { @@ -41,9 +45,14 @@ pub const List = struct { var entry: Entry = if (l.find(path)) |i| l.entries.orderedRemove(i) else .{ .path = gpa.dupe(u8, path) catch return, }; + entry.open = true; + // The oldest closed file goes for it; an open one never does. if (l.entries.items.len >= cap) { - const last = l.entries.pop().?; - gpa.free(last.path); + var i = l.entries.items.len; + while (i > 0) : (i -= 1) if (!l.entries.items[i - 1].open) { + gpa.free(l.entries.orderedRemove(i - 1).path); + break; + }; } l.entries.insert(gpa, 0, entry) catch { gpa.free(entry.path); @@ -70,6 +79,7 @@ pub const List = struct { l.entries.items[i].col = col; l.entries.items[i].aline = aline; l.entries.items[i].acol = acol; + l.entries.items[i].open = false; l.dirty = true; } @@ -80,7 +90,9 @@ pub const List = struct { /// 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} {d} {d} {s}\n", .{ e.line, e.col, e.aline, e.acol, e.path }); + // A name with a newline in it would read back as two lines: not kept. + for (l.entries.items) |e| if (std.mem.indexOfScalar(u8, e.path, '\n') == null) + 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. @@ -135,7 +147,26 @@ test "the list keeps a path once, most recent first, its dot from closing, cappe 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); + const path = std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable; + l.opened(gpa, path); + l.closed(gpa, path, 1, 1); } try std.testing.expectEqual(@as(usize, cap), l.entries.items.len); } + +test "the cap drops the oldest closed file, never an open one" { + const gpa = std.testing.allocator; + var l: List = .{}; + defer l.deinit(gpa); + var name: [16]u8 = undefined; + // The oldest is open; the next is closed. + l.opened(gpa, "/open"); + l.opened(gpa, "/shut"); + l.closed(gpa, "/shut", 1, 1); + for (2..cap) |i| l.opened(gpa, std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable); + try std.testing.expectEqual(@as(usize, cap), l.entries.items.len); + l.opened(gpa, "/new"); + try std.testing.expectEqual(@as(usize, cap), l.entries.items.len); + try std.testing.expect(l.get("/open") != null); + try std.testing.expect(l.get("/shut") == null); +} |
