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 | |
| 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')
| -rw-r--r-- | src/CHANGELOG.md | 4 | ||||
| -rw-r--r-- | src/Dir.zig | 507 | ||||
| -rw-r--r-- | src/File.zig | 11 | ||||
| -rw-r--r-- | src/Output.zig | 15 | ||||
| -rw-r--r-- | src/builtins.zig | 10 | ||||
| -rw-r--r-- | src/config.zig | 14 | ||||
| -rw-r--r-- | src/exec.zig | 6 | ||||
| -rw-r--r-- | src/host_io.zig | 2 | ||||
| -rw-r--r-- | src/look.zig | 5 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 21 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 2 | ||||
| -rw-r--r-- | src/panes.zig | 3 | ||||
| -rw-r--r-- | src/pardes.zig | 8 | ||||
| -rw-r--r-- | src/tagline.zig | 2 |
14 files changed, 594 insertions, 16 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 941ff2b1..6be39c7a 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.23 +- A look at a directory opens a pane listing it, as acme's directory + window does: its entries in columns, a directory's marked `/`. A look at + an entry opens it from there, and `Get` or a look at it again reads it + again. `DirLook terminal` types `ls` into a terminal there, as before. - Restore reads a clean file pane from disk, so a file changed since the Dump comes back as it is now. An unsaved pane keeps its text, and if its file changed on disk its first Save asks before overwriting. A dump holds 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); +} diff --git a/src/File.zig b/src/File.zig index b3c28290..eb11b0e4 100644 --- a/src/File.zig +++ b/src/File.zig @@ -84,6 +84,8 @@ pub const State = struct { mtime: u32 = 0, /// Non-null for a generated output buffer rather than an on-disk file. output: ?Output.State = null, + /// A directory pane's entries (Dir.zig), its output `.dir`. + listing: ?panes.Dir.State = null, mini: ?Mini.State = null, scroll: usize = 0, /// `line_starts[i]` is line i's byte offset. Empty means not built yet. @@ -943,6 +945,14 @@ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane { }; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); + // A directory pane is its directory as it is now, laid out at the next + // sync; one that cannot be read keeps the text the dump kept. + if (output) |o| if (o.from == .dir) { + const f = &pane.file.?; + if (panes.Dir.read(p.gpa, panes.Dir.ofName(path))) |names| { + f.listing = .{ .names = names, .revision = f.revision }; + } else |_| f.listing = .{ .names = &.{}, .revision = f.revision -% 1 }; + }; if (output == null and filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true, .mode = .baseline_disk } }); // Its file gone from disk since the dump: its text is nowhere else now, // so it comes back as one deleted on disk, dirty, and Del, Exit and @@ -984,6 +994,7 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { }; p.gpa.free(file.path); p.gpa.free(file.content); + if (file.listing) |listing| p.gpa.free(listing.names); if (file.mini) |*mini| mini.deinit(p.gpa); if (file.line_starts.len > 0) p.gpa.free(file.line_starts); if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); diff --git a/src/Output.zig b/src/Output.zig index 5422411f..3bae9ecf 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -31,6 +31,8 @@ pub const Origin = union(enum) { /// The panes a refusal (Exit, Restore, Del, Delcol, get) is over, kept /// in +Unsaved rather than a notice that goes. unsaved, + /// A directory's entries (Dir.zig), the pane named for it. + dir, }; pub const max_arg = dump.max_origin_arg; @@ -228,6 +230,8 @@ pub fn traits(o: Origin) Traits { .errors => .{ .name = config.errors_buffer, .doc = true }, .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = true }, .unsaved => .{ .name = config.unsaved_buffer, .doc = true }, + // Named for its directory, not a `+` name in it. + .dir => .{ .name = "", .doc = true }, .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, @@ -288,15 +292,20 @@ pub fn word(o: Origin) []const u8 { .errors => config.errors_buffer, .pdf_links => config.pdf_links_buffer, .unsaved => config.unsaved_buffer, + .dir => dir_word, }; } +/// A directory pane's origin as a dump names it. +const dir_word = "dir"; + pub fn fromWord(w: []const u8) ?Origin { if (w.len == 0) return null; if (std.mem.eql(u8, w, "/")) return .search; if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links; if (std.mem.eql(u8, w, config.unsaved_buffer)) return .unsaved; + if (std.mem.eql(u8, w, dir_word)) return .dir; if (std.meta.stringToEnum(Builtin, w)) |b| if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; @@ -359,7 +368,7 @@ fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?u => {}, else => return anchor, }, - .errors, .pdf_links, .unsaved => return anchor, + .errors, .pdf_links, .unsaved, .dir => return anchor, } if (content.len == 0) return anchor; const path_only = std.meta.eql(from, Origin{ .cmd = .Find }); @@ -666,7 +675,7 @@ pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void { return; } const pane = p.panes[id] orelse return p.gpa.free(content); - const own = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const own = if (pane.file) |f| panes.Dir.ofName(f.path) else pane.cwdSlice(); // Its directory gone (or never there, a name not yet saved): the list // goes in the session's, where a look from it can still resolve. const dir = if (comptime pardes.hosted) (if (pardes.filesystem.localPath(own)) |local| (if (exec.isDirectory(local)) own else p.sessionDir()) else own) else own; @@ -820,7 +829,7 @@ pub fn openFonts(p: *Pardes, id: usize) !void { fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { errdefer p.gpa.free(content); const pane = p.panes[id] orelse return error.MissingPane; - const own = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const own = if (pane.file) |f| panes.Dir.ofName(f.path) else pane.cwdSlice(); // A terminal whose shell has not said where it is yet (an init file's // word at boot): the session's directory, not `/`. const dir = if (own.len == 0) p.sessionDir() else own; diff --git a/src/builtins.zig b/src/builtins.zig index 1a2f132f..1172fa78 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1125,6 +1125,12 @@ pub const Save = struct { // silent success. if (c.pane.file == null and !c.pane.isTerminal()) return c.p.reportFailure(c.id, if (c.pane.hasPdf()) "Save: a PDF pane has no text to save" else "Save: an image pane has no text to save"); + // A directory's listing is no file to write, bare or to a path, as + // acme's Put does nothing in a directory window. + if (c.pane.file) |file| if (file.listing != null) { + var said: [limits.host_path_cap + 48]u8 = undefined; + return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Save: {s} is a directory", .{file.path[0..@min(file.path.len, limits.host_path_cap)]}) catch "Save: a directory pane has no file to save"); + }; const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (path.len > 0) return exec.saveTo(c.p, c.id, path); // A `+` name is a generated pane's (+Tutor), no file of its own: @@ -1505,7 +1511,7 @@ pub const LocationsConfig = struct { try c.p.locations_config.write(&out.writer); try out.writer.writeByte('\n'); const content = try out.toOwnedSlice(); - const dir = if (c.pane.file) |file| std.fs.path.dirname(file.path) orelse "/" else c.pane.cwdSlice(); + const dir = if (c.pane.file) |file| panes.Dir.ofName(file.path) else c.pane.cwdSlice(); try panes.Output.fillResults(c.p, c.id, dir, .{ .cmd = .LocationsConfig }, "", content, null); } }; @@ -2085,7 +2091,7 @@ const Board = struct { p.gpa.free(content); return error.MissingPane; }; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const dir = if (pane.file) |f| panes.Dir.ofName(f.path) else pane.cwdSlice(); try panes.Output.fillResults(p, id, dir, from, "", content, null); } diff --git a/src/config.zig b/src/config.zig index f83e7945..45deb7bb 100644 --- a/src/config.zig +++ b/src/config.zig @@ -81,6 +81,7 @@ pub const leader_path = paths: { .Placement = "cp", .BootShell = null, .LookWord = null, + .DirLook = null, .Pager = null, .PagerColor = null, .TermImages = null, @@ -296,6 +297,8 @@ pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del"; pub const file_pane_builtins_str = "Save Tty Collapse Del"; pub const terminal_pane_builtins_str = "Tty Save Mode Filter Collapse Del"; pub const command_pane_builtins_str = "Kill Save Collapse Del"; +/// A directory pane's (Dir.zig): Get reads it again, as acme's does. +pub const dir_pane_builtins_str = "Get Tty Collapse Del"; // Each mode badge is one codepoint. pub const tag_normal = " "; @@ -774,6 +777,9 @@ pub const Runtime = struct { /// next place in that pane, wrapping; `list`, what pardes did before, /// lists every place in a +Search pane (look.zig, lookWord). look_word: LookWord = .search, + /// A directory looked at: `pane`, acme's, a pane listing it (Dir.zig); + /// `terminal`, what pardes did before, `ls` in a terminal there. + dir_look: DirLook = .pane, /// What a terminal's shell pages through (host_io.ChildEnv): `pardes`, /// PAGER and GIT_PAGER set to `pardes -`, a +Pager pane, where the /// user's environment set neither; `off`, the environment as it is. @@ -790,10 +796,11 @@ pub const Runtime = struct { pub const Placement = enum { acme, pardes }; pub const BootShell = enum { keep, replace }; pub const LookWord = enum { search, list }; + pub const DirLook = enum { pane, terminal }; pub const Pager = enum { pardes, off }; pub const TermImages = enum { real, petscii }; /// A setting that is one of a few words, each an enum field of Runtime. - pub const Choice = enum { placement, boot_shell, look_word, pager, term_images }; + pub const Choice = enum { placement, boot_shell, look_word, dir_look, pager, term_images }; /// shadow: a soft drop shadow. rim: a light hairline along the top of /// the focused pane's tag rows. auto: a shadow on a light page; on a @@ -1058,6 +1065,7 @@ pub const Runtime = struct { .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." }, .{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." }, .{ .word = "LookWord", .action = .{ .choice = .look_word }, .doc = "A plain word looked at: search selects its next place in the pane, list lists every place in a +Search." }, + .{ .word = "DirLook", .action = .{ .choice = .dir_look }, .doc = "A directory looked at: pane, a pane listing it as acme's directory window, or terminal, ls in a terminal there." }, .{ .word = "Pager", .action = .{ .choice = .pager }, .doc = "What a terminal's commands page through: pardes, a +Pager pane (pardes -), or off, the environment's own; for terminals started after it." }, .{ .word = "PagerColor", .action = .{ .toggle = .pager_color }, .doc = "A +Pager keeps its program's colours, on or off; for the next text paged." }, .{ .word = "TermImages", .action = .{ .choice = .term_images }, .doc = "How a new terminal draws its program's kitty graphics: real, as pixels where it can, or petscii, as glyph art." }, @@ -1745,6 +1753,7 @@ pub const Runtime = struct { .placement => "pardes", .boot_shell => "replace", .look_word => "list", + .dir_look => "terminal", .pager => "off", .term_images => "petscii", }, @@ -1817,6 +1826,7 @@ pub const Runtime = struct { .placement => "Placement pardes\n", .boot_shell => "BootShell replace\n", .look_word => "LookWord list\n", + .dir_look => "DirLook terminal\n", .pager => "Pager off\n", .term_images => "TermImages petscii\n", }, @@ -2188,7 +2198,7 @@ const doc_typ = struct { if (w.end > 0) w.writeByte(' ') catch return false; w.writeAll(x[0 .. std.mem.indexOfScalar(u8, x, '+') orelse x.len]) catch return false; } - const tags = [_][]const u8{ topbar_str, @import("tagline.zig").column_tag_default, pane_builtins_str, pdf_pane_builtins_str, file_pane_builtins_str, terminal_pane_builtins_str, command_pane_builtins_str }; + const tags = [_][]const u8{ topbar_str, @import("tagline.zig").column_tag_default, pane_builtins_str, pdf_pane_builtins_str, file_pane_builtins_str, terminal_pane_builtins_str, command_pane_builtins_str, dir_pane_builtins_str }; for (tags) |d| if (std.mem.eql(u8, d, w.buffered())) return true; return false; } diff --git a/src/exec.zig b/src/exec.zig index 8ddb9abd..92fb6244 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -968,6 +968,12 @@ pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { var renamed: [1024 + 16]u8 = undefined; const cmd = for (acme_words) |w| { if (!std.mem.eql(u8, raw[0..word_end], w.acme)) continue; + // Get in a directory pane, its tag or its text: the directory read + // again (Dir.zig), as acme's Get is in a directory window. + if (std.mem.eql(u8, w.acme, "Get")) if (p.panes[id]) |pane| if (pane.file) |f| if (f.listing != null) { + panes.Dir.show(p, id, id); + return true; + }; const now = w.pardes orelse { var said: [128]u8 = undefined; p.reportFailure(id, std.fmt.bufPrint(&said, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not"); diff --git a/src/host_io.zig b/src/host_io.zig index 8ff55d74..ee503061 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -174,7 +174,7 @@ pub const Lsp = struct { errdefer gpa.free(source); const arg = try gpa.dupe(u8, req.arg); errdefer gpa.free(arg); - const declared_root = if (file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice(); + const declared_root = if (file) |f| pardes.panes.Dir.ofName(f.path) else pane.cwdSlice(); const root = try gpa.dupe(u8, filesystem.localPath(declared_root) orelse declared_root); errdefer gpa.free(root); job.* = .{ diff --git a/src/look.zig b/src/look.zig index a358b31a..0df1f307 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1787,6 +1787,8 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { if (t.at.line > pv.page_count) return missedPage(p, id, pv.path, t.at.line); if (!pv.hitExists(p.pdf_gpa, @max(1, t.at.line), t.at.col)) return missedHit(p, id, pv.path, @max(1, t.at.line), t.at.col); }; + // A directory's pane, looked at again: read again. + if (target.file) |f| if (f.listing != null) return panes.Dir.show(p, id, t.id); const before = p.active; clearNavigationSelection(p, target); focusPaneLine(p, t.id, t.at, .center); @@ -1800,6 +1802,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { var said: [4200]u8 = undefined; return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory"); }; + // acme's: a pane listing it (Dir.zig). `DirLook terminal`, what + // pardes did before: `ls` in a terminal there. + if (p.settings.dir_look == .pane) return panes.Dir.look(p, id, dir); for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and exec.takesCommandLine(p, i)) { clearNavigationSelection(p, tt); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 75e4b823..157bc8b9 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -385,10 +385,11 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { pub const pane_text_cap = 9 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48 + "collapsed fit:height tint:filtered ".len; /// acme's winctlprint (plan9port wind.c), field for field: serial, tag -/// length, body length, isdir (0), the dirty flag, the width in cells, the -/// font, the tab width, whether Undo has a step, whether Redo has one. Then -/// pardes's own: rio's word for whether the window has the keyboard, -/// `current` or `notcurrent` (rio(4), wctl), and a REPL's id. +/// length, body length, isdir (1 for a directory pane), the dirty flag, +/// the width in cells, the font, the tab width, whether Undo has a step, +/// whether Redo has one. Then pardes's own: rio's word for whether the +/// window has the keyboard, `current` or `notcurrent` (rio(4), wctl), and +/// a REPL's id. pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { var w = std.Io.Writer.fixed(buf); w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ @@ -396,7 +397,8 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { pane_files.tagOf(p, pane).len, // A terminal's body is its history, read whole on an open's first read. if (pane.isTerminal()) panes.terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len, - @as(u32, 0), + // A directory pane is one, as acme's isdir says. + @intFromBool(if (pane.file) |f| f.listing != null else false), @intFromBool(pane_files.dirtyOf(pane)), pane.cols, }) catch return w.buffered(); @@ -1148,6 +1150,14 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { const f = pane_files.fileOf(pane) orelse return 0; + // A directory pane: the directory read again (Dir.zig). + if (f.listing != null) { + panes.Dir.refresh(p, p.paneBySerial(pane.serial).?) catch |err| { + failed.* = err; + return if (err == error.FileNotFound) E.NOENT else E.IO; + }; + return 0; + } if (!panes.Output.fileTraits(f.output).saves) return 0; const bytes = fs.read(p, f.path) catch |err| { failed.* = err; @@ -2661,6 +2671,7 @@ test "a terminal told the directory it already goes by logs no rename" { test "a terminal with text typed at its prompt is never typed into: a look at its directory opens another" { const p = try th.withTerm(testing.allocator); defer p.deinit(); + p.settings.dir_look = .terminal; // `ls` in a terminal, not a directory pane const term = p.paneBySerial(serialOf(p)).?; p.setCwd(term, "/tmp"); p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07TYPED" } }); diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 92ce8d8a..981c2b0e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -1014,7 +1014,7 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { if (text.len == 0) return 0; const pane = p.panes[id] orelse return null; const cwd = pane.cwdSlice(); - const dir = if (pane.file) |*f| std.fs.path.dirname(f.path) orelse "/" else if (cwd.len > 0) cwd else "/"; + const dir = if (pane.file) |*f| panes.Dir.ofName(f.path) else if (cwd.len > 0) cwd else "/"; for (p.panes) |slot| { const q = slot orelse continue; const qf = fileOf(q) orelse continue; diff --git a/src/panes.zig b/src/panes.zig index 9d1b7571..89362996 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -640,6 +640,8 @@ pub const File = @import("File.zig"); pub const Output = @import("Output.zig"); +pub const Dir = @import("Dir.zig"); + pub const mini = @import("mini.zig"); pub const Image = @import("image.zig"); @@ -653,6 +655,7 @@ test { _ = Text; _ = File; _ = Output; + _ = Dir; _ = mini; _ = Image; _ = Pdf; diff --git a/src/pardes.zig b/src/pardes.zig index 1cc1c5b8..164bc253 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1986,6 +1986,7 @@ test "a Look on a directory does not type ls into an occupied terminal" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); + p.settings.dir_look = .terminal; // `ls` in a terminal, not a directory pane while (p.nextEffect()) |_| {} var realbuf: [4096]u8 = undefined; @@ -6433,7 +6434,7 @@ pub const Pardes = struct { .owned => |cwd| return cwd, .none => {}, } - if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; + if (pane.file) |f| return panes.Dir.ofName(f.path); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fs.path.dirname(pv.path) orelse "/"; if (pane.image) |iv| return std.fs.path.dirname(iv.path) orelse "/"; @@ -6807,7 +6808,7 @@ pub const Pardes = struct { return; } - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const dir = if (pane.file) |f| panes.Dir.ofName(f.path) else pane.cwdSlice(); const content = p.gpa.dupe(u8, rows) catch return; const serial_before = p.next_serial; p.fs.refilled = null; @@ -7260,6 +7261,9 @@ pub const Pardes = struct { pane.rows = rows; if (pane.file) |*file| file.syntax_dirty = true; } + // A directory pane's entries follow its width, as acme's + // columns do (Dir.fit). + if (pane.file) |file| if (file.listing != null and r.h > pane.tag_rows) panes.Dir.fit(p, pane, pane.cols); panes.terminal.releasePendingCommandIfReady(p, id, pane); // The greeting `ls` of a new terminal in a directory is typed at // its shell's FIRST prompt, or never: typed later it would land diff --git a/src/tagline.zig b/src/tagline.zig index a1ce437d..d13f3c5f 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -23,6 +23,7 @@ pub const context_pane_tail = file_pane_tail ++ " TreeContext"; pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; pub const command_pane_tail = " " ++ config.command_pane_builtins_str; +pub const dir_pane_tail = " " ++ config.dir_pane_builtins_str; /// A file with changes it has not saved. pub fn dirty(pane: *const panes.Pane) bool { const f = pane.file orelse return false; @@ -162,6 +163,7 @@ pub fn curTail(pane: *Pane) []const u8 { pub fn defaultTail(pane: *const Pane) []const u8 { if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; + if (pane.file) |file| if (file.listing != null) return dir_pane_tail; if (pane.file) |file| if (file.output) |out| if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; if (panes.File.supportsContext(pane)) return context_pane_tail; |
