//! 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//; reading either //! answers the serials the last command made or touched. /status reports the //! 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"); const panes = @import("../panes.zig"); const config = @import("../config.zig"); const fs = @import("../fs.zig"); const tagline = @import("../tagline.zig"); const layout = @import("../layout.zig"); const builtins = @import("../builtins.zig"); const exec_line = @import("../exec.zig"); const tree = @import("tree.zig"); const pane_files = @import("pane.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const MAX_PANES = pardes.MAX_PANES; const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; // ---- which panes a command made or touched ---- const Serials = struct { list: [MAX_PANES]u32 = undefined, len: usize = 0, fn snapshot(p: *Pardes) Serials { var s: Serials = .{}; for (p.panes) |slot| if (slot) |pane| { s.list[s.len] = pane.serial; s.len += 1; }; return s; } }; fn noteResult(p: *Pardes, serial: u32) void { for (p.fs.results[0..p.fs.results_len]) |known| if (known == serial) return; if (p.fs.results_len == p.fs.results.len) return; p.fs.results[p.fs.results_len] = serial; p.fs.results_len += 1; } /// Runs one look or exec from the pane `id`, then records the panes it /// created; failing that, a look records the pane it focused and an exec /// the pane it acted on (even one it closed). pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) void { const context = p.panes[id].?.serial; // A pane an event reader holds hears its clicks instead of acting on // them (editors/acme/look.c:53 and exec.c:173 send them to winevent), // and a line written to a look or exec is a click: on that pane, or for // the root's, on the pane with the keyboard. The reader writes the // record back to have it done. // A click in a column's or the workspace's tag is not the pane's. if (!in_tag and p.panes[id].?.fs.readers > 0) { const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null; var flag: u32 = pardes.ctlfs.events.flag_expansion; if (named) flag |= pardes.ctlfs.events.flag_builtin; if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename; _ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text); return noteResult(p, context); } const before = Serials.snapshot(p); // An exec answers the pane it went to: a command pane it reused, or // the terminal it typed into. p.look_missed = false; const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: { look.lookAt(p, id, text); break :blk null; }; // A look that found nothing answers nothing, not the pane before it. if (!exec and p.look_missed) return; const after = Serials.snapshot(p); var created = false; for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) { // A look's hits buffer is how it found the text, not where: it // answers the pane the hit is selected in. if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue; noteResult(p, serial); created = true; }; if (created) return; if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial); noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial); } /// A write's messages, a line each, except that an `Edit` line takes the /// lines after it while its `{` group or `a`/`c`/`i` text block is open /// (sam_edit.needsMore): a block written whole reaches Edit whole, never /// as lines of their own. const Messages = struct { p: *Pardes, data: []const u8, at: usize = 0, fn init(p: *Pardes, data: []const u8) Messages { return .{ .p = p, .data = data }; } fn next(m: *Messages) ?[]const u8 { if (m.at >= m.data.len) return null; const start = m.at; var end = std.mem.indexOfScalarPos(u8, m.data, start, '\n') orelse m.data.len; m.at = end + 1; const first = std.mem.trim(u8, m.data[start..end], " \t\r"); const edit = "Edit"; if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t')) return first; end = editEnd(m.p, m.data, start, end, true).?; m.at = end + 1; return std.mem.trim(u8, m.data[start..end], " \t\r"); } }; /// Where the Edit block whose first line is `data[start..first_end]` ends: /// the end of its last line, taking the lines after it while its `{` /// group or a/c/i text block is open. Out of lines, the end of `data` when /// `to_end` (a write run whole), else null (wait for more). Only a line /// that could end what is open is parsed again with all before it: within /// a text block, just a `.` line. The arena copy of each parse is freed. fn editEnd(p: *Pardes, data: []const u8, start: usize, first_end: usize, to_end: bool) ?usize { const sam = @import("../sam_edit.zig"); const edit = "Edit"; const arena = p.scratch.allocator(); var end = first_end; var wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]); while (wait != .none) { if (end >= data.len) return if (to_end) data.len else null; const next = std.mem.indexOfScalarPos(u8, data, end + 1, '\n') orelse (if (to_end) data.len else return null); const line = std.mem.trim(u8, data[end + 1 .. next], " \t\r"); end = next; if (wait == .text and !std.mem.eql(u8, line, ".")) continue; wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]); } return end; } /// How much of `data` is whole messages, as `Messages` reads them: lines /// ended by a newline, an Edit block only once it closes. The rest waits /// for more (tree.Open.pending). pub fn completeEnd(p: *Pardes, data: []const u8) usize { const edit = "Edit"; var whole: usize = 0; var at: usize = 0; while (std.mem.indexOfScalarPos(u8, data, at, '\n')) |nl| { const start = at; var end = nl; const first = std.mem.trim(u8, data[start..end], " \t\r"); if (std.mem.startsWith(u8, first, edit) and (first.len == edit.len or first[edit.len] == ' ' or first[edit.len] == '\t')) end = editEnd(p, data, start, end, false) orelse return whole; at = end + 1; whole = at; } return whole; } // ---- look and exec ---- /// 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) p.fs.results_len = 0; var it = Messages.init(p, req.data); while (it.next()) |text| { if (text.len == 0) continue; // Only an Edit block holds newlines (Messages). for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; 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); p.fs.no_pane_slot = false; run(p, id, exec, text, false); // A pane it would have opened had no slot: the write fails, and // a look reads back nothing. if (p.fs.no_pane_slot) { p.fs.results_len = 0; return tree.failText(req.tag, E.IO, p.fs.ename[0..p.fs.no_pane_slot_len]); } } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } pub const e_too_long = std.fmt.comptimePrint("a command line is at most {d} bytes", .{pardes.exec.command_max}); /// A line an exec would run as a command, over the most a command line may /// be: refused whole, before anything runs, rather than said on the message /// row after the write succeeded. A builtin's line (Msg, an Edit block) may /// be longer. pub fn tooLong(req: Req, line: []const u8) ?Reply { if (line.len <= pardes.exec.command_max) return null; const cmd = exec_line.commandText(line); const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len]; if (std.meta.stringToEnum(Builtin, word) != null) return null; return tree.failText(req.tag, E.INVAL, e_too_long); } 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; } /// The panes the open's last write touched (tree.Open.results); with no /// open, the session's last. pub fn readResults(p: *Pardes, req: Req) Reply { const out = p.fs.stage(p.gpa); const o = tree.openOf(p, req); const kept = if (o) |open| open.results.list[0..open.results.len] else p.fs.results[0..p.fs.results_len]; for (kept) |serial| out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM); const open = o orelse return tree.stagedReply(p, req); if (!open.results.wrote) return tree.stagedReply(p, req); var at = req; at.off = open.results.read; const reply = tree.stagedReply(p, at); open.results.read += reply.payload.staged; return reply; } // ---- /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; w.print("pid {d}\nversion {s}\npanes {d}\n", .{ pid, pardes.version, count }) catch {}; return w.buffered(); } 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 readStatus(p: *Pardes, req: Req) Reply { var buf: [status_text_cap]u8 = undefined; const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, statusText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } // ---- /pane//ctl ---- fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { const plain = s.len > 0 and for (s) |c| { if (c <= ' ' or c == '\'') break false; } else true; if (plain) return w.writeAll(s); try w.writeByte('\''); for (s) |c| { if (c == '\'') try w.writeByte('\''); try w.writeByte(c); } try w.writeByte('\''); } pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; /// acme's winctlprint: serial, tag length, body length, a reserved zero, the /// dirty flag, then the width in cells, the font and the tab width. Then /// rio's word for whether the window has the keyboard, `current` or /// `notcurrent` (rio(4), wctl), which acme's line has no room for because /// acme has one window for input at a time only by the mouse. pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { var w = std.Io.Writer.fixed(buf); w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ pane.serial, pane_files.tagOf(p, pane).len, // A terminal's body is its history, read whole on an open's first read. if (pane.isTerminal()) panes.Terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len, @as(u32, 0), @intFromBool(pane_files.dirtyOf(pane)), pane.cols, }) catch return w.buffered(); const font = p.settings.font.effective_name.get(); writeQuoted(&w, if (font.len == 0) "default" else font) catch return w.buffered(); // While a column's or the workspace's tag has the keyboard, no pane does. const current = p.panes[p.active] == pane and !p.header_focus; w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {}; // A terminal bound as a REPL ends with its id, `python-a`. var idbuf: [48]u8 = undefined; const id = exec_line.replId(&idbuf, pane); if (id.len > 0) w.print("{s} ", .{id}) catch {}; 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); } /// `refuse`, saying which ctl takes the message instead. fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { const room = p.fs.ename.len -| (why.len + ctl.len + 18); const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) 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 if and only if it takes or requires 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 { // A setting this build's frontend cannot show (Lift, GripWidth on a // terminal) is known, and says so rather than sound misspelt. if (config.Runtime.find(word) != null) { var why: [64]u8 = undefined; return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line); } return refuse(p, req, "unknown control message", line); }; // Edit is the one pane word the root takes too: at the active pane. if (builtins.registry.scope(b) != scope and b != .Edit) return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else if (b == .Delcol or b == .Joincol) "col//ctl" // a column's own ctl, the one /layout names else "pane//ctl"); if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line); if (arg.len == 0 and builtins.registry.requiresArg(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 { // `refuse` quotes the line after it, in what is left of 128. var near: [40]u8 = undefined; var why: [104]u8 = undefined; return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line); }, .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); }, }; if (takes) return null; var why: [104]u8 = undefined; return refuse(p, req, if (config.Runtime.choices(setting.action)) |values| std.fmt.bufPrint(&why, "bad value in control message; takes {s}", .{values}) catch "bad value in control message" else "bad value in control message", line); } /// Runs a checked control message at pane `id`, as a click on its word /// would. Answers the refusal when it would have opened a prompt for an /// argument, which nobody writing to a file is there to type, or when it /// reported an error: `Mount: AlreadyMounted "Mount peer /tmp/s"`. pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { p.fs.no_prompt = true; p.fs.refused = false; p.fs.failure_len = 0; defer p.fs.no_prompt = false; // What a ctl write touched reads back from /look and /exec as a click's // does (`run`), so a script that made a pane with `Tty` finds it there. const context = p.panes[id].?.serial; const before = Serials.snapshot(p); _ = exec_line.executeBuiltinLine(p, id, line); const after = Serials.snapshot(p); var created = false; for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) { noteResult(p, serial); created = true; }; if (!created) noteResult(p, context); if (p.fs.refused) return refuse(p, req, e_prompt, line); if (p.fs.failure_len == 0) return null; // An Edit's failure names what failed (the pattern that missed): its // line, a program, is not quoted after it. const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit"); const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line); return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename }; } 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 { try writeSettingsOf(p, &p.settings, w); 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(p.settings.pet)}); } /// The setting words of `s`, one a line, as the root ctl reads them; a Dump /// keeps those that differ from a fresh session's (dump.zig). pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer) !void { 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, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }), .shell => { const chosen = s.shell.requested.get(); try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.defaultShell() }); }, .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 }), .choice => |which| switch (which) { inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }), }, .message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }), // The directory in effect, as Config reports it; bare only when // there is none (no home to put the default in). .dump_dir => { var buf: [900]u8 = undefined; if (pardes.dump.directory(&buf, s.dump_dir.get())) |dir| try w.print("{s} {s}\n", .{ word, dir }) 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| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }), .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }), .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }), .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }), .grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }), } } } 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| { if (apply) p.fs.results_len = 0; var it = Messages.init(p, req.data); while (it.next()) |line| { if (line.len == 0) continue; if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) { const size = sizeOf(line) orelse return refuse(p, req, e_size, line); // A frontend's window is the size; only a session none is // attached to (--detach, unattended) takes one written. if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line); if (apply) { const was: [2]u16 = .{ p.screen_w, p.screen_h }; p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } }); p.sync(); // Every pane keeps its tag and two rows, as placement // leaves them: a size too small for that is refused. if (!pardes.layout.everyPaneRoomy(p)) { p.update(.{ .resize = .{ .cols = was[0], .rows = was[1] } }); p.sync(); return refuse(p, req, "size: too small for the panes, each its tag and 2 rows", line); } } 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); p.fs.session_write = true; defer p.fs.session_write = false; if (runBuiltin(p, req, p.active, line)) |refusal| return refusal; } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } const size_min = [2]u16{ 20, 6 }; const size_max = [2]u16{ 4096, 4096 }; const e_size = std.fmt.comptimePrint("size takes , at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] }); /// `size `, within size_min and size_max. fn sizeOf(line: []const u8) ?[2]u16 { var it = std.mem.tokenizeAny(u8, line["size".len..], " \t"); const cols = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; const rows = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; if (it.next() != null or cols < size_min[0] or rows < size_min[1] or cols > size_max[0] or rows > size_max[1]) return null; return .{ cols, rows }; } // ---- /commands ---- /// Every builtin the registry holds, one a line: its word, `arg` when it /// takes one, and which ctl takes it, `root` or `pane` -- the registry /// itself, so a new builtin is listed the moment it exists. fn writeCommands(w: *std.Io.Writer) !void { for (std.enums.values(Builtin)) |b| { try w.print("{s}{s} {s}", .{ @tagName(b), if (builtins.registry.takesArg(b)) " arg" else "", if (builtins.registry.scope(b) == .session) "root" else "pane", }); // A setting that chooses among words: the words, comma-joined. Bare, // it steps to the next of them (a two-valued one flips). if (config.Runtime.find(@tagName(b))) |setting| if (config.Runtime.choices(setting.action)) |values| { try w.writeByte(' '); var parts = std.mem.splitSequence(u8, values, ", "); var first = true; while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v }); }; try w.writeByte('\n'); } } pub fn commandsLen() u64 { var buf: [256]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&buf); writeCommands(&counting.writer) catch return 0; return counting.fullCount(); } pub fn readCommands(p: *Pardes, req: Req) Reply { var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); writeCommands(&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); } // ---- /focus ---- /// The pane with the keyboard, which a write of a serial moves, the way /// writing `current` to rio's wctl does (rio(4)). While a column's or the /// workspace's tag has it, no pane does, and this reads empty. A write /// gives the keyboard and nothing else: a folded pane stays folded, as /// rio keeps `current` apart from `unhide`. pub fn readFocus(p: *Pardes, req: Req) Reply { const out = p.fs.stage(p.gpa); if (p.header_focus) return tree.stagedReply(p, req); const pane = p.panes[p.active] orelse return tree.stagedReply(p, req); out.print(p.gpa, "{d}\n", .{pane.serial}) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } pub fn writeFocus(p: *Pardes, req: Req) Reply { const text = std.mem.trim(u8, req.data, " \t\r\n"); const serial = std.fmt.parseInt(u32, text, 10) catch return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); const id = p.paneBySerial(serial) orelse return tree.failText(req.tag, E.NOENT, e_no_pane); // The keyboard goes to the pane itself, off any header that had it, and // its column becomes the active one, as the user's click there would // make it: pane/new and a look place there next, whatever Placement. tagline.exitHeader(p); p.active = id; if (pardes.layout.findPane(p, id)) |at| p.setActiveColumn(at.col); return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } const e_no_pane = "no such pane"; pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { var buf: [pane_text_cap]u8 = undefined; const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, paneText(p, pane, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } /// `get` reloads the buffer from the name it carries, wherever that name /// 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 /// another's landing in between. As in acme the lock binds only the clients /// that ask for it; a write to any other file is never refused for it, nor /// is the person at the keyboard. It belongs to the open that wrote it, /// which alone may `unlock`, and closing that open or the pane gives it up. /// Where acme's qlock blocks a second locker, this refuses it at once with /// `file in use`, and the client tries again: through a kernel or FUSE /// mount a blocked write holds the file's writes, the holder's own `unlock` /// and close among them, so a waiting lock would never be let in. pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { // This open's handle is what holds the lock; a write that came on no // writable open (the editor's own) has none. const mine: ?u32 = if (tree.openOf(p, req)) |o| (if (o.what == .ctl) req.handle else null) else null; // The pane keeps the holder's handle, which names that open only on // this pane's ctl node; one that no longer does holds nothing. if (pane.fs.lock) |h| { const o = tree.openOf(p, .{ .tag = 0, .op = .write, .node = tree.Node.of(pane.serial, .ctl), .handle = h }); if (o == null or o.?.what != .ctl) pane.fs.lock = null; } const other = pane.fs.lock != null and pane.fs.lock != mine; 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| { if (apply) p.fs.results_len = 0; var held = !other and pane.fs.lock != null; var it = Messages.init(p, req.data); while (it.next()) |line| { 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")) { if (!apply) continue; if (getRefused(p, pane)) |said| return tree.failText(req.tag, E.IO, said); 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 tree.failText(req.tag, E.BUSY, e_locked); held = true; if (apply) pane.fs.lock = mine; } else if (std.mem.eql(u8, line, "unlock")) { if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked); held = false; if (apply) pane.fs.lock = null; } else if (std.mem.eql(u8, line, "answer")) { return tree.failText(req.tag, E.INVAL, "answer takes a choice or -"); } else if (std.mem.startsWith(u8, line, "answer ")) { // The pane's question (its `ask` in the log), answered. const choice = std.mem.trim(u8, line["answer ".len..], " "); const id = p.paneBySerial(serial).?; var offered: [64]u8 = undefined; const choices = p.questionChoices(id, &offered) orelse return refuse(p, req, "no question asked", line); // Only a choice the question offers answers it; anything // else leaves it standing, and says what it takes. if (!std.mem.eql(u8, choice, "-") and (choice.len != 1 or std.mem.indexOfScalar(u8, choices, choice[0]) == null)) { var why: [96]u8 = undefined; return refuse(p, req, std.fmt.bufPrint(&why, "answer takes {s} or -", .{choices}) catch "answer takes a choice or -", line); } if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]); } else if (!apply) { if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal; } else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| { return refusal; } } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } const e_not_locked = "pane not locked by this open"; /// Who holds it is another open of this ctl, whose client pardes cannot name. const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock"; /// acme's get asks winclean first (exec.c:513): unsaved text is warned /// about once, and the same get again, nothing edited since, discards it. fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { const f = pane_files.fileOf(pane) orelse return null; if (!panes.Output.fileTraits(f.output).saves or f.revision == f.saved_revision) return null; // Dirty by a rename alone is no text to lose: only edits are asked about. const saved = f.saved_hash orelse return null; if (std.hash.Wyhash.hash(0, f.content) == saved) return null; if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null; pane.discard_warned = .{ .revision = f.revision, .by = .get }; const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; p.setMessage(p.paneBySerial(pane.serial).?, said); return said; } 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, }; panes.File.pushUndo(p, pane); panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; f.saved_hash = std.hash.Wyhash.hash(0, f.content); f.disk_newer = null; return 0; } // ---- tests ---- const testing = std.testing; const th = @import("testing.zig"); const call = th.call; const rd = th.rd; const wr = th.wr; const withFile = th.withFile; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; 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, tab width and whether it is current" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const pane = p.panes[0].?; const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); try testing.expectEqual(Status.ok, a.reply.status); var want: std.ArrayList(u8) = .empty; defer want.deinit(gpa); try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} current ", .{ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, }); try testing.expectEqualStrings(want.items, a.bytes); try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size); var quoted: [64]u8 = undefined; var w = std.Io.Writer.fixed("ed); try writeQuoted(&w, "DejaVu Sans Mono"); try testing.expectEqualStrings("'DejaVu Sans Mono'", w.buffered()); w = std.Io.Writer.fixed("ed); try writeQuoted(&w, "it's"); try testing.expectEqualStrings("'it''s'", w.buffered()); } test "commands lists every builtin with its argument and the ctl that takes it" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const node = @intFromEnum(tree.TopFile.commands); const listed = rd(p, node, 0, 1 << 16); try testing.expectEqual(@as(u64, listed.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); try testing.expectEqual(E.PERM, wr(p, node, "Del\n").errno()); var lines: usize = 0; var it = std.mem.tokenizeScalar(u8, listed.bytes, '\n'); while (it.next()) |line| : (lines += 1) { var words = std.mem.tokenizeScalar(u8, line, ' '); const b = std.meta.stringToEnum(Builtin, words.next().?).?; if (builtins.registry.takesArg(b)) try testing.expectEqualStrings("arg", words.next().?); try testing.expectEqualStrings(if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?); if (config.Runtime.find(@tagName(b))) |s| if (config.Runtime.choices(s.action) != null) try testing.expect(words.next() != null); try testing.expect(words.next() == null); } try testing.expectEqual(std.enums.values(Builtin).len, lines); for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row| try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null); } test "Undo and Redo on a pane's ctl step its body through its edits" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); const body = Node.of(serial, .body); try testing.expectEqual(Status.ok, wr(p, body, "two\n").reply.status); try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status); try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes); // Nothing left: said, and the write still succeeds, as acme's is silent. try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status); try testing.expect(th.logHas(p, "Redo: nothing to redo")); _ = wr(p, Node.of(serial, .ctl), "Undo"); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); try testing.expect(th.logHas(p, "Undo: nothing to undo")); // Said again word for word, it is that line counted, as err's are. for (0..3) |_| _ = wr(p, Node.of(serial, .ctl), "Undo"); try testing.expect(th.logHas(p, "Undo: nothing to undo (x4)\n")); } test "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const first = serialOf(p); const second = try th.newPane(p); const focus = @intFromEnum(tree.TopFile.focus); var line: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{second}), rd(p, focus, 0, 64).bytes); try testing.expectEqual(@as(u64, rd(p, focus, 0, 64).bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size); try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " notcurrent ")); // A header has the keyboard: no pane does, and a write takes it back. tagline.enterHeader(p, null); try testing.expectEqualStrings("", rd(p, focus, 0, 64).bytes); try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size); try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(second, .ctl), 0, 4096).bytes, " notcurrent ")); try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}\n", .{first})).reply.status); try testing.expect(!p.header_focus); try testing.expectEqual(p.paneBySerial(first).?, p.active); try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " current ")); try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(second, .ctl), 0, 4096).bytes, " notcurrent ")); try testing.expectEqualStrings(e_no_pane, wr(p, focus, "99999\n").reply.ename); try testing.expectEqual(E.NOENT, wr(p, focus, "99999").errno()); for ([_][]const u8{ "", "two", "-1", "1 2" }) |bad| try testing.expectEqual(E.INVAL, wr(p, focus, bad).errno()); try testing.expectEqual(p.paneBySerial(first).?, p.active); // Focus gives the keyboard only: a folded pane stays folded. const folded = p.paneBySerial(second).?; layout.toggleCollapse(p, folded); try testing.expect(p.panes[folded].?.collapsed); try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}", .{second})).reply.status); try testing.expectEqual(folded, p.active); try testing.expect(p.panes[folded].?.collapsed); } 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 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", "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\": write it to /ctl", wr(p, ctl_node, "Newcol").reply.ename); try testing.expectEqualStrings("not a window control message \"Verbose off\": write it to /ctl", 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 means nothing bare is refused before anything runs. try testing.expectEqualStrings("wrong #args in control message \"Find\"", wr(p, ctl_node, "Find").reply.ename); try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nMsg").errno()); try testing.expect(!std.mem.eql(u8, pane.msg[0..pane.msg_len], "first")); 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 ++ " \"Save\"", 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\": write it to pane//ctl", 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; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename); const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename; try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n"); try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\"")); try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename); try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename); // A bare required word fails the check, so the line before never runs. try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Verbose on\nMount").errno()); try testing.expect(!p.settings.verbose); // A builtin that fails as it runs fails the write, quoting its error and // its line; the lines before it have taken effect, as in acme. const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off"); try testing.expectEqual(E.IO, failed.errno()); try testing.expectEqualStrings("Mount name dial: missing argument \"Mount x\"", failed.reply.ename); try testing.expect(p.settings.verbose); try testing.expect(!p.fs.no_prompt); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status); } test "a second lock is refused until the holder unlocks or closes, and binds nobody else" { const p = try withFile(testing.allocator, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const w = struct { fn ctl(pp: *Pardes, node: u64, h: u32, data: []const u8) th.Answer { return call(pp, .{ .tag = 4, .op = .write, .node = node, .handle = h, .data = data }); } }; const a = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 2 }).reply.handle; const b = call(p, .{ .tag = 2, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; try testing.expect(a != 0 and b != 0 and a != b); // Reading the status line holds nothing, so it cannot lock either. try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 3, .op = .open, .node = ctl_node }).reply.handle); try testing.expectEqual(E.INVAL, wr(p, ctl_node, "lock\n").errno()); try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\n").reply.status); try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\n").reply.status); // Another open's lock is refused at once, and so is its unlock; writes // to the pane's other files are not. try testing.expectEqualStrings(e_locked, w.ctl(p, ctl_node, b, "lock\n").reply.ename); try testing.expectEqual(E.BUSY, w.ctl(p, ctl_node, b, "lock\n").errno()); try testing.expectEqualStrings(e_not_locked, w.ctl(p, ctl_node, b, "unlock\n").reply.ename); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .addr), "1").reply.status); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "ONE\n").reply.status); try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); // Once the holder unlocks, the other's try takes it. try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "unlock\n").reply.status); try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, b, "lock\n").reply.status); try testing.expectEqual(E.BUSY, w.ctl(p, ctl_node, a, "lock\nunlock\n").errno()); // Closing the holder gives it up. _ = call(p, .{ .tag = 5, .op = .release, .node = ctl_node, .handle = b }); try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\nunlock\nlock\n").reply.status); try testing.expectEqual(E.INVAL, w.ctl(p, ctl_node, a, "unlock\nunlock\n").errno()); try testing.expectEqual(@as(?u32, a), p.panes[0].?.fs.lock); // The lock lives and dies with the pane. const other = try th.newPane(p); const other_ctl = Node.of(other, .ctl); const c = call(p, .{ .tag = 6, .op = .open, .node = other_ctl, .omode = 1 }).reply.handle; try testing.expectEqual(Status.ok, w.ctl(p, other_ctl, c, "lock\n").reply.status); try testing.expectEqual(Status.ok, th.rmdir(p, Node.of(other, .dir)).reply.status); try testing.expectEqual(E.NOENT, w.ctl(p, other_ctl, c, "lock\n").errno()); for ([_]u64{ ctl_node, other_ctl }, [_]u32{ a, c }) |node, h| _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = h }); try testing.expect(p.panes[0].?.fs.lock == null); for (p.fs.opens) |o| try testing.expect(o.node == 0); } test "Edit through a pane's ctl renames every foo in one undo step, and a failed one changes nothing" { const p = try withFile(testing.allocator, "foo x foo y foo\n"); defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const body = Node.of(serial, .body); try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,x/foo/c/foobar/\n").reply.status); try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes); const failed = wr(p, ctl_node, "Edit ,s/nothing/x/\n"); try testing.expectEqual(E.IO, failed.errno()); try testing.expectStringStartsWith(failed.reply.ename, "Edit: no substitution"); try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes); // The whole rename is one undo step. try testing.expectEqual(Status.ok, wr(p, ctl_node, "Undo").reply.status); try testing.expectEqualStrings("foo x foo y foo\n", rd(p, body, 0, 64).bytes); } test "a write of an Edit block carries it whole, to a pane's ctl, the root's ctl and exec" { const p = try withFile(testing.allocator, "foo x\nfoo y\n"); defer p.deinit(); const serial = serialOf(p); const body = Node.of(serial, .body); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\ni/[/\na/]/\n}\n").reply.status); try testing.expectEqualStrings("[foo] x\n[foo] y\n", rd(p, body, 0, 64).bytes); p.active = p.paneBySerial(serial).?; try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Edit ,x/\\[/{\nc/ 0); _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); _ = wr(p, Node.of(serial, .body), "unsaved\n"); // Unsaved text is asked about once, as acme's get asks winclean. const asked = wr(p, ctl_node, "get\n"); try testing.expectEqual(E.IO, asked.errno()); try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)")); 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)); } test "exec Save writes the named file through the host" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); 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, .exec), "Save\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expect(saved.saved); p.perform(.{ .save_file = .{ .pane = 0 } }); try testing.expect(!pane_files.dirtyOf(pane)); try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); } test "a pane's own look and exec go to the event reader holding it, which writes them back to act" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg routed\n"); defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg routed\n").reply.status); // Not done: the reader has it, as it would a middle click. const pane = p.panes[0].?; try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null); const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes; try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record); // Written back, it is done. try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status); try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null); try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status); try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); // The root's is a click at the pane with the keyboard, this one. try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status); try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null); try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); _ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h }); // With no reader, the root's and the pane's act at once. try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status); try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "direct") != null); } test "look and exec read back what the last command touched, a ctl write's too" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const serial = serialOf(p); var want: [32]u8 = undefined; _ = wr(p, root_exec, "Msg first\n"); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, root_exec, 0, 64).bytes); // A pane made through ctl is what both read next, not the Msg's pane. try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status); const made = p.panes[p.active].?.serial; try testing.expect(made != serial); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes); // A ctl write that makes nothing names the pane it ran at. try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes); } test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" { // 9ns (cloud9's 9ns/src/nine.zig) maps an error to an errno by its // words, and EIO when none of them match: these words must not. const addressing = @import("addr.zig"); const einval = [_][]const u8{ "control message", "invalid", "bad " }; const other = [_][]const u8{ "interrupt", "shut down", "not exist", "not found", "no such", "exists", "not empty", "not a dir", "is a dir", "permission", "denied", "read-only", "read only", "no space", "not allowed", "not permitted", "cannot", "fid", "busy", "in use", "too long", "not supported", "unsupported" }; const Case = struct { text: []const u8, malformed: bool }; for ([_]Case{ .{ .text = addressing.e_syntax, .malformed = true }, .{ .text = addressing.e_regexp, .malformed = true }, .{ .text = e_prompt, .malformed = true }, .{ .text = addressing.e_no_match, .malformed = false }, .{ .text = addressing.e_range, .malformed = false }, .{ .text = addressing.e_order, .malformed = false }, .{ .text = addressing.e_slow, .malformed = false }, .{ .text = "/tmp/x.txt: Modified (Exit again to discard)", .malformed = false }, }) |c| { const says_einval = for (einval) |w| { if (std.ascii.findIgnoreCase(c.text, w) != null) break true; } else false; try testing.expectEqual(c.malformed, says_einval); for (other) |w| try testing.expect(std.ascii.findIgnoreCase(c.text, w) == null); } } test "Joincol with no column to the right and Theme with no such theme say so" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); const joined = wr(p, root_ctl, "Joincol\n"); try testing.expectEqual(tree.Status.err, joined.reply.status); try testing.expectStringStartsWith(joined.reply.ename, "Joincol: no column to the right"); const themed = wr(p, root_ctl, "Theme drak\n"); try testing.expectEqual(E.INVAL, themed.errno()); try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: "); try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null); try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\"")); // A click on the word says it on the message row. _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n"); const pane = p.panes[p.active].?; try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null); } test "a look after Newcol fills the empty column the keyboard is on" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" }); var dir: [4096]u8 = undefined; const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; var line: [4200]u8 = undefined; // Newcol makes an empty column and gives its tag the keyboard; no pane // has it, so focus reads empty. try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); try testing.expectEqual(@as(usize, 0), p.col_n[p.ncol - 1]); try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.focus), 0, 64).bytes); try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]); try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const f = pane_files.fileOf(p.panes[0].?).?; f.content = try p.gpa.realloc(f.content, 3); @memcpy(f.content, "yy\n"); f.revision +%= 1; // unsaved: a Restore would warn about it first var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "notes.txt", .data = "just notes\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line_buf: [4200]u8 = undefined; const line = try std.fmt.bufPrint(&line_buf, "Restore {s}/notes.txt\n", .{dir}); const root_ctl = @intFromEnum(tree.TopFile.ctl); const refused = wr(p, root_ctl, line); try testing.expectEqual(E.IO, refused.errno()); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "not a pardes dump") != null); try testing.expect(p.restore_req == null); try testing.expect(p.panes[0].?.discard_warned == null); p.fs.late_failure_len = 0; p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound); try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: FileNotFound", p.fs.late_failure[0..p.fs.late_failure_len]); } test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); if (!builtins.capabilities.lift) { try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename); try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); } try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename); var buf: [900]u8 = undefined; if (pardes.dump.directory(&buf, "")) |dir| { var want: [920]u8 = undefined; const line = try std.fmt.bufPrint(&want, "\nDumpDir {s}\n", .{dir}); try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null); } } test "Shell refuses a path that is no executable, and bare it goes back to the default" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); const refused = wr(p, root_ctl, "Shell /nonexistent/zzsh\n"); try testing.expectEqual(Status.err, refused.reply.status); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: no shell \"/nonexistent/zzsh\"") != null); try testing.expect(th.logHas(p, "no shell \"/nonexistent/zzsh\"")); try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno()); try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory")); try testing.expectEqualStrings("", p.settings.shell.requested.get()); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status); try testing.expectEqualStrings("/bin/sh", p.settings.shell.requested.get()); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell\n").reply.status); try testing.expectEqualStrings("", p.settings.shell.requested.get()); try testing.expect(th.logHas(p, "Shell: the default again, ")); var want: [300]u8 = undefined; try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 8192).bytes, try std.fmt.bufPrint(&want, "Shell {s}\n", .{config.defaultShell()})) != null); } test "a builtin that fails a ctl write logs only its err, so the same failure again is counted" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); p.update(.tick); // the pane's `new` is logged first while (p.nextEffect()) |_| {} const ctl_node = @intFromEnum(tree.TopFile.ctl); for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word \"Kill zzz\" (x3)\n")); try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } test "an exec line over the command limit fails the write and says the limit; a builtin's may be longer" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const long = "echo " ++ "y" ** pardes.exec.command_max ++ "\n"; const refused = wr(p, root_exec, long); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectEqualStrings(e_too_long, refused.reply.ename); try testing.expect(th.logHas(p, "a command line is at most 1024 bytes")); try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status); } test "Edit's p and = never fail for want of room for +Errors: the text is logged instead" { const p = try withFile(testing.allocator, "one\ntwo\n"); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 6 } }); const ctl_node = Node.of(serialOf(p), .ctl); try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,p\n").reply.status); try testing.expect(th.logHas(p, " one\n") and th.logHas(p, " two\n")); p.sync(); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); try testing.expectEqual(@as(usize, 1), count); // the +Errors that had no room is gone } test "size sets the screen of a session no frontend is attached to, and only of one" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120 40\n").errno()); // a frontend's p.unattended = true; try testing.expectEqual(Status.ok, wr(p, root_ctl, "size 120 40\n").reply.status); try testing.expectEqual(@as(u16, 120), p.screen_w); try testing.expectEqual(@as(u16, 40), p.screen_h); try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120\n").errno()); try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size\n").errno()); try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 5 2\n").reply.ename, "at least 20x6, at most 4096x4096") != null); // Too small for the panes it has, each its tag and two rows: refused, // the size as it was. for (0..4) |_| p.newScratchBelow(p.active); p.sync(); try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 80 8\n").reply.ename, "too small for the panes") != null); try testing.expectEqual(@as(u16, 40), p.screen_h); } test "Repl with no such language names a few whole, and where the rest are" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const refused = wr(p, Node.of(serialOf(p), .ctl), "Repl zzlang\n"); try testing.expectEqual(Status.err, refused.reply.status); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Repl: no language \"zzlang\"; - or one like zig ada bash c c_sharp clojure (") != null); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, " more: docs/tags.md, Repl)") != null); } test "a 10k-line Edit text block is taken in linear time and memory" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); var block: std.ArrayList(u8) = .empty; defer block.deinit(gpa); try block.appendSlice(gpa, "Edit $a\n"); for (0..10_000) |i| try block.print(gpa, "line {d} of the block {{ with a brace }}\n", .{i}); try block.appendSlice(gpa, ".\n"); const started = std.Io.Clock.awake.now(std.testing.io); try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), block.items).reply.status); const took = started.durationTo(std.Io.Clock.awake.now(std.testing.io)).toNanoseconds(); try testing.expect(took < std.time.ns_per_s); try testing.expect(p.scratch.queryCapacity() < 50 * 1024 * 1024); try testing.expectEqual(@as(usize, 10_001), std.mem.count(u8, p.panes[0].?.file.?.content, "\n")); } test "a file changed on disk under unsaved edits is not reloaded: it says so, get asks, Save warns once" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); const f = &p.panes[0].?.file.?; // Clean, it takes the new text. pardes.panes.File.changed(p, 0, "two\n"); try testing.expectEqualStrings("two\n", f.content); // Edited, it keeps its text and stays dirty. _ = wr(p, Node.of(serial, .body), "mine\n"); pardes.panes.File.changed(p, 0, "three\n"); try testing.expectEqualStrings("two\nmine\n", f.content); try testing.expect(f.revision != f.saved_revision); try testing.expect(th.logHas(p, "changed on disk (get reloads it, Save overwrites it)")); var want: [32]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "changed {d}\n", .{serial}))); // Exit and get still ask about the edits. try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status); try testing.expect(std.mem.indexOf(u8, wr(p, Node.of(serial, .ctl), "get\n").reply.ename, "Modified") != null); // Save warns once before overwriting the newer file, then writes. const first = wr(p, Node.of(serial, .ctl), "Save\n"); try testing.expectEqual(Status.err, first.reply.status); try testing.expect(std.mem.indexOf(u8, first.reply.ename, "modified on disk since read") != null); try testing.expect(wr(p, Node.of(serial, .ctl), "Save\n").saved); } test "a looked word is found in its own pane, next place after the dot, wrapping, and opens no pane" { const p = try withFile(testing.allocator, "foo x foo y\n"); defer p.deinit(); const serial = serialOf(p); const look_node = Node.of(serial, .look); const dot = Node.of(serial, .dot); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); for ([_][]const u8{ " 0 3 ", " 6 9 ", " 0 3 " }) |want| { try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status); try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes); } var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); // LookWord list: the +Search listing, as before. try testing.expect(p.executeBuiltinLine(0, "LookWord list")); try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status); after = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expect(after > count); }