diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 2 | ||||
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | docs/fs.md | 5 | ||||
| -rw-r--r-- | docs/helix-keys.md | 2 | ||||
| -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 | ||||
| -rw-r--r-- | test/fs.py | 28 |
16 files changed, 434 insertions, 14 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 0575fab7..4678d55a 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -96,6 +96,8 @@ $m/ctl the settings, one a line as a write takes them; write a setting or column word Delcol, which closes that pane's column) go to $m/pane/<n>/ctl $m/commands every builtin: `Word`, `Word arg`, then `root`, `pane` or `both` (which ctl takes it), a setting's values comma-joined, then ` -- ` and one sentence of what it does +$m/recent files opened lately, closed ones too, most recent first: `open|closed <path>`; + lost a file you closed? read this, or run Recent and look at its row to reopen it $m/layout one line per column (16 columns at most: Newcol past that fails, no space for a column): serial index x width current|notcurrent empty|full pane-serials...; active <serial> $m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<serial>/tag a column's (serials stay, as panes' do) $m/tagexec a word as a click in the workspace tag (pane/<n>/tagexec: in that pane's tag); every exec @@ -103,6 +103,25 @@ pane shells outlive every frontend; a frontend keeps only what needs the human's own display. From inside the editor, `Attach` and `Detach` do the same thing as words. See `docs/detached.md`. +## Recent files + +A file closed by accident is found again. `Recent` (`SPC f r`) lists the +files opened lately, most recent first, closed ones too, each marked `open` +or `closed` at the place its dot was when it closed: + +``` +/home/me/src/main.zig:120:5 closed +/home/me/notes.txt open +``` + +A look at a row opens that file there. The list keeps 200 files, each once, +and lasts across sessions in `$XDG_STATE_HOME/pardes/recent` (else +`~/.local/state/pardes/recent`). The jumplist keeps a closed file's entries +too: `Back` to one opens the file again at its place, and `+Jumps` marks it +`(closed)`. Over 9P, `/recent` reads `open <path>` or `closed <path>` a line. +acme has nothing like it (its dump and Load are the nearest); pardes goes +beyond acme here. + ## Tests ``` @@ -125,6 +125,11 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /commands every builtin: word, `arg` if it takes one, `root`, `pane` or `both` (the ctl that takes it: Edit is both, at the active pane from the root; a pane's word such as Undo or Msg is refused at the root), a setting's values, then ` -- ` and what it does +/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 + 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 (the column with the keyboard now) empty|full pane-serials...; then active diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 8874bf08..5d89ba92 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -143,7 +143,7 @@ language-backend queries, and the shell pipe. | `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus | | `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus | | `=` | `format_selections` — writes a `- old` / `+ new` diff into `+Lsp` | deliberate divergence: the seam returns ROWS, not edits, so this SHOWS the formatting instead of applying it. Not in the corpus, so there is no waiver to name — the query leaves the core as an effect the headless harness has no shell to perform | out of corpus | -| `Ctrl-o` / `Ctrl-i` | jumplist back / forward (also Mouse4 / Mouse5 in SDL, macOS and web); successful navigation clears selections in both panes and places the cursor at the saved location; raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | +| `Ctrl-o` / `Ctrl-i` | jumplist back / forward (also Mouse4 / Mouse5 in SDL, macOS and web); successful navigation clears selections in both panes and places the cursor at the saved location; raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer; a closed file's entry stays, marked `(closed)`, and a jump to it opens the file again at its place | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | | `\|` | pipe every selection through `/bin/sh -c`: its bytes in on stdin, its stdout replacing them, one undo across all cursors | helix's own key and meaning; the command is typed into the pane's tag after a bare `\|` marker rather than into a popup | out of corpus | | `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus | | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | 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); +} @@ -161,7 +161,7 @@ def discovery(binary, embedded=False): with session(binary, root, 'discovery') as (client, address): top = client.list('/') # A set: which files the root holds, not the order a listing gives. - assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands', + assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands', 'recent', 'look', 'exec', 'log', 'screen', 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'col', 'os'}, top assert 'self' not in top and 'cons' not in top and 'new' not in top, top @@ -539,6 +539,31 @@ def new_terminals_named_once(binary): assert str(made[0]).encode() in b' '.join(r.split()[0] for r in client.read('/index').splitlines()) +def recent_survives_restart(binary): + """A file opened and closed is in /recent closed, and still is in the + next session: the list is kept under $XDG_STATE_HOME (here HOME's + .local/state) across restarts.""" + with tempfile.TemporaryDirectory(prefix='pardes-recent-') as directory: + root = Path(directory) + kept = root / 'kept.txt' + kept.write_bytes(b'one\ntwo\n') + with session(binary, root, 'recent1') as (client, _): + serial = look(client, f'{kept}:2') + assert f'open {kept}\n' in client.read('/recent').decode() + client.write(f'/pane/{serial}/ctl', b'Del\n') + assert f'closed {kept}\n' in client.read('/recent').decode(), client.read('/recent') + assert (root / '.local' / 'state' / 'pardes' / 'recent').exists() + with session(binary, root, 'recent2') as (client, _): + listed = client.read('/recent').decode() + assert f'closed {kept}\n' in listed, listed + # Recent shows it, and a look at its row opens it again. + client.write('/pane/1/exec', b'Recent\n') + rows = int(client.read('/pane/1/exec').split()[-1]) + row = next(r for r in client.read(f'/pane/{rows}/body').decode().splitlines() if r.startswith(f'{kept}:')) + client.write(f'/pane/{rows}/look', (row.split(' ')[0] + '\n').encode()) + assert f'open {kept}\n' in client.read('/recent').decode() + + def workflow_canary(binary): """One session driven as the dogfood agents drive it, asserting the log and the read-backs at every step: the canary for the main paths (the @@ -803,6 +828,7 @@ def test(binary, quic=False): unattended_messages_expire(binary) new_terminals_named_once(binary) workflow_canary(binary) + recent_survives_restart(binary) python_repl_runs_one_line(binary) signal_cleans_up(binary) for options, message in [ |
