//! 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 = if (p.panes[id]) |pane| pane.serial else 0; // 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; one // in the pane's own tag (its tagexec) is, an `x` or `l` record as // acme's tag clicks are. if (!p.exec_header and p.panes[id].?.fs.readers > 0) { const named = exec_line.namesBuiltin(text); // No flag 2: a written line was never expanded from a click, and no // expansion record follows (editors/acme/look.c:42-43, exec.c:154-157). var flag: u32 = 0; 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; const action: pardes.ctlfs.events.Action = if (in_tag) (if (exec) .tag_exec else .tag_look) else (if (exec) .body_exec else .body_look); _ = pardes.ctlfs.events.noteAction(p, id, action, 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; p.fs.refilled = null; 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 that made a +Search pane (LookWord list, a terminal) // answers it: that is where the places it found are. noteResult(p, serial); created = true; }; if (created) return; // A +Search it filled again (Find and Grep reuse one): that is what it // touched. if (p.fs.refilled) |serial| if (p.paneBySerial(serial) != null) return noteResult(p, serial); if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial); // A word in a column's or the workspace's tag ran at no pane: it // answers one only if it focused one (Newcol answers nothing). if (p.exec_header) { if (p.active != id) if (p.panes[p.active]) |pane| noteResult(p, pane.serial); return; } 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, /// A look's line is its text, leading blanks and all (` return x`, /// a diff's context line ` ` alone): only its `\r` goes. keep_blanks: bool = false, 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; if (m.keep_blanks) return std.mem.trimEnd(u8, m.data[start..end], "\r"); 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, in_tag: bool) Reply { // The root's lines run at the keyboard's pane: a refusal before any runs // is that pane's too, in its err record. p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 0; for ([2]bool{ false, true }) |apply| { if (apply) p.fs.results_len = 0; var it = Messages.init(p, req.data); it.keep_blanks = !exec; 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') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; var id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; // No pane at all: the root's look and exec act in the session's // directory. An exec runs as the workspace tag's; a look from a // scratch made as New makes one (and its column), dropped // unannounced when the look went elsewhere. var scratch: ?usize = null; if (p.panes[id] == null and serial == null and p.anyPane() == null) { if (exec) { p.exec_header = true; defer p.exec_header = false; if (captured(p, req, id, exec, text, true)) |refusal| return refusal; continue; } const free = p.freeSlot() orelse return Reply.fail(req.tag, E.NOSPC); p.newScratchFor(id, .scratch); if (p.panes[free] == null) return Reply.fail(req.tag, E.NOSPC); p.active = free; id = free; scratch = free; } if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); defer if (scratch) |s| if (p.active != s) if (p.panes[s]) |sp| if (sp.file) |f| { if (f.content.len == 0) p.removePane(s, null) catch {}; }; p.fs.ran_at = p.panes[id].?.serial; p.fs.no_pane_slot = false; if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal; // 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) }; } // "invalid" is the word 9ns reads as EINVAL (enameToErrno): through a // mount the refusal is EINVAL, as the reply's errno is. pub const e_too_long = std.fmt.comptimePrint("invalid command line: 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 const e_control = "invalid command line: it holds a control character (or DEL) other than a tab"; /// Runs one click (`run`) as a 9P write's: a builtin that fails there fails /// the write, with its words and an err record, and no msg, as a ctl line /// does (runBuiltin). The refusal, or null. pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, in_tag: bool) ?Reply { // A builtin that means nothing without its argument, written bare: the // write is refused, EINVAL, as a ctl's is (checkBuiltin), not run to // fail or to ask. if (exec) { const cmd = exec_line.commandText(text); if (std.meta.stringToEnum(Builtin, cmd)) |b| if (builtins.registry.requiresArg(b)) { p.fs.results_len = 0; return refuse(p, req, "wrong #args in control message", cmd); }; // ...and one that takes none, written with one (`Config extra`): // refused as a ctl's is, not run as a shell command of that name. if (std.mem.indexOfAny(u8, cmd, " \t")) |sp| if (std.meta.stringToEnum(Builtin, cmd[0..sp])) |b| { if (!builtins.registry.takesArg(b) and config.Runtime.find(cmd[0..sp]) == null) { p.fs.results_len = 0; return refuse(p, req, "wrong #args in control message", cmd); } }; } p.fs.capturing = true; p.fs.failure_len = 0; defer p.fs.capturing = false; run(p, id, exec, text, in_tag); if (p.fs.failure_len == 0) return null; // A failed click answers nothing: look reads back empty. p.fs.results_len = 0; const failure = p.fs.failure[0..p.fs.failure_len]; // A slot or a place refused keeps its errno words (no space: ENOSPC). return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); } /// The errno a builtin's failure answers with: ENOENT for what is not /// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else /// EIO. pub fn failureErrno(failure: []const u8) u16 { if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT; // A pattern refused (Edit's too) is malformed input, as 9ns reads it; // so is a word this build has not (`invalid: acme's Put ...`). if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid") or std.mem.startsWith(u8, failure, "address out of range")) return E.INVAL; // A word's arguments wrong, or a dial that is no address: what 9ns // reads as EINVAL from the same words (`control message`, `bad `). if (std.mem.indexOf(u8, failure, "control message") != null or std.mem.indexOf(u8, failure, "bad dial address") != null) return E.INVAL; return E.IO; } 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; // unreachable: a u32 serial and a newline fit 16 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]; // A look answers panes to go to: never one closed since (an exec may // answer the pane it closed, which is what it acted on). const looking = if (tree.Node.target(req.node)) |t| switch (t) { .top => |f| f == .look, .pane => |pt| pt.file == .look, .col => false, } else false; for (kept) |serial| if (!looking or p.paneBySerial(serial) != null) 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 = 9 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; /// acme's winctlprint (plan9port wind.c), field for field: serial, tag /// length, body length, isdir (0), the dirty flag, the width in cells, the /// font, the tab width, whether Undo has a step, whether Redo has one. Then /// pardes's own: rio's word for whether the window has the keyboard, /// `current` or `notcurrent` (rio(4), wctl), and a REPL's id. 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; const f = pane_files.fileOf(pane); const undo = if (f) |file| file.history.undo_len > 0 else false; const redo = if (f) |file| file.history.redo_len > 0 else false; w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), 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 { return refuseQuoting(p, req, why, firstWord(line)); } /// `refuse` quoting `word` as given: a setting's bad value, blanks and all. fn refuseQuoting(p: *Pardes, req: Req, why: []const u8, word: []const u8) Reply { // A word too long to quote whole gives up its middle (fitErr), so the // refusal keeps both its ends. var shown: [4096]u8 = undefined; var whole: [4096 + 320]u8 = undefined; const full = std.fmt.bufPrint(&whole, "{s} \"{s}\"", .{ why, blanked(word[0..@min(word.len, shown.len)], &shown) }) catch why; // Into the session's own buffer: `whole` is this frame's. if (full.len <= p.fs.ename.len) { @memcpy(p.fs.ename[0..full.len], full); return tree.failText(req.tag, E.INVAL, p.fs.ename[0..full.len]); } return tree.failText(req.tag, E.INVAL, tree.fitErr(full, &p.fs.ename)); } /// The word a refusal quotes: a control message's first, its offending /// word, never the line with its arguments (one rule everywhere: `wrong /// #args in control message "Newcol"`). pub fn firstWord(line: []const u8) []const u8 { const trimmed = std.mem.trim(u8, line, " \t\r\n"); return trimmed[0 .. std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len]; } /// `refuse`, saying which ctl takes the message instead. pub 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); var shown: [320]u8 = undefined; const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(firstWord(line)[0..@min(firstWord(line).len, room)], &shown), ctl }) catch why; return tree.failText(req.tag, E.INVAL, text); } /// A quoted line as a refusal shows it: a control byte (a tab, the one a /// line may hold) is a blank, so the reason reads as one line of words. fn blanked(line: []const u8, buf: []u8) []const u8 { const n = @min(line.len, buf.len); for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c; return buf[0..n]; } /// 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 { var sp = std.mem.indexOfAny(u8, line, " \t"); // `Word+arg` for a word that takes one so (`Tty+bash`), as exec does. if (sp == null) if (std.mem.indexOfScalar(u8, line, '+')) |at| if (std.meta.stringToEnum(Builtin, line[0..at])) |head| { if (builtins.registry.plusArg(head)) sp = at; }; const typed = line[0 .. sp orelse line.len]; // acme's words: checked as the pardes word they run as, or refused. const word = for (pardes.exec.acme_words) |w| { if (!std.mem.eql(u8, typed, w.acme)) continue; const now = w.pardes orelse return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not"); break now; } else typed; 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 (builtins.absentWhy(word) orelse if (config.Runtime.find(word) != null) @as([]const u8, "GUI-only") else null) |absent| { var why: [64]u8 = undefined; // EINVAL, a request this build cannot take, in words 9ns reads // as that: `invalid`. return refuse(p, req, std.fmt.bufPrint(&why, "{s} is {s}, invalid here", .{ word, absent }) catch "GUI-only, invalid here", 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 => if (pardes.colors.themeIndex(arg) != null) true else { // `refuse` quotes the line after it, all in the 128 bytes an // Rerror carries: the names get what is left. const head = "bad value in control message; like it: "; const tail = "; Themes lists all"; var near: [128]u8 = undefined; const room = @import("cloud9").fs.errmax -| (head.len + tail.len + line.len + 3); var why: [256]u8 = undefined; return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", arg); }, .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: [160]u8 = undefined; // The offending word is the value: quoted, so two bad values read, // and log, as two. return refuseQuoting(p, req, if (config.Runtime.takes(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", arg); } /// 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 = if (p.panes[id]) |pane| pane.serial else 0; 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; // A line that ran and failed says why in the builtin's own words, which // name what failed (`Mount peer /tmp/s: dial failed: ...`): the line is // not quoted after them, as an exec's is not. Only a line refused before // it runs, as no message at all, quotes it (`unknown control message // "X"`). const failure = p.fs.failure[0..p.fs.failure_len]; return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); } 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 DumpConfig 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 => if (s == &p.settings) { if (pardes.colors.loadedThemeFile(p)) |path| try w.print("ThemeFile {s}\n", .{path}) else try w.print("{s} {s}\n", .{ word, p.theme().name }); } else try w.print("{s} {s}\n", .{ word, 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 DumpConfig 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); } /// /pager, `pardes -`'s: the directory written (empty, or not absolute: the /// session's) gets its one +Pager, made or emptied; the serial is what a /// read of this same open answers, never another client's. pub fn writePager(p: *Pardes, req: Req) Reply { // A refused write leaves its open no answer, not the last one's. p.fs.results_len = 0; var it = Messages.init(p, req.data); const line = it.next() orelse ""; // Words 9ns maps back to EINVAL (`invalid`), as every refusal here. if (it.next() != null) return tree.failText(req.tag, E.INVAL, "pager: invalid write: one directory a write"); // `~` is home, and `/tmp/../etc` is `/etc`: the +Pager is named in the // directory it is, as any other pane's name is. var home_buf: [4096]u8 = undefined; const typed = pardes.filesystem.expandHome(std.mem.trim(u8, line, " \t"), &home_buf); const dir = if (typed.len > 0 and typed[0] == '/') std.fs.path.resolvePosix(p.scratch.allocator(), &.{typed}) catch typed else typed; // A directory that is there, or none (the session's): never a +Pager // named into one missing, nor a relative one quietly taken for none. if (dir.len > 0 and dir[0] != '/') { var shown: [256]u8 = undefined; return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "pager: invalid directory \"{s}\": must be absolute", .{blanked(dir[0..@min(dir.len, shown.len)], &shown)}) catch "pager: invalid directory: must be absolute"); } if (comptime pardes.hosted) if (dir.len > 0) if (pardes.filesystem.localPath(dir)) |local| if (!exec_line.isDirectory(local)) return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "pager: {s}: no such directory", .{dir[0..@min(dir.len, 256)]}) catch "pager: no such directory"); // Nor one that may not be written, as a file's name there is refused. if (comptime pardes.hosted) if (dir.len > 0) if (pardes.filesystem.localPath(dir)) |local| if (pardes.filesystem.deniedAbove(local)) return tree.failText(req.tag, E.PERM, std.fmt.bufPrint(&p.fs.ename, "pager: {s}: permission denied", .{dir[0..@min(dir.len, 256)]}) catch "pager: permission denied"); const id = pardes.panes.Output.openPager(p, p.active, dir) catch |err| return tree.failText(req.tag, if (err == error.NoPaneRoom or err == error.NoPaneSlots) E.NOSPC else E.IO, switch (err) { error.NoPaneRoom, error.NoPaneSlots => "pager: no space for a +Pager pane", else => "pager: could not make a +Pager pane", }); noteResult(p, p.panes[id].?.serial); return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// 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 or exec_line.isComment(line)) continue; if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) { // The offending word is the value: `size 5 5` quotes "5 5". const value = std.mem.trim(u8, line["size".len..], " \t"); const size = sizeOf(line) orelse return refuseQuoting(p, req, e_size, value); // A frontend's window is the size; only a session none is // attached to (--detach, unattended) takes one written. if (!p.unattended) return refuseQuoting(p, req, "size: a frontend is attached and owns the size", value); if (apply) { const was: [2]u16 = .{ p.screen_w, p.screen_h }; const before = p.rects; p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } }); p.sync(); // Every pane keeps its tag and two rows, as placement // leaves them (layout.minRows, the one minimum): a size // too small for the panes' minima is refused, and one // they fit gives back rows the resize took under them. if (!pardes.layout.minimaFit(p)) { p.update(.{ .resize = .{ .cols = was[0], .rows = was[1] } }); p.sync(); return refuseQuoting(p, req, "size: too small for the panes, each its tag and 2 rows", value); } pardes.layout.restoreMinima(p, &before); p.sync(); } continue; } if (!apply) { if (checkBuiltin(p, req, line, .session)) |refusal| return refusal; continue; } // An empty window (no pane at all) still takes its session words. if (p.panes[p.active] == null and p.anyPane() != 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 }; // "invalid": the word 9ns reads as EINVAL (enameToErrno), as winsize's. const e_size = std.fmt.comptimePrint("invalid 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. /// A builtin's doc comment, its first sentence, from the source itself: a /// line of /commands says what each word does without a second copy of it. fn summaryOf(word: []const u8, buf: []u8) []const u8 { const source = @embedFile("../builtins.zig"); var needle_buf: [64]u8 = undefined; const needle = std.fmt.bufPrint(&needle_buf, "\npub const {s} = struct", .{word}) catch return ""; const at = std.mem.indexOf(u8, source, needle) orelse return ""; // Back over the `///` lines just above it. var start = at + 1; while (start > 0) { const prev_end = start - 1; const prev_start = if (std.mem.lastIndexOfScalar(u8, source[0..prev_end], '\n')) |nl| nl + 1 else 0; if (!std.mem.startsWith(u8, std.mem.trimStart(u8, source[prev_start..prev_end], " "), "///")) break; start = prev_start; } var w = std.Io.Writer.fixed(buf); var lines = std.mem.splitScalar(u8, source[start .. at + 1], '\n'); while (lines.next()) |line| { const text = std.mem.trim(u8, std.mem.trimStart(u8, std.mem.trimStart(u8, line, " "), "/"), " "); if (text.len == 0) continue; if (w.buffered().len > 0) w.writeByte(' ') catch break; w.writeAll(text) catch break; } const all = stripCitations(w.buffered()); // The first sentence, whole: to a `.` that ends one. var i: usize = 0; while (std.mem.indexOfScalarPos(u8, all, i, '.')) |dot| : (i = dot + 1) { if (dot + 1 == all.len or all[dot + 1] == ' ') return all[0 .. dot + 1]; } return all; } /// Drops a parenthesis that cites source (`(editors/acme/exec.c:892)`, /// `(exec.c, \`kill\`; acme.c's WKill)`): a reader of /commands wants what /// the word does, not where acme has it. In place; answers the rest. fn stripCitations(text: []u8) []u8 { var out: usize = 0; var i: usize = 0; while (i < text.len) { if (text[i] == '(') if (std.mem.indexOfScalarPos(u8, text, i, ')')) |close| { const inside = text[i + 1 .. close]; if (std.mem.indexOf(u8, inside, ".c") != null or std.mem.indexOf(u8, inside, ".zig") != null) { // With the space before it, and a colon after it kept. if (out > 0 and text[out - 1] == ' ') out -= 1; i = close + 1; continue; } }; text[out] = text[i]; out += 1; i += 1; } return text[0..out]; } fn writeCommands(w: *std.Io.Writer) !void { for (std.enums.values(Builtin)) |b| { // Edit is a pane's word the root's ctl takes too, at the active pane. const scope: []const u8 = if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane"; try w.print("{s}{s} {s}", .{ @tagName(b), if (builtins.registry.takesArg(b)) " arg" else "", scope, }); // 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 }); }; // Then ` -- ` and what it does: a setting's doc, else its source's. var summary_buf: [2048]u8 = undefined; const summary = if (config.Runtime.find(@tagName(b))) |setting| setting.doc else summaryOf(@tagName(b), &summary_buf); if (summary.len > 0) { // The sentence whole unless it is long, then cut between words. const kept = @import("../Messages.zig").clip(summary, 240); try w.print(" -- {s}{s}", .{ kept, if (kept.len < summary.len) "…" else "" }); } 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_in| { if (line_in.len == 0 or exec_line.isComment(line_in)) 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); // acme's ctl words: done by the file that replaces each, or the // builtin that does it, checked with the rest before any runs. var line = line_in; switch (acmeCtl(p, req, serial, line_in, apply)) { .no => {}, .done => continue, .refused => |r| return r, .builtin => |word| line = word, } if (std.mem.eql(u8, line, "get")) { if (!apply) continue; // Quoting the line, as a builtin's refusal (Save's) does. if (getRefused(p, pane)) |said_in_ename| { // refuse writes ename, where the words are: copy them out. var said_buf: [320]u8 = undefined; const said = said_buf[0..said_in_ename.len]; @memcpy(said, said_in_ename); // It ran and was refused: its words, not the line quoted. return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{said}) catch said); } var failed: anyerror = error.Unexpected; const errno = get(p, pane, &failed); // Naming the path, in words 9ns reads as the errno; a peer // out of reach as a look or a Save says it. if (errno != 0) { const path = if (pane_files.fileOf(pane)) |f| f.path else ""; var why_buf: [48]u8 = undefined; const why = if (pardes.Messages.dialReason(failed)) |r| std.fmt.bufPrint(&why_buf, "dial failed: {s}", .{r}) catch "dial failed" else if (errno == E.NOENT) "no such file" else "cannot be read"; return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], why }) catch "get: no such file"); } } 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")) { // With a question up, what it offers. var offered: [64]u8 = undefined; if (p.questionChoices(p.paneBySerial(serial).?, &offered)) |choices| { var why: [96]u8 = undefined; return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "answer takes {s} or -", .{choices}) catch std.fmt.bufPrint(&why, "answer takes a choice or -", .{}) catch "answer takes a choice or -"); } return tree.failText(req.tag, E.INVAL, "answer takes a choice or -"); } else if (std.mem.startsWith(u8, line, "answer ") and p.panes[p.paneBySerial(serial).?].?.prompt == .save) { // Save's path, asked over 9P (`ask save path`). if (!apply) continue; const id = p.paneBySerial(serial).?; const asked = p.panes[id].?; const path = std.mem.trim(u8, line["answer ".len..], " "); var rec: [300]u8 = undefined; pardes.ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {s}", .{ serial, path[0..@min(path.len, 256)] }) catch ""); if (!std.mem.eql(u8, path, "-")) exec_line.saveTo(p, id, path); asked.closePrompt(); } 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; // Edit on a pane with no text is the refusal a data write // there gets, EINVAL, before anything runs. if (std.mem.startsWith(u8, line, "Edit") and pane_files.fileOf(p.panes[p.paneBySerial(serial).?].?) == null) return tree.failText(req.tag, E.INVAL, pane_files.e_no_text); } else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| { return refusal; } } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// acme's pane ctl vocabulary (editors/acme/xfid.c xfidctlwrite): each word /// done here by the file that replaces it, or run as the builtin that does /// it; those with no place here refused with why. const AcmeCtl = union(enum) { no, done, refused: Reply, builtin: []const u8 }; fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) AcmeCtl { const id = p.paneBySerial(serial) orelse return .no; const pane = p.panes[id].?; const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ') orelse line.len]; const rest = std.mem.trim(u8, line[word.len..], " "); const File = tree.PaneFile; // A word written to one of the pane's own files, as acme's is to its. const Into = struct { file: File, data: []const u8 }; // `name` takes all after its one blank, as the name file takes what is // written. A second blank after the word is refused, not taken as the // name's first byte: a line's words are split at blanks. if (std.mem.eql(u8, word, "name") and rest.len > 0 and line[word.len + 1] == ' ') return .{ .refused = tree.failText(req.tag, E.INVAL, "bad character in file name: a blank at its start") }; const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0) .{ .file = .name, .data = line[word.len + 1 ..] } else if (std.mem.eql(u8, line, "clean")) .{ .file = .dirty, .data = "0" } else if (std.mem.eql(u8, line, "dirty")) .{ .file = .dirty, .data = "1" } else if (std.mem.eql(u8, line, "mark")) .{ .file = .mark, .data = "1" } else if (std.mem.eql(u8, line, "nomark")) .{ .file = .mark, .data = "0" } else null; if (into) |w| { if (!apply) return .done; const r = pane_files.write(p, .{ .tag = req.tag, .op = .write, .node = tree.Node.of(serial, w.file), .data = w.data }, id, pane, w.file); return if (r.status == .err) .{ .refused = r } else .done; } if (std.mem.eql(u8, line, "put")) return .{ .builtin = "Save" }; if (std.mem.eql(u8, line, "del")) return .{ .builtin = "Del" }; if (std.mem.eql(u8, line, "delete")) return .{ .builtin = "Delete" }; const pf = &pane.fs; if (std.mem.eql(u8, line, "dot=addr")) { if (apply) pane_files.setDot(pane, pf.addr); return .done; } if (std.mem.eql(u8, line, "addr=dot")) { if (apply) pf.addr = pane_files.dotOf(pane); return .done; } if (std.mem.eql(u8, line, "limit=addr")) { if (apply) pf.limit = pf.addr; return .done; } if (std.mem.eql(u8, line, "show")) { if (apply) pane_files.showOffset(pane, pane_files.dotOf(pane).q0); return .done; } if (std.mem.eql(u8, line, "cleartag")) { if (apply) _ = pane_files.truncate(p, pane, .tag); return .done; } const none: ?[]const u8 = if (std.mem.eql(u8, word, "dump") or std.mem.eql(u8, word, "dumpdir")) "a pane is dumped with the session: Dump and DumpDir on /ctl" else if (std.mem.eql(u8, word, "font")) "the font is the session's: Font on /ctl" else if (std.mem.eql(u8, line, "menu") or std.mem.eql(u8, line, "nomenu")) "pardes has no menu" else null; if (none) |why| return .{ .refused = tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s}: {s}", .{ word, why }) catch "invalid: acme's ctl word") }; return .no; } 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 id = p.paneBySerial(pane.serial).?; // Kept in +Unsaved, as Exit's refusal is; the notice is short. The // write's err record logs it, not a msg too. // And logged `unsaved `, as the other refusals are. tree.events.noteLog(p, .unsaved, pane); const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null; if (listed) |content| panes.Output.openUnsaved(p, id, content); p.fs.unlogged = true; p.setMessage(id, "1 unsaved pane \u{2014} get again to discard"); p.fs.unlogged = false; return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; } fn get(p: *Pardes, pane: *Pane, failed: *anyerror) 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| { failed.* = err; return switch (err) { error.FileNotFound => E.NOENT, else => E.IO, }; }; // Throwing unsaved edits away for it is a reload the log hears of, as a // clean one from the watch is. const discarded = f.revision != f.saved_revision; 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.saved_len = f.content.len; f.disk_newer = null; f.disk_gone = false; if (discarded) { var rec: [32]u8 = undefined; pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch ""); } 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 "a control character in a write to any ctl refuses the whole write, and a quoted line shows a tab as a blank" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const col = layout.columnSerial(p, 0); for ([_]u64{ @intFromEnum(tree.TopFile.ctl), Node.of(serialOf(p), .ctl), Node.ofCol(col, .ctl) }) |node| { const r = wr(p, node, "Wrap off\nbo\x01gus\n"); try testing.expectEqual(E.INVAL, r.errno()); try testing.expectEqualStrings(e_control, r.reply.ename); } const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n"); try testing.expectEqual(E.INVAL, quoted.errno()); // The offending word alone is quoted: a tab ends it. try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "\"bogus\"") != null); try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null); } test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then 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} {d:>11} {d:>11} current ", .{ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0, }); 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 (b == .Edit) "both" else 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); // Then, where the source has one, ` -- ` and what it does. if (words.next()) |dashes| try testing.expectEqualStrings("--", dashes); } try testing.expectEqual(std.enums.values(Builtin).len, lines); for ([_][]const u8{ "\nDel arg pane -- ", "\nNewcol root -- ", "\nSave arg pane", "\nVerbose arg root on,off", "\nPlacement arg root acme,pardes", "\nUndo pane -- ", "\nEdit arg both -- acme's Edit" }) |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); // A builtin is its own capitalised word: `GET` and `DEL` are none; // acme's own words are done or refused as acme's (the test below). for ([_][]const u8{ "lock x", "bogus", "DEL", "GET", "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}\"", .{firstWord(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\": write it to /ctl", wr(p, ctl_node, "Verbose off").reply.ename); try testing.expectEqualStrings("wrong #args in control message \"Collapse\"", 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 DumpConfig 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\"", wr(p, root_ctl, "Nonsense 1").reply.ename); try testing.expectEqualStrings("bad value in control message; takes on, off \"maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"east\"", wr(p, root_ctl, "Placement east").reply.ename); // A number's refusal names its range, a path's what it takes. try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"200\"", wr(p, root_ctl, "InactiveDim 200").reply.ename); try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"x\"", wr(p, root_ctl, "MessageLinger x").reply.ename); for (config.Runtime.settings) |setting| if (setting.action != .theme) try testing.expect(config.Runtime.takes(setting.action) != null); 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, "; Themes lists all \"no-such-theme\"")); try testing.expectEqualStrings("wrong #args in control message \"Newcol\"", 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 /tmp/pardes-no-such-sock-zz\nVerbose off"); try testing.expectEqual(E.IO, failed.errno()); try testing.expectStringStartsWith(failed.reply.ename, "Mount x /tmp/pardes-no-such-sock-zz: dial failed"); 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); // Renamed but never edited, it was clean: nothing thrown away, nothing // logged as reloaded over edits. var reloaded: [32]u8 = undefined; try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial}))); _ = 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)")); var unsaved: [4200]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&unsaved, "unsaved {d} {s}/missing.txt\n", .{ serial, directory }))); 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 "a body write to a terminal is typed input, not a paste, even to a program that asked for bracketed paste" { const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const term = for (p.panes, 0..) |slot, id| { if (slot) |pane| if (pane.isTerminal()) break id; } else return error.SkipZigTest; p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b[?2004h" } }); while (p.nextEffect()) |_| {} const w = wr(p, Node.of(p.panes[term].?.serial, .body), "ls\n"); try testing.expectEqual(Status.ok, w.reply.status); try testing.expectEqualStrings("ls\n", w.pty_buf[0..w.pty_len]); } test "Save path of a file pane logs a save record naming that path" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); const saved = wr(p, Node.of(serial, .ctl), "Save /tmp/pardes-save-copy.txt\n"); try testing.expectEqual(Status.ok, saved.reply.status); p.perform(.{ .save_text = .{ .pane = 0, .serial = serial, .path = Pardes.SavePath.from("/tmp/pardes-save-copy.txt") } }); var want: [64]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "save {d} /tmp/pardes-save-copy.txt\n", .{serial}))); } test "Mount with no dial is wrong #args, and a dial that is no address says bad dial address" { if (comptime !pardes.hosted) return; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); const bare = wr(p, root_ctl, "Mount work\n"); try testing.expectEqual(E.INVAL, bare.errno()); try testing.expectStringStartsWith(bare.reply.ename, "wrong #args"); const bad = wr(p, root_ctl, "Mount work tcp!nohost\n"); try testing.expectEqual(E.INVAL, bad.errno()); try testing.expectStringStartsWith(bad.reply.ename, "Mount: bad dial address: tcp!nohost"); try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); } test "an address looked at in an image pane names the image, never an empty subject" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const id = p.freeSlot().?; const doc = try p.newDocPane(id); doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") }; _ = wr(p, Node.of(doc.serial, .look), ":/x/\n"); try testing.expect(th.logHas(p, "look: /tmp/pardes-pic.ppm is no text to address")); } test "Save and Save path on an image pane are refused with why, never a silent success" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const id = p.freeSlot().?; const doc = try p.newDocPane(id); doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") }; for ([_][]const u8{ "Save\n", "Save /tmp/pardes-copy.ppm\n" }) |line| { const r = wr(p, Node.of(doc.serial, .ctl), line); try testing.expectEqual(Status.err, r.reply.status); try testing.expectEqualStrings("Save: an image pane has no text to save", r.reply.ename); } } test "a builtin for one kind of pane is refused on another, and still acts on its own kind" { const gpa = testing.allocator; const p = try withFile(gpa, "text\n"); defer p.deinit(); const serial = serialOf(p); const cases = [_]struct { []const u8, []const u8 }{ .{ "Petscii\n", "Petscii: only an image or a terminal pane takes it" }, .{ "Palette\n", "Palette: only an image pane takes it" }, .{ "Ascii\n", "Ascii: only an image pane takes it" }, .{ "Filter\n", "Filter: only a terminal pane takes it" }, .{ "Togglettymode\n", "Togglettymode: only a terminal pane takes it" }, }; for (cases) |case| { const r = wr(p, Node.of(serial, .ctl), case[0]); try testing.expectEqual(Status.err, r.reply.status); try testing.expectEqualStrings(case[1], r.reply.ename); } if (pardes.pdf_enabled) for ([_][]const u8{ "PdfFit\n", "PdfTint\n", "PdfSections\n" }) |line| { try testing.expectEqual(Status.err, wr(p, Node.of(serial, .ctl), line).reply.status); }; const id = p.freeSlot().?; const doc = try p.newDocPane(id); doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") }; const r = wr(p, Node.of(doc.serial, .ctl), "Mode\n"); try testing.expectEqualStrings("Mode: only a text or terminal pane has modes", r.reply.ename); const glyphs = doc.image.?.glyph_art; try testing.expectEqual(Status.ok, wr(p, Node.of(doc.serial, .ctl), "Petscii\n").reply.status); try testing.expect(doc.image.?.glyph_art != glyphs); } 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)); // a name alone is no edit; Save writes all the same 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 refusal's +Unsaved goes in the session's directory when the active pane's directory is not there" { if (comptime !pardes.hosted) return; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/x.txt\n").reply.status); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "edited\n").reply.status); try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status); const unsaved = for (p.panes) |slot| { const pane = slot orelse continue; const f = pane.file orelse continue; if (f.output) |o| if (o.from == .unsaved) break f.path; } else return error.NoUnsavedPane; try testing.expectEqualStrings(p.sessionDir(), std.fs.path.dirname(unsaved).?); } test "a look at a file it may not read fails the write naming the file" { if (comptime !pardes.hosted) return; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "locked.txt", .data = "secret\n", .flags = .{ .permissions = .fromMode(0o000) } }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const path = try std.fmt.bufPrint(&line, "{s}/locked.txt", .{dir}); var want: [4200]u8 = undefined; const expected = try std.fmt.bufPrint(&want, "look: {s}: permission denied", .{path}); var with_newline: [4300]u8 = undefined; const refused = wr(p, root_look, try std.fmt.bufPrint(&with_newline, "{s}\n", .{path})); if (refused.reply.status == .ok) return error.SkipZigTest; // root reads anything try testing.expectEqualStrings(expected, refused.reply.ename); } test "Pager is a setting of the root ctl: pardes by default, off taken, anything else refused with its choices" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager pardes\n") != null); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Pager off\n").reply.status); try testing.expectEqual(config.Runtime.Pager.off, p.settings.pager); try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager off\n") != null); try testing.expectEqualStrings("bad value in control message; takes pardes, off \"less\"", wr(p, root_ctl, "Pager less\n").reply.ename); } test "a bad setting value is quoted, so two different bad values log as two records, never one counted twice" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); _ = wr(p, root_ctl, "Verbose maybe\n"); _ = wr(p, root_ctl, "Verbose nope\n"); try testing.expect(th.logHas(p, "takes on, off \"maybe\"\n")); try testing.expect(th.logHas(p, "takes on, off \"nope\"\n")); try testing.expect(!th.logHas(p, "(x2)")); } test "a look line's trailing colon is dropped, and a look whose address fails never leaves its file open" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const count = struct { fn of(pp: *Pardes) usize { var n: usize = 0; for (pp.panes) |slot| n += @intFromBool(slot != null); return n; } }.of; const before = count(p); // Failing addresses: nothing opened, the write still answered. inline for ([_][]const u8{ "{s}/a.txt:/nomatch/\n", "{s}/a.txt:9:\n" }) |fmt| { _ = wr(p, root_look, try std.fmt.bufPrint(&line, fmt, .{dir})); try testing.expectEqual(before, count(p)); } // `a.txt:2:3:` is `a.txt:2:3`: it opens there. try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:2:3:\n", .{dir})).reply.status); try testing.expectEqual(before + 1, count(p)); const opened = p.panes[p.active].?; try testing.expect(std.mem.endsWith(u8, opened.file.?.path, "/a.txt")); try testing.expectEqual(@as(i32, 1), opened.body.cur_row); } test "a bare :N or :N:M look addresses the pane itself, from its look, the root's and event write-back" { const p = try withFile(testing.allocator, "alpha\nbeta\ngamma\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[p.paneBySerial(serial).?].?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), ":2\n").reply.status); try testing.expectEqual(pane_files.State.Range{ .q0 = 6, .q1 = 11 }, pane_files.dotOf(pane)); try testing.expectEqual(Status.ok, wr(p, root_look, ":3\n").reply.status); try testing.expectEqual(pane_files.State.Range{ .q0 = 11, .q1 = 17 }, pane_files.dotOf(pane)); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), ":3:2\n").reply.status); try testing.expectEqual(@as(u32, 12), pane_files.dotOf(pane).q0); // Written back by an event reader, the same. const event = Node.of(serial, .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); try testing.expectEqual(Status.ok, wr(p, event, "ML0 0 0 2 :1\n").reply.status); try testing.expectEqual(pane_files.State.Range{ .q0 = 0, .q1 = 6 }, pane_files.dotOf(pane)); } test "a refused Del written back through event fails that write, EIO, with its err" { const gpa = testing.allocator; const p = try withFile(gpa, "text\n"); defer p.deinit(); const serial = serialOf(p); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "more\n").reply.status); // unsaved const event = Node.of(serial, .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status); const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes; var copy: [256]u8 = undefined; @memcpy(copy[0..record.len], record); const back = wr(p, event, copy[0..record.len]); try testing.expectEqual(E.IO, back.errno()); try testing.expect(std.mem.indexOf(u8, back.reply.ename, "Modified") != null); try testing.expect(p.paneBySerial(serial) != null); } 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; // Flag 1: its first word is a builtin; no 2, no expansion follows. try testing.expectEqualStrings("FX0 0 1 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 4 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 1 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); // A click in its tag is the reader's too, an `x` record, as acme's is; // written back, it is done. try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .tagexec), "Msg tagged\n").reply.status); try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "tagged") == null); const tagged = call(p, .{ .tag = 6, .op = .read, .node = event, .handle = h, .size = 256 }).bytes; try testing.expectEqualStrings("Fx0 0 1 10 Msg tagged\n", tagged); try testing.expectEqual(tree.Status.ok, wr(p, event, tagged).reply.status); try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "tagged") != null); _ = 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); // Closed, it is no pane to go to: a fresh read of look is empty. try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .ctl), "Del\n").reply.status); try testing.expect(p.paneBySerial(made) == null); try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes); } test "a builtin's Verbose announcement is logged only when it succeeds, its failure said now or by the host later" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const serial = serialOf(p); var want: [64]u8 = undefined; const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial}); // A Save a ctl write gives no name to, on a scratch: refused, no Save. const scratch = try th.newPane(p); var said_save_buf: [64]u8 = undefined; try testing.expectEqual(E.INVAL, wr(p, Node.of(scratch, .ctl), "Save\n").errno()); try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&said_save_buf, "msg {d} Save\n", .{scratch}))); // Refused as it runs, through a click: the write fails, and no Exit. const f = &p.panes[0].?.file.?; f.saved_revision = f.revision -% 1; f.saved_hash = 0; try testing.expect(wr(p, Node.of(serial, .exec), "Exit\n").errno() != 0); try testing.expect(!th.logHas(p, said_exit)); // Refused at the keyboard (no write to fail), edited since: the same. f.revision +%= 1; _ = p.executeBuiltinLine(p.active, "Exit"); try testing.expect(!th.logHas(p, said_exit)); try testing.expect(!p.quit); // A Save the host could not do, told a step later: the Save goes too. var save_buf: [64]u8 = undefined; const said_save = try std.fmt.bufPrint(&save_buf, "msg {d} Save\n", .{serial}); _ = wr(p, Node.of(serial, .exec), "Save\n"); p.saveFailed(@intCast(p.paneBySerial(serial).?), "/test.txt", error.AccessDenied); try testing.expect(!th.logHas(p, said_save)); // One that does what it says keeps its announcement. _ = wr(p, Node.of(serial, .exec), "Undo\n"); var undo_buf: [64]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&undo_buf, "msg {d} Undo", .{serial}))); } 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 }, .{ .text = "Edit: w is a sam command pardes's Edit leaves out (it has no file or shell commands)", .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 "DumpConfig's whole report writes back to the root ctl, line by line, with no error" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); try testing.expectEqual(Status.ok, wr(p, root_ctl, "DumpConfig\n").reply.status); const report = for (p.panes) |slot| { const pane = slot orelse continue; const f = pane.file orelse continue; const o = f.output orelse continue; if (std.meta.eql(o.from, panes.Output.Origin{ .cmd = .DumpConfig })) break try testing.allocator.dupe(u8, f.content); } else return error.NoDumpConfigPane; defer testing.allocator.free(report); // What this shell cannot show is a comment, never a value it refuses. if (pardes.platform == .tty) try testing.expect(std.mem.indexOf(u8, report, "# GripWidth unsupported\n") != null); var lines = std.mem.splitScalar(u8, report, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; const r = wr(p, root_ctl, line); if (r.reply.status != .ok) std.debug.print("refused: {s}: {s}\n", .{ line, r.reply.ename }); try testing.expectEqual(Status.ok, r.reply.status); } } test "a Restore asks again for the ThemeFile the dump names, as a /ctl write of it does" { if (comptime !pardes.hosted) return; const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const good = "/tmp/pardes-restored-theme.zon"; _ = wr(p, @intFromEnum(tree.TopFile.ctl), "ThemeFile " ++ good ++ "\n"); var custom = pardes.themes[0]; custom.name = "restored"; var encoded: std.Io.Writer.Allocating = .init(gpa); defer encoded.deinit(); try std.zon.stringify.serialize(custom, .{}, &encoded.writer); try testing.expect(pardes.colors.loadThemeFile(p, p.theme_file_generation, encoded.written(), false)); try pardes.dump.dumpState(p); const restored = try pardes.dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); defer restored.deinit(); const asked = while (restored.nextEffect()) |effect| switch (effect) { .theme_file => |t| if (t.on) break t, else => {}, } else return error.NoThemeFileAsked; try testing.expectEqualStrings(good, pardes.colors.themeFileRequest(restored, asked.generation).?.path); // The file broken since, the load fails: the restored session still // shows the theme the old one did, and names the file it loaded. try testing.expectEqualStrings("restored", restored.theme().name); pardes.colors.failThemeFile(restored, asked.generation, error.ParseZon); try testing.expectEqualStrings("restored", restored.theme().name); try testing.expectEqualStrings(good, restored.theme_file_path.get()); } test "a ThemeFile that fails to load changes nothing: the root ctl still names the one loaded, or none" { if (comptime !pardes.hosted) return; const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); // None loaded: a failed one leaves Theme as it was. _ = wr(p, root_ctl, "ThemeFile /tmp/pardes-no-such-theme.zon\n"); pardes.colors.failThemeFile(p, p.theme_file_generation, error.FileNotFound); try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "ThemeFile") == null); try testing.expectEqualStrings("", p.theme_file_path.get()); // One loaded, then another that fails: still the first, on every read. const good = "/tmp/pardes-good-theme.zon"; _ = wr(p, root_ctl, "ThemeFile " ++ good ++ "\n"); var custom = pardes.themes[0]; custom.name = "good"; var encoded: std.Io.Writer.Allocating = .init(gpa); defer encoded.deinit(); try std.zon.stringify.serialize(custom, .{}, &encoded.writer); try testing.expect(pardes.colors.loadThemeFile(p, p.theme_file_generation, encoded.written(), false)); _ = wr(p, root_ctl, "ThemeFile /tmp/pardes-bad-theme.zon\n"); try testing.expect(!pardes.colors.loadThemeFile(p, p.theme_file_generation, ".{ .name = ", false)); try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "ThemeFile " ++ good ++ "\n") != null); try testing.expectEqualStrings(good, p.theme_file_path.get()); } test "after a ThemeFile the root ctl and DumpConfig say ThemeFile and its path, which writes back" { if (comptime !pardes.hosted) return; const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); const path = "/tmp/pardes-mine-theme.zon"; try testing.expectEqual(Status.ok, wr(p, root_ctl, "ThemeFile " ++ path ++ "\n").reply.status); var custom = pardes.themes[0]; custom.name = "mine"; var encoded: std.Io.Writer.Allocating = .init(gpa); defer encoded.deinit(); try std.zon.stringify.serialize(custom, .{}, &encoded.writer); try testing.expect(pardes.colors.loadThemeFile(p, p.theme_file_generation, encoded.written(), false)); const settings = rd(p, root_ctl, 0, 1 << 16).bytes; try testing.expect(std.mem.indexOf(u8, settings, "ThemeFile " ++ path ++ "\n") != null); try testing.expect(std.mem.indexOf(u8, settings, "Theme mine") == null); var storage: [16384]u8 = undefined; var out: std.Io.Writer = .fixed(&storage); try config.Runtime.writeReport(&out, .{ .startup_config_path = null, .platform = @tagName(pardes.platform), .theme_name = p.theme().name, .theme_file = pardes.colors.loadedThemeFile(p), .compiled_default_shell = config.defaultShell(), .hover_delay_frames = null, .native_images = false, .capabilities = builtins.capabilities, .state = &p.settings, }); try testing.expect(std.mem.indexOf(u8, out.buffered(), "ThemeFile " ++ path ++ "\n") != null); // Written back, it is the same request again, not "no theme mine". try testing.expectEqual(Status.ok, wr(p, root_ctl, "ThemeFile " ++ path ++ "\n").reply.status); } 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, ": dr") != null); try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"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); // Within the 128 bytes an Rerror carries, the quoted line kept. try testing.expect(themed.reply.ename.len <= 128); // A name in any case is the theme. try testing.expectEqual(Status.ok, wr(p, root_ctl, "Theme DRACULA\n").reply.status); try testing.expectEqualStrings("dracula", pardes.themes[p.settings.theme].name); // A misspelling far from any first letter's list still finds its like. var near: [170]u8 = undefined; try testing.expect(std.mem.indexOf(u8, pardes.colors.themesNear(&near, "9dracula"), "dracula") != null); // Every theme sharing the first letter, when they fit. var tested = false; for ("abcdefghijklmnopqrstuvwxyz") |letter| { var count: usize = 0; var len: usize = 0; for (pardes.themes) |t| if (t.name[0] == letter) { count += 1; len += t.name.len + 1; }; if (count < 2 or len > near.len) continue; try testing.expectEqual(count, std.mem.count(u8, pardes.colors.themesNear(&near, &.{letter}), " ") + 1); tested = true; } try testing.expect(tested); } 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 look at file:addr whose file is not there is one miss, quoting what was written, in a terminal too" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const looked = Node.of(serialOf(p), .look); for ([_][]const u8{ "zzq:#3", "zzq:2", "zzq:/re/" }) |written| { var line: [64]u8 = undefined; _ = wr(p, looked, try std.fmt.bufPrint(&line, "{s}\n", .{written})); var want: [64]u8 = undefined; try testing.expectEqual(@as(usize, 1), th.logCount(p, try std.fmt.bufPrint(&want, "look: no match for \"{s}\"\n", .{written}))); } try testing.expectEqual(@as(usize, 3), th.logCount(p, "\nerr ")); } test "a look whose pane is refused a place fails with that alone, not a missing line too" { const gpa = testing.allocator; // A screen with rows for one pane only. const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 5 }); defer p.deinit(); while (p.nextEffect()) |_| {} _ = try p.setTestFile("x\n"); while (p.nextEffect()) |_| {} var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); var dir: [4096]u8 = undefined; const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; var line: [4200]u8 = undefined; const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{at})); try testing.expectEqual(Status.err, r.reply.status); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "no space for a pane") != null); try testing.expectEqual(@as(usize, 1), th.logCount(p, "\nerr ")); } test "a look under pardes's placement, from a pane pane/new made after Delcol, opens its file" { 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; const first = serialOf(p); try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Placement\n").reply.status); try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Delcol\n").reply.status); const made = try th.newPane(p); try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .look), try std.fmt.bufPrint(&line, "{s}/f.txt:#4\n", .{at})).reply.status); // The pane pane/new made is placed, and so is the one its look opened. for (p.panes, 0..) |slot, i| if (slot != null) try testing.expect(layout.findPane(p, i) != null); try testing.expect(p.panes[p.active].?.serial != made); } 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: line 1: ") != 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: no such directory", p.fs.late_failure[0..p.fs.late_failure_len]); // A DumpDir that is there but no directory says that. p.fs.late_failure_len = 0; p.dumpFailed("/dev/null/pardes.dump.zon", error.NotDir); try testing.expectEqualStrings("Dump /dev/null/pardes.dump.zon: /dev/null is not a directory", 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, invalid here \"Lift\"", wr(p, root_ctl, "Lift on\n").reply.ename); try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); // A builtin the GUI alone has, written to a ctl or an exec, never // runs as a shell command. if (comptime !pardes.font_picker) { try testing.expectEqualStrings("Fonts is GUI-only, invalid here \"Fonts\"", wr(p, root_ctl, "Fonts\n").reply.ename); const ran = wr(p, root_exec, "Fonts\n"); try testing.expectEqual(E.IO, ran.errno()); try testing.expectEqualStrings("Fonts is GUI-only, invalid here", ran.reply.ename); } } try testing.expectEqualStrings("unknown control message \"Liftt\"", 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: shell \"/nonexistent/zzsh\" not found") != null); try testing.expect(th.logHas(p, "shell \"/nonexistent/zzsh\" not found")); 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, "Unmount 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: Unmount: no such mount zzz (x3)\n")); try testing.expect(std.mem.indexOf(u8, text, "msg - Unmount") == 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); var rec: [96]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: a command line is at most 1024 bytes", .{serialOf(p)}))); try testing.expectEqualStrings(e_control, wr(p, root_exec, "echo a\x01b\n").reply.ename); try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: it holds", .{serialOf(p)}))); 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); // The bad value quoted, never the word size. try testing.expect(std.mem.endsWith(u8, wr(p, root_ctl, "size 5 2\n").reply.ename, "\"5 2\"")); // 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 "Mount dials its peer and fails, mounting nothing, when none answers; a look through a peer gone says dial failed" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); const dead = "/tmp/pardes-test-no-such-peer.sock"; const refused = wr(p, root_ctl, "Mount dead " ++ dead ++ "\n"); try testing.expectEqual(E.IO, refused.errno()); try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer", refused.reply.ename); try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); // A pane of a peer gone since: get says so too. try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "away"), .dial = try p.gpa.dupe(u8, dead) }); _ = wr(p, Node.of(serialOf(p), .name), "/n/away/f.txt\n"); const got = wr(p, Node.of(serialOf(p), .ctl), "get\n"); try testing.expectEqualStrings("get: /n/away/f.txt: dial failed: no answer", got.reply.ename); // Mounted when it answered, gone since: a look through it says so. try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "gone"), .dial = try p.gpa.dupe(u8, dead) }); const looked = wr(p, @intFromEnum(tree.TopFile.look), "/n/gone/f.txt\n"); try testing.expect(looked.errno() != 0); try testing.expect(th.logHas(p, "look: /n/gone/f.txt: dial failed: no answer")); } test "a root exec that fails logs its err against the pane it ran at" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const f = &p.panes[0].?.file.?; f.saved_revision = f.revision -% 1; f.saved_hash = 0; try testing.expect(wr(p, root_exec, "Exit\n").errno() != 0); var want: [32]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)}))); } test "Tty in a directory that is not there fails and makes no pane; a shell that never started answers a run" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); // A host that starts shells (the core alone starts none, and asks // nothing of a directory). const Starting = struct { fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} }; p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; defer p.host = .{}; var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const refused = wr(p, Node.of(serial, .ctl), "Tty\n"); try testing.expectEqual(E.NOENT, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "Tty: /tmp/pardes-no-such-dir-zz: no such directory"); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); // A terminal whose shell the host could not start. _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status); const term = p.active; p.shellFailed(@intCast(term), error.FileNotFound); const run_node = Node.of(p.panes[term].?.serial, .pty_run); const h = call(p, .{ .tag = 1, .op = .open, .node = run_node, .omode = 2 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = run_node, .handle = h, .data = "true\n" }); try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 3, .op = .read, .node = run_node, .handle = h, .size = 64 }).bytes); } test "a builtin that needs its argument, written bare to an exec, is EINVAL as through ctl" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); for ([_]u64{ root_exec, Node.of(serialOf(p), .exec), @intFromEnum(tree.TopFile.tagexec) }) |node| { const refused = wr(p, node, "Mount\n"); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectEqualStrings("wrong #args in control message \"Mount\"", refused.reply.ename); } } test "Grep with no hit says grep: no match and opens nothing, a missing pane directory passed over" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "alpha\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "one\nbravo here\nthree bravo\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir})); // A hit opens its +Search, a row a line: exec reads it back. Find and // Grep share it, a directory's one +Search, each run replacing it. try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep bravo\n").reply.status); const grepped = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; try testing.expectEqual(@as(usize, 2), std.mem.count(u8, grepped.file.?.content, "other.txt:")); try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:2") != null); try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:3") != null); try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status); const found = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; try testing.expect(std.mem.indexOf(u8, found.file.?.content, "other.txt") != null); try testing.expectEqual(grepped.serial, found.serial); try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") == null); // The same Find again reads back the +Search, not the pane it ran in. try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status); try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes); // A Grep run from the +Search itself fills that one, never a second. try testing.expectEqual(Status.ok, wr(p, Node.of(found.serial, .exec), "Grep bravo\n").reply.status); try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes); var searches: usize = 0; for (p.panes) |slot| if (slot) |q| if (q.file) |f| if (f.output != null) { searches += @intFromBool(std.mem.endsWith(u8, f.path, "/+Search")); }; try testing.expectEqual(@as(usize, 1), searches); try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") != null); // A second pane in a directory that is not there. const other = try th.newPane(p); _ = wr(p, Node.of(other, .name), "/tmp/pardes-no-such-dir-zz/b.txt\n"); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, missed.errno()); try testing.expectEqualStrings("Grep: pattern not found", missed.reply.ename); const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, unfound.errno()); try testing.expectEqualStrings("Find: name not found", unfound.reply.ename); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); // The +Search the miss would have filled is emptied, not left stale. try testing.expectEqualStrings("", found.file.?.content); } test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const refused = wr(p, @intFromEnum(tree.TopFile.ctl), "Unmount nothere\n"); try testing.expectEqual(E.NOENT, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "Unmount: no such mount nothere"); } test "a terminal told the directory it already goes by logs no rename" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); _ = wr(p, Node.of(serialOf(p), .name), "/tmp/f.txt\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), "Tty\n").reply.status); p.update(.tick); // announced const term = p.active; // The host says where its shell started: where it was already named. p.setCwd(term, "/tmp"); var rec: [32]u8 = undefined; try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial}))); // This process's directory, asked before the shell has said anything // (so before its chdir): not taken. const session = p.sessionDir(); p.setCwd(term, session); try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial}))); p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "$ " } }); // Somewhere else is a rename. p.setCwd(term, "/usr"); try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /usr", .{p.panes[term].?.serial}))); } test "a terminal with text typed at its prompt is never typed into: a look at its directory opens another" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const term = p.paneBySerial(serialOf(p)).?; p.setCwd(term, "/tmp"); p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07TYPED" } }); while (p.nextEffect()) |_| {} var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.look), "/tmp\n").reply.status); var typed_into = false; while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) { typed_into = true; }; try testing.expect(!typed_into); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count + 1, after); // An exec at it runs in a pane of its own, not typed after TYPED. _ = wr(p, Node.of(serialOf(p), .exec), "echo hi\n"); while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) { typed_into = true; }; try testing.expect(!typed_into); } test "a command line from a missing directory fails ENOENT and makes no pane; a command whose shell never started ends 127" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const Starting = struct { fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} }; p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; defer p.host = .{}; const serial = serialOf(p); _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const refused = wr(p, Node.of(serial, .exec), "echo hi\n"); try testing.expectEqual(E.NOENT, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory"); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); // A command pane whose shell the host could not start. _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "echo hi\n").reply.status); const cmd = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, Node.of(serial, .exec), 0, 64).bytes, "\n"), 10)).?; p.shellFailed(@intCast(cmd), error.FileNotFound); const pane = p.panes[cmd].?; try testing.expect(pane.command_done); try testing.expectEqual(@as(?u8, 127), pane.command_status); var rec: [32]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "exit {d} 127\n", .{pane.serial}))); try testing.expect(th.logHas(p, "shell: directory missing")); } test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot start fails the write" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const Starting = struct { fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} }; p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; defer p.host = .{}; const serial = serialOf(p); p.setCwd(p.paneBySerial(serial).?, "/tmp/pardes-no-such-dir-zz"); const refused = wr(p, Node.of(serial, .pty_ctl), "exec\n"); try testing.expectEqual(E.NOENT, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory"); // The host's own failure is the waiting write's (late_failure). p.fs.late_failure_len = 0; p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied); try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]); } test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const Starting = struct { fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} }; p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; defer p.host = .{}; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp\n", .flags = .{ .permissions = .executable_file } }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const serial = serialOf(p); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const refused = wr(p, Node.of(serial, .ctl), try std.fmt.bufPrint(&line, "Tty {s}/bad\n", .{dir})); try testing.expectEqual(E.NOENT, refused.errno()); try testing.expectEqualStrings("Tty: interpreter /no/such/interp not found", refused.reply.ename); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); // The log gained the err and nothing else: no new, msg or del. try testing.expect(th.logHas(p, "Tty: interpreter /no/such/interp not found\n")); try testing.expect(!th.logHas(p, "\nmsg ")); try testing.expect(!th.logHas(p, "\ndel ")); // The shell to start again is that script: refused before the running // one goes. const id = p.paneBySerial(serial).?; p.panes[id].?.shell = try p.gpa.dupe(u8, try std.fmt.bufPrint(&line, "{s}/bad", .{dir})); p.setCwd(id, dir); const exec_refused = wr(p, Node.of(serial, .pty_ctl), "exec\n"); try testing.expectEqual(E.NOENT, exec_refused.errno()); try testing.expectEqualStrings("exec: interpreter /no/such/interp not found", exec_refused.reply.ename); // One the host found could not start (the script changed after): said // with the interpreter's name, and the running shell is not gone. p.fs.late_failure_len = 0; p.restartFailed(@intCast(id), error.InterpreterNotFound); try testing.expectEqualStrings("shell: interpreter /no/such/interp not found", p.fs.late_failure[0..p.fs.late_failure_len]); try testing.expect(!p.panes[id].?.shell_failed); } test "a language server question that finds nothing, cannot be answered, or is asked of no file fails, and a write to an image's body is refused" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const id = p.active; // Asked of a pane that is no file's: at once. _ = wr(p, root_exec, "Recent\n"); const listed = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?; p.lspRequest(listed, .incoming_calls, ""); try testing.expect(std.mem.indexOf(u8, p.panes[listed].?.msg[0..p.panes[listed].?.msg_len], "Callers: only a file's pane asks") != null); // Nothing found: Callers says so, and the waiting write hears it. p.fs.late_failure_len = 0; p.lspRequest(id, .incoming_calls, ""); try testing.expect(p.lsp_wait != null); p.lspResponse(p.lsp_wait.?.id, ""); try testing.expectEqualStrings("Callers: callers not found", p.fs.late_failure[0..p.fs.late_failure_len]); // A backend that cannot answer says why. p.fs.late_failure_len = 0; p.lspRequest(id, .outgoing_calls, ""); p.lspResponse(p.lsp_wait.?.id, "@none zls does not answer call hierarchy\n"); try testing.expectEqualStrings("Callees: zls does not answer call hierarchy", p.fs.late_failure[0..p.fs.late_failure_len]); // No answer at all. p.fs.late_failure_len = 0; p.lspRequest(id, .hover, ""); p.lspResponse(p.lsp_wait.?.id, null); try testing.expectEqualStrings("Hover: the language server did not answer", p.fs.late_failure[0..p.fs.late_failure_len]); // No diagnostics is no failure. p.fs.late_failure_len = 0; p.lspRequest(id, .diagnostics, ""); p.lspResponse(p.lsp_wait.?.id, ""); try testing.expectEqual(@as(u16, 0), p.fs.late_failure_len); // An image's body takes no write. const slot = p.freeSlot().?; const img = try pardes.panes.Image.create(p, slot, "/tmp/pardes-no-such.png", try p.image_gpa.dupe(u8, "not a png")); try testing.expectEqual(E.INVAL, wr(p, Node.of(img.serial, .body), "text\n").errno()); } test "a corrupt image fails its look with an err, and no pane is made" { pardes.image.start(testing.io, testing.allocator); defer pardes.image.stop(); const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.png", .data = "\x89PNG not really" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/bad.png\n", .{dir})); try testing.expectEqual(E.IO, r.errno()); try testing.expect(std.mem.endsWith(u8, r.reply.ename, "bad.png: not an image pardes can read")); p.sync(); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); } test "a truncated PDF fails its look with its path and why" { if (comptime !pardes.pdf_enabled) return; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const bytes = try @import("mupdf").makeLinksTestPdf(testing.allocator); defer testing.allocator.free(bytes); try tmp.dir.writeFile(testing.io, .{ .sub_path = "cut.pdf", .data = bytes[0..40] }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/cut.pdf\n", .{dir})); try testing.expectEqual(Status.err, r.reply.status); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "cut.pdf: not a PDF pardes can read: ") != null); } test "a PDF's body reads its page's text layer, and takes no write" { if (comptime !pardes.pdf_enabled) return; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const bytes = try @import("mupdf").makeLinksTestPdf(testing.allocator); defer testing.allocator.free(bytes); try tmp.dir.writeFile(testing.io, .{ .sub_path = "doc.pdf", .data = bytes }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/doc.pdf\n", .{dir})); p.sync(); const doc = p.panes[p.active].?; try testing.expect(doc.hasPdf()); const body = Node.of(doc.serial, .body); try testing.expect(std.mem.indexOf(u8, rd(p, body, 0, 4096).bytes, "target.txt") != null); try testing.expectEqual(E.INVAL, wr(p, body, "more\n").errno()); } test "Grep, Symbols and Diagnostics share their directory's one +Search" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const id = p.active; const Output = pardes.panes.Output; try Output.fillResults(p, id, "/tmp", .{ .cmd = .Grep }, "x", try p.gpa.dupe(u8, "a.txt:1:1 x\n"), null); try Output.fillResults(p, id, "/tmp", .{ .query = .document_symbols }, "", try p.gpa.dupe(u8, "a.txt:2:1 fn f\n"), null); try Output.fillResults(p, id, "/tmp", .{ .query = .diagnostics }, "", try p.gpa.dupe(u8, "a.txt:3:1 error\n"), null); var searches: usize = 0; for (p.panes) |slot| if (slot) |q| if (q.file) |f| if (f.output != null) { searches += @intFromBool(std.mem.eql(u8, f.path, "/tmp/+Search")); }; try testing.expectEqual(@as(usize, 1), searches); } test "+Unsaved drops a pane once it has closed" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const a = try th.newPane(p); _ = wr(p, Node.of(a, .name), "/tmp/pardes-unsaved-a.txt\n"); _ = wr(p, Node.of(a, .body), "edited\n"); const b = try th.newPane(p); _ = wr(p, Node.of(b, .name), "/tmp/pardes-unsaved-b.txt\n"); _ = wr(p, Node.of(b, .body), "edited\n"); const root_ctl = @intFromEnum(tree.TopFile.ctl); try testing.expectEqual(Status.err, wr(p, root_ctl, "Exit\n").reply.status); var listed: ?*pardes.Pane = null; for (p.panes) |slot| if (slot) |q| if (q.file) |f| if (f.output) |o| if (o.from == .unsaved) { listed = q; }; const list = listed.?; try testing.expect(std.mem.indexOf(u8, list.file.?.content, "/tmp/pardes-unsaved-a.txt: Modified") != null); // a closes (discarded): its row goes; b's stays. _ = wr(p, Node.of(a, .ctl), "Del\n"); _ = wr(p, Node.of(a, .ctl), "Del\n"); p.sync(); try testing.expect(p.paneBySerial(a) == null); try testing.expect(std.mem.indexOf(u8, list.file.?.content, "/tmp/pardes-unsaved-a.txt: Modified") == null); } test "a buffer whose file was deleted on disk is dirty, and Del asks first" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); pardes.panes.File.deleted(p, @intCast(p.paneBySerial(serial).?)); try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); try testing.expectEqual(Status.err, wr(p, Node.of(serial, .ctl), "Del\n").reply.status); try testing.expect(p.paneBySerial(serial) != null); _ = wr(p, Node.of(serial, .ctl), "Del\n"); p.sync(); try testing.expect(p.paneBySerial(serial) == null); } test "a failure said while a 9P write waits on it posts no msg, only the write's err" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); p.fs.write_waits = true; p.saveFailed(@intCast(p.active), "/nowhere/zz.txt", error.FileNotFound); p.fs.write_waits = false; try testing.expect(!th.logHas(p, "msg ")); try testing.expect(p.fs.late_failure_len > 0); } test "Mini with no argument is wrong #args, EINVAL, as every word that needs one" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const r = wr(p, Node.of(serialOf(p), .ctl), "Mini\n"); try testing.expectEqual(E.INVAL, r.errno()); try testing.expectEqualStrings("wrong #args in control message \"Mini\"", r.reply.ename); } test "a look that misses file:/re/ names the file, and is logged as its pane's" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "alpha\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir})); p.sync(); const f_serial = p.panes[p.active].?.serial; _ = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}/f.txt:/zzqq/\n", .{dir})); var want: [4300]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir }))); } test "with no pane at all, the root look opens its file and the root exec runs in the session's directory" { const p = try withFile(testing.allocator, "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 buf: [4096]u8 = undefined; const session = buf[0..try tmp.dir.realPath(testing.io, &buf)]; @memcpy(p.session_dir[0..session.len], session); p.session_dir_len = @intCast(session.len); const Empty = struct { fn all(q: *Pardes) !void { while (q.anyPane()) |k| _ = wr(q, Node.of(q.panes[k].?.serial, .ctl), "delete\n"); } }; try Empty.all(p); const look_node = @intFromEnum(tree.TopFile.look); try testing.expectEqual(Status.ok, wr(p, look_node, "f.txt\n").reply.status); var want: [4200]u8 = undefined; const path = try std.fmt.bufPrint(&want, "{s}/f.txt", .{session}); var opened: usize = 0; var scratches: usize = 0; for (p.panes) |slot| { const f = (slot orelse continue).file orelse continue; opened += @intFromBool(std.mem.eql(u8, f.path, path)); scratches += @intFromBool(std.mem.endsWith(u8, f.path, "/+New")); } try testing.expectEqual(@as(usize, 1), opened); try testing.expectEqual(@as(usize, 0), scratches); try Empty.all(p); try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.exec), "echo hi\n").reply.status); const made = p.panes[p.anyPane().?].?; try testing.expect(made.command != null); try testing.expectEqualStrings(session, made.cwdSlice()); } test "a look at file:3:0 is refused as addr 3:0 is, a column counting from 1" { const p = try withFile(testing.allocator, "one\ntwo\nthree\n"); defer p.deinit(); const serial = serialOf(p); const by_addr = wr(p, Node.of(serial, .addr), "3:0"); const by_look = wr(p, Node.of(serial, .look), "/test.txt:3:0\n"); try testing.expectEqual(E.INVAL, by_addr.errno()); try testing.expectEqual(E.INVAL, by_look.errno()); try testing.expectEqualStrings(by_addr.reply.ename, by_look.reply.ename); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "/test.txt:3:1\n").reply.status); } test "Kill with a name and nothing running says so and does not fail, as bare Kill does" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const r = wr(p, @intFromEnum(tree.TopFile.ctl), "Kill zzz\n"); try testing.expectEqual(Status.ok, r.reply.status); try testing.expect(th.logHas(p, "Kill: nothing running")); } test "a restored pane whose file is gone from disk comes back dirty, and Del asks first" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try withFile(testing.allocator, "kept text\n"); defer p.deinit(); // On disk when dumped, gone by the Restore. var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "kept text\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var path_buf: [4200]u8 = undefined; try pane_files.nameBuffer(p, 0, try std.fmt.bufPrint(&path_buf, "{s}/f.txt", .{dir}), false); try pardes.dump.dumpState(p); try tmp.dir.deleteFile(testing.io, "f.txt"); const restored = try pardes.dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); defer restored.deinit(); const again = restored.panes[restored.paneBySerial(restored.panes[0].?.serial).?].?; try testing.expect(again.file.?.disk_gone); const r = wr(restored, Node.of(again.serial, .ctl), "Del\n"); try testing.expectEqual(Status.err, r.reply.status); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "Modified") != null); } test "a 9P look keeps its leading blanks, a blank-only look included, and exec still trims" { const p = try withFile(testing.allocator, "a indented\nb\n indented\n \n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[p.paneBySerial(serial).?].?; pane_files.setDot(pane, .{ .q0 = 0, .q1 = 0 }); // The indented line, not the `indented` after `a `. try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), " indented\n").reply.status); try testing.expectEqual(pane_files.State.Range{ .q0 = 13, .q1 = 25 }, pane_files.dotOf(pane)); // Blanks alone are text to find too. pane_files.setDot(pane, .{ .q0 = 26, .q1 = 26 }); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), " \n").reply.status); try testing.expectEqual(pane_files.State.Range{ .q0 = 26, .q1 = 30 }, pane_files.dotOf(pane)); // exec's line is still trimmed: ` Msg hi` runs Msg. try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), " Msg blanks trimmed\n").reply.status); try testing.expect(th.logHas(p, "blanks trimmed")); } test "a focus moved over 9P tells the programs that asked for focus reports, as a click does" { const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30, .shells = 2 }); defer p.deinit(); while (p.nextEffect()) |_| {} const first = for (p.panes) |slot| { if (slot) |pane| if (pane.isTerminal()) break pane.serial; } else return error.NoShell; try testing.expectEqual(Status.ok, wr(p, Node.of(first, .exec), "Tty\n").reply.status); var shells: [2]usize = undefined; var n: usize = 0; for (p.panes, 0..) |slot, id| if (slot) |pane| if (pane.isTerminal() and n < 2) { shells[n] = id; n += 1; }; try testing.expectEqual(@as(usize, 2), n); p.active = shells[0]; p.update(.{ .output = .{ .pane = @intCast(shells[0]), .bytes = "\x1b[?1004h" } }); p.update(.{ .output = .{ .pane = @intCast(shells[1]), .bytes = "\x1b[?1004h" } }); while (p.nextEffect()) |_| {} var digits: [16]u8 = undefined; const moved = wr(p, @intFromEnum(tree.TopFile.focus), try std.fmt.bufPrint(&digits, "{d}\n", .{p.panes[shells[1]].?.serial})); try testing.expectEqual(Status.ok, moved.reply.status); try testing.expectEqual(shells[1], p.active); try testing.expectEqualStrings("\x1b[O\x1b[I", moved.pty_buf[0..moved.pty_len]); } test "a Restore of a name in the DumpDir asks for, and logs, the whole path it reads" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); try pardes.dump.dumpState(p); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "x.dump.zon", .data = p.dump_out.? }); var buf: [4096]u8 = undefined; const dir = buf[0..try tmp.dir.realPath(testing.io, &buf)]; try testing.expect(p.settings.dump_dir.set(dir)); try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Restore x.dump.zon\n").reply.status); var want: [4200]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{s}/x.dump.zon", .{dir}), p.restore_req.?); } test "a line starting with # runs as nothing at an exec or a ctl, silently" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); // The setup's own announcements are made by the first write's update. _ = wr(p, @intFromEnum(tree.TopFile.ctl), "# settle\n"); const before = p.next_serial; const log_before = try pardes.ctlfs.events.freezeLog(p); defer p.gpa.free(log_before.bytes); for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec), @intFromEnum(tree.TopFile.ctl), Node.of(serial, .ctl), Node.ofCol(layout.columnSerial(p, 0), .ctl) }) |node| { try testing.expectEqual(Status.ok, wr(p, node, "# Platform: tty\n # indented too\n").reply.status); } try testing.expectEqual(before, p.next_serial); const log_after = try pardes.ctlfs.events.freezeLog(p); defer p.gpa.free(log_after.bytes); try testing.expectEqualStrings(log_before.bytes, log_after.bytes); } test "the ctl word name refuses a leading blank, as the name file does" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const r = wr(p, Node.of(serial, .ctl), "name /tmp/lead.txt\n"); try testing.expectEqual(E.INVAL, r.errno()); try testing.expectEqualStrings("bad character in file name: a blank at its start", r.reply.ename); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "name /tmp/ok.txt\n").reply.status); try testing.expectEqualStrings("/tmp/ok.txt", pane_files.nameOf(p, p.panes[0].?)); } test "a builtin that takes no argument, written with one to an exec, is refused wrong #args, as at a ctl" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec) }) |node| for ([_][]const u8{ "Config extra\n", "DumpConfig extra\n" }) |line| { const r = wr(p, node, line); try testing.expectEqual(E.INVAL, r.errno()); try testing.expectStringStartsWith(r.reply.ename, "wrong #args in control message"); }; } test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n", "Tab\n", "Indent\n", "Local\n", "Incl\n", "Abort\n" }) |line| { for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| { const r = wr(p, node, line); try testing.expectEqual(E.INVAL, r.errno()); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is not a pardes builtin") != null); } } // Put saves, as Save does. _ = wr(p, Node.of(serial, .name), "/tmp/pardes-put.txt\n"); const put = wr(p, Node.of(serial, .exec), "Put\n"); try testing.expectEqual(Status.ok, put.reply.status); try testing.expect(put.saved); // Delete closes an edited pane without asking. const made = try th.newPane(p); _ = wr(p, Node.of(made, .name), "/tmp/pardes-delete.txt\n"); _ = wr(p, Node.of(made, .body), "edited\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Delete\n").reply.status); p.sync(); try testing.expect(p.paneBySerial(made) == null); } test "a pane's ctl takes acme's words: done by the files that replace them, or refused with why" { const p = try withFile(testing.allocator, "one two\n"); defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const pane = p.panes[p.paneBySerial(serial).?].?; try testing.expectEqual(Status.ok, wr(p, ctl_node, "name /tmp/pardes-acme-ctl.txt\n").reply.status); try testing.expectEqualStrings("/tmp/pardes-acme-ctl.txt", pane.file.?.path); try testing.expectEqual(Status.ok, wr(p, ctl_node, "dirty\n").reply.status); try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); try testing.expectEqual(Status.ok, wr(p, ctl_node, "clean\n").reply.status); try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); _ = wr(p, Node.of(serial, .addr), "/two/"); try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\nshow\nnomark\nmark\nlimit=addr\n").reply.status); try testing.expectEqual(@as(u32, 4), pane_files.dotOf(pane).q0); try testing.expect(pane.fs.limit != null); try testing.expectEqual(Status.ok, wr(p, ctl_node, "cleartag\n").reply.status); for ([_][]const u8{ "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono" }) |line| { const r = wr(p, ctl_node, line); try testing.expectEqual(E.INVAL, r.errno()); try testing.expect(std.mem.startsWith(u8, r.reply.ename, "invalid: acme's ")); } // Checked whole: a refused word anywhere and nothing ran. try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nmenu\n").errno()); try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); } test "ctl reads acme's fields in acme's order, undo and redo flags included, then pardes's own" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const fields = struct { fn of(text: []const u8, buf: *[16][]const u8) [][]const u8 { var it = std.mem.tokenizeScalar(u8, text, ' '); var n: usize = 0; while (it.next()) |t| : (n += 1) buf[n] = t; return buf[0..n]; } }; var buf: [16][]const u8 = undefined; var f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); try testing.expectEqualStrings("0", f[8]); // nothing to undo try testing.expectEqualStrings("0", f[9]); // nothing to redo try testing.expect(std.mem.eql(u8, f[10], "current") or std.mem.eql(u8, f[10], "notcurrent")); _ = wr(p, Node.of(serial, .body), "two\n"); f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); try testing.expectEqualStrings("1", f[8]); _ = wr(p, ctl_node, "Undo\n"); f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf); try testing.expectEqualStrings("1", f[9]); } test "a write to addr, limit or mark asks for no frame; one to body does" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); for ([_]tree.PaneFile{ .addr, .limit, .mark }) |file| { p.needs_frame = false; _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, file), .data = if (file == .mark) "1" else "#0" }); try testing.expect(!p.needs_frame); } p.needs_frame = false; _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, .body), .data = "two\n" }); try testing.expect(p.needs_frame); } test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log, .omode = 2 }).reply.handle; const Case = struct { node: u64, data: []const u8, words: []const u8, handle: u32 = 0 }; for ([_]Case{ .{ .node = root_exec, .data = "echo a\x7fb\n", .words = "control character" }, .{ .node = root_look, .data = "a\x7fb\n", .words = "control character" }, .{ .node = Node.of(serial, .name), .data = "\n", .words = "an empty name" }, .{ .node = log, .data = "bogus\n", .words = "takes `follow`", .handle = h }, }) |c| { const refused = call(p, .{ .tag = 2, .op = .write, .node = c.node, .handle = c.handle, .data = c.data }); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c.words) != null); } _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); try testing.expect(!th.logHas(p, "Invalid argument")); try testing.expect(th.logHas(p, "an empty name")); } test "/index shows a name as the log does: a newline in it is \\n, a control byte, C1 or byte not UTF-8 \\xNN" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const term = p.paneBySerial(serialOf(p)).?; p.setCwd(term, "/tmp/two\nlines\x07\x7f\xc2\x85\xff"); const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes; // Each byte as itself, so the name decodes to what it is. try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines\\x07\\x7f\\xc2\\x85\\xff ") != null); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n")); try testing.expect(index.len > 0); try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size); // Its tag never carries the raw newline either. const tag = rd(p, Node.of(serialOf(p), .tag), 0, 4096).bytes; try testing.expect(std.mem.indexOf(u8, tag, "/tmp/two\\nlines") != null); // A backslash of the name's own is doubled: `\n` written in a name // reads back unlike a newline. p.setCwd(term, "/tmp/back\\nslash"); try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null); } test "a look at a ./ or ../ name that is not there names the file, ENOENT; a plain one is looked for as text" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(testing.io, "many"); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/many/a.txt\n", .{dir})); for ([_][]const u8{ "./zz.txt", "../zz.txt" }) |name| { const r = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}\n", .{name})); try testing.expectEqual(E.NOENT, r.errno()); var want: [64]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "look: {s}: no such file", .{name}), r.reply.ename); } try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .look), "zz.txt\n").reply.status); try testing.expect(th.logHas(p, "look: no match for \"zz.txt\"")); } test "a served-tree pane spoils no Grep, and Look . from a gone directory is ENOENT, not /virtual/" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "hit.txt", .data = "needle here\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/other.txt\n", .{dir})); // A pane of the served tree. const virtual = try th.newPane(p); _ = wr(p, Node.of(virtual, .name), "/virtual/index\n"); // One named the served tree's root, whose directory is `/`. const top = try th.newPane(p); _ = wr(p, Node.of(top, .name), "/virtual\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep needle\n").reply.status); // `.` from a pane whose directory is gone. const gone = try th.newPane(p); _ = wr(p, Node.of(gone, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); const looked = wr(p, Node.of(gone, .look), ".\n"); try testing.expectEqual(E.NOENT, looked.errno()); try testing.expectStringStartsWith(looked.reply.ename, "look: /tmp/pardes-no-such-dir-zz: no such directory"); } test "a tag write refuses NUL and the other control characters" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); for ([_]u64{ @intFromEnum(tree.TopFile.tag), tree.Node.ofCol(pardes.layout.columnSerial(p, 0), .tag), Node.of(serialOf(p), .tag) }) |node| { const refused = wr(p, node, " a\x00b"); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "invalid tag text"); try testing.expectEqual(E.INVAL, wr(p, node, " \x7f").errno()); } } test "a malformed address is bad address syntax; on a pane with no text any address is refused as nothing to address" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const t = try withFile(testing.allocator, "one\n"); defer t.deinit(); for ([_][]const u8{ "1x", "/(/", ",,x" }) |expr| { const refused = wr(t, Node.of(serialOf(t), .addr), expr); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expect(refused.reply.ename.len > 0); } try testing.expectEqualStrings("bad address syntax", wr(t, Node.of(serialOf(t), .addr), "1x").reply.ename); // A terminal (as an image or a PDF): addr, dot and limit alike. for ([_]tree.PaneFile{ .addr, .dot, .limit }) |file| for ([_][]const u8{ "1", "1x" }) |expr| { const refused = wr(p, Node.of(serialOf(p), file), expr); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectEqualStrings("invalid address: this pane has no text to address", refused.reply.ename); }; } test "a bad event record says what is wrong with it" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const event = Node.of(serialOf(p), .event); const h = call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle; for ([_][2][]const u8{ .{ "MI0 1 0 1 x\n", "only a look or exec" }, .{ "MX3 1 0 0 \n", "ends before it starts" }, .{ "MX9 9 0 0 \n", "range past end" }, .{ "garbage\n", "a record is" }, }) |c| { const refused = call(p, .{ .tag = 2, .op = .write, .node = event, .handle = h, .data = c[0] }); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null); } _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); } test "a column takes panes until their documented minimums fill it, not until none can be halved" { const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 50 }); defer p.deinit(); while (p.nextEffect()) |_| {} const new = @intFromEnum(tree.TopFile.new); while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync(); p.sync(); const avail = p.screen_h - (p.topBarHeight() + p.columnBarHeight()); for (0..p.ncol) |c| { var need: u32 = 0; for (p.col_panes[c][0..p.col_n[c]]) |k| need += layout.minRows(p, k); // Refused only when one more pane's tag and two rows would not fit. if (p.col_n[c] > 1) try testing.expect(avail - need < 3); // Every pane keeps its minimum. for (p.col_panes[c][0..p.col_n[c]]) |k| try testing.expect(p.rects[k].h >= layout.minRows(p, k)); } } test "Jumplist fills its one +Jumps again, as Recent does, never a second" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); var count: [2]usize = undefined; for (&count) |*n| { try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Jumplist\n").reply.status); p.sync(); n.* = 0; for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| { n.* += @intFromBool(std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist })); }; } try testing.expectEqual(@as(usize, 1), count[0]); try testing.expectEqual(@as(usize, 1), count[1]); } test "a Jumplist entry is (closed) only while no pane has its file open" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "two\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir})); p.sync(); const a = p.panes[p.active].?.serial; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})); p.sync(); _ = wr(p, Node.of(a, .ctl), "Del\n"); p.sync(); var closed: usize = 0; for (p.jumps[0..p.njumps]) |j| closed += @intFromBool(j.serial == 0); try testing.expect(closed > 0); // Open again, by a look: no entry is closed any more. _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir})); p.sync(); for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0); } test "Recent's row for a file still open is where its dot is now, not where it last closed" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:3\n", .{dir})).reply.status); p.sync(); const pane = p.panes[p.active].?; pane.body.vsel.active = false; // the look's selection of line 3 let go pane.body.cur_row = 1; pane.body.cur_col = 2; try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status); const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; var want: [4300]u8 = undefined; try testing.expect(std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}/a.txt:2:3 open\n", .{dir})) != null); } test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const a_path = try std.fmt.bufPrint(&line, "{s}/a.txt", .{dir}); var a_buf: [4200]u8 = undefined; const a = a_buf[0..a_path.len]; @memcpy(a, a_path); // Opened, its dot on line 3, then closed. try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}:3\n", .{a})).reply.status); p.sync(); const opened = p.active; try testing.expectEqualStrings(a, p.panes[opened].?.file.?.path); var want: [4300]u8 = undefined; try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{a})) != null); // Another file, so the jumplist has somewhere to come back from. try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})).reply.status); p.sync(); try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[opened].?.serial, .ctl), "Del\n").reply.status); p.sync(); try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "closed {s}\n", .{a})) != null); // Recent lists it, closed, at its dot; a look at the row opens it there. try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status); const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; const at = std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}:3:", .{a})).?; const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?]; try testing.expect(std.mem.endsWith(u8, row, " (closed)")); try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " (closed)".len]})).reply.status); p.sync(); const back = p.panes[p.active].?; try testing.expectEqualStrings(a, back.file.?.path); try testing.expectEqual(@as(i32, 2), back.body.cur_row); // Closed again; the jumplist keeps its entry, closed, and a jump back // to it opens it again. _ = wr(p, Node.of(back.serial, .ctl), "Del\n"); p.sync(); var closed_entry = false; for (p.jumps[0..p.njumps]) |j| closed_entry = closed_entry or j.serial == 0; try testing.expect(closed_entry); var tries: usize = 0; while (tries < p.njumps) : (tries += 1) { p.jumpBy(-1); p.sync(); if (p.panes[p.active].?.file) |f| if (std.mem.eql(u8, f.path, a)) break; } try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path); } test "Back and Forward over a closed file's entries: one reopen is that file's pane in every entry, so the next step moves" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var long: [400]u8 = undefined; var n: usize = 0; for (0..100) |_| { @memcpy(long[n..][0..4], "row\n"); n += 4; } try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = long[0..n] }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "sea\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})); p.sync(); const b_serial = p.panes[p.active].?.serial; // Two entries for a.txt, far apart in it. _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:1\n", .{dir})); p.sync(); const a = p.active; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:90\n", .{dir})); p.sync(); _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt\n", .{dir})); p.sync(); const c_serial = p.panes[p.active].?.serial; _ = wr(p, Node.of(p.panes[a].?.serial, .ctl), "Del\n"); p.sync(); var closed: usize = 0; for (p.jumps[0..p.njumps]) |j| closed += @intFromBool(j.serial == 0); try testing.expectEqual(@as(usize, 2), closed); // Back reopens it at line 90: both its entries are that pane now. p.jumpBy(-1); p.sync(); const re = p.panes[p.active].?; try testing.expect(std.mem.endsWith(u8, re.file.?.path, "/a.txt")); try testing.expectEqual(@as(i32, 89), re.body.cur_row); for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0); // Back again goes to its line 1, in the same pane, then to b. p.jumpBy(-1); p.sync(); try testing.expectEqual(re.serial, p.panes[p.active].?.serial); try testing.expectEqual(@as(i32, 0), re.body.cur_row); p.jumpBy(-1); p.sync(); try testing.expectEqual(b_serial, p.panes[p.active].?.serial); // And Forward walks it all again, to c. p.jumpBy(1); p.sync(); p.jumpBy(1); p.sync(); try testing.expectEqual(@as(i32, 89), re.body.cur_row); p.jumpBy(1); p.sync(); try testing.expectEqual(c_serial, p.panes[p.active].?.serial); } test "Recent keeps only files on disk: not a name never saved, nor the served tree's" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "real.txt", .data = "real\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; const names = [_][]const u8{ try std.fmt.allocPrint(testing.allocator, "{s}/c", .{dir}), try testing.allocator.dupe(u8, "/virtual/zz"), try std.fmt.allocPrint(testing.allocator, "{s}/real.txt", .{dir}) }; defer for (names) |n| testing.allocator.free(n); for (names) |name| { const made = try th.newPane(p); _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&line, "{s}\n", .{name})); p.sync(); _ = wr(p, Node.of(made, .ctl), "Del\n"); _ = wr(p, Node.of(made, .ctl), "Del\n"); p.sync(); } try testing.expect(p.recent.get(names[0]) == null); try testing.expect(p.recent.get(names[1]) == null); try testing.expect(p.recent.get(names[2]) != null); } test "a new file saved shows in /recent, open, while it is" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; // A host that writes files, as the real ones do. const Writing = struct { fn write(_: ?*anyopaque, _: u8, path: []const u8, bytes: []const u8) void { std.Io.Dir.cwd().writeFile(testing.io, .{ .sub_path = path, .data = bytes }) catch {}; } }; p.host = .{ .vtable = &.{ .write_file = Writing.write } }; defer p.host = .{}; const made = try th.newPane(p); var line: [4200]u8 = undefined; const path = try std.fmt.bufPrint(&line, "{s}/new.txt", .{dir}); var name: [4200]u8 = undefined; _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path})); _ = wr(p, Node.of(made, .body), "fresh\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Save\n").reply.status); p.perform(.{ .save_file = .{ .pane = @intCast(p.paneBySerial(made).?) } }); var want: [4300]u8 = undefined; try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{path})) != null); } test "/recent shows a name as /index does, a newline in it escaped" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); p.recent.opened(p.recentGpa(), "/tmp/a\nb\\c"); const text = rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes; try testing.expect(std.mem.indexOf(u8, text, "closed /tmp/a\\nb\\\\c\n") != null); } test "Back over a closed file deleted since skips it, says so, and goes on to the place before" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); for ([_][]const u8{ "a.txt", "gone.txt", "c.txt" }) |name| try tmp.dir.writeFile(testing.io, .{ .sub_path = name, .data = "text\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir})); p.sync(); const a_serial = p.panes[p.active].?.serial; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/gone.txt\n", .{dir})); p.sync(); const gone = p.active; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt\n", .{dir})); p.sync(); const c_id = p.active; _ = wr(p, Node.of(p.panes[gone].?.serial, .ctl), "Del\n"); p.sync(); try tmp.dir.deleteFile(testing.io, "gone.txt"); p.active = c_id; p.jumpBy(-1); p.sync(); try testing.expectEqual(a_serial, p.panes[p.active].?.serial); try testing.expect(std.mem.indexOf(u8, p.panes[c_id].?.msg[0..p.panes[c_id].?.msg_len], "gone.txt: deleted, skipped") != null); } test "a remembered place is clamped to the file as it is now, its selection's two ends kept where they fit" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\nthree\nfour\nfive\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt:2\n", .{dir})); p.sync(); const c = p.panes[p.active].?; // A selection over lines 2 to 4, then closed. @import("pane.zig").setDot(c, .{ .q0 = 4, .q1 = 19 }); try testing.expect(c.body.vsel.active); _ = wr(p, Node.of(c.serial, .ctl), "Del\n"); p.sync(); // The file shrinks to two lines while it is closed. try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\n" }); _ = wr(p, root_exec, "Recent\n"); const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; const at = std.mem.indexOf(u8, rows.file.?.content, "c.txt:").?; const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?]; // Both ends are in the row, a range. try testing.expect(std.mem.indexOfScalar(u8, row, '-') != null); var full: [4400]u8 = undefined; const loc = try std.fmt.bufPrint(&full, "{s}/{s}\n", .{ dir, row[0 .. std.mem.indexOfScalar(u8, row, ' ').?] }); // A look at it opens the file, never a miss, clamped to its last line. try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), loc).reply.status); p.sync(); const back = p.panes[p.active].?; try testing.expect(std.mem.endsWith(u8, back.file.?.path, "/c.txt")); try testing.expect(back.body.cur_row <= 1); try testing.expect(back.body.vsel.active); // Its ends clamped to the last character, `two`'s `o`, not its newline. try testing.expect(back.body.cur_col <= 2 and back.body.vsel.col <= 2); try testing.expect(std.mem.indexOf(u8, back.msg[0..back.msg_len], "c.txt has 2 lines now: at its last") != null); // The same row with the file open: the same place, said the same way. _ = wr(p, Node.of(rows.serial, .look), loc); p.sync(); try testing.expectEqual(back.serial, p.panes[p.active].?.serial); try testing.expect(back.body.cur_row <= 1); try testing.expect(std.mem.indexOf(u8, back.msg[0..back.msg_len], "at its last") != null); } test "size is monotonic: growing is never refused, and a size once taken is taken again" { const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.unattended = true; const root_ctl = @intFromEnum(tree.TopFile.ctl); const new = @intFromEnum(tree.TopFile.new); // The dogfood's repro: Newcol, three pane/new, a Tty, then pane/new // until a column has no room (ENOSPC). try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status); for (0..3) |_| try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status); try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .ctl), "Tty\n").reply.status); p.sync(); while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync(); p.sync(); var line: [32]u8 = undefined; // Down a row at a time (59, 58, 57, 56 and on) until one is refused: // once refused, every smaller one is too. var taken: [60]bool = @splat(false); var rows: u16 = 59; var refused = false; while (rows >= 20) : (rows -= 1) { taken[rows] = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok; if (refused) try testing.expect(!taken[rows]); if (!taken[rows]) refused = true; } try testing.expect(refused); // the columns are full: some size is too small // Back up: each size is taken again exactly when it was on the way down. rows = 20; while (rows <= 59) : (rows += 1) { const ok = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok; try testing.expectEqual(taken[rows], ok); } for ([_]u16{ 60, 61, 64, 70, 100 }) |grown| { try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{grown})).reply.status); try testing.expectEqual(grown, p.screen_h); } // Out of range is EINVAL, in words 9ns reads as that. const bad = wr(p, root_ctl, "size 5 2\n"); try testing.expectEqual(E.INVAL, bad.errno()); try testing.expectStringStartsWith(bad.reply.ename, "invalid size"); } 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/typ/reference.typ, REPLs)") != 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); var reloaded: [32]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial}))); // 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); // ...and look reads back that +Search pane, where the places are. var listed = false; var rows = std.mem.tokenizeScalar(u8, rd(p, look_node, 0, 256).bytes, '\n'); while (rows.next()) |row| { const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, row, 10)).?].?; if (made.file) |f| if (f.output) |o| if (o.from == .search) { listed = true; }; } try testing.expect(listed); } test "every word's /commands description is its own doc comment's first sentence, no citation" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const listed = rd(p, @intFromEnum(tree.TopFile.commands), 0, 1 << 16).bytes; for ([_][]const u8{ "\nRepl arg pane -- Binds this terminal as a language's REPL, so an exec on that language's files is typed into it instead of run.\n", "\nJoincol root -- Fold the active pane's column into the one on its right, keeping its panes.\n", "\nExit root -- Quit the editor, refusing once while a pane holds unsaved text: an Exit with nothing edited since discards it.\n", "\nColors arg root on,off -- Syntax colours in text panes, on or off.\n", // the reference's examples, as they read. "\nNewcol root -- An empty column right of the keyboard's, its tag taking the keyboard.\n", "\nSave arg pane -- Write the pane's text to its file, or to the file its argument names.\n", "\nVerbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.\n", "\nPlacement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.\n", "\nKill arg root -- acme's Kill: stop the commands pardes started", }) |row| try testing.expect(std.mem.indexOf(u8, listed, row) != null); try testing.expect(std.mem.indexOf(u8, listed, ".c:") == null); // Every description is the first sentence of the doc comment just // above its word's declaration in builtins.zig. const source = @embedFile("../builtins.zig"); var it = std.mem.tokenizeScalar(u8, listed, '\n'); var undescribed: usize = 0; while (it.next()) |line| { // Every builtin has one: a word nobody can look up is no use. const dashes = std.mem.indexOf(u8, line, " -- ") orelse { std.debug.print("no doc comment: {s}\n", .{line}); undescribed += 1; continue; }; const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ').?]; if (config.Runtime.find(word)) |setting| { try testing.expect(std.mem.endsWith(u8, line, setting.doc)); continue; } var needle: [64]u8 = undefined; const at = std.mem.indexOf(u8, source, try std.fmt.bufPrint(&needle, "\npub const {s} = struct", .{word})).?; const said = std.mem.trimEnd(u8, line[dashes + 4 ..], "…"); // Its opening words are in the comment block right above. const head = said[0..@min(said.len, 10)]; const block_start = std.mem.lastIndexOf(u8, source[0..at], "\n\n") orelse 0; try testing.expect(std.mem.indexOf(u8, source[block_start..at], head) != null); } try testing.expectEqual(0, undescribed); } test "Newcol at the column cap fails the write and says the cap" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); p.update(.{ .resize = .{ .cols = 4000, .rows = 30 } }); const root_ctl = @intFromEnum(tree.TopFile.ctl); while (p.ncol < pardes.MAX_COLS) { // From the widest column's tag, so no column gets too narrow first. p.sync(); var widest: usize = 0; for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) { widest = c; }; pardes.tagline.enterHeader(p, widest); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status); } const refused = wr(p, root_ctl, "Newcol\n"); try testing.expectEqual(Status.err, refused.reply.status); var want: [64]u8 = undefined; try testing.expect(std.mem.indexOf(u8, refused.reply.ename, try std.fmt.bufPrint(&want, "no space for a column: {d} max", .{pardes.MAX_COLS})) != null); try testing.expect(th.logHas(p, "no space for a column")); } test "Save on a terminal asked over 9P is an ask in the log, answered with a path, and not asked twice" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const serial = serialOf(p); const id = p.paneBySerial(serial).?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Save\n").reply.status); var want: [48]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} save path\n", .{serial}))); try testing.expect(p.panes[id].?.prompt == .save); // Again: the one open stands, one ask in the log. _ = wr(p, Node.of(serial, .tagexec), "Save\n"); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, "save path\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); // Answered: the path is written to, the prompt goes. const answered = wr(p, Node.of(serial, .ctl), "answer /tmp/pardes-ask-save.txt\n"); try testing.expectEqual(Status.ok, answered.reply.status); try testing.expect(p.panes[id].?.prompt == .none); try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} /tmp/pardes-ask-save.txt\n", .{serial}))); } test "get names the path it could not read, logs the edits it threw away, and a deleted file is said once" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const f = &p.panes[0].?.file.?; const path = f.path; // Its file is not on disk: get names it. const missing = wr(p, Node.of(serial, .ctl), "get\n"); try testing.expectEqual(E.NOENT, missing.errno()); var want: [512]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "get: {s}: no such file", .{path}), missing.reply.ename); // Deleted on disk: said once. pardes.panes.File.deleted(p, 0); pardes.panes.File.deleted(p, 0); var rec: [32]u8 = undefined; const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, try std.fmt.bufPrint(&rec, "changed {d} deleted\n", .{serial}))); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } test "a builtin that fails through exec, tagexec or a column's exec fails the write with one err and no msg" { 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 serial = serialOf(p); for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| { const failed = wr(p, node, "Unmount zzz\n"); try testing.expectEqual(Status.err, failed.reply.status); try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Unmount: no such mount zzz") != null); } 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.indexOf(u8, text, "msg ") == null); // The root's exec ran at the pane, so its err and the pane's exec's are // the same record, counted. try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Unmount: no such mount zzz")); try testing.expect(std.mem.indexOf(u8, text, "mount zzz (x2)") != null); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); // A control character is refused with its reason. try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename); // A look miss: one err, no msg. _ = wr(p, @intFromEnum(tree.TopFile.look), "zzqqnotthere\n"); try testing.expect(th.logHas(p, "look: no match for \"zzqqnotthere\"")); try testing.expect(!th.logHas(p, "msg 1 Look: no match")); // The same miss again is that record counted, as any repeated err is. _ = wr(p, @intFromEnum(tree.TopFile.look), "zzqqnotthere\n"); try testing.expect(th.logHas(p, "look: no match for \"zzqqnotthere\" (x2)\n")); } test "a word written to a raw terminal's look is listed as in normal mode" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const serial = serialOf(p); const id = p.paneBySerial(serial).?; p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one MARK two\r\n" } }); p.panes[id].?.body.mode = .tty; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "MARK\n").reply.status); const said = std.mem.trimEnd(u8, rd(p, Node.of(serial, .look), 0, 64).bytes, "\n"); const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, said, 10)).?].?; try testing.expect(made.file.?.output.?.from == .search); } test "@p:
takes any sam address; a miss and an unknown serial are errs" { const p = try withFile(testing.allocator, "foo x foo y\nbar\n"); defer p.deinit(); const serial = serialOf(p); const root = @intFromEnum(tree.TopFile.look); const dot = Node.of(serial, .dot); const Case = struct { addr: []const u8, want: []const u8 }; for ([_]Case{ .{ .addr = "0/foo/", .want = " 0 3 " }, .{ .addr = "/foo/", .want = " 6 9 " }, .{ .addr = "#12,#15", .want = " 12 15 " }, .{ .addr = "$", .want = " 16 16 " }, .{ .addr = "1,2", .want = " 0 16 " }, // Backward, as in addr: from the dot's start, wrapping to the last. .{ .addr = "?foo?", .want = " 6 9 " }, .{ .addr = "$-?y?", .want = " 10 11 " }, }) |c| { var line: [64]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:{s}\n", .{ serial, c.addr })).reply.status); try testing.expectEqualStrings(c.want, rd(p, dot, 0, 64).bytes); } var line: [64]u8 = undefined; _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial})); try testing.expect(th.logHas(p, ": no match for regexp")); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); _ = wr(p, root, "@p999:/foo/\n"); try testing.expect(th.logHas(p, "look: no pane @p999")); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); // A line address to no pane is a miss too, not the active pane. _ = wr(p, root, "@p77:1\n"); try testing.expect(th.logHas(p, "look: no pane @p77")); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); } test "a look that goes to another pane leaves the looking pane's dot alone" { const p = try withFile(testing.allocator, "alpha bravo\ncharlie\n"); defer p.deinit(); const source = serialOf(p); const other = try th.newPane(p); _ = wr(p, Node.of(other, .body), "one\ntwo\nthree\n"); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f one\nf two\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var line: [4200]u8 = undefined; for ([_][]const u8{ "@p{d}:2", "@p{d}:/three/", "{s}/f.txt:2" }) |form| { @import("pane.zig").setDot(p.panes[p.paneBySerial(source).?].?, .{ .q0 = 6, .q1 = 11 }); const text = if (form[0] == '@') (if (std.mem.eql(u8, form, "@p{d}:2")) try std.fmt.bufPrint(&line, "@p{d}:2\n", .{other}) else try std.fmt.bufPrint(&line, "@p{d}:/three/\n", .{other})) else try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{dir}); try testing.expectEqual(Status.ok, wr(p, Node.of(source, .look), text).reply.status); try testing.expect(p.active != p.paneBySerial(source).?); try testing.expectEqualStrings(" 6 11 ", rd(p, Node.of(source, .dot), 0, 64).bytes); } } test "@p:
takes any address over the terminal's lines, selecting the match" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const serial = serialOf(p); const id = p.paneBySerial(serial).?; p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one\r\ntwo MARK three\r\n" } }); const pane = p.panes[id].?; const root = @intFromEnum(tree.TopFile.look); var line: [64]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:0/MARK/\n", .{serial})).reply.status); try testing.expectEqual(@as(i32, 1), pane.body.cur_row); try testing.expect(pane.body.vsel.active); // #n lands on that byte: #5 is the `w` of `two`. try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:#5\n", .{serial})).reply.status); try testing.expectEqual(@as(i32, 1), pane.body.cur_row); try testing.expectEqual(@as(i32, 1), pane.body.cur_col); _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial})); try testing.expect(th.logHas(p, "look: no match for regexp")); // A line past its last is a miss, as in a file. const row_before = pane.body.cur_row; _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:999\n", .{serial})); try testing.expect(th.logHas(p, "has no line 999")); try testing.expectEqual(row_before, pane.body.cur_row); try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); } test "a bad command inside an Edit block written whole is named, not the } after it" { const p = try withFile(testing.allocator, "abc\n"); defer p.deinit(); const r = wr(p, Node.of(serialOf(p), .ctl), "Edit {\n,s/a/b/\nzq\n}\n"); try testing.expectEqual(Status.err, r.reply.status); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "\"}\"") == null); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "z") != null); try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); } test "a refused Delcol changes nothing: no pane, no focus" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n"); const made = try th.newPane(p); _ = wr(p, Node.of(made, .name), "/tmp/pardes-delcol-refused.txt\n"); _ = wr(p, Node.of(made, .body), "edit\n"); p.sync(); const col = layout.findPane(p, p.paneBySerial(made).?).?.col; const active = p.active; const active_column = p.active_column; var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); try testing.expectEqual(Status.err, wr(p, Node.ofCol(layout.columnSerial(p, col), .ctl), "Delcol\n").reply.status); p.sync(); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); try testing.expectEqual(active, p.active); try testing.expectEqual(active_column, p.active_column); try testing.expect(th.logHas(p, "unsaved ")); } test "pager answers its +Pager on the open that asked, whatever another client exec'd meanwhile" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const pager = @intFromEnum(tree.TopFile.pager); const exec = @intFromEnum(tree.TopFile.exec); const asked = call(p, .{ .tag = 1, .op = .open, .node = pager, .omode = 2 }).reply.handle; try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = pager, .handle = asked, .data = "/tmp\n" }).reply.status); const paged = for (p.panes) |slot| { const pn = slot orelse continue; if (pn.file) |f| if (std.mem.eql(u8, f.path, "/tmp/+Pager")) break pn.serial; } else return error.NoPager; // Another client execs between the request and its read: a pane of its own. const racer = call(p, .{ .tag = 3, .op = .open, .node = exec, .omode = 2 }).reply.handle; try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = racer, .data = "New\n" }).reply.status); var line: [16]u8 = undefined; const want = try std.fmt.bufPrint(&line, "{d}\n", .{paged}); // A fresh open reads the session's last answer, the racer's pane: what // `pardes -` once read, and wrote its text into. try testing.expect(!std.mem.eql(u8, want, rd(p, exec, 0, 64).bytes)); try testing.expectEqualStrings(want, call(p, .{ .tag = 5, .op = .read, .node = pager, .handle = asked, .size = 64 }).bytes); // Refused, the same open answers nothing, not its last answer. try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .write, .node = pager, .handle = asked, .data = "relative\n" }).errno()); try testing.expectEqualStrings("", call(p, .{ .tag = 7, .op = .read, .node = pager, .handle = asked, .size = 64 }).bytes); // Resolved: `/tmp/../tmp/.` is /tmp's +Pager, the one made above. try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = pager, .handle = asked, .data = "/tmp/../tmp/.\n" }).reply.status); try testing.expectEqualStrings(want, call(p, .{ .tag = 9, .op = .read, .node = pager, .handle = asked, .size = 64 }).bytes); // One that may not be written is refused, permission denied. try testing.expectEqual(E.PERM, wr(p, pager, "/proc/1\n").errno()); // One directory a write, absolute, and there. try testing.expectEqual(E.INVAL, wr(p, pager, "/a\n/b\n").errno()); const relative = wr(p, pager, "relative\n"); try testing.expectEqual(E.INVAL, relative.errno()); try testing.expectEqualStrings("pager: invalid directory \"relative\": must be absolute", relative.reply.ename); try testing.expectEqual(E.NOENT, wr(p, pager, "/tmp/pardes-surely-missing-dir\n").errno()); } test "a command pane an exec open was answered is leased to it while it stays open: another client's command gets a pane of its own" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const exec = Node.of(serialOf(p), .exec); const Client = struct { fn run(pp: *Pardes, node: u64, h: u32, line: []const u8) !usize { try testing.expectEqual(Status.ok, call(pp, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = line }).reply.status); const id = pp.paneBySerial(pp.fs.results[0]).?; // Its command finishes and its output ends. pp.panes[id].?.command_done = true; pp.panes[id].?.command_pty = false; return id; } }; const a = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle; const b = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle; const a_pane = try Client.run(p, exec, a, "echo a\n"); const b_pane = try Client.run(p, exec, b, "echo b\n"); try testing.expect(a_pane != b_pane); // Each one's next command runs in its own pane again. try testing.expectEqual(a_pane, try Client.run(p, exec, a, "echo a2\n")); try testing.expectEqual(b_pane, try Client.run(p, exec, b, "echo b2\n")); // Closed, a lease ends: the directory's one command pane is reused. _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = a, .opened = true }); try testing.expectEqual(Status.ok, wr(p, exec, "echo c\n").reply.status); try testing.expectEqual(a_pane, p.paneBySerial(p.fs.results[0]).?); } test "a Save whose directory is missing because one above refuses says permission denied, not no such directory" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); p.saveFailed(0, "/proc/1/root/x/f.txt", error.FileNotFound); try testing.expect(th.logHas(p, "Save /proc/1/root/x/f.txt: permission denied")); p.saveFailed(0, "/tmp/pardes-surely-missing-dir/f.txt", error.FileNotFound); try testing.expect(th.logHas(p, "no such directory")); } test "a body write to an image pane says its body takes none, not that terminals have no text" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const doc = try p.newDocPane(p.freeSlot().?); doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") }; try testing.expectEqualStrings(pane_files.e_no_body, wr(p, Node.of(doc.serial, .body), "x").reply.ename); } test "Tty+bash on a pane ctl is Tty with its argument, as exec and the tag take it" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const r = call(p, .{ .tag = 1, .op = .write, .node = Node.of(serialOf(p), .ctl), .data = "Tty+sh\n" }); try testing.expectEqual(Status.ok, r.reply.status); try testing.expect(r.spawned); } test "a Rename that reaches other files previews them, says so, and is the pane a waiting write answers" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const id = p.active; p.fs.lsp_result = null; p.lspRequest(id, .rename, "sum"); p.lspResponse(p.lsp_wait.?.id, "lib.rs:2:8 sum\nmain.rs:4:13-15 sum\n"); const preview = p.panes[p.panes[id].?.search_pane.?].?; try testing.expectEqual(preview.serial, p.fs.lsp_result.?); try testing.expect(th.logHas(p, "Rename: 2 edit(s) across files, previewed, not applied")); } test "Edit on an image pane is refused as a data write there is, EINVAL, before it runs" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const doc = try p.newDocPane(p.freeSlot().?); doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") }; const edit = wr(p, Node.of(doc.serial, .ctl), "Edit ,d\n"); const data = wr(p, Node.of(doc.serial, .data), "x"); try testing.expectEqual(E.INVAL, edit.errno()); try testing.expectEqualStrings(data.reply.ename, edit.reply.ename); try testing.expectEqual(E.INVAL, wr(p, Node.of(doc.serial, .name), "/tmp/other.ppm\n").errno()); } test "`@pN:` looks at pane N as `a.txt:` looks at a.txt, and a line past the end is said one way, `:N` or `file:N`" { const p = try withFile(testing.allocator, "one\ntwo\n"); defer p.deinit(); const s = serialOf(p); var buf: [64]u8 = undefined; p.look_missed = false; pardes.look.lookAt(p, 0, try std.fmt.bufPrint(&buf, "@p{d}:", .{s})); try testing.expect(!p.look_missed); const f = pane_files.fileOf(p.panes[0].?).?; for ([_][]const u8{ ":99999", try std.fmt.bufPrint(&buf, "{s}:99999", .{f.path}) }) |looked| { pardes.look.lookAt(p, 0, looked); const said = p.panes[0].?.msg[0..p.panes[0].?.msg_len]; try testing.expect(std.mem.endsWith(u8, said, "/test.txt:99999 has no line 99999")); } } test "a second LookWord list look in a directory refills its +Search rather than stacking another" { if (comptime !pardes.hosted) return; const gpa = testing.allocator; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "words.txt", .data = "alpha beta\nalpha beta\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; var path_buf: [4200]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/words.txt", .{dir}); const p = try Pardes.init(gpa, .{ .file = path, .tty_only = true, .cols = 80, .rows = 30 }); defer p.deinit(); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "LookWord list\n"); pardes.look.lookAt(p, 0, "alpha"); pardes.look.lookAt(p, 0, "beta"); var searches: usize = 0; for (p.panes) |slot| if (slot) |pn| if (pn.file) |f| if (std.mem.endsWith(u8, f.path, "+Search")) { searches += 1; try testing.expectEqualStrings("beta", f.output.?.arg()); }; try testing.expectEqual(@as(usize, 1), searches); } test "Petscii, Palette and Ascii take the state their tag word names, set rather than flipped" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const doc = try p.newDocPane(p.freeSlot().?); doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") }; const ctl_node = Node.of(doc.serial, .ctl); for ([_][]const u8{ "Petscii on\n", "Petscii on\n" }) |line| { try testing.expectEqual(Status.ok, wr(p, ctl_node, line).reply.status); try testing.expect(doc.image.?.glyph_art); } _ = wr(p, ctl_node, "Petscii off\n"); try testing.expect(!doc.image.?.glyph_art); _ = wr(p, ctl_node, "Palette terminal\n"); _ = wr(p, ctl_node, "Palette terminal\n"); try testing.expectEqual(pardes.panes.Image.PaletteMode.terminal, doc.image.?.pmode); _ = wr(p, ctl_node, "Ascii off\n"); _ = wr(p, ctl_node, "Ascii off\n"); try testing.expect(!doc.image.?.ascii); // Bare, each still flips. _ = wr(p, ctl_node, "Ascii\n"); try testing.expect(doc.image.?.ascii); _ = wr(p, ctl_node, "Petscii maybe\n"); try testing.expect(th.logHas(p, "Petscii takes on or off")); }