summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 18:35:29 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit45f152b2de946aa7c7ed5ffe37d07b0930dfc38b (patch)
tree0b18f8484163596414b20ee75a13fb508babc4ec /src/pardes.zig
parent35cecf26b06cf50d9827056da99d883ca975c39b (diff)
downloadpardes-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.zig177
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 {};
}