//! acme's Edit in the editor (exec.c `edit`, edit.c editcmd): the panes an //! Edit sees and what it does to them. src/sam_edit.zig runs the commands //! over the open text panes; here they are handed over, and what came of //! them applied: each file's changes as one undo step, the dots, `w`'s //! writes, the names `e` and `f` gave, `D`'s closes, and what was printed, //! to +Errors. //! //! An Edit with `<`, `|` or `>` runs their commands off the loop, as the //! `|` key's filter does (selection_pipe.zig, Pipe.zig): the commands go out //! as one pipe request, each in its file's directory through the session's //! Shell, and the Edit is applied when they have all answered -- or not at //! all, when one fails or a file it changes was edited meanwhile. One Edit //! waits at a time; a 9P write that ran it is answered when it is done //! (9p_io, `fs.edit_hold`), the connection serving on meanwhile; a flush //! of that write stops the commands and changes nothing. const std = @import("std"); const pardes = @import("pardes.zig"); const sam = @import("sam_edit.zig"); const panes = @import("panes.zig"); const layout = @import("layout.zig"); const look = @import("look.zig"); const builtins = @import("builtins.zig"); const filesystem = @import("fs.zig"); const selection_pipe = @import("selection_pipe.zig"); const Pipe = @import("Pipe.zig"); const ninep_pane = @import("ninep/pane.zig"); const Pardes = pardes.Pardes; /// An Edit whose commands are running: what it came to, and the request /// for them, all in `arena`. pub const Pending = struct { id: u32 = 0, arena: std.heap.ArenaAllocator, /// The pane the Edit ran from, which hears how it went. origin: usize, origin_serial: u32, res: sam.Result = undefined, /// Each file's pane serial and text revision when the Edit read it. serials: []u32 = &.{}, revisions: []u32 = &.{}, request: selection_pipe.Request = undefined, pub fn deinit(pd: *Pending, gpa: std.mem.Allocator) void { pd.arena.deinit(); gpa.destroy(pd); } }; /// What `w` took, written by the `edit_write` effect (pardes.zig perform). pub const Write = struct { pane: u8, serial: u32, path: []u8, bytes: []u8, /// The pane comes clean if its text is still this revision. clean: ?u32 = null, /// A scratch written whole is that file from then on (exec.saveTo). promote: bool = false, pub fn deinit(w: *Write, gpa: std.mem.Allocator) void { gpa.free(w.path); gpa.free(w.bytes); } }; /// `B`, `e` and `r` through the editor. const World = struct { p: *Pardes, from: usize, fn check(ctx: *anyopaque, path: []const u8, why: *sam.Why) bool { const w: *World = @ptrCast(@alignCast(ctx)); for (w.p.panes) |slot| { const pane = slot orelse continue; const f = ninep_pane.fileOf(pane) orelse continue; if (std.mem.eql(u8, f.path, path)) return true; } if (comptime !pardes.hosted) return true; const local = filesystem.localPath(path) orelse return true; if (@import("exec.zig").isDirectory(local)) return said(bool, why, "B: {s} is a directory", .{path}) orelse false; var buf: [4096]u8 = undefined; if (filesystem.resolveOs(local, &buf) == null) return said(bool, why, "B: {s}: no such file", .{path}) orelse false; return true; } fn refuseGet(ctx: *anyopaque, id: usize, why: *sam.Why) bool { const w: *World = @ptrCast(@alignCast(ctx)); const words = @import("ninep/ctl.zig").getRefused(w.p, w.p.panes[id] orelse return false, "e") orelse return false; return said(bool, why, "{s}", .{words}) orelse true; } fn open(ctx: *anyopaque, path: []const u8, why: *sam.Why) ?sam.File { const w: *World = @ptrCast(@alignCast(ctx)); const p = w.p; if (comptime pardes.hosted) if (filesystem.localPath(path)) |local| if (@import("exec.zig").isDirectory(local)) return said(sam.File, why, "B: {s} is a directory", .{path}); // A look opens it, or finds it open, where a look would; the // keyboard stays where the Edit ran. const was = p.active; look.lookAt(p, w.from, path); if (p.panes[was] != null) p.active = was; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const f = ninep_pane.fileOf(pane) orelse continue; if (!std.mem.eql(u8, f.path, path)) continue; return fileOf(pane, id); } return said(sam.File, why, "B: cannot open {s}", .{path}); } fn read(ctx: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *sam.Why) ?[]const u8 { const w: *World = @ptrCast(@alignCast(ctx)); if (comptime pardes.hosted) if (filesystem.localPath(path)) |local| if (@import("exec.zig").isDirectory(local)) return said([]const u8, why, "{s} is a directory", .{path}); const bytes = filesystem.read(w.p, path) catch |err| { const name = @errorName(err); const words = if (std.mem.eql(u8, name, "FileNotFound")) "file does not exist" else if (std.mem.eql(u8, name, "AccessDenied") or std.mem.eql(u8, name, "PermissionDenied")) "permission denied" else name; return said([]const u8, why, "can't open {s}: {s}", .{ path, words }); }; defer w.p.gpa.free(bytes); return arena.dupe(u8, bytes) catch return said([]const u8, why, "can't read {s}: out of memory", .{path}); } fn said(comptime T: type, why: *sam.Why, comptime fmt: []const u8, args: anytype) ?T { why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0; return null; } }; fn fileOf(pane: *panes.Pane, id: usize) sam.File { const f = ninep_pane.fileOf(pane).?; return .{ .name = f.path, .text = f.content, .dot = ninep_pane.dotOf(pane), .dirty = ninep_pane.dirtyOf(pane), .scratch = f.output != null, .id = id, }; } /// `Edit command` from pane `id`, a text pane. pub fn run(p: *Pardes, id: usize, command: []const u8) void { const pane = p.panes[id] orelse return; if (ninep_pane.fileOf(pane) == null) return p.reportFailure(id, "Edit: only a text pane is edited"); // acme's Edit blocks the editor while its commands run; here the // editor goes on, and a second Edit waits its turn. if (p.pipe.edit_run != null) return p.reportFailure(id, "Edit: another Edit's commands are still running"); const pd = p.gpa.create(Pending) catch |err| return p.reportError(id, "Edit", err); pd.* = .{ .arena = .init(p.gpa), .origin = id, .origin_serial = pane.serial }; var keep = false; defer if (!keep) pd.deinit(p.gpa); const arena = pd.arena.allocator(); // Every text pane in the order they were made, as /index lists them, // so X goes the same way whatever the layout; the Edit starts in its own. var files: std.ArrayList(sam.File) = .empty; for (p.panes, 0..) |slot, i| { const q = slot orelse continue; if (q.file == null) continue; files.append(arena, fileOf(q, i)) catch |err| return p.reportError(id, "Edit", err); } std.mem.sort(sam.File, files.items, p, struct { fn before(core: *Pardes, a: sam.File, b: sam.File) bool { return core.panes[a.id].?.serial < core.panes[b.id].?.serial; } }.before); const cur = for (files.items, 0..) |f, i| { if (f.id == id) break i; } else unreachable; var world: World = .{ .p = p, .from = id }; var why: sam.Why = .{}; var text: [260]u8 = undefined; const res = sam.run(arena, files.items, cur, command, .{ .ctx = &world, .check = World.check, .open = World.open, .read = World.read, .refuseGet = World.refuseGet }, &why) catch |err| switch (err) { error.Edit => return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: {s}", .{why.text()}) catch "Edit: failed"), error.OutOfMemory => return p.reportError(id, "Edit", err), }; if (res.undo != 0) { for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(p, pane) else panes.File.redo(p, pane); return; } pd.res = res; // What each file was, to know at the end that it still is. pd.serials = arena.alloc(u32, res.files.len) catch |err| return p.reportError(id, "Edit", err); pd.revisions = arena.alloc(u32, res.files.len) catch |err| return p.reportError(id, "Edit", err); for (res.files, 0..) |f, i| { const q = p.panes[f.id].?; pd.serials[i] = q.serial; pd.revisions[i] = q.file.?.revision; } if (res.jobs.len == 0) return commit(p, pd, &.{}); // The commands, as one pipe request: each with its input copied (the // text may change while they run, and the Edit is then refused), in // its file's directory. const n = res.jobs.len; const commands = arena.alloc([]const u8, n) catch |err| return p.reportError(id, "Edit", err); const cwds = arena.alloc([]const u8, n) catch |err| return p.reportError(id, "Edit", err); const inputs = arena.alloc(selection_pipe.Input, n) catch |err| return p.reportError(id, "Edit", err); for (res.jobs, 0..) |job, i| { const f = res.files[job.file]; commands[i] = job.command; cwds[i] = Pardes.paneDir(p.panes[f.id].?); inputs[i] = .{ .bytes = if (job.c == '<') "" else arena.dupe(u8, f.text[job.q0..job.q1]) catch |err| return p.reportError(id, "Edit", err) }; } p.pipe.seq +%= 1; pd.id = p.pipe.seq; pd.request = .{ .id = pd.id, .command = commands[0], .cwd = cwds[0], .inputs = inputs, .commands = commands, .cwds = cwds, .shell = arena.dupe(u8, p.shellBin()) catch |err| return p.reportError(id, "Edit", err), .token = selection_pipe.newToken(), }; keep = true; p.pipe.edit_run = pd; p.emit(.{ .pipe = .{ .id = pd.id } }); // A 9P write that ran it is held, and answered once it is done // (9p_io, fs.edit_hold); the connection serves on meanwhile. if (p.fs.serving) p.fs.edit_started = true; } /// The Edit waiting on its commands, stopped: its write was flushed, or /// its connection hung up (9p_io). Its commands are killed, and nothing /// changes. pub fn cancel(p: *Pardes) void { p.fs.edit_hold = null; const pd = p.pipe.edit_run orelse return; p.pipe.edit_run = null; selection_pipe.stop(pd.request.token); if (p.panes[pd.origin]) |pane| if (pane.serial == pd.origin_serial) p.setMessage(pd.origin, "Edit: interrupted; nothing changed"); pd.deinit(p.gpa); } /// The held 9P write that ran the Edit has its answer: done, failed in the /// words said on pane `at` when `failed`. fn answerHold(p: *Pardes, at: usize, failed: bool) void { const h = if (p.fs.edit_hold) |*held| held else return; h.done = true; p.fs.news = true; if (!failed) return; const words = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0) p.fs.said[0..p.fs.said_len] else if (p.panes[at]) |pane| pane.msg[0..pane.msg_len] else "Edit: failed"; const kept = @import("Messages.zig").clip(words, h.said.len); @memcpy(h.said[0..kept.len], kept); h.said_len = @intCast(kept.len); h.errno = @import("ninep/ctl.zig").failureErrno(kept); // The write's err record, as a refusal answered at once has. pardes.ctlfs.events.noteError(p, .{ .tag = h.tag, .op = .write, .node = h.node, .handle = h.handle }, @import("ninep/tree.zig").failText(h.tag, h.errno, h.said[0..h.said_len])); } /// The commands answered (pipe_resp): the Edit applied, or why not. pub fn finish(p: *Pardes, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure) void { const pd = p.pipe.edit_run orelse return; p.pipe.edit_run = null; defer pd.deinit(p.gpa); const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active; // Whatever fails from here fails the 9P write that ran it too. const failures = p.fs.failures; defer answerHold(p, at, p.fs.failures != failures); if (success and outputs.len == pd.res.jobs.len) return commit(p, pd, outputs); // Which command, in +Errors with its own words; the Edit changed nothing. const job = if (failure) |f| (if (f.index < pd.res.jobs.len) pd.res.jobs[f.index] else pd.res.jobs[0]) else pd.res.jobs[0]; var out: std.Io.Writer.Allocating = .init(p.gpa); defer out.deinit(); const w = &out.writer; var line: [256]u8 = undefined; report: { w.print("Edit: {c} {s}\n\n", .{ job.c, job.command }) catch break :report; if (pd.res.jobs.len > 1) if (failure) |f| w.print("command {d} of {d}: ", .{ f.index + 1, pd.res.jobs.len }) catch break :report; const why_at = out.written().len; Pipe.writeFailure(w, failure) catch break :report; const why = std.mem.sliceTo(out.written()[why_at..], '\n'); const said = std.fmt.bufPrint(&line, "Edit: {c} {s}: {s}", .{ job.c, job.command[0..@min(job.command.len, 120)], why }) catch "Edit: a command failed"; const was = p.active; _ = ninep_pane.appendErrors(p, at, out.written()); if (p.panes[was] != null) p.active = was; return p.reportFailure(at, said); } p.reportFailure(at, "Edit: a command failed"); } /// Apply what the Edit came to, `outputs` its jobs' answers: refused, all /// of it, if a pane it changes is not the one it read any more. fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void { const res = pd.res; const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active; var text: [320]u8 = undefined; for (res.files, 0..) |f, i| { if (f.ops.items.len == 0 and !f.close and !f.renamed and !f.moved and f.get == null) continue; const pane = p.panes[f.id] orelse return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed"); if (pane.serial != pd.serials[i]) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed"); if ((f.ops.items.len > 0 or f.get != null) and pane.file.?.revision != pd.revisions[i]) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} changed while its commands ran; nothing changed", .{f.name}) catch "Edit: a file changed"); } // e first, Get's way: read before anything is changed, so one that // cannot be read changes nothing; its own undo step puts the old name // back with the old text, and it leaves the pane clean. for (res.files) |f| { const path = f.get orelse continue; var failed: anyerror = error.Unexpected; const errno = @import("ninep/ctl.zig").getFile(p, p.panes[f.id].?, path, &failed); if (errno != 0) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: e {s}: {s}", .{ path, if (errno == @import("ninep/tree.zig").E.NOENT) "no such file" else "cannot be read" }) catch "Edit: e failed"); } // `w`'s bytes, written after this step, before anything else is. for (res.writes) |wr| { const f = res.files[wr.file]; const pane = p.panes[f.id].?; const path = p.gpa.dupe(u8, wr.path) catch |err| return p.reportError(at, "Edit", err); const bytes = p.gpa.dupe(u8, wr.bytes) catch |err| { p.gpa.free(path); return p.reportError(at, "Edit", err); }; const own = std.mem.eql(u8, wr.path, pane.file.?.path); p.pipe.writes.append(p.gpa, .{ .pane = @intCast(f.id), .serial = pane.serial, .path = path, .bytes = bytes, // Its own name, all of it, and nothing after the w changes it: // clean, as acme's putfile leaves it. .clean = if (own and wr.whole and !f.scratch and f.ops.items.len == 0) pane.file.?.revision else null, .promote = wr.whole and f.scratch, }) catch |err| { p.gpa.free(path); p.gpa.free(bytes); return p.reportError(at, "Edit", err); }; } if (res.writes.len > 0) p.emit(.edit_write); for (res.files) |f| { const pane = p.panes[f.id] orelse continue; const pf = ninep_pane.fileOf(pane) orelse continue; const ops = f.ops.items; for (ops) |*o| if (o.job) |j| { o.text = outputs[j]; }; if (ops.len > 0) { const new = sam.apply(p.gpa, pf.content, ops) catch |err| return p.reportError(at, "Edit", err); // `s/a/a/` changes nothing: no undo step, no new revision. if (std.mem.eql(u8, new, pf.content)) p.gpa.free(new) else { panes.File.pushUndo(p, pane); panes.File.setContent(p, pf, new); } } if (ops.len > 0 or f.moved) ninep_pane.setDot(pane, sam.moveDot(f.dot, ops)); if (f.renamed) ninep_pane.nameBuffer(p, f.id, f.name, false) catch |err| p.reportError(f.id, "Edit", err); } // What was printed, `>`'s output in its place among it. if (res.printed.len > 0 or outputs.len > 0) { var out: std.ArrayList(u8) = .empty; defer out.deinit(p.gpa); var from: usize = 0; for (res.jobs, 0..) |job, j| { if (job.c != '>') continue; out.appendSlice(p.gpa, res.printed[from..job.at]) catch return; out.appendSlice(p.gpa, outputs[j]) catch return; from = job.at; } out.appendSlice(p.gpa, res.printed[from..]) catch return; // Printed, the keyboard stays where it was: a +Errors made for it // does not take it, so the next Edit lands where this one did. const was = p.active; if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items); if (p.panes[was] != null) p.active = was; } // D's panes last, each as Del closes one: an edited one is warned about // once, and closes on the next. for (res.files) |f| { if (!f.close) continue; const pane = p.panes[f.id] orelse continue; if (builtins.warnModifiedIn(.{ .p = p, .pane = pane, .id = f.id, .txt = "", .arg = null }, .Del, .{ .pane = f.id })) continue; p.removePane(f.id, null) catch |err| p.reportError(f.id, "Edit", err); } } const testing = std.testing; const th = @import("ninep/testing.zig"); const tree = @import("ninep/tree.zig"); /// The answer of the Edit waiting on its commands: as a host's worker /// brings it home, run for real when `real`. fn answer(p: *Pardes, outputs: []const []const u8) void { const pd = p.pipe.edit_run.?; p.update(.{ .pipe_resp = .{ .id = pd.id, .success = true, .outputs = outputs } }); } fn runReal(p: *Pardes) !void { const pd = p.pipe.edit_run.?; const job = try selection_pipe.Job.copy(testing.allocator, pd.request); defer job.deinit(testing.allocator); var response = selection_pipe.runJob(testing.allocator, testing.io, job); defer response.deinit(testing.allocator); p.update(.{ .pipe_resp = .{ .id = pd.id, .success = response.success, .outputs = response.outputs, .failure = response.failure } }); } /// Whether any +Errors holds `needle`. fn errorsHas(p: *Pardes, needle: []const u8) bool { for (p.panes) |slot| { const q = slot orelse continue; const qf = q.file orelse continue; const o = qf.output orelse continue; if (std.meta.activeTag(o.from) == .errors and std.mem.indexOf(u8, qf.content, needle) != null) return true; } return false; } fn errorsText(p: *Pardes) ?[]const u8 { for (p.panes) |slot| { const q = slot orelse continue; const qf = q.file orelse continue; const o = qf.output orelse continue; if (std.meta.activeTag(o.from) == .errors) return qf.content; } return null; } test "Edit , | sort runs off the loop, through the session's shell in the file's directory, and is one undo step" { const p = try th.withFile(testing.allocator, "b\na\nc\n"); defer p.deinit(); const serial = th.serialOf(p); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\n").reply.status); // The listener is told to hold the write. try testing.expect(p.fs.edit_started); // Nothing changed yet: the command is out. try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content); const req = p.pipe.pipeRequest(p.pipe.edit_run.?.id).?; try testing.expectEqualStrings("sort", req.commands[0]); try testing.expectEqualStrings("b\na\nc\n", req.inputs[0].bytes); try testing.expectEqualStrings("/", req.cwds[0]); try testing.expectEqualStrings(p.shellBin(), req.shell); // A second Edit waits its turn rather than run beside it. try testing.expectEqual(tree.E.IO, th.wr(p, tree.Node.of(serial, .ctl), "Edit ,d\n").errno()); answer(p, &.{"a\nb\nc\n"}); try testing.expect(p.pipe.edit_run == null); try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Undo\n").reply.status); try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content); // Another line in its write would run before its commands answer: the // whole write is refused before any line runs, the one before it too. for ([_][]const u8{ "Edit , | sort\nSave\n", "Msg before\nEdit , | sort\nMsg after\n" }) |two| { const after = th.wr(p, tree.Node.of(serial, .ctl), two); try testing.expectEqual(tree.E.INVAL, after.errno()); try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename); try testing.expect(p.pipe.edit_run == null and !th.logHas(p, "before")); } try testing.expectEqual(tree.E.INVAL, th.wr(p, @intFromEnum(tree.TopFile.exec), "Msg before\nEdit , | sort\n").errno()); } test "the held write asked again is parked again, then answered once, and a flush stops the Edit" { const p = try th.withFile(testing.allocator, "b\na\n"); defer p.deinit(); const ctl = tree.Node.of(th.serialOf(p), .ctl); const req: tree.Req = .{ .tag = 7, .op = .write, .node = ctl, .handle = 0, .data = "Edit , | sort\n" }; try testing.expectEqual(tree.Status.ok, p.serveFs(req).status); // As 9p_io holds it. p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 7, .node = ctl, .handle = 0, .written = @intCast(req.data.len) }; // A retry of its park: still waiting, never run a second time. try testing.expectEqual(tree.Status.again, p.serveFs(req).status); try testing.expect(p.pipe.edit_run != null); answer(p, &.{"a\nb\n"}); try testing.expect(p.fs.edit_hold.?.done); const done = p.serveFs(req); try testing.expectEqual(tree.Status.ok, done.status); try testing.expectEqual(@as(u32, @intCast(req.data.len)), done.written); try testing.expect(p.fs.edit_hold == null); try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content); // A failure is the held write's answer, in the Edit's words. _ = p.serveFs(.{ .tag = 8, .op = .write, .node = ctl, .data = "Edit , | false\n" }); p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 2, .tag = 8, .node = ctl, .handle = 0, .written = 15 }; p.update(.{ .pipe_resp = .{ .id = p.pipe.edit_run.?.id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 1 } } }); const failed = filesystem.EditHold.answer(&p.fs); try testing.expectEqual(tree.Status.err, failed.status); try testing.expectEqualStrings("Edit: | false: exit status 1", failed.ename); // Flushed (or hung up): its commands stopped, nothing changed, and the // next Edit is free to run; the stopped one's late answer is no one's. _ = p.serveFs(.{ .tag = 9, .op = .write, .node = ctl, .data = "Edit , | sort -r\n" }); const stale = p.pipe.edit_run.?.id; p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 3, .tag = 9, .node = ctl, .handle = 0, .written = 18 }; cancel(p); try testing.expect(p.pipe.edit_run == null and p.fs.edit_hold == null); p.update(.{ .pipe_resp = .{ .id = stale, .success = true, .outputs = &.{"b\na\n"} } }); try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status); } test "Edit's < | and > with a real shell: a command per address, > printed to +Errors" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try th.withFile(testing.allocator, "one two\nthree\n"); defer p.deinit(); const ctl = tree.Node.of(th.serialOf(p), .ctl); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit ,x/[a-z]+/ | tr a-z A-Z\n").reply.status); try testing.expectEqual(@as(usize, 3), p.pipe.edit_run.?.request.inputs.len); try runReal(p); try testing.expectEqualStrings("ONE TWO\nTHREE\n", p.panes[0].?.file.?.content); try testing.expectEqual(@as(usize, 1), p.panes[0].?.file.?.history.undo_len); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit 2 < printf new\n").reply.status); try runReal(p); try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , > wc -l\n").reply.status); try runReal(p); // The +Errors it opened did not take the keyboard. try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content); try testing.expectEqualStrings("1", std.mem.trim(u8, errorsText(p).?, " \n")); } test "a failing command changes nothing and says why, its stderr in +Errors" { if (comptime !pardes.hosted) return error.SkipZigTest; const p = try th.withFile(testing.allocator, "keep\n"); defer p.deinit(); const ctl = tree.Node.of(th.serialOf(p), .ctl); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | echo boom >&2; exit 3\n").reply.status); p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 }; try runReal(p); try testing.expectEqualStrings("keep\n", p.panes[0].?.file.?.content); try testing.expectEqual(@as(usize, 0), p.panes[0].?.file.?.history.undo_len); // The held write's answer is the failure, in its words. try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", filesystem.EditHold.answer(&p.fs).ename); const report = errorsText(p).?; try testing.expect(std.mem.indexOf(u8, report, "Edit: | echo boom") != null); try testing.expect(std.mem.indexOf(u8, report, "boom\n") != null); } test "an Edit whose file is edited while its commands run is refused whole" { const p = try th.withFile(testing.allocator, "abc\n"); defer p.deinit(); const ctl = tree.Node.of(th.serialOf(p), .ctl); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status); p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 }; panes.File.setContent(p, &p.panes[0].?.file.?, try testing.allocator.dupe(u8, "typed\n")); answer(p, &.{"ABC\n"}); try testing.expectEqualStrings("typed\n", p.panes[0].?.file.?.content); try testing.expectEqualStrings("Edit: /test.txt changed while its commands ran; nothing changed", filesystem.EditHold.answer(&p.fs).ename); } test "X over open files, w, e, r, f, B and D on panes and the disk" { if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest; const gpa = testing.allocator; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.c", .data = "int foo;\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.h", .data = "foo\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.c", .data = "foo bar\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "data", .data = "DATA\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "n.c", .data = "new\n" }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; const p = try th.withFile(gpa, "scratch\n"); defer p.deinit(); var path: [4200]u8 = undefined; look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/a.c", .{dir})); const a = p.active; look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/b.h", .{dir})); p.active = 0; const ctl = tree.Node.of(th.serialOf(p), .ctl); // X: every open file whose line matches, each from its own dot. try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X/\\.[ch]$/ ,s/foo/FOO/g\n").reply.status); try testing.expectEqualStrings("int FOO;\n", p.panes[a].?.file.?.content); try testing.expectEqualStrings("scratch\n", p.panes[0].?.file.?.content); // X alone lists them in the order they were opened, as /index does. try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X\n").reply.status); const listed = errorsText(p).?; try testing.expect(std.mem.indexOf(u8, listed, "/test.txt").? < std.mem.indexOf(u8, listed, "/a.c").?); try testing.expect(std.mem.indexOf(u8, listed, "/a.c").? < std.mem.indexOf(u8, listed, "/b.h").?); // B opens a file, which a `"re"` address then edits, from pane 0. var cmd: [4400]u8 = undefined; try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/c.c\n", .{dir})).reply.status); try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit \"c\\.c$\" 1 s/bar/BAR/\n").reply.status); const c = for (p.panes, 0..) |slot, i| { if (slot) |q| if (q.file) |f| if (std.mem.endsWith(u8, f.path, "/c.c")) break i; } else return error.NotOpened; try testing.expectEqualStrings("foo BAR\n", p.panes[c].?.file.?.content); // w of all of a file to its own name leaves it clean; r reads into dot. try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[a].?.serial, .ctl), "Edit w\n").reply.status); p.perform(.edit_write); // the effect th.wr took off the queue try testing.expectEqualStrings("int FOO;\n", p.fallback.get(p.panes[a].?.file.?.path).?); try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?)); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit 1 r data\n").reply.status); try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content); // e of an edited file is asked about once, as Get asks; again, it is // read in Get's way, which leaves the pane clean and named after it. const c_ctl = tree.Node.of(p.panes[c].?.serial, .ctl); try testing.expect(th.wr(p, c_ctl, "Edit e data\n").reply.status == .err); try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content); try testing.expect(ninep_pane.dirtyOf(p.panes[c].?)); try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit e data\n").reply.status); try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/data")); try testing.expect(!ninep_pane.dirtyOf(p.panes[c].?)); // B checks every name first: a directory among them opens none. const before = p.next_serial; try testing.expect(th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/n.c {s}\n", .{ dir, dir })).reply.status == .err); try testing.expectEqual(before, p.next_serial); // f names one and prints it. try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit 1 c/edited/\n").reply.status); try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit f d.c\n").reply.status); try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/d.c")); // In its directory's +Errors, or the log when the screen has no room // left for one. try testing.expect(errorsHas(p, "'+. ") or th.logHas(p, "'+. ")); // D closes a pane by name, an edited one (b.h, by the X) on the // second asking, as Del does. const b_serial = for (p.panes) |slot| { if (slot) |q| if (q.file) |f| if (std.mem.endsWith(u8, f.path, "/b.h")) break q.serial; } else return error.NotOpened; const a_ctl = tree.Node.of(p.panes[a].?.serial, .ctl); try testing.expect(th.wr(p, a_ctl, "Edit D b.h\n").reply.status == .err); try testing.expect(p.paneBySerial(b_serial) != null); try testing.expectEqual(tree.Status.ok, th.wr(p, a_ctl, "Edit D b.h\n").reply.status); try testing.expect(p.paneBySerial(b_serial) == null); }