summaryrefslogtreecommitdiff
path: root/src/ninep/pane.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/pane.zig')
-rw-r--r--src/ninep/pane.zig840
1 files changed, 840 insertions, 0 deletions
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
new file mode 100644
index 00000000..b37ae645
--- /dev/null
+++ b/src/ninep/pane.zig
@@ -0,0 +1,840 @@
+//! The files under /pane/<serial>/: name, body, tag, ctl, addr, data, xdata,
+//! sel, errors and event. Terminal panes add pty/ (pty.zig).
+const std = @import("std");
+const pardes = @import("../pardes.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 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 {
+ addr: Range = .{},
+ limit: ?Range = null,
+ readers: u16 = 0,
+ events: events.Queue = .{},
+ nomark: bool = false,
+ noscroll: bool = false,
+ tag_snap: std.ArrayList(u8) = .empty,
+ pty_readers: u16 = 0,
+ pty_out: events.Queue = .{},
+ /// Installed during this update; /log hears about it once the update ends
+ /// and the pane has its file, name and place.
+ unannounced: bool = false,
+
+ pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
+
+ pub fn deinit(pf: *State, gpa: std.mem.Allocator) void {
+ 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 p.tagText(p.scratch.allocator(), pane) catch "";
+}
+
+pub fn dirOf(pane: *Pane) []const u8 {
+ if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/";
+ const cwd = pane.cwdSlice();
+ return if (cwd.len > 0) cwd else "/";
+}
+
+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;
+ return f.revision != f.saved_revision;
+}
+
+/// The pane's name: its file, its document, or a terminal's directory.
+pub fn nameOf(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();
+ return if (cwd.len > 0) cwd else "/";
+}
+
+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";
+ return "term";
+}
+
+pub fn mtimeOf(p: *Pardes, pane: *const Pane) u32 {
+ if (pane.file) |*f| if (f.mtime != 0) return f.mtime;
+ return p.fs.started;
+}
+
+pub fn clip(n: usize) u32 {
+ return std.math.cast(u32, n) orelse std.math.maxInt(u32);
+}
+
+fn cellOf(row: i32, col: i32) modal.Cursor {
+ return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) };
+}
+
+pub fn dotOf(pane: *Pane) State.Range {
+ const text = bodyOf(pane);
+ const head = modal.offsetAt(text, cellOf(pane.cur_row, pane.cur_col));
+ if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
+ const anchor = modal.offsetAt(text, cellOf(pane.vsel.row, pane.vsel.col));
+ var hi = @max(head, anchor);
+ if (hi < text.len) hi = modal.nextGrapheme(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 = modal.positionAt(text, q0);
+ pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
+ pane.cur_row = @intCast(h.row);
+ pane.cur_col = @intCast(h.col);
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+}
+
+pub fn showOffset(pane: *Pane, off: usize) void {
+ const text = bodyOf(pane);
+ const c = modal.positionAt(text, @min(off, text.len));
+ pane.cur_row = @intCast(c.row);
+ pane.cur_col = @intCast(c.col);
+ pane.cur_pinned = true;
+ pane.sticky_col = -1;
+ pane.ensureCursorVisible();
+}
+
+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 shiftBy(r: State.Range, at: u32, removed: u32, inserted: u32) State.Range {
+ return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) };
+}
+
+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, id: usize, 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));
+ 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..]);
+ if (!p.fs.panes[id].nomark) panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, f, new);
+ return take;
+}
+
+// ---- sizes reported by stat ----
+
+pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
+ const pane = p.panes[id] orelse return 0;
+ return switch (f) {
+ .body, .data, .xdata => bodyOf(pane).len,
+ .tag => tagOf(p, pane).len,
+ .name => nameOf(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;
+ },
+ .dir, .addr, .errors, .event, .pty, .pty_ctl, .pty_status, .pty_data => 0,
+ };
+}
+
+// ---- the index ----
+
+pub fn stageIndexLine(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void {
+ out.print(p.gpa, "{d} {s} {d} {s}\n", .{ pane.serial, kindOf(pane), @intFromBool(dirtyOf(pane)), nameOf(pane) }) catch {};
+}
+
+pub fn indexLen(p: *Pardes) u64 {
+ var n: u64 = 0;
+ var last: u32 = 0;
+ while (tree.nextSerialAfter(p, last)) |serial| {
+ last = serial;
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ var digits: [16]u8 = undefined;
+ n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len;
+ n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1;
+ }
+ return n;
+}
+
+// ---- reads ----
+
+pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply {
+ const pf = &p.fs.panes[id];
+ return switch (file) {
+ .name => name: {
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, nameOf(pane)) catch {};
+ out.append(p.gpa, '\n') catch {};
+ break :name tree.stagedReply(p, req);
+ },
+ .body => readBody(p, req, id, pane),
+ .tag => tag: {
+ 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),
+ .addr => addr: {
+ clampAddr(pf, bodyOf(pane).len);
+ const out = p.fs.stage(p.gpa);
+ out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {};
+ break :addr tree.stagedReply(p, req);
+ },
+ .data => readData(req, id, pane, pf, false),
+ .xdata => readData(req, id, pane, pf, true),
+ .sel => readSel(req, id, pane),
+ .event => events.readQueue(p, req, &pf.events),
+ .pty_status => pty.readStatus(p, req, id, pane),
+ .pty_data => pty.readData(p, req, pf),
+ .dir, .errors, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM),
+ };
+}
+
+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),
+ } } };
+ }
+ 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 {
+ 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.graphemeStart(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.serial),
+ .addr => writeAddr(p, req, id, pane),
+ .data, .xdata => writeData(p, req, id, pane),
+ .sel => writeSel(p, req, id, pane),
+ .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),
+ .dir, .pty, .pty_status => Reply.fail(req.tag, E.PERM),
+ };
+}
+
+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) {
+ const take = wholeUtf8(req.data);
+ p.emitWrite(id, req.data[0..take]);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+ }
+ const at = bodyOf(pane).len;
+ const take = spliceBody(p, id, pane, at, at, req.data) orelse
+ return Reply.fail(req.tag, E.NOMEM);
+ if (!p.fs.panes[id].noscroll) showOffset(pane, at + take);
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ p.seedTail(pane);
+ const room = pane.tag_tail.len - pane.tag_tail_len;
+ if (room == 0) return Reply.fail(req.tag, E.NOSPC);
+ const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
+ @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]);
+ pane.tag_tail_len += take;
+ pane.tag_init = true;
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ const pf = &p.fs.panes[id];
+ clampAddr(pf, bodyOf(pane).len);
+ const q0: usize = pf.addr.q0;
+ const q1: usize = @max(q0, @as(usize, pf.addr.q1));
+ const before = dotOf(pane);
+ const take = spliceBody(p, id, pane, q0, q1, req.data) orelse
+ return Reply.fail(req.tag, E.NOMEM);
+ setDot(pane, shiftBy(before, 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, id: usize, pane: *Pane) Reply {
+ if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL);
+ const d = dotOf(pane);
+ const q0: usize = d.q0;
+ const q1: usize = @max(q0, @as(usize, d.q1));
+ const take = spliceBody(p, id, pane, q0, q1, req.data) orelse
+ return Reply.fail(req.tag, E.NOMEM);
+ setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) });
+ return .{ .tag = req.tag, .written = @intCast(take) };
+}
+
+fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const pf = &p.fs.panes[id];
+ const text = bodyOf(pane);
+ clampAddr(pf, text.len);
+ const expr = std.mem.trimEnd(u8, req.data, "\n");
+ var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr };
+ const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL);
+ if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL);
+ pf.addr = r;
+ 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.
+fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const name = std.mem.trim(u8, req.data, " \t\r\n");
+ if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
+ for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
+ if (fileOf(pane) == null) return Reply.fail(req.tag, E.PERM);
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
+ return Reply.fail(req.tag, E.NOMEM);
+ if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return Reply.fail(req.tag, E.INVAL);
+ 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;
+ for (full) |c| if (c < ' ') 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) {
+ pane.tag_init = false;
+ pane.tag_tail_len = 0;
+ }
+ f.saved_revision = f.revision -% 1;
+ 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;
+ p.invalidateLookHover(id);
+ p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ events.noteLog(p, .rename, pane);
+}
+
+/// 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 dir = dirOf(pane);
+ for (p.panes, 0..) |slot, i| {
+ 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, i, 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;
+ const np = panes.Output.open(p, free, dir, .errors, "", content) catch {
+ p.gpa.free(content);
+ return null;
+ };
+ p.placeDoc(id, free, np);
+ return text.len;
+}
+
+/// Truncation empties a body; every other setattr just answers fresh attributes.
+pub fn truncate(p: *Pardes, id: usize, pane: *Pane, file: PaneFile) tree.Status {
+ switch (file) {
+ .body, .data, .xdata => if (fileOf(pane) != null) {
+ _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse return .err;
+ p.fs.panes[id].addr = .{};
+ setDot(pane, .{});
+ },
+ 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 stat reports its length" {
+ 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;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}), a.bytes);
+ const stat = call(p, .{ .tag = 1, .op = .getattr, .node = index });
+ try testing.expectEqual(@as(u64, a.bytes.len), stat.reply.attr.size);
+ try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode);
+
+ pane.file.?.saved_revision = pane.file.?.revision -% 1;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 1 /test.txt\n", .{pane.serial}), rd(p, index, 0, 4096).bytes);
+ const mid = rd(p, index, 2, 4);
+ try testing.expectEqualStrings("text", mid.bytes);
+
+ const serial = try th.newPane(p);
+ const both = rd(p, index, 0, 4096);
+ try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p));
+ 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}\n", .{ serial, nameOf(made) })));
+}
+
+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.expectEqualStrings(
+ try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}),
+ rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes,
+ );
+ try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes);
+ try testing.expectEqual(E.PERM, wr(p, Node.of(pane.serial, .name), "/elsewhere\n").errno());
+}
+
+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);
+}
+
+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, pane.tag_tail[0..pane.tag_tail_len], " 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"));
+
+ pane.tag_tail_len = pane.tag_tail.len;
+ 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.fs.panes[0].addr.q0);
+ try testing.expectEqual(@as(u32, 3), p.fs.panes[0].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.fs.panes[0].addr.q0);
+}
+
+test "data never splits a grapheme, 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 ctl_node = Node.of(serial, .ctl);
+ const sel = Node.of(serial, .sel);
+
+ _ = wr(p, Node.of(serial, .addr), "#0,#3");
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\n").reply.status);
+ 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, ctl_node, "addr=dot\n");
+ try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0);
+ try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1);
+
+ try testing.expectEqual(Status.ok, wr(p, sel, "ONE").reply.status);
+ try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);
+ _ = wr(p, sel, "!");
+ try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 2, .op = .getattr, .node = sel }).reply.attr.size);
+}
+
+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(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\x01name\n" }) |bad|
+ try testing.expectEqual(E.INVAL, wr(p, name, bad).errno());
+ 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));
+}
+
+test "relative names follow inherited scratch 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), "/project/src/source.zig\n").reply.status);
+ 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 "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);
+}