diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 18:35:29 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 45f152b2de946aa7c7ed5ffe37d07b0930dfc38b (patch) | |
| tree | 0b18f8484163596414b20ee75a13fb508babc4ec /src/pardes.zig | |
| parent | 35cecf26b06cf50d9827056da99d883ca975c39b (diff) | |
| download | pardes-45f152b2de946aa7c7ed5ffe37d07b0930dfc38b.tar.gz pardes-45f152b2de946aa7c7ed5ffe37d07b0930dfc38b.zip | |
add New builtin for an empty temporary file in the calling column
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 6156c56f..8cf8d2e2 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -633,6 +633,142 @@ test "startup config runs builtin lines in order and isolates bad lines" { }; } +test "New completes as an empty watched file in the calling column and focuses it" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} // initial shell spawns + + const source: usize = 2; // the right column; active starts in the left + const source_col = p.layoutFindTerm(source).?.col; + try std.testing.expect(p.executeBuiltinLine(source, "New")); + var request: ?Effect = null; + while (p.nextEffect()) |effect| switch (effect) { + .new_file => request = effect, + else => {}, + }; + const new_effect = request orelse return error.MissingNewFileEffect; + const req = new_effect.new_file; + try std.testing.expectEqual(@as(u8, @intCast(source)), req.pane); + try std.testing.expectEqual(p.panes[source].?.serial, req.serial); + + // Focus changes while the shell is doing IO. Placement must still use the + // effect's calling-pane token, never this later-active left column. + p.active = 0; + try std.testing.expect(p.openNewFile(req.pane, req.serial, "/tmp/pardes-test-new-a")); + const id = p.active; + try std.testing.expect(id != 0 and id != source); + try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); + const sf = p.layoutFindTerm(source).?; + try std.testing.expectEqual(id, p.col_terms[sf.col][sf.idx + 1]); + + const file = p.panes[id].?.file orelse return error.NewIsNotAFile; + try std.testing.expectEqualStrings("/tmp/pardes-test-new-a", file.path); + try std.testing.expectEqual(@as(usize, 0), file.content.len); + try std.testing.expect(file.output == null); + try std.testing.expect(!p.panes[id].?.isTerminal()); + + var watched = false; + while (p.nextEffect()) |effect| switch (effect) { + .watch => |w| if (w.pane == id and w.on) { + watched = true; + }, + else => {}, + }; + try std.testing.expect(watched); + + // It is an ordinary file from here on: Save uses the normal file effect, + // and the dump contains its path and empty content. + try std.testing.expect(p.executeBuiltinLine(id, "Save")); + var saved = false; + while (p.nextEffect()) |effect| switch (effect) { + .save_file => |s| if (s.pane == id) { + saved = true; + }, + else => {}, + }; + try std.testing.expect(saved); + try p.dumpState(); + const state = try dump.readZon(std.testing.allocator, p.dump_out.?, "New-test"); + defer dump.free(std.testing.allocator, state); + var dumped = false; + for (state.panes) |dp| if (dp.file) |df| { + if (std.mem.eql(u8, df.path, "/tmp/pardes-test-new-a")) { + try std.testing.expectEqual(@as(usize, 0), df.content.len); + dumped = true; + } + }; + try std.testing.expect(dumped); + while (p.nextEffect()) |_| {} // write_dump + + // Del closes and unwatches, but there is intentionally no unlink effect: + // adopted temporary documents preserve user data and dump restorability. + try std.testing.expect(p.executeBuiltinLine(id, "Del")); + try std.testing.expect(p.panes[id] == null); + var unwatched = false; + while (p.nextEffect()) |effect| switch (effect) { + .watch => |w| if (w.pane == id and !w.on) { + unwatched = true; + }, + else => {}, + }; + try std.testing.expect(unwatched); +} + +test "New requests repeat, stay anchored, and reject stale or full completions" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + const source: usize = 2; + const source_col = p.layoutFindTerm(source).?.col; + try std.testing.expect(p.executeBuiltinLine(source, "New")); + try std.testing.expect(p.executeBuiltinLine(source, "New")); + var requests: [2]Effect = undefined; + var n: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .new_file => if (n < requests.len) { + requests[n] = effect; + n += 1; + }, + else => {}, + }; + try std.testing.expectEqual(requests.len, n); + + p.active = 0; + try std.testing.expect(p.openNewFile(requests[0].new_file.pane, requests[0].new_file.serial, "/tmp/pardes-test-new-1")); + const first = p.active; + p.active = 1; // change focus again before the second completion + try std.testing.expect(p.openNewFile(requests[1].new_file.pane, requests[1].new_file.serial, "/tmp/pardes-test-new-2")); + const second = p.active; + try std.testing.expect(first != second); + try std.testing.expectEqual(source_col, p.layoutFindTerm(first).?.col); + try std.testing.expectEqual(source_col, p.layoutFindTerm(second).?.col); + try std.testing.expect(!std.mem.eql(u8, p.panes[first].?.file.?.path, p.panes[second].?.file.?.path)); + while (p.nextEffect()) |_| {} + + // A reused pane slot cannot answer an old request, and a malformed shell + // answer cannot manufacture a nameless file. + const old_serial = p.panes[source].?.serial; + p.panes[source].?.serial +%= 1; + try std.testing.expect(!p.openNewFile(@intCast(source), old_serial, "/tmp/pardes-test-stale")); + try std.testing.expect(!p.openNewFile(@intCast(source), p.panes[source].?.serial, "")); + + // Exhaust every remaining slot after a request was issued: completion + // rejects it, which tells the shell to unlink the just-created candidate. + const live_serial = p.panes[source].?.serial; + for (0..MAX_PANES) |id| { + if (p.panes[id] == null) _ = try p.newDocPane(id); + } + try std.testing.expect(!p.openNewFile(@intCast(source), live_serial, "/tmp/pardes-test-full")); + try std.testing.expect(p.executeBuiltinLine(source, "New")); + var emitted_when_full = false; + while (p.nextEffect()) |effect| switch (effect) { + .new_file => emitted_when_full = true, + else => {}, + }; + try std.testing.expect(!emitted_when_full); +} + test "startup config overrides restored styling before initFromDump returns" { const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" }); defer source.deinit(); @@ -1263,6 +1399,11 @@ pub const Effect = union(enum) { /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, + /// Create an empty, uniquely-named temporary file. `pane` + `serial` + /// identify the pane/column that issued New even if focus moves before the + /// shell completes the IO. The shell calls openNewFile with the path and + /// unlinks it itself when that completion rejects the request. + new_file: struct { pane: u8, serial: u32 }, /// a serialized state dump is ready in core.dump_out; write it to the /// path dump.outPath resolves (acme-style: another instance loads it /// with -l, or the Restore builtin loads it into this one) @@ -2252,6 +2393,42 @@ pub const Pardes = struct { pane.cwd_len = @intCast(n); } + /// Complete a `new_file` effect without doing IO in the core. The shell + /// has already created `path` as an empty file; we model those known empty + /// bytes directly, insert immediately below the still-live calling pane in + /// ITS column (never whichever column became active), focus it, and start + /// the same watch every ordinary file pane has. False leaves ownership + /// with the shell, which closes and unlinks the unadopted file. + pub fn openNewFile(p: *Pardes, source: u8, serial: u32, path: []const u8) bool { + if (path.len == 0 or source >= MAX_PANES) return false; + const src = p.panes[source] orelse return false; + if (src.serial != serial) return false; + const sf = p.layoutFindTerm(source) orelse return false; + const free = p.freeSlot() orelse return false; + + const path_copy = p.gpa.dupe(u8, path) catch return false; + const content = p.gpa.dupe(u8, "") catch { + p.gpa.free(path_copy); + return false; + }; + const pane = p.newDocPane(free) catch { + p.gpa.free(content); + p.gpa.free(path_copy); + return false; + }; + pane.file = .{ .path = path_copy, .content = content }; + pane.cur_pinned = true; + + // sf was captured from SOURCE, not active. Nothing above this point + // mutates layout, so it is still the exact calling column/index. + p.layoutInsert(sf.col, sf.idx + 1, free); + p.splitBelow(source, pane); + p.active = free; + p.emit(.{ .watch = .{ .pane = @intCast(free), .on = true } }); + p.sync(); + return true; + } + pub fn emit(p: *Pardes, e: Effect) void { p.effects.append(p.gpa, e) catch {}; } |
