summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig256
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 "));
+}