From 2d0c506583d18d6f004fc117282deb2faea1d17e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 10:30:38 -0300 Subject: Recent lists the files opened lately, closed ones too, and a jump back to a closed file reopens it A file closed by accident, its path forgotten, could not be found again: the jumplist dropped closed panes. Every file opened (a look, a Save of a scratch, a Restore, a rename) goes first in a list of 200, kept once, its dot noted when it closes, kept across sessions in $XDG_STATE_HOME/pardes/ recent. `Recent` (SPC f r) shows it in a reused +Recent, each row `path:line:col open|closed` a look reopens; /recent reads `open|closed `. The jumplist keeps a closed file's entries, +Jumps marks them `(closed)`, and Back to one opens the file at its place. acme has no such thing; its dump and Load are the nearest. Co-Authored-By: Claude Opus 5.5 --- src/ninep/ctl.zig | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ninep/events.zig | 2 ++ src/ninep/pane.zig | 1 + src/ninep/tree.zig | 31 +++++++++++++++++++++++------- 4 files changed, 81 insertions(+), 7 deletions(-) (limited to 'src/ninep') diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 6631e5f9..22a0923b 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -2106,6 +2106,60 @@ test "a column takes panes until their documented minimums fill it, not until no } } +test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" { + 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 = "a.txt", .data = "one\ntwo\nthree\n" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\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 a_path = try std.fmt.bufPrint(&line, "{s}/a.txt", .{dir}); + var a_buf: [4200]u8 = undefined; + const a = a_buf[0..a_path.len]; + @memcpy(a, a_path); + // Opened, its dot on line 3, then closed. + try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}:3\n", .{a})).reply.status); + p.sync(); + const opened = p.active; + try testing.expectEqualStrings(a, p.panes[opened].?.file.?.path); + var want: [4300]u8 = undefined; + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{a})) != null); + // Another file, so the jumplist has somewhere to come back from. + try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})).reply.status); + p.sync(); + try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[opened].?.serial, .ctl), "Del\n").reply.status); + p.sync(); + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "closed {s}\n", .{a})) != null); + // Recent lists it, closed, at its dot; a look at the row opens it there. + try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status); + 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); + p.sync(); + const back = p.panes[p.active].?; + try testing.expectEqualStrings(a, back.file.?.path); + try testing.expectEqual(@as(i32, 2), back.body.cur_row); + // Closed again; the jumplist keeps its entry, closed, and a jump back + // to it opens it again. + _ = wr(p, Node.of(back.serial, .ctl), "Del\n"); + p.sync(); + var closed_entry = false; + for (p.jumps[0..p.njumps]) |j| closed_entry = closed_entry or j.serial == 0; + try testing.expect(closed_entry); + var tries: usize = 0; + while (tries < p.njumps) : (tries += 1) { + p.jumpBy(-1); + p.sync(); + if (p.panes[p.active].?.file) |f| if (std.mem.eql(u8, f.path, a)) break; + } + try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path); +} + 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/ninep/events.zig b/src/ninep/events.zig index 868364a6..690efbe5 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -140,6 +140,8 @@ pub fn announce(p: *Pardes) void { if (!pane.fs.unannounced) continue; 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); } } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 1148041a..4370d401 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -683,6 +683,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); } /// Diagnostics go to the +Errors buffer of the pane's directory. diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 9d2027bf..64089c01 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -219,6 +219,9 @@ pub const TopFile = enum(u5) { tag, col, tagexec, + /// The files opened lately, closed ones too, a line each: + /// `open|closed `, most recent first. + recent, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); @@ -228,7 +231,7 @@ pub const TopFile = enum(u5) { return switch (f) { .root, .pane, .col => 0o755, .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666, - .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444, + .README, .index, .status, .screen, .listeners, .new, .commands, .layout, .recent => 0o444, }; } @@ -239,6 +242,13 @@ pub const TopFile = enum(u5) { pub const root: u64 = @intFromEnum(TopFile.root); +fn recentLen(p: *Pardes) u64 { + var buf: [64]u8 = undefined; + var counting: std.Io.Writer.Discarding = .init(&buf); + pardes.panes.Output.recentText(p, &counting.writer, false) catch return 0; + return counting.fullCount(); +} + /// Declaration order is readdir order. pub const PaneFile = enum(u5) { dir = 0, @@ -556,6 +566,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { }, .ctl => ctl.rootLen(p), .commands => ctl.commandsLen(), + .recent => recentLen(p), .focus => focus: { if (p.header_focus) break :focus 0; const pn = p.panes[p.active] orelse break :focus 0; @@ -653,7 +664,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { .root => { // The guide, then what describes the session, then the // files that act, then the directories. - inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.recent, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); @@ -933,6 +944,12 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { .focus => ctl.readFocus(p, req), .ctl => ctl.readRoot(p, req), .commands => ctl.readCommands(p, req), + .recent => recent: { + var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); + pardes.panes.Output.recentText(p, &text.writer, false) catch break :recent Reply.fail(req.tag, E.NOMEM); + p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :recent Reply.fail(req.tag, E.NOMEM); + break :recent stagedReply(p, req); + }, .layout => layout: { var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); cols.writeLayout(p, &text.writer) catch break :layout Reply.fail(req.tag, E.NOMEM); @@ -1201,13 +1218,13 @@ test "readdir lists the root and a pane directory without creating anything" { @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); try testing.expect(top.len >= 10); - for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { + for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "recent", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 14, top[i].dir); + try testing.expectEqual(i >= 15, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node); - try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node); - try testing.expectEqual(fs.os_root, top[16].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[15].node); + try testing.expectEqual(@intFromEnum(TopFile.col), top[16].node); + try testing.expectEqual(fs.os_root, top[17].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); -- cgit v1.3