diff options
| author | Gabriel Schneider <[email protected]> | 2026-10-01 17:43:39 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 20:04:42 -0300 |
| commit | 805b9f188768fee523d462411d846912e0877ff3 (patch) | |
| tree | dc17de5cee6e4b554446052a29fd55e8a5285498 /src/edit_cmd.zig | |
| parent | 0728b937028418232a1da3d9922615a6f49680f2 (diff) | |
| download | pardes-805b9f188768fee523d462411d846912e0877ff3.tar.gz pardes-805b9f188768fee523d462411d846912e0877ff3.zip | |
Edit runs acme's file and shell commands: < | > through the session's Shell off the loop (one undo step, a failure changes nothing, its stderr in +Errors, a 9P write answered when done), X and Y over the open text panes, b B D e r f w and the "file" address; checked against sam -d, and the reference's Edit section and Coming from acme row say so
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/edit_cmd.zig')
| -rw-r--r-- | src/edit_cmd.zig | 487 |
1 files changed, 487 insertions, 0 deletions
diff --git a/src/edit_cmd.zig b/src/edit_cmd.zig new file mode 100644 index 00000000..7f5c6279 --- /dev/null +++ b/src/edit_cmd.zig @@ -0,0 +1,487 @@ +//! 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_answer_at`). +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 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, column by column as acme's allwindows goes, then any + // not in a column; the Edit starts in its own. + var files: std.ArrayList(sam.File) = .empty; + var cur: usize = 0; + var seen: [pardes.MAX_PANES]bool = @splat(false); + for (0..p.ncol) |c| { + for (p.col_panes[c][0..p.col_n[c]]) |slot| addFile(p, &files, arena, slot, id, &cur, &seen) catch |err| return p.reportError(id, "Edit", err); + } + for (0..pardes.MAX_PANES) |slot| addFile(p, &files, arena, slot, id, &cur, &seen) catch |err| return p.reportError(id, "Edit", err); + + 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, .open = World.open, .read = World.read }, &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), + }; + keep = true; + p.pipe.edit_run = pd; + p.emit(.{ .pipe = .{ .id = pd.id } }); + // A 9P write that ran it is answered once it is done. + if (p.fs.serving) p.fs.edit_answer_at = pardes.turn.edit_answers; +} + +fn addFile(p: *Pardes, files: *std.ArrayList(sam.File), arena: std.mem.Allocator, slot: usize, from: usize, cur: *usize, seen: *[pardes.MAX_PANES]bool) !void { + if (seen[slot]) return; + seen[slot] = true; + const pane = p.panes[slot] orelse return; + if (pane.file == null) return; + if (slot == from) cur.* = files.items.len; + try files.append(arena, fileOf(pane, slot)); +} + +/// 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); + defer pardes.turn.editAnswered(); + 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 if (p.fs.failures != failures) p.noteLateFailure(at); + 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"; + _ = ninep_pane.appendErrors(p, at, out.written()); + 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) 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 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"); + } + // `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); + } + // e read its own file over it: what is on disk again. + if (f.clean) { + pf.saved_revision = pf.revision; + pf.disk_gone = false; + } + } + 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; + if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items); + } + // 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 } }); +} + +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); + const answers = pardes.turn.edit_answers; + try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\n").reply.status); + // 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.expectEqual(answers +% 1, pardes.turn.edit_answers); + 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); + // What follows it in the same write would run before its commands + // answer: refused, and the Edit goes on. + const after = th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\nSave\n"); + try testing.expectEqual(tree.E.INVAL, after.errno()); + try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename); + answer(p, &.{"a\nb\nc\n"}); + try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content); +} + +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); + 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.late_failure_len = 0; + 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); + try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", p.fs.late_failure[0..p.fs.late_failure_len]); + 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(); + try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit , | cat\n").reply.status); + panes.File.setContent(p, &p.panes[0].?.file.?, try testing.allocator.dupe(u8, "typed\n")); + p.fs.late_failure_len = 0; + 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", p.fs.late_failure[0..p.fs.late_failure_len]); +} + +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" }); + 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); + // 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 refused; f names one and prints it. + try testing.expectEqual(tree.E.IO, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit e\n").errno()); + 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")); + try testing.expect(std.mem.indexOf(u8, errorsText(p).?, "'+. ") != null); + // 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); +} |
