summaryrefslogtreecommitdiff
path: root/src/ninep/ctl.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/ctl.zig')
-rw-r--r--src/ninep/ctl.zig518
1 files changed, 501 insertions, 17 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index adfc91fa..105de07c 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -193,6 +193,7 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
// The root's lines run at the keyboard's pane: a refusal before any runs
// is that pane's too, in its err record.
p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 0;
+ if (editNotAlone(p, req.data)) return tree.failText(req.tag, E.INVAL, e_after_edit);
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
@@ -259,6 +260,26 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply {
pub const e_control = "invalid command line: it holds a control character (or DEL) other than a tab";
+/// An Edit whose `<`, `|` or `>` commands run answers its write when they
+/// are done (edit_cmd.zig); a line after it in the same write would run
+/// before them, on the text they are about to change. Refused up front,
+/// before any line runs.
+pub const e_after_edit = "invalid command line: an Edit that runs commands is a write of its own";
+
+/// Whether `data` holds an Edit that runs commands and any other line.
+fn editNotAlone(p: *Pardes, data: []const u8) bool {
+ const sam = @import("../sam_edit.zig");
+ var lines: usize = 0;
+ var runs = false;
+ var it = Messages.init(p, data);
+ while (it.next()) |line| {
+ if (line.len == 0 or exec_line.isComment(line)) continue;
+ lines += 1;
+ if (std.mem.startsWith(u8, line, "Edit") and sam.runsCommands(p.scratch.allocator(), line["Edit".len..])) runs = true;
+ }
+ return runs and lines > 1;
+}
+
/// Runs one click (`run`) as a 9P write's: a builtin that fails there fails
/// the write, with its words and an err record, and no msg, as a ctl line
/// does (runBuiltin). The refusal, or null.
@@ -297,7 +318,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i
/// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else
/// EIO.
pub fn failureErrno(failure: []const u8) u16 {
+ // Refused while something else runs (an Edit's commands): EBUSY, the
+ // word 9ns reads as it.
+ if (std.mem.indexOf(u8, failure, "busy: ") != null) return E.BUSY;
if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT;
+ // As 9ns reads the same words (cloud9.fs.enameErrno).
+ if (std.mem.indexOf(u8, failure, "not a dir") != null) return E.NOTDIR;
+ if (std.mem.indexOf(u8, failure, "is a dir") != null) return E.ISDIR;
+ if (std.mem.indexOf(u8, failure, "permission denied") != null) return 13; // EACCES
// A pattern refused (Edit's too) is malformed input, as 9ns reads it;
// so is a word this build has not (`invalid: acme's Put ...`).
if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.indexOf(u8, failure, "invalid") != null or
@@ -385,10 +413,11 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void {
pub const pane_text_cap = 9 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48 + "collapsed fit:height tint:filtered ".len;
/// acme's winctlprint (plan9port wind.c), field for field: serial, tag
-/// length, body length, isdir (0), the dirty flag, the width in cells, the
-/// font, the tab width, whether Undo has a step, whether Redo has one. Then
-/// pardes's own: rio's word for whether the window has the keyboard,
-/// `current` or `notcurrent` (rio(4), wctl), and a REPL's id.
+/// length, body length, isdir (1 for a directory pane), the dirty flag,
+/// the width in cells, the font, the tab width, whether Undo has a step,
+/// whether Redo has one. Then pardes's own: rio's word for whether the
+/// window has the keyboard, `current` or `notcurrent` (rio(4), wctl), and
+/// a REPL's id.
pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{
@@ -396,7 +425,8 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
pane_files.tagOf(p, pane).len,
// A terminal's body is its history, read whole on an open's first read.
if (pane.isTerminal()) panes.terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len,
- @as(u32, 0),
+ // A directory pane is one, as acme's isdir says.
+ @intFromBool(if (pane.file) |f| f.listing != null else false),
@intFromBool(pane_files.dirtyOf(pane)),
pane.cols,
}) catch return w.buffered();
@@ -407,7 +437,7 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
const f = pane_files.fileOf(pane);
const undo = if (f) |file| file.history.undo_len > 0 else false;
const redo = if (f) |file| file.history.redo_len > 0 else false;
- w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
+ w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_now, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
// A terminal bound as a REPL ends with its id, `python-a`.
var idbuf: [48]u8 = undefined;
const id = exec_line.replId(&idbuf, pane);
@@ -522,6 +552,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", arg);
},
.font => config.Runtime.FontSpec.parse(arg) != null,
+ // Bare, it says the width, as from a pane's exec.
+ .tab => arg.len == 0 or (std.fmt.parseInt(u16, arg, 10) catch 0) -% 1 < 16,
else => probe: {
var probe = p.settings;
break :probe probe.apply(setting, if (arg.len > 0) arg else null);
@@ -630,6 +662,7 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
.motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }),
.inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }),
.grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
+ .tab => try w.print("{s} {d}\n", .{ word, s.tab }),
}
}
}
@@ -789,7 +822,7 @@ fn sizeOf(line: []const u8) ?[2]u16 {
/// itself, so a new builtin is listed the moment it exists.
/// A builtin's doc comment, its first sentence, from the source itself: a
/// line of /commands says what each word does without a second copy of it.
-fn summaryOf(word: []const u8, buf: []u8) []const u8 {
+pub fn summaryOf(word: []const u8, buf: []u8) []const u8 {
const source = @embedFile("../builtins.zig");
var needle_buf: [64]u8 = undefined;
const needle = std.fmt.bufPrint(&needle_buf, "\npub const {s} = struct", .{word}) catch return "";
@@ -950,6 +983,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
const serial = pane.serial;
// Checked whole before anything applies, so a write that must wait for
// the lock has done nothing yet when it goes again.
+ if (editNotAlone(p, req.data)) return tree.failText(req.tag, E.INVAL, e_after_edit);
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
@@ -969,8 +1003,9 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
}
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
+ if (@import("../edit_cmd.zig").busyOn(p, p.paneBySerial(serial).?)) return tree.failText(req.tag, E.BUSY, "get: " ++ @import("../edit_cmd.zig").e_busy);
// Quoting the line, as a builtin's refusal (Save's) does.
- if (getRefused(p, pane)) |said_in_ename| {
+ if (getRefused(p, pane, "get")) |said_in_ename| {
// refuse writes ename, where the words are: copy them out.
var said_buf: [320]u8 = undefined;
const said = said_buf[0..said_in_ename.len];
@@ -1125,9 +1160,14 @@ const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
/// acme's get asks winclean first (exec.c:513): unsaved text is warned
/// about once, and the same get again, nothing edited since, discards it.
-fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
+/// Asked once over unsaved edits, by `word` (ctl's `get`, the Get builtin):
+/// the words of the refusal, in ename; null to go ahead.
+pub fn getRefused(p: *Pardes, pane: *Pane, word: []const u8) ?[]const u8 {
const f = pane_files.fileOf(pane) orelse return null;
if (!panes.Output.fileTraits(f.output).saves or f.revision == f.saved_revision) return null;
+ // A scratch under 100 bytes is not asked about, as Del and Exit do not
+ // ask (builtins.warnModifiedIn): nothing worth a question to lose.
+ if (f.output != null and f.content.len < 100) return null;
// Dirty by a rename alone is no text to lose: only edits are asked about.
const saved = f.saved_hash orelse return null;
if (std.hash.Wyhash.hash(0, f.content) == saved) return null;
@@ -1138,16 +1178,57 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
// write's err record logs it, not a msg too.
// And logged `unsaved <serial> <name>`, as the other refusals are.
tree.events.noteLog(p, .unsaved, pane);
- const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null;
+ const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\n{s} again to discard\n", .{ f.path, word }) catch null;
if (listed) |content| panes.Output.openUnsaved(p, id, content);
+ var notice: [64]u8 = undefined;
p.fs.unlogged = true;
- p.setMessage(id, "1 unsaved pane \u{2014} get again to discard");
+ p.setMessage(id, std.fmt.bufPrint(&notice, "1 unsaved pane \u{2014} {s} again to discard", .{word}) catch "1 unsaved pane");
p.fs.unlogged = false;
- return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
+ return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified ({s} again to discard)", .{ f.path, word }) catch "Modified (get again to discard)";
+}
+
+/// acme's `Get file`, and Edit's `e file`: the file at `path` (absolute)
+/// loaded into the pane, which takes its name, in one undo step that puts
+/// the old name back with the old text. Read first, so one that cannot be
+/// read changes nothing; loaded, the pane is clean. Asking about unsaved
+/// edits is the caller's (getRefused). Returns an errno, 0 when loaded.
+pub fn getFile(p: *Pardes, pane: *Pane, path: []const u8, failed: *anyerror) u16 {
+ const f = pane_files.fileOf(pane) orelse return E.INVAL;
+ const id = p.paneBySerial(pane.serial) orelse return E.NOENT;
+ const bytes = fs.read(p, path) catch |err| {
+ failed.* = err;
+ return switch (err) {
+ error.FileNotFound => E.NOENT,
+ else => E.IO,
+ };
+ };
+ panes.File.pushUndo(p, pane);
+ pane_files.nameBuffer(p, id, path, false) catch |err| {
+ p.gpa.free(bytes);
+ failed.* = err;
+ return E.INVAL;
+ };
+ panes.File.setContent(p, f, bytes);
+ f.saved_revision = f.revision;
+ f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.saved_len = f.content.len;
+ f.disk_newer = null;
+ f.disk_newer_warned = false;
+ f.disk_gone = false;
+ if (fs.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return 0;
}
-fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 {
+pub fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 {
const f = pane_files.fileOf(pane) orelse return 0;
+ // A directory pane: the directory read again (Dir.zig).
+ if (f.listing != null) {
+ panes.Dir.refresh(p, p.paneBySerial(pane.serial).?) catch |err| {
+ failed.* = err;
+ return if (err == error.FileNotFound) E.NOENT else E.IO;
+ };
+ return 0;
+ }
if (!panes.Output.fileTraits(f.output).saves) return 0;
const bytes = fs.read(p, f.path) catch |err| {
failed.* = err;
@@ -1215,7 +1296,7 @@ test "pane ctl read is acme's fields -- index's five, width in cells, font, tab
var want: std.ArrayList(u8) = .empty;
defer want.deinit(gpa);
try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} {d:>11} {d:>11} current ", .{
- pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0,
+ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_now, 0, 0,
});
try testing.expectEqualStrings(want.items, a.bytes);
try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size);
@@ -1765,6 +1846,348 @@ test "get reloads the pane from disk and reports a missing file without losing d
try testing.expect(pane_files.dirtyOf(pane));
}
+test "Get reads the pane's file again, refusing unsaved edits once, and Get file loads that file into the pane" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "the other\n" });
+ var directory_buf: [4096]u8 = undefined;
+ const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
+ const p = try withFile(testing.allocator, "in memory\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/note.txt\n", .{directory}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqualStrings("from disk\n", pane.file.?.content);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ // Edited: asked once, then read again.
+ _ = wr(p, Node.of(serial, .body), "unsaved\n");
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expect(p.fs.failures != failures);
+ try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqualStrings("from disk\n", pane.file.?.content);
+ // Get file: that file, by a name from the pane's directory.
+ try testing.expect(p.executeBuiltinLine(0, "Get other.txt"));
+ try testing.expectEqualStrings("the other\n", pane.file.?.content);
+ try testing.expect(std.mem.endsWith(u8, pane.file.?.path, "/other.txt"));
+ try testing.expect(!pane_files.dirtyOf(pane));
+}
+
+test "Putall saves every pane with unsaved edits to a file, one each, and leaves the rest" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ _ = wr(p, Node.of(first, .name), "/tmp/pardes-putall-a.txt\n");
+ _ = wr(p, Node.of(first, .body), "edited\n");
+ const second = p.freeSlot().?;
+ _ = try p.newDocPane(second);
+ p.panes[second].?.file = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-putall-b.txt"), .content = try p.gpa.dupe(u8, "clean\n"), .history = try panes.File.History.create(p.gpa) };
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.executeBuiltinLine(0, "Putall"));
+ var saves: usize = 0;
+ while (p.nextEffect()) |e| if (e == .save_file) {
+ saves += 1;
+ try testing.expectEqual(@as(u16, 0), e.save_file.pane);
+ };
+ try testing.expectEqual(@as(usize, 1), saves);
+}
+
+test "Zerox makes a second pane on one buffer: one text, undo and dirty state, its own cursor; Save cleans both, Del of one asks nothing" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(first, .name), try std.fmt.bufPrint(&name, "{s}/z.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ const twin_id = for (p.panes, 0..) |slot, id| {
+ if (id != 0) if (slot) |pane| if (pane.file != null) break id;
+ } else return error.NoTwin;
+ const a = p.panes[0].?;
+ const b = p.panes[twin_id].?;
+ // Both in the index, each its serial, one name.
+ const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes;
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, index, "/z.txt"));
+ // b's cursor on `three`; a line written above it in a moves it down.
+ b.body.cur_row = 2;
+ b.body.cur_col = 1;
+ _ = wr(p, Node.of(first, .addr), "0");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(first, .data), "zero\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ try testing.expectEqual(a.file.?.content.ptr, b.file.?.content.ptr);
+ try testing.expectEqual(@as(i32, 3), b.body.cur_row);
+ try testing.expectEqual(@as(i32, 1), b.body.cur_col);
+ try testing.expect(pane_files.dirtyOf(a) and pane_files.dirtyOf(b));
+ // A write through b is a's too; Undo in a takes it back from both.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "four\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfour\n", a.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ // Saved from b: both come clean.
+ try testing.expect(p.executeBuiltinLine(twin_id, "Save"));
+ while (p.nextEffect()) |e| p.perform(e);
+ try testing.expect(!pane_files.dirtyOf(a) and !pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", p.fallback.get(b.file.?.path).?);
+ // Edited again, Del of a asks nothing: b holds the text.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "five\n").reply.status);
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Del"));
+ p.sync();
+ try testing.expectEqual(failures, p.fs.failures);
+ try testing.expect(p.paneBySerial(first) == null);
+ try testing.expect(pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfive\n", b.file.?.content);
+ // Alone now, it is no twin; Del asks about it as about any pane.
+ try testing.expectEqual(@as(u32, 0), b.file.?.twin);
+ try testing.expect(p.executeBuiltinLine(p.paneBySerial(b.serial).?, "Del"));
+ try testing.expect(p.fs.failures != failures);
+}
+
+test "Undo after a load under another name (Get file, Edit e) puts the old name back with the old text and its clean state; Putall then writes nothing over the loaded file" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "other\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "keep.txt", .data = "keep\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var other_buf: [4200]u8 = undefined;
+ const other = try std.fmt.bufPrint(&other_buf, "{s}/other.txt", .{dir});
+ var keep_buf: [4200]u8 = undefined;
+ const keep = try std.fmt.bufPrint(&keep_buf, "{s}/keep.txt", .{dir});
+ for ([_][]const u8{ "Get keep.txt", "Edit e keep.txt" }) |load| {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [4300]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{other}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqualStrings("other\n", pane.file.?.content);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ // Loaded: keep.txt's text under its name.
+ try testing.expect(p.executeBuiltinLine(0, load));
+ try testing.expectEqualStrings("keep\n", pane.file.?.content);
+ try testing.expectEqualStrings(keep, pane.file.?.path);
+ if (std.mem.startsWith(u8, load, "Get")) try testing.expect(!pane_files.dirtyOf(pane));
+ // Undo: other.txt's text, other.txt's name, clean as it was.
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("other\n", pane.file.?.content);
+ try testing.expectEqualStrings(other, pane.file.?.path);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ // Putall saves nothing, and nothing lands on keep.txt.
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.executeBuiltinLine(0, "Putall"));
+ while (p.nextEffect()) |e| try testing.expect(e != .save_file);
+ // Redo: keep.txt again.
+ try testing.expect(p.executeBuiltinLine(0, "Redo"));
+ try testing.expectEqualStrings(keep, pane.file.?.path);
+ try testing.expectEqualStrings("keep\n", pane.file.?.content);
+ }
+}
+
+test "a Get file that fails changes nothing: no such file, a directory, or unsaved edits asked about once" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "here.txt", .data = "here\n" });
+ try tmp.dir.createDirPath(testing.io, "sub");
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [4300]u8 = undefined;
+ const here = try std.fmt.bufPrint(&name, "{s}/here.txt\n", .{dir});
+ _ = wr(p, Node.of(serial, .name), here);
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ const undo_len = pane.file.?.history.undo_len;
+ for ([_][]const u8{ "Get nosuch.txt", "Get sub" }) |line| {
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, line));
+ try testing.expect(p.fs.failures != failures);
+ try testing.expectEqualStrings(here[0 .. here.len - 1], pane.file.?.path);
+ try testing.expectEqualStrings("here\n", pane.file.?.content);
+ try testing.expectEqual(undo_len, pane.file.?.history.undo_len);
+ }
+ // Edited, the first Get asks and renames nothing.
+ _ = wr(p, Node.of(serial, .body), "more\n");
+ try testing.expect(p.executeBuiltinLine(0, "Get other.txt"));
+ try testing.expectEqualStrings(here[0 .. here.len - 1], pane.file.?.path);
+ try testing.expectEqualStrings("here\nmore\n", pane.file.?.content);
+}
+
+test "Zerox twins dump their text once and come back on one buffer from a Restore" {
+ const p = try withFile(testing.allocator, "one\ntwo\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ _ = wr(p, Node.of(first, .name), "/tmp/pardes-zerox-dump.txt\n");
+ _ = wr(p, Node.of(first, .body), "unsaved " ** 64 ++ "\n");
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ try pardes.dump.dumpState(p);
+ const text = p.dump_out.?;
+ // The text is kept once though three panes show it.
+ const encoded = try pardes.dump.encodeBytes(p.scratch.allocator(), p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, encoded));
+ const restored = try pardes.dump.restore(p, text, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ var files: [3]*pardes.panes.File.State = undefined;
+ var n: usize = 0;
+ for (restored.panes) |slot| if (slot) |pane| if (pane.file) |*f| if (std.mem.eql(u8, f.path, "/tmp/pardes-zerox-dump.txt")) {
+ files[n] = f;
+ n += 1;
+ };
+ try testing.expectEqual(@as(usize, 3), n);
+ for (files[1..]) |f| {
+ try testing.expectEqual(files[0].content.ptr, f.content.ptr);
+ try testing.expectEqual(files[0].history, f.history);
+ try testing.expect(f.twin != 0 and f.twin == files[0].twin);
+ }
+ try testing.expect(std.mem.startsWith(u8, files[0].content, "one\ntwo\nunsaved "));
+}
+
+test "a bad Tab width and a bad Incl directory are refused invalid, changing nothing; a twin's rename is each twin's" {
+ defer config.tab_now = config.tab_width;
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ for ([_][]const u8{ "Tab 0\n", "Tab 17\n", "Tab x\n" }) |line| {
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, line).errno());
+ // Run from a pane, as a tag click runs it: said invalid too.
+ const r = wr(p, Node.of(serialOf(p), .exec), line);
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "invalid tab width") != null);
+ }
+ try testing.expectEqual(config.tab_width, config.tab_now);
+ const before = p.incl.items.len;
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Incl rel/dir\n").errno());
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Incl - /usr/include\n").errno());
+ if (comptime pardes.hosted) try testing.expectEqual(E.NOENT, wr(p, root_ctl, "Incl /tmp/pardes-surely-no-such-dir\n").errno());
+ try testing.expectEqual(before, p.incl.items.len);
+ // A twin renamed: a rename record for each.
+ const first = serialOf(p);
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ const twin = for (p.panes) |slot| {
+ if (slot) |pane| if (pane.serial != first and pane.file != null) break pane.serial;
+ } else return error.NoTwin;
+ _ = wr(p, Node.of(first, .name), "/tmp/pardes-twin-renamed.txt\n");
+ var rec: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /tmp/pardes-twin-renamed.txt\n", .{first})));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /tmp/pardes-twin-renamed.txt\n", .{twin})));
+}
+
+test "Undo to unsaved text under its old name says nothing changed on disk when nothing did, and Save then writes" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "e.txt", .data = "e\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "b\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ for ([_][]const u8{ "Get b.txt", "Edit e b.txt" }) |load| {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [4300]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/e.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ _ = wr(p, Node.of(serial, .body), "appended\n");
+ // Asked once, then loaded.
+ try testing.expect(p.executeBuiltinLine(0, load));
+ try testing.expect(p.executeBuiltinLine(0, load));
+ try testing.expectEqualStrings("b\n", pane.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("e\nappended\n", pane.file.?.content);
+ try testing.expect(pane_files.dirtyOf(pane));
+ // The watch looks again at e.txt, as it was all along.
+ pardes.panes.File.changed(p, 0, "e\n");
+ try testing.expectEqual(@as(?u64, null), pane.file.?.disk_newer);
+ try testing.expect(!th.logHas(p, "changed on disk"));
+ // A change there is still news.
+ pardes.panes.File.changed(p, 0, "someone else\n");
+ try testing.expect(pane.file.?.disk_newer != null);
+ }
+}
+
+test "round 39's small refusals: one name an open, small scratches unasked, twins saved once, bare Tab on ctl, and errnos in words a mount reads" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "noread.txt", .data = "secret\n" });
+ try tmp.dir.createDirPath(testing.io, "sub");
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var line: [4300]u8 = undefined;
+ // One name an open: a second line, in the write or after, is refused.
+ try testing.expectEqual(E.INVAL, wr(p, Node.of(serial, .name), "/tmp/pardes-a\nb").errno());
+ const before = try testing.allocator.dupe(u8, pane.file.?.path);
+ defer testing.allocator.free(before);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .name), .omode = 1 }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = Node.of(serial, .name), .handle = h, .data = "/tmp/pardes-one.txt\n" }).reply.status);
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 3, .op = .write, .node = Node.of(serial, .name), .handle = h, .data = "b" }).errno());
+ _ = call(p, .{ .tag = 4, .op = .release, .node = Node.of(serial, .name), .handle = h, .opened = true });
+ tree.runClosedLines(p);
+ try testing.expectEqualStrings("/tmp/pardes-one.txt", pane.file.?.path);
+ // Bare Tab on the root ctl says the width, as from a pane.
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Tab\n").reply.status);
+ // Errnos a mount reads from the words.
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqual(E.ISDIR, wr(p, Node.of(serial, .exec), "Get sub\n").errno());
+ if (comptime pardes.hosted) {
+ var noread_buf: [4300]u8 = undefined;
+ _ = std.c.chmod(try std.fmt.bufPrintSentinel(&noread_buf, "{s}/noread.txt", .{dir}, 0), 0);
+ try testing.expectEqual(@as(u16, 13), wr(p, Node.of(serial, .exec), "Get noread.txt\n").errno());
+ try testing.expectEqual(E.NOTDIR, wr(p, Node.of(serial, .exec), try std.fmt.bufPrint(&line, "Incl {s}/f.txt\n", .{dir})).errno());
+ const zero = wr(p, Node.of(serial, .look), "/dev/zero\n");
+ _ = zero;
+ try testing.expect(th.logHas(p, "not a regular file"));
+ }
+ // Twins saved once by Putall.
+ _ = wr(p, Node.of(serial, .body), "edited\n");
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.executeBuiltinLine(0, "Putall"));
+ var saves: usize = 0;
+ while (p.nextEffect()) |e| if (e == .save_file) {
+ saves += 1;
+ };
+ try testing.expectEqual(@as(usize, 1), saves);
+ // A scratch of 2 bytes is loaded over unasked.
+ try testing.expect(p.executeBuiltinLine(0, "New"));
+ const scratch = p.panes[p.active].?;
+ _ = pane_files.spliceBody(p, scratch, 0, 0, "hi") orelse return error.NoScratch;
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Get {s}/f.txt", .{dir})));
+ try testing.expectEqual(failures, p.fs.failures);
+ try testing.expectEqualStrings("f\n", scratch.file.?.content);
+}
+
+test "Get of a file not there says so without asking about unsaved edits; the init file saved says a bad line as startup does" {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .startup_config = "Theme dusk\n", .startup_config_path = "/tmp/pardes-init-test/init" });
+ defer p.deinit();
+ const pane = try p.setTestFile("x\n");
+ _ = wr(p, Node.of(pane.serial, .body), "unsaved\n");
+ try testing.expect(p.executeBuiltinLine(0, "Get /tmp/pardes-surely-no-such-file.txt"));
+ try testing.expect(th.logHas(p, "no such file"));
+ try testing.expect(!th.logHas(p, "again to discard"));
+ try testing.expect(pane.discard_warned == null);
+ pardes.exec.reapplyInitFile(p, "/tmp/pardes-init-test/init", "Theme acme\nnosuchword 1\nNewcol\n");
+ try testing.expect(th.logHas(p, "err - init file line 2: unknown word \"nosuchword 1\""));
+ try testing.expect(!th.logHas(p, "init file line 3"));
+}
+
test "a body write to a terminal is typed input, not a paste, even to a program that asked for bracketed paste" {
const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30 });
defer p.deinit();
@@ -1855,6 +2278,15 @@ test "a builtin for one kind of pane is refused on another, and still acts on it
const glyphs = doc.image.?.glyph_art;
try testing.expectEqual(Status.ok, wr(p, Node.of(doc.serial, .ctl), "Petscii\n").reply.status);
try testing.expect(doc.image.?.glyph_art != glyphs);
+ // Its tag says what is drawn: with no graphics, glyph art whatever
+ // Petscii chose; with them, what it chose.
+ for ([_]bool{ false, true }) |art| {
+ doc.image.?.glyph_art = art;
+ p.native_images = false;
+ try testing.expect(std.mem.indexOf(u8, try pardes.tagline.tagPrefix(p, doc), " petscii:on ") != null);
+ p.native_images = true;
+ try testing.expect(std.mem.indexOf(u8, try pardes.tagline.tagPrefix(p, doc), if (art) " petscii:on " else " petscii:off ") != null);
+ }
}
test "exec Save writes the named file through the host" {
@@ -2411,7 +2843,8 @@ test "Shell refuses a path that is no executable, and bare it goes back to the d
try testing.expectEqual(Status.err, refused.reply.status);
try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: shell \"/nonexistent/zzsh\" not found") != null);
try testing.expect(th.logHas(p, "shell \"/nonexistent/zzsh\" not found"));
- try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno());
+ // Its words say a directory, which a mount reads as EISDIR too.
+ try testing.expectEqual(E.ISDIR, wr(p, root_ctl, "Shell /etc\n").errno());
try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory"));
try testing.expectEqualStrings("", p.settings.shell.requested.get());
try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status);
@@ -2652,6 +3085,7 @@ test "a terminal told the directory it already goes by logs no rename" {
test "a terminal with text typed at its prompt is never typed into: a look at its directory opens another" {
const p = try th.withTerm(testing.allocator);
defer p.deinit();
+ p.settings.dir_look = .terminal; // `ls` in a terminal, not a directory pane
const term = p.paneBySerial(serialOf(p)).?;
p.setCwd(term, "/tmp");
p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07TYPED" } });
@@ -2722,7 +3156,7 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot
// The host's own failure is the waiting write's (late_failure).
p.fs.late_failure_len = 0;
p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied);
- try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expectEqualStrings("shell: permission denied", p.fs.late_failure[0..p.fs.late_failure_len]);
}
test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" {
@@ -3137,13 +3571,16 @@ test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask)
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
const serial = serialOf(p);
- for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n", "Tab\n", "Indent\n", "Local\n", "Incl\n", "Abort\n" }) |line| {
+ for ([_][]const u8{ "Snarf\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
const r = wr(p, node, line);
try testing.expectEqual(E.INVAL, r.errno());
try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is not a pardes builtin") != null);
}
}
+ // Load is Restore: whatever it says, never acme's word refused.
+ const load = wr(p, Node.of(serial, .exec), "Load /tmp/pardes-surely-no-dump.zon\n");
+ try testing.expect(std.mem.indexOf(u8, load.reply.ename, "is not a pardes builtin") == null);
// Put saves, as Save does.
_ = wr(p, Node.of(serial, .name), "/tmp/pardes-put.txt\n");
const put = wr(p, Node.of(serial, .exec), "Put\n");
@@ -3556,6 +3993,53 @@ test "Back and Forward over a closed file's entries: one reopen is that file's p
try testing.expectEqual(c_serial, p.panes[p.active].?.serial);
}
+test "Back and Forward written to exec keep to the focused file, and JumpScope all, written to ctl, takes every place in order" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "row\n" ** 100 });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "row\n" ** 100 });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ // Each a look, as a user's jumps are: b1 a1 b40 a40 b80 a80.
+ for ([_][]const u8{ "b.txt:1", "a.txt:1", "b.txt:40", "a.txt:40", "b.txt:80", "a.txt:80" }) |place| {
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/{s}\n", .{ dir, place })).reply.status);
+ p.sync();
+ }
+ const Step = struct { word: []const u8, file: []const u8, line: i32 };
+ const walk = struct {
+ fn run(q: *Pardes, steps: []const Step) !void {
+ for (steps) |s| {
+ var cmd: [16]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(q, root_exec, try std.fmt.bufPrint(&cmd, "{s}\n", .{s.word})).reply.status);
+ q.sync();
+ const pane = q.panes[q.active].?;
+ try testing.expect(std.mem.endsWith(u8, pane.file.?.path, s.file));
+ try testing.expectEqual(s.line, pane.body.cur_row + 1);
+ }
+ }
+ }.run;
+ // a's places first, b only once a has none older; Forward mirrors it
+ // from b and ends at a80, where it began.
+ try walk(p, &.{
+ .{ .word = "Back", .file = "/a.txt", .line = 40 },
+ .{ .word = "Back", .file = "/a.txt", .line = 1 },
+ .{ .word = "Back", .file = "/b.txt", .line = 1 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 40 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 80 },
+ .{ .word = "Forward", .file = "/a.txt", .line = 80 },
+ });
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "JumpScope all\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.ctl), 0, 8192).bytes, "JumpScope all\n") != null);
+ try walk(p, &.{
+ .{ .word = "Back", .file = "/b.txt", .line = 80 },
+ .{ .word = "Back", .file = "/a.txt", .line = 40 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 80 },
+ });
+}
+
test "Recent keeps only files on disk: not a name never saved, nor the served tree's" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();