diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 10:30:38 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 2d0c506583d18d6f004fc117282deb2faea1d17e (patch) | |
| tree | 683d1dd2820df31222360c0308fb3333b64aae1e /src | |
| parent | f207f1d4d56d1012766e6206e713f2b3da70579d (diff) | |
| download | pardes-2d0c506583d18d6f004fc117282deb2faea1d17e.tar.gz pardes-2d0c506583d18d6f004fc117282deb2faea1d17e.zip | |
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
<path>`. 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 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/Output.zig | 49 | ||||
| -rw-r--r-- | src/builtins.zig | 10 | ||||
| -rw-r--r-- | src/config.zig | 2 | ||||
| -rw-r--r-- | src/fs-help.txt | 4 | ||||
| -rw-r--r-- | src/main.zig | 16 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 54 | ||||
| -rw-r--r-- | src/ninep/events.zig | 2 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 1 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 31 | ||||
| -rw-r--r-- | src/pardes.zig | 104 | ||||
| -rw-r--r-- | src/recent.zig | 119 |
11 files changed, 380 insertions, 12 deletions
diff --git a/src/Output.zig b/src/Output.zig index 37d25168..dd6ab92a 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -505,6 +505,12 @@ pub fn openJumps(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); for (p.jumps[0..p.njumps]) |j| { + // A closed file's entry: marked, and a look at it opens it again. + if (j.serial == 0) { + const e = p.recentByHash(j.path_hash) orelse continue; + try out.writer.print("{s}:{d}:{d} (closed)\n", .{ e.path, @max(1, j.line), @max(1, j.col) }); + continue; + } const jp = p.panes[j.pane] orelse continue; var idbuf: [16]u8 = undefined; const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; @@ -532,6 +538,49 @@ pub fn openJumps(p: *Pardes, id: usize) !void { try openStepped(p, id, .{ .cmd = .Jumplist }, content); } +/// The recent list as rows: `<path>:<line>:<col> open|closed`, most recent +/// first; a look at a row opens the file there, as a +Jumps row does. +pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void { + for (p.recent.entries.items) |e| { + var is_open = false; + 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 + try w.print("{s}:{d}:{d} {s}\n", .{ e.path, e.line, e.col, state }); + } +} + +/// Recent: the one +Recent, filled again when it is there (as Find and +/// Grep's +Search is), else opened below the asking pane. +pub fn openRecent(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try recentText(p, &out.writer, true); + const content = try out.toOwnedSlice(); + for (p.panes, 0..) |slot, i| { + const rp = slot orelse continue; + const rf = if (rp.file) |*f| f else continue; + const o = rf.output orelse continue; + if (!std.meta.eql(o.from, Origin{ .cmd = .Recent })) continue; + File.setContent(p, rf, content); + resetBody(p, rp); + p.fs.refilled = rp.serial; + if (p.panes[id]) |pane| { + pane.search_pane = i; + pane.search_row = null; + } + look.armLookWalk(p, i); + return; + } + try openStepped(p, id, .{ .cmd = .Recent }, content); +} + pub fn openThemes(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); diff --git a/src/builtins.zig b/src/builtins.zig index 7fdf2176..2247fcbb 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1447,6 +1447,7 @@ pub const Last = struct { while (i > 0) { i -= 1; const j = c.p.jumps[i]; + if (j.serial == 0) continue; // a closed file's: Back reopens it, Last goes to a pane // Restore the cursor without recentering the pane's retained view. if (j.pane != c.id) return look.focusPaneLine(c.p, j.pane, .{ .line = j.line, .col = j.col }, .keep); } @@ -1456,6 +1457,15 @@ pub const Last = struct { } }; +/// List the files opened lately, closed ones too, each a place to open again. +pub const Recent = struct { + pub const scope: Scope = .session; + pub const output: OutputTraits = .{ .name = config.recent_buffer, .steps = true, .locations = true }; + pub fn run(c: Ctx) void { + panes.Output.openRecent(c.p, c.id) catch |err| c.p.reportError(c.id, "recent", err); + } +}; + /// List the places jumped from, each a place to go. pub const Jumplist = struct { pub const scope: Scope = .session; diff --git a/src/config.zig b/src/config.zig index 229c70ea..c7ea5411 100644 --- a/src/config.zig +++ b/src/config.zig @@ -65,6 +65,7 @@ pub const leader_path = paths: { .New = "fn", .Tty = "nt", .Find = "ff", + .Recent = "fr", .Grep = "fg", .Config = "fc", .LocationsConfig = null, @@ -448,6 +449,7 @@ pub const help_buffer = "+Help"; pub const config_buffer = "+Config"; pub const effect_code_buffer = "+EffectCode"; pub const jumps_buffer = "+Jumps"; +pub const recent_buffer = "+Recent"; pub const themes_buffer = "+Themes"; pub const fonts_buffer = "+Fonts"; pub const pdf_sections_buffer = "+PdfSections"; diff --git a/src/fs-help.txt b/src/fs-help.txt index bd822b60..62d4ae48 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -11,11 +11,11 @@ listeners the session's dial addresses focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard ctl settings a line; or Newcol, Dump, Restore [f], Exit (asks once), Kill [w], Placement acme|pardes, size C R (--detach) commands every builtin: word, arg if it takes one, root|pane|both (the ctl that takes it), -- what it does +recent files opened lately, closed ones too, most recent first: open|closed <path> layout a line per column: serial index x width current|notcurrent empty|full pane-serials; active <serial> tag the workspace tag, tagexec a word as a click in it; col/<serial>/ a column's tag, ctl (Delcol Joincol New Tty), exec; rmdir: empty only pane/new open it to make a pane (<session dir>/+New) in the active column, filling it if empty; the read answers its serial -pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: - appended to its directory's +Errors pane) event look exec tagexec, pty/ on terminals; rmdir closes +pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only, to +Errors) event look exec tagexec, pty/ on terminals; rmdir closes os/, src/ the host filesystem; the editor's own sources $m below: $NINE_MOUNT/pardes/<pid|NAME>; $NINE_MOUNT under 9ns --unix; PARDES_MOUNT in a Tty9p shell. diff --git a/src/main.zig b/src/main.zig index 86c302fe..b784ec97 100644 --- a/src/main.zig +++ b/src/main.zig @@ -369,6 +369,7 @@ fn nativeMain(init: std.process.Init) !void { opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; + opts.recent_path = recentPath(init.io, arena, init.environ_map); if (found.dir) |d| @import("crash.zig").setDir(d); if (detach) |name| { const named = if (name.len != 0) @@ -427,3 +428,18 @@ test { if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); if (comptime pardes.font_picker) _ = @import("fonts.zig"); } + +/// Where the list of files opened lately is kept: $XDG_STATE_HOME/pardes +/// /recent, else ~/.local/state/pardes/recent, the directory made if need +/// be; null with no home to keep it in (the list then lasts the session). +fn recentPath(io: std.Io, arena: std.mem.Allocator, env: *const std.process.Environ.Map) ?[]const u8 { + const dir = dir: { + if (env.get("XDG_STATE_HOME")) |base| if (base.len != 0 and std.fs.path.isAbsolute(base)) + break :dir std.fs.path.join(arena, &.{ base, "pardes" }) catch return null; + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + break :dir std.fs.path.join(arena, &.{ home, ".local", "state", "pardes" }) catch return null; + }; + std.Io.Dir.cwd().createDirPath(io, dir) catch return null; + return std.fs.path.join(arena, &.{ dir, "recent" }) catch null; +} 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 <path>`, 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); diff --git a/src/pardes.zig b/src/pardes.zig index f7aee16c..33aff840 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -14,6 +14,7 @@ const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); pub const tagline = @import("tagline.zig"); pub const exec = @import("exec.zig"); +const recent_files = @import("recent.zig"); pub const mouse = @import("mouse.zig"); pub const normal = @import("normal.zig"); pub const edit = @import("edit.zig"); @@ -447,11 +448,17 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); } +const recent_path_cap = limits.host_path_cap; + pub const Loc = struct { pane: u16, + /// 0 once its pane has closed: a file's entry stays, reopened by a + /// jump to it from `path_hash`'s recent entry. serial: u32, line: u32, col: u32, + /// The file it is in (Wyhash of the path), 0 for a pane with none. + path_hash: u64 = 0, }; test { @@ -474,6 +481,7 @@ test { _ = @import("draw.zig"); _ = @import("tag_layer.zig"); _ = @import("dump.zig"); // its tests ran nowhere before + _ = @import("recent.zig"); _ = @import("Presentation.zig"); _ = @import("animation.zig"); } @@ -4232,6 +4240,10 @@ pub const Options = struct { ninep_quic: ?[]const u8 = null, mounts: []const filesystem.Mount = &.{}, config_dir: ?[]const u8 = null, + /// Where the list of files opened lately is kept across sessions + /// (recent.zig), set by the host that has a state directory; none, it + /// lives as long as the session. + recent_path: ?[]const u8 = null, startup_config_path: ?[]const u8 = null, image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, @@ -4298,6 +4310,12 @@ pub const Pardes = struct { presentation: Presentation = .{}, active: usize = 0, + /// The files opened lately, closed ones too (recent.zig): `Recent`, + /// /recent, and the jumplist's way back to a closed pane. + recent: recent_files.List = .{}, + /// The kept list was read (or there was none): only then is it written, + /// so a session never writes over one it did not read. + recent_loaded: bool = false, jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, @@ -4621,6 +4639,7 @@ pub const Pardes = struct { }, } p.sync(); + p.loadRecent(); exec.applyStartupConfig(p); colors.finishThemeInitialization(p); p.sync(); @@ -4641,7 +4660,58 @@ pub const Pardes = struct { p.col_panes[0][0] = 0; } + /// The recent list's own allocator: it is kept best effort (a file not + /// remembered for want of memory is no failure of what opened it), so + /// on a host it takes from the process heap, not the editor's, whose + /// every failure must be answered. + // ponytail: a host heap for a best-effort list; the board's gpa there. + pub fn recentGpa(p: *const Pardes) std.mem.Allocator { + return if (comptime hosted) std.heap.smp_allocator else p.gpa; + } + + /// The kept list of files opened lately, read once at start. + fn loadRecent(p: *Pardes) void { + const path = p.opts.recent_path orelse return; + if (comptime !hosted) return; + defer p.recent_loaded = true; + const bytes = filesystem.readFile(p.gpa, path) catch return; + defer p.gpa.free(bytes); + p.recent.read(p.recentGpa(), bytes); + } + + /// Written out when it changed, at the end of a step and at the end. + pub fn saveRecent(p: *Pardes) void { + if (!p.recent.dirty) return; + p.recent.dirty = false; + const path = p.opts.recent_path orelse return; + if (comptime !hosted) return; + if (!p.recent_loaded) return; + var out: std.Io.Writer.Allocating = .init(p.gpa); + defer out.deinit(); + p.recent.write(&out.writer) catch return; + turn.yield(); + defer turn.back(); + std.Io.Dir.cwd().writeFile(std.Io.Threaded.global_single_threaded.io(), .{ .sub_path = path, .data = out.written() }) catch {}; + } + + /// 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 } { + 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 recent entry a closed jump names, by its path's hash. + pub fn recentByHash(p: *const Pardes, hash: u64) ?recent_files.Entry { + for (p.recent.entries.items) |e| if (std.hash.Wyhash.hash(0, e.path) == hash) return e; + return null; + } + 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); + p.saveRecent(); + p.recent.deinit(p.recentGpa()); look.cancelLookHover(p); if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); @@ -4750,6 +4820,9 @@ 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); 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 } }); @@ -5240,6 +5313,7 @@ pub const Pardes = struct { f.saved_revision = f.revision; f.disk_gone = false; ctlfs.events.noteLog(p, .save, pane); + p.recent.opened(p.recentGpa(), f.path); if (filesystem.localPath(f.path) != null) p.emit(.{ .watch = .{ .pane = id, .on = true } }); } @@ -6562,9 +6636,15 @@ pub const Pardes = struct { var cur: usize = 0; for (p.jumps[0..p.njumps], 0..) |j, i| { if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does - const pane = p.panes[j.pane] orelse continue; - if (pane.serial != j.serial) continue; - p.jumps[w] = j; + var kept = j; + const live = if (j.serial == 0) false else if (p.panes[j.pane]) |pane| pane.serial == j.serial else false; + // A file's pane closed: its entry stays, closed, while the recent + // list knows the file, and a jump to it opens it again. + if (!live) { + if (j.path_hash == 0 or p.recentByHash(j.path_hash) == null) continue; + kept.serial = 0; + } + p.jumps[w] = kept; w += 1; } p.njumps = w; @@ -6576,6 +6656,7 @@ pub const Pardes = struct { .serial = pane.serial, .line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.body.cur_pinned) @intCast(@max(0, pane.body.cur_row) + 1) else 0, .col = if (pane.pdfPage() != null) 0 else if (pane.body.cur_pinned) @intCast(@max(0, pane.body.cur_col) + 1) else 0, + .path_hash = if (recentDot(pane)) |d| std.hash.Wyhash.hash(0, d.path) else 0, }; if (p.njumps > 0 and p.samePlace(p.jumps[p.jcur], now)) { p.jumps[p.jcur] = now; // same visit; the entry IS where you are @@ -6604,6 +6685,21 @@ pub const Pardes = struct { if (p.njumps == 0 or next < 0 or next >= p.njumps) return; const j = p.jumps[@intCast(next)]; if (j.pane >= MAX_PANES) return; + // A closed file's entry: opened again at its dot, placed as a look + // 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); + const opened = p.panes[p.active] orelse return; + if (p.active == from) return; + p.jumps[@intCast(next)].pane = @intCast(p.active); + p.jumps[@intCast(next)].serial = opened.serial; + p.jcur = @intCast(next); + return; + } const pane = p.panes[j.pane] orelse return; if (pane.serial != j.serial) return; if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source); @@ -6640,6 +6736,8 @@ pub const Pardes = struct { if (refused) for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { _ = layout.shareColumn(p, c); }; + // A file opened or closed this step: the kept list follows. + p.saveRecent(); tagline.flattenHeaders(p); if (p.header_column != null and p.columnBarHeight() == 0) { tagline.exitHeader(p); diff --git a/src/recent.zig b/src/recent.zig new file mode 100644 index 00000000..b6a1a66a --- /dev/null +++ b/src/recent.zig @@ -0,0 +1,119 @@ +//! The files opened lately, most recent first, those since closed too, as +//! `Recent` lists them and a jump to a closed pane reopens them: a path +//! and where the dot was when it closed. Kept across sessions in a small +//! file under $XDG_STATE_HOME/pardes (Options.recent_path) when the host +//! gives one. acme has nothing like it (its dump and Load are the nearest): +//! a file closed by accident, its path forgotten, is found here again. + +const std = @import("std"); + +/// At most this many files are remembered, the oldest dropped first. +pub const cap = 200; + +pub const Entry = struct { + path: []u8, + /// Where its dot was when it last closed (1-based; 0 none). + line: u32 = 0, + col: u32 = 0, +}; + +pub const List = struct { + entries: std.ArrayList(Entry) = .empty, + /// Changed since it was last written out. + dirty: bool = false, + + pub fn deinit(l: *List, gpa: std.mem.Allocator) void { + for (l.entries.items) |e| gpa.free(e.path); + l.entries.deinit(gpa); + } + + fn find(l: *const List, path: []const u8) ?usize { + for (l.entries.items, 0..) |e, i| if (std.mem.eql(u8, e.path, path)) return i; + return null; + } + + /// `path` opened: first in the list, once, keeping the dot it closed at. + pub fn opened(l: *List, gpa: std.mem.Allocator, path: []const u8) void { + if (path.len == 0) return; + var entry: Entry = if (l.find(path)) |i| l.entries.orderedRemove(i) else .{ + .path = gpa.dupe(u8, path) catch return, + }; + if (l.entries.items.len >= cap) { + const last = l.entries.pop().?; + gpa.free(last.path); + } + l.entries.insert(gpa, 0, entry) catch { + gpa.free(entry.path); + entry = undefined; + return; + }; + l.dirty = true; + } + + /// `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 { + const i = l.find(path) orelse found: { + l.opened(gpa, path); + // Out of memory for it: nothing to note the dot on. + break :found l.find(path) orelse return; + }; + l.entries.items[i].line = line; + l.entries.items[i].col = col; + l.dirty = true; + } + + pub fn get(l: *const List, path: []const u8) ?Entry { + return l.entries.items[l.find(path) orelse return null]; + } + + /// One line a file, `<line> <col> <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 }); + } + + /// The lines `write` wrote; a line that is not one is passed over. + pub fn read(l: *List, gpa: std.mem.Allocator, bytes: []const u8) void { + 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(); + 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 { + gpa.free(owned); + return; + }; + } + } +}; + +test "the list keeps a path once, most recent first, its dot from closing, capped" { + const gpa = std.testing.allocator; + var l: List = .{}; + defer l.deinit(gpa); + l.opened(gpa, "/a"); + l.opened(gpa, "/b"); + l.closed(gpa, "/a", 7, 3); + l.opened(gpa, "/a"); + try std.testing.expectEqualStrings("/a", l.entries.items[0].path); + try std.testing.expectEqual(@as(usize, 2), l.entries.items.len); + try std.testing.expectEqual(@as(u32, 7), l.get("/a").?.line); + 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()); + 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); + for (0..cap + 5) |i| { + var name: [16]u8 = undefined; + l.opened(gpa, std.fmt.bufPrint(&name, "/f{d}", .{i}) catch unreachable); + } + try std.testing.expectEqual(@as(usize, cap), l.entries.items.len); +} |
