summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md11
-rw-r--r--build.zig.zon4
-rw-r--r--docs/fs.md31
-rw-r--r--src/9p_io.zig4
-rw-r--r--src/builtins.zig59
-rw-r--r--src/config.zig83
-rw-r--r--src/fs-help.txt4
-rw-r--r--src/fs.zig7
-rw-r--r--src/ninep/ctl.zig237
-rw-r--r--src/ninep/tree.zig8
-rw-r--r--src/pardes.zig6
-rw-r--r--test/fs.py20
12 files changed, 412 insertions, 62 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 5e2280f9..5540db5f 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -53,6 +53,8 @@ $m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg
$m/screen the rendered screen as JSON, frozen per open
$m/listeners this session's dial addresses
$m/focus the serial of the pane with the keyboard; echo a serial into it to move the keyboard
+$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin
+ (Newcol, Dump, Kill, Theme x); a pane's builtins (Del, Save f) go to $m/pane/<n>/ctl
$m/pane/new open it to make a pane, read names it; rmdir $m/pane/<n> closes it
$m/os/ the host filesystem
```
@@ -131,8 +133,13 @@ written until someone writes or truncates it, so writing an address and reading
it back evaluates it, and two clients addressing the same pane will interfere.
`$pane/ctl` reads acme's window status line — serial, tag length, body length,
a reserved zero, the dirty flag, the width in cells, the font and the tab
-width, then `current` or `notcurrent` — and takes `get` (reload from disk) and
-`lock`/`unlock`. The lock binds only clients that take it, and is held by the
+width, then `current` or `notcurrent` — and takes `get` (reload from disk),
+`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
+`Collapse`). Session builtins and settings go to the root `ctl`, which reads
+back every setting in the syntax it takes. A ctl write is checked whole
+first and refused as `unknown control message "X"` (EINVAL) and the like; a
+builtin that would open a prompt (`Save` on a scratch, bare `Find`) fails
+there instead of prompting. The lock binds only clients that take it, and is held by the
open that wrote it, so a shell holds an fd across the edit:
`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`.
diff --git a/build.zig.zon b/build.zig.zon
index 165e9b54..4cfba514 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -9,8 +9,8 @@
// read-only HTTPS URL is what a manifest can carry. Re-pin with
// `zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`.
.cloud9 = .{
- .url = "git+https://git.sr.ht/~gbrls/cloud9#cca47d63c3ba774cb4de6f76603ba6edc5b475fe",
- .hash = "cloud9-0.1.0-yt86qobpHQDOvgZw-CSB4UHvMmtpp-N4IG0XyTbJlS2y",
+ .url = "git+https://git.sr.ht/~gbrls/cloud9#a8c7a7158cc9d3bde78e34db6de11c084fde3d65",
+ .hash = "cloud9-0.1.0-yt86qjLsHQC_9qrMuOQEcDOan4KTH6ermuxZvm0kgwpL",
},
// ZLS as a LIBRARY, not a language server: src/lsp_zls.zig imports the
// `zls` module its build.zig publishes and calls the analyser in
diff --git a/docs/fs.md b/docs/fs.md
index c2819adb..b3f3cb45 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -81,6 +81,7 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/screen rendered screen JSON; frozen per open handle
/listeners the session's dial addresses
/focus the serial of the pane with the keyboard; write a serial to give it the keyboard
+/ctl the settings, one a line as a write takes them; write a setting or a session builtin
/pane/new open it to make a pane; the read answers that pane's serial
/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
errors event look exec, plus pty/{ctl,status,data} on terminals
@@ -88,6 +89,33 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/src/ the editor's embedded sources, only when built with -Dembed-sources=true
```
+Control messages are split by what they act on, as acme keeps window verbs
+on a window's ctl and webfs and upas/fs keep session settings on a root ctl.
+Each builtin declares its scope in src/builtins.zig (`scope = .session`;
+every setting is one, the rest act on a pane). `/ctl` takes the session's
+builtins, one a line, at whichever pane has the keyboard as each runs --
+`Newcol`, `Dump`, `Kill`, `Mount name dial`, `Theme ink`, `Verbose off` --
+and reads every setting there is, one a line, in the words a write of it
+takes (`Verbose on`, `WindowOpacity 70`, `PanelSlide off`, `DumpDir` bare
+for the default directory, `LocationsConfig ...`), so writing what it reads
+back changes nothing; platform and startup facts are `/status`'s and the
+Config window's, not settings. A pane's `ctl` takes the builtins that act on
+a pane (`Del`, `Save f`, `Collapse`, `Find pat`) beside acme's `get`, `lock`
+and `unlock`. The words are case-sensitive and do not alias: acme's verbs
+are lowercase and the builtins keep their tag spelling, so `Get` is no word
+and `del` none either. A write is checked whole before any line runs, and a
+line is refused in Plan 9's words for a ctl (kernel/misc/parse.c:82-97):
+`unknown control message "X"`, `wrong #args in control message "X"` for an
+argument to a builtin that takes none, `bad value in control message "X"`
+for a setting's value it does not take, and `not a session control message
+"X"` or `not a window control message "X"` for a word of the other ctl; 9ns
+maps them all to EINVAL. A builtin that would ask at a prompt for its
+argument (`Save` on a scratch, `Find` bare) fails the write with `control
+message needs its argument` rather than open one nobody is there to answer,
+though a click on the same word written to `exec` still opens it. Like any
+write, a ctl write answers once the editor has performed what it asked for
+(a save written, a shell started).
+
`/focus` reads the serial of the pane with the keyboard, and a serial written
to it gives that pane the keyboard, off any column or workspace tag that had
it -- rio's `current` written to a window's `wctl`, named once for the whole
@@ -161,7 +189,8 @@ pane's Look and Exec clicks to that client; writing a record back performs the
action. `ctl` reads acme's window status line — serial, tag length, body
length, a reserved zero, the dirty flag, the width in cells, the font and the
tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`):
-whether the pane has the keyboard. It takes `get`, which reloads the buffer from the name it
+whether the pane has the keyboard. It takes the pane's builtins (below),
+`get`, which reloads the buffer from the name it
carries, and acme's `lock` and `unlock` (editors/acme/xfid.c:603-611), for an
edit of several writes to `addr` and `data` that another client must not
land in the middle of. As in acme the lock binds only the clients that take
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 7b0976bc..02b9efb5 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -258,6 +258,10 @@ pub const Listener = struct {
// file written. A change carries no payload, so the reply is still
// whole after the wait.
l.kick();
+ // A refusal answers at once: its text may be in the core's one
+ // buffer for it, which a request run while this one waited would
+ // write over.
+ if (reply.status == .err) return conn.reply(&reply, "");
const restoring = core.restore_req != null;
if (core.effects_len != 0) pardes.turn.awaitSettled(epoch);
// Once a wait returns `core` may be gone: a Restore meanwhile put a
diff --git a/src/builtins.zig b/src/builtins.zig
index ffb00ff6..ed0a07dd 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -30,6 +30,14 @@ pub const capabilities: config.Runtime.Capabilities = .{
.ligatures = pardes.platform == .gui,
};
+/// Where a builtin acts: at the pane it runs from, or on the editor as a
+/// whole. acme keeps its window verbs on a window's ctl and its session
+/// commands elsewhere, as webfs and upas/fs keep session settings on a root
+/// ctl; pardes's root `ctl` takes the session builtins and a pane's `ctl`
+/// the pane ones. A builtin says `pub const scope: Scope = .session;`,
+/// and every setting is one; the rest act on their pane.
+pub const Scope = enum { pane, session };
+
pub const Ctx = struct {
p: *Pardes,
pane: *Pane,
@@ -133,9 +141,14 @@ pub const registry = struct {
pub fn takesArg(b: Builtin()) bool {
inline for (manualBuiltinList(), 0..) |T, i|
if (@intFromEnum(b) == i) return @hasDecl(T, "takes_arg") and T.takes_arg;
- inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
- if (@intFromEnum(b) == i) return setting.takesArg();
- unreachable;
+ // Every setting takes its value, and a switch flips when bare.
+ return true;
+ }
+
+ pub fn scope(b: Builtin()) Scope {
+ inline for (manualBuiltinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return if (@hasDecl(T, "scope")) T.scope else .pane;
+ return .session; // a setting
}
pub fn outputTraits(b: Builtin()) ?OutputTraits {
@@ -173,6 +186,17 @@ test "capabilities exactly gate setting and effect-source builtins" {
try std.testing.expectEqual(EffectCode.enabled, effect_code_registered);
}
+test "a builtin's scope is the pane unless it says session, and every setting is the session's" {
+ const Builtin = registry.Builtin();
+ try std.testing.expectEqual(Scope.pane, registry.scope(Builtin.Del));
+ try std.testing.expectEqual(Scope.pane, registry.scope(Builtin.Save));
+ try std.testing.expectEqual(Scope.session, registry.scope(Builtin.Newcol));
+ try std.testing.expectEqual(Scope.session, registry.scope(Builtin.Kill));
+ try std.testing.expectEqual(Scope.session, registry.scope(Builtin.Verbose));
+ for (comptime settingList()) |setting|
+ try std.testing.expectEqual(Scope.session, registry.scope(std.meta.stringToEnum(Builtin, setting.word).?));
+}
+
test "Look does not announce itself on the message row; Del still does" {
const Builtin = registry.Builtin();
try std.testing.expect(!registry.announces(Builtin.Look));
@@ -233,6 +257,7 @@ pub const Exec = struct {
// ---- session ----
pub const Kill = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
c.p.quit = true;
c.p.emit(.quit);
@@ -240,12 +265,14 @@ pub const Kill = struct {
};
pub const Dump = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
dump.dumpState(c.p) catch {};
}
};
pub const Restore = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub fn run(c: Ctx) void {
const path = c.arg orelse (c.p.last_dump orelse return);
@@ -256,6 +283,7 @@ pub const Restore = struct {
};
pub const Attach = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.can_attach;
pub fn run(c: Ctx) void {
@@ -270,6 +298,7 @@ pub const Attach = struct {
};
pub const Detach = struct {
+ pub const scope: Scope = .session;
pub const enabled = pardes.can_attach;
pub fn run(c: Ctx) void {
if (comptime enabled)
@@ -280,6 +309,7 @@ pub const Detach = struct {
};
pub const Mount = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
@@ -293,6 +323,7 @@ pub const Mount = struct {
};
pub const Unmount = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
@@ -318,12 +349,14 @@ pub const Msg = struct {
};
pub const NextColor = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
colors.setThemeIndex(c.p, (@as(usize, c.p.settings.theme) + 1) % pardes.themes.len);
}
};
pub const ThemeSel = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true };
pub fn run(c: Ctx) void {
panes.Output.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err);
@@ -331,6 +364,7 @@ pub const ThemeSel = struct {
};
pub const ThemeFile = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
@@ -342,6 +376,7 @@ pub const ThemeFile = struct {
};
pub const DumpThemes = struct {
+ pub const scope: Scope = .session;
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime enabled) {
@@ -355,6 +390,7 @@ pub const DumpThemes = struct {
};
pub const FontSel = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true };
pub const enabled = capabilities.font_picker;
pub fn run(c: Ctx) void {
@@ -496,6 +532,7 @@ pub const New = struct {
/// The same empty scratch, opened in a fresh column beside the calling pane.
pub const Newcol = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
c.p.newScratchColumn(c.id);
}
@@ -603,12 +640,14 @@ pub const Tty9p = struct {
/// Fold the active pane's column into the one on its right, keeping its panes.
/// The horizontal mirror of the vertical stacking `New` does.
pub const Joincol = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
layout.joinCol(c.p);
}
};
pub const Tutor = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
const free = c.p.freeSlot() orelse return;
const nt = c.p.openTutorView(free) catch return;
@@ -617,6 +656,7 @@ pub const Tutor = struct {
};
pub const Help = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.help_buffer };
pub fn run(c: Ctx) void {
panes.Output.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err);
@@ -624,6 +664,7 @@ pub const Help = struct {
};
pub const Config = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.config_buffer };
pub fn run(c: Ctx) void {
panes.Output.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err);
@@ -631,6 +672,7 @@ pub const Config = struct {
};
pub const LocationsConfig = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const output: OutputTraits = .{ .name = "+LocationsConfig" };
@@ -660,6 +702,7 @@ pub const LocationsConfig = struct {
};
pub const Messages = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.messages_buffer };
pub fn run(c: Ctx) void {
panes.Output.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err);
@@ -669,6 +712,7 @@ pub const Messages = struct {
/// This build's version and what changed to reach it, printed into an output
/// buffer the same way Config prints the live settings.
pub const Changelog = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.changelog_buffer };
pub fn run(c: Ctx) void {
panes.Output.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err);
@@ -676,6 +720,7 @@ pub const Changelog = struct {
};
pub const EffectCode = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders;
pub const output: OutputTraits = .{ .name = config.effect_code_buffer };
@@ -690,6 +735,7 @@ pub const EffectCode = struct {
/// An optional SDL workspace-tag companion; never consumes input or text cells.
pub const Pet = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = pardes.platform == .gui;
pub fn run(c: Ctx) void {
@@ -793,6 +839,7 @@ pub const Right = struct {
/// Ctrl-o: one step back into the history.
pub const Back = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
c.p.jumpBy(-1);
}
@@ -800,6 +847,7 @@ pub const Back = struct {
/// Ctrl-i: one step forward again, up to wherever Back started.
pub const Forward = struct {
+ pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
c.p.jumpBy(1);
}
@@ -821,6 +869,7 @@ pub const Last = struct {
};
pub const Jumplist = struct {
+ pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true, .locations = true };
pub fn run(c: Ctx) void {
panes.Output.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err);
@@ -920,6 +969,7 @@ pub const Lspwhy = struct {
};
pub const Peek = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.peek_buffer };
@@ -932,6 +982,7 @@ pub const Peek = struct {
};
pub const Poke = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub fn run(c: Ctx) void {
@@ -943,6 +994,7 @@ pub const Poke = struct {
};
pub const Hexdump = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.hexdump_buffer };
@@ -955,6 +1007,7 @@ pub const Hexdump = struct {
};
pub const Gpio = struct {
+ pub const scope: Scope = .session;
pub const takes_arg = true;
pub const enabled = Board.enabled;
pub const output: OutputTraits = .{ .name = config.gpio_buffer };
diff --git a/src/config.zig b/src/config.zig
index 8c37a7ce..f898df11 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -705,15 +705,6 @@ pub const Runtime = struct {
action: Action,
availability: ?Capability = null,
- pub fn takesArg(setting: Setting) bool {
- return switch (setting.action) {
- // A toggle flips when it is a bare tagline word and SETS when
- // it is given `on` or `off`, so the report is configuration.
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true,
- else => false,
- };
- }
-
pub fn enabled(setting: Setting, capabilities: Capabilities) bool {
const capability = setting.availability orelse return true;
return switch (capability) {
@@ -831,15 +822,7 @@ pub const Runtime = struct {
// it SETS, so the report `writeReport` prints (`Verbose on`) can
// be fed straight back as configuration and mean what it says.
.toggle => |field| {
- const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
- const want: ?bool = if (text.len == 0)
- null
- else if (std.mem.eql(u8, text, "on"))
- true
- else if (std.mem.eql(u8, text, "off"))
- false
- else
- return false;
+ const want = switchedTo(argument) catch return false;
switch (field) {
inline else => |f| @field(state, @tagName(f)) = want orelse !@field(state, @tagName(f)),
}
@@ -881,18 +864,42 @@ pub const Runtime = struct {
inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms") = ms,
}
},
- .transition => |effect| state.toggleTransition(effect),
- .scene => |effect| switch (effect) {
- inline else => |field| {
- const value = &@field(state.scene_effects, @tagName(field));
- value.* = !value.*;
- },
+ // Panel effects are one choice, so `on` picks this one and `off`
+ // turns it off only if it is the one on.
+ .transition => |effect| {
+ const want = switchedTo(argument) catch return false;
+ if (want == null) {
+ state.toggleTransition(effect);
+ } else if (want.?) {
+ state.panel_transition = effect;
+ } else if (state.panel_transition == effect) {
+ state.panel_transition = .off;
+ }
+ },
+ .scene => |effect| {
+ const want = switchedTo(argument) catch return false;
+ switch (effect) {
+ inline else => |field| {
+ const value = &@field(state.scene_effects, @tagName(field));
+ value.* = want orelse !value.*;
+ },
+ }
},
.theme, .font => return false,
}
return true;
}
+ /// What `on`, `off` or nothing after a switch word asks for: null flips
+ /// it, as a bare tagline word does.
+ fn switchedTo(argument: ?[]const u8) error{NotOnOrOff}!?bool {
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ if (text.len == 0) return null;
+ if (std.mem.eql(u8, text, "on")) return true;
+ if (std.mem.eql(u8, text, "off")) return false;
+ return error.NotOnOrOff;
+ }
+
// Slices are borrowed for one writeReport call.
pub const ReportContext = struct {
startup_config_path: ?[]const u8,
@@ -1055,15 +1062,32 @@ pub const Runtime = struct {
try out.print("Pet: {s}\n", .{@tagName(state.pet)});
}
- test "setting names are unique and argument metadata follows actions" {
+ test "setting names are unique" {
for (settings, 0..) |setting, i| {
try std.testing.expect(setting.word.len > 0);
for (settings[i + 1 ..]) |later|
try std.testing.expect(!std.mem.eql(u8, setting.word, later.word));
- try std.testing.expectEqual(switch (setting.action) {
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true,
- else => false,
- }, setting.takesArg());
+ }
+ }
+
+ test "panel and scene effects flip bare and are set by on and off" {
+ var state: Runtime = .{};
+ const slide = findAction(.{ .transition = .slide }).?;
+ const zoom = findAction(.{ .transition = .zoom }).?;
+ try std.testing.expect(apply(&state, slide, "on"));
+ try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expect(apply(&state, zoom, "off"));
+ try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expect(apply(&state, slide, "on"));
+ try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expect(apply(&state, slide, null));
+ try std.testing.expectEqual(layout.Transition.off, state.panel_transition);
+ try std.testing.expect(!apply(&state, slide, "yes"));
+ const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?;
+ try std.testing.expect(apply(&state, scene, "on"));
+ try std.testing.expect(apply(&state, scene, "on"));
+ switch (std.enums.values(Scene)[0]) {
+ inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))),
}
}
@@ -1125,7 +1149,6 @@ pub const Runtime = struct {
var state: Runtime = .{};
try std.testing.expectEqual(@as(u8, 100), state.window_opacity);
try std.testing.expect(state.window_opacity_pending);
- try std.testing.expect(setting.takesArg());
try std.testing.expectEqual(Capability.window_opacity, setting.availability.?);
for ([_][]const u8{ "0", " 85\n", "100" }, [_]u8{ 0, 85, 100 }) |argument, expected| {
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 235ce072..f9e9c6bd 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -10,6 +10,7 @@ log recent events, one a line: new/del/rename/save <serial> <name>, and
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
focus the serial of the pane with the keyboard; write a serial to give it the keyboard
+ctl the settings, one a line as a write takes them; write one, or Newcol, Dump, Kill...
pane/new open it to make a pane; the read answers that pane's serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
errors event look exec, and pty/ for terminals; rmdir closes it
@@ -26,7 +27,6 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too
echo 'Msg hello' > $m/exec show text in the editor
echo '#0,#5' > $m/pane/$n/addr; printf NEW >> $m/pane/$n/data replace bytes 0..5
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
exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
cat $m/pane/$n/event hold it open to own that pane's clicks
echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT
@@ -42,4 +42,4 @@ Pitfalls, one each:
tag reads path++text; writes append to the text (newlines too); truncating clears it, and dot or addr.
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
A failing command is reported in the editor and in log, not as a write error; a bad line fails the write.
- pane/<n>/ctl: acme's status line, then current or notcurrent; takes get (reload), lock/unlock on a held fd.
+ pane/<n>/ctl: acme's status line + current; takes get, lock/unlock (on a held fd) and pane builtins (Del).
diff --git a/src/fs.zig b/src/fs.zig
index 7e387958..339910a4 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1256,6 +1256,13 @@ pub const Namespace = struct {
out: std.ArrayList(u8) = .empty,
listeners: u16 = 0,
origin: u8 = 'K',
+ /// A ctl write is running builtins: one that would open a prompt for
+ /// its argument refuses (`refused`), since nobody is at the prompt.
+ no_prompt: bool = false,
+ refused: bool = false,
+ /// A refusal that quotes the message it refuses, as Plan 9's cmderror
+ /// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
+ ename: [128]u8 = undefined,
/// Something a held read may be waiting on changed since they were last
/// answered: a record queued, a run answered, a pane gone.
news: bool = false,
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 5cc238a7..b43d7b20 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -2,8 +2,9 @@
//! 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 verbs no file of its
-//! own would say any better: `get`, `lock` and `unlock`.
+//! editor. The root ctl reads the settings and takes the session's builtins;
+//! a pane's ctl reads its acme status line and takes acme's verbs `get`,
+//! `lock` and `unlock` and the builtins that act on a pane.
const std = @import("std");
const pardes = @import("../pardes.zig");
const look = @import("../look.zig");
@@ -11,6 +12,8 @@ const panes = @import("../panes.zig");
const config = @import("../config.zig");
const fs = @import("../fs.zig");
const tagline = @import("../tagline.zig");
+const builtins = @import("../builtins.zig");
+const exec_line = @import("../exec.zig");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");
@@ -177,6 +180,134 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
return w.buffered();
}
+// ---- control messages: the builtins, by scope ----
+
+const Builtin = builtins.registry.Builtin();
+
+/// Refuses a control message, quoting it the way Plan 9's cmderror does
+/// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`.
+fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
+ const room = p.fs.ename.len - why.len - 3;
+ const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why;
+ return tree.failText(req.tag, E.INVAL, text);
+}
+
+/// Checks one line written to a ctl as a builtin of `scope` before any line
+/// of the write runs: a word the registry knows, of this ctl's scope, given
+/// an argument only if it takes one, and for a setting a value it takes.
+/// Answers the refusal, or null. Words are the builtins' own, capitalised
+/// as on a tag; acme's lowercase verbs are the pane ctl's and alias none.
+fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply {
+ const sp = std.mem.indexOfAny(u8, line, " \t");
+ const word = line[0 .. sp orelse line.len];
+ const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
+ const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line);
+ if (builtins.registry.scope(b) != scope)
+ return refuse(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line);
+ if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
+ const setting = config.Runtime.find(word) orelse return null;
+ const takes = switch (setting.action) {
+ .theme => for (pardes.themes) |t| {
+ if (std.mem.eql(u8, t.name, arg)) break true;
+ } else false,
+ .font => config.Runtime.FontSpec.parse(arg) != null,
+ else => probe: {
+ var probe = p.settings;
+ break :probe probe.apply(setting, if (arg.len > 0) arg else null);
+ },
+ };
+ return if (takes) null else refuse(p, req, "bad value in control message", line);
+}
+
+/// Runs a checked control message at pane `id`, as a click on its word
+/// would; false when it would have opened a prompt for an argument, which
+/// nobody writing to a file is there to type.
+fn runBuiltin(p: *Pardes, id: usize, line: []const u8) bool {
+ p.fs.no_prompt = true;
+ p.fs.refused = false;
+ defer p.fs.no_prompt = false;
+ _ = exec_line.executeBuiltinLine(p, id, line);
+ return !p.fs.refused;
+}
+
+const e_prompt = "control message needs its argument";
+
+// ---- the root ctl ----
+
+/// Every setting, one a line, in the words a write of it takes, so that the
+/// file read back is the configuration, as webfs's ctl reads (webfs(4)).
+/// What is not a setting -- platform, themes, where the config came from --
+/// is /status's or the Config window's.
+fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
+ const s = &p.settings;
+ inline for (config.Runtime.settings) |setting| {
+ if (comptime !setting.enabled(builtins.capabilities)) continue;
+ const word = setting.word;
+ switch (setting.action) {
+ .toggle => |field| try w.print("{s} {s}\n", .{ word, if (@field(s, @tagName(field))) "on" else "off" }),
+ .theme => try w.print("{s} {s}\n", .{ word, p.theme().name }),
+ .shell => {
+ const chosen = s.shell.requested.get();
+ try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
+ },
+ .font => {
+ const requested = s.font.requested_name.get();
+ const name = if (requested.len > 0) requested else s.font.effective_name.get();
+ const size = if (s.font.requested_size_hundredths != 0) s.font.requested_size_hundredths else s.font.effective_size_hundredths;
+ if (name.len > 0 and size != 0)
+ try w.print("{s} {s}:{d}.{d:0>2}\n", .{ word, name, size / 100, size % 100 })
+ else if (name.len > 0)
+ try w.print("{s} {s}\n", .{ word, name });
+ },
+ .tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }),
+ .window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }),
+ .window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }),
+ .message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }),
+ // Bare, it names the default directory.
+ .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
+ .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
+ .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }),
+ }
+ }
+ try w.writeAll("LocationsConfig ");
+ try p.locations_config.write(w);
+ try w.writeByte('\n');
+ if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(s.pet)});
+}
+
+pub fn rootLen(p: *Pardes) u64 {
+ var buf: [256]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&buf);
+ writeSettings(p, &counting.writer) catch return 0;
+ return counting.fullCount();
+}
+
+pub fn readRoot(p: *Pardes, req: Req) Reply {
+ var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
+ writeSettings(p, &text.writer) catch return Reply.fail(req.tag, E.NOMEM);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+/// One session builtin a line, at whichever pane has the keyboard as each
+/// runs, as the root's look and exec click there; all are checked first.
+pub fn writeRoot(p: *Pardes, req: Req) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ 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 (!apply) {
+ if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
+ continue;
+ }
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
+ if (!runBuiltin(p, p.active, line)) return tree.failText(req.tag, E.INVAL, e_prompt);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
// ---- /focus ----
/// The pane with the keyboard, which a write of a serial moves, the way
@@ -207,8 +338,8 @@ pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply {
}
/// `get` 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.
+/// resolves. Any other line is a builtin that acts on a pane (`Del`,
+/// `Save f`), run at this one; a session's builtin is the root ctl's.
///
/// `lock` and `unlock` are acme's (editors/acme/xfid.c:603-611), so that a
/// client can make an edit of several writes to addr and data without
@@ -229,7 +360,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (o == null or o.?.what != .ctl) pane.fs.lock = null;
}
const other = pane.fs.lock != null and pane.fs.lock != mine;
- var asked = false;
+ const serial = pane.serial;
// Checked whole before anything applies, so a write that must wait for
// the lock has done nothing yet when it goes again.
for ([2]bool{ false, true }) |apply| {
@@ -238,8 +369,12 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
+ // A line before this one may have closed the pane (`Del`).
+ if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
if (std.mem.eql(u8, line, "get")) {
- asked = true;
+ if (!apply) continue;
+ const errno = get(p, pane);
+ if (errno != 0) return Reply.fail(req.tag, errno);
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
if (other) return .{ .tag = req.tag, .status = .again };
@@ -252,13 +387,13 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
pane.fs.lock = null;
pardes.turn.parked = true; // a `lock` waiting goes again
}
- } else return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
+ } else if (!apply) {
+ if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
+ } else if (!runBuiltin(p, p.paneBySerial(serial).?, line)) {
+ return tree.failText(req.tag, E.INVAL, e_prompt);
+ }
}
}
- 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) };
}
@@ -342,20 +477,84 @@ test "focus reads the pane with the keyboard, and a serial written to it moves t
try testing.expectEqual(p.paneBySerial(first).?, p.active);
}
-test "the pane ctl takes get, lock and unlock, and nothing that a file of its own now answers" {
+test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the rest in Plan 9's words" {
const gpa = testing.allocator;
const p = try withFile(gpa, "one\ntwo\n");
defer p.deinit();
- const ctl_node = Node.of(serialOf(p), .ctl);
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ // acme's other ctl words have files of their own here, and a builtin is
+ // its own capitalised word: `Get`, `DEL` and `del` are none of them.
for ([_][]const u8{
- "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "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(p.paneBySerial(serialOf(p)) != null);
+ "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL",
+ "put", "del", "delete", "clean", "dirty", "show", "mark",
+ "Get", "limit=addr", "get x", "look /tmp", "exec Del",
+ }) |bad| {
+ const refused = wr(p, ctl_node, bad);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ var want: [64]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{bad}), refused.reply.ename);
+ }
+ try testing.expectEqualStrings("not a window control message \"Newcol\"", wr(p, ctl_node, "Newcol").reply.ename);
+ try testing.expectEqualStrings("not a window control message \"Verbose off\"", wr(p, ctl_node, "Verbose off").reply.ename);
+ try testing.expectEqualStrings("wrong #args in control message \"Collapse now\"", wr(p, ctl_node, "Collapse now").reply.ename);
+ // Checked whole first: a bad line anywhere and nothing ran.
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nbogus").errno());
+ try testing.expect(p.panes[0].?.msg_len == 0 or !std.mem.eql(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "first"));
+
+ // A pane's builtin runs at that pane.
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Msg from ctl\n").reply.status);
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]);
+ // One that would ask at a prompt for its argument fails instead.
+ try testing.expectEqualStrings(e_prompt, wr(p, ctl_node, "Find").reply.ename);
+ try testing.expect(pane.prompt == .none);
+ try testing.expect(!p.fs.no_prompt);
+ // Save with no name on a scratch would ask for one: refused, not asked.
+ const other = try th.newPane(p);
+ try testing.expectEqualStrings(e_prompt, wr(p, Node.of(other, .ctl), "Save").reply.ename);
+ try testing.expect(p.panes[p.paneBySerial(other).?].?.prompt == .none);
+ // A line after the one that closed the pane has nowhere to run.
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(other, .ctl), "Del\nMsg after").errno());
+ try testing.expect(p.paneBySerial(other) == null);
}
+test "the root ctl reads the settings as a write takes them, and takes the session's builtins" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const first = rd(p, root_ctl, 0, 1 << 16);
+ try testing.expectEqual(Status.ok, first.reply.status);
+ const settings = try gpa.dupe(u8, first.bytes);
+ defer gpa.free(settings);
+ try testing.expectEqual(@as(u64, settings.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }).reply.attr.size);
+ try testing.expect(std.mem.indexOf(u8, settings, "Verbose on\n") != null);
+ try testing.expect(std.mem.indexOf(u8, settings, "LocationsConfig ") != null);
+ // Settings only: nothing /status or the Config window says.
+ try testing.expect(std.mem.indexOf(u8, settings, "Platform") == null);
+ try testing.expect(std.mem.indexOf(u8, settings, "requested") == null);
+
+ // Every line written back is taken, and changes nothing.
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, settings).reply.status);
+ try testing.expectEqualStrings(settings, rd(p, root_ctl, 0, 1 << 16).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off\n").reply.status);
+ try testing.expect(!p.settings.verbose);
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Verbose off\n") != null);
+ const columns = p.ncol;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ try testing.expectEqual(columns + 1, p.ncol);
+
+ try testing.expectEqualStrings("not a session control message \"Del\"", wr(p, root_ctl, "Del").reply.ename);
+ try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
+ try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
+ try testing.expectEqualStrings("bad value in control message \"Theme no-such-theme\"", wr(p, root_ctl, "Theme no-such-theme").reply.ename);
+ try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
+ try testing.expect(!p.settings.verbose);
+}
+
+
test "a second lock waits until the holder unlocks or closes, and binds nobody else" {
const p = try withFile(testing.allocator, "one\ntwo\n");
defer p.deinit();
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index b0de446e..13dfb0cf 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -175,6 +175,7 @@ pub const TopFile = enum(u4) {
pane,
new,
focus,
+ ctl,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -183,7 +184,7 @@ pub const TopFile = enum(u4) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane => 0o755,
- .look, .exec, .log, .focus => 0o666,
+ .look, .exec, .log, .focus, .ctl => 0o666,
.README, .index, .status, .screen, .listeners, .new => 0o444,
};
}
@@ -445,6 +446,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.look, .exec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => screen.listenersLen(p),
+ .ctl => ctl.rootLen(p),
.focus => focus: {
const pn = p.panes[p.active] orelse break :focus 0;
var digits: [16]u8 = undefined;
@@ -523,7 +525,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
}
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os");
// After the first ten, which older clients list by position.
- inline for (.{TopFile.focus}) |t| {
+ inline for (.{ TopFile.focus, TopFile.ctl }) |t| {
if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
}
sources.stage(p, out, "", &skip);
@@ -716,6 +718,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
.focus => ctl.readFocus(p, req),
+ .ctl => ctl.readRoot(p, req),
// The serial the open handed this fid, so that two reads of one
// fid answer the same pane: the read observes, the open acted.
.new => serial: {
@@ -749,6 +752,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
.look, .exec => ctl.command(p, req, null, f == .exec),
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
+ .ctl => ctl.writeRoot(p, req),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
diff --git a/src/pardes.zig b/src/pardes.zig
index 321a6b3c..fbc3e119 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5081,6 +5081,12 @@ pub const Pardes = struct {
pipe: modal.Normal.PipeBehavior,
save,
}) void {
+ // Nobody is at the keyboard to answer a command a script wrote to a
+ // ctl file: the write fails instead (src/ninep/ctl.zig).
+ if (p.fs.no_prompt) {
+ p.fs.refused = true;
+ return;
+ }
const marker = switch (request) {
.search => |marker| marker,
.save => config.save_marker,
diff --git a/test/fs.py b/test/fs.py
index ccf3eb89..82d7cf0b 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -245,6 +245,24 @@ def discovery(binary, embedded=False):
raise AssertionError('focus took a serial no pane has')
except OSError as refused:
assert 'no such window' in str(refused), refused
+ # /ctl reads the settings as a write takes them and takes the
+ # session's builtins; a pane's ctl takes the pane's.
+ settings = client.read('/ctl')
+ assert b'Verbose ' in settings and b'Platform' not in settings, settings
+ client.write('/ctl', settings)
+ assert client.read('/ctl') == settings
+ client.write('/ctl', b'Verbose off\n')
+ assert b'Verbose off\n' in client.read('/ctl')
+ for path, line, why in (('/ctl', b'Del\n', 'not a session control message "Del"'),
+ ('/ctl', b'Nope 3\n', 'unknown control message "Nope 3"'),
+ (f'/pane/{first}/ctl', b'Newcol\n', 'not a window control message "Newcol"')):
+ try:
+ client.write(path, line)
+ raise AssertionError(f'{path} took {line!r}')
+ except OSError as refused:
+ assert why in str(refused), refused
+ client.write(f'/pane/{first}/ctl', b'Msg said through ctl\n')
+ assert 'said through ctl' in ''.join(cell[0] for cell in client.screen()['cells'])
assert set(client.list('/pane')) == {'new', str(fixture), str(first), str(second)}
client.write(f'/pane/{first}/body', b'first pane', truncate=True)
assert client.read(f'/pane/{first}/body') == b'first pane'
@@ -315,7 +333,7 @@ def discovery(binary, embedded=False):
assert client.read('/src/pardes.zig').startswith(b'const std')
assert client.stat('/src/pardes.zig')['mode'] == 0o444
assert b'pub const Pardes' in client.read(f'/pane/{look(client, "/virtual/src/pardes.zig")}/body')
- print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock and focus behave')
+ print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock, focus and the ctl split behave')
def run_file(binary):