From b43866d6ab784e518df8dbef374147367358ada9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:02:59 -0300 Subject: Save makes the directories its file goes in, always: for the pane's own name and Save path alike Whether a Save made missing directories depended on how the name had come about. Config's pane did, a name written into a missing directory did (since kkrqxtsp), and `Save path` and every other name did not, failing ENOENT instead. There is now one rule: a Save makes the parent directories of the file it writes, a pane's own or a terminal's scrollback included, and the make_dirs flag is gone. A directory it cannot make still fails the write with why, as fs.py's /nonexistent-pardes-dir case shows. fs.py now also saves into missing directories by Save path and by a written name. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 6 +++--- src/File.zig | 4 ---- src/builtins.zig | 3 +-- src/ninep/pane.zig | 18 ------------------ src/pardes.zig | 14 ++++++++++++-- test/fs.py | 14 ++++++++++++-- 6 files changed, 28 insertions(+), 31 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index 334b6783..49b10a7b 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -364,8 +364,7 @@ are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike. **`name`** reads the file name (a terminal's directory); a write renames the buffer (relative to the pane's directory) and marks nothing dirty; `Save` -then writes under the new name, making its directories first when the name -went into one not there. A name is one line; refused (EINVAL) are a +then writes under the new name. A name is one line; refused (EINVAL) are a second line, a blank at either end, control bytes and non-UTF-8 (`bad character in file name: a blank at its start`, ...). Up to 255 bytes a component. @@ -391,7 +390,8 @@ redo available, then `current`/`notcurrent` and a REPL's id if bound. It takes: - the pane builtins: `Del` (`Del k`/`Del j`, or `DelAbove`/`DelBelow`, give - its rows to the pane above or below), `Save [path]`, `Collapse` (fold), + its rows to the pane above or below), `Save [path]` (making the + directories the file goes in first, whichever name it writes), `Collapse` (fold), `Undo`/`Redo` (256 steps each), `Find pat`, `Edit ...`, `Tty [shell]` (a new terminal pane in its directory), and the column words acting on its column: `Delcol`, `Left`/`Right`/`Up`/`Down`. diff --git a/src/File.zig b/src/File.zig index 43b921e3..84cec879 100644 --- a/src/File.zig +++ b/src/File.zig @@ -61,10 +61,6 @@ pub const State = struct { /// edit to another length cannot be back to it, and is not hashed. saved_len: ?usize = null, watch_after_save: bool = false, - /// Save makes the file's directory, and any above it, first: `Config`'s - /// pane for an init file that is not there yet, whose directory may not - /// be either. - make_dirs: bool = false, /// The file changed on disk under unsaved edits, which were kept (see /// `changed`): its hash, so the same change is said once, until a Save /// or get settles it. A Save warns once before overwriting it, as acme's diff --git a/src/builtins.zig b/src/builtins.zig index 8fe49649..5a2037cf 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1304,9 +1304,8 @@ pub const Config = struct { // Not there yet: a pane named for it, which Save makes. const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Config", error.NoPaneSlots); c.p.newScratchFor(c.id, .scratch); - const pane = c.p.panes[free] orelse return; + if (c.p.panes[free] == null) return; @import("ninep/pane.zig").nameBuffer(c.p, free, path, false) catch |err| return c.p.reportError(c.id, "Config", err); - pane.file.?.make_dirs = true; c.p.active = free; } }; diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 6a0fbba5..de7f7cd4 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -867,11 +867,6 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { error.OutOfMemory => E.NOMEM, else => E.INVAL, }); - // A name into a directory not there yet asks for it, as Config's pane - // does: its Save makes the directories (`pardes new/dir/f` forwarded). - if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| { - if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true; - }; return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -1392,19 +1387,6 @@ test "a name with a component over 255 bytes is refused, and a long path's faile try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory")); } -test "a name into a directory not there has Save make it; one into a directory there does not" { - if (comptime !pardes.hosted) return error.SkipZigTest; - const p = try withFile(testing.allocator, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?; - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status); - try testing.expect(f.make_dirs); - f.make_dirs = false; - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status); - try testing.expect(!f.make_dirs); -} - test "a name cut across writes is one name, applied once at its newline or its close" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index d8124588..841faf7a 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5401,6 +5401,15 @@ pub const Pardes = struct { p.noteLateFailure(id); } + /// A Save makes the directories its file goes in, and any above them, + /// first: one rule, for the pane's own name and `Save path` alike. A + /// directory it cannot make leaves the write to fail and say why. + fn makeParents(path: []const u8) void { + if (comptime !hosted) return; + const local = filesystem.localPath(path) orelse return; + filesystem.makeDirs(std.fs.path.dirname(local) orelse "/"); + } + pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { // The `-%` spelling fs.zig already uses for "make this dirty". @@ -5490,8 +5499,7 @@ pub const Pardes = struct { const was = f.saved_revision; const failures = p.fs.failures; f.saved_revision = f.revision; - if (f.make_dirs) if (filesystem.localPath(f.path)) |local| - filesystem.makeDirs(std.fs.path.dirname(local) orelse "/"); + makeParents(f.path); p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; @@ -5520,6 +5528,7 @@ pub const Pardes = struct { if (pane.file) |*f| { const was = f.saved_revision; const failures = p.fs.failures; + makeParents(st.path.slice()); p.hostWriteFile(st.pane, st.path.slice(), f.content); const after = p.panes[st.pane] orelse return; if (after.serial != st.serial) return; @@ -5535,6 +5544,7 @@ pub const Pardes = struct { if (!pane.isTerminal()) return; const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); + makeParents(st.path.slice()); p.hostWriteFile(st.pane, st.path.slice(), text); }, .write_dump => { diff --git a/test/fs.py b/test/fs.py index a1e2ae79..b02b9e45 100644 --- a/test/fs.py +++ b/test/fs.py @@ -381,8 +381,18 @@ def discovery(binary, embedded=False): # Through a mount both read as EINVAL; the log says which and why. assert f'err {scratch} addr: no match for regexp\n'.encode() in client.read('/log') client.remove(f'/pane/{scratch}') - # A save the host cannot do fails the write that asked for it, - # and the log's err record names the path and why. + # A Save makes the directories its file goes in, whatever names + # it: the pane's own name or Save's path. + made_dirs = new_pane(client, b'deep\n') + client.write(f'/pane/{made_dirs}/ctl', f'Save {root}/save-made/a/b/x.txt\n'.encode()) + assert (root / 'save-made/a/b/x.txt').read_bytes() == b'deep\n' + client.write(f'/pane/{made_dirs}/name', f'{root}/save-named/c/y.txt\n'.encode()) + client.write(f'/pane/{made_dirs}/ctl', b'Save\n') + assert (root / 'save-named/c/y.txt').read_bytes() == b'deep\n' + client.remove(f'/pane/{made_dirs}') + # A save the host cannot do (a directory it may not make) fails + # the write that asked for it, and the log's err record names + # the path and why. unsaved = new_pane(client, b'unsaved\n') try: client.write(f'/pane/{unsaved}/ctl', b'Save /nonexistent-pardes-dir/x.txt\n') -- cgit v1.3