From 3ddd34bcd94ba95d9cacbf6afcb9f89a16a414ce Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 22:58:12 -0300 Subject: Edit's w to a file changed on disk since read is refused once, as Save is (w again to overwrite), the rest of an X/'/w written and the refused named; Edit filters get a pane command's environment ($PARDES_MOUNT, $PARDES_9P, $PARDES_PID, $winid); an Edit with no commands runs beside one whose commands run unless it touches a file that one changes, refused busy (EBUSY) as is a second Edit with commands Co-Authored-By: Claude Opus 5.5 --- src/edit_cmd.zig | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 6 deletions(-) (limited to 'src/edit_cmd.zig') diff --git a/src/edit_cmd.zig b/src/edit_cmd.zig index 4e2c09fd..ebeae223 100644 --- a/src/edit_cmd.zig +++ b/src/edit_cmd.zig @@ -148,9 +148,6 @@ fn fileOf(pane: *panes.Pane, id: usize) sam.File { 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; @@ -181,6 +178,22 @@ pub fn run(p: *Pardes, id: usize, command: []const u8) void { 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), }; + // acme's Edit blocks the editor while its commands run; here the editor + // goes on, and so does any Edit that runs no command and leaves alone + // the files the running one will change. A second Edit with commands, + // or one that would change such a file, is refused, busy. + if (p.pipe.edit_run) |running| { + if (res.jobs.len > 0) return p.reportFailure(id, "Edit: busy: another Edit's commands are running; one Edit runs commands at a time"); + for (res.files, 0..) |f, i| { + const touches = if (res.undo != 0) f.id == id else changes(f) or for (res.writes) |w| { + if (w.file == i) break true; + } else false; + if (!touches) continue; + const q = p.panes[f.id] orelse continue; + for (running.res.files, 0..) |g, j| if (g.id == f.id and running.serials[j] == q.serial and changes(g)) + return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: busy: {s}: an Edit's commands are running on it", .{f.name}) catch "Edit: busy"); + } + } 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; @@ -220,6 +233,9 @@ pub fn run(p: *Pardes, id: usize, command: []const u8) void { .cwds = cwds, .shell = arena.dupe(u8, p.shellBin()) catch |err| return p.reportError(id, "Edit", err), .token = selection_pipe.newToken(), + // What a pane's command is given: $PARDES_MOUNT, $PARDES_9P, + // $PARDES_PID, $winid as the Edit's pane. + .env = if (comptime pardes.hosted) @import("host_io.zig").commandEnv(arena, pane.serial, !p.opts.nested) catch &.{} else &.{}, }; keep = true; p.pipe.edit_run = pd; @@ -229,6 +245,11 @@ pub fn run(p: *Pardes, id: usize, command: []const u8) void { if (p.fs.serving) p.fs.edit_started = true; } +/// Whether the Edit changes file `f`: its text, its name, or its pane. +fn changes(f: sam.File) bool { + return f.ops.items.len > 0 or f.get != null or f.close or f.renamed; +} + /// 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. @@ -314,10 +335,22 @@ fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void { 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. + // `w`'s bytes, written after this step, before anything else is. To + // its own file changed on disk since it was read, as Save is: refused + // once, said, and written by the next (exec.saveFile); the others go. + var refused: std.Io.Writer.Allocating = .init(p.gpa); + defer refused.deinit(); for (res.writes) |wr| { const f = res.files[wr.file]; const pane = p.panes[f.id].?; + if (pane.file) |*pf| if (!f.scratch and std.mem.eql(u8, wr.path, pf.path)) if (pf.disk_newer != null) { + if (!pf.disk_newer_warned) { + pf.disk_newer_warned = true; + refused.writer.print("{s}{s}", .{ if (refused.written().len > 0) ", " else "", pf.path }) catch {}; + continue; + } + pf.disk_newer = null; + }; 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); @@ -376,6 +409,10 @@ fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void { if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items); if (p.panes[was] != null) p.active = was; } + if (refused.written().len > 0) { + var said: [320]u8 = undefined; + p.reportFailure(at, std.fmt.bufPrint(&said, "Edit: w: {s} modified on disk since read (w again to overwrite)", .{refused.written()[0..@min(refused.written().len, 200)]}) catch "Edit: w: modified on disk since read (w again to overwrite)"); + } // 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| { @@ -441,8 +478,8 @@ test "Edit , | sort runs off the loop, through the session's shell in the file's 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()); + // An Edit that would change the file being filtered is busy. + try testing.expectEqual(tree.E.BUSY, 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); @@ -622,3 +659,71 @@ test "X over open files, w, e, r, f, B and D on panes and the disk" { 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); } + +test "an Edit's w to a file changed on disk is refused once, as Save is, and the others are written" { + if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "a\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)]; + const p = try th.withFile(testing.allocator, "scratch\n"); + defer p.deinit(); + var path: [4200]u8 = undefined; + look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/a.txt", .{dir})); + const a = p.active; + look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/b.txt", .{dir})); + const b = p.active; + p.active = 0; + for ([_]usize{ a, b }) |i| try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[i].?.serial, .ctl), "Edit $a/more/\n").reply.status); + // a.txt changed on disk since it was read (the watch says so). + p.panes[a].?.file.?.disk_newer = 1; + const ctl = tree.Node.of(th.serialOf(p), .ctl); + const first = th.wr(p, ctl, "Edit X/'/w\n"); + try testing.expect(first.reply.status == .err); + try testing.expect(std.mem.indexOf(u8, first.reply.ename, "a.txt modified on disk since read (w again to overwrite)") != null); + try testing.expect(std.mem.indexOf(u8, first.reply.ename, "b.txt") == null); + // b.txt is written all the same; a.txt is not. + try testing.expectEqual(@as(usize, 1), p.pipe.writes.items.len); + try testing.expect(std.mem.endsWith(u8, p.pipe.writes.items[0].path, "/b.txt")); + p.perform(.edit_write); + try testing.expect(ninep_pane.dirtyOf(p.panes[a].?)); + // The same w again overwrites it, as a second Save does. + try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[a].?.serial, .ctl), "Edit w\n").reply.status); + try testing.expectEqual(@as(usize, 1), p.pipe.writes.items.len); + p.perform(.edit_write); + try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?)); +} + +test "an Edit with no command runs beside one whose commands run, unless it touches what that one changes" { + if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "x\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(testing.allocator, "b\na\n"); + defer p.deinit(); + var path: [4200]u8 = undefined; + look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/other.txt", .{dir})); + const other = p.active; + p.active = 0; + const ctl = tree.Node.of(th.serialOf(p), .ctl); + const other_ctl = tree.Node.of(p.panes[other].?.serial, .ctl); + try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | sort\n").reply.status); + // Another pane's plain s runs while the filter does. + try testing.expectEqual(tree.Status.ok, th.wr(p, other_ctl, "Edit ,s/x/y/\n").reply.status); + try testing.expectEqualStrings("y\n", p.panes[other].?.file.?.content); + // One that changes the filtered file, or runs commands of its own, is + // busy, EBUSY; a print of the filtered file is no change, and runs. + for ([_][]const u8{ "Edit ,s/a/A/\n", "Edit u\n" }) |line| { + const busy = th.wr(p, ctl, line); + try testing.expectEqual(tree.E.BUSY, busy.errno()); + try testing.expect(std.mem.indexOf(u8, busy.reply.ename, "busy: ") != null); + } + try testing.expectEqual(tree.E.BUSY, th.wr(p, other_ctl, "Edit , | cat\n").errno()); + try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit 1p\n").reply.status); + answer(p, &.{"a\nb\n"}); + try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content); +} -- cgit v1.3