summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Output.zig49
-rw-r--r--src/builtins.zig10
-rw-r--r--src/config.zig2
-rw-r--r--src/fs-help.txt4
-rw-r--r--src/main.zig16
-rw-r--r--src/ninep/ctl.zig54
-rw-r--r--src/ninep/events.zig2
-rw-r--r--src/ninep/pane.zig1
-rw-r--r--src/ninep/tree.zig31
-rw-r--r--src/pardes.zig104
-rw-r--r--src/recent.zig119
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);
+}