summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig200
1 files changed, 114 insertions, 86 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 9fa747cd..1bb13e3d 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1282,34 +1282,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,
@@ -1327,7 +1328,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();
@@ -1340,42 +1341,85 @@ 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 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].?;
+ 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" } });
+ p.update(.{ .eof = .{ .pane = @intCast(dst), .status = 2 } });
+ 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;
+ // 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"));
+ 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;
+ try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make\n", .{pane.serial})) != null);
+ try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "exit {d} 2\n", .{pane.serial})) != null);
+ // 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 });
@@ -1428,50 +1472,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;
@@ -1574,11 +1574,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" {
@@ -3081,7 +3083,9 @@ pub const Event = union(enum) {
row_metrics: ?RowMetrics = null,
},
output: struct { pane: u8, bytes: []const u8 },
- eof: struct { pane: u8 },
+ /// The pane's child hung up its pty; `status` is how it exited, when
+ /// the host could reap it at once (a command pane shows it).
+ eof: struct { pane: u8, status: ?u8 = null },
lsp_resp: struct { id: u32, rows: ?[]const u8 },
pipe_resp: struct {
id: u32,
@@ -4183,6 +4187,7 @@ 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.image) |*iv| {
iv.deinit(p.image_gpa);
}
@@ -4228,6 +4233,25 @@ pub const Pardes = struct {
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.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), .cwd = .from(cwd) } });
+ return pane;
+ }
+
pub fn newDocPane(p: *Pardes, id: usize) !*Pane {
std.debug.assert(p.panes[id] == null);
const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
@@ -4324,6 +4348,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 => "",
@@ -4777,8 +4803,10 @@ 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;
+ .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
+ exec.commandDone(p, e.pane, e.status);
+ } 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),