summaryrefslogtreecommitdiff
path: root/src/Dir.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 17:36:52 -0300
committerGabriel Schneider <[email protected]>2026-10-01 17:47:47 -0300
commit2dbb047a82523c578cb511058c6e1b745cfc8073 (patch)
tree4e03e5e59399a233f7f50f21dd883c707db57f40 /src/Dir.zig
parent322aff16a2ca0cfd73e1a62234b8904412b30844 (diff)
downloadpardes-2dbb047a82523c578cb511058c6e1b745cfc8073.tar.gz
pardes-2dbb047a82523c578cb511058c6e1b745cfc8073.zip
A look at a directory opens a pane listing it, as acme's directory window: named dir/, its entries in acme's columns (bytewise, dotfiles kept, a directory's marked /), a look at an entry from there, Get or a look again reads it again, never dirty, Save refused; DirLook terminal keeps ls in a terminal
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/Dir.zig')
-rw-r--r--src/Dir.zig507
1 files changed, 507 insertions, 0 deletions
diff --git a/src/Dir.zig b/src/Dir.zig
new file mode 100644
index 00000000..979cd51a
--- /dev/null
+++ b/src/Dir.zig
@@ -0,0 +1,507 @@
+//! Directory panes, acme's directory windows (plan9port acme text.c,
+//! textload and textcolumnate): a look at a directory opens a pane named
+//! for it with a trailing `/`, its body the directory's entries in columns
+//! that fit the pane, a directory's marked with a `/`. A look at an entry
+//! is a look from that directory; Get, or a look at the directory again,
+//! reads it again. Otherwise it is a text pane like +Errors (Output's
+//! `.dir`): searched, selected and edited as any, and never dirty.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const panes = @import("panes.zig");
+const config = @import("config.zig");
+const exec = @import("exec.zig");
+const filesystem = @import("fs.zig");
+const limits = @import("memory.zig").limits;
+const Pardes = pardes.Pardes;
+const Pane = panes.Pane;
+const File = panes.File;
+
+/// What a directory pane keeps of its listing, as acme's Window keeps
+/// dlp/ndl: the entries, for a change of width to lay them out again.
+pub const State = struct {
+ /// The entries, sorted, each ending in a newline; gpa-owned.
+ names: []u8,
+ /// The text width they were laid out at (none yet: the most there is),
+ /// and the text's revision then: a body edited since is left as it is.
+ width: usize = std.math.maxInt(usize),
+ revision: u32 = 0,
+};
+
+/// acme's tab in a directory window (text.c TABDIR), in cells: a column is
+/// a whole number of them, at least a cell wider than its widest entry.
+const tab = 3;
+
+/// The directory a pane named `name` works in, as acme's dirname reads a
+/// window's name: a directory pane's (ending in `/`) is its own, a file's
+/// the one it is in.
+pub fn ofName(name: []const u8) []const u8 {
+ if (std.mem.endsWith(u8, name, "/")) {
+ const dir = std.mem.trimEnd(u8, name, "/");
+ return if (dir.len == 0) "/" else dir;
+ }
+ return std.fs.path.dirname(name) orelse "/";
+}
+
+/// The entries of directory `dir` on this host as acme lists them
+/// (plan9port lib9 dirread, acme's dircmp): every name but `.` and `..`,
+/// dotfiles too; a directory's, a link to one's too, ending in `/`; sorted
+/// bytewise, the `/` counted; each ending in a newline.
+pub fn read(gpa: std.mem.Allocator, dir: []const u8) ![]u8 {
+ if (comptime !filesystem.platform_has_fs) return error.FileNotFound;
+ const local = filesystem.localPath(dir) orelse return error.NotLocal;
+ // Its own copy: the caller's may be a pane's, gone while the turn is out.
+ var own: [4096]u8 = undefined;
+ if (local.len > own.len) return error.NameTooLong;
+ @memcpy(own[0..local.len], local);
+ var entries: std.ArrayList([]u8) = .empty;
+ defer {
+ for (entries.items) |entry| gpa.free(entry);
+ entries.deinit(gpa);
+ }
+ var total: usize = 0;
+ {
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const io = std.Io.Threaded.global_single_threaded.io();
+ var d = try std.Io.Dir.cwd().openDir(io, own[0..local.len], .{ .iterate = true });
+ defer d.close(io);
+ var it = d.iterate();
+ while (try it.next(io)) |entry| {
+ if (std.mem.eql(u8, entry.name, ".") or std.mem.eql(u8, entry.name, "..")) continue;
+ const is_dir = switch (entry.kind) {
+ .directory => true,
+ // Followed, as dirread's stat is: a link to a directory is one.
+ .sym_link, .unknown => if (d.statFile(io, entry.name, .{})) |st| st.kind == .directory else |_| false,
+ else => false,
+ };
+ total += entry.name.len + 2;
+ if (total > limits.max_file_bytes) return error.FileTooLarge;
+ const name = try std.mem.concat(gpa, u8, &.{ entry.name, if (is_dir) "/" else "" });
+ entries.append(gpa, name) catch |err| {
+ gpa.free(name);
+ return err;
+ };
+ }
+ }
+ std.mem.sort([]u8, entries.items, {}, struct {
+ fn lt(_: void, a: []u8, b: []u8) bool {
+ return std.mem.lessThan(u8, a, b);
+ }
+ }.lt);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ try out.ensureTotalCapacity(gpa, total);
+ for (entries.items) |entry| {
+ out.appendSliceAssumeCapacity(entry);
+ out.appendAssumeCapacity('\n');
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+/// `entries` in columns at most `width` cells wide, as acme's
+/// textcolumnate lays them out with its 3-cell tabs: down each column
+/// first, every column as wide as the widest entry and a cell more,
+/// rounded up to a tab, and each row ending in a newline. Blanks pad where
+/// acme's tabs do: a pardes tab is a fixed width, not a stop.
+pub fn columnate(gpa: std.mem.Allocator, entries: []const []const u8, width: usize) ![]u8 {
+ var colw: usize = 0;
+ for (entries) |entry| {
+ const w = File.displayWidth(entry) + 1;
+ colw = @max(colw, (w + tab - 1) / tab * tab);
+ }
+ const ncol = if (colw == 0) 1 else @max(1, width / colw);
+ const nrow = (entries.len + ncol - 1) / ncol;
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ for (0..nrow) |row| {
+ var j = row;
+ while (j < entries.len) : (j += nrow) {
+ try out.appendSlice(gpa, entries[j]);
+ if (j + nrow >= entries.len) break;
+ try out.appendNTimes(gpa, ' ', colw - File.displayWidth(entries[j]));
+ }
+ try out.append(gpa, '\n');
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+/// The cells a directory pane `cols` wide lays its entries out in: what
+/// is left of a row after the line numbers and the cell a wrap keeps
+/// (File.wrapWidth), the numbers as wide as `count` lines would need.
+fn textWidth(cols: usize, count: usize) usize {
+ var lines = count + 1;
+ var digits: usize = 1;
+ while (lines >= 10) : (lines /= 10) digits += 1;
+ return cols -| @max(config.PREFIX_W, digits + 1) -| 1;
+}
+
+/// Lays the pane's entries out again `cols` wide, when the width they need
+/// changed and the body is still theirs: a body edited since is kept, as
+/// typed, until a Get or a look at the directory reads it again.
+pub fn fit(p: *Pardes, pane: *Pane, cols: usize) void {
+ const f = if (pane.file) |*file| file else return;
+ const l = if (f.listing) |*listing| listing else return;
+ const count = std.mem.count(u8, l.names, "\n");
+ const width = textWidth(cols, count);
+ if (f.revision != l.revision or width == l.width) return;
+ // Not the scratch arena: this runs from sync, on every event.
+ const entries = p.gpa.alloc([]const u8, count) catch return;
+ defer p.gpa.free(entries);
+ var it = std.mem.splitScalar(u8, l.names, '\n');
+ for (entries) |*entry| entry.* = it.next().?;
+ const text = columnate(p.gpa, entries, width) catch return;
+ if (std.mem.eql(u8, text, f.content)) p.gpa.free(text) else {
+ File.setContent(p, f, text);
+ panes.Output.resetBody(p, pane);
+ }
+ l.width = width;
+ l.revision = f.revision;
+}
+
+/// Reads pane `id`'s directory again and lays it out anew, its edits gone,
+/// as acme's Get does in a directory window.
+pub fn refresh(p: *Pardes, id: usize) !void {
+ const pane = p.panes[id] orelse return error.NoPane;
+ const serial = pane.serial;
+ const names = try read(p.gpa, ofName(pane.file.?.path));
+ // The pane may have closed while the turn was out.
+ const now = p.panes[id] orelse return p.gpa.free(names);
+ if (now.serial != serial) return p.gpa.free(names);
+ const f = &now.file.?;
+ const l = &f.listing.?;
+ p.gpa.free(l.names);
+ l.names = names;
+ l.width = std.math.maxInt(usize);
+ l.revision = f.revision;
+ fit(p, now, now.cols);
+ panes.Output.resetBody(p, now);
+}
+
+/// A look at directory `dir` (look.zig's `.dir`, with `DirLook pane`): its
+/// pane, read again, or a new one, placed as a document is.
+pub fn look(p: *Pardes, from: usize, dir: []const u8) void {
+ const name = (if (std.mem.endsWith(u8, dir, "/"))
+ p.gpa.dupe(u8, dir)
+ else
+ std.mem.concat(p.gpa, u8, &.{ dir, "/" })) catch |err| return p.reportError(from, "look", err);
+ for (p.panes, 0..) |slot, i| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ if (f.listing == null or !std.mem.eql(u8, f.path, name)) continue;
+ p.gpa.free(name);
+ return show(p, from, i);
+ }
+ var said: [limits.host_path_cap + 16]u8 = undefined;
+ const what = std.fmt.bufPrint(&said, "look: {s}", .{name[0..@min(name.len, limits.host_path_cap)]}) catch "look";
+ const names = read(p.gpa, dir) catch |err| {
+ p.gpa.free(name);
+ return p.reportError(from, what, err);
+ };
+ const free = p.freeSlot() orelse {
+ p.gpa.free(name);
+ p.gpa.free(names);
+ return p.reportError(from, "look", error.NoPaneSlots);
+ };
+ const pane = open(p, free, name, names) catch |err| return p.reportError(from, "look", err);
+ const failures = p.fs.failures;
+ exec.placeNew(p, from, from, free, .doc);
+ // Refused a place: that is the look's one failure.
+ if (p.fs.failures != failures) return;
+ fit(p, pane, @max(1, p.rects[free].w -| config.GUTTER));
+}
+
+/// A pane on directory `name` (ending in `/`) with its entries `names`,
+/// both taken, in slot `id`, not yet placed and laid out.
+pub fn open(p: *Pardes, id: usize, name: []u8, names: []u8) !*Pane {
+ errdefer p.gpa.free(name);
+ errdefer p.gpa.free(names);
+ const content = try p.gpa.dupe(u8, "");
+ errdefer p.gpa.free(content);
+ const history = try File.History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ const pane = try p.newDocPane(id);
+ pane.file = .{
+ .path = name,
+ .content = content,
+ .output = .{ .from = .dir },
+ .history = history,
+ .listing = .{ .names = names },
+ };
+ pane.body.cur_pinned = true;
+ return pane;
+}
+
+/// The directory pane `id` read again and given the keyboard, a look at it
+/// coming from pane `from`.
+pub fn show(p: *Pardes, from: usize, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const serial = pane.serial;
+ var said: [limits.host_path_cap + 16]u8 = undefined;
+ const name = pane.file.?.path;
+ const what = std.fmt.bufPrint(&said, "look: {s}", .{name[0..@min(name.len, limits.host_path_cap)]}) catch "look";
+ refresh(p, id) catch |err| return p.reportError(from, what, err);
+ // Closed while the turn was out: nothing to give the keyboard to.
+ const now = p.panes[id] orelse return;
+ if (now.serial == serial) p.active = id;
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+
+fn laidOut(names: []const []const u8, width: usize) ![]u8 {
+ return columnate(testing.allocator, names, width);
+}
+
+test "entries go down each column first, a column a tab wider than its widest entry, as acme's" {
+ // The widest, 5 cells, makes 6: two tabs. 20 cells take three columns.
+ const names = [_][]const u8{ "a", "bb", "ccc/", "ddddd", "e", "f", "g" };
+ const text = try laidOut(&names, 20);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings(
+ "a ddddd g\n" ++
+ "bb e\n" ++
+ "ccc/ f\n",
+ text,
+ );
+}
+
+test "a width that is a whole number of tabs takes one more cell, so a column never touches the next" {
+ // "abc" is 3 cells, a tab exactly: its column is 6, not 3.
+ const names = [_][]const u8{ "abc", "de", "f" };
+ const text = try laidOut(&names, 12);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings("abc f\nde\n", text);
+}
+
+test "one column when a row has room for no more, and nothing at all for an empty directory" {
+ const names = [_][]const u8{ "alpha", "beta" };
+ const narrow = try laidOut(&names, 4);
+ defer testing.allocator.free(narrow);
+ try testing.expectEqualStrings("alpha\nbeta\n", narrow);
+ const none = try laidOut(&.{}, 80);
+ defer testing.allocator.free(none);
+ try testing.expectEqualStrings("", none);
+}
+
+test "a wide character counts its cells, not its bytes" {
+ // 世界 is 4 cells: its column is 6 cells, padded with two blanks.
+ const names = [_][]const u8{ "世界", "x" };
+ const text = try laidOut(&names, 12);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings("世界 x\n", text);
+}
+
+test "a directory reads as acme lists it: dotfiles kept, . and .. not, directories and links to them marked, sorted bytewise" {
+ if (comptime !filesystem.platform_has_fs) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const io = testing.io;
+ try tmp.dir.writeFile(io, .{ .sub_path = "b.txt", .data = "" });
+ try tmp.dir.writeFile(io, .{ .sub_path = ".hidden", .data = "" });
+ try tmp.dir.writeFile(io, .{ .sub_path = "B", .data = "" });
+ try tmp.dir.createDir(io, "a", .default_dir);
+ try tmp.dir.createDir(io, "a.d", .default_dir);
+ try tmp.dir.symLink(io, "a", "link", .{ .is_directory = true });
+ var buf: [4096]u8 = undefined;
+ const dir = buf[0..try tmp.dir.realPath(io, &buf)];
+ const names = try read(testing.allocator, dir);
+ defer testing.allocator.free(names);
+ // Bytewise, as dircmp's memcmp: `.` before capitals before small
+ // letters, and `a.d/` before `a/` because `.` is before `/`.
+ try testing.expectEqualStrings(".hidden\nB\na.d/\na/\nb.txt\nlink/\n", names);
+}
+
+test "a directory pane's directory is its own name, a file's the one it is in" {
+ try testing.expectEqualStrings("/a/b", ofName("/a/b/"));
+ try testing.expectEqualStrings("/", ofName("/"));
+ try testing.expectEqualStrings("/a", ofName("/a/b"));
+ try testing.expectEqualStrings("/", ofName("/a"));
+}
+
+/// A session on a file in a fresh directory holding `sub/inner.txt`,
+/// `top.txt` and `.dot`: the directory's path in `buf`.
+fn session(tmp: *std.testing.TmpDir, buf: *[4096]u8) !struct { p: *Pardes, dir: []const u8 } {
+ const io = testing.io;
+ try tmp.dir.createDir(io, "sub", .default_dir);
+ try tmp.dir.writeFile(io, .{ .sub_path = "sub/inner.txt", .data = "inner\n" });
+ try tmp.dir.writeFile(io, .{ .sub_path = "top.txt", .data = "top\n" });
+ try tmp.dir.writeFile(io, .{ .sub_path = ".dot", .data = "" });
+ const dir = buf[0..try tmp.dir.realPath(io, buf)];
+ var path_buf: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/top.txt", .{dir});
+ const p = try Pardes.init(testing.allocator, .{ .file = path, .tty_only = true, .cols = 80, .rows = 30 });
+ while (p.nextEffect()) |_| {}
+ return .{ .p = p, .dir = dir };
+}
+
+/// The pane named `name`, and how many are.
+fn named(p: *Pardes, name: []const u8) struct { id: ?usize, count: usize } {
+ var id: ?usize = null;
+ var count: usize = 0;
+ for (p.panes, 0..) |slot, i| if (slot) |pane| if (pane.file) |f| if (std.mem.eql(u8, f.path, name)) {
+ id = i;
+ count += 1;
+ };
+ return .{ .id = id, .count = count };
+}
+
+test "a look at a directory opens a pane named for it with a /, its entries in columns, never dirty, its directory its own" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ var name_buf: [4200]u8 = undefined;
+ const name = try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir});
+ const id = named(p, name).id orelse return error.NoDirectoryPane;
+ try testing.expectEqual(id, p.active);
+ const pane = p.panes[id].?;
+ try testing.expectEqualStrings(".dot sub/ top.txt\n", pane.file.?.content);
+ try testing.expectEqualStrings(@import("tagline.zig").dir_pane_tail, @import("tagline.zig").curTail(pane));
+ try testing.expectEqualStrings(s.dir, Pardes.paneDir(pane));
+ // Edited, still never dirty, and no Save writes it.
+ try testing.expect(p.executeBuiltinLine(id, "Edit ,d"));
+ try testing.expectEqualStrings("", pane.file.?.content);
+ try testing.expect(!@import("tagline.zig").dirty(pane));
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(id, "Save"));
+ try testing.expect(p.executeBuiltinLine(id, "Save elsewhere.txt"));
+ try testing.expectEqual(failures +% 2, p.fs.failures);
+ try testing.expect(p.panes[id].?.prompt == .none);
+}
+
+test "a look at an entry is a look from the directory, .. goes up to the open pane, and Get or a look again reads it again" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ var name_buf: [4200]u8 = undefined;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ const top = named(p, try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir})).id.?;
+ // An entry that is a directory: a pane of its own.
+ pardes.look.lookAt(p, top, "sub/");
+ p.sync();
+ const sub = named(p, try std.fmt.bufPrint(&name_buf, "{s}/sub/", .{s.dir})).id orelse return error.NoSubPane;
+ try testing.expectEqualStrings("inner.txt\n", p.panes[sub].?.file.?.content);
+ // A file in it, by its name alone: that directory's file.
+ pardes.look.lookAt(p, sub, "inner.txt");
+ p.sync();
+ try testing.expect(named(p, try std.fmt.bufPrint(&name_buf, "{s}/sub/inner.txt", .{s.dir})).id != null);
+ // `..`: the open pane above, read again, not a second one.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "new.txt", .data = "" });
+ pardes.look.lookAt(p, sub, "..");
+ p.sync();
+ const again = named(p, try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir}));
+ try testing.expectEqual(@as(usize, 1), again.count);
+ try testing.expectEqual(top, p.active);
+ try testing.expect(std.mem.indexOf(u8, p.panes[top].?.file.?.content, "new.txt") != null);
+ // Get, in its tag or its text: read again.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "sub/later.txt", .data = "" });
+ try testing.expect(p.executeBuiltinLine(sub, "Get"));
+ try testing.expectEqualStrings("inner.txt later.txt\n", p.panes[sub].?.file.?.content);
+ // Get anywhere else is still the acme word pardes has not.
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expect(p.fs.failures != failures);
+}
+
+test "an exec in a directory pane runs in its directory, and its +Errors goes there" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ var name_buf: [4200]u8 = undefined;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ const sub_name = try std.fmt.bufPrint(&name_buf, "{s}/sub/", .{s.dir});
+ pardes.look.lookAt(p, p.active, sub_name);
+ p.sync();
+ const sub = named(p, sub_name).id.?;
+ try testing.expectEqualStrings(sub_name[0 .. sub_name.len - 1], p.execDir(sub));
+ _ = @import("ninep/pane.zig").appendErrors(p, sub, "oops\n");
+ var errors_buf: [4200]u8 = undefined;
+ try testing.expect(named(p, try std.fmt.bufPrint(&errors_buf, "{s}{s}", .{ sub_name, config.errors_buffer })).id != null);
+}
+
+test "DirLook terminal: a look at a directory opens a terminal there, no pane listing it" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ try testing.expect(p.executeBuiltinLine(0, "DirLook terminal"));
+ try testing.expectEqual(config.Runtime.DirLook.terminal, p.settings.dir_look);
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ var name_buf: [4200]u8 = undefined;
+ try testing.expectEqual(@as(usize, 0), named(p, try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir})).count);
+ try testing.expect(p.panes[p.active].?.isTerminal());
+ try testing.expectEqualStrings(s.dir, p.panes[p.active].?.cwdSlice());
+ // Bare, it steps back to pane, the default.
+ try testing.expect(p.executeBuiltinLine(0, "DirLook"));
+ try testing.expectEqual(config.Runtime.DirLook.pane, p.settings.dir_look);
+}
+
+test "over 9P a directory pane is a text pane whose name ends in /, its body the listing, ctl's isdir 1, and get reads it again" {
+ if (comptime !pardes.hosted) return;
+ const th = @import("ninep/testing.zig");
+ const tree = @import("ninep/tree.zig");
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ var line_buf: [4200]u8 = undefined;
+ _ = th.wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line_buf, "{s}\n", .{s.dir}));
+ p.sync();
+ const pane = p.panes[p.active].?;
+ const index = th.rd(p, @intFromEnum(tree.TopFile.index), 0, 8192).bytes;
+ try testing.expect(std.mem.indexOf(u8, index, try std.fmt.bufPrint(&line_buf, "{d} text 0 {s}/ ", .{ pane.serial, s.dir })) != null);
+ try testing.expectEqualStrings(".dot sub/ top.txt\n", th.rd(p, tree.Node.of(pane.serial, .body), 0, 4096).bytes);
+ var fields = std.mem.tokenizeScalar(u8, th.rd(p, tree.Node.of(pane.serial, .ctl), 0, 4096).bytes, ' ');
+ for (0..3) |_| _ = fields.next();
+ try testing.expectEqualStrings("1", fields.next().?);
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "zz", .data = "" });
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(pane.serial, .ctl), "get\n").reply.status);
+ const after = th.rd(p, tree.Node.of(pane.serial, .body), 0, 4096).bytes;
+ try testing.expect(std.mem.indexOf(u8, after, "zz") != null);
+ try testing.expectEqualStrings(pane.file.?.content, after);
+}
+
+test "a directory pane lays its entries out again when its width changes, unless its text was edited" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ const pane = p.panes[p.active].?;
+ // 9 cells a column: two of them want 18 cells and the line numbers.
+ fit(p, pane, 24);
+ try testing.expectEqualStrings(".dot top.txt\nsub/\n", pane.file.?.content);
+ fit(p, pane, 80);
+ try testing.expectEqualStrings(".dot sub/ top.txt\n", pane.file.?.content);
+ try testing.expect(p.executeBuiltinLine(p.active, "Edit 1d"));
+ const edited = try testing.allocator.dupe(u8, pane.file.?.content);
+ defer testing.allocator.free(edited);
+ fit(p, pane, 24);
+ try testing.expectEqualStrings(edited, pane.file.?.content);
+}