diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 11 | ||||
| -rw-r--r-- | build.zig.zon | 4 | ||||
| -rw-r--r-- | docs/fs.md | 31 | ||||
| -rw-r--r-- | src/9p_io.zig | 4 | ||||
| -rw-r--r-- | src/builtins.zig | 59 | ||||
| -rw-r--r-- | src/config.zig | 83 | ||||
| -rw-r--r-- | src/fs-help.txt | 4 | ||||
| -rw-r--r-- | src/fs.zig | 7 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 237 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 8 | ||||
| -rw-r--r-- | src/pardes.zig | 6 | ||||
| -rw-r--r-- | test/fs.py | 20 |
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 @@ -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). @@ -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, @@ -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): |
