summaryrefslogtreecommitdiff
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
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]>
-rw-r--r--docs/typ/builtins-gui.json1
-rw-r--r--docs/typ/builtins-tty.json1
-rw-r--r--docs/typ/cheatsheet.typ2
-rw-r--r--docs/typ/guide.typ2
-rw-r--r--docs/typ/reference.typ7
-rw-r--r--src/CHANGELOG.md4
-rw-r--r--src/Dir.zig507
-rw-r--r--src/File.zig11
-rw-r--r--src/Output.zig15
-rw-r--r--src/builtins.zig10
-rw-r--r--src/config.zig14
-rw-r--r--src/exec.zig6
-rw-r--r--src/host_io.zig2
-rw-r--r--src/look.zig5
-rw-r--r--src/ninep/ctl.zig21
-rw-r--r--src/ninep/pane.zig2
-rw-r--r--src/panes.zig3
-rw-r--r--src/pardes.zig8
-rw-r--r--src/tagline.zig2
-rw-r--r--test/fs.py26
-rw-r--r--test/snapshots/cmdword.snap3
-rw-r--r--test/snapshots/dirpane.golden13
-rw-r--r--test/snapshots/dirpane.snap27
-rw-r--r--test/snapshots/look-dir.snap3
-rw-r--r--test/snapshots/lookpanes.snap3
-rw-r--r--test/snapshots/nested.golden8
-rw-r--r--test/snapshots/ttylook.snap3
27 files changed, 684 insertions, 25 deletions
diff --git a/docs/typ/builtins-gui.json b/docs/typ/builtins-gui.json
index ec214069..e4a8f611 100644
--- a/docs/typ/builtins-gui.json
+++ b/docs/typ/builtins-gui.json
@@ -26,6 +26,7 @@
{"name": "Delcol", "leader": "SPC c d", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "Close this column and its panes, refusing once over unsaved text."},
{"name": "Detach", "leader": "SPC s D", "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Detach this pardes from its terminal, leaving the session running."},
{"name": "Diagnostics", "leader": "SPC l d", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the file's diagnostics from the language server."},
+ {"name": "DirLook", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "pane, terminal", "doc": "A directory looked at: pane, a pane listing it as acme's directory window, or terminal, ls in a terminal there."},
{"name": "Down", "leader": "SPC w j", "also": ["C-w j", "C-w down"], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "Move the keyboard to the pane below, off a tag onto the pane under it."},
{"name": "Dump", "leader": "SPC s d", "also": ["topbar"], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Write the session's panes and layout to a dump file, for Restore."},
{"name": "DumpConfig", "leader": null, "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Open the settings in effect, each a word to click, and the startup file's path."},
diff --git a/docs/typ/builtins-tty.json b/docs/typ/builtins-tty.json
index 6dd09882..959f88ce 100644
--- a/docs/typ/builtins-tty.json
+++ b/docs/typ/builtins-tty.json
@@ -23,6 +23,7 @@
{"name": "Delcol", "leader": "SPC c d", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "Close this column and its panes, refusing once over unsaved text."},
{"name": "Detach", "leader": "SPC s D", "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Detach this pardes from its terminal, leaving the session running."},
{"name": "Diagnostics", "leader": "SPC l d", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the file's diagnostics from the language server."},
+ {"name": "DirLook", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "pane, terminal", "doc": "A directory looked at: pane, a pane listing it as acme's directory window, or terminal, ls in a terminal there."},
{"name": "Down", "leader": "SPC w j", "also": ["C-w j", "C-w down"], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "Move the keyboard to the pane below, off a tag onto the pane under it."},
{"name": "Dump", "leader": "SPC s d", "also": ["topbar"], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Write the session's panes and layout to a dump file, for Restore."},
{"name": "DumpConfig", "leader": null, "also": [], "arg": false, "scope": "root", "setting": false, "choices": null, "doc": "Open the settings in effect, each a word to click, and the startup file's path."},
diff --git a/docs/typ/cheatsheet.typ b/docs/typ/cheatsheet.typ
index d8ea87ac..e95f5076 100644
--- a/docs/typ/cheatsheet.typ
+++ b/docs/typ/cheatsheet.typ
@@ -41,7 +41,7 @@ Typed anywhere, then #btn("B3") or #key("Enter"):
addr(":40"), [the pane looked from, line 40 (`:40:5`, any address after `:`)],
addr("~/x"), [home; also in #word("Save"), #word("Restore"), `pardes ~/x`],
addr("@p3:12"), [pane 3 by serial, a terminal's lines too],
- addr("src/"), [a directory: `ls` in an idle terminal there, else a new one],
+ addr("src/"), [a directory: a pane listing it; `Get` reads it again],
)
Addresses are sam's: `#n`, `/re/`, `?re?`, `$`, `a,b`. #doc("fs", section: "look-and-exec")
diff --git a/docs/typ/guide.typ b/docs/typ/guide.typ
index 077c62f1..5bfff0ce 100644
--- a/docs/typ/guide.typ
+++ b/docs/typ/guide.typ
@@ -157,7 +157,7 @@ pardes.
addr("notes.txt"), [opens it, or goes to the pane already showing it],
addr("notes.txt:12"), [line 12],
addr("notes.txt:/TODO/"), [the next match of `TODO`],
- addr("src/"), [`ls` typed into a terminal idle there, else a new terminal there],
+ addr("src/"), [a pane listing it, as acme's directory window: a #word("Look") at an entry opens it, `..` goes up, `Get` reads it again (#word("DirLook") `terminal`: `ls` in a terminal there)],
addr("https://ziglang.org"), [the browser, through `xdg-open` (`open` on macOS); there is no plumber],
)
diff --git a/docs/typ/reference.typ b/docs/typ/reference.typ
index 29f535ee..a7ac997e 100644
--- a/docs/typ/reference.typ
+++ b/docs/typ/reference.typ
@@ -403,6 +403,7 @@ word clicked in a tag does. The same lines go in the startup file
[#word("Placement") `acme|pardes`], [`acme`: where new panes go (#doc("fs", section: "placement"))],
[#word("BootShell") `keep|replace`], [`keep`; `replace` closes the untouched lone shell a dragged document lands beside],
[#word("LookWord") `search|list`], [`search`: a word given to #word("Look") selects its next place, or lists all in `+Search`],
+ [#word("DirLook") `pane|terminal`], [`pane`: #word("Look") on a directory opens a pane listing it, as acme's directory window (#doc("reference", section: "from-acme")); `terminal` types `ls` into a terminal idle there, else opens one],
[#word("TermImages") `real|petscii`], [`real`: a new terminal draws its program's kitty graphics (yazi's previews) as pixels where the shell can; #word("Petscii") flips one terminal; a tty under a terminal without kitty graphics draws them as glyph art either way],
[#word("Shell") `<name or path>`], [`$SHELL` when it is executable, else `/bin/sh`: the shell the next terminal and command pane run; a bare name is searched for in the usual bin directories, not `$PATH`; bare #word("Shell"): `$SHELL` if it is executable, else `/bin/sh`],
[#word("DumpDir") `<dir>`], [`$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`: where #word("Dump") writes, an absolute or `~` path to a directory that may be written (made if missing); a relative one, or one under a directory that may not be written, is refused; bare returns to the default],
@@ -564,7 +565,7 @@ makes its column the active one (a folded pane stays folded); `no such pane`, or
while a column or workspace tag has the keyboard.
*Pane ctl.* Reading it gives acme's status line: serial, tag length, body
-length, isdir (0), dirty, width in cells, font, tab width, undo available,
+length, isdir (1 for a directory pane, else 0), dirty, width in cells, font, tab width, undo available,
redo available, then `current`/`notcurrent`, a REPL's id if bound,
`collapsed` when it is, and for a PDF `fit:width|height
tint:disabled|filtered|full`. It
@@ -1026,13 +1027,13 @@ with Client(sys.argv[1]) as c: # a socket path, or (ip, port)
[`addr` reset on first open], [#file("addr") is the pane's; no open resets it (write `0`)],
[OTRUNC on `data` ignored], [`: > data` deletes the addressed text; OTRUNC on #file("body") (`9p write`) replaces it all],
[event flags 1, 2, 4, 8], [1, 4 and 8; never 2, so no expansion record follows],
- [`Get`], [`get` on the pane's #file("ctl")],
+ [`Get`], [`get` on the pane's #file("ctl"); `Get` itself only in a directory pane],
[`Put`, `Delete`], [#word("Save"); a #word("Del") that does not ask],
[`Snarf`, `Cut`, `Paste`], [refused: the chords, #key("y"), #key("p")],
[acme's other builtins], [refused: `Putall`, `Zerox`, `Sort`, `Load`, `ID`, `Send`, `Tab`, `Indent`, `Local`, `Incl`, `Abort`],
[Edit], [no `< | >`, `X`, `Y`, `b B D e r w f`, no `\1`-`\9`; an alternation takes the first branch that matches, not the longest (#doc("fs", section: "edit"))],
[`win`], [#word("Tty") is a VT terminal pane; #file("pty/run") runs a line at its prompt and answers `exit N` and the output; with #word("Repl") bound, #word("Exec") on a source pane types the text into it],
- [a directory window], [#word("Look") on a directory types `ls` into a terminal idle there, else opens one],
+ [a directory window], [as acme's: a text pane named `dir/` (#file("index") kind `text`, #file("ctl") isdir 1), its entries in columns sorted bytewise, dotfiles shown, a directory's marked `/`; blanks pad the columns where acme's tabs do; a #word("Look") at an entry is from that directory, `Get` or a look at it again reads it again, and an edit is kept until then rather than lost to a resize; never dirty, #word("Save") refuses it; #word("DirLook") `terminal` types `ls` into a terminal there instead],
[the plumber], [none: #word("Look") on an `http://` or `https://` URL runs `xdg-open` (`open` on macOS); files, addresses and directories follow built-in rules],
[#key("Esc") selects what was typed], [#key("Esc") leaves insert mode, or goes back a pane (#doc("tags", section: "modes"))],
)
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;
diff --git a/test/fs.py b/test/fs.py
index 204f4b37..65b354d9 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -605,6 +605,28 @@ def unattended_messages_expire(binary):
assert not shown(), 'an unattended message outlived MessageLinger'
+def directory_panes(binary):
+ """A look at a directory opens a pane listing it, as acme's directory
+ window: a text pane named with a trailing /, its body the entries (a
+ directory's marked /), ctl's isdir 1, and get reads it again."""
+ with tempfile.TemporaryDirectory(prefix='pardes-dirpane-') as directory:
+ root = Path(directory).resolve()
+ listed = root / 'listed'
+ (listed / 'sub').mkdir(parents=True)
+ (listed / '.dot').write_bytes(b'')
+ with session(binary, root, 'dirpane') as (client, _):
+ serial = look(client, str(listed))
+ rows = [row.split() for row in client.read('/index').decode().splitlines()]
+ row = next(r for r in rows if int(r[0]) == serial)
+ assert row[1:4] == ['text', '0', f'{listed}/'], row
+ body = client.read(f'/pane/{serial}/body').decode()
+ assert body.split() == ['.dot', 'sub/'], body
+ assert client.read(f'/pane/{serial}/ctl').split()[3] == b'1'
+ (listed / 'later').write_bytes(b'')
+ client.write(f'/pane/{serial}/ctl', b'get\n')
+ assert client.read(f'/pane/{serial}/body').decode().split() == ['.dot', 'later', 'sub/']
+
+
def new_terminals_named_once(binary):
"""A terminal started outside the session's directory is named by that
directory from its `new` on: no rename to the session's and back."""
@@ -612,6 +634,9 @@ def new_terminals_named_once(binary):
root = Path(directory)
(root / 'sub').mkdir()
with session(binary, root, 'names') as (client, _):
+ # A look at a directory starts a terminal there (DirLook
+ # terminal); DirLook pane, the default, lists it in a pane.
+ client.write('/ctl', b'DirLook terminal\n')
made = [look(client, str(root / 'sub'))]
before = {int(row.split()[0]) for row in client.read('/index').splitlines()}
client.write('/pane/1/ctl', b'Tty\n')
@@ -992,6 +1017,7 @@ def test(binary, quic=False):
exit_answers(binary)
unattended_messages_expire(binary)
new_terminals_named_once(binary)
+ directory_panes(binary)
workflow_canary(binary)
recent_survives_restart(binary)
python_repl_runs_one_line(binary)
diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap
index 631fab8b..42a91e18 100644
--- a/test/snapshots/cmdword.snap
+++ b/test/snapshots/cmdword.snap
@@ -23,6 +23,9 @@ file zzmark.txt x
# the top of the body -- which would move every row this script clicks. The
# verbs are what is under test here, not the announcement.
config Verbose off
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 31 120 cmds.txt
wait 8000 Newcol Joincol
wait 8000 cmds.txt
diff --git a/test/snapshots/dirpane.golden b/test/snapshots/dirpane.golden
new file mode 100644
index 00000000..3118079f
--- /dev/null
+++ b/test/snapshots/dirpane.golden
@@ -0,0 +1,13 @@
+== snap dir-open grid=80x24 cursor=7,6
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/dirpane/cwd/top.txt Save Tty Collapse Del
+| 1 sub
+|
+| /tmp/pardes-snap/dirpane/cwd/sub/ Get Tty Collapse Del
+| 1 .hidden beta-gamma.txt inner.txt
+| 2 alpha.txt deeper/ zeta
+| 3
+== snap entry-open grid=80x24 cursor=7,9
+|8: /tmp/pardes-snap/dirpane/cwd/sub/inner.txt Save Tty Collapse Del
+|9: 1 inner-marker
diff --git a/test/snapshots/dirpane.snap b/test/snapshots/dirpane.snap
new file mode 100644
index 00000000..a884b12e
--- /dev/null
+++ b/test/snapshots/dirpane.snap
@@ -0,0 +1,27 @@
+# a look at a directory opens a pane named for it with a trailing /, its
+# entries in columns as acme's directory window lays them out: down each
+# column first, a directory's marked /, dotfiles shown. A look at an entry
+# opens it from that directory. No shell anywhere: nothing here races a prompt.
+dirmk sub
+dirmk sub/deeper
+file sub/inner.txt inner-marker
+file sub/.hidden x
+file sub/alpha.txt x
+file sub/beta-gamma.txt x
+file sub/zeta x
+file top.txt sub
+config Verbose off
+start 24 80 top.txt
+wait 8000 top.txt
+stable 700 20000
+press right @sub 4
+release right @sub 4
+wait 8000 inner.txt
+stable 700 20000
+snap dir-open
+# an entry, by its name alone: sub's inner.txt
+press right @inner.txt 7
+release right @inner.txt 7
+wait 8000 inner-marker
+stable 700 20000
+snap entry-open
diff --git a/test/snapshots/look-dir.snap b/test/snapshots/look-dir.snap
index caf29da8..2032d0c0 100644
--- a/test/snapshots/look-dir.snap
+++ b/test/snapshots/look-dir.snap
@@ -2,6 +2,9 @@
dirmk sub
file sub/inner.txt inner-marker
file top.txt x
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 31 100 -n 3
wait 8000 top.txt
stable 700 20000
diff --git a/test/snapshots/lookpanes.snap b/test/snapshots/lookpanes.snap
index e1db907a..2834a4a9 100644
--- a/test/snapshots/lookpanes.snap
+++ b/test/snapshots/lookpanes.snap
@@ -24,6 +24,9 @@ file beta/bmark.txt both.txt\nb-marker
# the top of the body -- which moves every row this script clicks. What the
# verbs do is what is under test, not the announcement.
config Verbose off
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 51 130 beta/bmark.txt
wait 8000 b-marker
stable 700 20000
diff --git a/test/snapshots/nested.golden b/test/snapshots/nested.golden
index 07d6148d..44ab4b82 100644
--- a/test/snapshots/nested.golden
+++ b/test/snapshots/nested.golden
@@ -46,15 +46,15 @@
|
| /tmp/pardes-snap/nested/cwd/+New Save Tty Collapse Del
| 1
-== snap dir-arg grid=100x31 cursor=4,8
+== snap dir-arg grid=100x31 cursor=7,6
|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
| New Tty Find Grep Joincol Delcol
|$ /tmp/pardes-snap/nested/cwd Tty+bash Save Mode Filter Collapse Del
| $ $(readlink /proc/$PPID/exe) nestdir
| $
-| /tmp/pardes-snap/nested/cwd/nestdir Tty+bash Save Mode Filter Collapse Del
-| ls
-| inside-nested.txt
+| /tmp/pardes-snap/nested/cwd/nestdir/ Get Tty Collapse Del
+| 1 inside-nested.txt
+| 2
|
|
|
diff --git a/test/snapshots/ttylook.snap b/test/snapshots/ttylook.snap
index 5b82bd89..91401a64 100644
--- a/test/snapshots/ttylook.snap
+++ b/test/snapshots/ttylook.snap
@@ -9,6 +9,9 @@
lines big.txt 40
file plain.txt just text
dirmk sub
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 31 120 -n 3
wait 8000 big.txt
stable 700 20000