diff options
| author | Gabriel Schneider <[email protected]> | 2026-10-01 17:36:52 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 17:47:47 -0300 |
| commit | 2dbb047a82523c578cb511058c6e1b745cfc8073 (patch) | |
| tree | 4e03e5e59399a233f7f50f21dd883c707db57f40 /src/Dir.zig | |
| parent | 322aff16a2ca0cfd73e1a62234b8904412b30844 (diff) | |
| download | pardes-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.zig | 507 |
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); +} |
