//! The files under /pane//: name, body, tag, ctl, the three range //! files addr, dot and limit, data, xdata, sel, the three flag files dirty, //! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); const tagline = @import("../tagline.zig"); const exec = @import("../exec.zig"); const look = @import("../look.zig"); const panes = @import("../panes.zig"); const modal = @import("../modal.zig"); const config = @import("../config.zig"); const fs = @import("../fs.zig"); const tree = @import("tree.zig"); const events = @import("events.zig"); const addressing = @import("addr.zig"); const ctl = @import("ctl.zig"); const pty = @import("pty.zig"); const screen = @import("screen.zig"); const limits = @import("../memory.zig").limits; const Pardes = pardes.Pardes; const Pane = pardes.Pane; const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; const PaneFile = tree.PaneFile; const Node = tree.Node; /// Filesystem state a pane carries beside its editor state. pub const State = struct { /// The range `data` and `xdata` read and write through. acme clears it /// when the first client opens `addr` (editors/acme/xfid.c:105-108), /// which suits a client that holds the fid open; a shell cannot, and /// would evaluate every `echo /re/ > addr` from #0 and never advance. /// Here it is the pane's own register, cleared by neither an open nor /// a truncation: `0` or `,` starts over, and `cp addr dot` and /// `cat addr` answer what was written. addr: Range = .{}, /// The last address written to `addr` failed, so there is none: `data` /// and `xdata` refuse until a good one is written, /// rather than act at the address before it, which acme would do. addr_failed: bool = false, limit: ?Range = null, /// The limit a truncation cleared, kept until the write that follows it /// on that open (a shell's `>`): a write that fails puts it back. limit_truncated: ?Range = null, /// `dot` was truncated and nothing written since: the write that /// follows sets it or, refused, leaves it as it was; with none, the /// open's release empties it (tree.zig releaseHandle). dot_truncated: bool = false, /// The open whose writes to `sel` are being kept together (0: none) and /// the text they have made so far: the next write of that open goes at /// its end, as acme's wrselrange does. wrsel_by: u32 = 0, wrsel: Range = .{}, /// Opens of `event`, which hold the pane scripted. readers: u16 = 0, /// One of `readers` reads `event`; a second reading open is refused. event_reader: bool = false, events: events.Queue = .{}, nomark: bool = false, /// The revision a truncation or a write of `data`, `xdata` or `body` /// left: the write that follows it at once joins its undo step, until /// that open is released (tree.zig releaseHandle). So a shell's /// `printf 'x\ny\n' > data` is one step, as acme's replace is, though /// bash writes it a line at a time; any other edit between breaks it. joined: ?u32 = null, /// The open whose writes `joined` joins (0: any, as after a truncation /// or for an open with no record): an interleaved open's write starts /// a step of its own. joined_by: u32 = 0, noscroll: bool = false, tag_snap: std.ArrayList(u8) = .empty, /// An open reads pty/data, so output queues for it; a second is refused. pty_reader: bool = false, pty_out: events.Queue = .{}, /// The handle of the pty/run open waiting on this shell's current /// command, good only through `tree.openOf` on this pane's run node. run: ?u32 = null, /// The handle of the ctl open that wrote `lock` (ctl.zig), the same way. lock: ?u32 = null, /// The host started a shell it could not teach to mark its prompts. unmarked: bool = false, /// `tag` was truncated (`echo X > tag`): until that open is released, /// the newline that ends a write is held back, so the one that ends the /// whole of it is dropped and draws no empty row. tag_rewrite: bool = false, tag_held_newline: bool = false, /// A truncation of `tag` not yet done: the write after it does it, once /// the write is known to fit, so a refused `>` leaves the tag as it was; /// a read or a close with no write does it then. tag_trunc_pending: bool = false, /// The pty's size as pty/ctl's winsize last set it, until the pane's own /// grid resizes and gives the pty its size again. winsize: ?[2]u16 = null, /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, /// A chorded exec's argument an event reader was told of (flag 8), kept /// for the record's write-back at `chord_at`, which runs with it. chord_arg: ?[]u8 = null, chord_at: Range = .{}, /// A chorded record written back: how many of its two follow-up records /// (argument, location) are still to be consumed rather than run. chord_follow: u8 = 0, /// A chorded record written back alone with no argument kept: the /// command, run when its argument record comes. chord_pending: ?[]u8 = null, chord_pending_tag: bool = false, pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; pub fn deinit(pf: *State, gpa: std.mem.Allocator) void { if (pf.chord_arg) |a| gpa.free(a); if (pf.chord_pending) |c| gpa.free(c); pf.events.deinit(gpa); pf.pty_out.deinit(gpa); pf.tag_snap.deinit(gpa); pf.* = .{}; } }; // ---- what a pane is ---- pub fn bodyOf(pane: *const Pane) []const u8 { if (pane.file) |*f| return f.content; return ""; } pub fn fileOf(pane: *Pane) ?*panes.File.State { return if (pane.file) |*f| f else null; } pub fn tagOf(p: *Pardes, pane: *Pane) []const u8 { return tagline.tagText(p, p.scratch.allocator(), pane) catch ""; } pub fn dirtyOf(pane: *const Pane) bool { const f = if (pane.file) |*x| x else return false; if (!panes.Output.fileTraits(f.output).saves) return false; // Its file deleted on disk: the text is only here now, as acme counts it. return f.revision != f.saved_revision or f.disk_gone; } /// The pane's name: its file, its document, or a terminal's directory. pub fn nameOf(p: *Pardes, pane: *const Pane) []const u8 { if (pane.file) |*f| return f.path; if (pane.pdfPath()) |path| return path; if (pane.image) |state| return state.path; const cwd = pane.cwdSlice(); // A shell given no directory was started in the session's, and is // named by it until it says where it is (a `new` then, not a rename). return if (cwd.len > 0) cwd else p.sessionDir(); } pub fn kindOf(pane: *const Pane) []const u8 { if (pane.file != null) return "text"; if (pane.pdfPath() != null) return "pdf"; if (pane.image != null) return "image"; // A command's pane is no shell to type into: a script choosing a // terminal by kind must not pick one. if (pane.command != null) return "cmd"; return "term"; } pub fn clip(n: usize) u32 { return std.math.cast(u32, n) orelse std.math.maxInt(u32); } /// The file's line index when it describes `text` (built by a render or a /// look), else null: with it a row is a binary search, not a count of every /// newline from the top, which a bulk write paid on each flush, twice over. fn lineIndex(pane: *const Pane, text: []const u8) ?[]const usize { const f = if (pane.file) |*file| file else return null; if (f.line_starts.len == 0 or f.content.ptr != text.ptr or f.content.len != text.len) return null; return f.line_starts; } /// The line `off` is on: the last start at or before it. fn rowAt(starts: []const usize, off: usize) usize { const after = std.sort.upperBound(usize, starts, off, struct { fn order(key: usize, item: usize) std.math.Order { return std.math.order(key, item); } }.order); return after -| 1; } fn lineEnd(starts: []const usize, text: []const u8, row: usize) usize { return if (row + 1 < starts.len) starts[row + 1] - 1 else text.len; } /// `modal.runeOffsetAt`, through the line index when there is one. fn runeOffsetAt(pane: *const Pane, text: []const u8, c: modal.Cursor) usize { const starts = lineIndex(pane, text) orelse return modal.runeOffsetAt(text, c); const row = @min(c.row, starts.len - 1); const s = starts[row]; const e = lineEnd(starts, text, row); return s + modal.runeStart(text[s..e], @min(c.col, e - s)); } /// `modal.runePositionAt`, through the line index when there is one. fn runePositionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { const starts = lineIndex(pane, text) orelse return modal.runePositionAt(text, off); const bounded = modal.runeStart(text, off); const row = rowAt(starts, bounded); return .{ .row = row, .col = bounded - starts[row] }; } /// `modal.positionAt`, through the line index when there is one. fn positionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { const starts = lineIndex(pane, text) orelse return modal.positionAt(text, off); const bounded = @min(off, text.len); const row = rowAt(starts, bounded); const s = starts[row]; const e = lineEnd(starts, text, row); return .{ .row = row, .col = modal.graphemeStart(text[s..e], @min(bounded - s, e - s)) }; } pub fn dotOf(pane: *Pane) State.Range { const text = bodyOf(pane); // In runes, as every address is (modal.runeStart). const head = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; const anchor = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); var hi = @max(head, anchor); if (hi < text.len) hi = modal.nextRune(text, hi); return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; } pub fn setDot(pane: *Pane, r: State.Range) void { const text = bodyOf(pane); const q0 = @min(@as(usize, r.q0), text.len); const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); const a = runePositionAt(pane, text, q0); pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; const h = runePositionAt(pane, text, if (q1 > q0) modal.prevRune(text, q1) else q0); pane.body.cur_row = @intCast(h.row); pane.body.cur_col = @intCast(h.col); pane.body.cur_pinned = true; pane.body.sticky_col = -1; pane.body.msel.active = false; pane.ensureCursorVisible(); } /// Scrolls `off` into view and nothing else: the cursor is dot's head, and /// a write shown is not a write that moves dot (acme's textinsert leaves /// dot where its rule puts it; only the view follows the text). pub fn showOffset(pane: *Pane, off: usize) void { const text = bodyOf(pane); const c = positionAt(pane, text, @min(off, text.len)); // Where dot is now, kept: pinned, so the view moving under it does not // move it to the view's top (pinCursor). pane.pinCursor(); const row = pane.body.cur_row; const col = pane.body.cur_col; pane.body.cur_row = @intCast(c.row); pane.body.cur_col = @intCast(c.col); pane.ensureCursorVisible(); pane.body.cur_row = row; pane.body.cur_col = col; } pub fn clampAddr(pf: *State, len: usize) void { const n = clip(len); pf.addr.q0 = @min(pf.addr.q0, n); pf.addr.q1 = @min(pf.addr.q1, n); if (pf.limit) |*l| { l.q0 = @min(l.q0, n); l.q1 = @min(l.q1, n); } } fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 { if (v <= at) return v; if (v <= at +| removed) return at +| inserted; return v - removed +| inserted; } pub fn wholeUtf8(data: []const u8) usize { var i = data.len; var back: usize = 0; while (i > 0 and back < 4) : (back += 1) { i -= 1; const c = data[i]; if (c < 0x80) return data.len; if (c & 0xC0 == 0xC0) { const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; if (i + need <= data.len or i == 0) return data.len; return i; } } return data.len; } pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize { const f = fileOf(pane) orelse return null; const take = if (bytes.len == 0) 0 else wholeUtf8(bytes); const lo = @min(q0, f.content.len); const hi = @max(lo, @min(q1, f.content.len)); // An empty range deleted, or a range replaced by what it holds: no // edit, so no undo step and no new revision. if (std.mem.eql(u8, f.content[lo..hi], bytes[0..take])) return take; const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null; @memcpy(new[0..lo], f.content[0..lo]); @memcpy(new[lo..][0..take], bytes[0..take]); @memcpy(new[lo + take ..], f.content[hi..]); const join = pane.fs.joined == f.revision; pane.fs.joined = null; if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane); panes.File.setContentSpan(p, f, new, .{ .head = lo, .tail = f.content.len - hi }); return take; } test "a shell's > into an empty pane's body is one undo step, as into any other" { const p = try withFile(testing.allocator, ""); defer p.deinit(); const pane = p.panes[0].?; const f = &pane.file.?; const body = Node.of(pane.serial, .body); const steps = f.history.undo_len; const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .setattr, .node = body, .truncate = true }); try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 3, .op = .write, .node = body, .handle = h, .data = "fresh\n" }).reply.status); _ = call(p, .{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true }); try testing.expectEqualStrings("fresh\n", f.content); try testing.expectEqual(steps + 1, f.history.undo_len); } test "a write that changes nothing, an empty range deleted or a range replaced by itself, makes no undo step" { const p = try withFile(testing.allocator, "abc\ndef\n"); defer p.deinit(); const pane = p.panes[0].?; const serial = pane.serial; const f = &pane.file.?; const steps = f.history.undo_len; const revision = f.revision; const data = Node.of(serial, .data); // Each as a shell does it, on an open of its own: `: > data` after // an empty address, then `echo abc > data` over the line it holds. _ = wr(p, Node.of(serial, .addr), "#2,#2"); const h = call(p, .{ .tag = 1, .op = .open, .node = data, .omode = 1 }).reply.handle; try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 2, .op = .setattr, .node = data, .truncate = true }).reply.status); _ = call(p, .{ .tag = 3, .op = .release, .node = data, .handle = h, .opened = true }); for ([_][]const u8{ "abc\n", "abd\n" }) |text| { _ = wr(p, Node.of(serial, .addr), "1"); const w = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle; try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 5, .op = .write, .node = data, .handle = w, .data = text }).reply.status); _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = w, .opened = true }); if (text[2] == 'c') { try testing.expectEqualStrings("abc\ndef\n", f.content); try testing.expectEqual(steps, f.history.undo_len); try testing.expectEqual(revision, f.revision); } } // Edit's `s/d/d/` is the same nothing. const undone = f.history.undo_len; try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,s/d/d/\n").reply.status); try testing.expectEqual(undone, f.history.undo_len); // A real change is still one. try testing.expectEqualStrings("abd\ndef\n", f.content); try testing.expectEqual(steps + 1, f.history.undo_len); } test "rows through the line index are the ones counting newlines gives, at every offset" { const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail"); defer p.deinit(); const pane = p.panes[0].?; const f = &pane.file.?; _ = try panes.File.lineIndex(p.gpa, f); const text = bodyOf(pane); try testing.expect(lineIndex(pane, text) != null); for (0..text.len + 2) |off| { try testing.expectEqual(modal.positionAt(text, off), positionAt(pane, text, off)); try testing.expectEqual(modal.runePositionAt(text, off), runePositionAt(pane, text, off)); } for (0..8) |row| for (0..16) |col| { const c: modal.Cursor = .{ .row = row, .col = col }; try testing.expectEqual(modal.runeOffsetAt(text, c), runeOffsetAt(pane, text, c)); }; } test "a terminal's body stats as long as it reads" { const gpa = testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; try testing.expect(pane.isTerminal()); p.update(.{ .output = .{ .pane = 0, .bytes = "$ ls\r\nalpha beta\r\n$ " } }); const body = Node.of(pane.serial, .body); const whole = rd(p, body, 0, 1 << 16); try testing.expect(whole.bytes.len > 0); try testing.expectEqual(@as(u64, whole.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = body }).reply.attr.size); } // ---- what stat reports ---- /// A range file's text: acme's addr format, two right-aligned offsets, so /// that copying one range file onto another is the whole of `dot=addr`. pub const range_len: u64 = 24; pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; const pf = &pane.fs; return switch (f) { // A terminal's body reads its screen and history, as text. .body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, // What addr selects, generated by each read: no length, as acme's // data and xdata have none. .data, .xdata => 0, .tag => tagOf(p, pane).len, .name => nameOf(p, pane).len + 1, .ctl => ctl: { var buf: [ctl.pane_text_cap]u8 = undefined; break :ctl ctl.paneText(p, pane, &buf).len; }, .sel => sel: { const d = dotOf(pane); break :sel @max(d.q0, d.q1) - d.q0; }, .addr => if (pf.addr_failed) 0 else range_len, .dot => range_len, .limit => if (pf.limit == null) 0 else range_len, .dirty, .mark, .scroll => 2, .look, .exec, .tagexec => ctl.resultsLen(p), // What the next read would answer, which a client can see without // parking on it. .event => if (pf.events.peek()) |record| record.len else 0, .pty_status => pty.status_len, .pty_ctl => pty_ctl: { var buf: [32]u8 = undefined; // unreachable: two u16s and 9 bytes fit 32 break :pty_ctl (std.fmt.bufPrint(&buf, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch unreachable).len; }, .pty_data => if (pf.pty_out.peek()) |chunk| chunk.len else 0, .dir, .errors, .pty, .pty_run => 0, }; } // ---- the index ---- /// The serial of the column a pane is in, as /layout and /col name it; the /// last word of its /index line. pub fn columnOf(p: *Pardes, id: usize) u32 { const at = pardes.layout.findPane(p, id) orelse return 0; return pardes.layout.columnSerial(p, at.col); } // ---- reads ---- pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { const pf = &pane.fs; return switch (file) { .name => name: { const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, nameOf(p, pane)) catch {}; out.append(p.gpa, '\n') catch {}; break :name tree.stagedReply(p, req); }, .body => readBody(p, req, id, pane), .tag => tag: { applyTagTruncation(p, pane); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; break :tag tree.stagedReply(p, req); }, .ctl => ctl.readPane(p, req, pane), // A failed address reads empty, as an unset limit does: there is // none. data and xdata are what refuse. .addr => readRange(p, req, if (pf.addr_failed) null else addr: { clampAddr(pf, bodyOf(pane).len); break :addr pf.addr; }), .dot => readRange(p, req, dotOf(pane)), .limit => readRange(p, req, pf.limit), .data => readData(req, id, pane, pf, false), .xdata => readData(req, id, pane, pf, true), .sel => readSel(req, id, pane), .dirty => readFlag(p, req, dirtyOf(pane)), .mark => readFlag(p, req, !pf.nomark), .scroll => readFlag(p, req, !pf.noscroll), .look, .exec, .tagexec => ctl.readResults(p, req), .event => events.readQueue(p, req, &pf.events), .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), .pty_ctl => pty.readCtl(p, req, pane), .pty_run => pty.readRun(p, req), .dir, .errors, .pty => Reply.fail(req.tag, E.PERM), }; } /// An unset limit reads empty, which is how a client tells it from one that /// happens to bound nothing. fn readRange(p: *Pardes, req: Req, r: ?State.Range) Reply { const out = p.fs.stage(p.gpa); if (r) |range| out.print(p.gpa, "{d:>11} {d:>11} ", .{ range.q0, range.q1 }) catch {}; return tree.stagedReply(p, req); } fn readFlag(p: *Pardes, req: Req, on: bool) Reply { const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, if (on) "1\n" else "0\n") catch {}; return tree.stagedReply(p, req); } /// The text layer of the page a PDF pane shows (MuPDF's extraction, kept /// with the page), null for any other pane. fn pdfText(p: *Pardes, pane: *Pane) ?[]const u8 { if (comptime !pardes.pdf_enabled) return null; const pv = &(pane.pdf orelse return null); return pv.ensureText(p.pdf_gpa); } fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (pane.file != null) { const text = bodyOf(pane); const off = @min(req.off, text.len); const n = @min(text.len - off, req.size); return .{ .tag = req.tag, .payload = .{ .region = .{ .pane = @intCast(id), .serial = pane.serial, .off = clip(off), .len = clip(n), } } }; } // A PDF's body is its text layer, the page shown's: read-only. if (pdfText(p, pane)) |text| { const off = @min(req.off, text.len); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, text[off..][0..@min(text.len - off, req.size)]) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } if (req.handle != 0 and pane.isTerminal()) return screen.readSnapshot(p, req, pane); const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); defer p.gpa.free(text); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Reply { if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed); const text = bodyOf(pane); clampAddr(pf, text.len); const q0: usize = pf.addr.q0; const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; var end = @min(hi, q0 +| req.size); end = @max(q0, modal.runeStart(text, end)); pf.addr.q0 = clip(end); if (!stop_at_end) pf.addr.q1 = clip(end); if (pane.file == null) return .{ .tag = req.tag }; return .{ .tag = req.tag, .payload = .{ .region = .{ .pane = @intCast(id), .serial = pane.serial, .off = clip(q0), .len = clip(end - q0), } } }; } fn readSel(req: Req, id: usize, pane: *Pane) Reply { if (pane.file == null) return .{ .tag = req.tag }; const text = bodyOf(pane); const d = dotOf(pane); const lo = @min(@as(usize, d.q0), text.len); const hi = @max(lo, @min(@as(usize, d.q1), text.len)); const off = @min(req.off, hi - lo); const n = @min(hi - lo - off, req.size); return .{ .tag = req.tag, .payload = .{ .region = .{ .pane = @intCast(id), .serial = pane.serial, .off = clip(lo + off), .len = clip(n), } } }; } // ---- writes ---- pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { return switch (file) { .name => writeName(p, req, id, pane), .body => writeBody(p, req, id, pane), .tag => writeTag(p, req, pane), .ctl => ctl.writePane(p, req, pane), .addr, .dot, .limit => writeRange(req, pane, file), .data, .xdata => writeData(p, req, pane), .sel => writeSel(p, req, pane), .dirty, .mark, .scroll => writeFlag(p, req, pane, file), .look, .exec => ctl.command(p, req, pane.serial, file == .exec, false), // A click in its tag: an event reader holding the pane hears it as // an `x` record (ctl.run), as acme's tag clicks are. .tagexec => ctl.command(p, req, pane.serial, true, true), .event => events.writeEvent(p, req, id), .errors => if (appendErrors(p, id, req.data)) |took| .{ .tag = req.tag, .written = @intCast(took) } else Reply.fail(req.tag, E.IO), .pty_ctl => pty.writeCtl(p, req, id), .pty_data => pty.writeData(p, req, id), .pty_run => pty.writeRun(p, req, id, pane), .dir, .pty, .pty_status => Reply.fail(req.tag, E.PERM), }; } /// A write joins the step being made only when it is the same open's (or /// the step is anyone's, `joined_by` 0): another open's starts its own. fn joinOnly(pane: *Pane, handle: u32) void { if (pane.fs.joined_by != 0 and pane.fs.joined_by != handle) pane.fs.joined = null; } /// How long writes in a row may pause before the editor's own step puts /// them in: long enough that a stream of 8 KB writes is one edit. pub const batch_pause_ns: u64 = 20 * std.time.ns_per_ms; /// The editor's step: the held writes go in once they have paused. pub fn flushPausedBatch(p: *Pardes) void { const b = &p.fs.batch; if (b.bytes.items.len == 0) return; if (p.now_ns != 0 and p.now_ns -| b.last_ns < batch_pause_ns) return; flushBatch(p); } /// Puts the held writes (`fs.batch`) in as one edit: before any other /// request, the open's release, or the editor's own step. pub fn flushBatch(p: *Pardes) void { const b = &p.fs.batch; if (b.bytes.items.len == 0) return; defer b.bytes.clearRetainingCapacity(); const id = p.paneBySerial(b.serial) orelse return; const pane = p.panes[id].?; if (fileOf(pane) == null) return; const before = dotOf(pane); joinOnly(pane, b.handle); const take = spliceBody(p, pane, b.at, b.at, b.bytes.items) orelse return; pane.fs.joined = fileOf(pane).?.revision; pane.fs.joined_by = b.handle; setDot(pane, .{ .q0 = shiftOne(before.q0, clip(b.at), 0, clip(take)), .q1 = shiftOne(before.q1, clip(b.at), 0, clip(take)), }); if (!pane.fs.noscroll) showOffset(pane, b.at + take); } /// The held bytes a write at `at` on this open can go on from: its own /// batch, when it ends right at `at`; any other is put in first. fn batchFor(p: *Pardes, req: Req, pane: *Pane, at: usize) ?*@TypeOf(p.fs.batch) { const b = &p.fs.batch; const mine = b.serial == pane.serial and b.node == req.node and b.handle == req.handle; if (b.bytes.items.len > 0 and !(mine and b.at + b.bytes.items.len == at)) flushBatch(p); // Only an open's writes are held: one with no record (a test's, handle // 0) is put in at once. A hold past 64 MiB is put in first. if (req.handle == 0 or b.bytes.items.len > 64 << 20) { flushBatch(p); return null; } if (b.bytes.items.len == 0) b.* = .{ .serial = pane.serial, .node = req.node, .handle = req.handle, .at = at, .bytes = b.bytes }; return b; } fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; if (pane.file == null) { // An image's or a PDF's body is no text a write goes into. if (!pane.isTerminal()) return tree.failText(req.tag, E.INVAL, e_no_body); const take = wholeUtf8(req.data); p.emitWrite(id, req.data[0..take]); return .{ .tag = req.tag, .written = @intCast(take) }; } const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0; const at = bodyOf(pane).len + held; if (batchFor(p, req, pane, at)) |b| { const take = wholeUtf8(req.data); b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM); b.last_ns = p.now_ns; return .{ .tag = req.tag, .written = @intCast(take) }; } joinOnly(pane, req.handle); const take = spliceBody(p, pane, at, at, req.data) orelse return Reply.fail(req.tag, E.NOMEM); pane.fs.joined = fileOf(pane).?.revision; pane.fs.joined_by = req.handle; if (!pane.fs.noscroll) showOffset(pane, at + take); return .{ .tag = req.tag, .written = @intCast(take) }; } /// Why `data` is no text a tag takes: a control character but a tab or a /// newline, DEL, a C1 control, or bytes that are not UTF-8 (a NUL in a tag /// made a Dump that wrote nothing). pub fn tagFault(data: []const u8) ?[]const u8 { for (data) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return "invalid tag text: a control character"; if (!std.unicode.utf8ValidateSlice(data)) return "invalid tag text: not UTF-8"; var i: usize = 0; while (std.mem.indexOfScalarPos(u8, data, i, 0xC2)) |at| : (i = at + 1) if (at + 1 < data.len and data[at + 1] <= 0x9F) return "invalid tag text: a control character"; return null; } /// A tag write's limit, the same for a pane's, a column's and the /// workspace's: whole or not at all. pub const e_tag_over = std.fmt.comptimePrint("no space: over {d} bytes", .{limits.max_tag_tail}); fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; const pf = &pane.fs; // A refused write after a truncation changes nothing: the truncation // it would have come with is dropped too. if (tagFault(req.data)) |why| { pf.tag_trunc_pending = false; return tree.failText(req.tag, E.INVAL, why); } // The tag's own text grows by what is written, newlines and all: a tag // is a text like any other (acme's tag file appends the same way). const had = if (pf.tag_trunc_pending) 0 else tagline.curTail(pane).len; const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline and !pf.tag_trunc_pending); // A rewrite drops its one trailing newline: `echo <4096 bytes> > tag` // is 4096 bytes of tag, and fits. const rewrite = pf.tag_rewrite or pf.tag_trunc_pending; const counted = req.data.len - @intFromBool(rewrite and req.data[req.data.len - 1] == '\n'); if (counted > room) { pf.tag_trunc_pending = false; return tree.failText(req.tag, E.NOSPC, e_tag_over); } applyTagTruncation(p, pane); const take = wholeUtf8(req.data); // A truncating write drops ONE trailing newline, its whole text's: a // newline held from a write before goes in once more text follows it. if (pf.tag_held_newline) { pane.appendTag("\n") catch return Reply.fail(req.tag, E.NOMEM); pf.tag_held_newline = false; } var text = req.data[0..take]; if (pf.tag_rewrite and text.len > 0 and text[text.len - 1] == '\n') { text = text[0 .. text.len - 1]; pf.tag_held_newline = true; } pane.appendTag(text) catch return Reply.fail(req.tag, E.NOMEM); return .{ .tag = req.tag, .written = @intCast(take) }; } /// A pending truncation of the tag, done: its text cleared as `cleartag` /// clears it, the prefix left. pub fn applyTagTruncation(p: *Pardes, pane: *Pane) void { const pf = &pane.fs; if (!pf.tag_trunc_pending) return; pf.tag_trunc_pending = false; const empty = p.gpa.alloc(u8, 0) catch return; // An edit like a keyboard one: `u` in the tag brings it back. pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null); if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = empty; // what is left is the prefix; the cursor goes after it pane.tag.cur_row = 0; pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0; pane.tag.vsel.active = false; pane.tag.nsel = 0; pf.tag_rewrite = true; pf.tag_held_newline = false; } /// A write only text takes, to a pane with none: said, as every EINVAL is. pub const e_no_text = "invalid write: this pane has no text (a terminal, an image or a PDF)"; /// A body write to a pane whose body is a picture: a terminal's takes one /// (its keys), so the word is not "no text". pub const e_no_body = "invalid write: an image's or a PDF's body takes no writes"; fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply { if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); const pf = &pane.fs; if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed); const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0; clampAddr(pf, bodyOf(pane).len + held); const q0: usize = pf.addr.q0; const q1: usize = @max(q0, @as(usize, pf.addr.q1)); // An insert (an empty address) goes on from the open's held bytes. if (q1 == q0) if (batchFor(p, req, pane, q0)) |b| { const take = wholeUtf8(req.data); b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM); b.last_ns = p.now_ns; pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; return .{ .tag = req.tag, .written = @intCast(take) }; }; flushBatch(p); const before = dotOf(pane); joinOnly(pane, req.handle); const take = spliceBody(p, pane, q0, q1, req.data) orelse return Reply.fail(req.tag, E.NOMEM); pf.joined = fileOf(pane).?.revision; pf.joined_by = req.handle; setDot(pane, .{ .q0 = shiftOne(before.q0, clip(q0), clip(q1 - q0), clip(take)), .q1 = shiftOne(before.q1, clip(q0), clip(q1 - q0), clip(take)), }); pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; if (!pf.noscroll) showOffset(pane, q0 + take); return .{ .tag = req.tag, .written = @intCast(take) }; } fn writeSel(p: *Pardes, req: Req, pane: *Pane) Reply { if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); const pf = &pane.fs; // The same open's next write follows its last; any other replaces dot. const going_on = req.handle != 0 and pf.wrsel_by == req.handle; const d = if (going_on) State.Range{ .q0 = pf.wrsel.q1, .q1 = pf.wrsel.q1 } else dotOf(pane); const q0: usize = d.q0; const q1: usize = @max(q0, @as(usize, d.q1)); const take = spliceBody(p, pane, q0, q1, req.data) orelse return Reply.fail(req.tag, E.NOMEM); // What was written is selected, as acme's wrsel leaves it: `cat sel` // reads it back. const start = if (going_on) pf.wrsel.q0 else clip(q0); pf.wrsel = .{ .q0 = start, .q1 = clip(q0 + take) }; pf.wrsel_by = req.handle; setDot(pane, pf.wrsel); return .{ .tag = req.tag, .written = @intCast(take) }; } /// The pair of offsets a range file reads back, so that copying `addr` onto /// `dot` or `limit` does what acme spelled `dot=addr` and `limit=addr`. fn pairOf(text: []const u8) ?State.Range { var it = std.mem.tokenizeAny(u8, text, " \t"); const q0 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; const q1 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; if (it.next() != null) return null; return .{ .q0 = q0, .q1 = q1 }; } pub const e_no_text_address = "invalid address: this pane has no text to address"; pub const e_addr_failed = "no address: the last one written to addr failed"; /// A range file takes an address expression, or that pair of offsets. One /// that does not evaluate says why: `bad address syntax`, `no match for /// regexp`, `address out of range`, `bad regular expression`, `addresses /// out of order`. fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { // A terminal, an image or a PDF has no text an address names: refused // alike for addr, dot and limit, before the expression is read. if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text_address); const pf = &pane.fs; const text = bodyOf(pane); clampAddr(pf, text.len); const expr = std.mem.trimEnd(u8, req.data, "\n"); // One that goes from the address (`.`, `+`, `-`) has none to go from // after a failed one: said, rather than going from the one before it. if (file == .addr and pf.addr_failed and expr.len > 0 and std.mem.indexOfScalar(u8, ".+-", expr[0]) != null) return tree.failText(req.tag, E.INVAL, e_addr_failed); var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; // A pair is refused as the address form refuses it, never clamped: one // past the text is out of range, one ending before it starts out of // order (below). var r = if (pairOf(expr)) |pair| pair: { if (pair.q0 > clip(text.len) or pair.q1 > clip(text.len)) { a.err = addressing.e_range; break :pair null; } break :pair pair; } else if (a.address(pf.addr)) |found| (if (a.i < expr.len) null else found) else null; // sam's check, which acme leaves out: `#100,#50` names no range. if (r) |found| if (found.q0 > found.q1) { a.err = addressing.e_order; r = null; }; const range = r orelse { if (file == .addr) pf.addr_failed = true; if (file == .limit) if (pf.limit_truncated) |kept| { pf.limit = kept; pf.limit_truncated = null; }; if (file == .dot) pf.dot_truncated = false; return tree.failText(req.tag, E.INVAL, a.err); }; if (file == .limit) pf.limit_truncated = null; if (file == .dot) pf.dot_truncated = false; switch (file) { .addr => { pf.addr = range; pf.addr_failed = false; }, .limit => pf.limit = range, // Setting dot scrolls to it, which is the whole of acme's `show`. .dot => setDot(pane, range), else => unreachable, } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { const word = std.mem.trim(u8, req.data, " \t\r\n"); if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "bad flag: takes 1 or 0"); const on = word[0] == '1'; const pf = &pane.fs; switch (file) { .dirty => { const f = fileOf(pane) orelse return tree.failText(req.tag, E.INVAL, e_no_text); f.saved_revision = if (on) f.revision -% 1 else f.revision; }, // acme's nomark joins the writes after it into one undo step. The // step starts here: the text as it is now is what Undo brings back, // and turning mark on again only ends it. .mark => { if (!on and !pf.nomark) panes.File.pushUndo(p, pane); pf.nomark = !on; }, .scroll => pf.noscroll = !on, else => unreachable, } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// Writing `name` renames the buffer: a relative name resolves against the /// pane's directory, as the tag's name field does. /// acme's refusal of a name with a blank or a control character in it /// (editors/acme/xfid.c:650-652). A blank inside a name is taken here, as /// pardes names files with spaces; one at either end is refused, not /// quietly cut off, so the name a script wrote is the name it gets. const e_name_char = "bad character in file name"; /// Why `name` is not one file name, with the reason, or null: a newline, a /// control byte, DEL or a C1 control (U+0080-U+009F), a blank at either /// end, or bytes that are not UTF-8. /// What no file name can hold, said: a newline (a name is one line) and /// a NUL. Any other byte a file system takes is taken, controls and bytes /// not UTF-8 included, so a name read from `name` writes back as it was. fn nameFault(name: []const u8) ?[]const u8 { if (std.mem.indexOfScalar(u8, name, '\n') != null) return e_name_char ++ ": a newline (a name is one line)"; if (std.mem.indexOfScalar(u8, name, 0) != null) return e_name_char ++ ": a NUL"; // No file system takes a longer one (NAME_MAX): a Save would only fail. var parts = std.mem.splitScalar(u8, name, '/'); while (parts.next()) |part| if (part.len > 255) return "invalid file name: a component over 255 bytes"; return null; } fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // One name: its newline ends it, as `echo` writes it, and it is one. const name = if (std.mem.endsWith(u8, req.data, "\n")) req.data[0 .. req.data.len - 1] else req.data; if (name.len == 0) return tree.failText(req.tag, E.INVAL, e_name_char ++ ": an empty name"); if (nameFault(name)) |why| return tree.failText(req.tag, E.INVAL, why); // Words 9ns maps to EINVAL, as every refusal of a name here. if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, if (pane.isTerminal()) "invalid rename: a terminal is named by its shell's directory; cd there, or Tty in another" else "invalid rename: an image or PDF is named by the file it shows"); var home_buf: [4096]u8 = undefined; const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), fs.expandHome(name, &home_buf) }) catch return Reply.fail(req.tag, E.NOMEM); if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be"); // A directory (`~`, `/`, `foo/`) is no file to name a pane after: a // Save could only fail. Said now, EISDIR. const is_dir = name[name.len - 1] == '/' or std.mem.eql(u8, full, "/") or (if (comptime pardes.hosted) (if (fs.localPath(full)) |local| exec.isDirectory(local) else false) else false); if (is_dir) return tree.failText(req.tag, E.ISDIR, std.fmt.bufPrint(&p.fs.ename, "name: {s} is a directory, not a file", .{full[0..@min(full.len, 256)]}) catch "name: is a directory, not a file"); nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) { error.OutOfMemory => E.NOMEM, else => E.INVAL, }); return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } /// Change a buffer's absolute name without writing or renaming a disk file. /// Tag editing preserves user commands; the filesystem's rename resets its tail. pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !void { const pane = p.panes[id] orelse return error.NoPane; const f = fileOf(pane) orelse return error.NotFile; if (!std.fs.path.isAbsolute(full) or full.len == 0 or full.len >= 4096) return error.InvalidName; if (std.mem.indexOfAny(u8, full, "\x00\n") != null) return error.InvalidName; if (std.mem.eql(u8, f.path, full)) return; const copy = try p.gpa.dupe(u8, full); p.gpa.free(f.path); f.path = copy; if (panes.Output.fileTraits(f.output).saves) { f.output = null; pane.clearCwd(); if (!preserve_tag) { if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; } // A name alone is no edit: dirty stays what the text made it, as // the guards ask only whether it was ever edited, so /index, Exit // and Restore agree. Save writes it under the new name all the same. f.watch_after_save = fs.localPath(full) != null; } if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); f.highlights = &.{}; f.highlight_start = 0; f.syntax_dirty = true; f.context_revision = null; pane.context_rows = 0; pane.wrap_n = 0; look.invalidateLookHover(p, id); p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); events.noteLog(p, .rename, pane); if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path); } /// Diagnostics go to the +Errors buffer of the pane's directory. pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { if (text.len == 0) return 0; const pane = p.panes[id] orelse return null; const cwd = pane.cwdSlice(); const dir = if (pane.file) |*f| std.fs.path.dirname(f.path) orelse "/" else if (cwd.len > 0) cwd else "/"; for (p.panes) |slot| { const q = slot orelse continue; const qf = fileOf(q) orelse continue; const o = qf.output orelse continue; if (std.meta.activeTag(o.from) != .errors) continue; if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue; return spliceBody(p, q, qf.content.len, qf.content.len, text); } const free = p.freeSlot() orelse return null; const content = p.gpa.dupe(u8, text) catch return null; _ = panes.Output.open(p, free, dir, .errors, "", content) catch { p.gpa.free(content); return null; }; exec.placeNew(p, id, id, free, .errors); // No column had room for it (it closes at the step's end): the text is // logged instead, a msg a line, so it is not lost and nothing fails. if (p.unplaced.isSet(free)) { var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n'); while (lines.next()) |line| events.noteMessage(p, pane.serial, line[0..@min(line.len, pardes.Messages.LoggedMessage.cap)]); } return text.len; } /// Truncation empties whatever the file holds: the body, the tag's editable /// tail (acme's `cleartag`), a range. `data` and `xdata` hold what `addr` /// addresses, so truncating one deletes that range and no more, and a /// shell's `echo new > data` replaces it. Every other setattr just answers /// fresh attributes. pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { const pf = &pane.fs; switch (file) { .body => if (fileOf(pane) != null) { pf.joined = null; const was = fileOf(pane).?.revision; _ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err; // An empty body truncated is no edit: the write after it makes // the step, rather than join one that was never pushed. pf.joined = if (fileOf(pane).?.revision != was) fileOf(pane).?.revision else null; pf.joined_by = 0; pf.addr = .{}; setDot(pane, .{}); }, .data, .xdata => if (fileOf(pane) != null) { clampAddr(pf, bodyOf(pane).len); const q0 = pf.addr.q0; const q1 = @max(q0, pf.addr.q1); const before = dotOf(pane); pf.joined = null; const was = fileOf(pane).?.revision; _ = spliceBody(p, pane, q0, q1, "") orelse return .err; pf.joined = if (fileOf(pane).?.revision != was) fileOf(pane).?.revision else null; pf.joined_by = 0; setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) }); pf.addr = .{ .q0 = q0, .q1 = q0 }; }, // Done with the write after it, which may yet be refused. .tag => pf.tag_trunc_pending = true, // A shell's `>` truncates before it writes: the address written // is evaluated from where the last one left off, as with `>>`. // `0` (or `,`) is how to start over. See State.addr. .addr => {}, .limit => { pf.limit_truncated = pf.limit; pf.limit = null; }, .dot => pf.dot_truncated = fileOf(pane) != null, else => {}, } return .ok; } // ---- 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 Status = tree.Status; test "index lists serial kind dirty flag and name, and stats 0, a view generated by each read" { const gpa = testing.allocator; const p = try withFile(gpa, "hello\nthere\n"); defer p.deinit(); const pane = p.panes[0].?; const index = @intFromEnum(tree.TopFile.index); const a = rd(p, index, 0, 4096); try testing.expectEqual(Status.ok, a.reply.status); var want: [64]u8 = undefined; const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt {d}\n", .{ pane.serial, columnOf(p, 0) }); try testing.expect(std.mem.indexOf(u8, a.bytes, clean_row) != null); const at = std.mem.indexOf(u8, a.bytes, clean_row).?; const stat = call(p, .{ .tag = 1, .op = .getattr, .node = index }); try testing.expectEqual(@as(u64, 0), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); pane.file.?.saved_revision = pane.file.?.revision -% 1; var dirty_want: [64]u8 = undefined; const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt {d}\n", .{ pane.serial, columnOf(p, 0) }); try testing.expect(std.mem.indexOf(u8, rd(p, index, 0, 4096).bytes, dirty_row) != null); const kind = at + std.mem.indexOfScalar(u8, clean_row, ' ').? + 1; try testing.expectEqualStrings("text", rd(p, index, kind, 4).bytes); const serial = try th.newPane(p); const both = rd(p, index, 0, 4096); var expected: [4200]u8 = undefined; const made = p.panes[p.paneBySerial(serial).?].?; try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(p, made), columnOf(p, p.paneBySerial(serial).?) }))); } test "a terminal is listed as term with its directory as name" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const pane = p.panes[0].?; p.setCwd(0, "/work/dir"); var want: [64]u8 = undefined; try testing.expect(std.mem.indexOf( u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, try std.fmt.bufPrint(&want, "{d} term 0 /work/dir {d}\n", .{ pane.serial, columnOf(p, 0) }), ) != null); try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes); const refused = wr(p, Node.of(pane.serial, .name), "/elsewhere\n"); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expect(std.mem.startsWith(u8, refused.reply.ename, "invalid rename: a terminal is named")); } test "body reads at any offset and writes append" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const body = Node.of(serial, .body); try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" }); try testing.expectEqual(@as(u32, 6), w.reply.written); try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content); const short = wr(p, body, "a\xC3"); try testing.expectEqual(@as(u32, 1), short.reply.written); try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content); const stat = call(p, .{ .tag = 6, .op = .getattr, .node = body }); try testing.expectEqual(@as(u64, "one\ntwo\nthree\na".len), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); try testing.expect(stat.reply.attr.mtime >= p.fs.started); // The qid version is the pane's revision, so a stat sees an edit land. try testing.expectEqual(p.panes[0].?.file.?.revision, stat.reply.attr.version); _ = wr(p, body, "more\n"); const later = call(p, .{ .tag = 7, .op = .getattr, .node = body }); try testing.expect(later.reply.attr.version != stat.reply.attr.version); try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 8, .op = .getattr, .node = Node.of(serial, .tag) }).reply.attr.version); } test "a body write to a terminal pane types at its shell" { const gpa = testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; try testing.expect(pane.isTerminal()); const a = wr(p, Node.of(pane.serial, .body), "ls -l\r"); try testing.expectEqual(@as(u32, 6), a.reply.written); try testing.expectEqualStrings("ls -l\r", a.pty()); const r = rd(p, Node.of(pane.serial, .body), 0, 64); try testing.expectEqual(Status.ok, r.reply.status); } test "tag reads the whole tag and writes append to the editable tail" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const pane = p.panes[0].?; const node = Node.of(pane.serial, .tag); const whole = rd(p, node, 0, 4096); try testing.expect(std.mem.startsWith(u8, whole.bytes, "/test.txt")); try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null); try testing.expectEqual(@as(u64, whole.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); const before = rd(p, node, 0, 4096).bytes.len; const w = wr(p, node, " Mine"); try testing.expectEqual(@as(u32, 5), w.reply.written); try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine")); const after = rd(p, node, 0, 4096); try testing.expectEqual(before + 5, after.bytes.len); try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); // A tag is a text like the body: a newline written to it stays one, and // the tag reads back taller by it. try testing.expectEqual(@as(u32, 10), wr(p, node, "\nNext Line").reply.written); try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine\nNext Line")); try testing.expect(std.mem.endsWith(u8, rd(p, node, 0, 4096).bytes, " Mine\nNext Line")); if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = try p.gpa.alloc(u8, limits.max_tag_tail); @memset(pane.tag.own.?, 'x'); try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno()); } test "data and xdata read from addr, move it, and write through it" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const addr = Node.of(serial, .addr); const data = Node.of(serial, .data); const xdata = Node.of(serial, .xdata); _ = wr(p, addr, "#0"); try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes); try testing.expectEqual(@as(u32, 3), p.panes[0].?.fs.addr.q0); try testing.expectEqual(@as(u32, 3), p.panes[0].?.fs.addr.q1); _ = wr(p, addr, "1"); try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes); _ = wr(p, addr, "1"); try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes); _ = wr(p, addr, "1"); const w = wr(p, data, "ONE\n"); try testing.expectEqual(@as(u32, 4), w.reply.written); try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); try testing.expectEqual(@as(u32, 4), p.panes[0].?.fs.addr.q0); } test "data never splits a rune, in either direction" { const gpa = testing.allocator; const p = try withFile(gpa, "\u{00e9}x\n"); defer p.deinit(); const serial = serialOf(p); _ = wr(p, Node.of(serial, .addr), "#0"); try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes); _ = wr(p, Node.of(serial, .addr), "#0"); try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes); _ = wr(p, Node.of(serial, .addr), "#0"); try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written); } test "sel reads the selection and writing sel replaces it" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const addr_node = Node.of(serial, .addr); const dot = Node.of(serial, .dot); const sel = Node.of(serial, .sel); // Copying addr onto dot is the whole of acme's `dot=addr`, and back again // of `addr=dot`; each range file reads exactly what the other takes. _ = wr(p, addr_node, "#0,#3"); try testing.expectEqual(Status.ok, wr(p, dot, " 0 3 ").reply.status); try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 100).bytes); try testing.expectEqual(range_len, call(p, .{ .tag = 1, .op = .getattr, .node = dot }).reply.attr.size); try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes); try testing.expectEqual(@as(u64, 3), call(p, .{ .tag = 1, .op = .getattr, .node = sel }).reply.attr.size); _ = wr(p, addr_node, "#0"); _ = wr(p, addr_node, " 0 3 "); try testing.expectEqual(@as(u32, 0), p.panes[0].?.fs.addr.q0); try testing.expectEqual(@as(u32, 3), p.panes[0].?.fs.addr.q1); // A truncation empties dot once its open goes, nothing written after // it; an address expression addresses it. const h = call(p, .{ .tag = 8, .op = .open, .node = dot, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 9, .op = .setattr, .node = dot, .truncate = true }); try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes); _ = call(p, .{ .tag = 10, .op = .release, .node = dot, .handle = h, .opened = true }); try testing.expectEqualStrings("", rd(p, sel, 0, 100).bytes); try testing.expectEqual(Status.ok, wr(p, dot, "#0,#3").reply.status); try testing.expectEqual(Status.ok, wr(p, sel, "ONE").reply.status); try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); // What was written is selected, as acme's wrsel leaves it. try testing.expectEqualStrings("ONE", rd(p, sel, 0, 100).bytes); // A write of another open replaces it; one open's writes run on. _ = wr(p, sel, "!"); try testing.expectEqualStrings("!\ntwo\n", p.panes[0].?.file.?.content); const hs = call(p, .{ .tag = 3, .op = .open, .node = sel, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 4, .op = .write, .node = sel, .handle = hs, .data = "a" }); _ = call(p, .{ .tag = 5, .op = .write, .node = sel, .handle = hs, .data = "b" }); _ = call(p, .{ .tag = 6, .op = .release, .node = sel, .handle = hs, .opened = true }); try testing.expectEqualStrings("ab\ntwo\n", p.panes[0].?.file.?.content); try testing.expectEqualStrings("ab", rd(p, sel, 0, 100).bytes); } test "the flag files read and write the state acme kept behind ctl verbs" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[0].?; const pf = &p.panes[0].?.fs; const dirty = Node.of(serial, .dirty); const mark = Node.of(serial, .mark); const scroll = Node.of(serial, .scroll); for ([_]u64{ dirty, mark, scroll }) |node| { try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| { try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); try testing.expectEqualStrings("bad flag: takes 1 or 0", wr(p, node, bad).reply.ename); } } try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); try testing.expectEqualStrings("1\n", rd(p, mark, 0, 8).bytes); try testing.expectEqualStrings("1\n", rd(p, scroll, 0, 8).bytes); try testing.expectEqual(Status.ok, wr(p, dirty, "1\n").reply.status); try testing.expect(dirtyOf(pane)); try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes); _ = wr(p, dirty, "0"); try testing.expect(!dirtyOf(pane)); _ = wr(p, mark, "0"); _ = wr(p, scroll, "0"); try testing.expect(pf.nomark and pf.noscroll); try testing.expectEqualStrings("0\n", rd(p, mark, 0, 8).bytes); try testing.expectEqualStrings("0\n", rd(p, scroll, 0, 8).bytes); _ = wr(p, mark, "1"); _ = wr(p, scroll, "1"); try testing.expect(!pf.nomark and !pf.noscroll); // A terminal has no file, so its dirty flag is readable but not writable. const term = try th.withTerm(gpa); defer term.deinit(); try testing.expectEqualStrings("0\n", rd(term, Node.of(serialOf(term), .dirty), 0, 8).bytes); try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno()); } test "the writes of one open are one undo step, as bash's line-buffered printf makes them; the next open is another" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); for ([_]u64{ Node.of(serial, .data), Node.of(serial, .body), Node.of(serial, .xdata) }) |node| { _ = wr(p, Node.of(serial, .addr), "$"); for ([_][]const u8{ "x\n", "y\n", "z\n" }) |piece| try testing.expectEqual(Status.ok, wr(p, node, piece).reply.status); _ = call(p, .{ .tag = 3, .op = .release, .node = node }); // A second open: its own step. _ = wr(p, Node.of(serial, .addr), "$"); try testing.expectEqual(Status.ok, wr(p, node, "w\n").reply.status); _ = call(p, .{ .tag = 3, .op = .release, .node = node }); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); try testing.expectEqualStrings("one\nx\ny\nz\n", rd(p, Node.of(serial, .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, Node.of(serial, .body), 0, 64).bytes); } } test "an open's writes in a row are held and go in as one edit, seen by the next request" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); const body = Node.of(serial, .body); const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; const revision = p.panes[0].?.file.?.revision; for (0..100) |_| _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "0123456789\n" }); // Held: nothing edited yet. try testing.expectEqual(revision, p.panes[0].?.file.?.revision); // Any other request puts them in, once. try testing.expectEqual(@as(usize, 4 + 1100), rd(p, body, 0, 4096).bytes.len); try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision); _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = h, .opened = true }); 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); // data inserts at its address go on the same way. const data = Node.of(serial, .data); _ = wr(p, Node.of(serial, .addr), "#0"); const d = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "a" }); _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "b" }); _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = d, .opened = true }); try testing.expectEqualStrings("abone\n", rd(p, body, 0, 64).bytes); } test "writes held before the shell's clock is first seen go in at its first step" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const body = Node.of(serialOf(p), .body); try testing.expect(!p.clock_started); const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; const revision = p.panes[0].?.file.?.revision; _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "x\n" }); try testing.expectEqual(@as(u64, 0), p.fs.batch.last_ns); // A detached host's first step: its clock is days into the machine's. p.advance(700_000 * std.time.ns_per_s); flushPausedBatch(p); try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision); } test "a tag write past the limit is refused whole, naming the limit" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const tag = Node.of(serialOf(p), .tag); const before = try testing.allocator.dupe(u8, rd(p, tag, 0, 1 << 16).bytes); defer testing.allocator.free(before); const big = "w" ** (limits.max_tag_tail + 10); const r = wr(p, tag, big); try testing.expectEqual(E.NOSPC, r.errno()); // `no space` is what 9ns maps to ENOSPC, so a shell through a mount // sees the errno the tree gives. try testing.expect(std.mem.startsWith(u8, r.reply.ename, "no space: over ")); try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes); } test "two opens writing one body are two undo steps, however their writes interleave" { const p = try withFile(testing.allocator, "one\n"); defer p.deinit(); const serial = serialOf(p); const body = Node.of(serial, .body); const a = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; const b = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; try testing.expect(a != 0 and b != 0 and a != b); _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = a, .data = "a\n" }); _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = a, .data = "b\n" }); _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = b, .data = "c\n" }); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); try testing.expectEqualStrings("one\na\nb\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); _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = a, .opened = true }); _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = b, .opened = true }); } test "after a failed address, one that goes from it says there is none" { const p = try withFile(testing.allocator, "one\ntwo\n"); defer p.deinit(); const addr = Node.of(serialOf(p), .addr); try testing.expectEqual(Status.err, wr(p, addr, "/nomatch/").reply.status); try testing.expectEqualStrings(e_addr_failed, wr(p, addr, ".+1").reply.ename); try testing.expectEqualStrings(e_addr_failed, wr(p, addr, "+1").reply.ename); // An absolute one sets it again, and then `.` works. try testing.expectEqual(Status.ok, wr(p, addr, "2").reply.status); try testing.expectEqual(Status.ok, wr(p, addr, ".").reply.status); } test "a 9P write moves dot only as acme's textinsert does, and scrolls only the view" { const Case = struct { at: u32, want: State.Range }; // dot is `cd` of `abcdef\n` (2..4); two bytes go in at `at`. for ([_]Case{ .{ .at = 0, .want = .{ .q0 = 4, .q1 = 6 } }, // before: shifted .{ .at = 2, .want = .{ .q0 = 2, .q1 = 6 } }, // at its start: grown .{ .at = 3, .want = .{ .q0 = 2, .q1 = 6 } }, // inside: grown .{ .at = 4, .want = .{ .q0 = 2, .q1 = 4 } }, // at its end: left .{ .at = 6, .want = .{ .q0 = 2, .q1 = 4 } }, // after: left }) |c| { const p = try withFile(testing.allocator, "abcdef\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[p.paneBySerial(serial).?].?; setDot(pane, .{ .q0 = 2, .q1 = 4 }); var addr: [16]u8 = undefined; _ = wr(p, Node.of(serial, .addr), try std.fmt.bufPrint(&addr, "#{d}", .{c.at})); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "XX").reply.status); try testing.expectEqual(c.want, dotOf(pane)); } // A body append leaves dot, and a sel write after it replaces dot, not // the text appended. const p = try withFile(testing.allocator, "abcdef\n"); defer p.deinit(); const serial = serialOf(p); const pane = p.panes[p.paneBySerial(serial).?].?; setDot(pane, .{ .q0 = 2, .q1 = 4 }); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "more\n").reply.status); try testing.expectEqual(State.Range{ .q0 = 2, .q1 = 4 }, dotOf(pane)); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .sel), "YY").reply.status); try testing.expectEqualStrings("abYYef\nmore\n", fileOf(pane).?.content); } test "an addr pair out of order or out of range is refused as the address form is, not clamped" { const p = try withFile(testing.allocator, "abcdef\n"); defer p.deinit(); const serial = serialOf(p); const addr = Node.of(serial, .addr); try testing.expectEqualStrings(addressing.e_order, wr(p, addr, "5 2").reply.ename); try testing.expectEqualStrings(addressing.e_order, wr(p, addr, "#5,#2").reply.ename); try testing.expectEqualStrings(addressing.e_range, wr(p, addr, "2 99").reply.ename); try testing.expectEqualStrings(addressing.e_range, wr(p, addr, "#2,#99").reply.ename); try testing.expectEqual(Status.ok, wr(p, addr, "2 4").reply.status); try testing.expectEqual(State.Range{ .q0 = 2, .q1 = 4 }, p.panes[p.paneBySerial(serial).?].?.fs.addr); } test "name refuses a directory, EISDIR, and leaves the name as it was" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const node = Node.of(serial, .name); for ([_][]const u8{ "/\n", "/tmp/\n", "sub/\n", "/tmp\n" }) |dir| { if (comptime !pardes.hosted) if (std.mem.eql(u8, dir, "/tmp\n")) continue; const r = wr(p, node, dir); try testing.expectEqual(E.ISDIR, r.errno()); try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is a directory") != null); } try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); } test "a write of two lines to name is refused EINVAL, on a held open or not" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const node = Node.of(serial, .name); try testing.expectEqual(E.INVAL, wr(p, node, "/tmp/a\n/tmp/b\n").errno()); const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/a\n/tmp/b\n" }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); // Line by line, as bash writes it: the second line is refused. const h2 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-a\n" }).reply.status); try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-b\n" }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h2, .opened = true }); try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); } test "a name with a component over 255 bytes is refused, and a long path's failed Save keeps its reason" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const refused = wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 256 ++ "/f.txt\n"); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectEqualStrings("invalid file name: a component over 255 bytes", refused.reply.ename); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 255 ++ "\n").reply.status); // A Save of a 3000-byte path the host refuses: the waiting write's // reason is at the end, the path giving up its middle. // (Under /tmp: under / a user's Save is refused permission, not // missing.) const long = "/tmp/nonexistent-pardes-root/" ++ ("d" ** 200 ++ "/") ** 15 ++ "f.txt"; const id = p.paneBySerial(serial).?; try nameBuffer(p, id, long, false); p.fs.late_failure_len = 0; p.saveFailed(@intCast(id), long, error.AccessDenied); const late = p.fs.late_failure[0..p.fs.late_failure_len]; try testing.expect(std.mem.startsWith(u8, late, "Save /tmp/nonexistent-pardes-root/")); try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory")); } test "a name cut across writes is one name, applied once at its newline or its close" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); const node = Node.of(serial, .name); const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expect(h != 0); // A piece that fills its Twrite goes on in the next. p.fs.write_room = "/tmp/pardes-na".len; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" }); p.fs.write_room = 0; try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" }); try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); // No newline, the whole write: applied at once, on its own open. const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" }); try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true }); try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); } test "a replace through data, truncate then write, is one undo step" { const p = try withFile(testing.allocator, "one two\n"); defer p.deinit(); const serial = serialOf(p); _ = wr(p, Node.of(serial, .addr), "/two/"); _ = call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "2").reply.status); try testing.expectEqualStrings("one 2\n", rd(p, Node.of(serial, .body), 0, 64).bytes); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); try testing.expectEqualStrings("one two\n", rd(p, Node.of(serial, .body), 0, 64).bytes); } test "writes under mark 0 are one undo step, even with no history before them" { 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, Node.of(serial, .mark), "0").reply.status); _ = wr(p, body, "two\n"); _ = wr(p, body, "three\n"); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "1").reply.status); try testing.expectEqualStrings("one\ntwo\nthree\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\nthree\n", rd(p, body, 0, 64).bytes); } test "truncating the tag clears its editable tail" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const pane = p.panes[0].?; const tag = Node.of(pane.serial, .tag); _ = wr(p, tag, " Mine"); try testing.expect(tagline.curTail(pane).len > 0); const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); try testing.expectEqual(Status.ok, cleared.reply.status); // Done at the first read or write after it (or the close). _ = rd(p, tag, 0, 4096); try testing.expectEqualStrings("", pane.tag.own.?); try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null); // The tag keeps its undo: undoing in it brings back what was cleared. pardes.edit.doUndo(p, &pane.tag); try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine")); _ = call(p, .{ .tag = 2, .op = .setattr, .node = tag, .truncate = true }); _ = rd(p, tag, 0, 4096); try testing.expectEqualStrings("", pane.tag.own.?); // Written back without a leading space, the text still stands apart // from the path: the computed prefix brings the separator, so Look and // Exec find `Get`, not `test.txtGet`. _ = wr(p, tag, "Get"); try testing.expectEqualStrings("Get", pane.tag.own.?); const whole = rd(p, tag, 0, 4096).bytes; try testing.expectEqualStrings("/test.txt Get", whole); const word = config.wordBounds(whole, whole.len - "Get".len); try testing.expectEqualStrings("Get", whole[word.lo..word.hi]); // and a click on the G puts the tag's cursor on it const at = tagline.tagOffsetAt(p, pane, 0, "/test.txt ".len).?; try testing.expectEqualStrings("Get", whole[at.col..]); // `echo Make > tag`: the newline ending the truncating write's text is // dropped, one only; a newline between its writes stays. _ = call(p, .{ .tag = 3, .op = .release, .node = tag }); _ = call(p, .{ .tag = 4, .op = .setattr, .node = tag, .truncate = true }); _ = wr(p, tag, "Make\n"); try testing.expectEqualStrings("Make", pane.tag.own.?); _ = wr(p, tag, "More\n\n"); try testing.expectEqualStrings("Make\nMore\n", pane.tag.own.?); _ = call(p, .{ .tag = 5, .op = .release, .node = tag }); // `>>` keeps what it is given. _ = wr(p, tag, " Tail\n"); try testing.expectEqualStrings("Make\nMore\n Tail\n", pane.tag.own.?); } test "name reads the file name and writing it promotes a scratch without touching its body" { const gpa = testing.allocator; const p = try withFile(gpa, "opener\n"); defer p.deinit(); const serial = try th.newPane(p); const pane = p.panes[p.paneBySerial(serial).?].?; const name = Node.of(serial, .name); const body = Node.of(serial, .body); var scratch_name: [4200]u8 = undefined; const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(p, pane)}); try testing.expectEqualStrings(scratch_line, rd(p, name, 0, 4096).bytes); try testing.expectEqual(@as(u64, scratch_line.len), call(p, .{ .tag = 1, .op = .getattr, .node = name }).reply.attr.size); _ = wr(p, body, "scratch "); _ = wr(p, body, "work\n"); const undo_len = pane.file.?.history.undo_len; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "renamed.zig", .data = "existing target\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; var path_buffer: [4096]u8 = undefined; const path = try std.fmt.bufPrint(&path_buffer, "{s}/renamed.zig", .{directory}); var line: [4104]u8 = undefined; const renamed = wr(p, name, try std.fmt.bufPrint(&line, "{s}\n", .{path})); try testing.expectEqual(Status.ok, renamed.reply.status); try testing.expectEqual(false, renamed.watch.?); try testing.expectEqualStrings("scratch work\n", pane.file.?.content); try testing.expectEqual(undo_len, pane.file.?.history.undo_len); try testing.expect(pane.file.?.output == null and dirtyOf(pane)); try testing.expect(pane.file.?.watch_after_save); try testing.expectEqualStrings(path, std.mem.trimEnd(u8, rd(p, name, 0, 4096).bytes, "\n")); const target = try fs.readFile(gpa, path); defer gpa.free(target); try testing.expectEqualStrings("existing target\n", target); for ([_][]const u8{ "", "\n" }) |bad| try testing.expectEqual(E.INVAL, wr(p, name, bad).errno()); // One name, and what no file name holds: a second line, a NUL. for ([_][2][]const u8{ .{ "two\nlines\n", "a newline" }, .{ "nul\x00.zig\n", "a NUL" }, }) |c| { const refused = wr(p, name, c[0]); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "bad character in file name: "); try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null); } // Any other byte a file name holds is taken, and reads back as written: // blanks at its ends, a tab, a control byte, DEL, C1, bytes not UTF-8. for ([_][]const u8{ " lead.zig ", "tab\t.zig", "c\x01.zig", "del\x7f.zig", "c1\xc2\x85.zig", "bad\xff.zig" }) |odd| { try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "{s}\n", .{odd})).reply.status); const read_back = std.mem.trimEnd(u8, rd(p, name, 0, 4096).bytes, "\n"); try testing.expect(std.mem.endsWith(u8, read_back, odd)); var again: [4200]u8 = undefined; @memcpy(again[0..read_back.len], read_back); try testing.expectEqual(Status.ok, wr(p, name, again[0..read_back.len]).reply.status); try testing.expectEqualStrings(again[0..read_back.len], pane.file.?.path); } // A name that is one line and UTF-8 is taken, é and all. try testing.expectEqual(Status.ok, wr(p, name, "caf\xc3\xa9.zig\n").reply.status); try testing.expect(std.mem.endsWith(u8, pane.file.?.path, "/caf\xc3\xa9.zig")); try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "{s}\n", .{path})).reply.status); try testing.expectEqualStrings(path, pane.file.?.path); try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status); const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory}); try testing.expectEqualStrings(spaced, pane.file.?.path); } test "name refreshes cached syntax for unchanged contents" { if (!pardes.syntax.enabled) return error.SkipZigTest; pardes.syntax.start(testing.allocator); defer pardes.syntax.stop(); const p = try withFile(testing.allocator, "fn check() void {}\n"); defer p.deinit(); const pane = p.panes[0].?; panes.File.refreshHighlights(p); try testing.expectEqual(@as(usize, 0), pane.file.?.highlights.len); try testing.expectEqual(Status.ok, wr(p, Node.of(pane.serial, .name), "renamed.zig\n").reply.status); panes.File.refreshHighlights(p); try testing.expect(pane.file.?.highlights.len >= 2); try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[0]); try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[1]); try testing.expectEqualStrings("fn check() void {}\n", pane.file.?.content); } test "relative names use the file directory and can name a new target" { const gpa = testing.allocator; const p = try withFile(gpa, "retained body\n"); defer p.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from pane directory\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; const pane = p.panes[0].?; const name = Node.of(pane.serial, .name); const ctl_node = Node.of(pane.serial, .ctl); var line: [4140]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "{s}/old.txt\n", .{directory})).reply.status); try testing.expectEqual(Status.ok, wr(p, name, "child/../note.txt\n").reply.status); try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status); try testing.expectEqualStrings("from pane directory\n", pane.file.?.content); try testing.expectEqualStrings(directory, Pardes.paneDir(pane)); try testing.expectEqual(Status.ok, wr(p, name, "./created.txt\n").reply.status); var expected_buf: [4096]u8 = undefined; const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory}); try testing.expectEqualStrings(expected, pane.file.?.path); try testing.expect(!dirtyOf(pane)); // a name alone is no edit } test "relative names follow a pane/new scratch's session directory and virtual directories" { const p = try withFile(testing.allocator, "source body\n"); defer p.deinit(); const source = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/elsewhere/source.zig\n").reply.status); // pane/new's scratch is the session's, not the active pane's. const session = "/project/src"; @memcpy(p.session_dir[0..session.len], session); p.session_dir_len = session.len; const serial = try th.newPane(p); const scratch = p.panes[p.paneBySerial(serial).?].?; const name = Node.of(serial, .name); try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch)); try testing.expectEqual(Status.ok, wr(p, name, "../out/./notes.txt\n").reply.status); try testing.expectEqualStrings("/project/out/notes.txt", scratch.file.?.path); try testing.expectEqualStrings("/project/out", Pardes.paneDir(scratch)); var line: [256]u8 = undefined; try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "/virtual/pane/{d}/./body\n", .{source.serial})).reply.status); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "get\n").reply.status); try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content); const unchanged = wr(p, name, "./body\n"); try testing.expectEqual(Status.ok, unchanged.reply.status); try testing.expect(unchanged.watch == null); try testing.expect(!dirtyOf(scratch)); try testing.expect(!scratch.file.?.watch_after_save); } test "owned cwd name promotion releases the former directory" { const p = try th.withTerm(testing.allocator); defer p.deinit(); p.newScratchBelow(0); const id = p.active; const pane = p.panes[id].?; try pane.setOwnedCwd("/old/directory"); const result = wr(p, Node.of(pane.serial, .name), "saved.txt\n"); try testing.expectEqual(Status.ok, result.reply.status); try testing.expect(pane.cwd == .none); try testing.expect(pane.file.?.output == null); try testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); try testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } test "errors append to one +Errors buffer per directory" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const serial = serialOf(p); const live = for (p.panes) |slot| { if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q; } else null; try testing.expect(live == null); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "again\n").reply.status); var found: usize = 0; for (p.panes) |slot| { const q = slot orelse continue; const f = q.file orelse continue; const o = f.output orelse continue; if (std.meta.activeTag(o.from) != .errors) continue; found += 1; try testing.expectEqualStrings("boom\nagain\n", f.content); try testing.expectEqualStrings("/+Errors", f.path); } try testing.expectEqual(@as(usize, 1), found); } test "truncating data deletes only the addressed range, so > data replaces it" { const p = try withFile(testing.allocator, "hello world\nsecond line\n"); defer p.deinit(); const serial = serialOf(p); for ([_]PaneFile{ .data, .xdata }) |file| { _ = call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); _ = wr(p, Node.of(serial, .body), "hello world\nsecond line\n"); _ = wr(p, Node.of(serial, .addr), "#0,#5"); try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .setattr, .node = Node.of(serial, file), .truncate = true }).reply.status); try testing.expectEqualStrings(" world\nsecond line\n", p.panes[0].?.file.?.content); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, file), "HOWDY").reply.status); try testing.expectEqualStrings("HOWDY world\nsecond line\n", p.panes[0].?.file.?.content); } } test "setattr truncation empties the body and answers fresh attributes" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); try testing.expectEqual(Status.ok, a.reply.status); try testing.expectEqual(@as(u64, 0), a.reply.attr.size); try testing.expectEqualStrings("", p.panes[0].?.file.?.content); _ = wr(p, Node.of(serial, .body), "new text\n"); try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); } test "addresses are in runes: a combining mark and a lone \\r are places of their own" { const gpa = testing.allocator; // e, U+0301 (two bytes, 1-2), x, \r, \n const p = try withFile(gpa, "e\u{0301}x\r\n"); defer p.deinit(); const serial = serialOf(p); const addr_node = Node.of(serial, .addr); const dot = Node.of(serial, .dot); const Case = struct { expr: []const u8, q0: u32, q1: u32 }; for ([_]Case{ .{ .expr = "#1,#3", .q0 = 1, .q1 = 3 }, // the mark alone .{ .expr = "#2", .q0 = 1, .q1 = 1 }, // inside it: back to its start .{ .expr = "#4,#5", .q0 = 4, .q1 = 5 }, // the \r alone .{ .expr = "1:2", .q0 = 1, .q1 = 1 }, // line:col the same .{ .expr = "1:3", .q0 = 1, .q1 = 1 }, .{ .expr = "0/\xCC/", .q0 = 1, .q1 = 3 }, // a match inside a rune covers it }) |c| { try testing.expectEqual(Status.ok, wr(p, addr_node, c.expr).reply.status); try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0); try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1); } // Copied to dot, it reads back the same. _ = wr(p, addr_node, "#1,#3"); try testing.expectEqual(Status.ok, wr(p, dot, " 1 3 ").reply.status); p.sync(); try testing.expectEqualStrings(" 1 3 ", rd(p, dot, 0, 100).bytes); _ = wr(p, addr_node, "#4,#5"); try testing.expectEqualStrings("\r", rd(p, Node.of(serial, .xdata), 0, 100).bytes); } test "a limit write that fails keeps the limit a truncation cleared before it" { const gpa = testing.allocator; const p = try withFile(gpa, "one two\n"); defer p.deinit(); const limit = Node.of(serialOf(p), .limit); _ = wr(p, limit, "#0,#3"); _ = call(p, .{ .tag = 1, .op = .setattr, .node = limit, .truncate = true }); try testing.expectEqual(Status.err, wr(p, limit, "/nomatch/").reply.status); try testing.expectEqualStrings(" 0 3 ", rd(p, limit, 0, 100).bytes); // A truncation with no write after it clears it. _ = call(p, .{ .tag = 2, .op = .setattr, .node = limit, .truncate = true }); _ = call(p, .{ .tag = 3, .op = .release, .node = limit }); try testing.expectEqualStrings("", rd(p, limit, 0, 100).bytes); } test "a pane's tagexec runs a word as a click in its tag, and reads back what it touched" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); const serial = serialOf(p); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Msg from the tag\n").reply.status); try testing.expect(th.logHas(p, "from the tag")); var want: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, Node.of(serial, .tagexec), 0, 64).bytes); } test "index names a command's pane cmd, a shell's term" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "true\n").reply.status); const ran = p.panes[p.paneBySerial(p.fs.results[0]).?].?; try testing.expectEqualStrings("cmd", kindOf(ran)); const shell = try p.newShell(p.freeSlot().?, "/tmp"); try testing.expectEqualStrings("term", kindOf(shell)); }