diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-22 02:21:03 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 43b9d696f9e25ad7589527b91a74666e71ff0472 (patch) | |
| tree | 52ed8573c53a7a6f6ca956b4f473035126a15354 /src/fs.zig | |
| parent | 760448928186382da4ef9d942af1a2b7546ef54c (diff) | |
| download | pardes-43b9d696f9e25ad7589527b91a74666e71ff0472.tar.gz pardes-43b9d696f9e25ad7589527b91a74666e71ff0472.zip | |
Notices become tagline bands at the top of the body
A message, a leader chord and a prompt used to share one row of body text at
the bottom of a pane, wearing the tagline font and nothing else about a
tagline. Now each one is a TagLayer of its own, emitted through the same
renderHeaderLayer the pane and column tags go through, so it gets the tagline
height, the small-font metrics, the band offset and the border for free --
none of which a body-grid row can have by changing its font role. The text is
right aligned. The prompt stays on the canonical grid because it owns a
cursor, and a cursor has to sit on a real cell.
The body starts BELOW the bands rather than under them, the way tree-sitter
context rows already worked. Pane.body_offset is how many rows they took and
Pardes.bodyTop(pane, rect) is the one place that answers "where does the body
begin" -- replacing fifteen copies of `if (tag_bottom) r.y else r.y + BOX_H`
spread across the paint, hit-test, scroll, PDF and image paths, which is what
let the bands and the text under them come adrift. Every notice is painted on
the grid as well, because the grid is what a terminal client draws and a band
it cannot see is a message it never gets; the GUI skips grid cells a tag layer
covers, so nothing is drawn twice.
Three bugs the bands exposed, fixed here:
- a prompt band flush with the right edge put its caret one column past the
pane, which the detached wire refuses -- so every frame was dropped for as
long as the prompt was open. The band now reserves that column.
- a click on a band mapped to Sel row 0, which is the TAG row: clicking
chrome expanded a word out of the tagline and ran it as a builtin.
- a watched file reloading under the editor changed the core without going
through update, so needs_frame was never set and the reload was never
drawn. Pardes.invalidate() is the name for that, and the file and theme
reloads call it.
A session can now drive its own 9P namespace instead of being refused one:
ownMountSuffix answers what a path names inside this editors own tree and
resolve, readLimit and write serve it from memory rather than making the
syscall that never returns. The match is anchored to whole components under
the registrys 9p/pardes/<name>, because a bare /pardes/<name> anywhere in a
string would claim ~/src/pardes/<name>/README -- and, before write learned the
same trick, write the trees bytes over the real file. readFileLimit and
writeFile refuse instead, having no core to answer from.
A toggle setting SETS when given `on` or `off` and only flips when it is bare,
so the report LocationsConfig prints can be fed back as configuration and mean
what it says.
Snapshots: 97/98, from 0/98. The goldens were several commits stale and 17
scripts had stopped running; `config <line>` is a new script command that
appends to the per-script startup config, so a script that clicks body
coordinates pins `Verbose off` instead of counting the rows an announcement
moves. nested-optout is left failing on purpose: two levels of nesting prepend
vaxis F3 codepoints to typed lines, which is a real bug and is written down in
docs/divergences.md with a repro.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src/fs.zig')
| -rw-r--r-- | src/fs.zig | 124 |
1 files changed, 103 insertions, 21 deletions
@@ -65,6 +65,9 @@ pub const WriteError = error{ }; pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void { + // No core here to answer from, so the only safe answer is no answer: an + // open(2) into our own mount is the call that never returns. + if (isOwnMount(path)) return error.OpenFailed; var pathbuf: [4096:0]u8 = undefined; if (path.len >= pathbuf.len) return error.PathTooLong; if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; @@ -671,6 +674,9 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409 } if (std.mem.eql(u8, joined, "/virtual")) return resolveVirtual(p, "/", out); if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out); + // This editor's own tree, seen through a mount: answer from the tree + // instead of walking out into the view and back in. + if (ownMountSuffix(joined)) |inner| return resolveVirtual(p, inner, out); if (resolveOs(joined, out)) |found| return found; if (resolveVirtual(p, joined, out)) |found| return found; if (resolveVirtual(p, word, out)) |found| return found; @@ -682,7 +688,9 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409 } /// The name this session is posted under, taken from its socket path. -var own_name_buf: [64]u8 = undefined; +/// As wide as a name a listener will accept, so there is no session whose +/// name is too long to recognise and therefore too long to protect. +var own_name_buf: [108]u8 = undefined; var own_name_len: usize = 0; pub fn noteOwnSocket(socket_path: []const u8) void { @@ -697,35 +705,91 @@ pub fn noteOwnSocket(socket_path: []const u8) void { own_name_len = name.len; } -/// Is this path inside this editor's OWN 9P tree, as a mount presents it? +/// What this path names inside this editor's OWN 9P tree, if it does: +/// `/mnt/9p/pardes/<me>/pane/3/body` -> `pane/3/body`, and the mount root +/// itself -> `/`. /// -/// Touching one from inside the editor deadlocks the session outright: the -/// realpath, the stat and the read all leave through the mount and come back -/// as 9P requests that only the editor's loop can answer, while that loop is -/// blocked making them. The filesystem then stops answering anybody. So the -/// answer has to come from the NAME, before any syscall -- the syscall is the -/// thing that never returns. Look at `/n/self/...` instead, which the editor -/// serves from memory without leaving the process. -pub fn isOwnMount(path: []const u8) bool { - if (own_name_len == 0) return false; +/// A mount is a VIEW of a tree this editor already holds. Going out through +/// the view to reach it deadlocks the session outright -- the realpath, the +/// stat and the read all leave through the mount and come back as 9P requests +/// only this editor's loop can answer, while that loop is blocked making them, +/// and the filesystem then stops answering anybody. So the view is recognised +/// by NAME, before any syscall (the syscall is the thing that never returns), +/// and the request is served from the tree directly. Same answer, no round +/// trip, and a session can drive itself through its own 9P namespace. +pub fn ownMountSuffix(path: []const u8) ?[]const u8 { + if (own_name_len == 0 or path.len == 0 or path[0] != '/') return null; + // Whole components, and the registry's own layout: a session is posted at + // `<runtime>/9p/pardes/<name>` and mounts group under `/mnt/9p/pardes/`. + // Matching a bare `/pardes/<name>` anywhere in the string would claim + // `~/src/pardes/<name>/README` -- an ordinary directory that happens to + // read like a mount -- and serve the tree's README over the real file. var buf: [own_name_buf.len + 16]u8 = undefined; - const needle = std.fmt.bufPrint(&buf, "/pardes/{s}/", .{own_name_buf[0..own_name_len]}) catch return false; - return std.mem.indexOf(u8, path, needle) != null; + const stem = std.fmt.bufPrint(&buf, "/9p/pardes/{s}", .{own_name_buf[0..own_name_len]}) catch return null; + var at: usize = 0; + while (std.mem.indexOfPos(u8, path, at, stem)) |found| : (at = found + 1) { + const rest = path[found + stem.len ..]; + if (rest.len != 0 and rest[0] != '/') continue; // a longer name that merely starts the same + if (rest.len <= 1) return "/"; + return rest[1..]; + } + return null; +} + +pub fn isOwnMount(path: []const u8) bool { + return ownMountSuffix(path) != null; } -test "a path inside this session's own mount is refused before any syscall" { +test "a path inside this session's own mount is answered from the tree, not through the mount" { noteOwnSocket("/run/user/1000/pardes-9p-demo.sock"); defer own_name_len = 0; - try std.testing.expect(isOwnMount("/mnt/9p/pardes/demo/index")); - try std.testing.expect(isOwnMount("/mnt/9p/pardes/demo/pane/new")); - try std.testing.expect(!isOwnMount("/mnt/9p/pardes/other/index")); - try std.testing.expect(!isOwnMount("/home/goblin/src/pardes/demo.zig")); + // What the path names inside the tree, with the mount prefix taken off. + try std.testing.expectEqualStrings("index", ownMountSuffix("/mnt/9p/pardes/demo/index").?); + try std.testing.expectEqualStrings("pane/new", ownMountSuffix("/mnt/9p/pardes/demo/pane/new").?); + try std.testing.expectEqualStrings("/", ownMountSuffix("/mnt/9p/pardes/demo").?); + try std.testing.expectEqualStrings("/", ownMountSuffix("/mnt/9p/pardes/demo/").?); + // The registry posts the session there too, so that spelling counts. + try std.testing.expectEqualStrings("index", ownMountSuffix("/run/user/1000/9p/pardes/demo/index").?); + // Another session's mount is somebody else's to answer, and a name that + // merely STARTS with ours is a different name. + try std.testing.expect(ownMountSuffix("/mnt/9p/pardes/other/index") == null); + try std.testing.expect(ownMountSuffix("/mnt/9p/pardes/demo2/index") == null); + // An ordinary directory that reads like a mount is an ordinary directory. + // Serving the tree here would hand back the tree's README for the file on + // disk, and -- before `write` learned the same trick -- write the tree's + // bytes over it. + try std.testing.expect(ownMountSuffix("/home/goblin/src/pardes/demo/README") == null); + try std.testing.expect(ownMountSuffix("/home/goblin/src/pardes/demo.zig") == null); + // Relative paths never name a mount: they are resolved against a cwd first. + try std.testing.expect(ownMountSuffix("mnt/9p/pardes/demo/index") == null); + + // The syscall is the thing that never returns, so it is never made. var out: [4096]u8 = undefined; try std.testing.expect(resolveOs("/mnt/9p/pardes/demo/index", &out) == null); - // A session with no listener has no name, so nothing is refused. + // A session with no listener has no name, so nothing is redirected. noteOwnSocket("/tmp/not-a-pardes-socket"); - try std.testing.expect(!isOwnMount("/mnt/9p/pardes/demo/index")); + try std.testing.expect(ownMountSuffix("/mnt/9p/pardes/demo/index") == null); +} + +test "reading this session's own mount returns what the tree holds" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + noteOwnSocket("/run/user/1000/pardes-9p-selfread.sock"); + defer own_name_len = 0; + + const direct = try read(p, "/n/self/index"); + defer p.gpa.free(direct); + const mounted = try read(p, "/mnt/9p/pardes/selfread/index"); + defer p.gpa.free(mounted); + try std.testing.expectEqualStrings(direct, mounted); + try std.testing.expect(direct.len > 0); + + // A write goes to the same tree the read came from, not out through the + // mount: `/index` is 0400 there, and an OS write would instead try to + // create a file under a directory that does not exist. + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, "/mnt/9p/pardes/selfread/index", "nope\n")); + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, "/n/self/index", "nope\n")); } pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved { @@ -754,7 +818,13 @@ pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 { } pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { - if (isOwnMount(path)) return error.OwnMount; + // The same view, reached by a reader that never went through `resolve`. + // The rewritten path cannot contain the mount stem, so this recurses once. + if (ownMountSuffix(path)) |inner| { + var self_buf: [4096]u8 = undefined; + const internal = std.fmt.bufPrint(&self_buf, "/n/self/{s}", .{inner}) catch return error.FileTooLarge; + return readLimit(p, internal, max_bytes); + } const limit = @min(max_bytes, limits.max_file_bytes); if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) { var out: std.Io.Writer.Allocating = .init(p.gpa); @@ -871,6 +941,14 @@ fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize } pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void { + // The same view `readLimit` answers from the tree, so a write lands where + // the matching read came from -- and, just as importantly, never becomes + // an open(2) through a mount this loop is the one that answers. + if (ownMountSuffix(path)) |inner| { + var self_buf: [4096]u8 = undefined; + const internal = std.fmt.bufPrint(&self_buf, "/n/self/{s}", .{inner}) catch return error.PathTooLong; + return write(p, internal, bytes); + } if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) return error.IsDirectory; var self_path: ?[]const u8 = null; if (std.mem.eql(u8, path, "/virtual")) self_path = ""; @@ -1233,6 +1311,10 @@ test "Restore prefers default directory then falls back to original path" { fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; + // Same reason as `writeFile`: no core to answer from here, and the open is + // the call that never returns. Callers holding one use `readLimit`, which + // serves the tree instead. + if (isOwnMount(path)) return error.FileNotFound; if (!platform_has_fs or std.mem.startsWith(u8, path, "/virtual/")) { const archive_path = if (std.mem.startsWith(u8, path, "/virtual/")) path[9..] else path; var normalized_buf: [4096]u8 = undefined; |
