summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig11
-rw-r--r--src/config.zig13
-rw-r--r--src/gui/gui.zig11
-rw-r--r--src/pardes.zig177
-rw-r--r--src/temp_file.zig84
-rw-r--r--src/tty/tty.zig9
-rw-r--r--src/tutor.txt14
-rw-r--r--src/web.zig7
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,
};
}