diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 256 |
1 files changed, 227 insertions, 29 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 1f2c3175..72a90244 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -267,7 +267,9 @@ pub const MAX_TAG_ROWS = 8; pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; -pub const MAX_COLS = 6; +/// Columns a session holds: 16 on a desktop (each costs about 8 KB of state, +/// a column tag being most of it), 6 on the board. +pub const MAX_COLS = if (platform == .esp32p4) 6 else 16; const column_weight_unit = layout.column_weight_unit; const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; @@ -471,6 +473,7 @@ test { _ = @import("body_layer.zig"); _ = @import("draw.zig"); _ = @import("tag_layer.zig"); + _ = @import("dump.zig"); // its tests ran nowhere before _ = @import("Presentation.zig"); _ = @import("animation.zig"); } @@ -1653,13 +1656,30 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe try std.testing.expectEqualStrings("\x1b[200~print(1)\x1b[201~", Sent.said(1)); p.update(.{ .output = .{ .pane = 1, .bytes = "print(1)" } }); Sent.drain(p); + // Python's echo only restarts a short wait: the Enter goes once the + // echo of the whole paste has gone quiet. + try std.testing.expectEqualStrings("", Sent.said(1)); + p.now_ns = p.panes[1].?.repl_enter_due_ns; + p.update(.tick); + Sent.drain(p); try std.testing.expectEqualStrings("\r", Sent.said(1)); + // A paste of more than one line gets the blank line a block gets. + _ = exec.executeFrom(p, 0, "print(11)\nprint(22)", true); + Sent.drain(p); + try std.testing.expectEqualStrings("\x1b[200~print(11)\nprint(22)\x1b[201~", Sent.said(1)); + p.now_ns = p.panes[1].?.repl_enter_due_ns; + p.update(.tick); + Sent.drain(p); + try std.testing.expectEqualStrings("\r\r", Sent.said(1)); // Nothing answering, it goes when its wait is up. _ = exec.executeFrom(p, 0, "print(2)", true); - p.stepped_ns += 300 * std.time.ns_per_ms; + p.update(.tick); // on the clock: not yet, however old core time is + Sent.drain(p); + try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~")); + p.now_ns = p.panes[1].?.repl_enter_due_ns; // the clock reaches its wait p.update(.tick); Sent.drain(p); - try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~\r")); + try std.testing.expectEqualStrings("\r", Sent.said(1)); p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004l" } }); Sent.len[1] = 0; // A block left open by its indented last line gets the blank line that @@ -1714,6 +1734,7 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n"); try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status); try std.testing.expectEqualStrings("print(5)\r", answered.pty()); + try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} a\n", .{file.serial}))); } _ = exec.executeFrom(p, 0, "print(3)", true); p.update(.{ .key = .{ .cp = Key.escape } }); @@ -1804,6 +1825,10 @@ test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null); try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); try std.testing.expect(caller.shell == null); + // A directory passes access(X_OK); it is no shell either. + try std.testing.expect(p.executeBuiltinLine(0, "Tty /etc")); + try std.testing.expect(p.active == before); + try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "Tty: not a shell: /etc is a directory") != null); // Only a word that says so splits at `+`: `Dump+x.zon` is no Dump. try std.testing.expect(!p.executeBuiltinLine(0, "Dump+x.zon")); try std.testing.expect(!p.executeBuiltinLine(0, "Msg+hello")); @@ -2116,10 +2141,19 @@ test "owned cwd preserves long paths aliases and failed updates" { const original = pane.cwdSlice(); try pane.setOwnedCwd(original); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); - const effects = p.effects_len; + // A terminal's directory may be as long as a path is (PATH_MAX): one + // past the effect's buffer goes out of line (spawn_dirs); only one past + // the host's path limit is too long. const free = p.freeSlot().?; - try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); + _ = try p.newShell(free, path[0..]); + try std.testing.expectEqualStrings(&path, p.spawn_dirs[free].?); + try p.removePane(free, null); + while (p.nextEffect()) |_| {} + var too_long: [limits.host_path_cap + 1]u8 = @splat('d'); + too_long[0] = '/'; + try std.testing.expectError(error.PathTooLong, p.newShell(free, &too_long)); try std.testing.expect(p.panes[free] == null); + const effects = p.effects_len; exec.saveTo(p, 0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null); @@ -2680,7 +2714,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu }; const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; exec.runBuiltin(p, help, 0, "", null); - try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no space for a pane") != null); } test "Font keeps requested and acknowledged faces as separate plain state" { @@ -4272,6 +4306,19 @@ pub const Pardes = struct { /// The column whose tag the running command came from, while it runs: /// the column acme's commands take from their Text's col. exec_column: ?usize = null, + /// Panes made this step that found no room in their column + /// (exec.placeNew): closed at the step's end, once whatever made them + /// is done with them. + unplaced: std.StaticBitSet(MAX_PANES) = .initEmpty(), + /// The running word came from the workspace's or a column's tag, which + /// have no directory of their own: it runs in the session's + /// (`sessionDir`), as acme's row and column tags do. + exec_header: bool = false, + session_dir: [limits.host_path_cap]u8 = undefined, + /// A spawn's directory too long for the effect's inline buffer, by pane + /// slot: the effect then carries none, and perform gives the host this. + spawn_dirs: [MAX_PANES]?[]u8 = @splat(null), + session_dir_len: ?u16 = null, /// acme's activecol (dat.c:37), by column serial, 0 for none: the /// column last typed in (acme.c:487), clicked in with the select button /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new @@ -4547,7 +4594,7 @@ pub const Pardes = struct { // text under the shell. The shell keeps the keyboard. A boot // layout, like .classic's, not a placement. if (p.boot == .tty) { - const np = try p.newScratch(0, 1); + const np = try p.newScratch(0, 1, .scratch); layout.insert(p, 0, 1, 1); layout.splitBelow(p, 0, np); } @@ -4618,6 +4665,7 @@ pub const Pardes = struct { if (p.last_dump) |d| gpa.free(d); if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); + for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir); if (p.pipe.wait) |*wait| wait.deinit(gpa); p.locations_cache.deinit(p.tree_sitter_gpa); p.fs.deinit(gpa); @@ -4760,20 +4808,29 @@ pub const Pardes = struct { pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); - if (cwd.len > effect_path_cap) return error.PathTooLong; + if (cwd.len > limits.host_path_cap) return error.PathTooLong; const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); - p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } }); + p.emitSpawn(id, pane.serial, cwd); return pane; } + /// A child for pane `id` in `cwd`: a directory up to PATH_MAX, the ones + /// past the effect's inline buffer kept here for perform to give. + pub fn emitSpawn(p: *Pardes, id: usize, serial: u32, cwd: []const u8) void { + if (p.spawn_dirs[id]) |old| p.gpa.free(old); + p.spawn_dirs[id] = null; + if (cwd.len > effect_path_cap) p.spawn_dirs[id] = p.gpa.dupe(u8, cwd) catch null; + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = serial, .cwd = .from(if (cwd.len > effect_path_cap) "" else cwd) } }); + } + /// A command pane: a terminal whose child is `$SHELL -c line` rather /// than an interactive shell, left showing its output and `exit N` when /// it is done (exec.zig runCommand). pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); - if (cwd.len > effect_path_cap) return error.PathTooLong; + if (cwd.len > limits.host_path_cap) return error.PathTooLong; const owned = try p.gpa.dupe(u8, line); errdefer p.gpa.free(owned); const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); @@ -4784,7 +4841,7 @@ pub const Pardes = struct { // directory's commands reuse it; the child's own cd does not move it. pane.setOwnedCwd(cwd) catch {}; p.installPane(id, pane); - p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } }); + p.emitSpawn(id, pane.serial, cwd); return pane; } @@ -4795,12 +4852,19 @@ pub const Pardes = struct { return pane; } - fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane { + fn newScratch(p: *Pardes, from_id: usize, free: usize, kind: exec.NewPane) !*Pane { const src = p.panes[from_id] orelse return error.MissingPane; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); - const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); - np.cwd = .{ .inherited = src }; + // A terminal that has not said where it is yet (the boot layout's) + // has no directory: the scratch is the session's, <cwd>/+New. So is + // 9P's pane/new, and a New from a column's or the workspace's tag: + // no pane asked for it, whichever last had the keyboard (acme's new + // window has acme's directory). + const own = kind != .fs and !p.exec_header; + const dir = if (own) paneDir(src) else ""; + const np = try panes.Output.open(p, free, if (dir.len == 0) p.sessionDir() else dir, .{ .cmd = .New }, "", content); + if (own) np.cwd = .{ .inherited = src }; np.body.cur_pinned = true; return np; } @@ -4814,7 +4878,7 @@ pub const Pardes = struct { /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`). pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void { const free = p.freeSlot() orelse return p.reportError(from_id, "New", error.NoPaneSlots); - _ = p.newScratch(from_id, free) catch return; + _ = p.newScratch(from_id, free, kind) catch return; exec.placeNew(p, from_id, from_id, free, kind); } @@ -4822,7 +4886,14 @@ pub const Pardes = struct { /// on its tag, for the next pane made to fill. pub fn newColumn(p: *Pardes, from_id: usize) void { const col = p.keyboardColumn(from_id) orelse return; - const c = layout.insertColumn(p, col, false) orelse return; + // Said, not silently nothing: "no space" is 9ns's word for ENOSPC. + const c = layout.insertColumn(p, col, false) orelse { + var said: [96]u8 = undefined; + return p.reportFailure(from_id, if (p.ncol >= MAX_COLS) + std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column" + else + "Newcol: no space for a column: this one is too narrow to split"); + }; tagline.enterHeader(p, c); } @@ -4930,7 +5001,7 @@ pub const Pardes = struct { pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); - return if (selected.len == 0) config.default_shell else selected; + return if (selected.len == 0) config.defaultShell() else selected; } pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { @@ -5111,6 +5182,11 @@ pub const Pardes = struct { return ev; } + pub fn hostForegroundName(p: *const Pardes, id: usize, buf: []u8) ?[]const u8 { + const f = p.host.vtable.fg_name orelse return null; + return f(p.host.ctx, @intCast(id), buf); + } + pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { const f = p.host.vtable.tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); @@ -5151,8 +5227,9 @@ pub const Pardes = struct { var what: [limits.host_path_cap + 8]u8 = undefined; const operation = std.fmt.bufPrint(&what, "Dump {s}", .{path}) catch "Dump"; p.reportError(p.active, operation, err); - var buf: [256]u8 = undefined; - const said = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch "Dump failed"; + // In the words the message row has, so the write's err is the one + // record of it (its msg goes: 9p_io, dropMessage). + const said = if (p.panes[p.active]) |pane| pane.msg[0..pane.msg_len] else "Dump failed"; const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); @memcpy(p.fs.late_failure[0..kept.len], kept); p.fs.late_failure_len = @intCast(kept.len); @@ -5184,7 +5261,7 @@ pub const Pardes = struct { switch (e) { // The host reads the pane (its command, its shell) as it forks, // so the slot must still hold the pane the spawn was for. - .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, if (s.cwd.len == 0) (p.spawn_dirs[s.pane] orelse "") else s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its @@ -5207,9 +5284,13 @@ pub const Pardes = struct { p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; - ctlfs.events.noteLog(p, .save, saved_pane); const saved = if (saved_pane.file) |*file| file else return; - if (saved.saved_revision != saved.revision or !saved.watch_after_save) return; + // A save the host could not do (saveFailed) is its err + // record alone, not a `save`. + if (saved.saved_revision != saved.revision) return; + saved.disk_gone = false; + ctlfs.events.noteLog(p, .save, saved_pane); + if (!saved.watch_after_save) return; saved.watch_after_save = false; if (filesystem.localPath(saved.path) != null) p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } }); @@ -5316,12 +5397,14 @@ pub const Pardes = struct { /// that changes a pane costs a frame, which is why a round trip on a /// local socket costs microseconds and not a vsync. pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply { - if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req); + if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req); p.needs_frame = true; p.raw_hover_intent = false; look.cancelLookHover(p); + p.fs.serving = true; const reply = ctlfs.handle(p, req); - if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NFILE) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply); + p.fs.serving = false; + if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NOSPC) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -5566,6 +5649,8 @@ pub const Pardes = struct { const asked = p.panes[id] orelse return null; switch (asked.prompt) { .del_side => return "k j", + // Any path; `-` takes it back. + .save => return "<path>", .repl_choice => { const ask = p.repl_ask orelse return null; var w = std.Io.Writer.fixed(buf); @@ -5579,8 +5664,17 @@ pub const Pardes = struct { } } + /// Answers the pane's question: a choice it offers, else (null, or a + /// key it does not offer) takes it back. The log says which, as + /// `answer <serial> <choice|->`, after the `ask` it answers. pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool { const asked = p.panes[id] orelse return false; + var offered: [64]u8 = undefined; + if (p.questionChoices(id, &offered)) |choices| { + const chosen: ?u8 = if (choice) |c| (if (std.mem.indexOfScalar(u8, choices, c) != null and c != ' ') c else null) else null; + var rec: [32]u8 = undefined; + ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {c}", .{ asked.serial, chosen orelse '-' }) catch ""); + } switch (asked.prompt) { .repl_choice => { asked.prompt = .none; @@ -5768,6 +5862,8 @@ pub const Pardes = struct { // A column tag's command acts on that column, empty or not. p.exec_column = if (header != null) p.header_column else null; defer p.exec_column = null; + p.exec_header = header != null; + defer p.exec_header = false; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. if (header != null) tagline.exitHeader(p) else pane.focus = .body; @@ -5870,6 +5966,23 @@ pub const Pardes = struct { // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- + /// Where pardes started: the directory of what runs from a header. + pub fn sessionDir(p: *Pardes) []const u8 { + if (p.session_dir_len) |n| return p.session_dir[0..n]; + if (comptime !hosted) return "/"; + if (std.c.getcwd(&p.session_dir, p.session_dir.len) == null) return "/"; + const n: u16 = @intCast(std.mem.indexOfScalar(u8, &p.session_dir, 0) orelse return "/"); + p.session_dir_len = n; + return p.session_dir[0..n]; + } + + /// The directory a word run from pane `id`'s text or tag runs in, or, + /// from a header, the session's. + pub fn execDir(p: *Pardes, id: usize) []const u8 { + if (p.exec_header) return p.sessionDir(); + return paneDir(p.panes[id] orelse return p.sessionDir()); + } + pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), @@ -5896,6 +6009,15 @@ pub const Pardes = struct { p.fs.refused = true; return; } + // Asked for over 9P (Save from an exec on a terminal): a question + // the log says, `ask <serial> save path`, answered on the pane's + // ctl with `answer <path>` or `answer -`; asked again, the one + // already open stands, not a second on top of it. + if (request == .save and p.fs.serving) { + if (pane.prompt == .save) return; + var rec: [32]u8 = undefined; + ctlfs.events.notePath(p, "ask", std.fmt.bufPrint(&rec, "{d} save path", .{pane.serial}) catch ""); + } const marker = switch (request) { .search => |marker| marker, .save => config.save_marker, @@ -6384,7 +6506,26 @@ pub const Pardes = struct { /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. + /// The rows pane `id`'s tag takes at its width, wrapped as `sync` wraps + /// it: what a placement must leave it above its body rows. + pub fn wantedTagRows(p: *Pardes, id: usize) u16 { + const pane = p.panes[id] orelse return BOX_H; + if (pane.collapsed or !pane.tag_expanded) return BOX_H; + if (p.rects[id].w == 0) return pane.tag_rows; // never laid out + var wrap_room: [32 * 1024]u8 = undefined; + var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room); + const wa = wrap_fba.allocator(); + const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows; + const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows; + const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows; + return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS))); + } + pub fn sync(p: *Pardes) void { + while (p.unplaced.findFirstSet()) |id| { + p.unplaced.unset(id); + if (p.panes[id] != null) p.removePane(id, null) catch {}; + } tagline.flattenHeaders(p); if (p.header_column != null and p.columnBarHeight() == 0) { tagline.exitHeader(p); @@ -6466,8 +6607,14 @@ pub const Pardes = struct { pane.tag_top = @intCast(@min(top, lines - pane.tag_rows)); } else pane.tag_top = 0; const cols = @max(1, r.w -| config.GUTTER); - const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows - if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) { + // The tag steals the top rows. The pty is what the pane shows + // (layout keeps a terminal two body rows where the window has + // them): a taller pty would put its cursor below the pane. + const rows = @max(1, r.h -| pane.tag_rows); + // A terminal's pty follows its pane down to a row, whatever the + // resize; only a folded one keeps the size it had, and one not + // placed yet the grid it was made with. + if ((cols != pane.cols or rows != pane.rows) and (r.h > pane.tag_rows or (pane.isTerminal() and !pane.collapsed and r.h > 0))) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { @@ -6518,9 +6665,10 @@ pub const Pardes = struct { const wait = Messages.messageWake(p, pane) orelse continue; frames = if (frames) |soonest| @min(soonest, wait) else wait; }; - // A paste's Enter waiting on its REPL goes at the end of its wait. + // A paste's Enter waiting on its REPL goes at the end of its wait, + // a time on the clock: the frames from `stepped_ns` that reach it. for (p.panes) |slot| if (slot) |pane| if (pane.repl_enter > 0) { - const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns) / animation.frame_ns); + const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns + animation.frame_ns - 1) / animation.frame_ns); frames = if (frames) |soonest| @min(soonest, wait) else wait; }; return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null; @@ -7038,7 +7186,10 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { try std.testing.expectEqual(low, p.active); // focus follows the rows // Esc takes the question back and the pane stays; so does any other key, - // and so does a click. + // and so does a click. (Top gets rows back first: split at its cursor, + // it kept its tag and two, too few to give a pane below its own.) + p.panes[top].?.vweight = p.panes[low].?.vweight; + p.sync(); p.newScratchBelow(top); const again = p.active; p.sync(); @@ -7091,10 +7242,12 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[asked].?.prompt); try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status); try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none); + try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\n", .{serial}))); p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status); try std.testing.expect(p.paneBySerial(serial) == null); + try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} k\n", .{serial}))); } // Bare Del from anything but a key closes at once and gives the rows up, @@ -7874,3 +8027,48 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { p.advance(p.stepped_ns + frame); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); } + +test "a pane closed while its question stands ends the question in the log" { + const th = @import("ninep/testing.zig"); + const p = try th.withFile(std.testing.allocator, "x\n"); + defer p.deinit(); + p.settings.placement = .pardes; + p.newScratchBelow(0); + const closing = p.active; + p.newScratchBelow(closing); + p.sync(); + p.active = closing; // a middle pane: Del from a key asks which side + const serial = p.panes[closing].?.serial; + p.update(.{ .key = .{ .cp = ' ' } }); + p.update(.{ .key = .{ .cp = 'd' } }); + try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt); + // A bare answer says what this question takes. + try std.testing.expectEqualStrings("answer takes k j or -", th.wr(p, ctlfs.Node.of(serial, .ctl), "answer\n").reply.ename); + try p.removePane(closing, null); + var want: [64]u8 = undefined; + try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ serial, serial }))); +} + +test "a Save the host could not do logs no save record" { + const th = @import("ninep/testing.zig"); + const p = try th.withFile(std.testing.allocator, "x\n"); + defer p.deinit(); + const Failing = struct { + core: *Pardes, + const vtable: Host.VTable = .{ .write_file = write }; + fn write(ctx: ?*anyopaque, pane: u8, path: []const u8, _: []const u8) void { + const f: *@This() = @ptrCast(@alignCast(ctx.?)); + f.core.saveFailed(pane, path, error.AccessDenied); + } + }; + var host: Failing = .{ .core = p }; + p.host = .{ .ctx = &host, .vtable = &Failing.vtable }; + _ = th.wr(p, ctlfs.Node.of(p.panes[0].?.serial, .body), "more\n"); + p.perform(.{ .save_file = .{ .pane = 0 } }); + try std.testing.expect(!th.logHas(p, "\nsave ")); + try std.testing.expect(p.panes[0].?.file.?.saved_revision != p.panes[0].?.file.?.revision); + // With the host's write done, it is logged. + p.host = .{}; + p.perform(.{ .save_file = .{ .pane = 0 } }); + try std.testing.expect(th.logHas(p, "\nsave ")); +} |
