summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig164
1 files changed, 153 insertions, 11 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 8cf8d2e2..bc56f966 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -98,6 +98,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null);
try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null);
try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null);
+ try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail));
p.native_images = true;
var frame = std.heap.ArenaAllocator.init(gpa);
@@ -583,6 +584,12 @@ test "feature-off .pdf argv opens an ordinary file pane" {
const pane_tail = " " ++ config.pane_builtins_str;
const file_pane_tail = " " ++ config.file_pane_builtins_str;
+// Dumps persist the whole rendered tag. These were the two canonical tails
+// before New joined every pane; restoreTail recognizes exactly these spellings
+// as defaults so an old session upgrades, without mistaking an edited tail for
+// chrome it may replace.
+const legacy_pane_tail = " Del";
+const legacy_file_pane_tail = " Save Del";
// Builtins: executing the name (middle-click / Tab) runs it through the ONE
// dispatcher (runBuiltin, reached from execute), no matter where the name
@@ -633,6 +640,137 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
+test "pane-tag Exec prefers New and argument builtins before shell fallback" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {} // initial shell spawn/geometry
+
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings(" New Del", Pardes.curTail(pane));
+ const tag_x = p.rects[0].x + config.GUTTER;
+ const tag_y = p.rects[0].y;
+
+ // A real middle-click on the canonical pane-tag word reaches Exec, which
+ // must consume New as a builtin before any write can reach the shell.
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
+ var saw_new = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .new_file => |request| {
+ try std.testing.expectEqual(@as(u8, 0), request.pane);
+ try std.testing.expectEqual(pane.serial, request.serial);
+ saw_new = true;
+ },
+ .write => return error.NewFellThroughToPty,
+ else => {},
+ };
+ try std.testing.expect(saw_new);
+
+ // Argument-taking builtins use the same gesture path: a tag sweep is the
+ // complete command line, and Theme consumes its tail without a PTY write.
+ pane.tag_tail.clearRetainingCapacity();
+ try pane.tag_tail.appendSlice(gpa, " Theme dark");
+ pane.tag_init = true;
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 10, .row = tag_y } });
+ try std.testing.expectEqualStrings("dark", p.theme().name);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.ArgumentBuiltinFellThroughToPty,
+ else => {},
+ };
+
+ // Fixed-name builtins remain exact: a tail does not turn New or Kill into
+ // a match (especially important for the destructive one).
+ try std.testing.expect(!p.executeBuiltinLine(0, "New argument"));
+ try std.testing.expect(!p.executeBuiltinLine(0, "Kill trailing-garbage"));
+ try std.testing.expect(!p.quit);
+
+ // A word absent from the builtin vocabulary takes the fallback, including
+ // the terminating carriage return expected by the shell prompt.
+ pane.tag_tail.clearRetainingCapacity();
+ try pane.tag_tail.appendSlice(gpa, " DefinitelyNotABuiltin");
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, 0), w.pane);
+ try sent.appendSlice(gpa, w.bytes.slice());
+ },
+ .new_file => return error.UnknownCommandRanNew,
+ else => {},
+ };
+ try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent.items);
+}
+
+test "legacy default tag tails upgrade while custom tails remain owned" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{});
+ defer p.deinit();
+
+ const terminal = p.panes[0].?;
+ const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(terminal),
+ legacy_pane_tail,
+ });
+ p.restoreTail(terminal, terminal_old);
+ try std.testing.expect(!terminal.tag_init);
+ try std.testing.expectEqualStrings(" New Del", Pardes.curTail(terminal));
+
+ const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{
+ try p.tagPrefix(terminal),
+ });
+ p.restoreTail(terminal, terminal_custom);
+ try std.testing.expect(terminal.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal));
+
+ // A savable file has a distinct old default. Save remains first after the
+ // migration so the tag's established `:w<Tab>` route is unchanged.
+ const file = try p.hxOpenFileContent("");
+ const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(file),
+ legacy_file_pane_tail,
+ });
+ p.restoreTail(file, file_old);
+ try std.testing.expect(!file.tag_init);
+ try std.testing.expectEqualStrings(" Save New Del", Pardes.curTail(file));
+
+ const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{
+ try p.tagPrefix(file),
+ });
+ p.restoreTail(file, file_custom);
+ try std.testing.expect(file.tag_init);
+ try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file));
+}
+
+test "unknown Exec from an image writes to a terminal in the image directory" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{});
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ p.setCwd(0, "/tmp/pardes-image-dir");
+ const image_pane = try p.newDocPane(1);
+ image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
+ try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane));
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback"));
+
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, 0), w.pane);
+ try sent.appendSlice(gpa, w.bytes.slice());
+ },
+ .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal,
+ else => {},
+ };
+ try std.testing.expectEqualStrings("echo image-fallback\r", sent.items);
+}
+
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();
@@ -4751,6 +4889,7 @@ pub const Pardes = struct {
if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/";
if (comptime pdf_enabled) if (pane.pdf) |pv|
return std.fs.path.dirname(pv.path) orelse "/";
+ if (pane.image) |iv| return std.fs.path.dirname(iv.path) orelse "/";
return pane.cwdSlice();
}
@@ -8330,14 +8469,11 @@ pub const Pardes = struct {
// Anything not in the builtin vocabulary is a command line typed at
// a shell. Startup config calls executeBuiltinLine directly and never
// reaches this fallback.
- // terminal: run in itself. file pane: run in a shell in the file's
- // directory (found by cwd, else freshly forked).
- const dst = (if (pane.file) |f|
- p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
- else if (hasPdf(pane))
- p.ttyForDir(paneDir(pane))
- else
- id) orelse return null;
+ // A terminal runs in itself. Every document kind — real/output file,
+ // image, or PDF — runs in a terminal for its directory (an existing
+ // one when possible, otherwise a freshly forked shell). In particular,
+ // never emit a PTY write addressed to an image's no-PTY pane slot.
+ const dst = (if (pane.isTerminal()) id else p.ttyForDir(paneDir(pane))) orelse return null;
term_pane.padOutputBelowEdits(p, dst);
p.emitWrite(dst, cmd);
p.emitWrite(dst, "\r");
@@ -8760,13 +8896,19 @@ pub const Pardes = struct {
return p;
}
- /// restore a custom tag tail from a saved tag: whatever follows the live
- /// prefix, unless it's just the defaults
+ /// Restore a custom tag tail from a saved tag: whatever follows the live
+ /// prefix, unless it is this build's default or either exact legacy
+ /// default from before New appeared in every pane. Legacy defaults upgrade
+ /// to the live default; genuinely edited tails remain byte-for-byte owned.
fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
const pfx = p.tagPrefix(pane) catch return;
if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
const rest = saved_tag[pfx.len..];
- if (std.mem.eql(u8, rest, curTail(pane))) return;
+ const legacy = if (pane.file) |f|
+ if (output_pane.fileTraits(f.output).saves) legacy_file_pane_tail else legacy_pane_tail
+ else
+ legacy_pane_tail;
+ if (std.mem.eql(u8, rest, curTail(pane)) or std.mem.eql(u8, rest, legacy)) return;
pane.tag_tail.appendSlice(p.gpa, rest) catch return;
pane.tag_init = true;
}