summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig799
1 files changed, 665 insertions, 134 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 5fa3307b..f559b125 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -20,6 +20,8 @@ pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
pub const draw = @import("draw.zig");
pub const Messages = @import("Messages.zig");
+pub const Registers = @import("Registers.zig");
+pub const Macro = @import("Macro.zig");
pub const Pipe = @import("Pipe.zig");
pub const colors = @import("colors.zig");
pub const Theme = colors.Theme;
@@ -455,6 +457,8 @@ test {
_ = @import("regexp.zig");
_ = @import("edit.zig");
_ = @import("Messages.zig");
+ _ = @import("Registers.zig");
+ _ = @import("Macro.zig");
_ = @import("selection_pipe.zig");
_ = @import("Pipe.zig");
_ = @import("colors.zig");
@@ -555,7 +559,7 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le
p.perform(.{ .detach = asked });
const pane = p.panes[0].?;
- try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqualStrings("detach: not attached", pane.msg[0..pane.msg_len]);
try std.testing.expect(!p.quit);
}
@@ -626,8 +630,9 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" {
while (p.nextEffect()) |_| {} // initial shell spawn/geometry
p.acknowledgeShell(0, "/bin/sh", false);
+ // The Tty word names the shell the host ran, and opens another of it.
const pane = p.panes[0].?;
- try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(pane));
+ try std.testing.expectEqualStrings(" Tty+sh" ++ tagline.terminal_pane_tail[" Tty".len..], tagline.curTail(pane));
const tag_x = p.rects[0].x + TAG_TEXT_INSET;
const tag_y = p.rects[0].y;
@@ -649,6 +654,7 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" {
};
try std.testing.expectEqual(panes_before + 1, panes_after);
try std.testing.expect(p.panes[p.active].?.isTerminal());
+ try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?);
// Argument-taking builtins use the same gesture path: a tag sweep is the
// complete command line, and Theme consumes its tail without a PTY write.
@@ -1186,7 +1192,7 @@ test "first document fallback splits like Tty from the originating pane" {
const free = p.freeSlot().?;
const doc = try p.openTutorView(free);
exec.placeDoc(p, from, free, doc);
- exec.spawnTty(tty, from);
+ _ = exec.spawnTty(tty, from);
p.sync();
tty.sync();
try std.testing.expectEqual(@as(usize, 2), p.ncol);
@@ -1277,34 +1283,35 @@ test "an unsaved file marker sits between its path and builtins until Save" {
try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null);
}
-test "unknown Exec from an image writes to a terminal in the image directory" {
+test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
defer p.deinit();
while (p.nextEffect()) |_| {}
p.acknowledgeShell(0, "/bin/sh", false);
+ // A terminal free at a prompt in the same directory is not borrowed.
p.setCwd(0, "/tmp/pardes-image-dir");
const image_doc = try p.newDocPane(1);
image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc));
- try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 1, "echo image-fallback"));
+ const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun;
+ try std.testing.expect(dst != 0 and dst != 1);
+ try std.testing.expectEqualStrings("echo image-fallback", p.panes[dst].?.command.?);
- var sent: [256]u8 = undefined;
- var sent_len: usize = 0;
+ var spawned = false;
while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- try std.testing.expectEqual(@as(u8, 0), w.pane);
- try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
+ .write => return error.TypedIntoATerminal,
+ .spawn => |sp| if (sp.pane == dst) {
+ try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice());
+ spawned = true;
},
- .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal,
else => {},
};
- try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
+ try std.testing.expect(spawned);
}
+
const FakeTtyQuery = struct {
taken: [MAX_PANES]bool = @splat(false),
asked: usize = 0,
@@ -1322,7 +1329,7 @@ const FakeTtyQuery = struct {
}
};
-test "Exec in a terminal whose tty is taken spawns a shell instead of typing at the program" {
+test "Exec in a terminal whose tty is taken runs as a command pane instead of typing at the program" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
defer p.deinit();
@@ -1335,42 +1342,384 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at
host.install(p);
host.taken[0] = true;
const dst = exec.execute(p, 0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun;
- // somewhere ELSE — and still without moving focus, which is execute's
+ // somewhere ELSE -- and still without moving focus, which is execute's
// contract and the whole difference between it and a look
try std.testing.expect(dst != 0);
try std.testing.expectEqual(@as(usize, 0), p.active);
-
- var spawned: ?Effect = null;
- var sent: [256]u8 = undefined;
- var sent_len: usize = 0;
+ try std.testing.expectEqualStrings("echo taken-fallback", p.panes[dst].?.command.?);
+ var spawned = false;
while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty;
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
+ .write => return error.WroteACommandLineIntoATerminal,
+ .spawn => |sp| if (sp.pane == dst) {
+ // ...in the directory the command was about, the taken pane's own
+ try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice());
+ spawned = true;
},
- .spawn => spawned = effect,
else => {},
};
- const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn;
- try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane);
- // ...in the directory the command was about, which is the taken pane's own
- try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice());
- try std.testing.expectEqual(@as(usize, 0), sent_len);
- p.acknowledgeShell(dst, "/bin/sh", false);
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
- },
- else => {},
+ try std.testing.expect(spawned);
+}
+
+test "a command of several lines keeps its tag one row, and its echo does not staircase" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const doc = try p.newDocPane(1);
+ doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pic.ppm") };
+ const two = "def greet(name):\n return name\n\nprint(greet(\"x\"))\n";
+ const dst = exec.execute(p, 1, two) orelse return error.NoCommandPane;
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[dst].?;
+ const prefix = try tagline.tagPrefix(p, pane);
+ try std.testing.expect(std.mem.indexOfScalar(u8, prefix, '\n') == null);
+ try std.testing.expect(std.mem.indexOf(u8, prefix, "(def greet(name):...) running") != null);
+ // Rendered without a panic, however the tag wraps.
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 0 } });
+ _ = exec.execute(p, 1, two);
+ while (p.nextEffect()) |_| {}
+ _ = try p.render(frame.allocator());
+ const body = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(body);
+ try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null);
+}
+
+test "a spawn is for the pane it was made for, not whatever took the slot since" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const Count = struct {
+ var spawns: usize = 0;
+ var command: [32]u8 = undefined;
+ var core: *Pardes = undefined;
+ const vtable: Host.VTable = .{ .spawn = spawn };
+ fn spawn(_: ?*anyopaque, pane: u8, _: []const u8) void {
+ spawns += 1;
+ const line = core.panes[pane].?.command.?;
+ @memcpy(command[0..line.len], line);
+ }
+ };
+ Count.core = p;
+ p.host = .{ .vtable = &Count.vtable };
+ // A command pane closed and its slot taken, in one step, before the
+ // effects run: only the second's spawn is performed.
+ const slot = p.freeSlot().?;
+ _ = try p.newCommand(slot, "", "first");
+ try p.removePane(slot, null);
+ try std.testing.expectEqual(slot, p.freeSlot().?);
+ _ = try p.newCommand(slot, "", "second");
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expectEqual(@as(usize, 1), Count.spawns);
+ try std.testing.expectEqualStrings("second", Count.command[0.."second".len]);
+}
+
+test "a REPL bound for a language takes the exec made on a body in it; tags, Exec by name and command words still run" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const file = try p.setTestFile("print(1)\nls\n");
+ gpa.free(file.file.?.path);
+ file.file.?.path = try gpa.dupe(u8, "/x.py");
+ const Sent = struct {
+ var to: [MAX_PANES][128]u8 = undefined;
+ var len: [MAX_PANES]usize = @splat(0);
+ var spawned: bool = false;
+ fn drain(pp: *Pardes) void {
+ len = @splat(0);
+ spawned = false;
+ while (pp.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ @memcpy(to[w.pane][len[w.pane]..][0..w.bytes.slice().len], w.bytes.slice());
+ len[w.pane] += w.bytes.slice().len;
+ },
+ .spawn => spawned = true,
+ else => {},
+ };
+ }
+ fn said(pane: usize) []const u8 {
+ return to[pane][0..len[pane]];
+ }
+ };
+ // The programs the REPLs run hold their terminals' ttys.
+ var host: FakeTtyQuery = .{};
+ host.install(p);
+ host.taken[1] = true;
+ host.taken[2] = true;
+ // Only a terminal is a REPL; a language's name is the syntax table's.
+ try std.testing.expect(p.executeBuiltinLine(0, "Repl python"));
+ try std.testing.expect(file.repl == null);
+ try std.testing.expect(p.executeBuiltinLine(1, "Repl pithon"));
+ try std.testing.expect(p.panes[1].?.repl == null);
+ try std.testing.expect(std.mem.indexOf(u8, p.panes[1].?.msg[0..p.panes[1].?.msg_len], "python") != null);
+ try std.testing.expect(p.executeBuiltinLine(1, "Repl python"));
+ // again, as a code fence may name it: no change
+ try std.testing.expect(p.executeBuiltinLine(1, "Repl PY"));
+ var idbuf: [48]u8 = undefined;
+ try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?));
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, p.panes[1].?), " python-a"));
+ Sent.drain(p);
+
+ // A click on the file's body, a single word: typed into the REPL.
+ try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 0, "ls", true));
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("ls\r", Sent.said(1));
+ try std.testing.expect(!Sent.spawned);
+ try std.testing.expect(file.msg_sent);
+ try std.testing.expectEqualStrings("\u{2192} python-a", file.msg[0..file.msg_len]);
+ // A body's click written back to event is done as the click: sent. A
+ // line written to exec came back as F and runs as the command it was.
+ {
+ const th = @import("ninep/testing.zig");
+ const event = ctlfs.Node.of(file.serial, .event);
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle;
+ defer _ = th.call(p, .{ .tag = 2, .op = .release, .node = event, .handle = h });
+ // The 9P harness performs the effects: what it wrote is its answer's.
+ const sent = th.wr(p, event, "MX0 8\n");
+ try std.testing.expectEqualStrings("print(1)\r", sent.pty_buf[0..sent.pty_len]);
+ const ran = th.wr(p, event, "FX0 0 2 2 ls\n");
+ try std.testing.expect(ran.pty_len == 0 and ran.spawned);
+ // The pane's ctl line ends with the id it is bound as.
+ var line: [ctlfs.ctl.pane_text_cap]u8 = undefined;
+ try std.testing.expect(std.mem.endsWith(u8, ctlfs.ctl.paneText(p, p.panes[1].?, &line), " python-a "));
+ }
+ // What is sent is plain text: CRLF is LF, no ESC or other control.
+ _ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1));
+ // Several selections executed at once: each is sent, not the first.
+ {
+ const text = try edit.flatSurface(p, &file.body);
+ file.body.setRanges(text, &.{ .{ .anchor = 0, .head = 8 }, .{ .anchor = 9, .head = 11 } }, &.{}, 0, true);
+ try std.testing.expect(exec.chordEachSel(p, file, .Exec));
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("print(1)\rls\r", Sent.said(1));
+ }
+ // Its own body too.
+ try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 1, "x = 1", true));
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("x = 1\r", Sent.said(1));
+
+ // Escapes: a tag's word, Exec by name, a command word. Each a command.
+ _ = exec.execute(p, 0, "ls");
+ Sent.drain(p);
+ try std.testing.expect(Sent.spawned and Sent.len[1] == 0);
+ try std.testing.expect(p.executeBuiltinLine(0, "Exec ls"));
+ Sent.drain(p);
+ try std.testing.expect(Sent.spawned and Sent.len[1] == 0);
+ _ = exec.executeFrom(p, 0, "@`ls`", true);
+ Sent.drain(p);
+ try std.testing.expect(Sent.spawned and Sent.len[1] == 0);
+
+ // Two bound: it asks, one key answers, Esc sends nowhere.
+ try std.testing.expect(p.executeBuiltinLine(2, "Repl python"));
+ try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?));
+ Sent.drain(p);
+ try std.testing.expectEqual(@as(?usize, null), exec.executeFrom(p, 0, "print(2)", true));
+ try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt);
+ try std.testing.expect(std.mem.indexOf(u8, file.promptText().?, "a b") != null);
+ p.update(.{ .key = .{ .cp = 'b' } });
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("print(2)\r", Sent.said(2));
+ try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none);
+ _ = exec.executeFrom(p, 0, "print(3)", true);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ Sent.drain(p);
+ try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0);
+ // The pane a letter offered is the one it answers for: unbound while
+ // the question stood, it is sent nothing, and that is said.
+ _ = exec.executeFrom(p, 0, "print(4)", true);
+ try std.testing.expect(p.executeBuiltinLine(2, "Repl -"));
+ p.update(.{ .key = .{ .cp = 'b' } });
+ Sent.drain(p);
+ try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0);
+ try std.testing.expectEqualStrings("Repl: the REPL chosen is gone; nothing sent", file.msg[0..file.msg_len]);
+ try std.testing.expect(p.executeBuiltinLine(2, "Repl python"));
+ try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?));
+
+ // A REPL whose program has left (Ctrl-D: the shell has the tty) is
+ // sent nothing, and nothing runs: it says so.
+ host.taken[1] = false;
+ host.taken[2] = false;
+ _ = exec.executeFrom(p, 0, "import os", true);
+ Sent.drain(p);
+ try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0 and !Sent.spawned);
+ try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "no program running") != null);
+ host.taken[1] = true;
+ host.taken[2] = true;
+
+ // Unbound, its letter is free again; bare, it says what it is.
+ try std.testing.expect(p.executeBuiltinLine(1, "Repl -"));
+ try std.testing.expect(p.panes[1].?.repl == null);
+ try std.testing.expect(p.executeBuiltinLine(1, "Repl python"));
+ try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?));
+ try std.testing.expect(p.executeBuiltinLine(2, "Repl"));
+ try std.testing.expectEqualStrings("Repl: python-b", p.panes[2].?.msg[0..p.panes[2].?.msg_len]);
+
+ // A command pane is a REPL while its command runs: done, it is none,
+ // and a done one cannot be bound. A document pane is no terminal.
+ const cmd = p.freeSlot().?;
+ _ = try p.newCommand(cmd, "", "python3");
+ try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python"));
+ try std.testing.expect(p.panes[cmd].?.repl != null);
+ p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 0 } });
+ try std.testing.expect(p.panes[cmd].?.repl == null);
+ try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python"));
+ try std.testing.expect(p.panes[cmd].?.repl == null);
+ const doc_slot = p.freeSlot().?;
+ const doc = try p.newDocPane(doc_slot);
+ try std.testing.expect(p.executeBuiltinLine(doc_slot, "Repl python"));
+ try std.testing.expect(doc.repl == null);
+}
+
+test "a restored terminal's tag names neither the REPL it was nor the shell it had" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ p.acknowledgeShell(0, "/usr/bin/fish", true);
+ var host: FakeTtyQuery = .{};
+ host.install(p);
+ try std.testing.expect(p.executeBuiltinLine(0, "Repl python"));
+ try dump.dumpState(p);
+ const restored = try dump.restore(p, p.dump_out.?, "test");
+ defer restored.deinit();
+ const pane = restored.panes[0].?;
+ try std.testing.expect(pane.repl == null and pane.tag.own == null);
+ const shown = try tagline.tagText(restored, restored.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, shown, "python-a") == null);
+ try std.testing.expect(std.mem.indexOf(u8, shown, "Tty+fish") == null);
+}
+
+test "Tty+fish, one word a tag can hold, opens a terminal on that shell" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ // sh, the one shell every host has.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh"));
+ try std.testing.expect(p.active != 0);
+ try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?);
+ p.acknowledgeShell(p.active, "/bin/sh", false);
+ try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+sh "));
+ p.active = 0;
+ // A shell that is not there is said, not quietly replaced, and the
+ // pane that asked gets no shell of its own.
+ const before = p.active;
+ const caller = p.panes[0].?;
+ try std.testing.expect(p.executeBuiltinLine(0, "Tty /nonexistent"));
+ try std.testing.expect(p.active == before and caller.shell == null);
+ 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);
+ // 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"));
+}
+
+test "a command pane shows how its command ended, and the next command there runs below it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{});
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ p.setCwd(0, "/tmp/pardes-cmd");
+ const doc = try p.newDocPane(1);
+ doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-cmd/pic.ppm") };
+
+ const dst = exec.execute(p, 1, "make -j8") orelse return error.NoCommandPane;
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[dst].?;
+ // Its first command is echoed as every later one is.
+ {
+ const first = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(first);
+ try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8"));
+ }
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) running"));
+ // Running, a second command goes to a pane of its own.
+ const other = exec.execute(p, 1, "ls") orelse return error.NoCommandPane;
+ try std.testing.expect(other != dst);
+ while (p.nextEffect()) |_| {}
+ p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } });
+ // Its exit comes first, a job it left still printing: done, and not
+ // the next command's until its pty closes.
+ p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 2 } });
+ try std.testing.expect(pane.command_done and pane.command_pty);
+ const busy = exec.execute(p, 1, "true") orelse return error.NoCommandPane;
+ try std.testing.expect(busy != dst);
+ while (p.nextEffect()) |_| {}
+ // Its output ending: now it may be reused.
+ p.update(.{ .eof = .{ .pane = @intCast(dst) } });
+ try std.testing.expect(p.panes[dst] == pane and !pane.command_pty);
+ try std.testing.expect(p.panes[dst] == pane);
+ try std.testing.expect(pane.command_done);
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) exit 2"));
+ // Kill leaves a finished one be.
+ try std.testing.expect(p.executeBuiltinLine(0, "Kill make"));
+ while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) return error.SignalledAFinishedCommand;
+ // A program that crashed out of its full screen leaves the emulator
+ // in its modes; the next command starts clear of them.
+ p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } });
+ try std.testing.expect(panes.Terminal.bracketedPaste(pane));
+ // Done, the directory's next command runs there, below what it showed.
+ try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test"));
+ try std.testing.expect(!panes.Terminal.bracketedPaste(pane));
+ var respawned = false;
+ while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) {
+ respawned = true;
+ };
+ try std.testing.expect(respawned);
+ const body = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(body);
+ const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost;
+ const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost;
+ const next = std.mem.indexOf(u8, body, "% make test") orelse return error.NoSeparator;
+ try std.testing.expect(said < ended and ended < next);
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make test) running"));
+ // Kill stops a running one: its whole process group, SIGTERM.
+ try std.testing.expect(p.executeBuiltinLine(0, "Kill make"));
+ var signalled = false;
+ while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) {
+ try std.testing.expectEqual(PtySignal.term, effect.signal_pty.sig);
+ signalled = true;
};
- try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]);
+ try std.testing.expect(signalled);
+ // The log says what ran and how it ended.
+ const log = try ctlfs.events.freezeLog(p);
+ defer gpa.free(log.bytes);
+ var want: [64]u8 = undefined;
+ // The whole line, after the `new` of the pane it runs in.
+ const ran = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make -j8\n", .{pane.serial})) orelse return error.NoRunRecord;
+ const made = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} ", .{pane.serial})) orelse return error.NoNewRecord;
+ try std.testing.expect(made < ran);
+ try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "exit {d} 2\n", .{pane.serial})) != null);
+ // A signal for a pane is not performed on whoever takes its slot.
+ const Count = struct {
+ var signals: usize = 0;
+ const vtable: Host.VTable = .{ .pty_signal = signal };
+ fn signal(_: ?*anyopaque, _: u8, _: PtySignal) void {
+ signals += 1;
+ }
+ };
+ const host_before = p.host;
+ p.host = .{ .vtable = &Count.vtable };
+ try std.testing.expect(p.executeBuiltinLine(0, "Kill make"));
+ try p.removePane(dst, null);
+ _ = try p.newCommand(dst, "", "other");
+ while (p.nextEffect()) |effect| if (effect == .signal_pty) p.perform(effect);
+ try std.testing.expectEqual(@as(usize, 0), Count.signals);
+ p.host = host_before;
+ // A command line longer than a command pane takes is refused.
+ const long = "x" ** (exec.command_max + 1);
+ try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 1, long));
}
+
test "image dump restores source bytes renderer choices and exact custom tail" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -1423,50 +1772,6 @@ test "image dump restores source bytes renderer choices and exact custom tail" {
try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?);
}
-test "Exec from a document pane skips an occupied terminal in its directory and spawns" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{});
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- p.setCwd(0, "/tmp/pardes-image-dir");
- const image_doc = try p.newDocPane(1);
- image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
- var host: FakeTtyQuery = .{};
- host.install(p);
- host.taken[0] = true;
-
- const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun;
- try std.testing.expect(dst != 0 and dst != 1);
-
- var spawned: ?Effect = null;
- var sent: [256]u8 = undefined;
- var sent_len: usize = 0;
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty;
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
- },
- .spawn => spawned = effect,
- else => {},
- };
- const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn;
- try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice());
- try std.testing.expectEqual(@as(usize, 0), sent_len);
- p.acknowledgeShell(dst, "/bin/sh", false);
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
- },
- else => {},
- };
- try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
-}
test "a Look on a directory does not type ls into an occupied terminal" {
const gpa = std.testing.allocator;
@@ -1569,11 +1874,13 @@ test "the host is asked about a tty only where a command line is about to go" {
while (p.nextEffect()) |_| {}
try std.testing.expectEqual(@as(usize, 1), host.asked);
+ // ...and a taken one is asked once too: the line then runs as a command
+ // pane, with no other terminal to ask about.
host.taken[0] = true;
host.asked = 0;
_ = exec.execute(p, 0, "echo lazy-again");
while (p.nextEffect()) |_| {}
- try std.testing.expectEqual(@as(usize, 2), host.asked);
+ try std.testing.expectEqual(@as(usize, 1), host.asked);
}
test "New opens an empty scratch below the caller, inheriting its directory" {
@@ -1626,14 +1933,14 @@ test "owned cwd preserves long paths aliases and failed updates" {
try std.testing.expect(p.panes[free] == null);
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], "PathTooLong") != null);
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null);
const current = pane.cwdSlice();
allocator.fail_index = allocator.alloc_index;
p.setCwd(0, "/replacement");
try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr);
try std.testing.expectEqualStrings(&path, pane.cwdSlice());
- try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null);
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "out of memory") != null);
allocator.fail_index = std.math.maxInt(usize);
p.setCwd(0, "/replacement");
try std.testing.expectEqualStrings("/replacement", pane.cwdSlice());
@@ -1765,7 +2072,7 @@ test "owned cwd restore either copies the directory or preserves the old core" {
for (0..256) |failure| {
allocator.has_induced_failure = false;
allocator.fail_index = allocator.alloc_index + failure;
- const replacement = dump.restore(p, p.dump_out.?) catch {
+ const replacement = dump.restore(p, p.dump_out.?, "test") catch {
try std.testing.expect(allocator.has_induced_failure);
try std.testing.expectEqual(original, p.panes[0].?);
try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice());
@@ -2155,7 +2462,7 @@ test "a host with no methods at all is a complete in-process pardes" {
try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y);
// the clipboard round-trips through the in-process one
- p.yank = try gpa.dupe(u8, "copied");
+ p.registers.put(gpa, '+', "copied", 0, 1, true);
p.perform(.set_clipboard);
try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items);
@@ -2184,7 +2491,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: NoPaneSlots") != null);
+ try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null);
}
test "Font keeps requested and acknowledged faces as separate plain state" {
@@ -2717,14 +3024,14 @@ test "editable workspace and column tags are typed into and persist" {
try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New"));
try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content);
try dump.dumpState(p);
- const restored = try dump.restore(p, p.dump_out.?);
+ const restored = try dump.restore(p, p.dump_out.?, "test");
defer restored.deinit();
try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?);
try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0));
p.gpa.free(p.col_tags[0].own.?);
p.col_tags[0].own = try p.gpa.dupe(u8, "");
try dump.dumpState(p);
- const empty = try dump.restore(p, p.dump_out.?);
+ const empty = try dump.restore(p, p.dump_out.?, "test");
defer empty.deinit();
try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0));
}
@@ -2948,7 +3255,7 @@ test "s in a header selects in the header's own text" {
const text = try edit.flatSurface(p, &p.global_tag);
const got = p.global_tag.ranges(text, 0, &ranges);
try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor
- try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
}
test "Enter in a header runs the word under its cursor, as the execute key does" {
@@ -3076,7 +3383,13 @@ pub const Event = union(enum) {
row_metrics: ?RowMetrics = null,
},
output: struct { pane: u8, bytes: []const u8 },
- eof: struct { pane: u8 },
+ /// The pane's pty reached end of file: its output is over. An
+ /// interactive terminal closes; a command pane waits for `exited`.
+ eof: struct { pane: u8, status: ?u8 = null },
+ /// A command pane's child exited, with this status (null: unknown). It
+ /// is the command's end, whatever still holds its pty (a job it left in
+ /// the background prints on until end of file).
+ exited: struct { pane: u8, status: ?u8 },
lsp_resp: struct { id: u32, rows: ?[]const u8 },
pipe_resp: struct {
id: u32,
@@ -3114,10 +3427,14 @@ pub const WatchMode = enum { reconcile, baseline_disk };
pub const effect_path_cap = 256;
pub const Effect = union(enum) {
- spawn: struct { pane: u8, cwd: Buf(effect_path_cap) },
+ /// A child for the pane in slot `pane` -- the one whose serial is
+ /// `serial`: a pane closed and its slot taken before the effect is
+ /// performed is not the one it was for (perform).
+ spawn: struct { pane: u8, serial: u32, cwd: Buf(effect_path_cap) },
write: struct { pane: u8, bytes: Buf(64) },
resize_pty: struct { pane: u8, cols: u16, rows: u16 },
- signal_pty: struct { pane: u8, sig: PtySignal },
+ /// For the pane whose serial is `serial`, as `spawn` is (perform).
+ signal_pty: struct { pane: u8, serial: u32, sig: PtySignal },
/// The terminal pane in this slot is gone: the shell hangs up its pty
/// and ends the shell, rather than leave it running with nobody to read.
close_pty: struct { pane: u8 },
@@ -3801,6 +4118,9 @@ pub const Pardes = struct {
/// it is dragged instead of once on release.
native_image_patches: bool = false,
quit: bool = false,
+ /// A detached session with no frontend attached: nothing renders, so
+ /// no input ever dismisses a message (Messages.expireUnattended).
+ unattended: bool = false,
haptic: HapticSlot = if (platform == .macos) .none else {},
drag: mouse.Drag = .none,
hover_col: u16 = 0,
@@ -3819,6 +4139,14 @@ pub const Pardes = struct {
multi_on: bool = false,
multi_first: bool = false,
multi_stop: bool = false,
+ /// While a key is replayed across the ranges: which range this pass is,
+ /// in document order, and how many there are. A register value belongs
+ /// to its range by this index.
+ multi_index: usize = 0,
+ multi_count: usize = 1,
+ /// The register the running normal command names (`"<reg>`); 0 for the
+ /// default one.
+ register: u21 = 0,
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
@@ -3839,6 +4167,11 @@ pub const Pardes = struct {
chord_arg: ?[]u8 = null,
/// how many `execute` calls are on the stack — see max_exec_depth
exec_depth: u8 = 0,
+ /// An exec waiting on the REPL question (`Pane.Prompt.repl_choice`):
+ /// the pane it came from, the language, and the text, owned.
+ /// `offered` holds, by letter, the serial of the pane each answer named
+ /// when asked: a letter answered later is that pane or nothing.
+ repl_ask: ?struct { from: u32, lang: u8, text: []u8, offered: [26]u32 = @splat(0) } = null,
/// A key press is being handled: someone is at the keyboard to answer,
/// so a builtin may ask rather than guess. Nothing else sets it -- not
/// a click, a 9P write, a startup line or a pane's shell exiting.
@@ -3901,7 +4234,10 @@ pub const Pardes = struct {
in_head: usize = 0,
in_len: usize = 0,
- yank: ?[]u8 = null,
+ registers: Registers = .{},
+ macro: Macro = .{},
+ /// an edited text nothing kept (a tag's shown text): see edit.retireEditText
+ retired_edit: ?[]u8 = null,
clip_pending: ?edit.ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
@@ -3947,6 +4283,8 @@ pub const Pardes = struct {
.pdf_gpa = pdf_gpa,
.tree_sitter_gpa = tree_sitter_gpa,
.opts = opts,
+ .col_tags = @splat(.{ .what = .column, .gpa = gpa }),
+ .global_tag = .{ .what = .workspace, .gpa = gpa },
.fs = .{},
.screen_w = opts.cols,
.screen_h = opts.rows,
@@ -4055,6 +4393,7 @@ pub const Pardes = struct {
pub fn deinit(p: *Pardes) void {
look.cancelLookHover(p);
+ if (p.repl_ask) |ask| p.gpa.free(ask.text);
for (0..MAX_PANES) |id| p.dropPendingWrite(id);
for (&p.panes) |*slot| if (slot.*) |pane| {
p.teardownPane(pane);
@@ -4071,7 +4410,9 @@ pub const Pardes = struct {
const gpa = p.gpa;
for (&p.col_tags) |*t| t.deinit(gpa);
p.global_tag.deinit(gpa);
- if (p.yank) |y| gpa.free(y);
+ p.registers.deinit(gpa);
+ p.macro.deinit(gpa);
+ if (p.retired_edit) |r| gpa.free(r);
if (p.dump_out) |d| gpa.free(d);
p.fallback.deinit();
if (p.last_dump) |d| gpa.free(d);
@@ -4097,6 +4438,7 @@ pub const Pardes = struct {
const copy = p.gpa.dupe(u8, path) catch return;
if (p.last_dump) |old| p.gpa.free(old);
p.last_dump = copy;
+ ctlfs.events.notePath(p, "dump", path);
}
/// the shell polls this each frame: a pending Restore's dump path, or null
@@ -4181,6 +4523,8 @@ pub const Pardes = struct {
p.shell_rows.dropPane(pane);
for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot);
panes.Terminal.deinitPendingCommand(pane);
+ if (pane.command) |line| p.gpa.free(line);
+ if (pane.shell) |bin| p.gpa.free(bin);
if (pane.image) |*iv| {
iv.deinit(p.image_gpa);
}
@@ -4190,6 +4534,7 @@ pub const Pardes = struct {
pane.body.deinit(p.gpa);
pane.tag.deinit(p.gpa);
pane.input.deinit(p.gpa);
+ pane.jump.deinit(p.gpa);
panes.Terminal.deinitEmulator(pane, p.gpa);
pane.clearCwd();
pane.fs.deinit(p.gpa);
@@ -4222,7 +4567,27 @@ pub const Pardes = struct {
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), .cwd = .from(cwd) } });
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
+ return pane;
+ }
+
+ /// 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;
+ 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);
+ pane.command = owned;
+ pane.command_pty = true;
+ pane.body.mode = .tty;
+ // Its directory is the one it was run for, and says which of the
+ // 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) } });
return pane;
}
@@ -4300,6 +4665,14 @@ pub const Pardes = struct {
if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]);
}
+ /// How many bytes `emitWrite` can take for pane `id` now without
+ /// dropping any: the effects left, then what the pending buffer holds.
+ pub fn writeRoom(p: *const Pardes, id: usize) usize {
+ const pending = limits.pending_write_cap -| p.pending_write_bytes;
+ if (p.pending_write[id] != null) return pending;
+ return (p.effects.len - p.effects_len) * 64 + pending;
+ }
+
fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void {
if (comptime limits.pending_write_cap == 0) return;
if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return;
@@ -4322,6 +4695,8 @@ pub const Pardes = struct {
/// changes it observes, e.g. via /proc/<pid>/cwd before each frame).
pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void {
const pane = p.panes[id] orelse return;
+ // A command pane stays the directory's it was run for (newCommand).
+ if (pane.command != null) return;
const cur = switch (pane.cwd) {
.owned => |dir| dir,
else => "",
@@ -4343,6 +4718,15 @@ pub const Pardes = struct {
if (id < MAX_PANES) if (p.panes[id]) |pane| {
ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran
pane.fs.unmarked = !prompt_marks;
+ // The tag's Tty word names the shell the host ran, and a click
+ // on it (`Tty+fish`) opens another of the same.
+ if (pane.command == null) {
+ const base = std.fs.path.basename(executable);
+ const rest = tagline.terminal_pane_tail[" Tty".len..];
+ if (std.fmt.bufPrint(&pane.shell_tail, " Tty+{s}{s}", .{ base, rest })) |tail| {
+ pane.shell_tail_len = @intCast(tail.len);
+ } else |_| {}
+ }
};
if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks);
if (p.settings.shell.effective.set(executable))
@@ -4557,14 +4941,18 @@ pub const Pardes = struct {
pub fn perform(p: *Pardes, e: Effect) void {
const v = p.host.vtable;
switch (e) {
- .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else {
+ // 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 {
p.fallback.spawned[s.pane] = true;
},
// A pane with no child is silent: nothing invents output on its
// screen, and the bytes are dropped rather than transcribed.
.write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()),
.resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows),
- .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig),
+ // A signal for a pane that closed, its slot retaken in the same
+ // step, would reach another pane's child.
+ .signal_pty => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig),
.close_pty => |c| if (v.close_pty) |f| f(p.host.ctx, c.pane),
.open_link => |u| if (v.open_link) |f|
f(p.host.ctx, u.slice())
@@ -4604,7 +4992,7 @@ pub const Pardes = struct {
}
},
.set_clipboard => {
- const text = p.yank orelse "";
+ const text = p.registers.text(p.gpa, '+') orelse "";
if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text);
},
// No desktop to ask: answer from the in-process clipboard at once,
@@ -4691,6 +5079,10 @@ pub const Pardes = struct {
look.cancelLookHover(p);
const reply = ctlfs.handle(p, req);
if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply);
+ // A pane that joined a column holding only a stillborn scratch takes
+ // it now, before the answer: one read of index after the look shows
+ // the replacement done, and the log has its del with the new.
+ exec.replaceStillborn(p);
// 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
@@ -4707,6 +5099,7 @@ pub const Pardes = struct {
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
p.shell_rows.sweep(p.gpa);
+ Messages.expireUnattended(p);
// An announcement stands in only until its own step says more.
for (p.panes) |slot| if (slot) |pane| {
pane.msg_announcement = false;
@@ -4786,8 +5179,14 @@ pub const Pardes = struct {
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
},
- .eof => |e| p.removePane(e.pane, null) catch |err| {
- if (p.panes[e.pane]) |pane| pane.body.mode = .normal;
+ .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) {
+ exec.commandDone(p, e.pane, e.status);
+ },
+ .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
+ // Output is over; the command is over when its child exits.
+ pane.command_pty = false;
+ } else p.removePane(e.pane, null) catch |err| {
+ pane.body.mode = .normal;
p.reportError(e.pane, "terminal exited; Del retries close", err);
},
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
@@ -4896,11 +5295,42 @@ pub const Pardes = struct {
} else panes.Terminal.enterTty(p, id);
}
+ /// Type `keys` again (a macro, `.`): through the modal handling as
+ /// typed, except that no key leaves the text for pardes's own acts --
+ /// Esc does not hop panes, and Enter and Tab neither look nor execute.
+ pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void {
+ if (p.macro.replaying >= 8) return; // a macro typing itself
+ p.macro.replaying += 1;
+ defer p.macro.replaying -= 1;
+ for (keys) |stored| {
+ var buf: [4]u8 = undefined;
+ p.handleKey(stored.typed(&buf));
+ }
+ }
+
fn handleKey(p: *Pardes, key: Key) void {
+ if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
// A question waiting on a pane takes the next key, whatever mode the
// pane or a header edit is in: that key is its answer.
for (p.panes, 0..) |slot, id| {
const asked = slot orelse continue;
+ if (asked.prompt == .repl_choice) {
+ asked.prompt = .none;
+ const ask = p.repl_ask orelse return;
+ p.repl_ask = null;
+ defer p.gpa.free(ask.text);
+ // Its letter sends it there; anything else takes it back.
+ if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return;
+ const serial = ask.offered[key.cp - 'a'];
+ if (serial == 0) return;
+ // The pane offered, still that REPL and still running: it
+ // may have closed or been rebound while the question stood.
+ if (p.paneBySerial(serial)) |to| if (p.panes[to].?.repl) |r|
+ if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(p, to))
+ return exec.sendToRepl(p, id, to, ask.text);
+ p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent");
+ return;
+ }
if (asked.prompt != .del_side) continue;
asked.prompt = .none;
if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k");
@@ -4934,7 +5364,7 @@ pub const Pardes = struct {
// in it the chord is the program's -- vim's visual block,
// readline's quoted-insert -- and swallowing it would make
// Ctrl-V a black hole in every full-screen application.
- if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text);
+ if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text);
}
return panes.Terminal.forwardKey(p, p.active, key);
}
@@ -5011,8 +5441,8 @@ pub const Pardes = struct {
.search => if (submit)
look.submitSearch(p, p.active)
else if (normal.selRegexArmed(pane)) |_| if (target) |answered|
- normal.applySelRegex(p, pane, answered, "", false),
- .none, .del_side, .name => unreachable,
+ normal.applySelRegex(p, pane, answered, "", .select),
+ .none, .del_side, .repl_choice, .name => unreachable,
}
pane.closePrompt();
if (target) |answered| {
@@ -5022,10 +5452,11 @@ pub const Pardes = struct {
return;
}
edit.handleInsert(p, &pane.input, key);
- if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode);
return;
}
- if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
+ if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key);
+ if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) {
// A header's words are the workspace's and column's commands, so
// Enter runs them there, as the execute key does.
const header_word = t.what == .column or t.what == .workspace;
@@ -5058,32 +5489,59 @@ pub const Pardes = struct {
pane.body.vsel.active = false;
pane.body.msel.active = false;
pane.body.select = false;
- exec.runBuiltin(p, cmd, p.active, "", txt);
+ // The execute key on a body: text a bound REPL may take.
+ exec.runBuiltinFrom(p, cmd, p.active, "", txt, true);
return;
}
}
const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return;
const word = edit.selectionText(p, pane, sel) catch return;
- exec.runBuiltin(p, cmd, p.active, "", word);
+ exec.runBuiltinFrom(p, cmd, p.active, "", word, true);
return;
}
if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return;
// A tag's prefix is read-only: a key whose edit reached into it
// changes nothing, not even where the cursor is.
- const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel };
+ var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined;
+ const kept_n = t.nsel;
+ @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]);
t.refused = false;
defer if (t.refused) {
- t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
+ t.cur_row, t.cur_col, t.vsel, t.msel = kept;
+ t.restoreSels(kept_sels[0..kept_n]);
};
+ const was = t.mode;
switch (t.mode) {
.normal => normal.handleNormal(p, t, key),
- .insert => {
- if (hit(key, config.escape)) return edit.exitInsert(p, t);
- edit.handleInsert(p, t, key);
- },
+ .insert => if (hit(key, config.escape) and !t.reg_wait)
+ edit.exitInsert(p, t)
+ else
+ edit.handleInsert(p, t, key),
.tty => panes.Terminal.forwardKey(p, p.active, key),
}
+ // `.`'s log: the keys of the normal command that entered insert
+ // mode, then everything typed until it is left
+ if (p.macro.repeating) return;
+ const m = &p.macro;
+ switch (was) {
+ .normal => {
+ Macro.append(&m.command, p.gpa, key);
+ if (t.mode == .insert) {
+ m.last_insert.clearRetainingCapacity();
+ m.last_insert.appendSlice(p.gpa, m.command.items) catch {};
+ m.typed_from = m.last_insert.items.len;
+ m.inserting = true;
+ m.command.clearRetainingCapacity();
+ } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity();
+ },
+ .insert => if (m.inserting) {
+ Macro.append(&m.last_insert, p.gpa, key);
+ if (t.mode != .insert) m.inserting = false;
+ },
+ .tty => {},
+ }
}
fn leaderKey(p: *Pardes, key: Key) void {
@@ -5480,7 +5938,10 @@ pub const Pardes = struct {
// ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
/// Close a pane; its rows go to `to`, or where layout.absorbVWeight
- /// sends them when that is null.
+ /// sends them when that is null. Focus on it follows its rows: to the
+ /// pane that grew into them, or to the scratch left standing in an
+ /// emptied column. Every route that closes a pane comes through here or
+ /// removeColumn, so none picks focus for itself, and none by recency.
pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void {
const pane = p.panes[id] orelse return;
// A column outlives its panes. Closing the last one leaves an empty
@@ -5507,10 +5968,11 @@ pub const Pardes = struct {
layout.insert(p, f.col, f.idx + 1, free);
break :heir free;
};
- layout.absorbVWeight(p, id, to);
+ const gainer = layout.absorbVWeight(p, id, to);
+ const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null;
layout.removePane(p, id);
p.panes[id] = null;
- if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse {
+ if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse {
p.quit = true;
p.emit(.quit);
return;
@@ -5519,6 +5981,11 @@ pub const Pardes = struct {
pub fn removeColumn(p: *Pardes, id: usize) !void {
const place = layout.findPane(p, id) orelse return;
+ const had_focus = layout.findPane(p, p.active);
+ const beside = if (had_focus != null and had_focus.?.col == place.col)
+ p.besideColumn(place.col, p.active)
+ else
+ null;
var ids: [MAX_PANES]usize = undefined;
var parents: [MAX_PANES]*Pane = undefined;
const count = p.col_n[place.col];
@@ -5532,7 +5999,7 @@ pub const Pardes = struct {
layout.removePane(p, closed);
p.panes[closed] = null;
}
- if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse {
+ if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse {
p.quit = true;
p.emit(.quit);
return;
@@ -5555,16 +6022,26 @@ pub const Pardes = struct {
return null;
}
- pub fn prevFocus(p: *Pardes, closing: usize) ?usize {
- var i = p.njumps;
- while (i > 0) {
- i -= 1;
- const id = p.jumps[i].pane;
- if (id != closing and p.panes[id] != null) return id;
- }
- for (p.panes, 0..) |slot, k| {
- if (slot != null and k != closing) return k;
+ /// The pane that takes over `id`'s place when column `col` goes: in the
+ /// column layout.removePane gives the width to, the one level with the
+ /// middle of `id`. Asked before the column goes, while rects still hold.
+ fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize {
+ if (p.ncol < 2) return null;
+ const next = if (col > 0) col - 1 else col + 1;
+ const r = p.rects[id];
+ const mid = r.y + r.h / 2;
+ var pick: ?usize = null;
+ for (p.col_panes[next][0..p.col_n[next]]) |k| {
+ if (p.panes[k] == null) continue;
+ pick = k;
+ if (p.rects[k].y +| p.rects[k].h > mid) break;
}
+ return pick;
+ }
+
+ /// Any live pane: the last resort when nothing took the closed one's place.
+ fn anyPane(p: *const Pardes) ?usize {
+ for (p.panes, 0..) |slot, k| if (slot != null) return k;
return null;
}
@@ -6251,6 +6728,7 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
// A word that is not a side closes nothing.
try std.testing.expect(p.executeBuiltinLine(again, "Del x"));
+ try std.testing.expectEqualStrings("Del: takes k, j or nothing", p.panes[again].?.msg[0..p.panes[again].?.msg_len]);
try std.testing.expect(p.panes[again] != null);
// DelAbove is Del k, and a line that is not a key never asks.
try std.testing.expect(p.executeBuiltinLine(again, "DelAbove"));
@@ -6278,6 +6756,59 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expect(p.panes[low] == null);
}
+test "a Look that finds nothing says so" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.newScratchBelow(p.active);
+ const id = p.active;
+ p.sync();
+ look.lookAt(p, id, "/no/such/zzqq");
+ try std.testing.expectEqualStrings("Look: no match for \"/no/such/zzqq\"", p.panes[id].?.msg[0..p.panes[id].?.msg_len]);
+}
+
+test "focus on a closed pane goes to the pane that takes its place, not the one focused before" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 });
+ defer p.deinit();
+ const shell = p.active;
+ p.newScratchColumn(shell);
+ const right = p.active;
+ p.sync();
+ p.newScratchBelow(shell);
+ const low = p.active;
+ p.sync();
+ // The dogfood case: `right` was focused just before `low`, but `shell`
+ // grows into `low`'s rows, so `shell` gets the focus.
+ try std.testing.expect(p.executeBuiltinLine(low, "Del"));
+ p.sync();
+ try std.testing.expect(p.panes[low] == null);
+ try std.testing.expectEqual(shell, p.active);
+
+ // Closing a pane that has no focus leaves the focus where it is.
+ p.newScratchBelow(shell);
+ const other = p.active;
+ p.sync();
+ p.active = right;
+ p.sync();
+ try std.testing.expect(p.executeBuiltinLine(other, "Del"));
+ try std.testing.expectEqual(right, p.active);
+
+ // Delcol: the column that takes the width holds, level with the closed
+ // pane, the pane that takes its place; `right` was focused just before.
+ p.newScratchBelow(right);
+ const right_low = p.active;
+ p.newScratchBelow(shell);
+ const left_low = p.active;
+ p.active = right;
+ p.sync();
+ p.active = left_low;
+ p.sync();
+ try p.removeColumn(left_low);
+ p.sync();
+ try std.testing.expectEqual(right_low, p.active);
+}
+
test "closing the last pane in a column leaves an empty one in its place" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 });
@@ -6431,7 +6962,7 @@ test "raw tty Escape only leaves at a shell prompt" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
defer p.deinit();
- exec.spawnTty(p, 0);
+ _ = exec.spawnTty(p, 0);
const shell = p.active;
const pane = p.panes[shell].?;
var buf: [256]u8 = undefined;
@@ -6462,7 +6993,7 @@ test "raw tty Ctrl-B switches to editor mode without sending child input" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" });
defer p.deinit();
- exec.spawnTty(p, 0);
+ _ = exec.spawnTty(p, 0);
const shell = p.active;
p.sync();
var buf: [256]u8 = undefined;
@@ -6902,7 +7433,7 @@ test "jump history clears selections and lands at the recorded cursor" {
try std.testing.expectEqual(@as(usize, 2), p.njumps);
try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line);
try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select);
- try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
try std.testing.expectEqual(.none, pane.sel[0].state);
try std.testing.expect(pane.pointer_selections[0] == null);
try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row);