diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 20:07:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 9070942b29bd10dddcdecdb0e88ba0fb40608467 (patch) | |
| tree | fa1fc5984c7847c52bf4e78d277586edce2d8325 /src | |
| parent | 0122e94fb37422085325f2dc78ca015051ce5f91 (diff) | |
| download | pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.tar.gz pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.zip | |
Plan 9 idiom for the control filesystem, and the regressions a624a56 left
The 9P tree stops being a command language wearing a filesystem. /new
created a pane as a side effect of a *read*; it is now Tcreate in /pane,
with Tremove to close, which cloud9's engine has always supported and the
editor never declared: tree.zig now says
`features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs
become files that can be read as well as written -- dot, limit, dirty,
mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file
would say better. Root /ctl splits into a read-only /status and the
/look and /exec files whose write IS the click. stat carries real sizes
where it used to answer 0, and qid versions track a pane's revision, so a
client can poll for change without re-reading the body.
Commit a624a56 moved raw-tty keys to an early-return branch that knew only
Ctrl-B and bare Escape, and in the same edit deleted the paste branch below
it. That cost Shift-Escape (the unconditional way out of tty mode) and both
paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an
agent CLI running in a pane took Ctrl-V for its image-paste binding and
answered "No image found in clipboard". Both are restored, with tests.
Nested detection was not subtly broken but deleted: 60367d8 removed
nested.zig's process-ancestry walk and left "am I inside pardes" derived from
PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener
did not come up". PARDES_PID now answers that question on its own, checked
with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag
is gone. The posted-9P registry also self-heals now -- a session that aborts
cannot unlink its own socket, so posting sweeps entries whose target refuses
a connection, symlinks only and on a definite ECONNREFUSED only.
Elsewhere: tty scrolling is sticky-bottom, following new output only from
the last row, with typing and entering raw mode snapping back to live; the
boot layouts are a Boot enum instead of a chain of ifs, and the bare tty
startup (Boot.tty, which main.zig names) opens an empty text pane under the
shell while tests keep Boot.tty_shell; builtins announce themselves on the
message row under a Verbose setting that is on by default; Config prints
each setting the way you would type it back, so WindowOpacity 70 rather than
"WindowOpacity: 70%"; LocationsConfig opens its window only when called bare;
every tagline puts the word that closes the thing last, and a column now
outlives its panes -- closing the last one leaves an empty pane, and only
Delcol, newly on the column tagline, takes the column away.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p_io.zig | 290 | ||||
| -rw-r--r-- | src/builtins.zig | 15 | ||||
| -rw-r--r-- | src/config.zig | 187 | ||||
| -rw-r--r-- | src/fs-help.txt | 42 | ||||
| -rw-r--r-- | src/fs.zig | 11 | ||||
| -rw-r--r-- | src/host_io.zig | 3 | ||||
| -rw-r--r-- | src/main.zig | 71 | ||||
| -rw-r--r-- | src/ninep/addr.zig | 16 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 317 | ||||
| -rw-r--r-- | src/ninep/events.zig | 15 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 241 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 9 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 25 | ||||
| -rw-r--r-- | src/ninep/testing.zig | 29 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 207 | ||||
| -rw-r--r-- | src/panes.zig | 14 | ||||
| -rw-r--r-- | src/pardes.zig | 294 | ||||
| -rw-r--r-- | src/tty/tty.zig | 31 | ||||
| -rw-r--r-- | src/tutor.txt | 21 |
19 files changed, 1312 insertions, 526 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig index 4f7c2e97..3d019e0d 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -596,7 +596,7 @@ pub const Listener = struct { } for (l.control) |fd| { setCloexec(fd); - setNonblock(fd); + _ = setNonblock(fd); } l.arm(); l.watcher = try std.Thread.spawn(.{}, watchQuic, .{l}); @@ -671,6 +671,7 @@ pub const Listener = struct { log.warn("registry post skipped: cannot create {s}", .{svc}); return; } + sweepRegistry(l.io, svc); var entry_buf: [sun_path_len:0]u8 = undefined; const entry = std.fmt.bufPrintSentinel(&entry_buf, "{s}/{s}", .{ svc, name }, 0) catch { log.warn("registry post skipped: name too long: {s}", .{name}); @@ -679,15 +680,15 @@ pub const Listener = struct { const target = l.path_buf[0..l.path_len]; // Replace only what is provably not live: our own entry, or a - // dead predecessor's symlink. `isListening` treats uncertainty - // as live, so it is only asked about an entry that *is* a - // symlink; anything else is left strictly alone and the - // symlink below simply fails. + // dead predecessor's symlink. `probe` treats uncertainty as + // live, so it is only asked about an entry that *is* a symlink; + // anything else is left strictly alone and the symlink below + // simply fails. var link_buf: [sun_path_len]u8 = undefined; const n = libc.readlink(entry, &link_buf, link_buf.len); if (n >= 0) { const had = link_buf[0..@intCast(n)]; - if (!std.mem.eql(u8, had, target) and alive(entry)) { + if (!std.mem.eql(u8, had, target) and probe(entry) == .live) { log.warn("registry entry pardes/{s} is live; not re-posted", .{name}); return; } @@ -842,12 +843,129 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, named: []const u8, fallback: [ const alive = transport.isListening; -fn setNonblock(fd: c_int) void { +/// What is at a socket path, in cloud9's three answers (`post.Probe`). +/// Asked here with libc rather than through `cloud9.post.probe`, whose +/// raw Linux syscalls darwin cannot compile, but the classification is +/// that file's and must not become a second opinion: the one definite +/// refusal is `.stale`, nothing at the path at all is `.none`, and +/// everything else — connected, busy, refused permission, a surprise — +/// is `.live`. Uncertainty belongs to the server that owns the socket, +/// never to a sweeper deciding what to delete. +const Probe = enum { none, stale, live }; + +/// `SOCK.STREAM`, and the kernel's own non-blocking bit where there is +/// one. Darwin's `SOCK.NONBLOCK` is a Zig shim for `std.posix.socket` +/// to unpack, not an ABI value, so handing it to the raw libc call +/// would ask a kernel that has never heard of it; there it is an +/// `fcntl` instead. +const probe_socket_kind: c_uint = libc.SOCK.STREAM | (if (darwin) 0 else libc.SOCK.NONBLOCK); + +fn probe(path: [:0]const u8) Probe { + if (path.len + 1 > sun_path_len) return .live; // cannot ask; assume occupied + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + const fd = libc.socket(libc.AF.UNIX, probe_socket_kind, 0); + if (fd < 0) return .live; + defer _ = libc.close(fd); + + // Non-blocking is the whole safety of this function, so it is read + // back rather than assumed: a BLOCKING connect to a live server + // whose backlog is full parks in the kernel with no timeout to end + // it — measured, it simply never returns — and a sweep that parks + // takes the editor's startup with it. An fd that cannot be proven + // non-blocking is never connected at all, which lands on `.live`, + // the answer that deletes nothing. + if (comptime darwin) _ = setNonblock(fd); + if (!isNonblocking(fd)) return .live; + + if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) return .live; + // cloud9's `post.probe` mapping, answer for answer. The connection + // is never wanted: EAGAIN (a full Unix backlog) and EINPROGRESS say + // somebody is listening, which is all that was asked, and the fd + // closes without ever waiting for it to become writable. + return switch (libc.errno(-1)) { + .AGAIN, .INPROGRESS, .PERM, .ACCES => .live, + .CONNREFUSED => .stale, + .NOENT, .NOTDIR => .none, + else => .live, + }; +} + +/// Reaps the registry entries whose editor is gone, once per post. +/// +/// A clean exit unposts itself (`unpostFromRegistry`), so everything +/// left behind comes from an exit that could not: an aborted test, a +/// kill, a crash. No code in the dead process can ever run, so the cure +/// has to be somebody else's readdir, and the next editor to start is +/// the somebody. It is only ever a symlink that is followed or +/// unlinked, because that is the only thing `postToRegistry` makes and +/// anything else under a name belongs to whoever put it there; and only +/// a definite refusal counts as gone. The socket a stale entry points +/// at goes too, but not before a stat agrees it is a socket of ours: a +/// plain file answers a connect with the same refusal. +fn sweepRegistry(io: std.Io, svc: [:0]const u8) void { + if (comptime !supported) return; + + // The names are staged before anything is unlinked, so the sweep + // never asks a directory to keep reading while it is being edited. + var names: [4096]u8 = undefined; + var staged: usize = 0; + { + const dir = std.Io.Dir.openDirAbsolute(io, svc, .{ .iterate = true }) catch return; + defer dir.close(io); + var read_buf: [std.Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var reader: std.Io.Dir.Reader = .init(dir, &read_buf); + while (true) { + const listed = (reader.next(io) catch break) orelse break; + if (listed.name.len == 0 or listed.name.len > 255) continue; + if (staged + 1 + listed.name.len > names.len) break; + names[staged] = @intCast(listed.name.len); + @memcpy(names[staged + 1 ..][0..listed.name.len], listed.name); + staged += 1 + listed.name.len; + } + } + + var reaped: usize = 0; + var i: usize = 0; + while (i < staged) { + const name = names[i + 1 ..][0..names[i]]; + i += 1 + name.len; + var entry_buf: [sun_path_len:0]u8 = undefined; + const entry = std.fmt.bufPrintSentinel(&entry_buf, "{s}/{s}", .{ svc, name }, 0) catch continue; + const facts = statNoFollow(entry) orelse continue; + if (facts.mode & 0o170000 != 0o120000) continue; // not a symlink: not ours to judge + const state = probe(entry); + if (state == .live) continue; + var link_buf: [sun_path_len]u8 = undefined; + const n = libc.readlink(entry, &link_buf, link_buf.len); + if (libc.unlink(entry) != 0) continue; + reaped += 1; + // A relative target would resolve against this editor's working + // directory, which says nothing about what the entry named. + if (state != .stale or n <= 0 or link_buf[0] != '/') continue; + var target_buf: [sun_path_len:0]u8 = undefined; + const target = std.fmt.bufPrintSentinel(&target_buf, "{s}", .{link_buf[0..@intCast(n)]}, 0) catch continue; + const t = statNoFollow(target) orelse continue; + if (t.mode & 0o170000 == 0o140000 and t.uid == libc.getuid()) _ = libc.unlink(target); + } + if (reaped != 0) log.info("reaped {d} stale registry entries under {s}", .{ reaped, svc }); +} + +/// Whether it worked, because `probe` is not allowed to find out the +/// hard way: a caller that needs the guarantee has to be able to check. +fn setNonblock(fd: c_int) bool { const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; + if (flags < 0) return false; var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); + return libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))) >= 0; +} + +fn isNonblocking(fd: c_int) bool { + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return false; + const o: libc.O = @bitCast(@as(u32, @bitCast(flags))); + return o.NONBLOCK; } const testing = std.testing; @@ -866,6 +984,108 @@ test "a name that is not one path component is no address at all" { try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); } +test "the registry sweep takes the dead entries and leaves everything else" { + if (comptime !supported) return error.SkipZigTest; + // Under the real runtime directory, because a sun_path is 108 bytes + // and the test cache's temporary directories are longer than that. + var dir_buf: [sun_path_len:0]u8 = undefined; + const base = socketDir(&dir_buf) orelse return error.SkipZigTest; + if (!ensureSocketDir(base)) return error.SkipZigTest; + + var svc_buf: [sun_path_len:0]u8 = undefined; + const svc = std.fmt.bufPrintSentinel(&svc_buf, "{s}/sweep-{d}", .{ base, @as(u32, @intCast(libc.getpid())) }, 0) catch + return error.SkipZigTest; + if (libc.mkdir(svc, 0o700) != 0) return error.SkipZigTest; + + var paths: [5][sun_path_len:0]u8 = undefined; + const live_sock = try std.fmt.bufPrintSentinel(&paths[0], "{s}/live.sock", .{svc}, 0); + const dead_sock = try std.fmt.bufPrintSentinel(&paths[1], "{s}/dead.sock", .{svc}, 0); + const live = try std.fmt.bufPrintSentinel(&paths[2], "{s}/live", .{svc}, 0); + const dead = try std.fmt.bufPrintSentinel(&paths[3], "{s}/dead", .{svc}, 0); + const stranger = try std.fmt.bufPrintSentinel(&paths[4], "{s}/stranger", .{svc}, 0); + defer { + for ([_][:0]const u8{ live_sock, dead_sock, live, dead, stranger }) |p| _ = libc.unlink(p); + _ = libc.rmdir(svc); + } + + const listening = bindSocket(live_sock); + try testing.expect(listening >= 0); + defer _ = libc.close(listening); + try testing.expectEqual(@as(c_int, 0), libc.listen(listening, 1)); + + // Bound and then dropped: the file stays, and nobody answers it — + // exactly what an aborted test leaves behind. + const abandoned = bindSocket(dead_sock); + try testing.expect(abandoned >= 0); + _ = libc.close(abandoned); + + try testing.expectEqual(@as(c_int, 0), libc.symlink(live_sock, live)); + try testing.expectEqual(@as(c_int, 0), libc.symlink(dead_sock, dead)); + // Not a symlink, so not this program's to reason about, even though + // connecting to it is refused exactly like the dead socket. + try std.Io.Dir.cwd().writeFile(testing.io, .{ .sub_path = stranger, .data = "" }); + + // The guarantee itself, asserted rather than inferred from the fact + // that the test finished: if a probe socket ever stops being + // non-blocking the connect below parks instead of failing, and a + // parked test costs whoever is building far more than a red one. + const checking = libc.socket(libc.AF.UNIX, probe_socket_kind, 0); + try testing.expect(checking >= 0); + if (comptime darwin) try testing.expect(setNonblock(checking)); + try testing.expect(isNonblocking(checking)); + _ = libc.close(checking); + + // The listener's backlog is filled before anything is asked, because + // a full backlog is the one place a blocking connect parks forever + // (`unix_wait_for_peer`, no timeout) and a sweep that only answers + // while nobody is queued is the sweep that hangs a build. Nothing + // below accepts any of these, so the queue stays full throughout. + var queued: [8]c_int = @splat(-1); + defer for (queued) |fd| { + if (fd >= 0) _ = libc.close(fd); + }; + for (&queued) |*slot| { + const fd = libc.socket(libc.AF.UNIX, probe_socket_kind, 0); + if (fd < 0) break; + if (comptime darwin) _ = setNonblock(fd); + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. live_sock.len + 1], live_sock[0 .. live_sock.len + 1]); + _ = libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))); + slot.* = fd; + } + + const started = transport.nowMs(); + try testing.expectEqual(Probe.live, probe(live)); + try testing.expectEqual(Probe.stale, probe(dead)); + + sweepRegistry(testing.io, svc); + + // Promptly, and not "eventually": a blocking probe never comes back + // at all, so any wall-clock bound at all is the assertion that + // matters. A second is several thousand times what three connects + // and a readdir cost. + try testing.expect(transport.nowMs() - started < 1000); + + try testing.expect(statNoFollow(dead) == null); + try testing.expect(statNoFollow(dead_sock) == null); + try testing.expect(statNoFollow(live) != null); + try testing.expect(statNoFollow(live_sock) != null); + try testing.expect(statNoFollow(stranger) != null); + try testing.expectEqual(Probe.live, probe(live)); +} + +fn bindSocket(path: [:0]const u8) c_int { + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return fd; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = libc.close(fd); + return -1; + } + return fd; +} + test "one connection's buffers are sized from the one msize constant" { try testing.expect(msize >= ninep.min_msize); try testing.expectEqual(@as(u32, msize), Runner.msize); @@ -1102,24 +1322,41 @@ pub fn start(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listene return listener; } -pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, forward_look: bool) void { - _ = unsetenv("PARDES_FORWARD_LOOK"); +/// What a pane shell is told about the editor above it, set into this +/// process's environment just before `forkpty` so the child inherits it. +/// +/// Two independent facts, and they are separate variables because they answer +/// separate questions. `PARDES_PID` says "you are inside this editor, and it +/// will take a Look from you": a session whose 9P listener never came up still +/// owns its children, so the child says so rather than looking, from its own +/// side, exactly like no pardes at all. `PARDES_9P` and `PARDES_PANE` say how +/// to reach it, and a `--nested` session exports them too — its socket stays +/// open to scripts and to mounted shells — while withholding `PARDES_PID`, +/// which is the whole of what `--nested` means. +pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, adopts: bool) void { + var announced = false; + if (adopts) announcing: { + var buf: [16]u8 = undefined; + const text = std.fmt.bufPrintSentinel(&buf, "{d}", .{@as(u32, @intCast(libc.getpid()))}, 0) catch + break :announcing; + announced = setenv("PARDES_PID", text, 1) == 0; + } + if (!announced) _ = unsetenv("PARDES_PID"); + if (listener) |l| exporting: { var sock: [sun_path_len]u8 = undefined; const path = std.fmt.bufPrintSentinel(&sock, "{s}", .{l.path()}, 0) catch break :exporting; var buf: [16]u8 = undefined; const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch break :exporting; if (setenv("PARDES_9P", path, 1) != 0) break :exporting; - if (setenv("PARDES_PANE", id, 1) != 0) break :exporting; - if (setenv("PARDES_FORWARD_LOOK", if (forward_look) "1" else "0", 1) == 0) return; + if (setenv("PARDES_PANE", id, 1) == 0) return; } _ = unsetenv("PARDES_9P"); _ = unsetenv("PARDES_PANE"); - _ = setenv("PARDES_FORWARD_LOOK", "0", 1); } -test "9P shell environment preserves identity when nested Look forwarding is disabled" { - const names = [_][*:0]const u8{ "XDG_RUNTIME_DIR", "HOME", "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }; +test "9P shell environment states being inside pardes apart from how to reach it" { + const names = [_][*:0]const u8{ "XDG_RUNTIME_DIR", "HOME", "PARDES_PID", "PARDES_9P", "PARDES_PANE" }; var saved: [names.len]?[:0]u8 = @splat(null); for (names, &saved) |name, *value| { if (libc.getenv(name)) |old| value.* = try testing.allocator.dupeZ(u8, std.mem.span(old)); @@ -1146,17 +1383,32 @@ test "9P shell environment preserves identity when nested Look forwarding is dis const path = "/tmp/pardes-example.sock"; @memcpy(listener.path_buf[0..path.len], path); listener.path_len = path.len; + var own: [16]u8 = undefined; + const own_pid = try std.fmt.bufPrint(&own, "{d}", .{@as(u32, @intCast(libc.getpid()))}); + + // A `--nested` session: reachable for scripts and mounts, but nobody's + // parent, so a pardes started in one of its shells runs a session of + // its own instead of handing its argument over. exportPaneEnv(listener, 7, false); try testing.expectEqualStrings(path, std.mem.span(libc.getenv("PARDES_9P").?)); try testing.expectEqualStrings("7", std.mem.span(libc.getenv("PARDES_PANE").?)); - try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + try testing.expect(libc.getenv("PARDES_PID") == null); + exportPaneEnv(listener, 8, true); try testing.expectEqualStrings("8", std.mem.span(libc.getenv("PARDES_PANE").?)); - try testing.expectEqualStrings("1", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + try testing.expectEqualStrings(own_pid, std.mem.span(libc.getenv("PARDES_PID").?)); + + // A session whose listener never came up is still the session this shell + // is inside: the pid stands on its own, and only the address is missing. + exportPaneEnv(null, 0, true); + try testing.expect(libc.getenv("PARDES_9P") == null); + try testing.expect(libc.getenv("PARDES_PANE") == null); + try testing.expectEqualStrings(own_pid, std.mem.span(libc.getenv("PARDES_PID").?)); + exportPaneEnv(null, 0, false); try testing.expect(libc.getenv("PARDES_9P") == null); try testing.expect(libc.getenv("PARDES_PANE") == null); - try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + try testing.expect(libc.getenv("PARDES_PID") == null); } pub const Client = struct { diff --git a/src/builtins.zig b/src/builtins.zig index 39d70e5d..aa2b2d6d 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -137,6 +137,14 @@ pub const registry = struct { unreachable; } + /// Does this builtin say its own name on the message row before it runs? + /// The ones that own that row themselves declare `quiet` and do not. + pub fn announces(b: Builtin()) bool { + inline for (manualBuiltinList(), 0..) |T, i| + if (@intFromEnum(b) == i) return !(@hasDecl(T, "quiet") and T.quiet); + return true; // a setting builtin changes something invisible; say so + } + pub fn dispatch(b: Builtin(), c: Ctx) void { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return T.run(c); @@ -259,6 +267,7 @@ pub const Unmount = struct { pub const Msg = struct { pub const takes_arg = true; + pub const quiet = true; // it IS the message row pub fn run(c: Ctx) void { if (c.arg) |text| c.p.setMessage(c.id, text) @@ -542,11 +551,15 @@ pub const LocationsConfig = struct { pub const output: OutputTraits = .{ .name = "+LocationsConfig" }; pub fn run(c: Ctx) void { - const next = c.p.locations_config.parse(c.arg orelse "") catch |err| { + const argument = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + const next = c.p.locations_config.parse(argument) catch |err| { c.p.reportError(c.id, "LocationsConfig", err); return; }; c.p.locations_config = next; + // Bare, it is a question, and the window is the answer. With arguments + // it is a setting, and a setting that opens a window is noise. + if (argument.len != 0) return; report(c) catch |err| c.p.reportError(c.id, "LocationsConfig", err); } diff --git a/src/config.zig b/src/config.zig index 440549b3..3169b20c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -71,6 +71,7 @@ pub const leader_path = paths: { .FocusTint = null, .ColumnTags = null, .SyntaxBold = null, + .Verbose = null, .TreeContextTagStyle = null, .Wrap = "tw", .Tagbottom = "tb", @@ -224,10 +225,10 @@ pub const tty_filter_min_contrast: f64 = 1.5; pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; -pub const pane_builtins_str = "Tty Del Collapse"; -pub const pdf_pane_builtins_str = "Tty Del PdfSections PdfTint Collapse"; -pub const file_pane_builtins_str = "Save Tty Del Collapse"; -pub const terminal_pane_builtins_str = "Tty Save Del Mode Filter Collapse"; +pub const pane_builtins_str = "Tty Collapse Del"; +pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del"; +pub const file_pane_builtins_str = "Save Tty Collapse Del"; +pub const terminal_pane_builtins_str = "Tty Save Mode Filter Collapse Del"; // Each mode badge is one codepoint. pub const tag_normal = " "; @@ -540,6 +541,8 @@ pub const Runtime = struct { focus_tint: bool = true, column_tags: bool = true, syntax_bold: bool = false, + /// Builtins announce themselves on the message row. + verbose: bool = true, window_opacity: u8 = 100, window_blur: u8 = 0, window_opacity_pending: bool = true, @@ -637,7 +640,7 @@ pub const Runtime = struct { pub const Capability = std.meta.FieldEnum(Capabilities); - pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose }; pub const Scene = std.meta.FieldEnum(layout.SceneEffect); pub const Action = union(enum) { @@ -682,6 +685,7 @@ pub const Runtime = struct { .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } }, .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } }, .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, + .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "Theme", .action = .theme }, .{ .word = "Shell", .action = .shell }, .{ .word = "Font", .action = .font, .availability = .font_picker }, @@ -761,15 +765,10 @@ pub const Runtime = struct { pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { switch (setting.action) { + // Every toggle names a bool field of its own, so one line covers + // all of them and a new toggle needs no arm here. .toggle => |field| switch (field) { - .colors => state.colors = !state.colors, - .wrap => state.wrap = !state.wrap, - .tree_context_tag_style => state.tree_context_tag_style = !state.tree_context_tag_style, - .tag_bottom => state.tag_bottom = !state.tag_bottom, - .debug => state.debug = !state.debug, - .focus_tint => state.focus_tint = !state.focus_tint, - .column_tags => state.column_tags = !state.column_tags, - .syntax_bold => state.syntax_bold = !state.syntax_bold, + inline else => |f| @field(state, @tagName(f)) = !@field(state, @tagName(f)), }, .shell => { const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); @@ -835,46 +834,42 @@ pub const Runtime = struct { return findAction(.{ .transition = transition }).?.word; } + /// Each line reads as the command that would set it: the word, then the + /// value you would type after it. No colons, no units — `WindowOpacity 70` + /// is both what Config prints and what you write back. pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { const state = context.state; var wrote_transition = false; for (settings) |setting| switch (setting.action) { .toggle => |field| { const value = switch (field) { - .colors => state.colors, - .wrap => state.wrap, - .tree_context_tag_style => state.tree_context_tag_style, - .tag_bottom => state.tag_bottom, - .debug => state.debug, - .focus_tint => state.focus_tint, - .column_tags => state.column_tags, - .syntax_bold => state.syntax_bold, + inline else => |f| @field(state, @tagName(f)), }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); + try out.print("{s} {s}\n", .{ setting.word, onOff(value) }); }, - .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), + .theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }), .window_opacity => { if (setting.enabled(context.capabilities)) - try out.print("{s}: {d}%{s}\n", .{ + try out.print("{s} {d}{s}\n", .{ setting.word, state.window_opacity, if (state.window_opacity_pending) " (pending)" else "", }) else - try out.print("{s}: unsupported\n", .{setting.word}); + try out.print("{s} unsupported\n", .{setting.word}); }, .window_blur => { if (setting.enabled(context.capabilities)) - try out.print("{s}: {d}%\n", .{ setting.word, state.window_blur }) + try out.print("{s} {d}\n", .{ setting.word, state.window_blur }) else - try out.print("{s}: unsupported\n", .{setting.word}); + try out.print("{s} unsupported\n", .{setting.word}); }, .shell => { const chosen = state.shell.requested.get(); try out.print( - "{s} requested (new panes): {s}{s}\n" ++ - "{s} effective (last spawn): {s}\n" ++ - "{s} pending: {s}\n", + "{s} requested (new panes) {s}{s}\n" ++ + "{s} effective (last spawn) {s}\n" ++ + "{s} pending {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen, @@ -887,18 +882,18 @@ pub const Runtime = struct { ); }, .font => if (!setting.enabled(context.capabilities)) - try out.print("{s}: unsupported\n", .{setting.word}) + try out.print("{s} unsupported\n", .{setting.word}) else { if (state.font.requested_size_hundredths == 0) - try out.print("{s} requested size: unchanged\n", .{setting.word}) + try out.print("{s} requested size unchanged\n", .{setting.word}) else - try out.print("{s} requested size: {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 }); + try out.print("{s} requested size {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 }); try out.print( - "{s} requested: {s}\n" ++ - "{s} requested path: {s}\n" ++ - "{s} effective: {s}\n" ++ - "{s} pending: {s}\n" ++ - "{s} effective size: {d}.{d:0>2} {s}\n", + "{s} requested {s}\n" ++ + "{s} requested path {s}\n" ++ + "{s} effective {s}\n" ++ + "{s} pending {s}\n" ++ + "{s} effective size {d}.{d:0>2} {s}\n", .{ setting.word, shown(state.font.requested_name.get()), @@ -916,28 +911,28 @@ pub const Runtime = struct { ); }, .tagline_size => if (!context.capabilities.tagline_font_size) - try out.print("{s}: unsupported\n", .{setting.word}) + try out.print("{s} unsupported\n", .{setting.word}) else if (!setting.enabled(context.capabilities)) - try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) + try out.print("{s} {d} (build-time only)\n", .{ setting.word, state.font.tagline_percent }) else - try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), + try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }), .transition => { if (wrote_transition) continue; wrote_transition = true; if (!setting.enabled(context.capabilities)) - try out.writeAll("Panel transition: unsupported\n") + try out.writeAll("Panel transition unsupported\n") else - try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); + try out.print("Panel transition {s}\n", .{transitionSettingName(state.panel_transition)}); }, .scene => |effect| { if (!setting.enabled(context.capabilities)) { - try out.print("{s}: unsupported\n", .{setting.word}); + try out.print("{s} unsupported\n", .{setting.word}); continue; } const enabled = switch (effect) { inline else => |field| @field(state.scene_effects, @tagName(field)), }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); + try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) }); }, }; @@ -1073,7 +1068,8 @@ pub const Runtime = struct { test "Config report observes every simple setting and all live context" { var state: Runtime = .{}; - var storage: [4096]u8 = undefined; + // Every setting, every context line: the report outgrows 4K. + var storage: [8192]u8 = undefined; const context: ReportContext = .{ .startup_config_path = "/tmp/pardes/init", .platform = "gui", @@ -1112,37 +1108,38 @@ pub const Runtime = struct { const report = storage[0..out.end]; const expected = switch (setting.action) { .toggle => |field| switch (field) { - .colors => "Colors: off\n", - .wrap => "Wrap: off\n", - .tree_context_tag_style => "TreeContextTagStyle: off\n", - .tag_bottom => "Tagbottom: on\n", - .debug => "Debug: on\n", - .focus_tint => "FocusTint: off\n", - .column_tags => "ColumnTags: off\n", - .syntax_bold => "SyntaxBold: on\n", + .colors => "Colors off\n", + .wrap => "Wrap off\n", + .tree_context_tag_style => "TreeContextTagStyle off\n", + .tag_bottom => "Tagbottom on\n", + .debug => "Debug on\n", + .focus_tint => "FocusTint off\n", + .column_tags => "ColumnTags off\n", + .syntax_bold => "SyntaxBold on\n", + .verbose => "Verbose off\n", }, - .shell => "Shell requested (new panes): fish\n", - .tagline_size => "TaglineSize: 73%\n", - .window_opacity => "WindowOpacity: 85% (pending)\n", - .window_blur => "WindowBlur: 40%\n", + .shell => "Shell requested (new panes) fish\n", + .tagline_size => "TaglineSize 73\n", + .window_opacity => "WindowOpacity 85 (pending)\n", + .window_blur => "WindowBlur 40\n", .transition => |transition| switch (transition) { .off => unreachable, - .slide => "Panel transition: PanelSlide\n", - .zoom => "Panel transition: PanelZoom\n", - .dissolve => "Panel transition: PanelDissolve\n", - .ascii => "Panel transition: PanelAscii\n", - .vertical => "Panel transition: PanelVertical\n", - .edges => "Panel transition: PanelEdges\n", - .fall => "Panel transition: PanelFall\n", - .wave => "Panel transition: PanelWave\n", - .curtain => "Panel transition: PanelCurtain\n", - .scramble => "Panel transition: PanelScramble\n", - .typewriter => "Panel transition: PanelType\n", + .slide => "Panel transition PanelSlide\n", + .zoom => "Panel transition PanelZoom\n", + .dissolve => "Panel transition PanelDissolve\n", + .ascii => "Panel transition PanelAscii\n", + .vertical => "Panel transition PanelVertical\n", + .edges => "Panel transition PanelEdges\n", + .fall => "Panel transition PanelFall\n", + .wave => "Panel transition PanelWave\n", + .curtain => "Panel transition PanelCurtain\n", + .scramble => "Panel transition PanelScramble\n", + .typewriter => "Panel transition PanelType\n", }, .scene => |effect| switch (effect) { - .crt => "Crt: on\n", - .ripple => "Ripple: on\n", - .glitch => "Glitch: on\n", + .crt => "Crt on\n", + .ripple => "Ripple on\n", + .glitch => "Glitch on\n", }, .theme, .font => unreachable, }; @@ -1163,15 +1160,15 @@ pub const Runtime = struct { try writeReport(&out, context); const report = storage[0..out.end]; for ([_][]const u8{ - "Theme: acme\n", - "WindowOpacity: 85%\n", - "Font requested size: 18.50\n", - "Font requested: Wanted Mono\n", - "Font requested path: /fonts/wanted.ttf\n", - "Font effective: Effective Mono\n", - "Font pending: on\n", - "Font effective size: 13.75 points\n", - "TaglineSize: 82%\n", + "Theme acme\n", + "WindowOpacity 85\n", + "Font requested size 18.50\n", + "Font requested Wanted Mono\n", + "Font requested path /fonts/wanted.ttf\n", + "Font effective Effective Mono\n", + "Font pending on\n", + "Font effective size 13.75 points\n", + "TaglineSize 82\n", "Startup config: /tmp/pardes/init\n", "Platform: gui\n", "Compiled default shell: /bin/sh\n", @@ -1197,23 +1194,23 @@ pub const Runtime = struct { out = .fixed(&storage); try writeReport(&out, defaults_context); const defaults_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowOpacity: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowOpacity unsupported\n") != null); try std.testing.expect(!find("WindowOpacity").?.enabled(defaults_context.capabilities)); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur unsupported\n") != null); try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities)); try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities)); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes) /bin/sh (default)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); @@ -1223,8 +1220,8 @@ pub const Runtime = struct { out = .fixed(&storage); try writeReport(&out, defaults_context); const web_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100 (build-time only)\n") != null); } }; diff --git a/src/fs-help.txt b/src/fs-help.txt index a0a84b28..1e5d64c2 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -2,41 +2,43 @@ Pardes control filesystem ========================= README this guide index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name -ctl write commands; read the serials the last command made or touched -new reading it creates one empty pane and answers its serial +status pid, version and pane count +look write a line: a right click on it at the active pane; read: the serials it touched +exec write a line: a middle click, an editor command word or a shell line; read the same log one line per editor event (new/del/rename/save <serial> <name>); reads wait screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses -pane/<n>/ name body tag ctl addr data xdata sel errors event, and pty/ for terminals +pane/ mkdir makes a pane, rmdir <serial> closes it +pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll + errors event look exec, and pty/ for terminals os/ the host filesystem src/ the editor's own sources, only in a -Dembed-sources=true build -Commands, on /ctl (applied at the active pane) or on pane/<n>/ctl (at that pane): - look TEXT a right click: open a file, a file:12 location, a directory, a URL - exec TEXT a middle click: an editor command word (Save Del New Newcol Mount Unmount - Dump Restore Msg Find Grep Tty ...), or a shell command for the pane's terminal Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too): cat $m/index which panes exist - n=$(cat $m/new) make a pane, remember its serial - echo 'look /etc/hosts:3' > $m/ctl; cat $m/ctl open a file, see the pane it went to - printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first) + mkdir $m/pane/x; n=$(awk 'END{print $1}' $m/index) make a pane, take its serial + echo /etc/hosts:3 > $m/look; cat $m/look open a file, see the pane it went to + printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first) cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename - echo 'exec Save' > $m/pane/$n/ctl save it (exec Del closes it) - echo 'exec Msg hello' > $m/ctl show text in the editor + echo Save > $m/pane/$n/exec save it; rmdir $m/pane/$n closes it + echo 'Msg hello' > $m/exec show text in the editor echo '#0,#5' > $m/pane/$n/addr; echo NEW > $m/pane/$n/data replace bytes 0..5 - echo 'dot=addr' > $m/pane/$n/ctl; cat $m/pane/$n/sel select the range, read it + cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it + cat $m/pane/$n/dirty; echo 0 > $m/pane/$n/dirty is it modified? say it is not cat $m/log block until a pane is made, renamed, saved or closed 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 + echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT Pitfalls, one each: - Every open of new makes another pane, so `cat $m/new` twice makes two. - ls, stat and find create nothing; reading log, event or pty/data blocks until something happens. - exec Del closes a pane even when it is dirty; index shows the dirty flag first. - look and exec are lowercase verbs; the words after exec are the editor's capitalized commands. - ctl also takes acme's addr verbs (addr=dot dot=addr limit=addr clean dirty get show ...), one per line. + Only mkdir in pane/ makes a pane; ls, stat, find and every read create nothing. + Panes are named by the serial the editor gives them, not by the name mkdir asked for. + rmdir closes a pane even when it is dirty; index and pane/<n>/dirty show the flag. + addr, dot and limit read the same pair of offsets they take, so cp between them works. + dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it. Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences. + Truncating tag clears the part you may edit; truncating dot or addr empties it. A terminal's body is a history snapshot frozen per open; pty/data is the live stream. screen is frozen per open handle as well; reopen it for a fresh frame. - A command that fails is reported in the editor, not as a write error; a malformed line fails the write. + A failing command is reported in the editor, not as a write error; a bad line fails the write. + pane/<n>/ctl reads acme's window status line and takes one verb, get, which reloads from disk. @@ -260,12 +260,13 @@ test "unmount refuses pane paths inherited directories and queued save targets" } gpa.free(pane.file.?.path); pane.file.?.path = try gpa.dupe(u8, "/n/peer2/file"); - const shell = try p.newShell(1, "/n/peer/dir"); - p.setCwd(1, "/n/peer/dir"); + const slot = p.freeSlot().?; + const shell = try p.newShell(slot, "/n/peer/dir"); + p.setCwd(slot, "/n/peer/dir"); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); pane.cwd = .{ .inherited = shell }; try std.testing.expectError(error.MountInUse, unmount(p, "peer")); - p.setCwd(1, "/"); + p.setCwd(slot, "/"); while (p.nextEffect()) |_| {} p.emit(.{ .save_text = .{ .pane = 0, .serial = pane.serial, .path = .from("/n/peer/pending") } }); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); @@ -1261,10 +1262,10 @@ pub const Namespace = struct { /// The editor-wide event stream, recorded only while /log is held open. log: tree.events.Queue = .{}, log_readers: u16 = 0, - /// What the last ctl command created or touched, answered by reading /ctl. + /// What the last look or exec created or touched, answered by reading + /// either of those files. results: [MAX_PANES]u32 = undefined, results_len: u8 = 0, - results_valid: bool = false, /// Process start, the mtime of everything that has no edit time of its own. started: u32 = 0, diff --git a/src/host_io.zig b/src/host_io.zig index 212c23b4..fb09fcc7 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -874,6 +874,9 @@ pub fn forkShell( if (fs == null) return error.SessionSocketRequired; break :blk try @import("linux/v9fs.zig").helperPath(&helper_buf); } else null; + // The last argument is whether this session takes its children's Looks — + // every session does but a `--nested` one, and a fork with no core at all + // (the tests below) is nobody's parent. ninep_io.exportPaneEnv( fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, diff --git a/src/main.zig b/src/main.zig index 4b1910bd..1cbdf24c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -112,6 +112,16 @@ const nested_text = \\ ; +/// $PARDES_PID named a live editor, so this shell IS inside one, but the Look +/// never got there. Saying so beats quietly opening the second editor that +/// $PARDES_PID exists to prevent. +const unreachable_text = + \\pardes: this shell is inside pardes, but that session did not take the + \\file. Check that it is still running, or pass --nested to start a second + \\editor in here anyway. + \\ +; + // The browser runtime calls a C main (exported below); everything else keeps // the std.process.Init entry. pub const main = if (is_emscripten) webMain else nativeMain; @@ -260,6 +270,10 @@ fn nativeMain(init: std.process.Init) !void { } } opts.tty_only = explicit_tty or session_only; + // The real bare-tty startup wants the editor half present from the first + // frame, so it names that layout; a core booted for a test names none and + // gets the raw shell by itself. + if (opts.tty_only) opts.boot = .tty; opts.mounts = mounts[0..mounts_len]; if (detach != null and attach != null) badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); @@ -272,31 +286,60 @@ fn nativeMain(init: std.process.Init) !void { if (opts.mounts.len != 0 and attach != null) badArgs(init.io, "--mount configures a session's own core, and --attach has none of its own", .{}); if (!new_session and !opts.nested and detach == null and attach == null) forwarding: { - const enabled = std.c.getenv("PARDES_FORWARD_LOOK") orelse break :forwarding; - if (!std.mem.eql(u8, std.mem.span(enabled), "1")) break :forwarding; - const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :forwarding); - ninep_io.Client.validateDial(dial) catch break :forwarding; - const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :forwarding); - for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :forwarding; - const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; - if (serial == 0) break :forwarding; - var ctl_buf: [64]u8 = undefined; - const ctl = try std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}); + // Am I inside a pardes at all? That is $PARDES_PID and nothing else — + // one variable, exported by every session that means to take its + // children's Looks, so a session that could not open a socket is still + // recognisably the session this shell is inside. A pid naming no live + // process is a shell that outlived its editor (a daemon started from a + // pane, a stale environment carried into a new terminal), which is not + // nested at all. + const owner = std.mem.span(std.c.getenv("PARDES_PID") orelse break :forwarding); + const pid = std.fmt.parseInt(std.c.pid_t, owner, 10) catch break :forwarding; + if (pid <= 0 or std.c.kill(pid, @enumFromInt(0)) != 0) break :forwarding; + + // Can I reach it? That is the other two, and it is a separate answer. + const Parent = struct { dial: []const u8, serial: u32 }; + const found: ?Parent = reaching: { + const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :reaching null); + ninep_io.Client.validateDial(dial) catch break :reaching null; + const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :reaching null); + for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :reaching null; + const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :reaching null; + if (serial == 0) break :reaching null; + break :reaching .{ .dial = dial, .serial = serial }; + }; + const parent = found orelse { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; + // The pane's `look` file: one line, and the line is the clicked text + // itself, which is what a right click in that pane would have been. + var look_buf: [64]u8 = undefined; + const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial}); const word = positional orelse { var tag_buf: [64]u8 = undefined; - const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{serial}); - const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; + const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); + const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; + // A word this launch cannot turn into a path is an argument problem + // rather than a nesting one: fall through and let the session that + // starts report it, the way `pardes typo` outside pardes does. if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; var command_buf: [8192]u8 = undefined; - const command = std.fmt.bufPrint(&command_buf, "look {s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; - ninep_io.Client.write(arena, dial, ctl, command) catch break :forwarding; + const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, look, command) catch { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; return; } if (positional) |a| { diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index f7642b9e..566e2364 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -255,17 +255,25 @@ test "the address language, form by form" { try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); + // limit is its own file: copying addr onto it bounds the search, and + // truncating it lifts the bound again. + const limit = Node.of(serial, .limit); _ = th.wr(p, addr, "1"); - _ = th.wr(p, Node.of(serial, .ctl), "limit=addr\n"); + try testing.expectEqualStrings("", th.rd(p, limit, 0, 64).bytes); + var copied: [64]u8 = undefined; + const pair = th.rd(p, addr, 0, 64).bytes; + @memcpy(copied[0..pair.len], pair); + _ = th.wr(p, limit, copied[0..pair.len]); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].limit.?.q1); + try testing.expectEqualStrings(" 0 4 ", th.rd(p, limit, 0, 64).bytes); _ = th.wr(p, addr, "#0"); try testing.expectEqual(E.INVAL, th.wr(p, addr, "/three/").errno()); - _ = th.wr(p, Node.of(serial, .ctl), "clean\n"); - _ = th.call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); + _ = th.call(p, .{ .tag = 6, .op = .setattr, .node = limit, .truncate = true }); try testing.expect(p.fs.panes[0].limit == null); _ = th.wr(p, addr, "#0"); try testing.expectEqual(Status.ok, th.wr(p, addr, "/three/").reply.status); - for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { + for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2 3", "99", "/a\\" }) |bad| { _ = th.wr(p, addr, "#0"); try testing.expectEqual(E.INVAL, th.wr(p, addr, bad).errno()); } diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index d61ebf75..c213ece5 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1,6 +1,9 @@ -//! The two ctl files. `look TEXT` is a right click and `exec TEXT` a middle -//! click in the editor's own command language; a pane's ctl adds the addr -//! verbs. Reading /ctl answers the serials the last command made or touched. +//! The command files and the status they answer. Writing a line to `look` is +//! a right click on it and writing one to `exec` a middle click, at the +//! active pane from the root and at that pane from /pane/<n>/; reading either +//! answers the serials the last command made or touched. /status reports the +//! editor, and a pane's ctl its acme status line and the one verb, `get`, +//! that no file of its own would say any better. const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); @@ -16,36 +19,6 @@ const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; -const Verb = enum { - @"addr=dot", - clean, - cleartag, - dirty, - @"dot=addr", - get, - @"limit=addr", - mark, - nomark, - noscroll, - scroll, - show, -}; - -fn verbIs(line: []const u8, word: []const u8) bool { - if (!std.mem.startsWith(u8, line, word)) return false; - return line.len == word.len or line[word.len] == ' ' or line[word.len] == '\t'; -} - -/// The text after `look` or `exec`, or null when the line is not that verb. -/// Control characters other than tab are refused. -fn textAfter(line: []const u8, verb: []const u8) ?[]const u8 { - if (!verbIs(line, verb)) return null; - const text = std.mem.trim(u8, line[verb.len..], " \t"); - if (text.len == 0) return ""; - for (text) |c| if (c < ' ' and c != '\t') return ""; - return text; -} - // ---- which panes a command made or touched ---- const Serials = struct { @@ -96,37 +69,49 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { noteResult(p, if (exec) context else activeSerial(p) orelse return); } -fn beginResults(p: *Pardes) void { - p.fs.results_len = 0; - p.fs.results_valid = true; -} - -// ---- /ctl ---- +// ---- look and exec ---- -pub fn writeRoot(p: *Pardes, req: Req) Reply { +/// One command per line, checked whole before any line runs: a line is the +/// clicked text itself, so it may not be empty or carry control characters. +/// A null `serial` clicks at whichever pane is active as each line runs, +/// which is what the root's own look and exec mean; a serial stays put. +pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply { for ([2]bool{ false, true }) |apply| { - if (apply) beginResults(p); + if (apply) p.fs.results_len = 0; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - const exec = verbIs(line, "exec"); - const text = textAfter(line, if (exec) "exec" else "look") orelse return Reply.fail(req.tag, E.INVAL); - if (text.len == 0) return Reply.fail(req.tag, E.INVAL); + const text = std.mem.trim(u8, raw, " \t\r"); + if (text.len == 0) continue; + for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL); if (!apply) continue; - if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); - run(p, p.active, exec, text); + const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; + if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); + run(p, id, exec, text); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -pub fn rootText(p: *Pardes, buf: []u8) []const u8 { - var w = std.Io.Writer.fixed(buf); - if (p.fs.results_valid) { - for (p.fs.results[0..p.fs.results_len]) |serial| w.print("{d}\n", .{serial}) catch break; - return w.buffered(); +pub fn resultsLen(p: *Pardes) u64 { + var n: u64 = 0; + for (p.fs.results[0..p.fs.results_len]) |serial| { + var digits: [16]u8 = undefined; + n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len; } + return n; +} + +pub fn readResults(p: *Pardes, req: Req) Reply { + const out = p.fs.stage(p.gpa); + for (p.fs.results[0..p.fs.results_len]) |serial| + out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +// ---- /status ---- + +fn statusText(p: *Pardes, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0; @@ -134,13 +119,17 @@ pub fn rootText(p: *Pardes, buf: []u8) []const u8 { return w.buffered(); } -pub const root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len); +pub const status_text_cap = 64 + pardes.version.len; + +pub fn statusLen(p: *Pardes) u64 { + var buf: [status_text_cap]u8 = undefined; + return statusText(p, &buf).len; +} -pub fn readRoot(p: *Pardes, req: Req) Reply { - var buf: [root_text_cap]u8 = undefined; - const text = rootText(p, &buf); +pub fn readStatus(p: *Pardes, req: Req) Reply { + var buf: [status_text_cap]u8 = undefined; const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + out.appendSlice(p.gpa, statusText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } @@ -190,74 +179,35 @@ pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { return tree.stagedReply(p, req); } -pub fn writePane(p: *Pardes, req: Req, serial: u32) Reply { - for ([2]bool{ false, true }) |apply| { - if (apply) beginResults(p); - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); - const errno = paneVerb(p, live, line, apply); - if (errno != 0) return Reply.fail(req.tag, errno); - } +/// `get` is the one thing here that no file of the pane's own would say: it +/// reloads the buffer from the name it carries, wherever that name resolves. +/// Repeating it in one write would only reload the same bytes, so it runs once. +pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { + var asked = false; + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (!std.mem.eql(u8, line, "get")) return Reply.fail(req.tag, E.INVAL); + asked = true; + } + if (asked) { + const errno = get(p, pane); + if (errno != 0) return Reply.fail(req.tag, errno); } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -fn paneVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) u16 { - const pane = p.panes[id] orelse return E.INVAL; - const pf = &p.fs.panes[id]; - - for ([_]bool{ false, true }) |exec| if (textAfter(line, if (exec) "exec" else "look")) |text| { - if (text.len == 0) return E.INVAL; - if (apply) run(p, id, exec, text); - return 0; +fn get(p: *Pardes, pane: *Pane) u16 { + const f = pane_files.fileOf(pane) orelse return 0; + if (!panes.Output.fileTraits(f.output).saves) return 0; + const bytes = fs.read(p, f.path) catch |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, }; - const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; - if (!apply) return 0; - - switch (v) { - .@"addr=dot" => pf.addr = pane_files.dotOf(pane), - .@"dot=addr" => { - pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); - pane_files.setDot(pane, pf.addr); - }, - .@"limit=addr" => { - pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); - pf.limit = pf.addr; - }, - .clean => if (pane_files.fileOf(pane)) |f| { - f.saved_revision = f.revision; - }, - .dirty => if (pane_files.fileOf(pane)) |f| { - f.saved_revision = f.revision -% 1; - }, - .cleartag => { - pane.tag_tail_len = 0; - pane.tag_init = true; - }, - .get => if (pane_files.fileOf(pane)) |f| { - if (panes.Output.fileTraits(f.output).saves) { - if (fs.read(p, f.path)) |bytes| { - panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, bytes); - f.saved_revision = f.revision; - } else |err| return switch (err) { - error.FileNotFound => E.NOENT, - else => E.IO, - }; - } - }, - .mark => { - pf.nomark = false; - panes.File.pushUndo(p, pane); - }, - .nomark => pf.nomark = true, - .noscroll => pf.noscroll = true, - .scroll => pf.noscroll = false, - .show => pane_files.showOffset(pane, pane_files.dotOf(pane).q0), - } + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; return 0; } @@ -272,7 +222,9 @@ const withFile = th.withFile; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; -const root_ctl = @intFromEnum(tree.TopFile.ctl); +const root_status = @intFromEnum(tree.TopFile.status); +const root_look = @intFromEnum(tree.TopFile.look); +const root_exec = @intFromEnum(tree.TopFile.exec); test "pane ctl read is index's five fields plus width in cells, font and tab width" { const gpa = testing.allocator; @@ -299,37 +251,18 @@ test "pane ctl read is index's five fields plus width in cells, font and tab wid try testing.expectEqualStrings("'it''s'", w.buffered()); } -test "every pane ctl verb, and every refusal" { +test "the pane ctl takes get, and nothing that a file of its own now answers" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); - const serial = serialOf(p); - const ctl_node = Node.of(serial, .ctl); - const pane = p.panes[0].?; - const pf = &p.fs.panes[0]; - - try testing.expectEqual(Status.ok, wr(p, ctl_node, "nomark\nnoscroll\ndirty\n").reply.status); - try testing.expect(pf.nomark and pf.noscroll and pane_files.dirtyOf(pane)); - try testing.expectEqual(Status.ok, wr(p, ctl_node, "mark\nscroll\nclean\n").reply.status); - try testing.expect(!pf.nomark and !pf.noscroll and !pane_files.dirtyOf(pane)); - - _ = wr(p, ctl_node, "cleartag\n"); - try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); - - _ = wr(p, Node.of(serial, .addr), "2"); - _ = wr(p, ctl_node, "limit=addr\n"); - try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); - _ = wr(p, ctl_node, "dot=addr\nshow\n"); - try testing.expectEqual(@as(i32, 1), pane.cur_row); - + const ctl_node = Node.of(serialOf(p), .ctl); for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", - "name x.txt", "put", "del", "delete", "Look x", "Exec Save", + "menu", "nomenu", "dump echo hi", "font Go Mono", "lock", "bogus", "DEL", + "name x.txt", "put", "del", "delete", "Look x", "Exec Save", "clean", + "dirty", "cleartag", "dot=addr", "addr=dot", "show", "mark", "nomark", + "scroll", "limit=addr", "get x", "look /tmp", "exec Del", }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno()); - - try testing.expect(!pane_files.dirtyOf(pane)); - try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nbogus\n").errno()); - try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expect(p.paneBySerial(serialOf(p)) != null); } test "exec runs a builtin at the pane and records the pane it acted on" { @@ -338,61 +271,69 @@ test "exec runs a builtin at the pane and records the pane it acted on" { defer p.deinit(); const serial = serialOf(p); const pane = p.panes[0].?; - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Msg from ctl\n").reply.status); - try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Msg from exec\n").reply.status); + try testing.expectEqualStrings("from exec", pane.msg[0..pane.msg_len]); var expected: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{serial}), rd(p, root_ctl, 0, 4096).bytes); + const own = try std.fmt.bufPrint(&expected, "{d}\n", .{serial}); + try testing.expectEqualStrings(own, rd(p, root_exec, 0, 4096).bytes); + // Either command file reads back what the last one did. + try testing.expectEqualStrings(own, rd(p, Node.of(serial, .look), 0, 4096).bytes); + try testing.expectEqual(@as(u64, own.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_exec }).reply.attr.size); - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec New\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "New\n").reply.status); const made = p.panes[p.active].?; try testing.expect(made.serial != serial); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); - try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "exec Del\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .exec), "Del\n").reply.status); try testing.expect(p.paneBySerial(made.serial) == null); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } -test "the root ctl reads editor status until a command runs and validates whole batches" { +test "status reads the editor, and look and exec validate whole batches" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg root\n"); defer p.deinit(); - const status = rd(p, root_ctl, 0, 4096); + const status = rd(p, root_status, 0, 4096); try testing.expectEqual(Status.ok, status.reply.status); var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n'); try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid ")); var expected: [64]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?); - try testing.expectEqualStrings("panes 1", lines.next().?); + var live: usize = 0; + for (p.panes) |slot| live += @intFromBool(slot != null); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "panes {d}", .{live}), lines.next().?); try testing.expect(lines.next() == null); - const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }); + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_status }); try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size); - try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); + try testing.expectEqual(E.PERM, wr(p, root_status, "anything\n").errno()); + try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); const serial = p.next_serial; - for ([_][]const u8{ "look", "exec", "look \t", "Look /virtual/index", "exec Msg a\x00b", "exec Msg x\nbogus\n", "name x", "put" }) |invalid| { - try testing.expectEqual(E.INVAL, wr(p, root_ctl, invalid).errno()); + for ([_][]const u8{ "Msg a\x00b", "Msg x\nMsg \x01y\n" }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, root_exec, invalid).errno()); try testing.expectEqual(serial, p.next_serial); } - try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes); + // An empty write is a no-op, not a command. + try testing.expectEqual(Status.ok, wr(p, root_exec, "\n \n").reply.status); + try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); + try testing.expectEqualStrings(status.bytes, rd(p, root_status, 0, 4096).bytes); const pane = p.panes[0].?; - try testing.expectEqual(Status.ok, wr(p, root_ctl, "exec Msg hello there\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg hello there\n").reply.status); try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_exec, 0, 4096).bytes); - try testing.expectEqual(Status.ok, wr(p, root_ctl, "look /virtual/index\nexec New\n").reply.status); - var made: [MAX_PANES]u32 = undefined; - var n: usize = 0; - for (p.panes) |slot| if (slot) |q| if (q.serial != pane.serial) { - made[n] = q.serial; - n += 1; - }; - try testing.expectEqual(@as(usize, 2), n); - const listed = rd(p, root_ctl, 0, 4096); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n{d}\n", .{ @min(made[0], made[1]), @max(made[0], made[1]) }), listed.bytes); - const opened = p.panes[p.paneBySerial(@min(made[0], made[1])).?].?; + try testing.expectEqual(Status.ok, wr(p, root_look, "/virtual/index\n").reply.status); + const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_exec, "New\n").reply.status); + const made = p.panes[p.active].?; + try testing.expect(made.serial != opened.serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } test "look opens spaced paths and locations without editing the source pane" { @@ -406,12 +347,12 @@ test "look opens spaced paths and locations without editing the source pane" { const p = try withFile(gpa, "source stays intact\n"); defer p.deinit(); const source = p.panes[0].?; - const ctl_node = Node.of(source.serial, .ctl); + const look_node = Node.of(source.serial, .look); const revision = source.file.?.revision; const undo_len = source.file.?.history.undo_len; source.cur_col = 3; - var command: [4200]u8 = undefined; - try testing.expectEqual(Status.ok, wr(p, ctl_node, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); + var clicked: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&clicked, "{s}:2:3\n", .{path})).reply.status); const opened = p.panes[p.active].?; try testing.expect(opened != source); try testing.expectEqualStrings(path, opened.file.?.path); @@ -423,14 +364,14 @@ test "look opens spaced paths and locations without editing the source pane" { try testing.expectEqual(undo_len, source.file.?.history.undo_len); try testing.expectEqual(@as(i32, 3), source.cur_col); var expected: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); p.active = 0; - try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); + try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&clicked, "{s}:3:2\n", .{path})).reply.status); try testing.expect(p.panes[p.active].? == opened); try testing.expectEqual(@as(i32, 2), opened.cur_row); try testing.expectEqual(@as(i32, 1), opened.cur_col); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); } test "look validates the whole batch before opening virtual files" { @@ -438,17 +379,17 @@ test "look validates the whole batch before opening virtual files" { const p = try withFile(gpa, "source\n"); defer p.deinit(); const source = p.panes[0].?; - const ctl_node = Node.of(source.serial, .ctl); + const look_node = Node.of(source.serial, .look); const serial = p.next_serial; for ([_][]const u8{ - "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + "/virtual/index\x00ignored", "/virtual/index\n/virtual/\x01index\n", }) |invalid| { - try testing.expectEqual(E.INVAL, wr(p, ctl_node, invalid).errno()); + try testing.expectEqual(E.INVAL, wr(p, look_node, invalid).errno()); try testing.expectEqual(serial, p.next_serial); try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqualStrings("source\n", source.file.?.content); } - try testing.expectEqual(Status.ok, wr(p, ctl_node, "look /n/self/index\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, look_node, "/n/self/index\n").reply.status); const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); @@ -480,7 +421,7 @@ test "get reloads the pane from disk and reports a missing file without losing d _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); _ = wr(p, Node.of(serial, .body), "unsaved\n"); - try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\nexec Del\n").errno()); + try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno()); try testing.expect(p.paneBySerial(serial) != null); try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content); try testing.expect(pane_files.dirtyOf(pane)); @@ -494,7 +435,7 @@ test "exec Save writes the named file through the host" { const pane = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status); try testing.expect(pane_files.dirtyOf(pane)); - const saved = wr(p, Node.of(serial, .ctl), "exec Save\n"); + const saved = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expect(saved.saved); p.perform(.{ .save_file = .{ .pane = 0 } }); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index fb49e41b..bd53bcd9 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -89,6 +89,13 @@ pub const Queue = struct { } }; +/// What the next read would answer, which is the size a stat reports: a +/// client can see there is something waiting without parking on it. +pub fn pending(q: *const Queue) u64 { + const record = q.peek() orelse return 0; + return record.len; +} + /// One record per read; `.again` parks the read until a record arrives. pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; @@ -461,7 +468,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" const b = call(p, .{ .tag = 19, .op = .open, .node = event }); try testing.expectEqual(@as(u16, 2), p.fs.listeners); - _ = wr(p, Node.of(serial, .ctl), "exec Del\n"); + _ = wr(p, Node.of(serial, .exec), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); @@ -471,7 +478,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .dirty), "0\n").errno()); try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); } @@ -535,7 +542,7 @@ test "the log parks until a pane is created, renamed, saved or deleted" { try std.fmt.bufPrint(&expected, "rename {d} /tmp/logged.txt\n", .{serial}), rd(p, log, 0, 4096).bytes, ); - const saving = wr(p, Node.of(serial, .ctl), "exec Save\n"); + const saving = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saving.reply.status); try testing.expect(saving.saved); p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); @@ -543,7 +550,7 @@ test "the log parks until a pane is created, renamed, saved or deleted" { try std.fmt.bufPrint(&expected, "save {d} /tmp/logged.txt\n", .{serial}), rd(p, log, 0, 4096).bytes, ); - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Del\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status); try testing.expectEqualStrings( try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}), rd(p, log, 0, 4096).bytes, diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index b37ae645..42c0c48e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -1,5 +1,6 @@ -//! The files under /pane/<serial>/: name, body, tag, ctl, addr, data, xdata, -//! sel, errors and event. Terminal panes add pty/ (pty.zig). +//! The files under /pane/<serial>/: name, body, tag, ctl, the three range +//! files addr, dot and limit, data, xdata, sel, the three flag files dirty, +//! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); @@ -187,10 +188,15 @@ pub fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, byte return take; } -// ---- sizes reported by stat ---- +// ---- what stat reports ---- + +/// A range file's text: acme's addr format, two right-aligned offsets, so +/// that copying one range file onto another is the whole of `dot=addr`. +pub const range_len: u64 = 24; pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; + const pf = &p.fs.panes[id]; return switch (f) { .body, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, @@ -203,7 +209,24 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const d = dotOf(pane); break :sel @max(d.q0, d.q1) - d.q0; }, - .dir, .addr, .errors, .event, .pty, .pty_ctl, .pty_status, .pty_data => 0, + .addr, .dot => range_len, + .limit => if (pf.limit == null) 0 else range_len, + .dirty, .mark, .scroll => 2, + .look, .exec => ctl.resultsLen(p), + .event => events.pending(&pf.events), + .pty_status => pty.status_len, + .pty_data => events.pending(&pf.pty_out), + .dir, .errors, .pty, .pty_ctl => 0, + }; +} + +/// The qid version, which a client polls to watch a file for change. Only +/// the pane's text can promise one: its revision counts every edit, and +/// nothing else here changes in step with it. +pub fn versionOf(pane: *const Pane, f: PaneFile) u32 { + return switch (f) { + .body, .data, .xdata => if (pane.file) |*file| file.revision else 0, + else => 0, }; } @@ -246,13 +269,17 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .ctl => ctl.readPane(p, req, pane), .addr => addr: { clampAddr(pf, bodyOf(pane).len); - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; - break :addr tree.stagedReply(p, req); + break :addr readRange(p, req, pf.addr); }, + .dot => readRange(p, req, dotOf(pane)), + .limit => readRange(p, req, pf.limit), .data => readData(req, id, pane, pf, false), .xdata => readData(req, id, pane, pf, true), .sel => readSel(req, id, pane), + .dirty => readFlag(p, req, dirtyOf(pane)), + .mark => readFlag(p, req, !pf.nomark), + .scroll => readFlag(p, req, !pf.noscroll), + .look, .exec => ctl.readResults(p, req), .event => events.readQueue(p, req, &pf.events), .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), @@ -260,6 +287,20 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply }; } +/// An unset limit reads empty, which is how a client tells it from one that +/// happens to bound nothing. +fn readRange(p: *Pardes, req: Req, r: ?State.Range) Reply { + const out = p.fs.stage(p.gpa); + if (r) |range| out.print(p.gpa, "{d:>11} {d:>11} ", .{ range.q0, range.q1 }) catch {}; + return tree.stagedReply(p, req); +} + +fn readFlag(p: *Pardes, req: Req, on: bool) Reply { + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, if (on) "1\n" else "0\n") catch {}; + return tree.stagedReply(p, req); +} + fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (pane.file != null) { const text = bodyOf(pane); @@ -322,10 +363,12 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .name => writeName(p, req, id, pane), .body => writeBody(p, req, id, pane), .tag => writeTag(p, req, pane), - .ctl => ctl.writePane(p, req, pane.serial), - .addr => writeAddr(p, req, id, pane), + .ctl => ctl.writePane(p, req, pane), + .addr, .dot, .limit => writeRange(p, req, id, pane, file), .data, .xdata => writeData(p, req, id, pane), .sel => writeSel(p, req, id, pane), + .dirty, .mark, .scroll => writeFlag(p, req, id, pane, file), + .look, .exec => ctl.command(p, req, pane.serial, file == .exec), .event => events.writeEvent(p, req, id), .errors => if (appendErrors(p, id, req.data)) |took| .{ .tag = req.tag, .written = @intCast(took) } @@ -389,15 +432,68 @@ fn writeSel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } -fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { +/// The pair of offsets a range file reads back, so that copying `addr` onto +/// `dot` or `limit` does what acme spelled `dot=addr` and `limit=addr`. +fn pairOf(text: []const u8) ?State.Range { + var it = std.mem.tokenizeAny(u8, text, " \t"); + const q0 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; + const q1 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; + if (it.next() != null) return null; + return .{ .q0 = q0, .q1 = @max(q0, q1) }; +} + +/// A range file takes an address expression, or that pair of offsets. +fn rangeOf(pf: *State, text: []const u8, data: []const u8) ?State.Range { + const expr = std.mem.trimEnd(u8, data, "\n"); + if (pairOf(expr)) |r| { + const n = clip(text.len); + return .{ .q0 = @min(r.q0, n), .q1 = @min(r.q1, n) }; + } + var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; + const r = a.address(pf.addr) orelse return null; + return if (a.i < expr.len) null else r; +} + +fn writeRange(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { const pf = &p.fs.panes[id]; const text = bodyOf(pane); clampAddr(pf, text.len); - const expr = std.mem.trimEnd(u8, req.data, "\n"); - var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); - if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); - pf.addr = r; + const r = rangeOf(pf, text, req.data) orelse return Reply.fail(req.tag, E.INVAL); + switch (file) { + .addr => pf.addr = r, + .limit => pf.limit = r, + // Setting dot scrolls to it, which is the whole of acme's `show`. + .dot => { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + setDot(pane, r); + }, + else => unreachable, + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn flagOf(data: []const u8) ?bool { + const word = std.mem.trim(u8, data, " \t\r\n"); + if (std.mem.eql(u8, word, "1")) return true; + if (std.mem.eql(u8, word, "0")) return false; + return null; +} + +fn writeFlag(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { + const on = flagOf(req.data) orelse return Reply.fail(req.tag, E.INVAL); + const pf = &p.fs.panes[id]; + switch (file) { + .dirty => { + const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL); + f.saved_revision = if (on) f.revision -% 1 else f.revision; + }, + .mark => { + pf.nomark = !on; + if (on) panes.File.pushUndo(p, pane); + }, + .scroll => pf.noscroll = !on, + else => unreachable, + } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -475,14 +571,24 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { return text.len; } -/// Truncation empties a body; every other setattr just answers fresh attributes. +/// Truncation empties whatever the file holds: the body, the tag's editable +/// tail (acme's `cleartag`), a range. Every other setattr just answers fresh +/// attributes. pub fn truncate(p: *Pardes, id: usize, pane: *Pane, file: PaneFile) tree.Status { + const pf = &p.fs.panes[id]; switch (file) { .body, .data, .xdata => if (fileOf(pane) != null) { _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse return .err; - p.fs.panes[id].addr = .{}; + pf.addr = .{}; setDot(pane, .{}); }, + .tag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .addr => pf.addr = .{}, + .limit => pf.limit = null, + .dot => if (fileOf(pane) != null) setDot(pane, .{}), else => {}, } return .ok; @@ -509,15 +615,19 @@ test "index lists serial kind dirty flag and name and stat reports its length" { const a = rd(p, index, 0, 4096); try testing.expectEqual(Status.ok, a.reply.status); var want: [64]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}), a.bytes); + const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}); + try testing.expect(std.mem.indexOf(u8, a.bytes, clean_row) != null); + const at = std.mem.indexOf(u8, a.bytes, clean_row).?; const stat = call(p, .{ .tag = 1, .op = .getattr, .node = index }); try testing.expectEqual(@as(u64, a.bytes.len), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); pane.file.?.saved_revision = pane.file.?.revision -% 1; - try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 1 /test.txt\n", .{pane.serial}), rd(p, index, 0, 4096).bytes); - const mid = rd(p, index, 2, 4); - try testing.expectEqualStrings("text", mid.bytes); + var dirty_want: [64]u8 = undefined; + const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt\n", .{pane.serial}); + try testing.expect(std.mem.indexOf(u8, rd(p, index, 0, 4096).bytes, dirty_row) != null); + const kind = at + std.mem.indexOfScalar(u8, clean_row, ' ').? + 1; + try testing.expectEqualStrings("text", rd(p, index, kind, 4).bytes); const serial = try th.newPane(p); const both = rd(p, index, 0, 4096); @@ -533,10 +643,11 @@ test "a terminal is listed as term with its directory as name" { const pane = p.panes[0].?; p.setCwd(0, "/work/dir"); var want: [64]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}), + try testing.expect(std.mem.indexOf( + u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, - ); + try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}), + ) != null); try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes); try testing.expectEqual(E.PERM, wr(p, Node.of(pane.serial, .name), "/elsewhere\n").errno()); } @@ -566,6 +677,13 @@ test "body reads at any offset and writes append" { try testing.expectEqual(@as(u64, "one\ntwo\nthree\na".len), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); try testing.expect(stat.reply.attr.mtime >= p.fs.started); + + // The qid version is the pane's revision, so a stat sees an edit land. + try testing.expectEqual(p.panes[0].?.file.?.revision, stat.reply.attr.version); + _ = wr(p, body, "more\n"); + const later = call(p, .{ .tag = 7, .op = .getattr, .node = body }); + try testing.expect(later.reply.attr.version != stat.reply.attr.version); + try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 8, .op = .getattr, .node = Node.of(serial, .tag) }).reply.attr.version); } test "a body write to a terminal pane types at its shell" { @@ -653,18 +771,29 @@ test "sel reads the selection and writing sel replaces it" { const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); - const ctl_node = Node.of(serial, .ctl); + const addr_node = Node.of(serial, .addr); + const dot = Node.of(serial, .dot); const sel = Node.of(serial, .sel); - _ = wr(p, Node.of(serial, .addr), "#0,#3"); - try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\n").reply.status); + // Copying addr onto dot is the whole of acme's `dot=addr`, and back again + // of `addr=dot`; each range file reads exactly what the other takes. + _ = wr(p, addr_node, "#0,#3"); + try testing.expectEqual(Status.ok, wr(p, dot, " 0 3 ").reply.status); + try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 100).bytes); + try testing.expectEqual(range_len, call(p, .{ .tag = 1, .op = .getattr, .node = dot }).reply.attr.size); try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes); try testing.expectEqual(@as(u64, 3), call(p, .{ .tag = 1, .op = .getattr, .node = sel }).reply.attr.size); - _ = wr(p, ctl_node, "addr=dot\n"); + _ = wr(p, addr_node, "#0"); + _ = wr(p, addr_node, " 0 3 "); try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + // A truncation empties dot; an address expression addresses it. + _ = call(p, .{ .tag = 9, .op = .setattr, .node = dot, .truncate = true }); + try testing.expectEqualStrings("", rd(p, sel, 0, 100).bytes); + try testing.expectEqual(Status.ok, wr(p, dot, "#0,#3").reply.status); + try testing.expectEqual(Status.ok, wr(p, sel, "ONE").reply.status); try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); _ = wr(p, sel, "!"); @@ -672,6 +801,62 @@ test "sel reads the selection and writing sel replaces it" { try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 2, .op = .getattr, .node = sel }).reply.attr.size); } +test "the flag files read and write the state acme kept behind ctl verbs" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + const dirty = Node.of(serial, .dirty); + const mark = Node.of(serial, .mark); + const scroll = Node.of(serial, .scroll); + + for ([_]u64{ dirty, mark, scroll }) |node| { + try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); + for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| + try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); + } + + try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); + try testing.expectEqualStrings("1\n", rd(p, mark, 0, 8).bytes); + try testing.expectEqualStrings("1\n", rd(p, scroll, 0, 8).bytes); + + try testing.expectEqual(Status.ok, wr(p, dirty, "1\n").reply.status); + try testing.expect(dirtyOf(pane)); + try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes); + _ = wr(p, dirty, "0"); + try testing.expect(!dirtyOf(pane)); + + _ = wr(p, mark, "0"); + _ = wr(p, scroll, "0"); + try testing.expect(pf.nomark and pf.noscroll); + try testing.expectEqualStrings("0\n", rd(p, mark, 0, 8).bytes); + try testing.expectEqualStrings("0\n", rd(p, scroll, 0, 8).bytes); + _ = wr(p, mark, "1"); + _ = wr(p, scroll, "1"); + try testing.expect(!pf.nomark and !pf.noscroll); + + // A terminal has no file, so its dirty flag is readable but not writable. + const term = try th.withTerm(gpa); + defer term.deinit(); + try testing.expectEqualStrings("0\n", rd(term, Node.of(serialOf(term), .dirty), 0, 8).bytes); + try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno()); +} + +test "truncating the tag clears its editable tail" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + const tag = Node.of(pane.serial, .tag); + _ = wr(p, tag, " Mine"); + try testing.expect(pane.tag_tail_len > 0); + const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); + try testing.expectEqual(Status.ok, cleared.reply.status); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null); +} + test "name reads the file name and writing it promotes a scratch without touching its body" { const gpa = testing.allocator; const p = try withFile(gpa, "opener\n"); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index e6696b85..1b71a8d3 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -76,6 +76,9 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { return true; } +/// Three right-aligned fields: cols, rows and whether the host holds the tty. +pub const status_len: u64 = 36; + pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const out = p.fs.stage(p.gpa); out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ @@ -146,7 +149,7 @@ test "a pane that is not a terminal has no pty/ at all" { var buf: [32]th.Dirent = undefined; const files = th.dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); + try testing.expectEqual(@as(usize, 17), files.len); try testing.expect(th.nameAt(files, "pty") == null); try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); @@ -167,7 +170,7 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" { var buf: [32]th.Dirent = undefined; const files = th.dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 11), files.len); + try testing.expectEqual(@as(usize, 18), files.len); try testing.expect(th.nameAt(files, "pty").?.dir); const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); @@ -247,7 +250,7 @@ test "every pty/ctl verb, and every refusal" { "cooked", "winsize 80 24\nbogus", // a good verb beside a bad one "bogus\nwinsize 80 24", - "look x", // a `ctl` verb; the two files share no vocabulary + "look x", // a pane command; pty/ctl shares none of that vocabulary "exec Del", }) |bad| { const a = wr(p, ctl, bad); diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index 5b68b34c..2684aa41 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -83,22 +83,33 @@ pub fn releaseSnapshot(p: *Pardes, req: Req) bool { return true; } -pub fn readListeners(p: *Pardes, req: Req) Reply { - var buf: [512]u8 = undefined; - var text = std.Io.Writer.fixed(&buf); +const listeners_cap = 512; + +fn listenersText(p: *Pardes, buf: []u8) []const u8 { + var text = std.Io.Writer.fixed(buf); if (p.fs.socket_path.len != 0) - text.print("unix!{s}\n", .{p.fs.socket_path}) catch return Reply.fail(req.tag, E.IO); + text.print("unix!{s}\n", .{p.fs.socket_path}) catch return text.buffered(); for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { const address = maybe orelse continue; switch (address) { .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch - return Reply.fail(req.tag, E.IO), + return text.buffered(), .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch - return Reply.fail(req.tag, E.IO), + return text.buffered(), } } + return text.buffered(); +} + +pub fn listenersLen(p: *Pardes) u64 { + var buf: [listeners_cap]u8 = undefined; + return listenersText(p, &buf).len; +} + +pub fn readListeners(p: *Pardes, req: Req) Reply { + var buf: [listeners_cap]u8 = undefined; const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text.buffered()) catch return Reply.fail(req.tag, E.NOMEM); + out.appendSlice(p.gpa, listenersText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index ba26cb38..ad1d84e4 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -69,13 +69,30 @@ pub fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); } -/// Opens /new once, as a client would, and returns the pane it made. +/// Creates a pane in /pane, as a client's mkdir does, and returns its serial. pub fn newPane(p: *Pardes) !u32 { - const new = @intFromEnum(tree.TopFile.new); - const opened = call(p, .{ .tag = 9, .op = .open, .node = new }); - if (opened.reply.status != .ok or opened.reply.handle == 0) return error.NoPane; - _ = call(p, .{ .tag = 9, .op = .release, .node = new, .handle = opened.reply.handle }); - return opened.reply.handle; + const made = mkdir(p, "scratch"); + if (made.reply.status != .ok) return error.NoPane; + const target = tree.Node.target(made.reply.attr.node) orelse return error.NoPane; + return switch (target) { + .pane => |t| t.serial, + .top => error.NoPane, + }; +} + +pub fn mkdir(p: *Pardes, name: []const u8) Answer { + return call(p, .{ + .tag = 9, + .op = .open, + .node = @intFromEnum(tree.TopFile.pane), + .data = name, + .create = true, + .perm = 0x8000_0000 | 0o755, + }); +} + +pub fn rmdir(p: *Pardes, node: u64) Answer { + return call(p, .{ .tag = 9, .op = .release, .node = node, .remove = true }); } pub fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 56c3112d..46609649 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -4,8 +4,10 @@ //! extended here with the editor's reply payload; fs.zig keeps host access //! and mounts. //! -//! /README /index /ctl /new /log /screen /listeners -//! /pane/<serial>/{name,body,tag,ctl,addr,data,xdata,sel,errors,event,pty/} +//! /README /index /status /look /exec /log /screen /listeners +//! /pane/ mkdir makes a pane, rmdir closes it +//! /pane/<serial>/{name,body,tag,ctl,addr,dot,limit,data,xdata,sel, +//! dirty,mark,scroll,errors,event,look,exec,pty/} //! /os/... the host filesystem /src/... embedded sources (opt-in) const std = @import("std"); const cloud9 = @import("cloud9"); @@ -47,11 +49,16 @@ pub const Payload = union(enum) { pub const Reply = cloud9.fs.ReplyWith(Payload); +/// Tcreate opens a pane and Tremove closes one; nothing else in the tree is +/// created or destroyed by the protocol, and wstat stays a truncation. +pub const features: cloud9.fs.Features = .{ .create = true, .remove = true }; + pub fn changesPane(req: Req) bool { return switch (req.op) { .write, .setattr => true, - .open => req.node == @intFromEnum(TopFile.new), - .lookup, .getattr, .read, .release, .readdir => false, + .open => req.create, + .release => req.remove, + .lookup, .getattr, .read, .readdir => false, }; } @@ -63,8 +70,9 @@ pub const TopFile = enum(u4) { root = 1, README, index, - ctl, - new, + status, + look, + exec, log, screen, listeners, @@ -77,8 +85,8 @@ pub const TopFile = enum(u4) { pub fn mode(f: TopFile) u16 { return switch (f) { .root, .pane => 0o755, - .ctl => 0o666, - .README, .index, .new, .log, .screen, .listeners => 0o444, + .look, .exec => 0o666, + .README, .index, .status, .log, .screen, .listeners => 0o444, }; } @@ -89,18 +97,26 @@ pub const TopFile = enum(u4) { pub const root: u64 = @intFromEnum(TopFile.root); -pub const PaneFile = enum(u4) { +/// Declaration order is readdir order. +pub const PaneFile = enum(u5) { dir = 0, name, body, tag, ctl, addr, + dot, + limit, data, xdata, sel, + dirty, + mark, + scroll, errors, event, + look, + exec, pty, pty_ctl, pty_status, @@ -138,8 +154,8 @@ pub const PaneFile = enum(u4) { }; pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, + file: u5 = 0, + serial: u59 = 0, pub fn of(serial: u32, file: PaneFile) u64 { std.debug.assert(serial != 0); @@ -240,8 +256,13 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { // ---- dispatch ---- pub fn handle(p: *Pardes, req: Req) Reply { - if (req.node == fs.os_root or req.node & fs.os_node != 0) return fs.osHandle(p, req); - if (req.node & sources.archive_node != 0) return sources.handle(p, req); + const host = req.node == fs.os_root or req.node & fs.os_node != 0; + const archive = req.node & sources.archive_node != 0; + // Only /pane is created in and removed from. The host tree and the + // embedded sources say so, rather than quietly doing nothing. + if ((host or archive) and (req.create or req.remove)) return Reply.fail(req.tag, E.PERM); + if (host) return fs.osHandle(p, req); + if (archive) return sources.handle(p, req); const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), @@ -280,6 +301,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .mode = t.file.mode(), .size = pane.fileSize(p, id, t.file), .mtime = pane.mtimeOf(p, pn), + .version = pane.versionOf(pn, t.file), }; }, } @@ -292,13 +314,14 @@ fn attrReply(p: *Pardes, tag: u64, target: Target) Reply { fn topSize(p: *Pardes, f: TopFile) u64 { return switch (f) { - .root, .pane, .new, .log, .screen, .listeners => 0, + // ponytail: /screen has no length until an open renders its frame. + .root, .pane, .screen => 0, .index => pane.indexLen(p), .README => fs.help.len, - .ctl => ctl: { - var buf: [ctl.root_text_cap]u8 = undefined; - break :ctl ctl.rootText(p, &buf).len; - }, + .status => ctl.statusLen(p), + .look, .exec => ctl.resultsLen(p), + .log => events.pending(&p.fs.log), + .listeners => screen.listenersLen(p), }; } @@ -366,7 +389,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| switch (f) { .root => { - inline for (.{ TopFile.README, TopFile.index, TopFile.ctl, TopFile.new, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); @@ -403,21 +426,27 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; +/// Tcreate in /pane opens a pane, the way mkdir opens a directory. The name +/// asked for is ignored: a pane is named by the serial the editor gives it, +/// which the reply carries back and /index lists last. +fn create(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| if (f != .pane) return Reply.fail(req.tag, E.PERM), + .pane => return Reply.fail(req.tag, E.PERM), + } + if (req.perm & cloud9.dmdir == 0) return Reply.fail(req.tag, E.PERM); + const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); p.newScratchBelow(p.active); - const pn = p.panes[slot] orelse return null; - return pn.serial; + const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); + const attr = attrOf(p, .{ .pane = .{ .serial = made.serial, .file = .dir } }) orelse + return Reply.fail(req.tag, E.NFILE); + return .{ .tag = req.tag, .handle = 1, .attr = attr }; } fn open(p: *Pardes, req: Req, target: Target) Reply { + if (req.create) return create(p, req, target); switch (target) { .top => |f| switch (f) { - // The clone idiom: each open makes one empty pane; reads answer its serial. - .new => { - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - return .{ .tag = req.tag, .handle = serial }; - }, .screen => return screen.openSnapshot(p, req, true), .log => p.fs.log_readers +|= 1, else => {}, @@ -429,11 +458,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); switch (t.file) { .body => if (pn.isTerminal()) return screen.openSnapshot(p, req, false), - .ctl => pf.limit = null, - .addr => { - pf.addr = .{}; - pf.limit = null; - }, + .addr => pf.addr = .{}, .event => { pf.readers +|= 1; p.fs.listeners +|= 1; @@ -446,7 +471,27 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .handle = 1 }; } +/// Tremove closes a pane. Nothing else in the tree can be removed, and this +/// is also where an ORCLOSE open lands, so every node is checked here. +fn remove(p: *Pardes, req: Req) Reply { + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + const t = switch (target) { + .pane => |t| t, + .top => return Reply.fail(req.tag, E.PERM), + }; + if (t.file != .dir) return Reply.fail(req.tag, E.PERM); + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + p.removePane(id) catch return Reply.fail(req.tag, E.IO); + return .{ .tag = req.tag }; +} + fn release(p: *Pardes, req: Req) Reply { + // The handle's bookkeeping runs whether or not the removal is allowed. + const done = releaseHandle(p, req); + return if (req.remove) remove(p, req) else done; +} + +fn releaseHandle(p: *Pardes, req: Req) Reply { if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag }; const target = Node.target(req.node) orelse return .{ .tag = req.tag }; switch (target) { @@ -502,12 +547,8 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { } break :index stagedReply(p, req); }, - .ctl => ctl.readRoot(p, req), - .new => new: { - if (req.handle == 0) break :new Reply.fail(req.tag, E.INVAL); - p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch break :new Reply.fail(req.tag, E.NOMEM); - break :new stagedReply(p, req); - }, + .status => ctl.readStatus(p, req), + .look, .exec => ctl.readResults(p, req), .log => events.readQueue(p, req, &p.fs.log), .screen => screen.readSnapshot(p, req, null), .listeners => screen.readListeners(p, req), @@ -525,7 +566,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { fn write(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { - .ctl => ctl.writeRoot(p, req), + .look, .exec => ctl.command(p, req, null, f == .exec), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { @@ -587,8 +628,8 @@ test "filesystem inspection preserves pending and displayed Look hover" { const preview = p.look_hover_preview; const requests = [_]Req{ .{ .tag = 1, .op = .lookup, .node = root, .data = "screen" }, - .{ .tag = 1, .op = .lookup, .node = root, .data = "new" }, - .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 1, .op = .lookup, .node = root, .data = "status" }, + .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.pane) }, .{ .tag = 2, .op = .getattr, .node = body }, .{ .tag = 3, .op = .open, .node = body }, .{ .tag = 4, .op = .read, .node = body, .size = 5 }, @@ -614,7 +655,7 @@ test "filesystem inspection preserves pending and displayed Look hover" { test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ - .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.pane), .data = "x", .create = true, .perm = cloud9.dmdir | 0o755 }, .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, }; for (requests) |request| { @@ -644,13 +685,13 @@ test "readdir lists the root and a pane directory without creating anything" { var top_bytes: [4096]u8 = undefined; @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); - try testing.expect(top.len >= 9); - for ([_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { + try testing.expect(top.len >= 10); + for ([_][]const u8{ "README", "index", "status", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 7, top[i].dir); + try testing.expectEqual(i >= 8, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[7].node); - try testing.expectEqual(fs.os_root, top[8].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[8].node); + try testing.expectEqual(fs.os_root, top[9].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); @@ -674,13 +715,18 @@ test "readdir lists the root and a pane directory without creating anything" { } const panes_dir = rdir(p, @intFromEnum(TopFile.pane), 0); const listed = th.dirents(panes_dir.bytes, &buf); - try testing.expectEqual(@as(usize, 1), listed.len); - try testing.expectEqual(Node.of(serial, .dir), listed[0].node); + var idname: [16]u8 = undefined; + const own = th.nameAt(listed, try std.fmt.bufPrint(&idname, "{d}", .{serial})).?; + try testing.expectEqual(Node.of(serial, .dir), own.node); + try testing.expect(own.dir); const dir = rdir(p, Node.of(serial, .dir), 0); const files = th.dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "data", "xdata", "sel", "errors", "event" }, 0..) |name, i| + try testing.expectEqual(@as(usize, 17), files.len); + for ([_][]const u8{ + "name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata", + "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", + }, 0..) |name, i| try testing.expectEqualStrings(name, files[i].name); try testing.expect(th.nameAt(files, ".") == null); try testing.expect(th.nameAt(files, "rdsel") == null); @@ -703,8 +749,10 @@ test "lookup resolves top files, pane serials and pane files" { const serial = serialOf(p); try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); - try testing.expect(!look_up(p, root, "new").reply.attr.dir); - try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "new").reply.attr.mode); + try testing.expect(!look_up(p, root, "status").reply.attr.dir); + try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "status").reply.attr.mode); + try testing.expectEqual(@as(u16, 0o666), look_up(p, root, "look").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, root, "new").errno()); try testing.expect(look_up(p, root, "pane").reply.attr.dir); try testing.expect(look_up(p, root, "os").reply.attr.dir); try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); @@ -732,38 +780,46 @@ test "lookup resolves top files, pane serials and pane files" { try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); } -test "each open of new creates exactly one pane whose serial the read answers" { +test "creating in the pane directory opens a pane and removing one closes it" { const gpa = testing.allocator; const p = try withFile(gpa, "first\n"); defer p.deinit(); const before = p.next_serial; - const new = @intFromEnum(TopFile.new); + const panes_dir = @intFromEnum(TopFile.pane); - try testing.expectEqual(Status.ok, look_up(p, root, "new").reply.status); - try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = new }).reply.status); + // Browsing /pane creates nothing; only a create does. + try testing.expectEqual(Status.ok, rdir(p, panes_dir, 0).reply.status); try testing.expectEqual(before, p.next_serial); - const opened = call(p, .{ .tag = 1, .op = .open, .node = new }); - try testing.expectEqual(Status.ok, opened.reply.status); + const made = th.mkdir(p, "scratch"); + try testing.expectEqual(Status.ok, made.reply.status); try testing.expectEqual(before + 1, p.next_serial); - try testing.expectEqual(before + 1, opened.reply.handle); - try testing.expectEqual(@as(u64, 0), opened.reply.attr.node); + const serial = before + 1; + try testing.expectEqual(Node.of(serial, .dir), made.reply.attr.node); + try testing.expect(made.reply.attr.dir); var expected: [16]u8 = undefined; - const line = try std.fmt.bufPrint(&expected, "{d}\n", .{opened.reply.handle}); - try testing.expectEqualStrings(line, call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .size = 64 }).bytes); - try testing.expectEqualStrings(line[1..], call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = 1, .size = 64 }).bytes); - try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = line.len, .size = 64 }).bytes); - try testing.expectEqual(before + 1, p.next_serial); - _ = call(p, .{ .tag = 3, .op = .release, .node = new, .handle = opened.reply.handle }); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}", .{serial}), made.reply.attr.name); - const id = p.paneBySerial(opened.reply.handle).?; + const id = p.paneBySerial(serial).?; try testing.expectEqualStrings("", p.panes[id].?.file.?.content); - _ = wr(p, Node.of(opened.reply.handle, .body), "hi"); - const second = call(p, .{ .tag = 4, .op = .open, .node = new }); + _ = wr(p, Node.of(serial, .body), "hi"); + const second = th.mkdir(p, "another"); try testing.expectEqual(Status.ok, second.reply.status); - try testing.expect(second.reply.handle != opened.reply.handle); + try testing.expect(second.reply.attr.node != made.reply.attr.node); try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); - try testing.expectEqual(E.INVAL, call(p, .{ .tag = 5, .op = .read, .node = new, .size = 64 }).errno()); + + // A plain file, and a create anywhere else, are refused. + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = panes_dir, .data = "f", .create = true, .perm = 0o666 }).errno()); + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = root, .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .dir), .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + + // Tremove closes the pane it names, and nothing else in the tree. + try testing.expectEqual(E.PERM, th.rmdir(p, Node.of(serial, .body)).errno()); + try testing.expectEqual(E.PERM, th.rmdir(p, @intFromEnum(TopFile.index)).errno()); + try testing.expect(p.paneBySerial(serial) != null); + try testing.expectEqual(Status.ok, th.rmdir(p, Node.of(serial, .dir)).reply.status); + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqual(E.NOENT, th.rmdir(p, Node.of(serial, .dir)).errno()); } test "directories walk to their parents" { @@ -791,14 +847,15 @@ test "editor paths resolve to the same nodes the wire serves" { try testing.expectEqual(root, resolveSelf(p, "").?); try testing.expectEqual(root, resolveSelf(p, "/./pane/../").?); try testing.expectEqual(@intFromEnum(TopFile.index), resolveSelf(p, "index").?); - try testing.expectEqual(@intFromEnum(TopFile.new), resolveSelf(p, "new").?); + try testing.expectEqual(@intFromEnum(TopFile.status), resolveSelf(p, "status").?); + try testing.expect(resolveSelf(p, "new") == null); try testing.expectEqual(@intFromEnum(TopFile.pane), resolveSelf(p, "pane").?); try testing.expectEqual(Node.of(serial, .dir), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}", .{serial})).?); try testing.expectEqual(Node.of(serial, .sel), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/sel", .{serial})).?); try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/pty/ctl", .{serial})) == null); try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/rdsel", .{serial})) == null); try testing.expect(resolveSelf(p, "self/index") == null); - try testing.expect(resolveSelf(p, "new/ctl") == null); + try testing.expect(resolveSelf(p, "status/ctl") == null); try testing.expect(resolveSelf(p, "cons") == null); try testing.expect(resolveSelf(p, "pane/0") == null); } diff --git a/src/panes.zig b/src/panes.zig index edd7038f..9a6802d0 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -6273,6 +6273,13 @@ pub const Terminal = struct { state.vt.screens.active.scroll(.{ .delta_row = delta }); } + /// Is the viewport already showing the last row of output? + pub fn atBottom(pane: *const Pane) bool { + if (comptime !enabled) return true; + const sb = scrollbar(pane); + return sb.offset + sb.len >= sb.total; + } + /// Snap the viewport back onto live output. pub fn followOutput(pane: *Pane) void { if (comptime !enabled) return; @@ -6559,10 +6566,14 @@ pub const Terminal = struct { const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0; + // Sticky bottom, as every other terminal does it: output drags the + // viewport down only when the viewport was already on the last row, so + // a reader scrolled back into the scrollback stays where they scrolled. + const at_bottom = atBottom(pane); if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); p.shell_rows.markStale(pane); const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (body_mode == .tty) followOutput(pane); + if (body_mode == .tty and at_bottom) followOutput(pane); } const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; @@ -6890,6 +6901,7 @@ pub const Terminal = struct { }; pane.mode = .tty; + followOutput(pane); // entering raw mode is a request for the live shell pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; diff --git a/src/pardes.zig b/src/pardes.zig index ed71102e..c66688f5 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -295,6 +295,12 @@ const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; +// ...and the defaults from before the closing word moved to the end of every +// tagline, where a click cannot land on it by overshooting the one before. +const pre_close_last_pane_tail = " Tty Collapse Del"; +const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; +const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; +const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; const Builtin = builtins.registry.Builtin(); @@ -1078,7 +1084,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" const height = p.rects[0].h; const revision = pane.file.?.revision; for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| - try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse")); + try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); @@ -1115,6 +1121,8 @@ test "LocationsConfig command reports partial updates and survives restore" { try std.testing.expectEqual(expected, p.locations_config); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on")); try std.testing.expectEqual(expected, p.locations_config); + // Setting it is silent; asking bare is what opens the window. + try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig")); var reports: usize = 0; for (p.panes) |slot| { const pane = slot orelse continue; @@ -1409,7 +1417,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); - try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), @@ -1430,7 +1438,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -1576,7 +1584,7 @@ test "pane defaults keep creation of scratch panes in column tags" { var words = std.mem.tokenizeScalar(u8, tag, ' '); while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } - try std.testing.expectEqualStrings("New Tty Find Grep Joincol", p.columnTag(0)); + try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0)); const terminal = p.panes[0].?; p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(!terminal.tag_init); @@ -2441,13 +2449,13 @@ test "Save reaches every tagline with text behind it and no other" { }); p.restoreTail(out, scratch_dumped); try std.testing.expect(!out.tag_init); - try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -5818,8 +5826,42 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { pub const Rect = layout.Rect; +/// The session layouts `init` can put on screen. One name each, so choosing +/// between them reads as a choice instead of a chain of ifs over four options. +pub const Boot = enum { + /// the document named on the command line, or the pane explaining why not + document, + /// a Look that found nothing: reported, not opened + missing, + /// the firmware's boot buffer, the only pane a board has + embedded, + /// the bare tty startup: one shell in raw tty mode, with an empty text + /// pane under it so the editor half is there before you want it + tty, + /// that raw shell by itself, which is all a core booted for a test wants + tty_shell, + /// acme's boot: two columns, the left one split in two + classic, + /// one greeted shell + shell, + + pub fn of(opts: Options) Boot { + if (opts.boot) |named| return named; + if (opts.file != null) return .document; + if (opts.missing != null) return .missing; + if (comptime platform == .esp32p4) return .embedded; + if (opts.tty_only) return .tty_shell; + // ponytail: 1 and 3 are the only shell counts anyone asked for + return if (opts.shells >= 3) .classic else .shell; + } +}; + pub const Options = struct { tty_only: bool = false, + /// The layout to boot, when a host means one outright rather than leaving + /// it to be read off the options below. `--tty` names `.tty`; a core + /// booted for a test names nothing and gets `.tty_shell`. + boot: ?Boot = null, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, @@ -6035,6 +6077,11 @@ pub const Pardes = struct { pipe_wait: ?PendingPipe = null, fs: filesystem.Namespace = .{}, + boot: Boot = .shell, + /// Builtins announce themselves on the message row. Off while startup + /// configuration is applied: those lines are nobody clicking, and should + /// not leave the row holding the name of the last one. + announce: bool = true, // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, @@ -6114,7 +6161,10 @@ pub const Pardes = struct { for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; p.opts.ninep_identity = .{}; - if (opts.file) |path| { + p.boot = Boot.of(opts); + switch (p.boot) { + .document => { + const path = opts.file.?; const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line); @@ -6137,10 +6187,10 @@ pub const Pardes = struct { errdefer gpa.free(content); _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (opts.missing) |missing| { + p.oneColumn(); + }, + .missing => { + const missing = opts.missing.?; const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", @@ -6148,24 +6198,26 @@ pub const Pardes = struct { ); errdefer gpa.free(content); _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (comptime platform == .esp32p4) { + p.oneColumn(); + }, + .embedded => { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (opts.tty_only) { + p.oneColumn(); + }, + .tty, .tty_shell => { _ = try p.newShell(0, ""); p.panes[0].?.mode = .tty; - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (opts.shells >= 3) { - // classic layout: two columns, the left one split in two. + p.oneColumn(); + // A bare tty is still an editor: it opens with somewhere to put + // text under the shell. The shell keeps the keyboard. + if (p.boot == .tty) { + p.newScratchBelow(0); + p.active = 0; + } + }, + .classic => { _ = try p.newShell(0, ""); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); @@ -6176,13 +6228,12 @@ pub const Pardes = struct { p.col_panes[0][1] = 1; p.col_n[1] = 1; p.col_panes[1][0] = 2; - } else { - // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 + }, + .shell => { _ = try p.newShell(0, ""); p.panes[0].?.greet = true; - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; + p.oneColumn(); + }, } p.sync(); p.applyStartupConfig(); @@ -6195,6 +6246,13 @@ pub const Pardes = struct { return p; } + /// The layout every single-pane boot starts from. + fn oneColumn(p: *Pardes) void { + p.ncol = 1; + p.col_n[0] = 1; + p.col_panes[0][0] = 0; + } + pub fn deinit(p: *Pardes) void { p.cancelLookHover(); for (0..MAX_PANES) |id| p.dropPendingWrite(id); @@ -6268,7 +6326,7 @@ pub const Pardes = struct { } pub fn columnTag(p: *const Pardes, c: usize) []const u8 { - return p.col_tags[c].slice("New Tty Find Grep Joincol"); + return p.col_tags[c].slice("New Tty Find Grep Joincol Delcol"); } fn headerText(p: *Pardes) []const u8 { @@ -6413,6 +6471,17 @@ pub const Pardes = struct { return np; } + /// The empty pane that stands in for one that closed, keeping its column + /// alive and its directory. Unlike `newScratch` it inherits nothing: the + /// pane it replaces is on its way out. + fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { + const content = try p.gpa.dupe(u8, ""); + errdefer p.gpa.free(content); + const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); + np.cur_pinned = true; + return np; + } + /// New: a scratch below the calling pane, in its column. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; @@ -8237,8 +8306,23 @@ pub const Pardes = struct { p.ctrl_w_pending = false; if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle) return p.toggleTty(p.active); + // Shift-Escape is the unconditional way out, even mid-application; + // bare Escape only leaves at a shell prompt. + if (hit(key, config.tty_toggle_alt)) + return p.runBuiltin(.Last, p.active, "", null); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active)) return p.runBuiltin(.Last, p.active, "", null); + panes.Terminal.followOutput(pane); // typing snaps back to live output + // Paste stays the window's, the one exception to forwarding a raw + // tty's keys: Ctrl-V types the register at the program and + // Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded, + // they reach an application that reads the clipboard on its own + // account instead — a coding agent goes there hunting for an image. + // A host that folds Shift into the letter says the same thing. + if (key.ctrl and !key.alt and (key.cp == 'v' or key.cp == 'V')) { + if (key.shift or key.cp == 'V') return p.clipRequest(p.active, .after); + return p.typeToTty(p.active, pane, p.yank orelse return); + } return panes.Terminal.forwardKey(p, p.active, key); } if (p.leader_on) return p.leaderKey(key); @@ -12499,6 +12583,8 @@ pub const Pardes = struct { } fn applyStartupConfig(p: *Pardes) void { + p.announce = false; + defer p.announce = true; var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } @@ -12506,6 +12592,11 @@ pub const Pardes = struct { fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; + // A builtin says its own name before it runs, so a tagline click is + // legible even when what it did is invisible. Whatever the builtin + // then says for itself replaces the announcement. + if (p.announce and p.settings.verbose and builtins.registry.announces(b)) + p.setMessage(id, @tagName(b)); const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } @@ -12887,11 +12978,13 @@ pub const Pardes = struct { const rest = saved_tag[pfx.len..]; const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { - .generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, - .file => &.{ context_pane_tail, file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, + .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, .output => &.{ locations_pane_tail, file_pane_tail, + pre_close_last_file_pane_tail, + pre_close_last_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, @@ -12902,7 +12995,7 @@ pub const Pardes = struct { pre_collapse_file_pane_tail, pre_collapse_pane_tail, }, - .terminal => &.{ terminal_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; @@ -12912,11 +13005,34 @@ pub const Pardes = struct { pub fn removePane(p: *Pardes, id: usize) !void { const pane = p.panes[id] orelse return; + // A column outlives its panes. Closing the last one leaves an empty + // pane standing in its place and in its directory; only Delcol ever + // takes a column away. + const place = layout.findPane(p, id); + const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; + var dir_buf: [limits.host_path_cap]u8 = undefined; + var dir: []const u8 = ""; + if (last_in_column) { + const had = paneDir(pane); + const n = @min(had.len, dir_buf.len); + @memcpy(dir_buf[0..n], had[0..n]); + dir = dir_buf[0..n]; + } + // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); + // ...and after it, so the stand-in inherits none of the closing pane. + const heir: ?usize = heir: { + if (!last_in_column) break :heir null; + const f = place.?; + const free = p.freeSlot() orelse break :heir null; + _ = p.newScratchIn(free, dir) catch break :heir null; + layout.insert(p, f.col, f.idx + 1, free); + break :heir free; + }; layout.absorbVWeight(p, id); layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = p.prevFocus(id) orelse { + if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; @@ -14408,6 +14524,75 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } +test "the bare tty startup opens a text pane under the shell, unfocused" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .boot = .tty }); + defer p.deinit(); + try std.testing.expectEqual(Boot.tty, p.boot); + + // One column, shell on top in raw mode, an empty text pane under it. + try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); + const shell = p.panes[p.col_panes[0][0]].?; + const scratch = p.panes[p.col_panes[0][1]].?; + try std.testing.expect(shell.isTerminal()); + try std.testing.expectEqual(Pane.Mode.tty, shell.mode); + try std.testing.expect(!scratch.isTerminal()); + try std.testing.expectEqualStrings("", scratch.file.?.content); + + // ...and the shell keeps the keyboard. + try std.testing.expectEqual(p.col_panes[0][0], p.active); + + // Unnamed, it is the bare shell every test asks for. + const plain = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer plain.deinit(); + try std.testing.expectEqual(Boot.tty_shell, plain.boot); + try std.testing.expectEqual(@as(usize, 1), plain.col_n[0]); +} + +test "tty output follows only from the bottom" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc + const shell = p.active; + const sp = p.panes[shell].?; + p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); + try std.testing.expectEqual(Pane.Mode.tty, sp.mode); + + for (0..60) |i| { + var buf: [32]u8 = undefined; + p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); + } + p.sync(); + const bottom = panes.Terminal.scrollbar(sp).offset; + try std.testing.expect(bottom > 0); + + // Scrolled back, the reader stays put however much the shell prints. + sp.scrollBy(-10); + p.sync(); + const parked = panes.Terminal.scrollbar(sp).offset; + try std.testing.expect(parked < bottom); + for (60..70) |i| { + var buf: [32]u8 = undefined; + p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); + } + p.sync(); + try std.testing.expectEqual(parked, panes.Terminal.scrollbar(sp).offset); + + // Typing snaps back to live output, so nobody types blind. + p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); + p.sync(); + try std.testing.expect(panes.Terminal.scrollbar(sp).offset > parked); + + // ...and from there output follows again. + const live = panes.Terminal.scrollbar(sp).offset; + p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } }); + p.sync(); + try std.testing.expect(panes.Terminal.scrollbar(sp).offset > live); +} + test "Esc back into a file leaves its view where it was" { if (platform == .web) return; const gpa = std.testing.allocator; @@ -14461,15 +14646,23 @@ test "raw tty Escape only leaves at a shell prompt" { const pane = p.panes[shell].?; var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); - for ([_]Key{ - .{ .cp = Key.escape, .shift = true }, - }) |key| { - p.update(.{ .key = key }); - try std.testing.expectEqual(shell, p.active); - try std.testing.expectEqual(Pane.Mode.tty, pane.mode); - try std.testing.expect(drainWrites(p, &buf).len > 0); - } - p.update(.{ .key = .{ .cp = Key.escape } }); + + // Ordinary keys reach the child; Shift-Escape is the one unconditional way + // out and sends it nothing. + p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); + try std.testing.expectEqual(shell, p.active); + try std.testing.expect(drainWrites(p, &buf).len > 0); + + p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + + p.update(.{ .key = .{ .cp = Key.escape } }); // back into the shell + try std.testing.expectEqual(shell, p.active); + _ = drainWrites(p, &buf); + + p.update(.{ .key = .{ .cp = Key.escape } }); // bare Escape leaves at a prompt try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); @@ -14620,9 +14813,10 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" { defer p.deinit(); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); - p.setYank("must not be pasted"); + p.setYank("register text"); for ('a'..'z' + 1) |letter| { if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut + if (letter == 'v') continue; // the window's paste, asserted just below p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } }); const expected = [_]u8{@intCast(letter - 'a' + 1)}; // The terminal encoder distinguishes Ctrl-I/M from Tab/Return. @@ -14636,15 +14830,21 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" { try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode); try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null); } - p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); - try std.testing.expectEqualStrings("\x1b[118;6u", drainWrites(p, &buf)); + // ...and the two paste chords are the exception: Ctrl-V types the register + // at the program, Ctrl-Shift-V asks the desktop first and types nothing yet. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings("register text", drainWrites(p, &buf)); try std.testing.expect(p.clip_pending == null); + p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + try std.testing.expect(p.clip_pending != null); + const unopened = p.freeSlot(); // whatever the boot layout already used for ([_]u8{ 'n', 'c' }) |letter| { p.update(.{ .key = .{ .cp = letter, .alt = true } }); const expected = [_]u8{ 0x1b, letter }; try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf)); try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expect(p.panes[1] == null); + try std.testing.expectEqual(unopened, p.freeSlot()); } p.update(.{ .key = .{ .cp = Key.up } }); try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf)); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 23b7c09b..a1917ba9 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -71,6 +71,21 @@ fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void { loop.postEvent(.quit) catch {}; } +/// How long a lone ESC waits for the rest of a sequence before it counts as +/// the Escape key. Long enough for the remainder of a real sequence to arrive +/// even over a slow link, short enough that nobody sees the delay. +const escape_hold_ms = 25; + +/// Is there more input right behind what we have already read? Only a real +/// terminal has an fd to ask; the test readers hand their parts over whole, so +/// for them the answer is always no. +fn morePending(tty: anytype) bool { + const Reader = @typeInfo(@TypeOf(tty)).pointer.child; + if (!@hasField(Reader, "fd") or @FieldType(Reader, "fd") != std.Io.File) return false; + var fds = [_]std.posix.pollfd{.{ .fd = tty.fd.handle, .events = std.posix.POLL.IN, .revents = 0 }}; + return (std.posix.poll(&fds, escape_hold_ms) catch return false) > 0; +} + fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { try loop.postEvent(.{ .winsize = try tty.getWinsize() }); var parser: vaxis.Parser = .{}; @@ -101,6 +116,22 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } carried = end - consumed; std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); + // A lone ESC opens most sequences and is also the Escape key, so the + // parser holds it for a remainder that a keypress never sends: the + // press would only land when the NEXT key arrived. Wait a beat, and + // when nothing follows it was the key. A terminal speaking the kitty + // protocol never reaches here — it spells Escape out in full. + if (carried == 1 and buf[0] == 0x1b and !morePending(tty)) { + carried = 0; + try vaxis.loop.handleEventGeneric( + loop, + loop.vaxis, + cache, + @TypeOf(Command.value), + @as(vaxis.Event, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }), + loop.vaxis.opts.system_clipboard_allocator, + ); + } } } diff --git a/src/tutor.txt b/src/tutor.txt index 394a48a3..adf4ae3a 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -160,8 +160,10 @@ RAW INPUT Ctrl-b switches to editor mode. Other keys go to the child, - including Ctrl-o, Ctrl-w, Ctrl-V, Ctrl-Shift-V, Alt shortcuts, and - modified Escape. Plain Esc at a detected shell prompt hops + including Ctrl-o, Ctrl-w, Alt shortcuts, and + modified Escape. The two paste chords are the exception: Ctrl-V + types the yank register at the program and Ctrl-Shift-V types the + desktop clipboard. Plain Esc at a detected shell prompt hops to the previous pane. While a program owns the terminal, Esc goes to that program too. Use the Togglettymode tag to leave raw input in place. Desktop paste events still feed the child. @@ -387,15 +389,15 @@ typed pardes the 9P socket opens by default - A directory per pane holding `name`, `body`, `tag`, `ctl`, `addr`, - `data`, `sel`, `event`, `pty/` and the rest, plus `index`, `ctl`, - `new` and `log` at the root. Every pane shell receives `$PARDES_9P` - (the socket) and `$PARDES_PANE` (its serial). Use a 9P client: + A directory per pane holding `name`, `body`, `tag`, `addr`, `dot`, + `data`, `sel`, `dirty`, `event`, `pty/` and the rest, plus `index`, + `status`, `look`, `exec` and `log` at the root. Every pane shell + receives `$PARDES_9P` (the socket) and `$PARDES_PANE` (its serial): /pane/<serial>/body /index - /new reading it makes a pane, answers its serial - /ctl `look FILE:12` and `exec Save`, as the mouse does + /pane mkdir here makes a pane, rmdir closes it + /look /exec `FILE:12` and `Save`, as the mouse does A terminal pane also has `pty/`: @@ -446,7 +448,8 @@ typed Esc goes to the program — except at a shell PROMPT, where it hops to the previous pane Ctrl-b switches to editor mode - All other keys belong to the child, including Ctrl-V + Ctrl-V types the register, Ctrl-Shift-V the clipboard + All other keys belong to the child DETACHED pardes --detach[=name] the core, no terminal pardes --attach[=name] a frontend for it |
