diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p.zig | 2 | ||||
| -rw-r--r-- | src/9p_io.zig | 39 | ||||
| -rw-r--r-- | src/CHANGELOG.md | 10 | ||||
| -rw-r--r-- | src/effect_sources.zig | 1 | ||||
| -rw-r--r-- | src/fs-help.txt | 97 | ||||
| -rw-r--r-- | src/fs.zig | 3594 | ||||
| -rw-r--r-- | src/main.zig | 4 | ||||
| -rw-r--r-- | src/memory.zig | 5 | ||||
| -rw-r--r-- | src/ninep/addr.zig | 276 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 503 | ||||
| -rw-r--r-- | src/ninep/events.zig | 558 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 840 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 388 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 351 | ||||
| -rw-r--r-- | src/ninep/sources.zig | 152 | ||||
| -rw-r--r-- | src/ninep/testing.zig | 135 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 850 | ||||
| -rw-r--r-- | src/panes.zig | 7 | ||||
| -rw-r--r-- | src/pardes.zig | 34 | ||||
| -rw-r--r-- | src/tutor.txt | 25 |
20 files changed, 4243 insertions, 3628 deletions
@@ -894,7 +894,7 @@ pub fn Server(comptime fs: type, comptime fid_capacity: usize) type { .qid = qidOf(if (a.node != 0) a.node else f.node, a.dir), .mode = (if (a.dir) dmdir else 0) | @as(u32, a.mode), .atime = 0, - .mtime = 0, + .mtime = if (@hasField(@TypeOf(a), "mtime")) a.mtime else 0, .length = a.size, .name = f.name[0..f.name_len], .uid = who, diff --git a/src/9p_io.zig b/src/9p_io.zig index 943561eb..d0db1c86 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -138,7 +138,7 @@ fn localIp(address: std.Io.net.IpAddress) bool { return false; } -const Srv = ninep.Server(pardes.filesystem, ninep.max_fids); +const Srv = ninep.Server(pardes.ctlfs, ninep.max_fids); const Conn = struct { fd: c_int = -1, @@ -149,7 +149,7 @@ const Conn = struct { in: [msize]u8 = undefined, out: [2 * msize]u8 = undefined, - fn step(c: *Conn, core: *pardes.Pardes, req: pardes.filesystem.Req) void { + fn step(c: *Conn, core: *pardes.Pardes, req: pardes.ctlfs.Req) void { core.update(.{ .fs_req = req }); var answered = false; while (core.nextEffect()) |effect| { @@ -160,7 +160,7 @@ const Conn = struct { } else core.perform(effect); } if (!answered) { - const reply = pardes.filesystem.Reply.fail(req.tag, pardes.filesystem.E.IO); + const reply = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO); c.srv.reply(&reply, ""); } } @@ -226,7 +226,7 @@ pub const Listener = struct { c.srv = .init(.{ .in = &c.in, .out = &c.out, - .root = pardes.filesystem.namespace_root, + .root = pardes.ctlfs.root, }); } } @@ -246,7 +246,7 @@ pub const Listener = struct { c.quic = connection; c.draining = false; c.accepted_ms = nowMs(); - c.srv = .init(.{ .in = &c.in, .out = &c.out, .root = pardes.filesystem.namespace_root }); + c.srv = .init(.{ .in = &c.in, .out = &c.out, .root = pardes.ctlfs.root }); }; } } @@ -748,17 +748,17 @@ test "Unix TCP and QUIC share one listener through reads writes reconnects and r defer testing.allocator.free(before); try testing.expectEqualStrings(w.expected, before); try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, w.body_path, w.body_path, w.expected.len - 1)); - const listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", 128); + const listing = try Client.readLimit(testing.allocator, w.dial, "/pane", "/pane", 128); defer testing.allocator.free(listing); - const exact_listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len); + const exact_listing = try Client.readLimit(testing.allocator, w.dial, "/pane", "/pane", listing.len); defer testing.allocator.free(exact_listing); try testing.expectEqualStrings(listing, exact_listing); - try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len - 1)); + try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, "/pane", "/pane", listing.len - 1)); try Client.write(testing.allocator, w.dial, w.body_path, w.replacement); const after = try Client.read(testing.allocator, w.dial, w.body_path, w.body_path); defer testing.allocator.free(after); try testing.expectEqualStrings(w.replacement, after); - const screen = try Client.read(testing.allocator, w.dial, "/self/screen", "/self/screen"); + const screen = try Client.read(testing.allocator, w.dial, "/screen", "/screen"); defer testing.allocator.free(screen); const parsed = try std.json.parseFromSlice(struct { cols: u16, rows: u16 }, testing.allocator, screen, .{ .ignore_unknown_fields = true }); defer parsed.deinit(); @@ -790,7 +790,7 @@ test "Unix TCP and QUIC share one listener through reads writes reconnects and r var dial_buf: [64]u8 = undefined; const network_dial = try std.fmt.bufPrint(&dial_buf, "{s}!{s}!{d}", .{ protocol, host, port }); var body_buf: [64]u8 = undefined; - const body = try std.fmt.bufPrint(&body_buf, "/self/pane/{d}/body", .{pane.serial}); + const body = try std.fmt.bufPrint(&body_buf, "/pane/{d}/body", .{pane.serial}); for ([_][]const u8{ network_dial, l.path(), network_dial }, 0..) |dial, attempt| { var worker: Worker = .{ .dial = dial, .body_path = body, .expected = if (attempt == 0) "initial\n" else replacement, .replacement = replacement }; const thread = try std.Thread.spawn(.{}, Worker.run, .{&worker}); @@ -1245,15 +1245,14 @@ pub const Client = struct { test "a path becomes walk elements, normalised the way a shell would" { var out: [max_depth][]const u8 = undefined; - try testing.expectEqual(@as(usize, 4), try elements("/self/pane/1/body", &out)); - try testing.expectEqualStrings("self", out[0]); - try testing.expectEqualStrings("pane", out[1]); - try testing.expectEqualStrings("1", out[2]); - try testing.expectEqualStrings("body", out[3]); + try testing.expectEqual(@as(usize, 3), try elements("/pane/1/body", &out)); + try testing.expectEqualStrings("pane", out[0]); + try testing.expectEqualStrings("1", out[1]); + try testing.expectEqualStrings("body", out[2]); - try testing.expectEqual(@as(usize, 4), try elements("self/pane/1/body", &out)); - try testing.expectEqual(@as(usize, 4), try elements("//self//pane//1//body//", &out)); - try testing.expectEqual(@as(usize, 2), try elements("/self/index", &out)); + try testing.expectEqual(@as(usize, 3), try elements("pane/1/body", &out)); + try testing.expectEqual(@as(usize, 3), try elements("//pane//1//body//", &out)); + try testing.expectEqual(@as(usize, 1), try elements("/index", &out)); try testing.expectEqual(@as(usize, 0), try elements("/", &out)); @@ -1284,12 +1283,12 @@ pub const Client = struct { test "a dial with nothing listening is one error and not a wait" { if (comptime !supported) return error.SkipZigTest; var names: [max_depth][]const u8 = undefined; - const n = try elements("/self/pane/1/body", &names); + const n = try elements("/pane/1/body", &names); var remote: RemoteError = .{}; const before = nowMs(); try testing.expectError( Error.Dial, - fetchBytes(testing.allocator, .{ .unix = "/tmp/pardes-9p-no-such-socket.sock" }, names[0..n], &remote, null, "/self/pane/1/body", limits.max_file_bytes), + fetchBytes(testing.allocator, .{ .unix = "/tmp/pardes-9p-no-such-socket.sock" }, names[0..n], &remote, null, "/pane/1/body", limits.max_file_bytes), ); try testing.expect(nowMs() - before < budget_ms); } diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 015f726b..18bcceb9 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -2,6 +2,16 @@ ## 0.0.3 +- Flatten the 9P control tree: `self/` is gone, so `/index`, `/pane/<n>/`, + `/screen` and `/os` sit at the root. `/new` is one clone file (reading it + makes a pane and answers its serial), `/ctl` takes `look TEXT` and + `exec TEXT` in the editor's own command language and answers the serials a + command touched, `/log` streams pane events, and each pane gains `name` and + `sel` (replacing `rdsel`/`wrsel`); `cons`, `new/` and the `name`, `put`, + `del` and `delete` ctl verbs are gone in favour of `name` and `exec`. Stats + carry real lengths, modes and mtimes. Sources are served under `/src` only + with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to + `src/ninep/`. - Add pinned tree-sitter declaration context, with optional compact tagline styling and separators between discontinuous source lines. - Configure search and LSP result context with `LocationsConfig`. Results use diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 3ca15c3f..08680860 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -79,6 +79,7 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { } test "EffectCode paths resolve uniquely to this backend's archived inputs" { + if (!@import("memory.zig").limits.embedded_sources) return error.SkipZigTest; for (config.Runtime.settings) |setting| { const available = switch (setting.action) { .transition => backend == .tty or backend == .gui or backend == .macos, diff --git a/src/fs-help.txt b/src/fs-help.txt index eeb000cc..a0a84b28 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -1,65 +1,42 @@ Pardes control filesystem ========================= -The mount root contains self/ (this editor) and os/ (host files). -This guide is available as self/README and self/new/README. -Examples below run inside a Tty9p shell, where PARDES_MOUNT is set. +README this guide +index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name +ctl write commands; read the serials the last command made or touched +new reading it creates one empty pane and answers its serial +log one line per editor event (new/del/rename/save <serial> <name>); reads wait +screen rendered screen as JSON, frozen from open to close +listeners the session's dial addresses +pane/<n>/ name body tag ctl addr data xdata sel errors event, and pty/ for terminals +os/ the host filesystem +src/ the editor's own sources, only in a -Dembed-sources=true build -Explore -------- - ls "$PARDES_MOUNT/self" - cat "$PARDES_MOUNT/self/index" - ls -l "$PARDES_MOUNT/self/new" - ls "$PARDES_MOUNT/self/pane" +Commands, on /ctl (applied at the active pane) or on pane/<n>/ctl (at that pane): + look TEXT a right click: open a file, a file:12 location, a directory, a URL + exec TEXT a middle click: an editor command word (Save Del New Newcol Mount Unmount + Dump Restore Msg Find Grep Tty ...), or a shell command for the pane's terminal +Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too): -index lists panes: serial, tag length, body length, reserved zero, -modified flag, then tag text. Use the serial in self/pane/<serial>/. -screen is rendered screen JSON; listeners lists the session addresses. -cons accepts text to display in the editor. + cat $m/index which panes exist + n=$(cat $m/new) make a pane, remember its serial + echo 'look /etc/hosts:3' > $m/ctl; cat $m/ctl open a file, see the pane it went to + printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first) + cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename + echo 'exec Save' > $m/pane/$n/ctl save it (exec Del closes it) + echo 'exec Msg hello' > $m/ctl show text in the editor + echo '#0,#5' > $m/pane/$n/addr; echo NEW > $m/pane/$n/data replace bytes 0..5 + echo 'dot=addr' > $m/pane/$n/ctl; cat $m/pane/$n/sel select the range, read it + cat $m/log block until a pane is made, renamed, saved or closed + cat $m/pane/$n/event hold it open to own that pane's clicks + echo 'exec' > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT -Create a pane -------------- -new/ lists the same files as a scratch pane, plus this README. -Listing, walking and statting these paths do not create anything. -OPENING any file except README creates one scratch pane below the active pane. -Each open creates a separate pane, even if you open the same path again. - - cat "$PARDES_MOUNT/self/new/ctl" - -This creates a pane and prints its control information, beginning with its -serial. Use self/pane/<serial>/ for all subsequent operations on that pane. -To create a pane and write its initial text in one operation: - - printf 'Hello from the shell\n' > "$PARDES_MOUNT/self/new/body" - -Reading all files recursively would open factories and create panes. -Use ls or stat to inspect them; read README for this guide. - -Work with an existing pane --------------------------- -Replace <serial> with a number from index or new/ctl: - - cat "$PARDES_MOUNT/self/pane/<serial>/body" - printf 'Replacement text\n' > "$PARDES_MOUNT/self/pane/<serial>/body" - printf 'More text\n' >> "$PARDES_MOUNT/self/pane/<serial>/body" - printf 'name notes.txt\n' > "$PARDES_MOUNT/self/pane/<serial>/ctl" - -body buffer text; for terminals, reads history and writes child input - tag editable pane tag - ctl control information on read; newline-separated commands on write - addr address range; write #0,#5 to select the first five bytes - data read from addr onward, or replace the range selected by addr - xdata read only the range selected by addr; writes replace that range - rdsel read the editor selection - wrsel replace the editor selection - errors write diagnostics - event pane interaction events; opening intercepts Look/Exec events - -Common ctl commands: name PATH, put (save), get (reload), dot=addr, -addr=dot, look PATH, del (close, refusing dirty text), delete (force close). -Reload and force close can discard unsaved changes. - -Terminal panes also expose pty/ctl, pty/status and pty/data. -Writing pty/data sends terminal input; use carriage return to submit a command. -Reading pty/data streams new output; read body for a history snapshot. - -Embedded source files under self/src/ document the complete interface. +Pitfalls, one each: + Every open of new makes another pane, so `cat $m/new` twice makes two. + ls, stat and find create nothing; reading log, event or pty/data blocks until something happens. + exec Del closes a pane even when it is dirty; index shows the dirty flag first. + look and exec are lowercase verbs; the words after exec are the editor's capitalized commands. + ctl also takes acme's addr verbs (addr=dot dot=addr limit=addr clean dirty get show ...), one per line. + Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences. + A terminal's body is a history snapshot frozen per open; pty/data is the live stream. + screen is frozen per open handle as well; reopen it for a fresh frame. + A command that fails is reported in the editor, not as a write error; a malformed line fails the write. @@ -9,6 +9,22 @@ const panes = @import("panes.zig"); const mvzr = @import("mvzr"); const modal = @import("modal.zig"); const look = @import("look.zig"); +pub const tree = @import("ninep/tree.zig"); +const sources_fs = @import("ninep/sources.zig"); +const th = @import("ninep/testing.zig"); + +const Pardes = pardes.Pardes; +const MAX_PANES = pardes.MAX_PANES; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; + +/// The served README; tree.zig answers it. +pub const help = @embedFile("fs-help.txt"); + +/// Host filesystem nodes: the root, and one hashed node per path a client walked to. +pub const os_root: u64 = (1 << 63) + 2; +pub const os_node: u64 = 1 << 62; pub const name_capacity: usize = 255; @@ -18,8 +34,6 @@ pub const sources: []const Source = if (limits.embedded_sources) else &.{}; const source_files = [_]Source{ - .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, - .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, @@ -86,101 +100,6 @@ pub const Mount = struct { name: []const u8, dial: []const u8 }; pub const max_mounts = 8; pub const Resolved = struct { path: []const u8, dir: bool = false }; pub const OsPath = struct { node: u64, path: []const u8 }; -pub const archive_node: u64 = 1 << 61; - -pub fn archiveNode(path: []const u8) ?u64 { - if (path.len == 0) return archive_node; - if (path.len >= 4096) return null; - for (sources, 0..) |source, i| { - if (std.mem.eql(u8, source.path, path) or - (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path))) - return archive_node | (@as(u64, i) << 12) | path.len; - } - return null; -} - -fn archiveInfo(node: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } { - if (node == archive_node) return .{ .path = "", .contents = "", .dir = true }; - const index = (node & ~archive_node) >> 12; - if (index >= sources.len) return null; - const source = sources[@intCast(index)]; - const len: usize = @intCast(node & 4095); - if (len > source.path.len) return null; - return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len }; -} - -pub fn stageArchive(p: *pardes.Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void { - var seen: [sources.len][]const u8 = undefined; - var count: usize = 0; - for (sources, 0..) |source, i| { - if (path.len != 0 and - (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue; - const start = if (path.len == 0) 0 else path.len + 1; - const rest = source.path[start..]; - const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len; - const name = rest[0..len]; - var duplicate = false; - for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) { - duplicate = true; - break; - }; - if (duplicate) continue; - seen[count] = name; - count += 1; - if (skip.* > 0) { - skip.* -= 1; - continue; - } - const node = archive_node | (@as(u64, i) << 12) | (start + len); - stageDirent(out, p.gpa, node, len < rest.len, name); - } -} - -pub fn archiveHandle(p: *pardes.Pardes, req: Req) Reply { - const info = archiveInfo(req.node) orelse return Reply.fail(req.tag, E.NOENT); - const attr: Reply.Attr = .{ - .node = req.node, - .name = std.fs.path.basename(info.path), - .dir = info.dir, - .size = if (info.dir) 0 else info.contents.len, - .mode = if (info.dir) 0o500 else 0o400, - }; - switch (req.op) { - .getattr => return .{ .tag = req.tag, .attr = attr }, - .open => return .{ .tag = req.tag, .handle = 1 }, - .release => return .{ .tag = req.tag }, - .lookup => { - if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); - if (std.mem.eql(u8, req.data, "..")) { - const parent = std.fs.path.dirname(info.path) orelse ""; - if (parent.len == 0) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); - const node = archiveNode(parent) orelse return Reply.fail(req.tag, E.NOENT); - return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); - } - if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); - var buf: [4096]u8 = undefined; - const path = if (info.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ info.path, req.data }) catch return Reply.fail(req.tag, E.NOENT); - const node = archiveNode(path) orelse return Reply.fail(req.tag, E.NOENT); - return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); - }, - .readdir => { - if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); - const out = p.fs.stage(p.gpa); - var skip = req.off; - stageArchive(p, out, info.path, &skip); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; - }, - .read => { - if (info.dir) return Reply.fail(req.tag, E.PERM); - const off: usize = @intCast(@min(req.off, info.contents.len)); - const bytes = info.contents[off..][0..@min(req.size, info.contents.len - off)]; - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; - }, - else => return Reply.fail(req.tag, E.PERM), - } -} fn osPath(p: *pardes.Pardes, node: u64) ?[]const u8 { if (node == os_root) return "/"; @@ -208,7 +127,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { if (req.op == .release) return .{ .tag = req.tag }; const io = std.Io.Threaded.global_single_threaded.io(); const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); - const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o500 else 0o600 }; + const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o755 else 0o644, .mtime = std.math.cast(u32, stat.mtime.toSeconds()) orelse 0 }; switch (req.op) { .getattr => return .{ .tag = req.tag, .attr = attr }, .open => { @@ -218,7 +137,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { .lookup => { if (stat.kind != .directory) return Reply.fail(req.tag, E.NOTDIR); if (req.node == os_root and std.mem.eql(u8, req.data, "..")) - return handle(p, .{ .tag = req.tag, .op = .getattr, .node = namespace_root }); + return tree.handle(p, .{ .tag = req.tag, .op = .getattr, .node = tree.root }); if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); var buf: [4096]u8 = undefined; const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), req.data }) catch return Reply.fail(req.tag, E.NOENT); @@ -244,7 +163,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { continue; } const node = if (std.mem.eql(u8, normalized.path, "/")) os_root else os_node | (std.hash.Wyhash.hash(0, normalized.path) & (os_node - 1)); - stageDirent(out, p.gpa, node, child.kind == .directory, entry.name); + tree.stageDirent(out, p.gpa, node, child.kind == .directory, entry.name); if (out.items.len >= @max(req.size, 512)) break; } return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; @@ -362,10 +281,16 @@ test "virtual writes enforce permissions even when contents are empty" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); for ([_][]const u8{ "", "bytes" }) |bytes| { - for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path| + for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/README", "/n/self/log" }) |path| try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); - try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes)); + try std.testing.expectError(error.IsDirectory, write(p, "/virtual/pane", bytes)); try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes)); + try std.testing.expectError(error.FileNotFound, write(p, "/virtual/cons", bytes)); + if (limits.embedded_sources) { + for ([_][]const u8{ "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path| + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); + try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes)); + } else try std.testing.expectError(error.FileNotFound, write(p, "/virtual/src/fs.zig", bytes)); } for (p.fs.snapshots) |snapshot| try std.testing.expect(snapshot.node == 0); } @@ -385,8 +310,8 @@ test "direct self reads and writes use the same dot paths as Look" { try std.testing.expectEqualStrings(pane.file.?.content, bytes); var resolved: [4096]u8 = undefined; const canonical = resolve(p, path, "/", &resolved).?; - try std.testing.expectEqual(resolveSelf(p, canonical.path[9..]), resolveSelf(p, path[root.len..])); - try std.testing.expectEqual(@as(?u64, @intFromEnum(SelfFile.root)), resolveSelf(p, "./pane/../../")); + try std.testing.expectEqual(tree.resolveSelf(p, canonical.path[9..]), tree.resolveSelf(p, path[root.len..])); + try std.testing.expectEqual(@as(?u64, tree.root), tree.resolveSelf(p, "./pane/../../")); const index_path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/../index", .{root}); try std.testing.expectError(error.ReadOnlyFilesystem, write(p, index_path, "")); const index = try read(p, index_path); @@ -397,7 +322,7 @@ test "direct self reads and writes use the same dot paths as Look" { @memcpy(overlong[0..9], "/virtual/"); var resolved: [4096]u8 = undefined; try std.testing.expect(resolve(p, &overlong, "/", &resolved) == null); - try std.testing.expect(resolveSelf(p, overlong[9..]) == null); + try std.testing.expect(tree.resolveSelf(p, overlong[9..]) == null); try std.testing.expectError(error.FileNotFound, write(p, &overlong, "")); } @@ -462,8 +387,8 @@ test "bounded reads apply the same limits to self bodies and embedded sources" { contents[contents.len - 1] = '\n'; for ([_][]const u8{ &contents, "" }) |expected| { const pane = try p.setTestFile(expected); - for ([_][]const u8{ "/virtual", "/n/self", "/n/own/self" }) |prefix| { - if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own/self")) continue; + for ([_][]const u8{ "/virtual", "/n/self", "/n/own" }) |prefix| { + if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own")) continue; var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/pane/{d}/body", .{ prefix, pane.serial }); const bytes = try readLimit(p, path, expected.len); @@ -478,7 +403,7 @@ test "bounded reads apply the same limits to self bodies and embedded sources" { } if (limits.embedded_sources) { const source = findEmbeddedSource("src/look.zig", false).?; - for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/self/src/look.zig" }) |path| { + for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/src/look.zig" }) |path| { if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own/")) continue; const bytes = try readLimit(p, path, source.contents.len); defer gpa.free(bytes); @@ -498,7 +423,7 @@ test "bounded reads count rendered directory paths and unknown self lengths" { defer p.deinit(); p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; try p.fs.mount(gpa, "own", p.fs.socket_path); - for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/self", "/virtual/listeners" }) |path| { + for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/pane", "/virtual/listeners", "/virtual/README" }) |path| { if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own")) continue; const expected = try read(p, path); defer gpa.free(expected); @@ -515,10 +440,10 @@ test "bounded reads count rendered directory paths and unknown self lengths" { test "bounded reads release terminal snapshots after success and size refusal" { if (!pardes.terminal_panes) return error.SkipZigTest; const gpa = std.testing.allocator; - const p = try withTerm(gpa); + const p = try th.withTerm(gpa); defer p.deinit(); var path_buf: [128]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{serialOf(p)}); + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{th.serialOf(p)}); const empty = try readLimit(p, path, 0); defer gpa.free(empty); try std.testing.expectEqual(@as(usize, 0), empty.len); @@ -626,9 +551,9 @@ test "virtual body writes can read their input from the same pane" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("same pane contents\n"); - const event_node = Node.of(pane.serial, .event); - _ = handle(p, .{ .tag = 0, .op = .open, .node = event_node }); - defer _ = handle(p, .{ .tag = 0, .op = .release, .node = event_node }); + const event_node = tree.Node.of(pane.serial, .event); + _ = tree.handle(p, .{ .tag = 0, .op = .open, .node = event_node }); + defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = event_node }); var path_buffer: [64]u8 = undefined; const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial}); try write(p, path, pane.file.?.content); @@ -654,11 +579,10 @@ test "same-core mount directories preserve their mount prefix across reads" { try p.fs.mount(gpa, "own", socket_path); const roots = try read(p, "/n/own"); defer gpa.free(roots); - try std.testing.expectEqualStrings("/n/own/os/\n/n/own/self/\n", roots); - const self = try read(p, "/n/own/self"); - defer gpa.free(self); - try std.testing.expect(std.mem.indexOf(u8, self, "/n/own/self/index\n") != null); - try std.testing.expectError(error.NotADirectory, read(p, "/n/own/self/index/..")); + try std.testing.expect(std.mem.startsWith(u8, roots, "/n/own/README\n/n/own/index\n")); + try std.testing.expect(std.mem.indexOf(u8, roots, "/n/own/pane/\n/n/own/os/\n") != null); + try std.testing.expectError(error.NotADirectory, read(p, "/n/own/index/..")); + try std.testing.expectError(error.FileNotFound, read(p, "/n/own/self/index")); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -767,11 +691,11 @@ pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved { fn resolveVirtual(p: ?*pardes.Pardes, path: []const u8, out: *[4096]u8) ?Resolved { var normalized_buf: [4096]u8 = undefined; const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return null; - if (p) |core| if (resolveSelf(core, normalized) != null) + if (p) |core| if (tree.resolveSelf(core, normalized) != null) return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; if (findEmbeddedSource(normalized, false)) |source| return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null }; - if (archiveNode(normalized) != null) + if (sources_fs.node(normalized) != null) return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; return null; } @@ -793,7 +717,7 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { } return out.toOwnedSlice(); } - if (std.mem.eql(u8, path, "/virtual")) return readNode(p, @intFromEnum(SelfFile.root), path, limit); + if (std.mem.eql(u8, path, "/virtual")) return readNode(p, tree.root, path, limit); if (std.mem.startsWith(u8, path, "/n/")) { const explicit = path[3..]; const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; @@ -812,7 +736,7 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { return readNode(p, node, path, limit); } if (std.mem.eql(u8, name, "self")) { - const node = resolveSelf(p, remote_path) orelse return error.FileNotFound; + const node = tree.resolveSelf(p, remote_path) orelse return error.FileNotFound; return readNode(p, node, path, limit); } for (p.fs.mounts.items) |mount| { @@ -823,13 +747,13 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); p.fs.os_paths.shrinkRetainingCapacity(saved_paths); } - var node = namespace_root; + var node = tree.root; var directory = true; var parts = std.mem.tokenizeScalar(u8, remote_path, '/'); while (parts.next()) |part| { if (!directory) return error.NotADirectory; if (std.mem.eql(u8, part, ".")) continue; - const reply = handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part }); + const reply = tree.handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part }); if (reply.status != .ok) return error.FileNotFound; node = reply.attr.node; directory = reply.attr.dir; @@ -842,14 +766,14 @@ pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { } if (std.mem.startsWith(u8, path, "/virtual/")) { const name = path[9..]; - if (resolveSelf(p, name)) |node| return readNode(p, node, path, limit); + if (tree.resolveSelf(p, name)) |node| return readNode(p, node, path, limit); } return readFileLimit(p.gpa, path, limit); } fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize) ![]u8 { var node = initial_node; - const attr = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); + const attr = tree.handle(p, .{ .tag = 0, .op = .getattr, .node = node }); if (attr.status != .ok) return error.FileNotFound; if (attr.attr.dir) { var out: std.Io.Writer.Allocating = .init(p.gpa); @@ -858,7 +782,7 @@ fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize const max_bytes = @min(limit, limits.max_stream_bytes); const prefix = std.mem.trimEnd(u8, path, "/"); while (true) { - const reply = handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 }); + const reply = tree.handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 }); if (reply.status != .ok) return error.ReadFailed; const entries = p.fsPayload(reply); if (entries.len == 0) return out.toOwnedSlice(); @@ -878,15 +802,15 @@ fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize } } if (attr.attr.size > limit) return error.FileTooLarge; - const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); + const opened = tree.handle(p, .{ .tag = 0, .op = .open, .node = node }); if (opened.status != .ok) return error.OpenFailed; if (opened.attr.node != 0) node = opened.attr.node; - defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); + defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); while (true) { const want: u32 = @intCast(@min(8192, limit + 1 - out.written().len)); - const reply = handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want }); + const reply = tree.handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want }); if (reply.status == .again) return error.NotAFile; if (reply.status != .ok) return error.ReadFailed; const bytes = p.fsPayload(reply); @@ -923,27 +847,27 @@ pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void { } } if (self_path) |name| { - var node = resolveSelf(p, name) orelse return error.FileNotFound; - const attributes = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); + var node = tree.resolveSelf(p, name) orelse return error.FileNotFound; + const attributes = tree.handle(p, .{ .tag = 0, .op = .getattr, .node = node }); if (attributes.status != .ok) return error.FileNotFound; if (attributes.attr.dir) return error.IsDirectory; if (attributes.attr.mode & 0o200 == 0) return error.ReadOnlyFilesystem; - const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); + const opened = tree.handle(p, .{ .tag = 0, .op = .open, .node = node }); if (opened.status != .ok) return error.OpenFailed; if (opened.attr.node != 0) node = opened.attr.node; - defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); + defer _ = tree.handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); var preserved: ?[]u8 = null; defer if (preserved) |copy| p.gpa.free(copy); - const target = Node.target(node); + const target = tree.Node.target(node); if (target != null and target.? == .pane and target.?.pane.file == .body) { preserved = try p.gpa.dupe(u8, bytes); - const trunc = handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true }); + const trunc = tree.handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true }); if (trunc.status != .ok) return error.WriteFailed; } const contents = preserved orelse bytes; var off: usize = 0; while (off < contents.len) { - const reply = handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] }); + const reply = tree.handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] }); if (reply.status != .ok or reply.written == 0) return error.WriteFailed; off += reply.written; } @@ -964,7 +888,7 @@ fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?Sourc return findEmbeddedSource(normalized_word, false); } -fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { +pub fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { if (std.mem.indexOfScalar(u8, path, 0) != null) return null; var len: usize = 0; var parts = std.mem.tokenizeAny(u8, path, "/\\"); @@ -1322,361 +1246,6 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { return buf; } -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const MAX_PANES = pardes.MAX_PANES; - -pub const namespace_root: u64 = 1 << 63; -pub const namespace_panes: u64 = namespace_root + 1; -pub const os_root: u64 = namespace_root + 2; -pub const os_node: u64 = 1 << 62; -const factory_base: u64 = namespace_root + 256; -const fs_help = @embedFile("fs-help.txt"); - -pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { - var path_buf: [4096]u8 = undefined; - const normalized = normalizeVirtualPath(path, &path_buf) orelse return null; - var parts = std.mem.tokenizeScalar(u8, normalized, '/'); - const first = parts.next() orelse return @intFromEnum(SelfFile.root); - if (!std.mem.eql(u8, first, "pane")) { - const top = topFileNamed(first) orelse return archiveNode(normalized); - if (parts.next()) |name| { - if (top != .new or parts.next() != null) return null; - if (std.mem.eql(u8, name, "README")) return @intFromEnum(SelfFile.README); - const file = paneFileNamed(name) orelse return null; - if (file.inPty()) return null; - return factory_base + @intFromEnum(file); - } - return @intFromEnum(top); - } - const serial = serialNamed(parts.next() orelse return namespace_panes) orelse return null; - const id = p.paneBySerial(serial) orelse return null; - const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null; - if (file.inPty() and !p.panes[id].?.isTerminal()) return null; - if (parts.next()) |name| { - if (file != .pty) return null; - const child = ptyFileNamed(name) orelse return null; - if (parts.next() != null) return null; - return Node.of(serial, child); - } - return Node.of(serial, file); -} - -fn namespace(p: *Pardes, req: Req) Reply { - switch (req.op) { - .getattr => return .{ .tag = req.tag, .attr = .{ .node = req.node, .dir = true, .mode = 0o500 } }, - .open => return .{ .tag = req.tag, .handle = 1 }, - .release => return .{ .tag = req.tag }, - .lookup => { - if (std.mem.eql(u8, req.data, "..")) return handle(p, .{ - .tag = req.tag, - .op = .getattr, - .node = if (req.node == namespace_root) namespace_root else @intFromEnum(SelfFile.root), - }); - if (req.node == namespace_root) { - if (std.mem.eql(u8, req.data, "self")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); - if (std.mem.eql(u8, req.data, "os")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = os_root }); - } else { - const serial = serialNamed(req.data) orelse return Reply.fail(req.tag, E.NOENT); - if (serial == 0) return Reply.fail(req.tag, E.NOENT); - return handle(p, .{ .tag = req.tag, .op = .getattr, .node = Node.of(serial, .dir) }); - } - return Reply.fail(req.tag, E.NOENT); - }, - .readdir => { - const out = p.fs.stage(p.gpa); - var skip = req.off; - if (req.node == namespace_root) { - if (skip == 0) stageDirent(out, p.gpa, os_root, true, "os") else skip -= 1; - if (skip == 0) stageDirent(out, p.gpa, @intFromEnum(SelfFile.root), true, "self"); - } else { - var last: u32 = 0; - while (nextSerialAfter(p, last)) |serial| { - last = serial; - if (skip > 0) { - skip -= 1; - continue; - } - var buf: [16]u8 = undefined; - const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; - stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); - } - } - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; - }, - else => return Reply.fail(req.tag, E.PERM), - } -} - -pub const Op = enum(u8) { - lookup, - getattr, - setattr, - open, - read, - write, - release, - readdir, -}; - -pub const Status = enum(u8) { - ok, - again, - err, -}; - -pub const Req = struct { - tag: u64, - op: Op, - node: u64, - handle: u32 = 0, - off: u64 = 0, - size: u32 = 0, - data: []const u8 = &.{}, - truncate: bool = false, - - pub fn changesPane(req: Req) bool { - return switch (req.op) { - .write, .setattr => true, - .lookup => false, - .open => req.node >= factory_base and req.node < factory_base + 16, - .getattr, .read, .release, .readdir => false, - }; - } -}; - -pub const Reply = struct { - tag: u64, - status: Status = .ok, - errno: u16 = 0, - attr: Attr = .{}, - handle: u32 = 0, - payload: Payload = .none, - written: u32 = 0, - - pub const Attr = struct { - name: []const u8 = "", - node: u64 = 0, - dir: bool = false, - size: u64 = 0, - mode: u16 = 0o600, - }; - - pub const Payload = union(enum) { - none, - staged: u32, - region: struct { pane: u8, serial: u32, off: u32, len: u32 }, - }; - - pub fn fail(tag: u64, e: u16) Reply { - return .{ .tag = tag, .status = .err, .errno = e }; - } -}; - -pub const E = struct { - pub const PERM: u16 = 1; - pub const NOENT: u16 = 2; - pub const IO: u16 = 5; - pub const NOMEM: u16 = 12; - pub const NOTDIR: u16 = 20; - pub const INVAL: u16 = 22; - pub const NFILE: u16 = 23; - pub const NOSPC: u16 = 28; - pub const NOSYS: u16 = 38; -}; - -pub const PaneFile = enum(u4) { - dir = 0, - addr, - body, - ctl, - data, - errors, - event, - tag, - xdata, - rdsel, - wrsel, - pty, - pty_ctl, - pty_status, - pty_data, - - pub fn name(f: PaneFile) []const u8 { - return switch (f) { - .dir => ".", - .pty_ctl => "ctl", - .pty_status => "status", - .pty_data => "data", - else => @tagName(f), - }; - } - - pub fn mode(f: PaneFile) u16 { - return switch (f) { - .dir, .pty => 0o500, - .errors, .wrsel, .pty_ctl => 0o200, - .rdsel, .pty_status => 0o400, - else => 0o600, - }; - } - - pub fn isDir(f: PaneFile) bool { - return f == .dir or f == .pty; - } - - pub fn inPty(f: PaneFile) bool { - return switch (f) { - .pty, .pty_ctl, .pty_status, .pty_data => true, - else => false, - }; - } -}; - -pub const SelfFile = enum(u4) { - root = 1, - index = 2, - cons = 3, - new = 4, - screen = 5, - listeners = 6, - README = 7, - - pub fn name(f: SelfFile) []const u8 { - return if (f == .root) "." else @tagName(f); - } - - pub fn mode(f: SelfFile) u16 { - return switch (f) { - .root, .new => 0o500, - .index, .screen, .listeners, .README => 0o400, - .cons => 0o200, - }; - } - - pub fn dir(f: SelfFile) bool { - return f == .root or f == .new; - } -}; - -pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, - - pub fn of(serial: u32, file: PaneFile) u64 { - std.debug.assert(serial != 0); - return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); - } - - pub fn target(node: u64) ?Target { - const n: Node = @bitCast(node); - if (n.serial == 0) { - return .{ .top = std.enums.fromInt(SelfFile, n.file) orelse return null }; - } - return .{ .pane = .{ - .serial = std.math.cast(u32, n.serial) orelse return null, - .file = std.enums.fromInt(PaneFile, n.file) orelse return null, - } }; - } -}; - -pub const Target = union(enum) { - top: SelfFile, - pane: struct { serial: u32, file: PaneFile }, -}; - -pub const out_reserve = 4 * 1024; - -pub const queue_cap = 64 * 1024; - -pub const Queue = struct { - buf: std.ArrayList(u8) = .empty, - head: usize = 0, - - pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.deinit(gpa); - q.head = 0; - } - - pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { - if (record.len > std.math.maxInt(u32)) return; - while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { - if (q.peek() == null) return; - q.pop(); - } - q.compact(); - var head: [4]u8 = undefined; - std.mem.writeInt(u32, &head, @intCast(record.len), .little); - q.buf.appendSlice(gpa, &head) catch return; - q.buf.appendSlice(gpa, record) catch { - q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); - return; - }; - } - - pub fn peek(q: *const Queue) ?[]const u8 { - const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; - if (rest.len < 4) return null; - const len = std.mem.readInt(u32, rest[0..4], .little); - if (rest.len < 4 + len) return null; - return rest[4 .. 4 + len]; - } - - pub fn pop(q: *Queue) void { - const record = q.peek() orelse return; - q.head += 4 + record.len; - if (q.head == q.buf.items.len) { - q.buf.clearRetainingCapacity(); - q.head = 0; - } - } - - pub fn popFront(q: *Queue, n: usize) void { - const record = q.peek() orelse return; - if (n >= record.len) return q.pop(); - q.head += n; - std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); - } - - fn compact(q: *Queue) void { - if (q.head == 0 or q.head * 2 < q.buf.items.len) return; - const rest = q.buf.items.len - q.head; - std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); - q.buf.shrinkRetainingCapacity(rest); - q.head = 0; - } - - pub fn empty(q: *const Queue) bool { - return q.peek() == null; - } - - pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.clearAndFree(gpa); - q.head = 0; - } -}; - -pub const PaneState = struct { - addr: Range = .{}, - limit: ?Range = null, - readers: u16 = 0, - events: Queue = .{}, - nomark: bool = false, - noscroll: bool = false, - tag_snap: std.ArrayList(u8) = .empty, - pty_readers: u16 = 0, - pty_out: Queue = .{}, - - pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; - - fn deinit(pf: *PaneState, gpa: std.mem.Allocator) void { - pf.events.deinit(gpa); - pf.pty_out.deinit(gpa); - pf.tag_snap.deinit(gpa); - pf.* = .{}; - } -}; - pub const Namespace = struct { socket_path: []const u8 = "", tcp_address: ?std.Io.net.IpAddress = null, @@ -1684,11 +1253,20 @@ pub const Namespace = struct { mounts: std.ArrayList(Mount) = .empty, node_name: [16]u8 = undefined, os_paths: std.ArrayList(OsPath) = .empty, - snapshots: [32]struct { node: u64 = 0, bytes: ?[]const u8 = null } = @splat(.{}), + snapshots: [tree.screen.snapshot_slots]tree.screen.Snapshot = @splat(.{}), out: std.ArrayList(u8) = .empty, - panes: [MAX_PANES]PaneState = @splat(.{}), + panes: [MAX_PANES]tree.pane.State = @splat(.{}), listeners: u16 = 0, origin: u8 = 'K', + /// The editor-wide event stream, recorded only while /log is held open. + log: tree.events.Queue = .{}, + log_readers: u16 = 0, + /// What the last ctl command created or touched, answered by reading /ctl. + results: [MAX_PANES]u32 = undefined, + results_len: u8 = 0, + results_valid: bool = false, + /// Process start, the mtime of everything that has no edit time of its own. + started: u32 = 0, pub fn deinit(st: *Namespace, gpa: std.mem.Allocator) void { for (st.mounts.items) |entry| { @@ -1700,6 +1278,7 @@ pub const Namespace = struct { st.os_paths.deinit(gpa); for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes); for (&st.panes) |*pf| pf.deinit(gpa); + st.log.deinit(gpa); st.out.deinit(gpa); } @@ -1718,7 +1297,7 @@ pub const Namespace = struct { pub fn stage(st: *Namespace, gpa: std.mem.Allocator) *std.ArrayList(u8) { st.out.clearRetainingCapacity(); - st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; + st.out.ensureTotalCapacity(gpa, tree.out_reserve) catch {}; return &st.out; } @@ -1764,3023 +1343,6 @@ pub fn unmount(p: *Pardes, name: []const u8) !void { return error.NotMounted; } -pub const max_record_text = 256; - -pub const Action = enum(u8) { - body_delete = 'D', - tag_delete = 'd', - body_insert = 'I', - tag_insert = 'i', - body_look = 'L', - tag_look = 'l', - body_exec = 'X', - tag_exec = 'x', - - pub fn char(a: Action) u8 { - return @intFromEnum(a); - } - - pub fn fromChar(c: u8) ?Action { - return std.enums.fromInt(Action, c); - } - - pub fn onTag(a: Action) bool { - return @intFromEnum(a) >= 'a'; - } -}; - -pub const flag_builtin: u32 = 1; -pub const flag_expansion: u32 = 2; -pub const flag_filename: u32 = 4; -pub const flag_chorded: u32 = 8; - -pub fn formatRecord( - buf: []u8, - origin: u8, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) []const u8 { - const sent = if (text.len >= max_record_text) text[0..0] else text; - return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ - origin, - action.char(), - q0, - q1, - flag, - sent.len, - sent, - }) catch buf[0..0]; -} - -pub const Span = struct { at: u32, removed: u32, inserted: u32 }; - -pub fn diffSpan(old: []const u8, new: []const u8) Span { - const both = @min(old.len, new.len); - const stride = 64; - var head: usize = 0; - while (head + stride <= both and - std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; - while (head < both and old[head] == new[head]) head += 1; - var tail: usize = 0; - const rest = both - head; - while (tail + stride <= rest and std.mem.eql( - u8, - old[old.len - tail - stride ..][0..stride], - new[new.len - tail - stride ..][0..stride], - )) tail += stride; - while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; - return .{ - .at = @intCast(head), - .removed = @intCast(old.len - tail - head), - .inserted = @intCast(new.len - tail - head), - }; -} - -pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { - if (!p.fs.scripted(id)) return; - const span = diffSpan(old, new); - if (span.removed == 0 and span.inserted == 0) return; - if (span.removed > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_delete else .body_delete, - span.at, - span.at + span.removed, - 0, - "", - ); - if (span.inserted > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_insert else .body_insert, - span.at, - span.at + span.inserted, - 0, - new[span.at..][0..span.inserted], - ); -} - -pub fn noteAction( - p: *Pardes, - id: usize, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) bool { - if (!p.fs.scripted(id)) return false; - var buf: [max_record_text + 64]u8 = undefined; - const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); - p.fs.panes[id].events.push(p.gpa, record); - return true; -} - -pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { - if (id >= MAX_PANES or bytes.len == 0) return; - const pf = &p.fs.panes[id]; - if (pf.pty_readers == 0) return; - var off: usize = 0; - while (off < bytes.len) { - const n = @min(bytes.len - off, queue_cap / 2); - pf.pty_out.push(p.gpa, bytes[off..][0..n]); - off += n; - } -} - -pub fn handle(p: *Pardes, req: Req) Reply { - if (req.node >= factory_base and req.node < factory_base + 16) { - const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT); - if (file == .dir or file.inPty()) return Reply.fail(req.tag, E.NOENT); - // Truncating a factory is harmless: its next open creates an empty pane. - if (req.op == .getattr or (req.op == .setattr and req.truncate)) - return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } }; - if (req.op != .open) return Reply.fail(req.tag, E.PERM); - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - const node = Node.of(serial, file); - var reply = handle(p, .{ .tag = req.tag, .op = .open, .node = node }); - reply.attr.node = node; - return reply; - } - if (req.node == namespace_root or req.node == namespace_panes) return namespace(p, req); - if (req.node == os_root or req.node & os_node != 0) return osHandle(p, req); - if (req.node & archive_node != 0) return archiveHandle(p, req); - const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); - if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { - .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), - .top => 'F', - }; - return switch (req.op) { - .lookup => lookup(p, req, target), - .getattr => switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }, - .setattr => setattr(p, req, target), - .open => open(p, req, target), - .release => release(p, req), - .readdir => readdir(p, req, target), - .read => handleRead(p, req, target), - .write => handleWrite(p, req, target), - }; -} - -const AttrResult = union(enum) { ok: Reply.Attr, missing }; - -fn attrOf(p: *Pardes, target: Target) AttrResult { - switch (target) { - .top => |f| return .{ .ok = .{ - .name = if (f == .root) "self" else f.name(), - .node = @intFromEnum(f), - .dir = f.dir(), - .mode = f.mode(), - .size = topSize(p, f), - } }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return .missing; - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; - return .{ .ok = .{ - .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.name(), - .node = Node.of(t.serial, t.file), - .dir = t.file.isDir(), - .mode = t.file.mode(), - .size = paneFileSize(p, id, t.file), - } }; - }, - } -} - -fn topSize(p: *Pardes, f: SelfFile) u64 { - return switch (f) { - .root, .new, .cons, .screen, .listeners => 0, - .index => indexLen(p), - .README => fs_help.len, - }; -} - -fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { - const pane = p.panes[id] orelse return 0; - return switch (f) { - .body, .data, .xdata => bodyLen(p, pane), - .tag => tagLen(p, pane), - .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, - .pty, .pty_ctl, .pty_status, .pty_data => 0, - }; -} - -fn bodyOf(pane: *const Pane) []const u8 { - if (pane.file) |*f| return f.content; - return ""; -} - -fn fileOf(pane: *Pane) ?*panes.File.State { - return if (pane.file) |*f| f else null; -} - -fn tagOf(p: *Pardes, pane: *Pane) []const u8 { - return p.tagText(p.scratch.allocator(), pane) catch ""; -} - -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 "/"; -} - -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; -} - -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)) }; -} - -fn firstLine(s: []const u8) []const u8 { - return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len]; -} - -fn dotOf(pane: *Pane) PaneState.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) }; -} - -fn setDot(pane: *Pane, r: PaneState.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(); -} - -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(); -} - -fn clampAddr(pf: *PaneState, 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: PaneState.Range, at: u32, removed: u32, inserted: u32) PaneState.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; -} - -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; -} - -fn paneFileNamed(name: []const u8) ?PaneFile { - const f = std.meta.stringToEnum(PaneFile, name) orelse return null; - if (f == .dir) return null; - return if (f.inPty() and f != .pty) null else f; -} - -fn ptyFileNamed(name: []const u8) ?PaneFile { - if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; - if (std.mem.eql(u8, name, "status")) return .pty_status; - if (std.mem.eql(u8, name, "data")) return .pty_data; - return null; -} - -fn topFileNamed(name: []const u8) ?SelfFile { - const f = std.meta.stringToEnum(SelfFile, name) orelse return null; - return if (f == .root) null else f; -} - -fn serialNamed(name: []const u8) ?u32 { - if (name.len == 0 or name.len > 10) return null; - for (name) |c| if (c < '0' or c > '9') return null; - return std.fmt.parseInt(u32, name, 10) catch null; -} - -fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { - var best: ?u32 = null; - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.serial <= after) continue; - if (best == null or pane.serial < best.?) best = pane.serial; - } - return best; -} - -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; - p.newScratchBelow(p.active); - const pane = p.panes[slot] orelse return null; - return pane.serial; -} - -fn lookup(p: *Pardes, req: Req, target: Target) Reply { - const name = req.data; - if (std.mem.eql(u8, name, "..")) { - const parent: u64 = switch (target) { - .top => |top| if (top == .root) namespace_root else @intFromEnum(SelfFile.root), - .pane => |t| if (t.file == .pty) Node.of(t.serial, .dir) else namespace_panes, - }; - return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); - } - if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT); - const node: u64 = switch (target) { - .top => |f| switch (f) { - .root => root: { - if (std.mem.eql(u8, name, "pane")) return .{ .tag = req.tag, .attr = .{ .node = namespace_panes, .dir = true, .mode = 0o500 } }; - if (topFileNamed(name)) |t| break :root @intFromEnum(t); - if (archiveNode(name)) |node| return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); - return Reply.fail(req.tag, E.NOENT); - }, - .new => new: { - if (std.mem.eql(u8, name, "README")) break :new @intFromEnum(SelfFile.README); - const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - if (want.inPty()) return Reply.fail(req.tag, E.NOENT); - return handle(p, .{ .tag = req.tag, .op = .getattr, .node = factory_base + @intFromEnum(want) }); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| pane: { - _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const f = switch (t.file) { - .dir => paneFileNamed(name), - .pty => ptyFileNamed(name), - else => return Reply.fail(req.tag, E.NOTDIR), - } orelse return Reply.fail(req.tag, E.NOENT); - break :pane Node.of(t.serial, f); - }, - }; - return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { - if (name.len == 0 or name.len > 255) return; - var head: [10]u8 = undefined; - std.mem.writeInt(u64, head[0..8], node, .little); - head[8] = @intFromBool(dir); - head[9] = @intCast(name.len); - out.appendSlice(gpa, &head) catch return; - out.appendSlice(gpa, name) catch return; -} - -fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - if (comptime f == .dir or (f.inPty() and f != .pty)) continue; - const present = f != .pty or terminal; - if (present) { - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name()); - } - } -} - -fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - if (comptime !f.inPty() or f == .pty) continue; - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name()); - } -} - -fn readdir(p: *Pardes, req: Req, target: Target) Reply { - const out = p.fs.stage(p.gpa); - var skip = req.off; - switch (target) { - .top => |f| switch (f) { - .root => { - inline for (.{ SelfFile.index, SelfFile.cons, SelfFile.new }) |t| { - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); - } - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane"); - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen"); - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners"); - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README"); - stageArchive(p, out, "", &skip); - }, - .new => { - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README"); - inline for (comptime std.enums.values(PaneFile)) |file| { - if (comptime file == .dir or file.inPty()) continue; - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, factory_base + @intFromEnum(file), false, file.name()); - } - }, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const terminal = p.panes[id].?.isTerminal(); - switch (t.file) { - .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), - .pty => { - if (!terminal) return Reply.fail(req.tag, E.NOENT); - stagePtyFiles(p, out, t.serial, &skip); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - } - }, - } - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -fn open(p: *Pardes, req: Req, target: Target) Reply { - const snapshot = switch (target) { - .top => |f| f == .screen, - .pane => |t| t.file == .body and if (p.paneBySerial(t.serial)) |id| p.panes[id].?.isTerminal() else false, - }; - if (snapshot) { - for (&p.fs.snapshots, 0..) |*slot, i| { - if (slot.node != 0) continue; - const bytes = if (target == .top) screenSnapshot(p) catch return Reply.fail(req.tag, E.NOMEM) else null; - slot.* = .{ .node = req.node, .bytes = bytes }; - return .{ .tag = req.tag, .handle = @intCast(i + 1) }; - } - return Reply.fail(req.tag, E.NFILE); - } - switch (target) { - .top => {}, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pf = &p.fs.panes[id]; - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); - switch (t.file) { - .ctl => pf.limit = null, - .addr => { - pf.addr = .{}; - pf.limit = null; - }, - .event => { - pf.readers +|= 1; - p.fs.listeners +|= 1; - }, - .pty_data => pf.pty_readers +|= 1, - else => {}, - } - }, - } - return .{ .tag = req.tag, .handle = 1 }; -} - -fn release(p: *Pardes, req: Req) Reply { - if (req.handle > 0 and req.handle <= p.fs.snapshots.len) { - const snapshot = &p.fs.snapshots[req.handle - 1]; - if (snapshot.node == req.node) { - if (snapshot.bytes) |bytes| p.gpa.free(bytes); - snapshot.* = .{}; - return .{ .tag = req.tag }; - } - } - const target = Node.target(req.node) orelse return .{ .tag = req.tag }; - switch (target) { - .top => {}, - .pane => |t| { - if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; - const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; - const pf = &p.fs.panes[id]; - if (t.file == .pty_data) { - if (pf.pty_readers == 0) return .{ .tag = req.tag }; - pf.pty_readers -= 1; - if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); - return .{ .tag = req.tag }; - } - if (pf.readers == 0) return .{ .tag = req.tag }; - pf.readers -= 1; - p.fs.listeners -|= 1; - if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); - }, - } - return .{ .tag = req.tag }; -} - -fn setattr(p: *Pardes, req: Req, target: Target) Reply { - if (req.truncate) switch (target) { - .pane => |t| switch (t.file) { - .body, .data, .xdata => { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (fileOf(pane) != null) { - _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse - return Reply.fail(req.tag, E.NOMEM); - p.fs.panes[id].addr = .{}; - setDot(pane, .{}); - } - }, - else => {}, - }, - else => {}, - }; - return switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -fn staged(p: *Pardes, req: Req) Reply { - const out = &p.fs.out; - const off = @min(req.off, out.items.len); - const n = @min(out.items.len - off, req.size); - if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); - out.shrinkRetainingCapacity(n); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; -} - -fn handleRead(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - .index => readIndex(p, req), - .README => help: { - const off = @min(req.off, fs_help.len); - const bytes = fs_help[off..][0..@min(req.size, fs_help.len - off)]; - p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM); - break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; - }, - .listeners => listeners: { - var buf: [512]u8 = undefined; - var text = std.Io.Writer.fixed(&buf); - if (p.fs.socket_path.len != 0) - text.print("unix!{s}\n", .{p.fs.socket_path}) catch break :listeners Reply.fail(req.tag, E.IO); - for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { - const address = maybe orelse continue; - switch (address) { - .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch - break :listeners Reply.fail(req.tag, E.IO), - .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch - break :listeners Reply.fail(req.tag, E.IO), - } - } - const bytes = text.buffered(); - const off = @min(req.off, bytes.len); - const len = @min(bytes.len - off, req.size); - p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..len]) catch break :listeners Reply.fail(req.tag, E.NOMEM); - break :listeners .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; - }, - .screen => readSnapshot(p, req, null), - .cons, .root, .new => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - const pf = &p.fs.panes[id]; - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => readAddr(p, req, pf, pane), - .body => readBody(p, req, id, pane), - .ctl => readCtl(p, req, pane), - .data => readData(req, id, pane, pf, false), - .xdata => readData(req, id, pane, pf, true), - .tag => readTag(p, req, pane), - .event => readQueue(p, req, &pf.events), - .rdsel => readRdsel(req, id, pane), - .pty_status => readPtyStatus(p, req, id, pane), - .pty_data => readPtyData(p, req, pf), - .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -fn screenSnapshot(p: *Pardes) ![]u8 { - var arena: std.heap.ArenaAllocator = .init(p.gpa); - defer arena.deinit(); - const surface = try p.render(arena.allocator()); - var styles: std.ArrayList(pardes.CellStyle) = .empty; - var indices: std.ArrayList(usize) = .empty; - for (surface.cells) |cell| { - const style: pardes.CellStyle = if (cell.default) .{} else cell.style; - const index = for (styles.items, 0..) |previous, i| { - if (std.meta.eql(style, previous)) break i; - } else new: { - try styles.append(arena.allocator(), style); - break :new styles.items.len - 1; - }; - try indices.append(arena.allocator(), index); - } - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - const writer = &out.writer; - try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows }); - try std.json.Stringify.value(surface.cursor, .{}, writer); - try writer.writeAll(",\"styles\":"); - try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer); - try writer.writeAll(",\"cells\":["); - for (surface.cells, indices.items, 0..) |cell, index, i| { - if (i != 0) try writer.writeByte(','); - try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer); - } - try writer.writeAll("]}\n"); - return out.toOwnedSlice(); -} - -const ctl_fields = 5 * 12; - -fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void { - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ - pane.serial, - tagOf(p, pane).len, - bodyOf(pane).len, - @as(u32, 0), - @intFromBool(dirtyOf(pane)), - }) catch {}; -} - -fn readIndex(p: *Pardes, req: Req) Reply { - const out = p.fs.stage(p.gpa); - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - const pane = p.panes[p.paneBySerial(s).?].?; - stageCtlNumbers(p, out, pane); - out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {}; - out.append(p.gpa, '\n') catch {}; - } - return staged(p, req); -} - -fn readAddr(p: *Pardes, req: Req, pf: *PaneState, pane: *Pane) Reply { - 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 {}; - return staged(p, req); -} - -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 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 staged(p, req); -} - -fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { - if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL); - const snapshot = &p.fs.snapshots[req.handle - 1]; - if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL); - if (snapshot.bytes == null) { - const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); - snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); - } - const bytes = snapshot.bytes.?; - const off = @min(req.off, bytes.len); - const len = @min(bytes.len - off, req.size); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; -} - -fn fontName(p: *Pardes) []const u8 { - const name = p.settings.font.effective_name.get(); - return if (name.len == 0) "default" else name; -} - -fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void { - const plain = s.len > 0 and for (s) |c| { - if (c <= ' ' or c == '\'') break false; - } else true; - if (plain) { - out.appendSlice(gpa, s) catch {}; - return; - } - out.append(gpa, '\'') catch {}; - for (s) |c| { - if (c == '\'') out.append(gpa, '\'') catch {}; - out.append(gpa, c) catch {}; - } - out.append(gpa, '\'') catch {}; -} - -fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - stageCtlNumbers(p, out, pane); - out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {}; - stageQuoted(out, p.gpa, fontName(p)); - out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {}; - return staged(p, req); -} - -fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; - return staged(p, req); -} - -fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneState, 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 readRdsel(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), - } } }; -} - -fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { - const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; - if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); - q.pop(); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ - pane.cols, - pane.rows, - @intFromBool(p.hostTtyTaken(id)), - }) catch {}; - return staged(p, req); -} - -fn readPtyData(p: *Pardes, req: Req, pf: *PaneState) Reply { - if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; - const out = p.fs.stage(p.gpa); - while (out.items.len < req.size) { - const chunk = pf.pty_out.peek() orelse break; - const n = @min(chunk.len, req.size - out.items.len); - out.appendSlice(p.gpa, chunk[0..n]) catch break; - pf.pty_out.popFront(n); - } - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -fn handleWrite(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - .cons => if (appendErrors(p, p.active, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - else => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => writeAddr(p, req, id, pane), - .body => writeBody(p, req, id, pane), - .ctl => writeCtl(p, req, t.serial), - .data, .xdata => writeData(p, req, id, pane), - .tag => writeTag(p, req, pane), - .event => writeEvent(p, req, id), - .wrsel => writeWrsel(p, req, id, pane), - .errors => if (appendErrors(p, id, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - .pty_ctl => writePtyCtl(p, req, id), - .pty_data => writePtyData(p, req, id), - .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -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; -} - -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 writeWrsel(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: 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) }; -} - -fn safePattern(pat: []const u8) bool { - var i: usize = 0; - while (i < pat.len) : (i += 1) { - if (pat[i] != '\\') continue; - if (i + 1 >= pat.len) return false; - i += 1; - } - return true; -} - -const Addr = struct { - text: []const u8, - lim: ?PaneState.Range, - expr: []const u8, - i: usize = 0, - depth: u8 = 0, - - const max_depth = 32; - const Size = enum { char, line }; - - fn address(a: *Addr, ar_in: PaneState.Range) ?PaneState.Range { - const start = a.i; - var ar = ar_in; - var r = ar_in; - var dir: u8 = 0; - var size: Size = .line; - var c: u8 = 0; - while (a.i < a.expr.len) { - const prevc = c; - c = a.expr[a.i]; - a.i += 1; - switch (c) { - ',', ';' => { - if (c == ';') ar = r; - if (prevc == 0) r.q0 = 0; // lhs defaults to 0 - if (a.i >= a.expr.len) { - r.q1 = clip(a.text.len); // rhs defaults to $ - } else { - if (a.depth >= max_depth) return null; - a.depth += 1; - const nr = a.address(ar) orelse return null; - a.depth -= 1; - r.q1 = nr.q1; - } - return r; - }, - '+', '-' => { - if (prevc == '+' or prevc == '-') { - const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; - if (nc != '#' and nc != '/' and nc != '?') - r = a.number(r, 1, prevc, .line) orelse return null; - } - dir = c; - }, - '.', '$' => { - if (a.i != start + 1) { - a.i -= 1; - return r; - } - r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; - dir = if (a.i < a.expr.len) '+' else 0; - }, - '#', '0'...'9' => { - var digit = c; - if (c == '#') { - if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { - a.i -= 1; - return r; - } - digit = a.expr[a.i]; - a.i += 1; - size = .char; - } - var n: u64 = digit - '0'; - while (a.i < a.expr.len) : (a.i += 1) { - const d = a.expr[a.i]; - if (d < '0' or d > '9') break; - n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); - } - r = a.number(r, @intCast(n), dir, size) orelse return null; - dir = 0; - size = .line; - }, - '/', '?' => { - const back = c == '?'; - r = a.regexp(r, a.pattern(c), back) orelse return null; - dir = 0; - size = .line; - }, - else => { - a.i -= 1; - return r; - }, - } - } - if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; - return r; - } - - fn pattern(a: *Addr, delim: u8) []const u8 { - const s = a.i; - while (a.i < a.expr.len) { - const c = a.expr[a.i]; - if (c == '\n') break; - a.i += 1; - if (c == '\\') { - if (a.i < a.expr.len) a.i += 1; - continue; - } - if (c == delim) return a.expr[s .. a.i - 1]; - } - return a.expr[s..a.i]; - } - - fn number(a: *Addr, r_in: PaneState.Range, n: u32, dir: u8, size: Size) ?PaneState.Range { - var r = r_in; - if (size == .char) { - var off: i64 = n; - if (dir == '+') { - off = @as(i64, r.q1) + n; - } else if (dir == '-') { - if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); - off = @as(i64, r.q0) - n; - } - if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; - const g = clip(modal.graphemeStart(a.text, @intCast(off))); - return .{ .q0 = g, .q1 = g }; - } - var line: i64 = n; - var q0: usize = r.q0; - var q1: usize = r.q1; - switch (dir) { - '-' => { - if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { - q0 -= 1; - }; - q1 = q0; - while (line > 0 and q0 > 0) { - if (a.text[q0 - 1] == '\n') { - line -= 1; - q1 = q0; - } - q0 -= 1; - } - if (line > 1) return null; - while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - }, - '+' => { - if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { - q1 += 1; - }; - q0 = q1; - }, - else => { - q0 = 0; - q1 = 0; - }, - } - while (line > 0 and q1 < a.text.len) { - const ch = a.text[q1]; - q1 += 1; - if (ch == '\n' or q1 == a.text.len) { - line -= 1; - if (line > 0) q0 = q1; - } - } - if (line > 0) return null; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - } - - fn regexp(a: *Addr, r: PaneState.Range, pat: []const u8, back: bool) ?PaneState.Range { - if (pat.len == 0 or !safePattern(pat)) return null; - const re = mvzr.compile(pat) orelse return null; - if (back) { - const hi = @min(@as(usize, r.q0), a.text.len); - var best: ?mvzr.Match = null; - var at: usize = 0; - while (at < hi) { - const m = re.matchPos(at, a.text[0..hi]) orelse break; - best = m; - at = if (m.end > m.start) m.end else m.end + 1; - } - const m = best orelse return null; - return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; - } - const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; - const from = @min(@as(usize, r.q1), hi); - const m = re.match(a.text[from..hi]) orelse return null; - return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; - } -}; - -const Verb = enum { - @"addr=dot", - clean, - cleartag, - del, - delete, - dirty, - @"dot=addr", - get, - @"limit=addr", - mark, - nomark, - noscroll, - put, - scroll, - show, -}; - -const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" }; - -fn verbIs(line: []const u8, word: []const u8) bool { - if (!std.mem.startsWith(u8, line, word)) return false; - return line.len == word.len or line[word.len] == ' '; -} - -fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { - for ([2]bool{ false, true }) |apply| { - var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false; - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); - const errno = ctlVerb(p, live, line, apply, &dirty); - if (errno != 0) return Reply.fail(req.tag, errno); - } - } - 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 9P name operation 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 = 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 } }); -} - -fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u16 { - const pane = p.panes[id] orelse return E.INVAL; - const pf = &p.fs.panes[id]; - - if (verbIs(line, "look")) { - if (line.len <= 5) return E.INVAL; - const word = std.mem.trim(u8, line[5..], " \t"); - if (word.len == 0) return E.INVAL; - for (word) |c| if (c < ' ') return E.INVAL; - if (apply) p.lookAt(id, word); - return 0; - } - if (verbIs(line, "name")) { - if (line.len <= 5) return E.INVAL; - const name = std.mem.trim(u8, line[5..], " \t"); - if (name.len == 0) return E.INVAL; - for (name) |c| if (c <= ' ') return E.INVAL; - const f = fileOf(pane) orelse return 0; - const full = std.fs.path.resolvePosix(p.gpa, &.{ Pardes.paneDir(pane), name }) catch return E.NOMEM; - defer p.gpa.free(full); - if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return E.INVAL; - if (std.mem.eql(u8, f.path, full)) return 0; - if (panes.Output.fileTraits(f.output).saves) dirty.* = true; - if (!apply) return 0; - nameBuffer(p, id, full, false) catch return E.NOMEM; - return 0; - } - for (refused_verbs) |w| if (verbIs(line, w)) return E.INVAL; - - const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; - if (v == .del and dirty.*) return E.INVAL; - switch (v) { - .dirty => dirty.* = true, - .clean, .get, .put => dirty.* = false, - else => {}, - } - if (!apply) return 0; - - switch (v) { - .@"addr=dot" => pf.addr = dotOf(pane), - .@"dot=addr" => { - clampAddr(pf, bodyOf(pane).len); - setDot(pane, pf.addr); - }, - .@"limit=addr" => { - clampAddr(pf, bodyOf(pane).len); - pf.limit = pf.addr; - }, - .clean => if (fileOf(pane)) |f| { - f.saved_revision = f.revision; - }, - .dirty => if (fileOf(pane)) |f| { - f.saved_revision = f.revision -% 1; - }, - .cleartag => { - pane.tag_tail_len = 0; - pane.tag_init = true; - }, - .del, .delete => p.removePane(id) catch return E.NOMEM, - .put => { - const serial = pane.serial; - if (fileOf(pane)) |f| { - if (f.output == null) { - p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); - const current = p.paneBySerial(serial) orelse return E.NOENT; - if (dirtyOf(p.panes[current].?)) return E.IO; - } else _ = p.executeBuiltinLine(id, "Save"); - } else _ = p.executeBuiltinLine(id, "Save"); - }, - .get => if (fileOf(pane)) |f| { - if (panes.Output.fileTraits(f.output).saves) { - if (read(p, f.path)) |bytes| { - panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, bytes); - f.saved_revision = f.revision; - } else |err| return switch (err) { - error.FileNotFound => E.NOENT, - else => E.IO, - }; - } - }, - .mark => { - pf.nomark = false; - panes.File.pushUndo(p, pane); - }, - .nomark => pf.nomark = true, - .noscroll => pf.noscroll = true, - .scroll => pf.noscroll = false, - .show => showOffset(pane, dotOf(pane).q0), - } - return 0; -} - -const PtyVerb = enum { winsize, sig, exec }; - -fn ptyDimension(word: []const u8) ?u16 { - if (word.len == 0 or word.len > 5) return null; - for (word) |c| if (c < '0' or c > '9') return null; - const n = std.fmt.parseInt(u16, word, 10) catch return null; - return if (n == 0) null else n; -} - -fn ptySignalNamed(word: []const u8) ?pardes.PtySignal { - if (std.mem.eql(u8, word, "INT")) return .int; - if (std.mem.eql(u8, word, "TERM")) return .term; - if (std.mem.eql(u8, word, "HUP")) return .hup; - if (std.mem.eql(u8, word, "QUIT")) return .quit; - if (std.mem.eql(u8, word, "KILL")) return .kill; - return null; -} - -fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply { - for ([2]bool{ false, true }) |apply| { - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { - const pane = p.panes[id] orelse return false; - var words = std.mem.tokenizeAny(u8, line, " \t"); - const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; - switch (v) { - .winsize => { - const cols = ptyDimension(words.next() orelse return false) orelse return false; - const rows = ptyDimension(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); - }, - .sig => { - const which = ptySignalNamed(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); - }, - .exec => { - if (words.next() != null) return false; - if (pane.cwdSlice().len > pardes.effect_path_cap) return false; - if (!apply) return true; - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); - }, - } - return true; -} - -fn writePtyData(p: *Pardes, req: Req, id: usize) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -const EventRecord = struct { action: Action, q0: u32, q1: u32 }; - -const EventReader = struct { - data: []const u8, - i: usize = 0, - - fn next(er: *EventReader) ?EventRecord { - if (er.i >= er.data.len) return null; - var i = er.i; - if (i + 2 > er.data.len) return null; - i += 1; - const action = Action.fromChar(er.data[i]) orelse return null; - i += 1; - const q0 = scanNumber(er.data, &i) orelse return null; - const q1 = scanNumber(er.data, &i) orelse return null; - while (i < er.data.len and er.data[i] == ' ') i += 1; - if (i >= er.data.len or er.data[i] != '\n') return null; - er.i = i + 1; - return .{ .action = action, .q0 = q0, .q1 = q1 }; - } -}; - -fn scanNumber(data: []const u8, i: *usize) ?u32 { - while (i.* < data.len and data[i.*] == ' ') i.* += 1; - const s = i.*; - var n: u64 = 0; - while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) - n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); - if (i.* == s) return null; - return @intCast(n); -} - -fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { - const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); - const serial = pane0.serial; - { - const body = bodyOf(pane0); - const tag = tagOf(p, pane0); - var check: EventReader = .{ .data = req.data }; - while (check.next()) |r| { - switch (r.action) { - .body_look, .tag_look, .body_exec, .tag_exec => {}, - else => return Reply.fail(req.tag, E.INVAL), - } - const n = if (r.action.onTag()) tag.len else body.len; - if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); - } - if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); - } - var run: EventReader = .{ .data = req.data }; - while (run.next()) |r| { - const now = p.paneBySerial(serial) orelse break; - const pane = p.panes[now].?; - const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane); - const lo = @min(@as(usize, r.q0), whole.len); - const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); - const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; - switch (r.action) { - .body_exec, .tag_exec => _ = p.execute(now, text), - .body_look, .tag_look => p.lookAt(now, text), - else => unreachable, - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -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; -} - -fn indexLen(p: *Pardes) u64 { - var n: u64 = 0; - for (p.panes) |slot| { - const pane = slot orelse continue; - n += ctl_fields + firstLine(tagOf(p, pane)).len + 1; - } - return n; -} - -fn bodyLen(p: *Pardes, pane: *Pane) u64 { - _ = p; - return bodyOf(pane).len; -} - -fn tagLen(p: *Pardes, pane: *Pane) u64 { - return tagOf(p, pane).len; -} - -test "filesystem inspection preserves pending and displayed Look hover" { - const delay = config.look_preview_delay_frames orelse return; - const gpa = std.testing.allocator; - const p = try withFile(gpa, "alpha beta gamma\n"); - defer p.deinit(); - var frame: std.heap.ArenaAllocator = .init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - const rect = p.rects[0]; - p.update(.{ .mouse = .{ - .button = .none, - .kind = .motion, - .col = rect.x + config.GUTTER + config.PREFIX_W + 7, - .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, - } }); - try testing.expect(p.look_hover_wait != null); - const body = Node.of(serialOf(p), .body); - const screen = @intFromEnum(SelfFile.screen); - for (0..2) |phase| { - if (phase == 1) { - for (0..delay) |_| p.update(.tick); - try testing.expect(p.look_hover_preview != null); - } - const waiting = p.look_hover_wait; - const preview = p.look_hover_preview; - const requests = [_]Req{ - .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.root), .data = "screen" }, - .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." }, - .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" }, - .{ .tag = 2, .op = .getattr, .node = body }, - .{ .tag = 3, .op = .open, .node = body }, - .{ .tag = 4, .op = .read, .node = body, .size = 5 }, - .{ .tag = 5, .op = .release, .node = body, .handle = 1 }, - .{ .tag = 6, .op = .readdir, .node = @intFromEnum(SelfFile.root), .size = 4096 }, - .{ .tag = 7, .op = .open, .node = screen }, - .{ .tag = 8, .op = .read, .node = screen, .handle = 1, .size = 32 }, - .{ .tag = 9, .op = .release, .node = screen, .handle = 1 }, - }; - for (requests) |req| { - const answer = call(p, req); - try testing.expectEqual(Status.ok, answer.reply.status); - try testing.expect(std.meta.eql(waiting, p.look_hover_wait)); - try testing.expect(std.meta.eql(preview, p.look_hover_preview)); - try testing.expect(p.raw_hover_intent); - } - } - try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status); - try testing.expect(p.look_hover_wait == null); - try testing.expect(p.look_hover_preview == null); - try testing.expect(!p.raw_hover_intent); -} - -test "filesystem pane creation and truncation cancel Look hover" { - const requests = [_]Req{ - .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }, - .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, - }; - for (requests) |request| { - const p = try withFile(testing.allocator, "word\n"); - defer p.deinit(); - p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) }; - p.raw_hover_intent = true; - var req = request; - if (req.op == .setattr) req.node = Node.of(serialOf(p), .body); - try testing.expectEqual(Status.ok, call(p, req).reply.status); - try testing.expect(p.look_hover_wait == null); - try testing.expect(p.look_hover_preview == null); - try testing.expect(!p.raw_hover_intent); - } -} - -test "terminal body handles keep one history snapshot across fragmented reads" { - const gpa = std.testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const node = Node.of(serialOf(p), .body); - p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); - while (p.nextEffect()) |_| {} - const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); - defer gpa.free(original); - const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); - try testing.expectEqual(Status.ok, opened.reply.status); - const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 }); - try testing.expectEqualStrings(original[0..3], first.bytes); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } }); - while (p.nextEffect()) |_| {} - var offset: usize = 3; - while (offset < original.len) { - const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 }); - try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes); - offset += part.bytes.len; - } - try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes); - _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle }); - const newer = call(p, .{ .tag = 6, .op = .open, .node = node }); - try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); - _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle }); -} - -test "an empty terminal body snapshot stays empty while output continues" { - const p = try withTerm(std.testing.allocator); - defer p.deinit(); - const node = Node.of(serialOf(p), .body); - const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); - try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); - try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null); - p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); - _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); - const newer = call(p, .{ .tag = 5, .op = .open, .node = node }); - try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); - _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle }); -} - -test "terminal body snapshots are lazy bounded and released after the pane closes" { - const gpa = std.testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const node = Node.of(serialOf(p), .body); - var handles: [32]u32 = undefined; - for (&handles) |*opened_handle| { - const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); - try testing.expectEqual(Status.ok, opened.reply.status); - opened_handle.* = opened.reply.handle; - } - for (p.fs.snapshots) |snapshot| { - try testing.expectEqual(node, snapshot.node); - try testing.expect(snapshot.bytes == null); - } - try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno()); - const scratch = call(p, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); - try testing.expectEqual(Status.ok, scratch.reply.status); - const scratch_id = p.paneBySerial(Node.target(scratch.reply.attr.node).?.pane.serial).?; - try testing.expect(p.panes[scratch_id].?.file != null); - _ = call(p, .{ .tag = 2, .op = .release, .node = scratch.reply.attr.node, .handle = scratch.reply.handle }); - p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } }); - while (p.nextEffect()) |_| {} - var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); - p.gpa = failing.allocator(); - const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 }); - p.gpa = gpa; - try testing.expectEqual(E.NOMEM, failed.errno()); - try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null); - const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 }); - try testing.expectEqualStrings("latest output", first.bytes); - const saved = p.fs.snapshots[handles[0] - 1].bytes.?; - failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); - p.gpa = failing.allocator(); - const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 }); - p.gpa = gpa; - try testing.expectEqualStrings("output", second.bytes); - try testing.expect(!failing.has_induced_failure); - try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr); - const screen = @intFromEnum(SelfFile.screen); - try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen, .handle = handles[0], .size = 32 }).errno()); - _ = call(p, .{ .tag = 7, .op = .release, .node = screen, .handle = handles[0] }); - try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node); - try p.removePane(0); - for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle }); - for (p.fs.snapshots) |snapshot| { - try testing.expectEqual(@as(u64, 0), snapshot.node); - try testing.expect(snapshot.bytes == null); - } -} - -test "screen snapshots preserve rendered cells and styles until their handle is released" { - const gpa = std.testing.allocator; - const p = try withFile(gpa, "const value = 1;\n"); - defer p.deinit(); - const node = @intFromEnum(SelfFile.screen); - const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); - try testing.expectEqual(Status.ok, opened.reply.status); - const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?; - const original = try gpa.dupe(u8, snapshot); - defer gpa.free(original); - const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{}); - defer parsed.deinit(); - const data = parsed.value.object; - try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer); - try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer); - try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len); - try testing.expect(data.get("styles").?.array.items.len > 0); - _ = wr(p, Node.of(serialOf(p), .body), "changed\n"); - const newer = call(p, .{ .tag = 2, .op = .open, .node = node }); - try testing.expectEqual(Status.ok, newer.reply.status); - try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?)); - var off: usize = 0; - while (off < original.len) { - const result = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = off, .size = 13 }); - try testing.expectEqual(Status.ok, result.reply.status); - try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes); - off += result.bytes.len; - } - _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); - _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = newer.reply.handle }); - for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0); -} - -test "screen inspection preserves acknowledged presentation and the next real frame" { - const gpa = testing.allocator; - for (std.enums.values(pardes.layout.Transition)) |transition| { - errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); - const control = try withFile(gpa, "const value = 1;\n"); - defer control.deinit(); - const inspected = try withFile(gpa, "const value = 1;\n"); - defer inspected.deinit(); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - for ([_]*Pardes{ control, inspected }) |p| { - p.settings.panel_transition = .off; - p.update(.tick); - p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); - p.settings.panel_transition = transition; - } - for (0..4) |phase| { - for ([_]*Pardes{ control, inspected }) |p| switch (phase) { - 0 => {}, - 1 => p.update(.{ .command = "New" }), - 2 => { - p.update(.tick); - try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status); - }, - 3 => { - for (0..transition.frames() + 1) |_| p.update(.tick); - p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); - p.update(.{ .command = "Del" }); - }, - else => unreachable, - }; - const screen = @intFromEnum(SelfFile.screen); - for (0..2) |_| { - const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen }); - try testing.expectEqual(Status.ok, opened.reply.status); - const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen, .handle = opened.reply.handle, .size = 32 }); - try testing.expectEqual(Status.ok, captured.reply.status); - try testing.expect(captured.bytes.len > 0); - try testing.expectEqual(Status.ok, call(inspected, .{ - .tag = 3, - .op = .release, - .node = screen, - .handle = opened.reply.handle, - }).reply.status); - } - const a = &control.presentation; - const b = &inspected.presentation; - try testing.expectEqualDeep(a.shown, b.shown); - try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks); - try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]); - try testing.expectEqual(a.pending, b.pending); - try testing.expectEqual(a.acknowledged, b.acknowledged); - try testing.expectEqual(a.previous_valid, b.previous_valid); - try testing.expectEqual(a.previous_cols, b.previous_cols); - try testing.expectEqual(a.previous_rows, b.previous_rows); - try testing.expectEqualDeep(a.previous_layout, b.previous_layout); - try testing.expectEqual(a.previous_cells.len, b.previous_cells.len); - for (a.previous_cells, b.previous_cells) |*expected, *actual| - try testing.expect(expected.visuallyEqual(actual)); - - const expected = try control.render(arena.allocator()); - const actual = try inspected.render(arena.allocator()); - try testing.expectEqual(expected.cols, actual.cols); - try testing.expectEqual(expected.rows, actual.rows); - try testing.expectEqualDeep(expected.cursor, actual.cursor); - try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks()); - try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs); - try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len); - try testing.expectEqual(expected.cells.len, actual.cells.len); - try testing.expectEqual(@as(usize, 0), expected.nimages); - try testing.expectEqual(@as(usize, 0), actual.nimages); - for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); - for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); - if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0); - if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff()); - if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0); - control.acknowledgePanelPresentation(expected.panelTracks()); - inspected.acknowledgePanelPresentation(actual.panelTracks()); - try testing.expectEqualDeep(a.shown, b.shown); - try testing.expectEqualDeep(a.previous_layout, b.previous_layout); - _ = arena.reset(.retain_capacity); - } - } -} - -const testing = std.testing; - -const Answer = struct { - reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, - bytes: []const u8 = "", - saved: bool = false, - watch: ?bool = null, - pty_buf: [256]u8 = undefined, - pty_len: usize = 0, - winsize: ?struct { cols: u16, rows: u16 } = null, - signal: ?pardes.PtySignal = null, - spawned: bool = false, - - fn pty(a: *const Answer) []const u8 { - return a.pty_buf[0..a.pty_len]; - } - - fn errno(a: Answer) u16 { - return if (a.reply.status == .err) a.reply.errno else 0; - } -}; - -fn call(p: *Pardes, req: Req) Answer { - p.update(.{ .fs_req = req }); - var ans: Answer = .{}; - while (p.nextEffect()) |e| switch (e) { - .fs_reply => |r| { - ans.reply = r; - ans.bytes = p.fsPayload(r); - }, - .save_file, .save_text => ans.saved = true, - .watch => |w| ans.watch = w.on, - .write => |w| { - const b = w.bytes.slice(); - const n = @min(b.len, ans.pty_buf.len - ans.pty_len); - @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); - ans.pty_len += n; - }, - .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, - .signal_pty => |s| ans.signal = s.sig, - .spawn => ans.spawned = true, - else => {}, - }; - return ans; -} - -fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { - return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); -} - -fn wr(p: *Pardes, node: u64, data: []const u8) Answer { - return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); -} - -fn rdir(p: *Pardes, node: u64, skip: u64) Answer { - return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); -} - -fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { - return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); -} - -fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - _ = try p.setTestFile(text); - while (p.nextEffect()) |_| {} - return p; -} - -fn withTerm(gpa: std.mem.Allocator) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - std.debug.assert(p.panes[0].?.isTerminal()); - return p; -} - -fn serialOf(p: *Pardes) u32 { - return p.panes[0].?.serial; -} - -const Dirent = struct { node: u64, dir: bool, name: []const u8 }; - -fn dirents(bytes: []const u8, out: []Dirent) []Dirent { - var n: usize = 0; - var i: usize = 0; - while (i + 10 <= bytes.len and n < out.len) { - const node = std.mem.readInt(u64, bytes[i..][0..8], .little); - const kind = bytes[i + 8]; - const len = bytes[i + 9]; - i += 10; - if (i + len > bytes.len) break; - out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; - i += len; - n += 1; - } - return out[0..n]; -} - -fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { - for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; - return null; -} - -test "listener addresses are readable canonical dials with bounded partial reads" { - const gpa = testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - p.fs.socket_path = "/tmp/a socket.sock"; - p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; - p.fs.quic_address = .{ .ip6 = .loopback(5641) }; - const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n"; - for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| { - const bytes = try read(p, path); - defer gpa.free(bytes); - try testing.expectEqualStrings(expected, bytes); - try testing.expectError(error.ReadOnlyFilesystem, write(p, path, "")); - } - const node = @intFromEnum(SelfFile.listeners); - var off: usize = 0; - while (off < expected.len) { - const part = rd(p, node, off, 3); - try testing.expectEqual(Status.ok, part.reply.status); - try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes); - off += part.bytes.len; - } - try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len); -} - -test "readdir lists the root, a pane directory, and new/ without creating anything" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - var buf: [32]Dirent = undefined; - - const root = rdir(p, @intFromEnum(SelfFile.root), 0); - try testing.expectEqual(Status.ok, root.reply.status); - const top = dirents(root.bytes, &buf); - try testing.expect(top.len >= 5); - try testing.expectEqualStrings("index", top[0].name); - try testing.expectEqualStrings("cons", top[1].name); - try testing.expectEqualStrings("new", top[2].name); - try testing.expect(top[2].dir and !top[0].dir); - try testing.expectEqualStrings("pane", top[3].name); - try testing.expect(top[3].dir); - try testing.expectEqual(namespace_panes, top[3].node); - var idbuf: [16]u8 = undefined; - try testing.expect(nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); - try testing.expect(nameAt(top, "src") != null); - - const rest = rdir(p, @intFromEnum(SelfFile.root), 3); - try testing.expectEqual(top.len - 3, dirents(rest.bytes, &buf).len); - const eof = rdir(p, @intFromEnum(SelfFile.root), 99); - try testing.expectEqual(Status.ok, eof.reply.status); - try testing.expectEqual(@as(usize, 0), eof.bytes.len); - - const dir = rdir(p, Node.of(serial, .dir), 0); - const files = dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - try testing.expect(nameAt(files, "addr") != null); - try testing.expect(nameAt(files, "xdata") != null); - try testing.expect(nameAt(files, ".") == null); - try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node); - - const before = p.next_serial; - const new = rdir(p, @intFromEnum(SelfFile.new), 0); - try testing.expectEqual(Status.ok, new.reply.status); - const factories = dirents(new.bytes, &buf); - try testing.expectEqual(@as(usize, 11), factories.len); - try testing.expect(nameAt(factories, "README") != null); - for (factories) |entry| { - const inspected = look_up(p, @intFromEnum(SelfFile.new), entry.name); - try testing.expectEqual(Status.ok, inspected.reply.status); - try testing.expectEqual(entry.node, inspected.reply.attr.node); - try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status); - } - try testing.expectEqual(before, p.next_serial); - - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); -} - -test "lookup resolves top files, pane serials and pane files" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const root = @intFromEnum(SelfFile.root); - - try testing.expectEqual(@as(u64, @intFromEnum(SelfFile.index)), look_up(p, root, "index").reply.attr.node); - try testing.expect(look_up(p, root, "new").reply.attr.dir); - try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); - - var idbuf: [16]u8 = undefined; - const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial}); - try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno()); - const dir = look_up(p, namespace_panes, serial_name); - try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); - try testing.expect(dir.reply.attr.dir); - try testing.expectEqual(E.NOENT, look_up(p, namespace_panes, "99999").errno()); - - const body = look_up(p, Node.of(serial, .dir), "body"); - try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); - const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); - try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); -} - -test "new/ lookup is inert and each open creates a distinct pane" { - const gpa = testing.allocator; - const p = try withFile(gpa, "first\n"); - defer p.deinit(); - const before = serialOf(p); - - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "bogus").errno()); - try testing.expectEqual(before, p.next_serial); - - const a = look_up(p, @intFromEnum(SelfFile.new), "body"); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(before, p.next_serial); - const opened = call(p, .{ .tag = 1, .op = .open, .node = a.reply.attr.node }); - try testing.expectEqual(Status.ok, opened.reply.status); - const made: Node = @bitCast(opened.reply.attr.node); - try testing.expect(made.serial != before); - try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); - - _ = wr(p, opened.reply.attr.node, "hi"); - const second = call(p, .{ .tag = 2, .op = .open, .node = a.reply.attr.node }); - try testing.expectEqual(Status.ok, second.reply.status); - try testing.expect(second.reply.attr.node != opened.reply.attr.node); - const id = p.paneBySerial(@intCast(made.serial)).?; - try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); -} - -test "index prints winctlprint's five fields then the tag" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\nthere\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, @intFromEnum(SelfFile.index), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var got: [512]u8 = undefined; - @memcpy(got[0..a.bytes.len], a.bytes); - const line = got[0..a.bytes.len]; - - const tag = try gpa.dupe(u8, tagOf(p, pane)); - defer gpa.free(tag); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ - pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag), - }); - try testing.expectEqualStrings(want.items, line); - try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); - - const mid = rd(p, @intFromEnum(SelfFile.index), 60, 5); - try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); - - pane.file.?.saved_revision = pane.file.?.revision -% 1; - const dirty = rd(p, @intFromEnum(SelfFile.index), 48, 12); - try testing.expectEqualStrings(" 1 ", dirty.bytes); -} - -test "ctl read is index's five fields plus width in cells, font and tab width" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ - pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, - }); - try testing.expectEqualStrings(want.items, a.bytes); - - var quoted: std.ArrayList(u8) = .empty; - defer quoted.deinit(gpa); - stageQuoted("ed, gpa, "DejaVu Sans Mono"); - try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); - quoted.clearRetainingCapacity(); - stageQuoted("ed, gpa, "it's"); - try testing.expectEqualStrings("'it''s'", quoted.items); -} - -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); -} - -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); - - 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 "the address language, form by form" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\nthree\n"); - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - - const Case = struct { expr: []const u8, q0: u32, q1: u32 }; - for ([_]Case{ - .{ .expr = "#0", .q0 = 0, .q1 = 0 }, - .{ .expr = "#5", .q0 = 5, .q1 = 5 }, - .{ .expr = "0", .q0 = 0, .q1 = 0 }, - .{ .expr = "1", .q0 = 0, .q1 = 4 }, - .{ .expr = "2", .q0 = 4, .q1 = 8 }, - .{ .expr = "$", .q0 = 14, .q1 = 14 }, - .{ .expr = ",", .q0 = 0, .q1 = 14 }, - .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, - .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, - .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, - .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, - .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, - .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, - .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, - }) |c| { - _ = wr(p, addr, "#0"); - const w = wr(p, addr, c.expr); - try testing.expectEqual(Status.ok, w.reply.status); - const got = rd(p, addr, 0, 64); - var want: [32]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), - got.bytes, - ); - } - - _ = wr(p, addr, "1"); - _ = wr(p, addr, "."); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); - - _ = wr(p, addr, "$"); - _ = wr(p, addr, "?o?"); - try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" - try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); - - _ = wr(p, addr, "1"); - _ = wr(p, Node.of(serial, .ctl), "limit=addr\n"); - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno()); - _ = wr(p, Node.of(serial, .ctl), "clean\n"); - _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); - try testing.expect(p.fs.panes[0].limit == null); - _ = wr(p, addr, "#0"); - try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status); - - for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); - } - - const nested = "," ** 4096; - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, nested).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 "rdsel reads the selection and wrsel replaces it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - - _ = wr(p, Node.of(serial, .addr), "#0,#3"); - try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status); - try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes); - - _ = wr(p, ctl, "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, Node.of(serial, .wrsel), "ONE").reply.status); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - _ = wr(p, Node.of(serial, .wrsel), "!"); - try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content); -} - -test "every ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - const pf = &p.fs.panes[0]; - - try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status); - try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane)); - try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status); - try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane)); - - _ = wr(p, ctl, "cleartag\n"); - try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); - - _ = wr(p, Node.of(serial, .addr), "2"); - _ = wr(p, ctl, "limit=addr\n"); - try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); - _ = wr(p, ctl, "dot=addr\nshow\n"); - try testing.expectEqual(@as(i32, 1), pane.cur_row); - - try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno()); - try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno()); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - - try testing.expectEqual(Status.ok, wr(p, ctl, "put\n").reply.status); - try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); - - for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", - }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno()); - - try testing.expect(!dirtyOf(pane)); - try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); - try testing.expect(!dirtyOf(pane)); -} - -test "ctl look opens spaced paths and locations without editing the source pane" { - if (!platform_has_fs) return error.SkipZigTest; - const gpa = testing.allocator; - var tmp = testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); - var path_buf: [4096]u8 = undefined; - const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; - const p = try withFile(gpa, "source stays intact\n"); - defer p.deinit(); - const source = p.panes[0].?; - const ctl = Node.of(source.serial, .ctl); - const revision = source.file.?.revision; - const undo_len = source.file.?.history.undo_len; - source.cur_col = 3; - var command: [4200]u8 = undefined; - try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); - const opened = p.panes[p.active].?; - try testing.expect(opened != source); - try testing.expectEqualStrings(path, opened.file.?.path); - try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); - try testing.expectEqual(@as(i32, 1), opened.cur_row); - try testing.expectEqual(@as(i32, 2), opened.cur_col); - try testing.expectEqualStrings("source stays intact\n", source.file.?.content); - try testing.expectEqual(revision, source.file.?.revision); - try testing.expectEqual(undo_len, source.file.?.history.undo_len); - try testing.expectEqual(@as(i32, 3), source.cur_col); - - try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); - try testing.expect(p.panes[p.active].? == opened); - try testing.expectEqual(@as(i32, 2), opened.cur_row); - try testing.expectEqual(@as(i32, 1), opened.cur_col); -} - -test "ctl look validates the whole batch before opening virtual files" { - const gpa = testing.allocator; - const p = try withFile(gpa, "source\n"); - defer p.deinit(); - const source = p.panes[0].?; - const ctl = Node.of(source.serial, .ctl); - const serial = p.next_serial; - for ([_][]const u8{ - "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", - }) |invalid| { - try testing.expectEqual(E.INVAL, wr(p, ctl, invalid).errno()); - try testing.expectEqual(serial, p.next_serial); - try testing.expectEqual(@as(usize, 0), p.active); - try testing.expectEqualStrings("source\n", source.file.?.content); - } - try testing.expectEqual(Status.ok, wr(p, ctl, "look /n/self/index\n").reply.status); - const opened = p.panes[p.active].?; - try testing.expectEqualStrings("/virtual/index", opened.file.?.path); - try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); - try testing.expectEqualStrings("source\n", source.file.?.content); -} - -test "ctl name promotes a scratch without changing its body or undo history" { - const gpa = testing.allocator; - const p = try withFile(gpa, "opener\n"); - defer p.deinit(); - _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) }); - const pane = p.panes[p.active].?; - const ctl = Node.of(pane.serial, .ctl); - const body = Node.of(pane.serial, .body); - _ = 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 command: [4104]u8 = undefined; - const renamed = wr(p, ctl, try std.fmt.bufPrint(&command, "name {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); - const target = try readFile(gpa, path); - defer gpa.free(target); - try testing.expectEqualStrings("existing target\n", target); - const saved = wr(p, ctl, "put\n"); - try testing.expectEqual(Status.ok, saved.reply.status); - try testing.expectEqual(true, saved.watch.?); - try testing.expect(!dirtyOf(pane) and !pane.file.?.watch_after_save); - try testing.expectEqualStrings("scratch work\n", p.fallback.get(path).?); -} - -test "ctl 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, .ctl), "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 "ctl 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 ctl = Node.of(pane.serial, .ctl); - var command: [4140]u8 = undefined; - try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}/old.txt\n", .{directory})).reply.status); - try testing.expectEqual(Status.ok, wr(p, ctl, "name child/../note.txt\nget\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, ctl, "name ./created.txt\nput\n").reply.status); - try testing.expect(!dirtyOf(pane)); - 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.expectEqualStrings(pane.file.?.content, p.fallback.get(expected).?); -} - -test "ctl 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, .ctl), "name /project/src/source.zig\n").reply.status); - _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) }); - const scratch = p.panes[p.active].?; - const ctl = Node.of(scratch.serial, .ctl); - try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch)); - try testing.expectEqual(Status.ok, wr(p, ctl, "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 command: [256]u8 = undefined; - try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name /virtual/pane/{d}/./body\nget\n", .{source.serial})).reply.status); - try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content); - const unchanged = wr(p, ctl, "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 "ctl get reloads the pane from disk and del honours a dirty body" { - const gpa = testing.allocator; - var tmp = testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); - var directory_buf: [4096]u8 = undefined; - const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; - var path_buf: [4096]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); - - const p = try withFile(gpa, "in memory\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - - var name: [std.fs.max_path_bytes + 8]u8 = undefined; - _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path})); - try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status); - try testing.expectEqualStrings("from disk\n", pane.file.?.content); - try testing.expect(!dirtyOf(pane)); - try testing.expect(pane.file.?.history.undo_len > 0); - - _ = wr(p, ctl, "dirty\n"); - try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); - try testing.expect(p.paneBySerial(serial) != null); - _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); - try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); - try testing.expect(p.paneBySerial(serial) == null); -} - -test "ctl get reports missing files without losing dirty contents or applying del" { - const p = try withFile(testing.allocator, "unsaved contents\n"); - defer p.deinit(); - var tmp = testing.tmpDir(.{}); - defer tmp.cleanup(); - var directory_buf: [4096]u8 = undefined; - const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; - var name: [4140]u8 = undefined; - const ctl = Node.of(serialOf(p), .ctl); - try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}/missing.txt\ndirty\n", .{directory})).reply.status); - try testing.expectEqual(E.NOENT, wr(p, ctl, "get\ndel\n").errno()); - const pane = p.panes[0].?; - try testing.expectEqualStrings("unsaved contents\n", pane.file.?.content); - try testing.expect(dirtyOf(pane)); -} - -test "ctl put waits for one host write and stops before del when saving fails" { - const Refusing = struct { - p: *Pardes, - calls: usize = 0, - - fn writeFile(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { - const self: *@This() = @ptrCast(@alignCast(ctx.?)); - self.calls += 1; - self.p.saveFailed(id, "save", error.PermissionDenied); - } - }; - const p = try withFile(testing.allocator, "retained contents\n"); - defer p.deinit(); - var refusing: Refusing = .{ .p = p }; - p.host = .{ .ctx = &refusing, .vtable = &.{ .write_file = Refusing.writeFile } }; - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - for ([_][]const u8{ "clean\n", "dirty\n" }, 0..) |initial, i| { - _ = wr(p, ctl, initial); - const result = wr(p, ctl, "put\ndel\n"); - try testing.expectEqual(E.IO, result.errno()); - try testing.expect(!result.saved); - try testing.expectEqual(i + 1, refusing.calls); - const pane = p.panes[p.paneBySerial(serial).?].?; - try testing.expect(dirtyOf(pane)); - try testing.expectEqualStrings("retained contents\n", pane.file.?.content); - } -} - -test "self directories walk to their namespace parents" { - const p = try withFile(testing.allocator, "contents\n"); - defer p.deinit(); - const root = @intFromEnum(SelfFile.root); - for ([_]struct { node: u64, parent: u64 }{ - .{ .node = root, .parent = namespace_root }, - .{ .node = @intFromEnum(SelfFile.new), .parent = root }, - .{ .node = namespace_panes, .parent = root }, - .{ .node = Node.of(serialOf(p), .dir), .parent = namespace_panes }, - }) |case| { - const result = look_up(p, case.node, ".."); - try testing.expectEqual(Status.ok, result.reply.status); - try testing.expectEqual(case.parent, result.reply.attr.node); - } -} - -test "errors and cons 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); - _ = wr(p, @intFromEnum(SelfFile.cons), "again\n"); - - 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); -} - -test "event records are acme's bytes, one per read, and .again when empty" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); - try testing.expect(p.fs.panes[0].events.empty()); - - const h = call(p, .{ .tag = 10, .op = .open, .node = event }); - try testing.expect(h.reply.handle != 0); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - p.fs.origin = 'M'; - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); - _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); - try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); - try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); - try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); - try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); - - const big = "z" ** max_record_text; - _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); - try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); - - _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { - var used: usize = 0; - var n: usize = 0; - while (n < out.len) { - const a = rd(p, node, 0, 4096); - if (a.reply.status != .ok) break; - @memcpy(store[used..][0..a.bytes.len], a.bytes); - out[n] = store[used..][0..a.bytes.len]; - used += a.bytes.len; - n += 1; - } - return out[0..n]; -} - -test "a write through the filesystem is reported once, attributed to the file it came through" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - _ = call(p, .{ .tag = 40, .op = .open, .node = event }); - var store: [4096]u8 = undefined; - var slots: [16][]const u8 = undefined; - _ = drainEvents(p, event, &store, &slots); - - _ = wr(p, Node.of(serial, .body), "three\n"); - const body_recs = drainEvents(p, event, &store, &slots); - try testing.expect(body_recs.len >= 1); - try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); - for (body_recs[1..]) |r| { - try testing.expectEqual(@as(u8, 'E'), r[0]); - try testing.expect(Action.fromChar(r[1]).?.onTag()); - } - - _ = wr(p, Node.of(serial, .addr), "1"); - _ = wr(p, Node.of(serial, .data), "ONE\n"); - const data_recs = drainEvents(p, event, &store, &slots); - try testing.expectEqual(@as(usize, 2), data_recs.len); - try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); - try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); -} - -test "two event readers each count once, and the second closing leaves the first" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const event = Node.of(serialOf(p), .event); - - _ = call(p, .{ .tag = 12, .op = .open, .node = event }); - _ = call(p, .{ .tag = 13, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - _ = call(p, .{ .tag = 14, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); - try testing.expect(p.fs.scripted(0)); - - _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - _ = call(p, .{ .tag = 16, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - _ = call(p, .{ .tag = 17, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -test "a pane deleted while its event file is open leaves no suppression behind" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); - - const a = call(p, .{ .tag = 18, .op = .open, .node = event }); - const b = call(p, .{ .tag = 19, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - _ = wr(p, Node.of(serial, .ctl), "delete\n"); - try testing.expect(p.paneBySerial(serial) == null); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); - _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); -} - -test "writing an event record back performs the action it names" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - const pane = p.panes[0].?; - - const w = wr(p, event, "FX0 10\n"); - try testing.expectEqual(Status.ok, w.reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - pane.msg_len = 0; - try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - pane.msg_len = 0; - for ([_][]const u8{ - "FX0 10\nFQ0 1\n", // unknown type character - "FX0 999\n", // out of range - "FX0 10", // no newline - "FX5 1\n", // q0 > q1 - "FD0 3\n", // a report, not a request - "F\n", - }) |bad| { - try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); - try testing.expectEqual(@as(usize, 0), pane.msg_len); - } - - _ = call(p, .{ .tag = 23, .op = .open, .node = event }); - p.fs.origin = 'K'; - _ = wr(p, event, "KX0 10\n"); - try testing.expectEqual(@as(u8, 'F'), p.fs.origin); -} - -test "a pane that is not a terminal has no pty/ at all" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 1, - .op = .getattr, - .node = Node.of(serial, .pty), - }).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); - try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 2, - .op = .open, - .node = Node.of(serial, .pty_data), - }).errno()); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - try testing.expect(nameAt(files, "pty") == null); - - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); - try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); - - const before = p.next_serial; - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "pty").errno()); - try testing.expectEqual(before, p.next_serial); -} - -test "a terminal pane's pty/ holds exactly ctl, status and data" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - const pty = look_up(p, dir, "pty"); - try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); - try testing.expect(pty.reply.attr.dir); - try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode); - - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 11), files.len); - try testing.expect(nameAt(files, "pty").?.dir); - - const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); - try testing.expectEqual(@as(usize, 3), inside.len); - try testing.expectEqualStrings("ctl", inside[0].name); - try testing.expectEqualStrings("status", inside[1].name); - try testing.expectEqualStrings("data", inside[2].name); - for (inside) |d| try testing.expect(!d.dir); - try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); - - const ctl = look_up(p, Node.of(serial, .pty), "ctl"); - try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); - try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode); - try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); - - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); - try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); -} - -test "every pty/ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const ctl = Node.of(serialOf(p), .pty_ctl); - - const pane = p.panes[0].?; - const cols = pane.cols; - const rows = pane.rows; - const ws = wr(p, ctl, "winsize 132 44\n"); - try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); - try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); - try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); - try testing.expectEqual(cols, pane.cols); - try testing.expectEqual(rows, pane.rows); - - for ([_]struct { line: []const u8, want: pardes.PtySignal }{ - .{ .line = "sig INT", .want = .int }, - .{ .line = "sig TERM", .want = .term }, - .{ .line = "sig HUP", .want = .hup }, - .{ .line = "sig QUIT", .want = .quit }, - .{ .line = "sig KILL", .want = .kill }, - }) |c| { - const a = wr(p, ctl, c.line); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(c.want, a.signal.?); - } - - const ex = wr(p, ctl, "exec\n"); - try testing.expectEqual(Status.ok, ex.reply.status); - try testing.expect(ex.spawned); - - const both = wr(p, ctl, "winsize 100 30\nsig TERM"); - try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); - try testing.expectEqual(pardes.PtySignal.term, both.signal.?); - - for ([_][]const u8{ - "winsize", // no arguments - "winsize 80", // one argument - "winsize 80 24 extra", // three - "winsize 0 24", // zero is "unknown", never a width - "winsize 80 0", - "winsize -1 24", // not a decimal - "winsize 999999 24", // wider than a u16 - "sig", // no name - "sig INT TERM", // two - "sig SIGINT", - "sig int", // lower case - "sig 9", // a number is one platform's number - "sig USR1", // a real signal, deliberately not offered - "exec /bin/sh", // the effect carries no argv; refused, never ignored - "raw", - "cooked", - "winsize 80 24\nbogus", // a good verb beside a bad one - "bogus\nwinsize 80 24", - "name x", // a `ctl` verb; the two files share no vocabulary - "del", - }) |bad| { - const a = wr(p, ctl, bad); - try testing.expectEqual(E.INVAL, a.errno()); - try testing.expect(a.winsize == null); - try testing.expect(a.signal == null); - try testing.expect(!a.spawned); - } - - const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); - try testing.expectEqual(Status.ok, spaced.reply.status); - try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); - try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); -} - -const FakeTty = struct { - taken: bool, - - const vtable: pardes.Host.VTable = .{ .tty_taken = answer }; - - fn answer(ctx: ?*anyopaque, pane: u8) bool { - _ = pane; - const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); - return f.taken; - } -}; - -test "owned cwd pty exec rejects long paths before applying its batch" { - const p = try withTerm(testing.allocator); - defer p.deinit(); - const pane = p.panes[0].?; - const ctl = Node.of(pane.serial, .pty_ctl); - var path: [1025]u8 = @splat('d'); - path[0] = '/'; - p.setCwd(0, &path); - for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| { - const result = wr(p, ctl, command); - try testing.expectEqual(E.INVAL, result.errno()); - try testing.expect(!result.spawned); - try testing.expect(result.signal == null and result.winsize == null); - } - try testing.expectEqualStrings(&path, pane.cwdSlice()); - p.setCwd(0, path[0..pardes.effect_path_cap]); - const accepted = wr(p, ctl, "exec\n"); - try testing.expectEqual(Status.ok, accepted.reply.status); - try testing.expect(accepted.spawned); -} - -test "owned cwd name promotion releases the former directory" { - const p = try 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, .ctl), "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 "pty/status reports the grid and who holds the tty" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const pane = p.panes[0].?; - const status = Node.of(pane.serial, .pty_status); - - const a = rd(p, status, 0, 256); - try testing.expectEqual(Status.ok, a.reply.status); - var want: [64]u8 = undefined; - const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); - try testing.expectEqualStrings(whole, a.bytes); - try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); - try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); - - var probe: FakeTty = .{ .taken = true }; - p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable }; - const held = rd(p, status, 0, 256); - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), - held.bytes, - ); -} - -test "pty/data writes at the shell and reads the raw stream" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const data = Node.of(serial, .pty_data); - - const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("ls -l\r", w.pty()); - try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); - try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); - - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); - try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); - p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); - - p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); - while (p.nextEffect()) |_| {} - _ = call(p, .{ .tag = 6, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - _ = call(p, .{ .tag = 7, .op = .open, .node = data }); - _ = call(p, .{ .tag = 8, .op = .open, .node = data }); - _ = call(p, .{ .tag = 9, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); -} - -test "the pty queue drops the oldest at its cap" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const data = Node.of(serialOf(p), .pty_data); - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - - const oldest: [4096]u8 = @splat('A'); - const rest: [4096]u8 = @splat('B'); - notePtyOutput(p, 0, &oldest); - for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest); - const q = &p.fs.panes[0].pty_out; - try testing.expect(q.buf.items.len - q.head <= queue_cap); - - var seen: usize = 0; - while (true) { - const a = rd(p, data, 0, 1 << 16); - if (a.reply.status == .again) break; - try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); - if (a.bytes.len == 0) break; - seen += a.bytes.len; - } - try testing.expect(seen > 0 and seen <= queue_cap); +test { + _ = tree; } diff --git a/src/main.zig b/src/main.zig index 40ec8667..4b1910bd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -281,10 +281,10 @@ fn nativeMain(init: std.process.Init) !void { const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; if (serial == 0) break :forwarding; var ctl_buf: [64]u8 = undefined; - const ctl = try std.fmt.bufPrint(&ctl_buf, "/self/pane/{d}/ctl", .{serial}); + const ctl = try std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}); const word = positional orelse { var tag_buf: [64]u8 = undefined; - const tag = try std.fmt.bufPrint(&tag_buf, "/self/pane/{d}/tag", .{serial}); + const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{serial}); const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); diff --git a/src/memory.zig b/src/memory.zig index 60ec1bb6..f30e4392 100644 --- a/src/memory.zig +++ b/src/memory.zig @@ -19,7 +19,8 @@ pub const limits = struct { pub const message_log = if (board) 16 else 128; pub const max_tag_tail: usize = if (board) 512 else 4096; pub const host_path_cap: usize = if (board) 0 else 4095; - pub const embedded_sources = !board; + /// -Dembed-sources: the editor serves its own sources under /src. + pub const embedded_sources = config.embed_sources; pub const hexdump_row_bytes: u32 = if (board) 8 else 16; pub const arena = struct { @@ -138,7 +139,7 @@ test "memory limits preserve desktop capacities" { try std.testing.expectEqual(128, limits.message_log); try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail); try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap); - try std.testing.expect(limits.embedded_sources); + try std.testing.expectEqual(config.embed_sources, limits.embedded_sources); try std.testing.expectEqual(@as(u32, 16), limits.hexdump_row_bytes); try std.testing.expectEqual(32 * MiB, limits.arena.pardes); try std.testing.expectEqual(16 * MiB, limits.arena.frame); diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig new file mode 100644 index 00000000..f7642b9e --- /dev/null +++ b/src/ninep/addr.zig @@ -0,0 +1,276 @@ +//! The address language of a pane's `addr` file: acme's, with mvzr regexps. +const std = @import("std"); +const mvzr = @import("mvzr"); +const modal = @import("../modal.zig"); +const pane_files = @import("pane.zig"); + +pub const Range = pane_files.State.Range; + +fn clip(n: usize) u32 { + return std.math.cast(u32, n) orelse std.math.maxInt(u32); +} + +fn safePattern(pat: []const u8) bool { + var i: usize = 0; + while (i < pat.len) : (i += 1) { + if (pat[i] != '\\') continue; + if (i + 1 >= pat.len) return false; + i += 1; + } + return true; +} + +pub const Addr = struct { + text: []const u8, + lim: ?Range, + expr: []const u8, + i: usize = 0, + depth: u8 = 0, + + const max_depth = 32; + const Size = enum { char, line }; + + pub fn address(a: *Addr, ar_in: Range) ?Range { + const start = a.i; + var ar = ar_in; + var r = ar_in; + var dir: u8 = 0; + var size: Size = .line; + var c: u8 = 0; + while (a.i < a.expr.len) { + const prevc = c; + c = a.expr[a.i]; + a.i += 1; + switch (c) { + ',', ';' => { + if (c == ';') ar = r; + if (prevc == 0) r.q0 = 0; // lhs defaults to 0 + if (a.i >= a.expr.len) { + r.q1 = clip(a.text.len); // rhs defaults to $ + } else { + if (a.depth >= max_depth) return null; + a.depth += 1; + const nr = a.address(ar) orelse return null; + a.depth -= 1; + r.q1 = nr.q1; + } + return r; + }, + '+', '-' => { + if (prevc == '+' or prevc == '-') { + const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; + if (nc != '#' and nc != '/' and nc != '?') + r = a.number(r, 1, prevc, .line) orelse return null; + } + dir = c; + }, + '.', '$' => { + if (a.i != start + 1) { + a.i -= 1; + return r; + } + r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; + dir = if (a.i < a.expr.len) '+' else 0; + }, + '#', '0'...'9' => { + var digit = c; + if (c == '#') { + if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { + a.i -= 1; + return r; + } + digit = a.expr[a.i]; + a.i += 1; + size = .char; + } + var n: u64 = digit - '0'; + while (a.i < a.expr.len) : (a.i += 1) { + const d = a.expr[a.i]; + if (d < '0' or d > '9') break; + n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); + } + r = a.number(r, @intCast(n), dir, size) orelse return null; + dir = 0; + size = .line; + }, + '/', '?' => { + const back = c == '?'; + r = a.regexp(r, a.pattern(c), back) orelse return null; + dir = 0; + size = .line; + }, + else => { + a.i -= 1; + return r; + }, + } + } + if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; + return r; + } + + fn pattern(a: *Addr, delim: u8) []const u8 { + const s = a.i; + while (a.i < a.expr.len) { + const c = a.expr[a.i]; + if (c == '\n') break; + a.i += 1; + if (c == '\\') { + if (a.i < a.expr.len) a.i += 1; + continue; + } + if (c == delim) return a.expr[s .. a.i - 1]; + } + return a.expr[s..a.i]; + } + + fn number(a: *Addr, r_in: Range, n: u32, dir: u8, size: Size) ?Range { + var r = r_in; + if (size == .char) { + var off: i64 = n; + if (dir == '+') { + off = @as(i64, r.q1) + n; + } else if (dir == '-') { + if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); + off = @as(i64, r.q0) - n; + } + if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; + const g = clip(modal.graphemeStart(a.text, @intCast(off))); + return .{ .q0 = g, .q1 = g }; + } + var line: i64 = n; + var q0: usize = r.q0; + var q1: usize = r.q1; + switch (dir) { + '-' => { + if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { + q0 -= 1; + }; + q1 = q0; + while (line > 0 and q0 > 0) { + if (a.text[q0 - 1] == '\n') { + line -= 1; + q1 = q0; + } + q0 -= 1; + } + if (line > 1) return null; + while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; + return .{ .q0 = clip(q0), .q1 = clip(q1) }; + }, + '+' => { + if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { + q1 += 1; + }; + q0 = q1; + }, + else => { + q0 = 0; + q1 = 0; + }, + } + while (line > 0 and q1 < a.text.len) { + const ch = a.text[q1]; + q1 += 1; + if (ch == '\n' or q1 == a.text.len) { + line -= 1; + if (line > 0) q0 = q1; + } + } + if (line > 0) return null; + return .{ .q0 = clip(q0), .q1 = clip(q1) }; + } + + fn regexp(a: *Addr, r: Range, pat: []const u8, back: bool) ?Range { + if (pat.len == 0 or !safePattern(pat)) return null; + const re = mvzr.compile(pat) orelse return null; + if (back) { + const hi = @min(@as(usize, r.q0), a.text.len); + var best: ?mvzr.Match = null; + var at: usize = 0; + while (at < hi) { + const m = re.matchPos(at, a.text[0..hi]) orelse break; + best = m; + at = if (m.end > m.start) m.end else m.end + 1; + } + const m = best orelse return null; + return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; + } + const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; + const from = @min(@as(usize, r.q1), hi); + const m = re.match(a.text[from..hi]) orelse return null; + return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; + } +}; + +const testing = std.testing; +const th = @import("testing.zig"); +const tree = @import("tree.zig"); +const Node = tree.Node; +const E = tree.E; +const Status = tree.Status; + +test "the address language, form by form" { + const gpa = testing.allocator; + const p = try th.withFile(gpa, "one\ntwo\nthree\n"); + defer p.deinit(); + const serial = th.serialOf(p); + const addr = Node.of(serial, .addr); + + const Case = struct { expr: []const u8, q0: u32, q1: u32 }; + for ([_]Case{ + .{ .expr = "#0", .q0 = 0, .q1 = 0 }, + .{ .expr = "#5", .q0 = 5, .q1 = 5 }, + .{ .expr = "0", .q0 = 0, .q1 = 0 }, + .{ .expr = "1", .q0 = 0, .q1 = 4 }, + .{ .expr = "2", .q0 = 4, .q1 = 8 }, + .{ .expr = "$", .q0 = 14, .q1 = 14 }, + .{ .expr = ",", .q0 = 0, .q1 = 14 }, + .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, + .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, + .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, + .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, + .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, + .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, + .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, + }) |c| { + _ = th.wr(p, addr, "#0"); + const w = th.wr(p, addr, c.expr); + try testing.expectEqual(Status.ok, w.reply.status); + const got = th.rd(p, addr, 0, 64); + var want: [32]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), + got.bytes, + ); + } + + _ = th.wr(p, addr, "1"); + _ = th.wr(p, addr, "."); + try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); + + _ = th.wr(p, addr, "$"); + _ = th.wr(p, addr, "?o?"); + try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" + try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); + + _ = th.wr(p, addr, "1"); + _ = th.wr(p, Node.of(serial, .ctl), "limit=addr\n"); + _ = th.wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, th.wr(p, addr, "/three/").errno()); + _ = th.wr(p, Node.of(serial, .ctl), "clean\n"); + _ = th.call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); + try testing.expect(p.fs.panes[0].limit == null); + _ = th.wr(p, addr, "#0"); + try testing.expectEqual(Status.ok, th.wr(p, addr, "/three/").reply.status); + + for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { + _ = th.wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, th.wr(p, addr, bad).errno()); + } + + const nested = "," ** 4096; + _ = th.wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, th.wr(p, addr, nested).errno()); +} diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig new file mode 100644 index 00000000..d61ebf75 --- /dev/null +++ b/src/ninep/ctl.zig @@ -0,0 +1,503 @@ +//! The two ctl files. `look TEXT` is a right click and `exec TEXT` a middle +//! click in the editor's own command language; a pane's ctl adds the addr +//! verbs. Reading /ctl answers the serials the last command made or touched. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const panes = @import("../panes.zig"); +const config = @import("../config.zig"); +const fs = @import("../fs.zig"); +const tree = @import("tree.zig"); +const pane_files = @import("pane.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; + +const Verb = enum { + @"addr=dot", + clean, + cleartag, + dirty, + @"dot=addr", + get, + @"limit=addr", + mark, + nomark, + noscroll, + scroll, + show, +}; + +fn verbIs(line: []const u8, word: []const u8) bool { + if (!std.mem.startsWith(u8, line, word)) return false; + return line.len == word.len or line[word.len] == ' ' or line[word.len] == '\t'; +} + +/// The text after `look` or `exec`, or null when the line is not that verb. +/// Control characters other than tab are refused. +fn textAfter(line: []const u8, verb: []const u8) ?[]const u8 { + if (!verbIs(line, verb)) return null; + const text = std.mem.trim(u8, line[verb.len..], " \t"); + if (text.len == 0) return ""; + for (text) |c| if (c < ' ' and c != '\t') return ""; + return text; +} + +// ---- which panes a command made or touched ---- + +const Serials = struct { + list: [MAX_PANES]u32 = undefined, + len: usize = 0, + + fn snapshot(p: *Pardes) Serials { + var s: Serials = .{}; + for (p.panes) |slot| if (slot) |pane| { + s.list[s.len] = pane.serial; + s.len += 1; + }; + return s; + } + + fn has(s: *const Serials, serial: u32) bool { + for (s.list[0..s.len]) |known| if (known == serial) return true; + return false; + } +}; + +fn noteResult(p: *Pardes, serial: u32) void { + for (p.fs.results[0..p.fs.results_len]) |known| if (known == serial) return; + if (p.fs.results_len == p.fs.results.len) return; + p.fs.results[p.fs.results_len] = serial; + p.fs.results_len += 1; +} + +fn activeSerial(p: *Pardes) ?u32 { + const pane = p.panes[p.active] orelse return null; + return pane.serial; +} + +/// Runs one look or exec from the pane `id`, then records the panes it +/// created; failing that, a look records the pane it focused and an exec +/// the pane it acted on (even one it closed). +fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { + const context = p.panes[id].?.serial; + const before = Serials.snapshot(p); + if (exec) _ = p.execute(id, text) else p.lookAt(id, text); + const after = Serials.snapshot(p); + var created = false; + for (after.list[0..after.len]) |serial| if (!before.has(serial)) { + noteResult(p, serial); + created = true; + }; + if (created) return; + noteResult(p, if (exec) context else activeSerial(p) orelse return); +} + +fn beginResults(p: *Pardes) void { + p.fs.results_len = 0; + p.fs.results_valid = true; +} + +// ---- /ctl ---- + +pub fn writeRoot(p: *Pardes, req: Req) Reply { + for ([2]bool{ false, true }) |apply| { + if (apply) beginResults(p); + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const exec = verbIs(line, "exec"); + const text = textAfter(line, if (exec) "exec" else "look") orelse return Reply.fail(req.tag, E.INVAL); + if (text.len == 0) return Reply.fail(req.tag, E.INVAL); + if (!apply) continue; + if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); + run(p, p.active, exec, text); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +pub fn rootText(p: *Pardes, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); + if (p.fs.results_valid) { + for (p.fs.results[0..p.fs.results_len]) |serial| w.print("{d}\n", .{serial}) catch break; + return w.buffered(); + } + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0; + w.print("pid {d}\nversion {s}\npanes {d}\n", .{ pid, pardes.version, count }) catch {}; + return w.buffered(); +} + +pub const root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len); + +pub fn readRoot(p: *Pardes, req: Req) Reply { + var buf: [root_text_cap]u8 = undefined; + const text = rootText(p, &buf); + 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); +} + +// ---- /pane/<n>/ctl ---- + +fn fontName(p: *Pardes) []const u8 { + const name = p.settings.font.effective_name.get(); + return if (name.len == 0) "default" else name; +} + +fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { + const plain = s.len > 0 and for (s) |c| { + if (c <= ' ' or c == '\'') break false; + } else true; + if (plain) return w.writeAll(s); + try w.writeByte('\''); + for (s) |c| { + if (c == '\'') try w.writeByte('\''); + try w.writeByte(c); + } + try w.writeByte('\''); +} + +pub const pane_text_cap = 7 * 12 + 2 * 256 + 4; + +/// acme's winctlprint: serial, tag length, body length, a reserved zero, the +/// dirty flag, then the width in cells, the font and the tab width. +pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); + w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ + pane.serial, + pane_files.tagOf(p, pane).len, + pane_files.bodyOf(pane).len, + @as(u32, 0), + @intFromBool(pane_files.dirtyOf(pane)), + pane.cols, + }) catch return w.buffered(); + writeQuoted(&w, fontName(p)) catch return w.buffered(); + w.print(" {d:>11} ", .{config.tab_width}) catch {}; + return w.buffered(); +} + +pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { + var buf: [pane_text_cap]u8 = undefined; + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, paneText(p, pane, &buf)) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +pub fn writePane(p: *Pardes, req: Req, serial: u32) Reply { + for ([2]bool{ false, true }) |apply| { + if (apply) beginResults(p); + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); + const errno = paneVerb(p, live, line, apply); + if (errno != 0) return Reply.fail(req.tag, errno); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn paneVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) u16 { + const pane = p.panes[id] orelse return E.INVAL; + const pf = &p.fs.panes[id]; + + for ([_]bool{ false, true }) |exec| if (textAfter(line, if (exec) "exec" else "look")) |text| { + if (text.len == 0) return E.INVAL; + if (apply) run(p, id, exec, text); + return 0; + }; + const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; + if (!apply) return 0; + + switch (v) { + .@"addr=dot" => pf.addr = pane_files.dotOf(pane), + .@"dot=addr" => { + pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); + pane_files.setDot(pane, pf.addr); + }, + .@"limit=addr" => { + pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); + pf.limit = pf.addr; + }, + .clean => if (pane_files.fileOf(pane)) |f| { + f.saved_revision = f.revision; + }, + .dirty => if (pane_files.fileOf(pane)) |f| { + f.saved_revision = f.revision -% 1; + }, + .cleartag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .get => if (pane_files.fileOf(pane)) |f| { + if (panes.Output.fileTraits(f.output).saves) { + if (fs.read(p, f.path)) |bytes| { + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; + } else |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, + }; + } + }, + .mark => { + pf.nomark = false; + panes.File.pushUndo(p, pane); + }, + .nomark => pf.nomark = true, + .noscroll => pf.noscroll = true, + .scroll => pf.noscroll = false, + .show => pane_files.showOffset(pane, pane_files.dotOf(pane).q0), + } + return 0; +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const withFile = th.withFile; +const serialOf = th.serialOf; +const Node = tree.Node; +const Status = tree.Status; +const root_ctl = @intFromEnum(tree.TopFile.ctl); + +test "pane ctl read is index's five fields plus width in cells, font and tab width" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + + const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); + try testing.expectEqual(Status.ok, a.reply.status); + var want: std.ArrayList(u8) = .empty; + defer want.deinit(gpa); + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ + pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, + }); + try testing.expectEqualStrings(want.items, a.bytes); + try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size); + + var quoted: [64]u8 = undefined; + var w = std.Io.Writer.fixed("ed); + try writeQuoted(&w, "DejaVu Sans Mono"); + try testing.expectEqualStrings("'DejaVu Sans Mono'", w.buffered()); + w = std.Io.Writer.fixed("ed); + try writeQuoted(&w, "it's"); + try testing.expectEqualStrings("'it''s'", w.buffered()); +} + +test "every pane ctl verb, and every refusal" { + 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 pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + + try testing.expectEqual(Status.ok, wr(p, ctl_node, "nomark\nnoscroll\ndirty\n").reply.status); + try testing.expect(pf.nomark and pf.noscroll and pane_files.dirtyOf(pane)); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "mark\nscroll\nclean\n").reply.status); + try testing.expect(!pf.nomark and !pf.noscroll and !pane_files.dirtyOf(pane)); + + _ = wr(p, ctl_node, "cleartag\n"); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + + _ = wr(p, Node.of(serial, .addr), "2"); + _ = wr(p, ctl_node, "limit=addr\n"); + try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); + _ = wr(p, ctl_node, "dot=addr\nshow\n"); + try testing.expectEqual(@as(i32, 1), pane.cur_row); + + for ([_][]const u8{ + "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", + "name x.txt", "put", "del", "delete", "Look x", "Exec Save", + }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno()); + + try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nbogus\n").errno()); + try testing.expect(!pane_files.dirtyOf(pane)); +} + +test "exec runs a builtin at the pane and records the pane it acted on" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Msg from ctl\n").reply.status); + try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]); + var expected: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{serial}), rd(p, root_ctl, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec New\n").reply.status); + const made = p.panes[p.active].?; + try testing.expect(made.serial != serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "exec Del\n").reply.status); + try testing.expect(p.paneBySerial(made.serial) == null); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); +} + +test "the root ctl reads editor status until a command runs and validates whole batches" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg root\n"); + defer p.deinit(); + const status = rd(p, root_ctl, 0, 4096); + try testing.expectEqual(Status.ok, status.reply.status); + var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n'); + try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid ")); + var expected: [64]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?); + try testing.expectEqualStrings("panes 1", lines.next().?); + try testing.expect(lines.next() == null); + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }); + try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size); + try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); + + const serial = p.next_serial; + for ([_][]const u8{ "look", "exec", "look \t", "Look /virtual/index", "exec Msg a\x00b", "exec Msg x\nbogus\n", "name x", "put" }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, root_ctl, invalid).errno()); + try testing.expectEqual(serial, p.next_serial); + } + try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes); + + const pane = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, root_ctl, "exec Msg hello there\n").reply.status); + try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_ctl, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_ctl, "look /virtual/index\nexec New\n").reply.status); + var made: [MAX_PANES]u32 = undefined; + var n: usize = 0; + for (p.panes) |slot| if (slot) |q| if (q.serial != pane.serial) { + made[n] = q.serial; + n += 1; + }; + try testing.expectEqual(@as(usize, 2), n); + const listed = rd(p, root_ctl, 0, 4096); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n{d}\n", .{ @min(made[0], made[1]), @max(made[0], made[1]) }), listed.bytes); + const opened = p.panes[p.paneBySerial(@min(made[0], made[1])).?].?; + try testing.expectEqualStrings("/virtual/index", opened.file.?.path); +} + +test "look opens spaced paths and locations without editing the source pane" { + if (!fs.platform_has_fs) return error.SkipZigTest; + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); + var path_buf: [4096]u8 = undefined; + const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; + const p = try withFile(gpa, "source stays intact\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl_node = Node.of(source.serial, .ctl); + const revision = source.file.?.revision; + const undo_len = source.file.?.history.undo_len; + source.cur_col = 3; + var command: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl_node, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); + const opened = p.panes[p.active].?; + try testing.expect(opened != source); + try testing.expectEqualStrings(path, opened.file.?.path); + try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); + try testing.expectEqual(@as(i32, 1), opened.cur_row); + try testing.expectEqual(@as(i32, 2), opened.cur_col); + try testing.expectEqualStrings("source stays intact\n", source.file.?.content); + try testing.expectEqual(revision, source.file.?.revision); + try testing.expectEqual(undo_len, source.file.?.history.undo_len); + try testing.expectEqual(@as(i32, 3), source.cur_col); + var expected: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); + + p.active = 0; + try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); + try testing.expect(p.panes[p.active].? == opened); + try testing.expectEqual(@as(i32, 2), opened.cur_row); + try testing.expectEqual(@as(i32, 1), opened.cur_col); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); +} + +test "look validates the whole batch before opening virtual files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "source\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl_node = Node.of(source.serial, .ctl); + const serial = p.next_serial; + for ([_][]const u8{ + "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, ctl_node, invalid).errno()); + try testing.expectEqual(serial, p.next_serial); + try testing.expectEqual(@as(usize, 0), p.active); + try testing.expectEqualStrings("source\n", source.file.?.content); + } + try testing.expectEqual(Status.ok, wr(p, ctl_node, "look /n/self/index\n").reply.status); + const opened = p.panes[p.active].?; + try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); + try testing.expectEqualStrings("source\n", source.file.?.content); +} + +test "get reloads the pane from disk and reports a missing file without losing dirty contents" { + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); + + const p = try withFile(gpa, "in memory\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const pane = p.panes[0].?; + + var name: [std.fs.max_path_bytes + 8]u8 = undefined; + _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path})); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status); + try testing.expectEqualStrings("from disk\n", pane.file.?.content); + try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expect(pane.file.?.history.undo_len > 0); + + _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); + _ = wr(p, Node.of(serial, .body), "unsaved\n"); + try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\nexec Del\n").errno()); + try testing.expect(p.paneBySerial(serial) != null); + try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content); + try testing.expect(pane_files.dirtyOf(pane)); +} + +test "exec Save writes the named file through the host" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status); + try testing.expect(pane_files.dirtyOf(pane)); + const saved = wr(p, Node.of(serial, .ctl), "exec Save\n"); + try testing.expectEqual(Status.ok, saved.reply.status); + try testing.expect(saved.saved); + p.perform(.{ .save_file = .{ .pane = 0 } }); + try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig new file mode 100644 index 00000000..fb49e41b --- /dev/null +++ b/src/ninep/events.zig @@ -0,0 +1,558 @@ +//! Event streams: per-pane `event` records, the editor-wide `log`, and the +//! bounded queues that park a read until something happens. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const tree = @import("tree.zig"); +const pane_files = @import("pane.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; + +pub const queue_cap = 64 * 1024; + +/// Wall-clock seconds, or zero where the platform has no clock. +pub fn now() u32 { + if (comptime !pardes.hosted) return 0; + var ts: std.c.timespec = undefined; + if (std.c.clock_gettime(.REALTIME, &ts) != 0) return 0; + return std.math.cast(u32, ts.sec) orelse 0; +} + +pub const Queue = struct { + buf: std.ArrayList(u8) = .empty, + head: usize = 0, + + pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { + q.buf.deinit(gpa); + q.head = 0; + } + + pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { + if (record.len > std.math.maxInt(u32)) return; + while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { + if (q.peek() == null) return; + q.pop(); + } + q.compact(); + var head: [4]u8 = undefined; + std.mem.writeInt(u32, &head, @intCast(record.len), .little); + q.buf.appendSlice(gpa, &head) catch return; + q.buf.appendSlice(gpa, record) catch { + q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); + return; + }; + } + + pub fn peek(q: *const Queue) ?[]const u8 { + const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; + if (rest.len < 4) return null; + const len = std.mem.readInt(u32, rest[0..4], .little); + if (rest.len < 4 + len) return null; + return rest[4 .. 4 + len]; + } + + pub fn pop(q: *Queue) void { + const record = q.peek() orelse return; + q.head += 4 + record.len; + if (q.head == q.buf.items.len) { + q.buf.clearRetainingCapacity(); + q.head = 0; + } + } + + pub fn popFront(q: *Queue, n: usize) void { + const record = q.peek() orelse return; + if (n >= record.len) return q.pop(); + q.head += n; + std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); + } + + fn compact(q: *Queue) void { + if (q.head == 0 or q.head * 2 < q.buf.items.len) return; + const rest = q.buf.items.len - q.head; + std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); + q.buf.shrinkRetainingCapacity(rest); + q.head = 0; + } + + pub fn empty(q: *const Queue) bool { + return q.peek() == null; + } + + pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { + q.buf.clearAndFree(gpa); + q.head = 0; + } +}; + +/// One record per read; `.again` parks the read until a record arrives. +pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { + const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; + if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); + q.pop(); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +// ---- the editor-wide log ---- + +pub const LogKind = enum { new, del, rename, save }; + +/// A pane installed now is announced by `announce` once the update that made +/// it has finished, when its name is known. +pub fn noteInstall(p: *Pardes, id: usize) void { + if (id < MAX_PANES) p.fs.panes[id].unannounced = true; +} + +/// A pane leaving; one never announced leaves silently. +pub fn noteRetire(p: *Pardes, id: usize, pane: *Pane) void { + if (id >= MAX_PANES) return; + if (p.fs.panes[id].unannounced) { + p.fs.panes[id].unannounced = false; + return; + } + noteLog(p, .del, pane); +} + +/// Called at the end of every update: reports the panes installed by it. +pub fn announce(p: *Pardes) void { + for (p.panes, 0..) |slot, id| { + const pf = &p.fs.panes[id]; + if (!pf.unannounced) continue; + pf.unannounced = false; + if (slot) |pane| noteLog(p, .new, pane); + } +} + +/// Records `<kind> <serial> <name>` while a reader holds /log open. +pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void { + if (p.fs.log_readers == 0) return; + var buf: [4096 + 64]u8 = undefined; + const name = pane_files.nameOf(pane); + const record = std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return; + p.fs.log.push(p.gpa, record); +} + +// ---- per-pane event records ---- + +pub const max_record_text = 256; + +pub const Action = enum(u8) { + body_delete = 'D', + tag_delete = 'd', + body_insert = 'I', + tag_insert = 'i', + body_look = 'L', + tag_look = 'l', + body_exec = 'X', + tag_exec = 'x', + + pub fn char(a: Action) u8 { + return @intFromEnum(a); + } + + pub fn fromChar(c: u8) ?Action { + return std.enums.fromInt(Action, c); + } + + pub fn onTag(a: Action) bool { + return @intFromEnum(a) >= 'a'; + } +}; + +pub const flag_builtin: u32 = 1; +pub const flag_expansion: u32 = 2; +pub const flag_filename: u32 = 4; +pub const flag_chorded: u32 = 8; + +pub fn formatRecord( + buf: []u8, + origin: u8, + action: Action, + q0: u32, + q1: u32, + flag: u32, + text: []const u8, +) []const u8 { + const sent = if (text.len >= max_record_text) text[0..0] else text; + return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ + origin, + action.char(), + q0, + q1, + flag, + sent.len, + sent, + }) catch buf[0..0]; +} + +pub const Span = struct { at: u32, removed: u32, inserted: u32 }; + +pub fn diffSpan(old: []const u8, new: []const u8) Span { + const both = @min(old.len, new.len); + const stride = 64; + var head: usize = 0; + while (head + stride <= both and + std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; + while (head < both and old[head] == new[head]) head += 1; + var tail: usize = 0; + const rest = both - head; + while (tail + stride <= rest and std.mem.eql( + u8, + old[old.len - tail - stride ..][0..stride], + new[new.len - tail - stride ..][0..stride], + )) tail += stride; + while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; + return .{ + .at = @intCast(head), + .removed = @intCast(old.len - tail - head), + .inserted = @intCast(new.len - tail - head), + }; +} + +pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { + if (!p.fs.scripted(id)) return; + const span = diffSpan(old, new); + if (span.removed == 0 and span.inserted == 0) return; + if (span.removed > 0) _ = noteAction( + p, + id, + if (on_tag) .tag_delete else .body_delete, + span.at, + span.at + span.removed, + 0, + "", + ); + if (span.inserted > 0) _ = noteAction( + p, + id, + if (on_tag) .tag_insert else .body_insert, + span.at, + span.at + span.inserted, + 0, + new[span.at..][0..span.inserted], + ); +} + +pub fn noteAction( + p: *Pardes, + id: usize, + action: Action, + q0: u32, + q1: u32, + flag: u32, + text: []const u8, +) bool { + if (!p.fs.scripted(id)) return false; + var buf: [max_record_text + 64]u8 = undefined; + const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); + p.fs.panes[id].events.push(p.gpa, record); + return true; +} + +pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { + if (id >= MAX_PANES or bytes.len == 0) return; + const pf = &p.fs.panes[id]; + if (pf.pty_readers == 0) return; + var off: usize = 0; + while (off < bytes.len) { + const n = @min(bytes.len - off, queue_cap / 2); + pf.pty_out.push(p.gpa, bytes[off..][0..n]); + off += n; + } +} + +const EventRecord = struct { action: Action, q0: u32, q1: u32 }; + +const EventReader = struct { + data: []const u8, + i: usize = 0, + + fn next(er: *EventReader) ?EventRecord { + if (er.i >= er.data.len) return null; + var i = er.i; + if (i + 2 > er.data.len) return null; + i += 1; + const action = Action.fromChar(er.data[i]) orelse return null; + i += 1; + const q0 = scanNumber(er.data, &i) orelse return null; + const q1 = scanNumber(er.data, &i) orelse return null; + while (i < er.data.len and er.data[i] == ' ') i += 1; + if (i >= er.data.len or er.data[i] != '\n') return null; + er.i = i + 1; + return .{ .action = action, .q0 = q0, .q1 = q1 }; + } +}; + +fn scanNumber(data: []const u8, i: *usize) ?u32 { + while (i.* < data.len and data[i.*] == ' ') i.* += 1; + const s = i.*; + var n: u64 = 0; + while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) + n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); + if (i.* == s) return null; + return @intCast(n); +} + +/// Writing a Look or Exec record back performs the action it names. +pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { + const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); + const serial = pane0.serial; + { + const body = pane_files.bodyOf(pane0); + const tag = pane_files.tagOf(p, pane0); + var check: EventReader = .{ .data = req.data }; + while (check.next()) |r| { + switch (r.action) { + .body_look, .tag_look, .body_exec, .tag_exec => {}, + else => return Reply.fail(req.tag, E.INVAL), + } + const n = if (r.action.onTag()) tag.len else body.len; + if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); + } + if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); + } + var run: EventReader = .{ .data = req.data }; + while (run.next()) |r| { + const live = p.paneBySerial(serial) orelse break; + const pane = p.panes[live].?; + const whole = if (r.action.onTag()) pane_files.tagOf(p, pane) else pane_files.bodyOf(pane); + const lo = @min(@as(usize, r.q0), whole.len); + const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); + const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; + switch (r.action) { + .body_exec, .tag_exec => _ = p.execute(live, text), + .body_look, .tag_look => p.lookAt(live, text), + else => unreachable, + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const withFile = th.withFile; +const serialOf = th.serialOf; +const Node = tree.Node; +const Status = tree.Status; + +test "event records are acme's bytes, one per read, and .again when empty" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + + _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); + try testing.expect(p.fs.panes[0].events.empty()); + + const h = call(p, .{ .tag = 10, .op = .open, .node = event }); + try testing.expect(h.reply.handle != 0); + try testing.expectEqual(@as(u16, 1), p.fs.listeners); + + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); + + p.fs.origin = 'M'; + _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); + _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); + try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); + try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); + + _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); + try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); + try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); + + const big = "z" ** max_record_text; + _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); + try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); + + _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); +} + +fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { + var used: usize = 0; + var n: usize = 0; + while (n < out.len) { + const a = rd(p, node, 0, 4096); + if (a.reply.status != .ok) break; + @memcpy(store[used..][0..a.bytes.len], a.bytes); + out[n] = store[used..][0..a.bytes.len]; + used += a.bytes.len; + n += 1; + } + return out[0..n]; +} + +test "a write through the filesystem is reported once, attributed to the file it came through" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + _ = call(p, .{ .tag = 40, .op = .open, .node = event }); + var store: [4096]u8 = undefined; + var slots: [16][]const u8 = undefined; + _ = drainEvents(p, event, &store, &slots); + + _ = wr(p, Node.of(serial, .body), "three\n"); + const body_recs = drainEvents(p, event, &store, &slots); + try testing.expect(body_recs.len >= 1); + try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); + for (body_recs[1..]) |r| { + try testing.expectEqual(@as(u8, 'E'), r[0]); + try testing.expect(Action.fromChar(r[1]).?.onTag()); + } + + _ = wr(p, Node.of(serial, .addr), "1"); + _ = wr(p, Node.of(serial, .data), "ONE\n"); + const data_recs = drainEvents(p, event, &store, &slots); + try testing.expectEqual(@as(usize, 2), data_recs.len); + try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); + try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); +} + +test "two event readers each count once, and the second closing leaves the first" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const event = Node.of(serialOf(p), .event); + + _ = call(p, .{ .tag = 12, .op = .open, .node = event }); + _ = call(p, .{ .tag = 13, .op = .open, .node = event }); + try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); + try testing.expectEqual(@as(u16, 2), p.fs.listeners); + + _ = call(p, .{ .tag = 14, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); + try testing.expect(p.fs.scripted(0)); + + _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); + try testing.expectEqual(@as(u16, 1), p.fs.listeners); + + _ = call(p, .{ .tag = 16, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + _ = call(p, .{ .tag = 17, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); +} + +test "a pane deleted while its event file is open leaves no suppression behind" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + _ = try th.newPane(p); + + const a = call(p, .{ .tag = 18, .op = .open, .node = event }); + const b = call(p, .{ .tag = 19, .op = .open, .node = event }); + try testing.expectEqual(@as(u16, 2), p.fs.listeners); + + _ = wr(p, Node.of(serial, .ctl), "exec Del\n"); + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + + _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); + _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + + try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); +} + +test "writing an event record back performs the action it names" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + const pane = p.panes[0].?; + + const w = wr(p, event, "FX0 10\n"); + try testing.expectEqual(Status.ok, w.reply.status); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + + pane.msg_len = 0; + try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + + pane.msg_len = 0; + for ([_][]const u8{ + "FX0 10\nFQ0 1\n", // unknown type character + "FX0 999\n", // out of range + "FX0 10", // no newline + "FX5 1\n", // q0 > q1 + "FD0 3\n", // a report, not a request + "F\n", + }) |bad| { + try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); + try testing.expectEqual(@as(usize, 0), pane.msg_len); + } + + _ = call(p, .{ .tag = 23, .op = .open, .node = event }); + p.fs.origin = 'K'; + _ = wr(p, event, "KX0 10\n"); + try testing.expectEqual(@as(u8, 'F'), p.fs.origin); +} + +test "the log parks until a pane is created, renamed, saved or deleted" { + const gpa = testing.allocator; + const p = try withFile(gpa, "logged\n"); + defer p.deinit(); + const log = @intFromEnum(tree.TopFile.log); + _ = try th.newPane(p); + try testing.expect(p.fs.log.empty()); + + const opened = call(p, .{ .tag = 1, .op = .open, .node = log }); + try testing.expectEqual(Status.ok, opened.reply.status); + try testing.expectEqual(@as(u16, 1), p.fs.log_readers); + try testing.expectEqual(Status.again, rd(p, log, 0, 4096).reply.status); + + const serial = try th.newPane(p); + const id = p.paneBySerial(serial).?; + var expected: [4200]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p.panes[id].?) }), + rd(p, log, 0, 4096).bytes, + ); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&expected, "rename {d} /tmp/logged.txt\n", .{serial}), + rd(p, log, 0, 4096).bytes, + ); + const saving = wr(p, Node.of(serial, .ctl), "exec Save\n"); + try testing.expectEqual(Status.ok, saving.reply.status); + try testing.expect(saving.saved); + p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&expected, "save {d} /tmp/logged.txt\n", .{serial}), + rd(p, log, 0, 4096).bytes, + ); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Del\n").reply.status); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}), + rd(p, log, 0, 4096).bytes, + ); + try testing.expectEqual(Status.again, rd(p, log, 0, 4096).reply.status); + + _ = call(p, .{ .tag = 2, .op = .release, .node = log, .handle = opened.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.log_readers); + _ = try th.newPane(p); + try testing.expect(p.fs.log.empty()); + try testing.expectEqual(@as(usize, 0), p.fs.log.buf.capacity); +} 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); +} diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig new file mode 100644 index 00000000..e6696b85 --- /dev/null +++ b/src/ninep/pty.zig @@ -0,0 +1,388 @@ +//! A terminal pane's pty/: ctl (winsize, sig, exec), status and the raw data stream. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const tree = @import("tree.zig"); +const pane_files = @import("pane.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; + +pub fn fileNamed(name: []const u8) ?PaneFile { + if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; + if (std.mem.eql(u8, name, "status")) return .pty_status; + if (std.mem.eql(u8, name, "data")) return .pty_data; + return null; +} + +const Verb = enum { winsize, sig, exec }; + +fn dimension(word: []const u8) ?u16 { + if (word.len == 0 or word.len > 5) return null; + for (word) |c| if (c < '0' or c > '9') return null; + const n = std.fmt.parseInt(u16, word, 10) catch return null; + return if (n == 0) null else n; +} + +fn signalNamed(word: []const u8) ?pardes.PtySignal { + if (std.mem.eql(u8, word, "INT")) return .int; + if (std.mem.eql(u8, word, "TERM")) return .term; + if (std.mem.eql(u8, word, "HUP")) return .hup; + if (std.mem.eql(u8, word, "QUIT")) return .quit; + if (std.mem.eql(u8, word, "KILL")) return .kill; + return null; +} + +pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { + for ([2]bool{ false, true }) |apply| { + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (!verb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { + const pane = p.panes[id] orelse return false; + var words = std.mem.tokenizeAny(u8, line, " \t"); + const v = std.meta.stringToEnum(Verb, words.next() orelse return false) orelse return false; + switch (v) { + .winsize => { + const cols = dimension(words.next() orelse return false) orelse return false; + const rows = dimension(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + }, + .sig => { + const which = signalNamed(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); + }, + .exec => { + if (words.next() != null) return false; + if (pane.cwdSlice().len > pardes.effect_path_cap) return false; + if (!apply) return true; + p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); + }, + } + return true; +} + +pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ + pane.cols, + pane.rows, + @intFromBool(p.hostTtyTaken(id)), + }) catch {}; + return tree.stagedReply(p, req); +} + +pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { + if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; + const out = p.fs.stage(p.gpa); + while (out.items.len < req.size) { + const chunk = pf.pty_out.peek() orelse break; + const n = @min(chunk.len, req.size - out.items.len); + out.appendSlice(p.gpa, chunk[0..n]) catch break; + pf.pty_out.popFront(n); + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +pub fn writeData(p: *Pardes, req: Req, id: usize) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + const take = pane_files.wholeUtf8(req.data); + p.emitWrite(id, req.data[0..take]); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const events = @import("events.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const rdir = th.rdir; +const look_up = th.look_up; +const withFile = th.withFile; +const withTerm = th.withTerm; +const serialOf = th.serialOf; +const Node = tree.Node; +const Status = tree.Status; + +test "a pane that is not a terminal has no pty/ at all" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 1, + .op = .getattr, + .node = Node.of(serial, .pty), + }).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); + try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 2, + .op = .open, + .node = Node.of(serial, .pty_data), + }).errno()); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + + var buf: [32]th.Dirent = undefined; + const files = th.dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + try testing.expect(th.nameAt(files, "pty") == null); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); + try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); +} + +test "a terminal pane's pty/ holds exactly ctl, status and data" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + const pty = look_up(p, dir, "pty"); + try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); + try testing.expect(pty.reply.attr.dir); + try testing.expectEqual(@as(u16, 0o755), pty.reply.attr.mode); + + var buf: [32]th.Dirent = undefined; + const files = th.dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 11), files.len); + try testing.expect(th.nameAt(files, "pty").?.dir); + + const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); + try testing.expectEqual(@as(usize, 3), inside.len); + try testing.expectEqualStrings("ctl", inside[0].name); + try testing.expectEqualStrings("status", inside[1].name); + try testing.expectEqualStrings("data", inside[2].name); + for (inside) |d| try testing.expect(!d.dir); + try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); + + const ctl = look_up(p, Node.of(serial, .pty), "ctl"); + try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); + try testing.expectEqual(@as(u16, 0o222), ctl.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o444), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); + + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); + try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); +} + +test "every pty/ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const ctl = Node.of(serialOf(p), .pty_ctl); + + const pane = p.panes[0].?; + const cols = pane.cols; + const rows = pane.rows; + const ws = wr(p, ctl, "winsize 132 44\n"); + try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); + try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); + try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); + try testing.expectEqual(cols, pane.cols); + try testing.expectEqual(rows, pane.rows); + + for ([_]struct { line: []const u8, want: pardes.PtySignal }{ + .{ .line = "sig INT", .want = .int }, + .{ .line = "sig TERM", .want = .term }, + .{ .line = "sig HUP", .want = .hup }, + .{ .line = "sig QUIT", .want = .quit }, + .{ .line = "sig KILL", .want = .kill }, + }) |c| { + const a = wr(p, ctl, c.line); + try testing.expectEqual(Status.ok, a.reply.status); + try testing.expectEqual(c.want, a.signal.?); + } + + const ex = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, ex.reply.status); + try testing.expect(ex.spawned); + + const both = wr(p, ctl, "winsize 100 30\nsig TERM"); + try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); + try testing.expectEqual(pardes.PtySignal.term, both.signal.?); + + for ([_][]const u8{ + "winsize", // no arguments + "winsize 80", // one argument + "winsize 80 24 extra", // three + "winsize 0 24", // zero is "unknown", never a width + "winsize 80 0", + "winsize -1 24", // not a decimal + "winsize 999999 24", // wider than a u16 + "sig", // no name + "sig INT TERM", // two + "sig SIGINT", + "sig int", // lower case + "sig 9", // a number is one platform's number + "sig USR1", // a real signal, deliberately not offered + "exec /bin/sh", // the effect carries no argv; refused, never ignored + "raw", + "cooked", + "winsize 80 24\nbogus", // a good verb beside a bad one + "bogus\nwinsize 80 24", + "look x", // a `ctl` verb; the two files share no vocabulary + "exec Del", + }) |bad| { + const a = wr(p, ctl, bad); + try testing.expectEqual(E.INVAL, a.errno()); + try testing.expect(a.winsize == null); + try testing.expect(a.signal == null); + try testing.expect(!a.spawned); + } + + const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); + try testing.expectEqual(Status.ok, spaced.reply.status); + try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); + try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); +} + +test "owned cwd pty exec rejects long paths before applying its batch" { + const p = try withTerm(testing.allocator); + defer p.deinit(); + const pane = p.panes[0].?; + const ctl = Node.of(pane.serial, .pty_ctl); + var path: [1025]u8 = @splat('d'); + path[0] = '/'; + p.setCwd(0, &path); + for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| { + const result = wr(p, ctl, command); + try testing.expectEqual(E.INVAL, result.errno()); + try testing.expect(!result.spawned); + try testing.expect(result.signal == null and result.winsize == null); + } + try testing.expectEqualStrings(&path, pane.cwdSlice()); + p.setCwd(0, path[0..pardes.effect_path_cap]); + const accepted = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, accepted.reply.status); + try testing.expect(accepted.spawned); +} + +test "pty/status reports the grid and who holds the tty" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const pane = p.panes[0].?; + const status = Node.of(pane.serial, .pty_status); + + const a = rd(p, status, 0, 256); + try testing.expectEqual(Status.ok, a.reply.status); + var want: [64]u8 = undefined; + const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); + try testing.expectEqualStrings(whole, a.bytes); + try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); + try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); + + var probe: th.FakeTty = .{ .taken = true }; + p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable }; + const held = rd(p, status, 0, 256); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), + held.bytes, + ); +} + +test "pty/data writes at the shell and reads the raw stream" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const data = Node.of(serial, .pty_data); + + const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); + try testing.expectEqual(@as(u32, 6), w.reply.written); + try testing.expectEqualStrings("ls -l\r", w.pty()); + try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); + try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); + + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); + try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); + + p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); + while (p.nextEffect()) |_| {} + _ = call(p, .{ .tag = 6, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 7, .op = .open, .node = data }); + _ = call(p, .{ .tag = 8, .op = .open, .node = data }); + _ = call(p, .{ .tag = 9, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); +} + +test "the pty queue drops the oldest at its cap" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const data = Node.of(serialOf(p), .pty_data); + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + + const oldest: [4096]u8 = @splat('A'); + const rest: [4096]u8 = @splat('B'); + events.notePtyOutput(p, 0, &oldest); + for (0..events.queue_cap / rest.len + 4) |_| events.notePtyOutput(p, 0, &rest); + const q = &p.fs.panes[0].pty_out; + try testing.expect(q.buf.items.len - q.head <= events.queue_cap); + + var seen: usize = 0; + while (true) { + const a = rd(p, data, 0, 1 << 16); + if (a.reply.status == .again) break; + try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); + if (a.bytes.len == 0) break; + seen += a.bytes.len; + } + try testing.expect(seen > 0 and seen <= events.queue_cap); +} diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig new file mode 100644 index 00000000..5b68b34c --- /dev/null +++ b/src/ninep/screen.zig @@ -0,0 +1,351 @@ +//! /screen, /listeners and the snapshot handles that freeze a screen or a +//! terminal history between open and close. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const panes = @import("../panes.zig"); +const tree = @import("tree.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; + +pub const Snapshot = struct { node: u64 = 0, bytes: ?[]const u8 = null }; +pub const snapshot_slots = 32; + +pub fn render(p: *Pardes) ![]u8 { + var arena: std.heap.ArenaAllocator = .init(p.gpa); + defer arena.deinit(); + const surface = try p.render(arena.allocator()); + var styles: std.ArrayList(pardes.CellStyle) = .empty; + var indices: std.ArrayList(usize) = .empty; + for (surface.cells) |cell| { + const style: pardes.CellStyle = if (cell.default) .{} else cell.style; + const index = for (styles.items, 0..) |previous, i| { + if (std.meta.eql(style, previous)) break i; + } else new: { + try styles.append(arena.allocator(), style); + break :new styles.items.len - 1; + }; + try indices.append(arena.allocator(), index); + } + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + const writer = &out.writer; + try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows }); + try std.json.Stringify.value(surface.cursor, .{}, writer); + try writer.writeAll(",\"styles\":"); + try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer); + try writer.writeAll(",\"cells\":["); + for (surface.cells, indices.items, 0..) |cell, index, i| { + if (i != 0) try writer.writeByte(','); + try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer); + } + try writer.writeAll("]}\n"); + return out.toOwnedSlice(); +} + +/// A screen freezes at open; a terminal body freezes at its first read. +pub fn openSnapshot(p: *Pardes, req: Req, of_screen: bool) Reply { + for (&p.fs.snapshots, 0..) |*slot, i| { + if (slot.node != 0) continue; + const bytes = if (of_screen) render(p) catch return Reply.fail(req.tag, E.NOMEM) else null; + slot.* = .{ .node = req.node, .bytes = bytes }; + return .{ .tag = req.tag, .handle = @intCast(i + 1) }; + } + return Reply.fail(req.tag, E.NFILE); +} + +pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { + if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL); + const snapshot = &p.fs.snapshots[req.handle - 1]; + if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL); + if (snapshot.bytes == null) { + const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); + snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); + } + const bytes = snapshot.bytes.?; + const off = @min(req.off, bytes.len); + const len = @min(bytes.len - off, req.size); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; +} + +/// True when the handle named a snapshot, which is now released. +pub fn releaseSnapshot(p: *Pardes, req: Req) bool { + if (req.handle == 0 or req.handle > p.fs.snapshots.len) return false; + const snapshot = &p.fs.snapshots[req.handle - 1]; + if (snapshot.node != req.node) return false; + if (snapshot.bytes) |bytes| p.gpa.free(bytes); + snapshot.* = .{}; + return true; +} + +pub fn readListeners(p: *Pardes, req: Req) Reply { + var buf: [512]u8 = undefined; + var text = std.Io.Writer.fixed(&buf); + if (p.fs.socket_path.len != 0) + text.print("unix!{s}\n", .{p.fs.socket_path}) catch return Reply.fail(req.tag, E.IO); + for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { + const address = maybe orelse continue; + switch (address) { + .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch + return Reply.fail(req.tag, E.IO), + .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch + return Reply.fail(req.tag, E.IO), + } + } + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, text.buffered()) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const fs = @import("../fs.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const withFile = th.withFile; +const withTerm = th.withTerm; +const serialOf = th.serialOf; +const Node = tree.Node; +const Status = tree.Status; +const screen_node = @intFromEnum(tree.TopFile.screen); + +test "listener addresses are readable canonical dials with bounded partial reads" { + const gpa = testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/a socket.sock"; + p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; + p.fs.quic_address = .{ .ip6 = .loopback(5641) }; + const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n"; + for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| { + const bytes = try fs.read(p, path); + defer gpa.free(bytes); + try testing.expectEqualStrings(expected, bytes); + try testing.expectError(error.ReadOnlyFilesystem, fs.write(p, path, "")); + } + const node = @intFromEnum(tree.TopFile.listeners); + var off: usize = 0; + while (off < expected.len) { + const part = rd(p, node, off, 3); + try testing.expectEqual(Status.ok, part.reply.status); + try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes); + off += part.bytes.len; + } + try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len); +} + +test "terminal body handles keep one history snapshot across fragmented reads" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); + while (p.nextEffect()) |_| {} + const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); + defer gpa.free(original); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 }); + try testing.expectEqualStrings(original[0..3], first.bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } }); + while (p.nextEffect()) |_| {} + var offset: usize = 3; + while (offset < original.len) { + const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 }); + try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes); + offset += part.bytes.len; + } + try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes); + _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle }); + const newer = call(p, .{ .tag = 6, .op = .open, .node = node }); + try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle }); +} + +test "an empty terminal body snapshot stays empty while output continues" { + const p = try withTerm(std.testing.allocator); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); + try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null); + p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); + const newer = call(p, .{ .tag = 5, .op = .open, .node = node }); + try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle }); +} + +test "terminal body snapshots are lazy bounded and released after the pane closes" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + var handles: [snapshot_slots]u32 = undefined; + for (&handles) |*opened_handle| { + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + opened_handle.* = opened.reply.handle; + } + for (p.fs.snapshots) |snapshot| { + try testing.expectEqual(node, snapshot.node); + try testing.expect(snapshot.bytes == null); + } + try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno()); + const scratch = try th.newPane(p); + const scratch_id = p.paneBySerial(scratch).?; + try testing.expect(p.panes[scratch_id].?.file != null); + p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } }); + while (p.nextEffect()) |_| {} + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.gpa = failing.allocator(); + const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 }); + p.gpa = gpa; + try testing.expectEqual(E.NOMEM, failed.errno()); + try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null); + const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 }); + try testing.expectEqualStrings("latest output", first.bytes); + const saved = p.fs.snapshots[handles[0] - 1].bytes.?; + failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.gpa = failing.allocator(); + const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 }); + p.gpa = gpa; + try testing.expectEqualStrings("output", second.bytes); + try testing.expect(!failing.has_induced_failure); + try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno()); + _ = call(p, .{ .tag = 7, .op = .release, .node = screen_node, .handle = handles[0] }); + try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node); + try p.removePane(0); + for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle }); + for (p.fs.snapshots) |snapshot| { + try testing.expectEqual(@as(u64, 0), snapshot.node); + try testing.expect(snapshot.bytes == null); + } +} + +test "screen snapshots preserve rendered cells and styles until their handle is released" { + const gpa = testing.allocator; + const p = try withFile(gpa, "const value = 1;\n"); + defer p.deinit(); + const opened = call(p, .{ .tag = 1, .op = .open, .node = screen_node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?; + const original = try gpa.dupe(u8, snapshot); + defer gpa.free(original); + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{}); + defer parsed.deinit(); + const data = parsed.value.object; + try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer); + try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer); + try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len); + try testing.expect(data.get("styles").?.array.items.len > 0); + _ = wr(p, Node.of(serialOf(p), .body), "changed\n"); + const newer = call(p, .{ .tag = 2, .op = .open, .node = screen_node }); + try testing.expectEqual(Status.ok, newer.reply.status); + try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?)); + var off: usize = 0; + while (off < original.len) { + const result = call(p, .{ .tag = 3, .op = .read, .node = screen_node, .handle = opened.reply.handle, .off = off, .size = 13 }); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes); + off += result.bytes.len; + } + _ = call(p, .{ .tag = 4, .op = .release, .node = screen_node, .handle = opened.reply.handle }); + _ = call(p, .{ .tag = 5, .op = .release, .node = screen_node, .handle = newer.reply.handle }); + for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0); +} + +test "screen inspection preserves acknowledged presentation and the next real frame" { + const gpa = testing.allocator; + for (std.enums.values(pardes.layout.Transition)) |transition| { + errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); + const control = try withFile(gpa, "const value = 1;\n"); + defer control.deinit(); + const inspected = try withFile(gpa, "const value = 1;\n"); + defer inspected.deinit(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + for ([_]*Pardes{ control, inspected }) |p| { + p.settings.panel_transition = .off; + p.update(.tick); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.settings.panel_transition = transition; + } + for (0..4) |phase| { + for ([_]*Pardes{ control, inspected }) |p| switch (phase) { + 0 => {}, + 1 => p.update(.{ .command = "New" }), + 2 => { + p.update(.tick); + try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status); + }, + 3 => { + for (0..transition.frames() + 1) |_| p.update(.tick); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.update(.{ .command = "Del" }); + }, + else => unreachable, + }; + for (0..2) |_| { + const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen_node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen_node, .handle = opened.reply.handle, .size = 32 }); + try testing.expectEqual(Status.ok, captured.reply.status); + try testing.expect(captured.bytes.len > 0); + try testing.expectEqual(Status.ok, call(inspected, .{ + .tag = 3, + .op = .release, + .node = screen_node, + .handle = opened.reply.handle, + }).reply.status); + } + const a = &control.presentation; + const b = &inspected.presentation; + try testing.expectEqualDeep(a.shown, b.shown); + try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks); + try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]); + try testing.expectEqual(a.pending, b.pending); + try testing.expectEqual(a.acknowledged, b.acknowledged); + try testing.expectEqual(a.previous_valid, b.previous_valid); + try testing.expectEqual(a.previous_cols, b.previous_cols); + try testing.expectEqual(a.previous_rows, b.previous_rows); + try testing.expectEqualDeep(a.previous_layout, b.previous_layout); + try testing.expectEqual(a.previous_cells.len, b.previous_cells.len); + for (a.previous_cells, b.previous_cells) |*expected, *actual| + try testing.expect(expected.visuallyEqual(actual)); + + const expected = try control.render(arena.allocator()); + const actual = try inspected.render(arena.allocator()); + try testing.expectEqual(expected.cols, actual.cols); + try testing.expectEqual(expected.rows, actual.rows); + try testing.expectEqualDeep(expected.cursor, actual.cursor); + try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks()); + try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs); + try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len); + try testing.expectEqual(expected.cells.len, actual.cells.len); + try testing.expectEqual(@as(usize, 0), expected.nimages); + try testing.expectEqual(@as(usize, 0), actual.nimages); + for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); + for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); + if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0); + if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff()); + if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0); + control.acknowledgePanelPresentation(expected.panelTracks()); + inspected.acknowledgePanelPresentation(actual.panelTracks()); + try testing.expectEqualDeep(a.shown, b.shown); + try testing.expectEqualDeep(a.previous_layout, b.previous_layout); + _ = arena.reset(.retain_capacity); + } + } +} diff --git a/src/ninep/sources.zig b/src/ninep/sources.zig new file mode 100644 index 00000000..b0408594 --- /dev/null +++ b/src/ninep/sources.zig @@ -0,0 +1,152 @@ +//! The editor's embedded sources, served read-only from the root when the +//! build carries them (-Dembed-sources=true). +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const fs = @import("../fs.zig"); +const tree = @import("tree.zig"); + +const Pardes = pardes.Pardes; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; +const sources = fs.sources; + +pub const archive_node: u64 = 1 << 61; + +/// The node of an archive path, or null when nothing embedded lives there. +/// The empty path is the archive root, which is never listed by itself. +pub fn node(path: []const u8) ?u64 { + if (path.len == 0) return archive_node; + if (path.len >= 4096) return null; + for (sources, 0..) |source, i| { + if (std.mem.eql(u8, source.path, path) or + (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path))) + return archive_node | (@as(u64, i) << 12) | path.len; + } + return null; +} + +fn info(n: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } { + if (n == archive_node) return .{ .path = "", .contents = "", .dir = true }; + const index = (n & ~archive_node) >> 12; + if (index >= sources.len) return null; + const source = sources[@intCast(index)]; + const len: usize = @intCast(n & 4095); + if (len > source.path.len) return null; + return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len }; +} + +/// Stages the children of one archive directory; "" lists the top-level +/// directories the served root shows. +pub fn stage(p: *Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void { + var seen: [sources.len][]const u8 = undefined; + var count: usize = 0; + for (sources, 0..) |source, i| { + if (path.len != 0 and + (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue; + const start = if (path.len == 0) 0 else path.len + 1; + const rest = source.path[start..]; + const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len; + const name = rest[0..len]; + var duplicate = false; + for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) { + duplicate = true; + break; + }; + if (duplicate) continue; + seen[count] = name; + count += 1; + if (skip.* > 0) { + skip.* -= 1; + continue; + } + const child = archive_node | (@as(u64, i) << 12) | (start + len); + tree.stageDirent(out, p.gpa, child, len < rest.len, name); + } +} + +pub fn handle(p: *Pardes, req: Req) Reply { + const found = info(req.node) orelse return Reply.fail(req.tag, E.NOENT); + const attr: Reply.Attr = .{ + .node = req.node, + .name = std.fs.path.basename(found.path), + .dir = found.dir, + .size = if (found.dir) 0 else found.contents.len, + .mode = if (found.dir) 0o555 else 0o444, + .mtime = p.fs.started, + }; + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = attr }, + .open => return .{ .tag = req.tag, .handle = 1 }, + .release => return .{ .tag = req.tag }, + .lookup => { + if (!found.dir) return Reply.fail(req.tag, E.NOTDIR); + if (std.mem.eql(u8, req.data, "..")) { + const parent = std.fs.path.dirname(found.path) orelse ""; + if (parent.len == 0) return tree.handle(p, .{ .tag = req.tag, .op = .getattr, .node = tree.root }); + const parent_node = node(parent) orelse return Reply.fail(req.tag, E.NOENT); + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent_node }); + } + if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + var buf: [4096]u8 = undefined; + const path = if (found.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ found.path, req.data }) catch return Reply.fail(req.tag, E.NOENT); + const child = node(path) orelse return Reply.fail(req.tag, E.NOENT); + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = child }); + }, + .readdir => { + if (!found.dir) return Reply.fail(req.tag, E.NOTDIR); + const out = p.fs.stage(p.gpa); + var skip = req.off; + stage(p, out, found.path, &skip); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + .read => { + if (found.dir) return Reply.fail(req.tag, E.PERM); + const off: usize = @intCast(@min(req.off, found.contents.len)); + const bytes = found.contents[off..][0..@min(req.size, found.contents.len - off)]; + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +const testing = std.testing; +const th = @import("testing.zig"); +const limits = @import("../memory.zig").limits; + +test "embedded sources appear under the root only when the build carries them" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + var buf: [64]th.Dirent = undefined; + const top = th.dirents(th.rdir(p, tree.root, 0).bytes, &buf); + const listed = th.nameAt(top, "src"); + try testing.expectEqual(limits.embedded_sources, listed != null); + const looked = th.look_up(p, tree.root, "src"); + try testing.expectEqual(limits.embedded_sources, looked.reply.status == .ok); + try testing.expect(tree.resolveSelf(p, "shaders/../src/../") == tree.root); + if (!limits.embedded_sources) { + try testing.expect(tree.resolveSelf(p, "src/pardes.zig") == null); + try testing.expect(tree.resolveSelf(p, "src") == null); + try testing.expect(th.nameAt(top, "build.zig") == null); + return; + } + try testing.expect(looked.reply.attr.dir); + try testing.expectEqual(@as(u16, 0o555), looked.reply.attr.mode); + try testing.expectEqual(listed.?.node, looked.reply.attr.node); + try testing.expect(th.nameAt(top, "build.zig") == null); + try testing.expect(th.nameAt(top, "build.zig.zon") == null); + const file = th.look_up(p, looked.reply.attr.node, "pardes.zig"); + try testing.expectEqual(tree.Status.ok, file.reply.status); + try testing.expectEqual(@as(u16, 0o444), file.reply.attr.mode); + try testing.expectEqual(@as(u64, fs.sourceBytes("src/pardes.zig").?.len), file.reply.attr.size); + try testing.expectEqual(file.reply.attr.node, tree.resolveSelf(p, "src/pardes.zig").?); + const opened = th.call(p, .{ .tag = 1, .op = .open, .node = file.reply.attr.node }); + const head = th.call(p, .{ .tag = 2, .op = .read, .node = file.reply.attr.node, .handle = opened.reply.handle, .size = 16 }); + try testing.expectEqualStrings(fs.sourceBytes("src/pardes.zig").?[0..16], head.bytes); + const parent = th.look_up(p, looked.reply.attr.node, ".."); + try testing.expectEqual(tree.root, parent.reply.attr.node); + try testing.expectEqual(E.NOENT, th.look_up(p, tree.root, "build.zig").errno()); + try testing.expectEqual(E.NOENT, th.look_up(p, looked.reply.attr.node, "missing.zig").errno()); +} diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig new file mode 100644 index 00000000..ba26cb38 --- /dev/null +++ b/src/ninep/testing.zig @@ -0,0 +1,135 @@ +//! Test helpers shared by the control-tree modules: drive one request +//! through the core and collect the reply and the effects it raised. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const tree = @import("tree.zig"); + +const Pardes = pardes.Pardes; +pub const Req = tree.Req; +pub const Reply = tree.Reply; +pub const E = tree.E; + +pub const Answer = struct { + reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, + bytes: []const u8 = "", + saved: bool = false, + watch: ?bool = null, + pty_buf: [256]u8 = undefined, + pty_len: usize = 0, + winsize: ?struct { cols: u16, rows: u16 } = null, + signal: ?pardes.PtySignal = null, + spawned: bool = false, + + pub fn pty(a: *const Answer) []const u8 { + return a.pty_buf[0..a.pty_len]; + } + + pub fn errno(a: Answer) u16 { + return if (a.reply.status == .err) a.reply.errno else 0; + } +}; + +pub fn call(p: *Pardes, req: Req) Answer { + p.update(.{ .fs_req = req }); + var ans: Answer = .{}; + while (p.nextEffect()) |e| switch (e) { + .fs_reply => |r| { + ans.reply = r; + ans.bytes = p.fsPayload(r); + }, + .save_file, .save_text => ans.saved = true, + .watch => |w| ans.watch = w.on, + .write => |w| { + const b = w.bytes.slice(); + const n = @min(b.len, ans.pty_buf.len - ans.pty_len); + @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); + ans.pty_len += n; + }, + .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, + .signal_pty => |s| ans.signal = s.sig, + .spawn => ans.spawned = true, + else => {}, + }; + return ans; +} + +pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { + return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); +} + +pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer { + return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); +} + +pub fn rdir(p: *Pardes, node: u64, skip: u64) Answer { + return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); +} + +pub fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { + return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); +} + +/// Opens /new once, as a client would, and returns the pane it made. +pub fn newPane(p: *Pardes) !u32 { + const new = @intFromEnum(tree.TopFile.new); + const opened = call(p, .{ .tag = 9, .op = .open, .node = new }); + if (opened.reply.status != .ok or opened.reply.handle == 0) return error.NoPane; + _ = call(p, .{ .tag = 9, .op = .release, .node = new, .handle = opened.reply.handle }); + return opened.reply.handle; +} + +pub fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + errdefer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile(text); + while (p.nextEffect()) |_| {} + return p; +} + +pub fn withTerm(gpa: std.mem.Allocator) !*Pardes { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + errdefer p.deinit(); + while (p.nextEffect()) |_| {} + std.debug.assert(p.panes[0].?.isTerminal()); + return p; +} + +pub fn serialOf(p: *Pardes) u32 { + return p.panes[0].?.serial; +} + +pub const Dirent = struct { node: u64, dir: bool, name: []const u8 }; + +pub fn dirents(bytes: []const u8, out: []Dirent) []Dirent { + var n: usize = 0; + var i: usize = 0; + while (i + 10 <= bytes.len and n < out.len) { + const node = std.mem.readInt(u64, bytes[i..][0..8], .little); + const kind = bytes[i + 8]; + const len = bytes[i + 9]; + i += 10; + if (i + len > bytes.len) break; + out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; + i += len; + n += 1; + } + return out[0..n]; +} + +pub fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { + for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; + return null; +} + +pub const FakeTty = struct { + taken: bool, + + pub const vtable: pardes.Host.VTable = .{ .tty_taken = answer }; + + fn answer(ctx: ?*anyopaque, pane: u8) bool { + _ = pane; + const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); + return f.taken; + } +}; diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig new file mode 100644 index 00000000..f716bf5f --- /dev/null +++ b/src/ninep/tree.zig @@ -0,0 +1,850 @@ +//! The control tree served over 9P: its nodes, lookup, readdir and the +//! dispatch that answers the engine in src/9p.zig. The backend contract +//! (Req, Reply, Op, Status, E) lives here; fs.zig keeps host access and mounts. +//! +//! /README /index /ctl /new /log /screen /listeners +//! /pane/<serial>/{name,body,tag,ctl,addr,data,xdata,sel,errors,event,pty/} +//! /os/... the host filesystem /src/... embedded sources (opt-in) +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const fs = @import("../fs.zig"); +pub const events = @import("events.zig"); +pub const pane = @import("pane.zig"); +pub const addr = @import("addr.zig"); +pub const ctl = @import("ctl.zig"); +pub const pty = @import("pty.zig"); +pub const sources = @import("sources.zig"); +pub const screen = @import("screen.zig"); + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; + +pub const name_capacity: usize = 255; + +// ---- the backend contract ---- + +pub const Op = enum(u8) { + lookup, + getattr, + setattr, + open, + read, + write, + release, + readdir, +}; + +pub const Status = enum(u8) { + ok, + again, + err, +}; + +pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + + pub fn changesPane(req: Req) bool { + return switch (req.op) { + .write, .setattr => true, + .open => req.node == @intFromEnum(TopFile.new), + .lookup, .getattr, .read, .release, .readdir => false, + }; + } +}; + +pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + payload: Payload = .none, + written: u32 = 0, + + pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o644, + mtime: u32 = 0, + }; + + pub const Payload = union(enum) { + none, + staged: u32, + region: struct { pane: u8, serial: u32, off: u32, len: u32 }, + }; + + pub fn fail(tag: u64, e: u16) Reply { + return .{ .tag = tag, .status = .err, .errno = e }; + } +}; + +pub const E = struct { + pub const PERM: u16 = 1; + pub const NOENT: u16 = 2; + pub const IO: u16 = 5; + pub const NOMEM: u16 = 12; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; + pub const NFILE: u16 = 23; + pub const NOSPC: u16 = 28; + pub const NOSYS: u16 = 38; +}; + +pub const out_reserve = 4 * 1024; + +// ---- nodes ---- + +pub const TopFile = enum(u4) { + root = 1, + README, + index, + ctl, + new, + log, + screen, + listeners, + pane, + + pub fn fileName(f: TopFile) []const u8 { + return if (f == .root) "/" else @tagName(f); + } + + pub fn mode(f: TopFile) u16 { + return switch (f) { + .root, .pane => 0o755, + .ctl => 0o666, + .README, .index, .new, .log, .screen, .listeners => 0o444, + }; + } + + pub fn dir(f: TopFile) bool { + return f == .root or f == .pane; + } +}; + +pub const root: u64 = @intFromEnum(TopFile.root); + +pub const PaneFile = enum(u4) { + dir = 0, + name, + body, + tag, + ctl, + addr, + data, + xdata, + sel, + errors, + event, + pty, + pty_ctl, + pty_status, + pty_data, + + pub fn fileName(f: PaneFile) []const u8 { + return switch (f) { + .dir => ".", + .pty_ctl => "ctl", + .pty_status => "status", + .pty_data => "data", + else => @tagName(f), + }; + } + + pub fn mode(f: PaneFile) u16 { + return switch (f) { + .dir, .pty => 0o755, + .errors, .pty_ctl => 0o222, + .pty_status => 0o444, + else => 0o666, + }; + } + + pub fn isDir(f: PaneFile) bool { + return f == .dir or f == .pty; + } + + pub fn inPty(f: PaneFile) bool { + return switch (f) { + .pty, .pty_ctl, .pty_status, .pty_data => true, + else => false, + }; + } +}; + +pub const Node = packed struct(u64) { + file: u4 = 0, + serial: u60 = 0, + + pub fn of(serial: u32, file: PaneFile) u64 { + std.debug.assert(serial != 0); + return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); + } + + pub fn target(node: u64) ?Target { + const n: Node = @bitCast(node); + if (n.serial == 0) { + return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null }; + } + return .{ .pane = .{ + .serial = std.math.cast(u32, n.serial) orelse return null, + .file = std.enums.fromInt(PaneFile, n.file) orelse return null, + } }; + } +}; + +pub const Target = union(enum) { + top: TopFile, + pane: struct { serial: u32, file: PaneFile }, +}; + +fn paneFileNamed(name: []const u8) ?PaneFile { + const f = std.meta.stringToEnum(PaneFile, name) orelse return null; + if (f == .dir) return null; + return if (f.inPty() and f != .pty) null else f; +} + +fn topFileNamed(name: []const u8) ?TopFile { + const f = std.meta.stringToEnum(TopFile, name) orelse return null; + return if (f == .root) null else f; +} + +fn serialNamed(name: []const u8) ?u32 { + if (name.len == 0 or name.len > 10) return null; + for (name) |c| if (c < '0' or c > '9') return null; + const serial = std.fmt.parseInt(u32, name, 10) catch return null; + return if (serial == 0) null else serial; +} + +pub fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { + var best: ?u32 = null; + for (p.panes) |slot| { + const pn = slot orelse continue; + if (pn.serial <= after) continue; + if (best == null or pn.serial < best.?) best = pn.serial; + } + return best; +} + +/// An editor path below the root (`pane/3/body`, `index`, `src/...`) to its node. +pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { + var path_buf: [4096]u8 = undefined; + const normalized = fs.normalizeVirtualPath(path, &path_buf) orelse return null; + var parts = std.mem.tokenizeScalar(u8, normalized, '/'); + const first = parts.next() orelse return root; + if (!std.mem.eql(u8, first, "pane")) { + const top = topFileNamed(first) orelse return sources.node(normalized); + if (top == .pane or parts.next() != null) return null; + return @intFromEnum(top); + } + const serial = serialNamed(parts.next() orelse return @intFromEnum(TopFile.pane)) orelse return null; + const id = p.paneBySerial(serial) orelse return null; + const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null; + if (file.inPty() and !p.panes[id].?.isTerminal()) return null; + if (parts.next()) |name| { + if (file != .pty) return null; + const child = pty.fileNamed(name) orelse return null; + if (parts.next() != null) return null; + return Node.of(serial, child); + } + return Node.of(serial, file); +} + +// ---- staging ---- + +pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { + if (name.len == 0 or name.len > 255) return; + var head: [10]u8 = undefined; + std.mem.writeInt(u64, head[0..8], node, .little); + head[8] = @intFromBool(dir); + head[9] = @intCast(name.len); + out.appendSlice(gpa, &head) catch return; + out.appendSlice(gpa, name) catch return; +} + +/// Answers a read from whatever the handler staged, windowed by offset and size. +pub fn stagedReply(p: *Pardes, req: Req) Reply { + const out = &p.fs.out; + const off = @min(req.off, out.items.len); + const n = @min(out.items.len - off, req.size); + if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); + out.shrinkRetainingCapacity(n); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; +} + +// ---- dispatch ---- + +pub fn handle(p: *Pardes, req: Req) Reply { + if (req.node == fs.os_root or req.node & fs.os_node != 0) return fs.osHandle(p, req); + if (req.node & sources.archive_node != 0) return sources.handle(p, req); + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { + .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), + .top => 'F', + }; + return switch (req.op) { + .lookup => lookup(p, req, target), + .getattr => attrReply(p, req.tag, target), + .setattr => setattr(p, req, target), + .open => open(p, req, target), + .release => release(p, req), + .readdir => readdir(p, req, target), + .read => read(p, req, target), + .write => write(p, req, target), + }; +} + +fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { + switch (target) { + .top => |f| return .{ + .name = f.fileName(), + .node = @intFromEnum(f), + .dir = f.dir(), + .mode = f.mode(), + .size = topSize(p, f), + .mtime = p.fs.started, + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return null; + const pn = p.panes[id].?; + if (t.file.inPty() and !pn.isTerminal()) return null; + return .{ + .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.fileName(), + .node = Node.of(t.serial, t.file), + .dir = t.file.isDir(), + .mode = t.file.mode(), + .size = pane.fileSize(p, id, t.file), + .mtime = pane.mtimeOf(p, pn), + }; + }, + } +} + +fn attrReply(p: *Pardes, tag: u64, target: Target) Reply { + const a = attrOf(p, target) orelse return Reply.fail(tag, E.NOENT); + return .{ .tag = tag, .attr = a }; +} + +fn topSize(p: *Pardes, f: TopFile) u64 { + return switch (f) { + .root, .pane, .new, .log, .screen, .listeners => 0, + .index => pane.indexLen(p), + .README => fs.help.len, + .ctl => ctl: { + var buf: [ctl.root_text_cap]u8 = undefined; + break :ctl ctl.rootText(p, &buf).len; + }, + }; +} + +fn lookup(p: *Pardes, req: Req, target: Target) Reply { + const name = req.data; + if (std.mem.eql(u8, name, "..")) { + const parent: u64 = switch (target) { + .top => |top| if (top.dir()) root else return Reply.fail(req.tag, E.NOTDIR), + .pane => |t| switch (t.file) { + .dir => @intFromEnum(TopFile.pane), + .pty => Node.of(t.serial, .dir), + else => return Reply.fail(req.tag, E.NOTDIR), + }, + }; + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); + } + if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + const node: u64 = switch (target) { + .top => |f| switch (f) { + .root => root: { + if (std.mem.eql(u8, name, "os")) return fs.osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = fs.os_root }); + if (topFileNamed(name)) |t| break :root @intFromEnum(t); + if (sources.node(name)) |n| return sources.handle(p, .{ .tag = req.tag, .op = .getattr, .node = n }); + return Reply.fail(req.tag, E.NOENT); + }, + .pane => pane: { + const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT); + break :pane Node.of(serial, .dir); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| pane: { + _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const f = switch (t.file) { + .dir => paneFileNamed(name), + .pty => pty.fileNamed(name), + else => return Reply.fail(req.tag, E.NOTDIR), + } orelse return Reply.fail(req.tag, E.NOENT); + break :pane Node.of(t.serial, f); + }, + }; + return attrReply(p, req.tag, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT)); +} + +fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime f == .dir or (f.inPty() and f != .pty)) continue; + const present = f != .pty or terminal; + if (present) { + if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.fileName()); + } + } +} + +fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime !f.inPty() or f == .pty) continue; + if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.fileName()); + } +} + +fn readdir(p: *Pardes, req: Req, target: Target) Reply { + const out = p.fs.stage(p.gpa); + var skip = req.off; + switch (target) { + .top => |f| switch (f) { + .root => { + inline for (.{ TopFile.README, TopFile.index, TopFile.ctl, TopFile.new, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); + } + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); + sources.stage(p, out, "", &skip); + }, + .pane => { + var last: u32 = 0; + while (nextSerialAfter(p, last)) |serial| { + last = serial; + if (skip > 0) { + skip -= 1; + continue; + } + var buf: [16]u8 = undefined; + const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; + stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); + } + }, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const terminal = p.panes[id].?.isTerminal(); + switch (t.file) { + .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), + .pty => { + if (!terminal) return Reply.fail(req.tag, E.NOENT); + stagePtyFiles(p, out, t.serial, &skip); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + } + }, + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn newPane(p: *Pardes) ?u32 { + const slot = p.freeSlot() orelse return null; + p.newScratchBelow(p.active); + const pn = p.panes[slot] orelse return null; + return pn.serial; +} + +fn open(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| switch (f) { + // The clone idiom: each open makes one empty pane; reads answer its serial. + .new => { + const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); + return .{ .tag = req.tag, .handle = serial }; + }, + .screen => return screen.openSnapshot(p, req, true), + .log => p.fs.log_readers +|= 1, + else => {}, + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pn = p.panes[id].?; + const pf = &p.fs.panes[id]; + if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); + switch (t.file) { + .body => if (pn.isTerminal()) return screen.openSnapshot(p, req, false), + .ctl => pf.limit = null, + .addr => { + pf.addr = .{}; + pf.limit = null; + }, + .event => { + pf.readers +|= 1; + p.fs.listeners +|= 1; + }, + .pty_data => pf.pty_readers +|= 1, + else => {}, + } + }, + } + return .{ .tag = req.tag, .handle = 1 }; +} + +fn release(p: *Pardes, req: Req) Reply { + if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag }; + const target = Node.target(req.node) orelse return .{ .tag = req.tag }; + switch (target) { + .top => |f| if (f == .log and p.fs.log_readers > 0) { + p.fs.log_readers -= 1; + if (p.fs.log_readers == 0) p.fs.log.clearAndFree(p.gpa); + }, + .pane => |t| { + if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; + const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; + const pf = &p.fs.panes[id]; + if (t.file == .pty_data) { + if (pf.pty_readers == 0) return .{ .tag = req.tag }; + pf.pty_readers -= 1; + if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); + return .{ .tag = req.tag }; + } + if (pf.readers == 0) return .{ .tag = req.tag }; + pf.readers -= 1; + p.fs.listeners -|= 1; + if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); + }, + } + return .{ .tag = req.tag }; +} + +fn setattr(p: *Pardes, req: Req, target: Target) Reply { + if (req.truncate) switch (target) { + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + if (pane.truncate(p, id, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM); + }, + .top => {}, + }; + return attrReply(p, req.tag, target); +} + +fn read(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .README => help: { + const off = @min(req.off, fs.help.len); + const bytes = fs.help[off..][0..@min(req.size, fs.help.len - off)]; + p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM); + break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; + }, + .index => index: { + const out = p.fs.stage(p.gpa); + var last: u32 = 0; + while (nextSerialAfter(p, last)) |serial| { + last = serial; + pane.stageIndexLine(p, out, p.panes[p.paneBySerial(serial).?].?); + } + break :index stagedReply(p, req); + }, + .ctl => ctl.readRoot(p, req), + .new => new: { + if (req.handle == 0) break :new Reply.fail(req.tag, E.INVAL); + p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch break :new Reply.fail(req.tag, E.NOMEM); + break :new stagedReply(p, req); + }, + .log => events.readQueue(p, req, &p.fs.log), + .screen => screen.readSnapshot(p, req, null), + .listeners => screen.readListeners(p, req), + .root, .pane => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pn = p.panes[id].?; + if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return pane.read(p, req, id, pn, t.file); + }, + } +} + +fn write(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .ctl => ctl.writeRoot(p, req), + else => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pn = p.panes[id].?; + if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return pane.write(p, req, id, pn, t.file); + }, + } +} + +// ---- tests ---- + +test { + _ = events; + _ = pane; + _ = addr; + _ = ctl; + _ = pty; + _ = sources; + _ = screen; +} + +const testing = std.testing; +const th = @import("testing.zig"); +const config = @import("../config.zig"); +const call = th.call; +const rd = th.rd; +const wr = th.wr; +const rdir = th.rdir; +const look_up = th.look_up; +const withFile = th.withFile; +const serialOf = th.serialOf; + +test "filesystem inspection preserves pending and displayed Look hover" { + const delay = config.look_preview_delay_frames orelse return; + const gpa = testing.allocator; + const p = try withFile(gpa, "alpha beta gamma\n"); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + p.update(.{ .mouse = .{ + .button = .none, + .kind = .motion, + .col = rect.x + config.GUTTER + config.PREFIX_W + 7, + .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, + } }); + try testing.expect(p.look_hover_wait != null); + const body = Node.of(serialOf(p), .body); + const screen_node = @intFromEnum(TopFile.screen); + for (0..2) |phase| { + if (phase == 1) { + for (0..delay) |_| p.update(.tick); + try testing.expect(p.look_hover_preview != null); + } + const waiting = p.look_hover_wait; + const preview = p.look_hover_preview; + const requests = [_]Req{ + .{ .tag = 1, .op = .lookup, .node = root, .data = "screen" }, + .{ .tag = 1, .op = .lookup, .node = root, .data = "new" }, + .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 2, .op = .getattr, .node = body }, + .{ .tag = 3, .op = .open, .node = body }, + .{ .tag = 4, .op = .read, .node = body, .size = 5 }, + .{ .tag = 5, .op = .release, .node = body, .handle = 1 }, + .{ .tag = 6, .op = .readdir, .node = root, .size = 4096 }, + .{ .tag = 7, .op = .open, .node = screen_node }, + .{ .tag = 8, .op = .read, .node = screen_node, .handle = 1, .size = 32 }, + .{ .tag = 9, .op = .release, .node = screen_node, .handle = 1 }, + }; + for (requests) |req| { + const answer = call(p, req); + try testing.expectEqual(Status.ok, answer.reply.status); + try testing.expect(std.meta.eql(waiting, p.look_hover_wait)); + try testing.expect(std.meta.eql(preview, p.look_hover_preview)); + try testing.expect(p.raw_hover_intent); + } + } + try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); +} + +test "filesystem pane creation and truncation cancel Look hover" { + const requests = [_]Req{ + .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, + }; + for (requests) |request| { + const p = try withFile(testing.allocator, "word\n"); + defer p.deinit(); + p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) }; + p.raw_hover_intent = true; + var req = request; + if (req.op == .setattr) req.node = Node.of(serialOf(p), .body); + try testing.expectEqual(Status.ok, call(p, req).reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); + } +} + +test "readdir lists the root and a pane directory without creating anything" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + var buf: [32]th.Dirent = undefined; + + const before = p.next_serial; + const top_reply = rdir(p, root, 0); + try testing.expectEqual(Status.ok, top_reply.reply.status); + var top_bytes: [4096]u8 = undefined; + @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); + const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); + try testing.expect(top.len >= 9); + for ([_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { + try testing.expectEqualStrings(name, top[i].name); + try testing.expectEqual(i >= 7, top[i].dir); + } + try testing.expectEqual(@intFromEnum(TopFile.pane), top[7].node); + try testing.expectEqual(fs.os_root, top[8].node); + var idbuf: [16]u8 = undefined; + try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); + try testing.expect(th.nameAt(top, "self") == null); + try testing.expect(th.nameAt(top, "cons") == null); + + var rest_buf: [32]th.Dirent = undefined; + const rest = rdir(p, root, 3); + try testing.expectEqual(top.len - 3, th.dirents(rest.bytes, &rest_buf).len); + const eof = rdir(p, root, 99); + try testing.expectEqual(Status.ok, eof.reply.status); + try testing.expectEqual(@as(usize, 0), eof.bytes.len); + + // Walk, stat and open-for-read every top entry a browser would, then every pane file. + for (top) |entry| { + if (std.mem.eql(u8, entry.name, "os")) continue; + const inspected = look_up(p, root, entry.name); + try testing.expectEqual(Status.ok, inspected.reply.status); + try testing.expectEqual(entry.node, inspected.reply.attr.node); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status); + try testing.expectEqual(before, p.next_serial); + } + const panes_dir = rdir(p, @intFromEnum(TopFile.pane), 0); + const listed = th.dirents(panes_dir.bytes, &buf); + try testing.expectEqual(@as(usize, 1), listed.len); + try testing.expectEqual(Node.of(serial, .dir), listed[0].node); + + const dir = rdir(p, Node.of(serial, .dir), 0); + const files = th.dirents(dir.bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "data", "xdata", "sel", "errors", "event" }, 0..) |name, i| + try testing.expectEqualStrings(name, files[i].name); + try testing.expect(th.nameAt(files, ".") == null); + try testing.expect(th.nameAt(files, "rdsel") == null); + try testing.expectEqual(Node.of(serial, .body), th.nameAt(files, "body").?.node); + for (files) |entry| { + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }); + try testing.expectEqual(Status.ok, stat.reply.status); + try testing.expectEqual(p.fs.started, stat.reply.attr.mtime); + } + try testing.expectEqual(before, p.next_serial); + + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); + try testing.expectEqual(E.NOTDIR, rdir(p, @intFromEnum(TopFile.index), 0).errno()); +} + +test "lookup resolves top files, pane serials and pane files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + + try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); + try testing.expect(!look_up(p, root, "new").reply.attr.dir); + try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "new").reply.attr.mode); + try testing.expect(look_up(p, root, "pane").reply.attr.dir); + try testing.expect(look_up(p, root, "os").reply.attr.dir); + try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); + try testing.expectEqual(E.NOENT, look_up(p, root, "self").errno()); + try testing.expectEqual(E.NOENT, look_up(p, root, "cons").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "x").errno()); + + var idbuf: [16]u8 = undefined; + const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial}); + try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno()); + const dir = look_up(p, @intFromEnum(TopFile.pane), serial_name); + try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); + try testing.expect(dir.reply.attr.dir); + try testing.expectEqualStrings(serial_name, dir.reply.attr.name); + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "99999").errno()); + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.pane), "0").errno()); + + const body = look_up(p, Node.of(serial, .dir), "body"); + try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); + const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); + try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); + try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "rdsel").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); +} + +test "each open of new creates exactly one pane whose serial the read answers" { + const gpa = testing.allocator; + const p = try withFile(gpa, "first\n"); + defer p.deinit(); + const before = p.next_serial; + const new = @intFromEnum(TopFile.new); + + try testing.expectEqual(Status.ok, look_up(p, root, "new").reply.status); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = new }).reply.status); + try testing.expectEqual(before, p.next_serial); + + const opened = call(p, .{ .tag = 1, .op = .open, .node = new }); + try testing.expectEqual(Status.ok, opened.reply.status); + try testing.expectEqual(before + 1, p.next_serial); + try testing.expectEqual(before + 1, opened.reply.handle); + try testing.expectEqual(@as(u64, 0), opened.reply.attr.node); + var expected: [16]u8 = undefined; + const line = try std.fmt.bufPrint(&expected, "{d}\n", .{opened.reply.handle}); + try testing.expectEqualStrings(line, call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .size = 64 }).bytes); + try testing.expectEqualStrings(line[1..], call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = 1, .size = 64 }).bytes); + try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = line.len, .size = 64 }).bytes); + try testing.expectEqual(before + 1, p.next_serial); + _ = call(p, .{ .tag = 3, .op = .release, .node = new, .handle = opened.reply.handle }); + + const id = p.paneBySerial(opened.reply.handle).?; + try testing.expectEqualStrings("", p.panes[id].?.file.?.content); + _ = wr(p, Node.of(opened.reply.handle, .body), "hi"); + const second = call(p, .{ .tag = 4, .op = .open, .node = new }); + try testing.expectEqual(Status.ok, second.reply.status); + try testing.expect(second.reply.handle != opened.reply.handle); + try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 5, .op = .read, .node = new, .size = 64 }).errno()); +} + +test "directories walk to their parents" { + const p = try withFile(testing.allocator, "contents\n"); + defer p.deinit(); + for ([_]struct { node: u64, parent: u64 }{ + .{ .node = root, .parent = root }, + .{ .node = @intFromEnum(TopFile.pane), .parent = root }, + .{ .node = Node.of(serialOf(p), .dir), .parent = @intFromEnum(TopFile.pane) }, + .{ .node = fs.os_root, .parent = root }, + }) |case| { + const result = look_up(p, case.node, ".."); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqual(case.parent, result.reply.attr.node); + } + try testing.expectEqual(E.NOTDIR, look_up(p, @intFromEnum(TopFile.index), "..").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serialOf(p), .body), "..").errno()); +} + +test "editor paths resolve to the same nodes the wire serves" { + const p = try withFile(testing.allocator, "contents\n"); + defer p.deinit(); + const serial = serialOf(p); + var path: [64]u8 = undefined; + try testing.expectEqual(root, resolveSelf(p, "").?); + try testing.expectEqual(root, resolveSelf(p, "/./pane/../").?); + try testing.expectEqual(@intFromEnum(TopFile.index), resolveSelf(p, "index").?); + try testing.expectEqual(@intFromEnum(TopFile.new), resolveSelf(p, "new").?); + try testing.expectEqual(@intFromEnum(TopFile.pane), resolveSelf(p, "pane").?); + try testing.expectEqual(Node.of(serial, .dir), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}", .{serial})).?); + try testing.expectEqual(Node.of(serial, .sel), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/sel", .{serial})).?); + try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/pty/ctl", .{serial})) == null); + try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/rdsel", .{serial})) == null); + try testing.expect(resolveSelf(p, "self/index") == null); + try testing.expect(resolveSelf(p, "new/ctl") == null); + try testing.expect(resolveSelf(p, "cons") == null); + try testing.expect(resolveSelf(p, "pane/0") == null); +} diff --git a/src/panes.zig b/src/panes.zig index 49e5d624..edd7038f 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -1014,6 +1014,8 @@ pub const File = struct { /// Equal means the screen matches disk. saved_revision: u32 = 0, watch_after_save: bool = false, + /// Seconds of the last content change, for the filesystem's stat; zero until edited. + mtime: u32 = 0, /// Non-null for a generated output buffer rather than an on-disk file. output: ?Output.State = null, mini: ?Mini.State = null, @@ -1702,7 +1704,7 @@ pub const File = struct { const pane = slot orelse continue; if (pane.file) |*state| if (state == f) break i; } else return; - pardes.filesystem.noteReplace(p, id, false, f.content, new); + pardes.ctlfs.events.noteReplace(p, id, false, f.content, new); } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { @@ -1722,6 +1724,7 @@ pub const File = struct { p.gpa.free(f.content); f.content = new; f.revision +%= 1; + f.mtime = pardes.ctlfs.events.now(); if (f.line_starts.len > 0) p.gpa.free(f.line_starts); f.line_starts = &.{}; if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); @@ -2876,6 +2879,8 @@ pub const Output = struct { else => return error.NotAnEffect, } if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; + // Without -Dembed-sources the paths would name nothing this build can open. + if (!limits.embedded_sources) return error.EffectUnavailable; const paths = effect_sources.forSetting(setting) orelse return error.EffectUnavailable; diff --git a/src/pardes.zig b/src/pardes.zig index 5c0ac263..88442542 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -6,6 +6,7 @@ const mvzr = @import("mvzr"); pub const modal = @import("modal.zig"); pub const look = @import("look.zig"); pub const filesystem = @import("fs.zig"); +pub const ctlfs = @import("ninep/tree.zig"); pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); @@ -4711,7 +4712,7 @@ pub const Event = union(enum) { /// this must not clamp onto and preview the final grid cell. pointer_leave, tick, - fs_req: filesystem.Req, + fs_req: ctlfs.Req, }; pub const attach_name_max = 256; @@ -4749,7 +4750,7 @@ pub const Effect = union(enum) { /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, - fs_reply: filesystem.Reply, + fs_reply: ctlfs.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, detach: struct { pane: u8 }, quit, @@ -6109,6 +6110,7 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); + p.fs.started = ctlfs.events.now(); for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; p.opts.ninep_identity = .{}; @@ -6335,6 +6337,7 @@ pub const Pardes = struct { const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf(); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + ctlfs.events.noteRetire(p, id, pane); p.fs.forget(p.gpa, id); // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); @@ -6380,6 +6383,7 @@ pub const Pardes = struct { pane.serial = p.next_serial; p.rects[id] = .{}; p.panes[id] = pane; + ctlfs.events.noteInstall(p, id); } pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { @@ -6924,7 +6928,7 @@ pub const Pardes = struct { return pane.pdfPath(); } - pub fn fsPayload(p: *const Pardes, r: filesystem.Reply) []const u8 { + pub fn fsPayload(p: *const Pardes, r: ctlfs.Reply) []const u8 { return switch (r.payload) { .none => &.{}, .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], @@ -6961,6 +6965,7 @@ pub const Pardes = struct { p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; + ctlfs.events.noteLog(p, .save, saved_pane); const saved = if (saved_pane.file) |*file| file else return; if (saved.saved_revision != saved.revision or !saved.watch_after_save) return; saved.watch_after_save = false; @@ -7116,7 +7121,7 @@ pub const Pardes = struct { }, .output => |o| { const pane = p.panes[o.pane] orelse return; - filesystem.notePtyOutput(p, o.pane, o.bytes); + ctlfs.events.notePtyOutput(p, o.pane, o.bytes); panes.Terminal.feedOutput(p, pane, o.bytes); }, .eof => |e| p.removePane(e.pane) catch |err| { @@ -7146,7 +7151,7 @@ pub const Pardes = struct { }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), - .fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }), + .fs_req => |r| p.emit(.{ .fs_reply = ctlfs.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => { @@ -7163,6 +7168,7 @@ pub const Pardes = struct { _ = p.scratch.reset(.retain_capacity); p.sync(); p.fsReport(); + ctlfs.events.announce(p); } fn fsReport(p: *Pardes) void { @@ -7174,7 +7180,7 @@ pub const Pardes = struct { const snap = &p.fs.panes[id].tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; if (snap.capacity != 0 or snap.items.len != 0) - filesystem.noteReplace(p, id, true, snap.items, tag); + ctlfs.events.noteReplace(p, id, true, snap.items, tag); snap.clearRetainingCapacity(); snap.appendSlice(p.gpa, tag) catch {}; } @@ -7451,7 +7457,7 @@ pub const Pardes = struct { p.reportError(id, "name", err); return false; }; - filesystem.nameBuffer(p, id, full, true) catch |err| { + ctlfs.pane.nameBuffer(p, id, full, true) catch |err| { p.reportError(id, "name", err); return false; }; @@ -11673,21 +11679,21 @@ pub const Pardes = struct { ) bool { if (!p.fs.scripted(id)) return false; const is_look = cmd == config.look_cmd; - const action: filesystem.Action = if (is_look) + const action: ctlfs.events.Action = if (is_look) (if (on_tag) .tag_look else .body_look) else (if (on_tag) .tag_exec else .body_exec); const named = std.meta.stringToEnum(Builtin, commandText(text)) != null; const range = p.gestureRange(id, text, on_tag, operand); - var flag: u32 = if (named) filesystem.flag_builtin else 0; - if (range.q0 == range.q1 and text.len > 0) flag |= filesystem.flag_expansion; + var flag: u32 = if (named) ctlfs.events.flag_builtin else 0; + if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion; if (is_look) { - if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= filesystem.flag_filename; - } else if (chorded) flag |= filesystem.flag_chorded; - return filesystem.noteAction(p, id, action, range.q0, range.q1, flag, text); + if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename; + } else if (chorded) flag |= ctlfs.events.flag_chorded; + return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text); } - fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) filesystem.PaneState.Range { + fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) ctlfs.pane.State.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; diff --git a/src/tutor.txt b/src/tutor.txt index ae22ede9..394a48a3 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -387,29 +387,30 @@ typed pardes the 9P socket opens by default - A directory per pane holding `body`, `tag`, `ctl`, `addr`, `data`, - `event`, `pty/` and the rest, plus `index`, `cons` and `new/` at the - top under /self. Every pane shell receives `$PARDES_9P` (the socket) - and `$PARDES_PANE` (its serial). Use a 9P client to read and write: + A directory per pane holding `name`, `body`, `tag`, `ctl`, `addr`, + `data`, `sel`, `event`, `pty/` and the rest, plus `index`, `ctl`, + `new` and `log` at the root. Every pane shell receives `$PARDES_9P` + (the socket) and `$PARDES_PANE` (its serial). Use a 9P client: - /self/pane/<serial>/body - /self/index - /self/new/ctl + /pane/<serial>/body + /index + /new reading it makes a pane, answers its serial + /ctl `look FILE:12` and `exec Save`, as the mouse does A terminal pane also has `pty/`: - /self/pane/3/pty/ctl winsize, sig - /self/pane/3/pty/data terminal input/output + /pane/3/pty/ctl winsize, sig + /pane/3/pty/data terminal input/output Over 9P the same tree answers plan9port, from anywhere: 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock ls / - 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read /self/index + 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read index ...and pardes is a 9P CLIENT too, so one session can read another's: pardes --mount=peer=work - /n/peer/self/pane/1/body Look opens the other session's body + /n/peer/pane/1/body Look opens the other session's body TWO THINGS WORTH KNOWING. While a program holds a pane's `event` file open, MIDDLE AND RIGHT CLICKS IN THAT PANE BELONG TO IT: pardes reports @@ -467,7 +468,7 @@ typed SPC is the leader (SPC ? lists every path) SCRIPTING 9P is served by default on $PARDES_9P - /self/pane/<id>/{body,tag,ctl,addr,data,event,pty/} + /pane/<id>/{name,body,tag,ctl,addr,data,sel,event,pty/} a script holding `event` owns that pane's middle and right clicks; writing a record back runs it |
