diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/ninep/events.zig | 11 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 75 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 6 | ||||
| -rw-r--r-- | src/panes.zig | 37 |
6 files changed, 112 insertions, 20 deletions
diff --git a/src/fs-help.txt b/src/fs-help.txt index cc682cf7..5f890d66 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -30,7 +30,7 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more cat $m/pane/$n/event hold it open to own that pane's clicks echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT - exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N, busy or error, once back at a prompt + exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N then its output; or busy, or error printf 'ls\r' > $m/pane/$n/pty/data type into a terminal; pty/status: cols rows busy Pitfalls, one each: @@ -1262,6 +1262,7 @@ pub const Namespace = struct { for (st.os_paths.items) |entry| gpa.free(entry.path); st.os_paths.deinit(gpa); for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes); + for (&st.runs) |*slot| slot.reset(gpa, 0); for (&st.panes) |*pf| pf.deinit(gpa); st.log.deinit(gpa); st.out.deinit(gpa); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 20cdeda4..bd5fecf3 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -209,10 +209,12 @@ pub fn readLog(p: *Pardes, req: Req) Reply { const frozen = slot.bytes orelse &.{}; const out = p.fs.stage(p.gpa); if (!slot.follow or slot.pos < frozen.len) { - const off = @min(slot.pos, frozen.len); + // Until `follow`, the frozen text is a file like any other: `tail -n` + // seeks near its end, `less` seeks back. After it, a stream. + const off = @min(if (slot.follow) slot.pos else req.off, frozen.len); const len = @min(frozen.len - off, req.size); out.appendSlice(p.gpa, frozen[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); - slot.pos += len; + slot.pos = off + len; return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; } const q = &p.fs.log; @@ -671,6 +673,8 @@ test "the log records whether or not anyone reads, and an open that follows wait const past = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len, .size = 8192 }); try testing.expectEqual(Status.ok, past.reply.status); try testing.expectEqual(@as(usize, 0), past.bytes.len); + const tail = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len - early_owned.len, .size = 8192 }); + try testing.expectEqualStrings(early_owned, tail.bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = log, .handle = plain.reply.handle }); // `follow` on an open makes it wait past what it froze, one record a read. @@ -680,7 +684,8 @@ test "the log records whether or not anyone reads, and an open that follows wait try testing.expectEqual(Status.err, call(p, .{ .tag = 7, .op = .write, .node = log, .handle = fh, .data = "tail\n" }).reply.status); try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = log, .handle = fh, .data = "follow\n" }).reply.status); // A stream: after `echo follow >&3` a shell reads at offset 7, and gets - // what comes next, not the frozen text from byte 7. + // what comes next, not the frozen text from byte 7. Before `follow` the + // offset is honoured, so `tail -n` works (checked below on `plain`). const rdf = struct { fn next(pp: *Pardes, node: u64, h: u32, off: u64) th.Answer { return call(pp, .{ .tag = 9, .op = .read, .node = node, .handle = h, .off = off, .size = 8192 }); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index ec2e18e5..a689d87f 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -111,6 +111,10 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { pub const run_slots = 32; +/// The most of a command's output an answer carries: its last this-many +/// bytes, since that is where a failure says what went wrong. +pub const output_cap = 64 * 1024; + pub const Run = struct { /// 0 is a free slot. node: u64 = 0, @@ -129,9 +133,17 @@ pub const Run = struct { /// `exec 3<>run` shares one offset between the write and the read, so /// the offset a read arrives with means nothing (acme's event file and /// Plan 9's kprint read the same way). - read: u8 = 0, + read: usize = 0, + /// The first line: how it ended. answer: [48]u8 = undefined, len: u8 = 0, + /// Then what the command printed, gpa-owned. + output: []u8 = &.{}, + + pub fn reset(slot: *Run, gpa: std.mem.Allocator, node: u64) void { + gpa.free(slot.output); + slot.* = .{ .node = node }; + } }; fn runOf(p: *Pardes, req: Req) ?*Run { @@ -158,9 +170,10 @@ fn answer(slot: *Run, comptime fmt: []const u8, args: anytype) void { pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL); - if (slot.phase == .sent) return tree.failText(req.tag, 16, tree.e_in_use); - slot.* = .{ .node = slot.node }; // a new line on this open: a new answer + if (slot.phase == .sent) return tree.failText(req.tag, E.BUSY, tree.e_in_use); + slot.reset(p.gpa, slot.node); // a new line on this open: a new answer const line = std.mem.trimEnd(u8, req.data, "\r\n"); + if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); const pf = &p.fs.panes[id]; @@ -196,11 +209,21 @@ pub fn readRun(p: *Pardes, req: Req) Reply { .sent => return .{ .tag = req.tag, .status = .again }, .done => {}, } - const text = slot.answer[slot.read..slot.len]; - const n = @min(text.len, req.size); - p.fs.stage(p.gpa).appendSlice(p.gpa, text[0..n]) catch return Reply.fail(req.tag, E.NOMEM); - slot.read += @intCast(n); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; + // The answer line, then the output: one stream, read from `read` on. + const out = p.fs.stage(p.gpa); + var at = slot.read; + for ([_][]const u8{ slot.answer[0..slot.len], slot.output }) |part| { + if (at >= part.len) { + at -= part.len; + continue; + } + const n = @min(part.len - at, req.size - out.items.len); + out.appendSlice(p.gpa, part[at..][0..n]) catch return Reply.fail(req.tag, E.NOMEM); + at = 0; + if (out.items.len == req.size) break; + } + slot.read += out.items.len; + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } pub fn releaseRun(p: *Pardes, req: Req) Reply { @@ -210,7 +233,7 @@ pub fn releaseRun(p: *Pardes, req: Req) Reply { for (&p.fs.panes) |*pf| if (pf.run == idx) { pf.run = null; }; - slot.* = .{}; + slot.reset(p.gpa, 0); return .{ .tag = req.tag }; } @@ -268,7 +291,33 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { } const done = marks.finished orelse return; if (done.seq != slot.want) return; - if (done.status) |status| answer(slot, "exit {d}", .{status}) else answer(slot, "exit", .{}); + const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa) catch null; + defer if (printed) |text| p.gpa.free(text); + const scrolled_out = if (state.stream.handler.out_start) |pin| pin.garbage else false; + // Keep the tail, cut at a line start so the first line kept is whole. + var keep: []const u8 = printed orelse ""; + if (keep.len > output_cap) { + keep = keep[keep.len - output_cap ..]; + if (std.mem.indexOfScalar(u8, keep, '\n')) |nl| keep = keep[nl + 1 ..]; + } + const cut = (if (printed) |text| text.len else 0) - keep.len; + const status = done.status orelse 0; + // The header is the whole first line, so a count there can never be + // mistaken for output; `cut` with no count: its start scrolled away. + if (printed == null or scrolled_out) + answer(slot, "exit {d} cut", .{status}) + else if (cut > 0) + answer(slot, "exit {d} cut {d}", .{ status, cut }) + else + answer(slot, "exit {d}", .{status}); + if (keep.len > 0) { + const nl = @intFromBool(keep[keep.len - 1] != '\n'); + if (p.gpa.alloc(u8, keep.len + nl)) |owned| { + @memcpy(owned[0..keep.len], keep); + if (nl == 1) owned[keep.len] = '\n'; + slot.output = owned; + } else |_| {} + } pf.run = null; } @@ -605,7 +654,8 @@ test "run answers how its command ended once the shell is back at a prompt" { out.put(p, prompt); // A shell's `exec 3<>run; echo make >&3; cat <&3` reads at the offset // its write left (5): the answer is a stream and comes whole anyway. - try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .off = 5, .size = 64 }).bytes); + // The answer is how it ended, then what it printed. + try testing.expectEqualStrings("exit 2\nbuilding\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .off = 5, .size = 64 }).bytes); try testing.expectEqual(@as(usize, 0), call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes.len); _ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h }); @@ -619,7 +669,8 @@ test "run answers how its command ended once the shell is back at a prompt" { try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 34, .op = .read, .node = node, .handle = nested.reply.handle, .size = 64 }).bytes); _ = call(p, .{ .tag = 35, .op = .release, .node = node, .handle = nested.reply.handle }); out.put(p, "logout\r\n\x1b]133;D;0;aid=pardes\x07" ++ prompt); - try testing.expectEqualStrings("exit 0\n", call(p, .{ .tag = 36, .op = .read, .node = node, .handle = outer.reply.handle, .size = 64 }).bytes); + const ssh = call(p, .{ .tag = 36, .op = .read, .node = node, .handle = outer.reply.handle, .size = 64 }).bytes; + try testing.expect(std.mem.startsWith(u8, ssh, "exit 0\n") and std.mem.indexOf(u8, ssh, "remote$ logout\n") != null); _ = call(p, .{ .tag = 37, .op = .release, .node = node, .handle = outer.reply.handle }); // Typed text on the prompt line: busy, rather than typing into it. diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 0d328594..e8bcad2f 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -68,8 +68,6 @@ pub const e_in_use = "file in use"; /// Handles that mark an open as the one reading `event` or `pty/data`, so the /// release knows to give the file up; other opens get `open_handle`. const open_handle: u32 = 1; -// ponytail: EBUSY spelled locally; use E.BUSY once cloud9 names it and pardes bumps the pin. -const e_busy: u16 = 16; const reader_handle: u32 = 2; fn reads(omode: u8) bool { @@ -527,7 +525,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { // the stream; the second is refused rather than robbed. .event => { const reader = reads(req.omode); - if (reader and pf.event_reader) return failText(req.tag, e_busy, e_in_use); + if (reader and pf.event_reader) return failText(req.tag, E.BUSY, e_in_use); pf.readers +|= 1; p.fs.listeners +|= 1; pf.event_reader = pf.event_reader or reader; @@ -535,7 +533,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { }, .pty_run => return pty.openRun(p, req, t.serial), .pty_data => if (reads(req.omode)) { - if (pf.pty_readers > 0) return failText(req.tag, e_busy, e_in_use); + if (pf.pty_readers > 0) return failText(req.tag, E.BUSY, e_in_use); pf.pty_readers = 1; return .{ .tag = req.tag, .handle = reader_handle }; }, diff --git a/src/panes.zig b/src/panes.zig index 410492b8..68e03a04 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -6526,11 +6526,26 @@ pub const Terminal = struct { /// The newest prompt continues an unfinished line (bash's PS2, marked /// `k=c`): the shell still holds the start of the command. continuation: bool = false, + /// Where the newest command's output began (its C) and ended (its + /// D) on the primary screen, pinned so scrolling keeps them. + out_start: ?*ghostty_vt.Pin = null, + out_end: ?*ghostty_vt.Pin = null, + /// The newest command to end: which one, and its status if given. finished: ?struct { seq: u32, status: ?i32 } = null, const Action = ghostty_vt.TerminalStream.Action; + /// Move one of the output pins to the cursor. Only the primary screen + /// is pinned: a command's own full-screen display is not its output. + fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void { + const screen = self.inner.terminal.screens.get(.primary) orelse return; + if (slot.*) |old| screen.pages.untrackPin(old); + slot.* = null; + if (!now or self.inner.terminal.screens.active_key != .primary) return; + slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null; + } + pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void { if (comptime action == .semantic_prompt) mark: { const aid = value.readOption(.aid) orelse break :mark; @@ -6549,10 +6564,13 @@ pub const Terminal = struct { .end_input_start_output => if (self.phase == .input) { self.phase = .running; self.started +%= 1; + self.pinCursor(&self.out_start, true); + self.pinCursor(&self.out_end, false); }, .end_command => if (self.phase == .running) { self.phase = .none; self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; + self.pinCursor(&self.out_end, true); } else { self.declined +%= 1; }, @@ -7357,6 +7375,25 @@ pub const Terminal = struct { state.vt.screens.active.cursor.semantic_content == .input; } + /// What the newest finished command printed: the screen text between its + /// C and D marks, owned by the caller. When its start has scrolled out of + /// the history, ghostty moved that pin to the oldest row it kept (and + /// flagged it `garbage`), so this is the part that survived. + pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator) ![:0]const u8 { + if (comptime enabled) printed: { + const state = pane.terminal orelse break :printed; + const start = state.stream.handler.out_start orelse break :printed; + const end = state.stream.handler.out_end orelse break :printed; + if (end.garbage) break :printed; + const screen = state.vt.screens.get(.primary) orelse break :printed; + // D is where the next prompt begins: the output ends a cell before. + const last = end.leftWrap(1) orelse break :printed; + if (start.eql(end.*) or last.before(start.*)) break :printed; + return try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(start.*, last, false) }); + } + return try gpa.dupeZ(u8, ""); // it printed nothing + } + /// At a prompt with nothing typed on its line: no input left of the /// cursor and none under it (text typed and then Ctrl-A'd starts at the /// cursor). Nothing further right counts: that is where fish draws a |
