diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 200 |
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), |
