//! 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, and a pane's ctl its acme status line and the one verb, `get`, //! that no file of its own would say any better. const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); const config = @import("../config.zig"); const fs = @import("../fs.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 has(s: *const Serials, serial: u32) bool { for (s.list[0..s.len]) |known| if (known == serial) return true; return false; } }; 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; } fn activeSerial(p: *Pardes) ?u32 { const pane = p.panes[p.active] orelse return null; return pane.serial; } /// 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). fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { const context = p.panes[id].?.serial; const before = Serials.snapshot(p); if (exec) _ = p.execute(id, text) else p.lookAt(id, text); const after = Serials.snapshot(p); var created = false; for (after.list[0..after.len]) |serial| if (!before.has(serial)) { noteResult(p, serial); created = true; }; if (created) return; noteResult(p, if (exec) context else activeSerial(p) orelse return); } // ---- look and exec ---- /// One command per line, checked whole before any line runs: a line is the /// clicked text itself, so it may not be empty or carry control characters. /// A null `serial` clicks at whichever pane is active as each line runs, /// which is what the root's own look and exec mean; a serial stays put. pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply { for ([2]bool{ false, true }) |apply| { if (apply) p.fs.results_len = 0; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { const text = std.mem.trim(u8, raw, " \t\r"); if (text.len == 0) continue; for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL); if (!apply) continue; const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); run(p, id, exec, text); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } pub fn resultsLen(p: *Pardes) u64 { var n: u64 = 0; for (p.fs.results[0..p.fs.results_len]) |serial| { var digits: [16]u8 = undefined; n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len; } return n; } pub fn readResults(p: *Pardes, req: Req) Reply { const out = p.fs.stage(p.gpa); for (p.fs.results[0..p.fs.results_len]) |serial| out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } // ---- /status ---- fn statusText(p: *Pardes, buf: []u8) []const u8 { var w = std.Io.Writer.fixed(buf); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0; 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 fontName(p: *Pardes) []const u8 { const name = p.settings.font.effective_name.get(); return if (name.len == 0) "default" else name; } fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { const plain = s.len > 0 and for (s) |c| { if (c <= ' ' or c == '\'') break false; } else true; if (plain) return w.writeAll(s); try w.writeByte('\''); for (s) |c| { if (c == '\'') try w.writeByte('\''); try w.writeByte(c); } try w.writeByte('\''); } pub const pane_text_cap = 7 * 12 + 2 * 256 + 4; /// acme's winctlprint: serial, tag length, body length, a reserved zero, the /// dirty flag, then the width in cells, the font and the tab width. 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, pane_files.bodyOf(pane).len, @as(u32, 0), @intFromBool(pane_files.dirtyOf(pane)), pane.cols, }) catch return w.buffered(); writeQuoted(&w, fontName(p)) catch return w.buffered(); w.print(" {d:>11} ", .{config.tab_width}) catch {}; return w.buffered(); } 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` is the one thing here that no file of the pane's own would say: it /// reloads the buffer from the name it carries, wherever that name resolves. /// Repeating it in one write would only reload the same bytes, so it runs once. pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { var asked = false; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; if (!std.mem.eql(u8, line, "get")) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); asked = true; } if (asked) { const errno = get(p, pane); if (errno != 0) return Reply.fail(req.tag, errno); } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } fn get(p: *Pardes, pane: *Pane) u16 { const f = pane_files.fileOf(pane) orelse return 0; if (!panes.Output.fileTraits(f.output).saves) return 0; const bytes = fs.read(p, f.path) catch |err| return switch (err) { error.FileNotFound => E.NOENT, else => E.IO, }; panes.File.pushUndo(p, pane); panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; return 0; } // ---- tests ---- const testing = std.testing; const th = @import("testing.zig"); const call = th.call; const rd = th.rd; const wr = th.wr; const withFile = th.withFile; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; const root_status = @intFromEnum(tree.TopFile.status); const root_look = @intFromEnum(tree.TopFile.look); const root_exec = @intFromEnum(tree.TopFile.exec); test "pane ctl read is index's five fields plus width in cells, font and tab width" { 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} ", .{ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, }); try testing.expectEqualStrings(want.items, a.bytes); try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size); var quoted: [64]u8 = undefined; var w = std.Io.Writer.fixed("ed); try writeQuoted(&w, "DejaVu Sans Mono"); try testing.expectEqualStrings("'DejaVu Sans Mono'", w.buffered()); w = std.Io.Writer.fixed("ed); try writeQuoted(&w, "it's"); try testing.expectEqualStrings("'it''s'", w.buffered()); } test "the pane ctl takes get, and nothing that a file of its own now answers" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const ctl_node = Node.of(serialOf(p), .ctl); for ([_][]const u8{ "menu", "nomenu", "dump echo hi", "font Go Mono", "lock", "bogus", "DEL", "name x.txt", "put", "del", "delete", "Look x", "Exec Save", "clean", "dirty", "cleartag", "dot=addr", "addr=dot", "show", "mark", "nomark", "scroll", "limit=addr", "get x", "look /tmp", "exec Del", }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno()); try testing.expect(p.paneBySerial(serialOf(p)) != null); } test "exec runs a builtin at the pane and records the pane it acted on" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg fs-ran\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Msg from exec\n").reply.status); try testing.expectEqualStrings("from exec", pane.msg[0..pane.msg_len]); var expected: [16]u8 = undefined; const own = try std.fmt.bufPrint(&expected, "{d}\n", .{serial}); try testing.expectEqualStrings(own, rd(p, root_exec, 0, 4096).bytes); // Either command file reads back what the last one did. try testing.expectEqualStrings(own, rd(p, Node.of(serial, .look), 0, 4096).bytes); try testing.expectEqual(@as(u64, own.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_exec }).reply.attr.size); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "New\n").reply.status); const made = p.panes[p.active].?; try testing.expect(made.serial != serial); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .exec), "Del\n").reply.status); try testing.expect(p.paneBySerial(made.serial) == null); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } test "status reads the editor, and look and exec validate whole batches" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg root\n"); defer p.deinit(); const status = rd(p, root_status, 0, 4096); try testing.expectEqual(Status.ok, status.reply.status); var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n'); try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid ")); var expected: [64]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?); var live: usize = 0; for (p.panes) |slot| live += @intFromBool(slot != null); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "panes {d}", .{live}), lines.next().?); try testing.expect(lines.next() == null); const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_status }); try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); try testing.expectEqual(E.PERM, wr(p, root_status, "anything\n").errno()); try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); const serial = p.next_serial; for ([_][]const u8{ "Msg a\x00b", "Msg x\nMsg \x01y\n" }) |invalid| { try testing.expectEqual(E.INVAL, wr(p, root_exec, invalid).errno()); try testing.expectEqual(serial, p.next_serial); } // An empty write is a no-op, not a command. try testing.expectEqual(Status.ok, wr(p, root_exec, "\n \n").reply.status); try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); try testing.expectEqualStrings(status.bytes, rd(p, root_status, 0, 4096).bytes); const pane = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg hello there\n").reply.status); try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_exec, 0, 4096).bytes); try testing.expectEqual(Status.ok, wr(p, root_look, "/virtual/index\n").reply.status); const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); try testing.expectEqual(Status.ok, wr(p, root_exec, "New\n").reply.status); const made = p.panes[p.active].?; try testing.expect(made.serial != opened.serial); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } test "look opens spaced paths and locations without editing the source pane" { if (!fs.platform_has_fs) return error.SkipZigTest; const gpa = testing.allocator; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); var path_buf: [4096]u8 = undefined; const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; const p = try withFile(gpa, "source stays intact\n"); defer p.deinit(); const source = p.panes[0].?; const look_node = Node.of(source.serial, .look); const revision = source.file.?.revision; const undo_len = source.file.?.history.undo_len; source.cur_col = 3; var clicked: [4200]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&clicked, "{s}:2:3\n", .{path})).reply.status); const opened = p.panes[p.active].?; try testing.expect(opened != source); try testing.expectEqualStrings(path, opened.file.?.path); try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); try testing.expectEqual(@as(i32, 1), opened.cur_row); try testing.expectEqual(@as(i32, 2), opened.cur_col); try testing.expectEqualStrings("source stays intact\n", source.file.?.content); try testing.expectEqual(revision, source.file.?.revision); try testing.expectEqual(undo_len, source.file.?.history.undo_len); try testing.expectEqual(@as(i32, 3), source.cur_col); var expected: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); p.active = 0; try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&clicked, "{s}:3:2\n", .{path})).reply.status); try testing.expect(p.panes[p.active].? == opened); try testing.expectEqual(@as(i32, 2), opened.cur_row); try testing.expectEqual(@as(i32, 1), opened.cur_col); try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); } test "look validates the whole batch before opening virtual files" { const gpa = testing.allocator; const p = try withFile(gpa, "source\n"); defer p.deinit(); const source = p.panes[0].?; const look_node = Node.of(source.serial, .look); const serial = p.next_serial; for ([_][]const u8{ "/virtual/index\x00ignored", "/virtual/index\n/virtual/\x01index\n", }) |invalid| { try testing.expectEqual(E.INVAL, wr(p, look_node, invalid).errno()); try testing.expectEqual(serial, p.next_serial); try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqualStrings("source\n", source.file.?.content); } try testing.expectEqual(Status.ok, wr(p, look_node, "/n/self/index\n").reply.status); const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); try testing.expectEqualStrings("source\n", source.file.?.content); } test "get reloads the pane from disk and reports a missing file without losing dirty contents" { const gpa = testing.allocator; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; var path_buf: [4096]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); const p = try withFile(gpa, "in memory\n"); defer p.deinit(); const serial = serialOf(p); const ctl_node = Node.of(serial, .ctl); const pane = p.panes[0].?; var name: [std.fs.max_path_bytes + 8]u8 = undefined; _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path})); try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status); try testing.expectEqualStrings("from disk\n", pane.file.?.content); try testing.expect(!pane_files.dirtyOf(pane)); try testing.expect(pane.file.?.history.undo_len > 0); _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); _ = wr(p, Node.of(serial, .body), "unsaved\n"); try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno()); try testing.expect(p.paneBySerial(serial) != null); try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content); try testing.expect(pane_files.dirtyOf(pane)); } test "exec Save writes the named file through the host" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status); try testing.expect(pane_files.dirtyOf(pane)); const saved = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expect(saved.saved); p.perform(.{ .save_file = .{ .pane = 0 } }); try testing.expect(!pane_files.dirtyOf(pane)); try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); }