summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 07:56:48 -0300
committerGabriel Schneider <[email protected]>2026-10-01 08:53:11 -0300
commit6c82d06cd2a15d724b8e82e1128fa15dc9e35d05 (patch)
tree1c13f10465ce797333f8712d95d587af97ca2bc0
parent989f2bae90fde52b5dd36b57c31f535dd011461e (diff)
downloadpardes-6c82d06cd2a15d724b8e82e1128fa15dc9e35d05.tar.gz
pardes-6c82d06cd2a15d724b8e82e1128fa15dc9e35d05.zip
Restore reads a clean file from disk, and an unsaved one whose file changed since the Dump is said changed, its first Save asking before overwriting it
A Restore put back the dump's copy of every file pane and armed the watch with the disk as it now was, so a file changed on disk after the Dump came back as its old text, marked clean, and the next Save wrote the old text over the new without a word. A clean file pane's text is now not in the dump: Restore reads its file (an older dump's copy is read past too, and used only when the file is gone). An unsaved pane keeps its text once, base64, the plain copy beside it dropped, with the hash of the file it was read against (`disk_hash`); a Restore that finds another there says the file changed on disk, and Save asks once before overwriting it, as for a change seen while it was open. file_watch.zig's own tests were never collected (no test root imported it): main.zig's test block does now, and the restored watch test says what a clean restore now reads. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/File.zig40
-rw-r--r--src/dump.zig8
-rw-r--r--src/file_watch.zig11
-rw-r--r--src/main.zig2
-rw-r--r--src/ninep/ctl.zig40
-rw-r--r--test/fs_namespace.zig3
6 files changed, 94 insertions, 10 deletions
diff --git a/src/File.zig b/src/File.zig
index c738ee58..d41007c3 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -105,6 +105,7 @@ pub const State = struct {
pub const NodeStep = struct { ranges: []modal.Selection, primary: usize };
pub fn dumpPane(
+ p: *Pardes,
arena: std.mem.Allocator,
pane: *const Pane,
file: *const State,
@@ -114,6 +115,18 @@ pub fn dumpPane(
origin: []const u8,
origin_arg: []const u8,
) !dump.Pane {
+ const dirty = file.revision != file.saved_revision;
+ const on_disk = onDisk(file.path);
+ // A clean file's text is its file's: read from disk when restored, so
+ // a change made there since is what comes back. Any other is kept, once.
+ const from_disk = file.output == null and !dirty and on_disk;
+ // What an unsaved pane's text was read against: the disk as it is, or,
+ // when a change there was seen already, something it is not.
+ const disk_hash: ?u64 = if (file.output != null or !dirty or !on_disk) null else if (file.disk_newer) |newer| newer +% 1 else blk: {
+ const bytes = filesystem.read(p, file.path) catch break :blk null;
+ defer p.gpa.free(bytes);
+ break :blk std.hash.Wyhash.hash(0, bytes);
+ };
return .{
.kind = .file,
.tag = tag,
@@ -127,10 +140,10 @@ pub fn dumpPane(
.tree_context = file.tree_context,
.location_rows = try dumpLocationRows(arena, file.location_rows),
.path = file.path,
- .content = file.content,
- .content_b64 = try dump.encodeBytes(arena, file.content),
- .dirty = file.revision != file.saved_revision,
- .on_disk = onDisk(file.path),
+ .content_b64 = if (from_disk) "" else try dump.encodeBytes(arena, file.content),
+ .dirty = dirty,
+ .disk_hash = disk_hash,
+ .on_disk = on_disk,
.origin = origin,
.origin_arg = origin_arg,
.mini_source = if (file.mini) |mini| mini.source else "",
@@ -855,7 +868,16 @@ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
saved.content_b64.len > encoded_limit or saved.mini_colors_b64.len > encoded_limit)
return error.InvalidMini;
}
- const content: []u8 = if (saved.content_b64.len > 0)
+ // A clean file's text is read from its file: one changed on disk since
+ // the dump comes back changed, never as the old text marked clean (and
+ // a dump from before, which kept it, is read so too). Gone from disk, the
+ // dump's text if it kept one.
+ const from_disk: ?[]u8 = if (Output.fromWord(saved.origin) == null and !saved.dirty and saved.on_disk and
+ saved.mini_source.len == 0 and filesystem.localPath(saved.path) != null)
+ filesystem.read(p, saved.path) catch null
+ else
+ null;
+ const content: []u8 = if (from_disk) |bytes| bytes else if (saved.content_b64.len > 0)
try dump.decodeBytes(p.gpa, saved.content_b64)
else
try p.gpa.dupe(u8, saved.content);
@@ -923,6 +945,14 @@ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
// One never on disk (Config's new file, a name not yet saved) is
// restored as it was: clean, and nothing deleted.
if (output == null and saved.on_disk and !onDisk(path)) deleted(p, @intCast(id));
+ // Unsaved, and its file changed on disk since the text was read: said,
+ // and its first Save asks before overwriting the newer file.
+ if (saved.disk_hash) |read_against| if (output == null and onDisk(path)) {
+ if (filesystem.read(p, path)) |bytes| {
+ defer p.gpa.free(bytes);
+ if (std.hash.Wyhash.hash(0, bytes) != read_against) changed(p, @intCast(id), bytes);
+ } else |_| {}
+ };
return pane;
}
diff --git a/src/dump.zig b/src/dump.zig
index ca3751f2..1420ea8f 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -208,9 +208,16 @@ pub const LocationRow = struct {
pub const File = struct {
path: []const u8 = "",
+ /// The text, in older dumps plain and base64 both; now base64 alone,
+ /// and only for a pane whose text is not its file's on disk (unsaved,
+ /// an output, a file never written): a clean file's is read from disk.
content: []const u8 = "",
content_b64: []const u8 = "",
dirty: bool = false,
+ /// An unsaved pane's: the hash of the file on disk its text was read
+ /// against. A Restore that finds another there says it changed, and
+ /// Save asks before overwriting it.
+ disk_hash: ?u64 = null,
/// Its file was on disk when dumped: one gone by the Restore is said
/// deleted, one never written (Config's new file) is not. True in
/// older dumps.
@@ -728,6 +735,7 @@ pub fn dumpState(p: *Pardes) !void {
const body = try body_layer.bodyText(p, arena, pane);
const scroll: usize = @intCast(@max(0, pane.scroll()));
var dp: Pane = if (pane.file) |*f| try pardes.panes.File.dumpPane(
+ p,
arena,
pane,
f,
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 6c746e74..46764a73 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -687,11 +687,14 @@ test "restored file watches preserve snapshots and accept later disk changes" {
else => {},
};
try std.testing.expect(armed and watches[id] != null);
- try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ // A clean pane's text is its file's, read at the Restore: a dump
+ // that kept other text (an older dump) is not what comes back.
+ const restored: []const u8 = if (case.dirty) "restored snapshot\n" else "disk baseline\n";
+ try std.testing.expectEqualStrings(restored, pane.file.?.content);
try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
try std.testing.expectEqual(case.dirty, pane.file.?.revision != pane.file.?.saved_revision);
_ = reloadChanged(core, io, gpa, &watches);
- try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ try std.testing.expectEqualStrings(restored, pane.file.?.content);
try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" });
_ = reloadChanged(core, io, gpa, &watches);
@@ -704,9 +707,9 @@ test "restored file watches preserve snapshots and accept later disk changes" {
}
try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content);
pardes.panes.File.undo(core, pane);
- try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ try std.testing.expectEqualStrings(restored, pane.file.?.content);
_ = reloadChanged(core, io, gpa, &watches);
- try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ try std.testing.expectEqualStrings(restored, pane.file.?.content);
}
}
diff --git a/src/main.zig b/src/main.zig
index 08ead756..5dd6a8e6 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -822,6 +822,8 @@ test {
_ = @import("host_io.zig");
_ = @import("host_io.zig").Shell;
_ = @import("host_io.zig").Lsp;
+ // Its tests too: imported by the hosts only, it was never collected.
+ _ = @import("file_watch.zig");
_ = @import("lsp/lsp_client.zig");
_ = @import("lsp/lsp.zig");
if (comptime pardes.platform == .tty) {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index f4aff960..1a2dbdbc 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -4213,3 +4213,43 @@ test "Petscii, Palette and Ascii take the state their tag word names, set rather
_ = wr(p, ctl_node, "Petscii maybe\n");
try testing.expect(th.logHas(p, "Petscii takes on or off"));
}
+
+test "a Restore reads a clean file from disk; an unsaved one whose file changed since is said so, and its first Save asks" {
+ if (comptime !pardes.hosted) return;
+ const gpa = testing.allocator;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var path_buf: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/a.txt", .{dir});
+ const p = try Pardes.init(gpa, .{ .file = path, .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ // Clean: the dump keeps no text, and the newer disk is what comes back.
+ try pardes.dump.dumpState(p);
+ try testing.expect(std.mem.indexOf(u8, p.dump_out.?, "b25lCg==") == null); // "one\n" in base64
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "two\n" });
+ {
+ const restored = try pardes.dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ const f = pane_files.fileOf(restored.panes[0].?).?;
+ try testing.expectEqualStrings("two\n", f.content);
+ try testing.expectEqual(f.saved_revision, f.revision);
+ }
+ // Unsaved: its text comes back, the disk changed under it is said, and
+ // Save asks once before overwriting it.
+ const f = pane_files.fileOf(p.panes[0].?).?;
+ const edited = try gpa.dupe(u8, "edited\n");
+ pardes.panes.File.setContent(p, f, edited);
+ try testing.expect(f.revision != f.saved_revision);
+ try pardes.dump.dumpState(p);
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "three\n" });
+ const restored = try pardes.dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ const rf = pane_files.fileOf(restored.panes[0].?).?;
+ try testing.expectEqualStrings("edited\n", rf.content);
+ try testing.expect(rf.disk_newer != null);
+ pardes.exec.saveFile(restored, 0);
+ try testing.expect(th.logHas(restored, "modified on disk since read (Save again to overwrite)"));
+}
diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig
index bd620c2d..5bce9eda 100644
--- a/test/fs_namespace.zig
+++ b/test/fs_namespace.zig
@@ -206,7 +206,8 @@ test "explicit OS file opens and restores keep their names and request watches"
.kind = .file,
.tag = path,
.body = "",
- .file = .{ .path = path, .content = "const unsaved = 2;\n" },
+ // Unsaved text: kept (a clean pane's is read from disk).
+ .file = .{ .path = path, .content = "const unsaved = 2;\n", .dirty = true },
});
try std.testing.expectEqualStrings(path, copy.file.?.path);
try std.testing.expectEqualStrings("const unsaved = 2;\n", copy.file.?.content);