diff options
Diffstat (limited to 'src/ninep/ctl.zig')
| -rw-r--r-- | src/ninep/ctl.zig | 518 |
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(¬ice, "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(); |
