//! 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); } 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); }