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 | |
| 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')
| -rw-r--r-- | src/builtins.zig | 11 | ||||
| -rw-r--r-- | src/config.zig | 13 | ||||
| -rw-r--r-- | src/gui/gui.zig | 11 | ||||
| -rw-r--r-- | src/pardes.zig | 177 | ||||
| -rw-r--r-- | src/temp_file.zig | 84 | ||||
| -rw-r--r-- | src/tty/tty.zig | 9 | ||||
| -rw-r--r-- | src/tutor.txt | 14 | ||||
| -rw-r--r-- | src/web.zig | 7 |
8 files changed, 314 insertions, 12 deletions
diff --git a/src/builtins.zig b/src/builtins.zig index 578a60dd..b89952cd 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -345,6 +345,17 @@ pub const Save = struct { } }; +/// Ask the native shell for an atomically-created empty temporary file. The +/// pane and its lifetime serial are the placement token: by the time IO +/// finishes another column may be active, or this slot may even have been +/// reused, and neither is permission to put the document somewhere else. +pub const New = struct { + pub fn run(c: Ctx) void { + if (c.p.freeSlot() == null) return; + c.p.emit(.{ .new_file = .{ .pane = @intCast(c.id), .serial = c.pane.serial } }); + } +}; + pub const Newcol = struct { pub fn run(c: Ctx) void { const free = c.p.freeSlot() orelse return; diff --git a/src/config.zig b/src/config.zig index 6748a438..dec4272c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -109,9 +109,10 @@ pub const leader_path = paths: { .Lspwhy = "lw", .Del = "d", .Kill = "k", - // the `f` file group (spacemacs): Save left vim's `w` to join Find here, - // which frees `w` for the window group (SPC w h/j/k/l) to move into. + // the `f` file group (spacemacs): Save left vim's `w` to join Find and + // New here, which frees `w` for the window group (SPC w h/j/k/l). .Save = "fs", + .New = "fn", .Find = "ff", .Grep = "fg", .Tutor = "ht", @@ -289,9 +290,11 @@ pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true // reason it earns the width. Appended rather than inserted so every existing // word keeps its column and no golden's click coordinates move. // -// The two searches go LAST (before the optional `Restore <path>`), and next to -// each other: they are one pair — Find matches file NAMES, Grep their CONTENTS. -pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep Help"; +// New is appended with the late additions, immediately before Help: every +// older action keeps its click column, and the roadmap's separate tagline-order +// item owns the later reshuffle. The two searches stay next to each other: +// Find matches file NAMES, Grep their CONTENTS. +pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep New Help"; /// the default editable tail of a pane's tag, per kind (an output buffer has /// no file to Save, so it gets the plain one) diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 7d2a8cf1..0069164d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -25,6 +25,7 @@ const crt = @import("crt.zig"); const fonts = @import("fonts.zig"); // the Font builtin's half of the seam const is_emscripten = builtin.os.tag == .emscripten; +const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @@ -2486,6 +2487,14 @@ fn drainEffects( // from the bytes we just put there so it reads as "no change" if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, + .new_file => |request| { + var path_buf: [4096:0]u8 = undefined; + const made = temp_file.create(&path_buf) orelse continue; + if (core.openNewFile(request.pane, request.serial, made.path)) + made.adopt() + else + made.discard(); + }, .write_dump => { const out = core.dump_out orelse continue; var pbuf: [1024:0]u8 = undefined; @@ -2554,7 +2563,7 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { .open_link => |url| openLinkWeb(gpa, url.slice()), // nothing to spawn/write/resize/save/dump into, and no filesystem to // watch — all no-ops - .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .watch, .quit => {}, + .spawn, .write, .resize_pty, .save_file, .new_file, .write_dump, .lsp, .watch, .quit => {}, }; } 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 {}; } diff --git a/src/temp_file.zig b/src/temp_file.zig new file mode 100644 index 00000000..a6174efd --- /dev/null +++ b/src/temp_file.zig @@ -0,0 +1,84 @@ +//! Native-shell ownership of `New`'s one filesystem operation. +//! +//! The core asks for a temporary file by effect and receives only its path. +//! `mkstemp` creates and opens the name atomically with mode 0600, so there is +//! no name-then-open race and repeated requests cannot collide. A file the +//! core declines is unlinked here immediately; an adopted file becomes an +//! ordinary Pardes document and is deliberately left on disk when its pane is +//! closed, just like every other document (and so a dump remains restorable). +const std = @import("std"); +const libc = std.c; + +extern "c" fn mkstemp(template: [*:0]u8) c_int; +extern "c" fn lseek(fd: c_int, offset: libc.off_t, whence: c_int) libc.off_t; + +pub const Created = struct { + fd: c_int, + path: [:0]u8, + + /// The core copied the path and now owns the document. Close our creation + /// handle; Save and watching reopen/use the pathname through their normal + /// seams. + pub fn adopt(f: Created) void { + _ = libc.close(f.fd); + } + + /// Creation succeeded but the request became stale or the core could not + /// allocate a pane. Nothing user-visible owns this name, so remove it. + pub fn discard(f: Created) void { + _ = libc.close(f.fd); + _ = libc.unlink(f.path); + } +}; + +/// Create in the platform's conventional temporary directory. TMPDIR is a +/// shell concern (environment + filesystem), intentionally outside the core. +pub fn create(buf: *[4096:0]u8) ?Created { + const env = libc.getenv("TMPDIR"); + const dir = if (env) |p| std.mem.span(p) else "/tmp"; + return createIn(buf, if (dir.len > 0) dir else "/tmp"); +} + +/// Split out for a hermetic failure test and to keep template construction +/// independently checkable. The six Xs are consumed by mkstemp itself. +pub fn createIn(buf: *[4096:0]u8, dir_arg: []const u8) ?Created { + const dir = std.mem.trimEnd(u8, dir_arg, "/"); + const path = std.fmt.bufPrintSentinel( + buf, + "{s}{s}pardes-XXXXXX", + .{ if (dir.len == 0) "/" else dir, if (dir.len == 0) "" else "/" }, + 0, + ) catch return null; + const fd = mkstemp(path.ptr); + if (fd < 0) return null; + return .{ .fd = fd, .path = path }; +} + +test "mkstemp creates distinct empty files and rejected files are removable" { + var abuf: [4096:0]u8 = undefined; + var bbuf: [4096:0]u8 = undefined; + const a = createIn(&abuf, "/tmp") orelse return error.TempCreateFailed; + const b = createIn(&bbuf, "/tmp") orelse return error.TempCreateFailed; + defer b.discard(); + + try std.testing.expect(!std.mem.eql(u8, a.path, b.path)); + try std.testing.expectEqual(@as(libc.off_t, 0), lseek(a.fd, 0, 2)); // SEEK_END + try std.testing.expectEqual(@as(libc.off_t, 0), lseek(b.fd, 0, 2)); + + a.discard(); + try std.testing.expect(libc.unlink(a.path) < 0); // already removed +} + +test "an adopted tempfile remains named for the document" { + var buf: [4096:0]u8 = undefined; + const made = createIn(&buf, "/tmp") orelse return error.TempCreateFailed; + made.adopt(); + // Adoption closes only the creation handle. The ordinary file document + // keeps this path for Save, watch, Del and dump/restore. + try std.testing.expectEqual(@as(c_int, 0), libc.unlink(made.path)); +} + +test "mkstemp failure creates no candidate file" { + var buf: [4096:0]u8 = undefined; + try std.testing.expect(createIn(&buf, "/definitely/not/a/pardes/temp/directory") == null); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 60a2d958..6f7cc5ba 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -11,6 +11,7 @@ const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); +const temp_file = @import("../temp_file.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -684,6 +685,14 @@ fn drainEffects( // from the bytes we just put there so it reads as "no change" if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, + .new_file => |request| { + var path_buf: [4096:0]u8 = undefined; + const made = temp_file.create(&path_buf) orelse continue; + if (core.openNewFile(request.pane, request.serial, made.path)) + made.adopt() + else + made.discard(); + }, .write_dump => { const out = core.dump_out orelse continue; var pbuf: [1024:0]u8 = undefined; diff --git a/src/tutor.txt b/src/tutor.txt index 6721e9e8..f13346fe 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -136,9 +136,10 @@ "Delcol" still exists as a command: type it in a tag or body and execute it to close the whole column. A SEPARATE bar across the top of the screen holds the window-agnostic builtins: - Kill Newcol Tutor Debug NextColor Dump - Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor, - "Kill" to quit. From the KEYBOARD the bar is `k` off the topmost + Kill Newcol Tutor Debug NextColor Dump Find Grep New Help + Middle-click "New" for an empty temporary file in this pane's column, + "Newcol" for a new column, "Tutor" to spawn this tutor, "Kill" to + quit. From the KEYBOARD the bar is `k` off the topmost tagline: it takes the cursor, h/l and the arrows move by character, w/b/e and 0/$ by word, Enter or Tab runs the word under the cursor exactly as a middle-click on it would, j drops back onto the tagline @@ -512,7 +513,7 @@ SPC ? Help: every builtin and the keys that run it SPC k Kill (quit) SPC d Del (close this pane) - SPC f s / f f Save / Find SPC h t Tutor (this file) + SPC f s/f f/f n Save / Find / New SPC h t Tutor (this file) SPC c n / c d Newcol / Delcol SPC t d/c/n/r Debug / Colors / NextColor / Crt toggles SPC t p/l/a Petscii / Palette / Ascii: an image pane's @@ -542,7 +543,7 @@ chords: 1-2 cut 1-3 paste (both in one hold = snarf) 2-1 = middle-exec with the left selection as argument file tag = mode + path + "Save Del"; other tags show Del - top bar = Kill Newcol Tutor Debug NextColor Dump + top bar = Kill Newcol Tutor Debug NextColor Dump ... New Help (keyboard: k off the topmost tagline) WEB TOUCH: one-finger tap = LOOK; drag = natural scroll LOOK opens URLs or embedded tracked .zig source (read-only) @@ -561,6 +562,7 @@ Esc = last document <-> last terminal (body normal) SPC = the leader: a key path runs a builtin (SPC ? lists them; SPC k Kill, SPC d Del, SPC f s Save, SPC f f Find, + SPC f n New, SPC w hjkl focus, SPC w t file <-> terminal) vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars. @@ -570,7 +572,7 @@ shell's own prompt to position you precisely. To spawn THIS tutor again from anywhere: middle-click "Tutor" in the - top bar (next to Kill / Newcol), or press SPC h t. + top bar (next to New / Newcol), or press SPC h t. Quit the tutor: this is a file pane — `:q` isn't wired; close the window (middle-click "Del" in its tag), "Kill" (top bar) to quit diff --git a/src/web.zig b/src/web.zig index cebbeebf..6ceca904 100644 --- a/src/web.zig +++ b/src/web.zig @@ -375,6 +375,13 @@ export fn pardes_effect_next() u32 { if (e.on) if (s.core.panes[e.pane]) |pane| if (pane.file) |file| putEffect(s, file.path); break :blk 9; }, + // Browser hosts cannot create this file, but surface the request like + // the other host-specific IO effects instead of performing IO here. + .new_file => |e| blk: { + effect_aux0 = e.pane; + effect_aux1 = e.serial; + break :blk 11; + }, .quit => 10, }; } |
