summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig290
-rw-r--r--src/builtins.zig15
-rw-r--r--src/config.zig187
-rw-r--r--src/fs-help.txt42
-rw-r--r--src/fs.zig11
-rw-r--r--src/host_io.zig3
-rw-r--r--src/main.zig71
-rw-r--r--src/ninep/addr.zig16
-rw-r--r--src/ninep/ctl.zig317
-rw-r--r--src/ninep/events.zig15
-rw-r--r--src/ninep/pane.zig241
-rw-r--r--src/ninep/pty.zig9
-rw-r--r--src/ninep/screen.zig25
-rw-r--r--src/ninep/testing.zig29
-rw-r--r--src/ninep/tree.zig207
-rw-r--r--src/panes.zig14
-rw-r--r--src/pardes.zig294
-rw-r--r--src/tty/tty.zig31
-rw-r--r--src/tutor.txt21
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.
diff --git a/src/fs.zig b/src/fs.zig
index 7f416440..7cb08980 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -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