summaryrefslogtreecommitdiff
path: root/src/edit_cmd.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 22:58:12 -0300
committerGabriel Schneider <[email protected]>2026-10-01 22:58:12 -0300
commit3ddd34bcd94ba95d9cacbf6afcb9f89a16a414ce (patch)
treee4fee3fc0c7e228f4ac0a5877eb44b997f7bdb11 /src/edit_cmd.zig
parent1b654dfeb5b832d110beef0ec7dd8a3b7f66e431 (diff)
downloadpardes-3ddd34bcd94ba95d9cacbf6afcb9f89a16a414ce.tar.gz
pardes-3ddd34bcd94ba95d9cacbf6afcb9f89a16a414ce.zip
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 <[email protected]>
Diffstat (limited to 'src/edit_cmd.zig')
-rw-r--r--src/edit_cmd.zig117
1 files changed, 111 insertions, 6 deletions
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);
+}