summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig104
-rw-r--r--src/CHANGELOG.md61
-rw-r--r--src/Dir.zig503
-rw-r--r--src/File.zig419
-rw-r--r--src/Messages.zig3
-rw-r--r--src/Output.zig15
-rw-r--r--src/Pipe.zig59
-rw-r--r--src/builtins.zig194
-rw-r--r--src/builtins_json.zig97
-rw-r--r--src/config.zig122
-rw-r--r--src/draw.zig39
-rw-r--r--src/dump.zig41
-rw-r--r--src/edit.zig48
-rw-r--r--src/edit_cmd.zig797
-rw-r--r--src/esp32p4.zig5
-rw-r--r--src/exec.zig128
-rw-r--r--src/file_watch.zig40
-rw-r--r--src/fs.zig69
-rw-r--r--src/host_io.zig16
-rw-r--r--src/image.zig6
-rw-r--r--src/look.zig276
-rw-r--r--src/main.zig17
-rw-r--r--src/mini.zig4
-rw-r--r--src/modal.zig8
-rw-r--r--src/mouse.zig22
-rw-r--r--src/ninep/ctl.zig518
-rw-r--r--src/ninep/events.zig10
-rw-r--r--src/ninep/pane.zig7
-rw-r--r--src/ninep/tree.zig12
-rw-r--r--src/panes.zig3
-rw-r--r--src/pardes.zig394
-rw-r--r--src/sam_edit.zig791
-rw-r--r--src/selection_pipe.zig291
-rw-r--r--src/surface.zig4
-rw-r--r--src/tagline.zig20
-rw-r--r--src/terminal.zig9
-rw-r--r--src/tty/tty.zig34
-rw-r--r--src/tutor.txt69
38 files changed, 4871 insertions, 384 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 0b3db274..18c51ece 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -180,6 +180,10 @@ pub const Listener = struct {
restore_writer: std.atomic.Value(bool) = .init(false),
/// Clients turned away with every slot taken, since the log last said so.
refused: std.atomic.Value(u32) = .init(0),
+ /// Writes being served right now, a retry of a parked one included: a
+ /// held Edit write a retry has taken out of its park is not flushed
+ /// (watchEdit).
+ writes_out: std.atomic.Value(u32) = .init(0),
/// Connections accepted so far, and the count each slot's connection
/// was accepted at: a Restore cuts those accepted before it (`reset`),
/// and a new client in a freed slot has a later stamp. Written on the
@@ -241,6 +245,10 @@ pub const Listener = struct {
fn onServe(ctx: ?*anyopaque, conn: *Runner.Conn, req: pardes.ctlfs.Req) void {
const l = of(ctx);
+ if (req.op == .write) _ = l.writes_out.fetchAdd(1, .acq_rel);
+ defer if (req.op == .write) {
+ _ = l.writes_out.fetchSub(1, .acq_rel);
+ };
// Once `stop` has begun the editor is tearing down and may never
// rest again: answer without waiting on it.
if (l.stopping.load(.acquire)) {
@@ -268,7 +276,17 @@ pub const Listener = struct {
const changes = pardes.ctlfs.changesPane(core, req);
const fills = pardes.ctlfs.handsOff(core, req);
const reply = core.serveFs(req);
+ const edit_started = core.fs.edit_started;
+ core.fs.edit_started = false;
+ // The held Edit write asked again by a retry, its Edit still
+ // running: parked again, as it was.
+ if (reply.status == .again and req.op == .write) return conn.reply(&reply, "");
if (req.op == .release and quiet) l.collectOs();
+ // A close's held last line runs now when no step is out, so its err
+ // is logged before the close is answered, as before; with a step
+ // out (perhaps through a mount this close came by) it waits for
+ // that step, and the close is answered at once all the same.
+ if (req.op == .release and quiet) pardes.ctlfs.runClosedLines(core);
// A read with nothing yet stays parked in the engine, and the core
// keeps its ticket to answer it when what it waits on has something.
if (reply.status == .again and req.op == .read) {
@@ -308,14 +326,38 @@ pub const Listener = struct {
// mount: 9ns answers nothing else on it while a clunk is out, and
// the editor's step may be out opening a file through that mount.
if (fills) return conn.reply(&reply, core.fsPayload(reply));
- // A refusal answers at once: its text may be in the core's one
- // buffer for it, which a request run while this one waited would
- // write over.
+ // A refusal that left saves going (Putall's one refused among the
+ // rest) is answered once they have landed, its words copied out of
+ // the core's one buffer for them, which a request run meanwhile
+ // could write over.
+ if (reply.status == .err and core.effects_len != 0 and !core.quit) {
+ var kept: [320]u8 = undefined;
+ const words = kept[0..@min(reply.ename.len, kept.len)];
+ @memcpy(words, reply.ename[0..words.len]);
+ var waited = reply;
+ waited.ename = words;
+ pardes.turn.awaitSettled(epoch);
+ return conn.reply(&waited, "");
+ }
+ // Any other refusal answers at once, for the same buffer's sake.
if (reply.status == .err) return conn.reply(&reply, "");
// A write that quits the editor (Kill) is answered now: the editor
// is on its way out and will settle nothing this could wait for,
// and `deinit` lets the answer out before it cuts the connections.
if (core.quit) return conn.reply(&reply, "");
+ // An Edit whose `<`, `|` or `>` commands run off the loop: the write
+ // is held, as a read with nothing yet is, and answered when the Edit
+ // is done (answerHeld), so the connection serves on meanwhile -- a
+ // filter that reads this session through a mount among what it
+ // serves. A flush of it, or a hang-up, stops the Edit (watchEdit).
+ if (edit_started and core.pipe.edit_run != null) {
+ const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again };
+ const ticket = conn.hold(&later) orelse return;
+ core.fs.edit_hold = .{ .asker = conn, .slot = ticket.slot, .seq = ticket.seq, .tag = req.tag, .node = req.node, .handle = req.handle, .written = reply.written };
+ const watcher = std.Thread.spawn(.{}, watchEdit, .{ l, conn, ticket }) catch return;
+ watcher.detach();
+ return;
+ }
const restoring = core.restore_req != null;
// Set with the turn still held, so `reset` sees it and waits.
if (restoring) l.restore_writer.store(true, .release);
@@ -395,6 +437,46 @@ pub const Listener = struct {
const conn: *Runner.Conn = @ptrCast(@alignCast(held.asker));
if (!conn.answerWith(held.ticket, Held{ .core = core, .req = held.req }, Held.make)) o.held = null;
}
+ // An Edit's held write, its Edit done. Not waiting: a retry has it
+ // out, and its asking answers it (Pardes.serveFs); or it was
+ // flushed, and watchEdit lets the record go.
+ if (core.fs.edit_hold) |h| if (h.done) {
+ const conn: *Runner.Conn = @ptrCast(@alignCast(h.asker));
+ _ = conn.answerWith(.{ .slot = h.slot, .seq = h.seq }, core, struct {
+ fn make(c: *pardes.Pardes) ?Runner.Conn.Answer {
+ return .{ .reply = pardes.filesystem.EditHold.answer(&c.fs) };
+ }
+ }.make);
+ };
+ }
+
+ /// While an Edit's write is held: when nobody waits for it any more --
+ /// flushed (an interrupted writer), its fid clunked, its connection
+ /// hung up -- the Edit is stopped, its commands killed, nothing
+ /// changed (edit_cmd.cancel). The engine tells the backend nothing of a
+ /// flush, so it is looked for, every 100 ms; a park out for a retry is
+ /// not waiting either, so only three looks in a row with no write being
+ /// served count.
+ fn watchEdit(l: *Listener, conn: *Runner.Conn, ticket: cloud9.fs.Ticket) void {
+ const io = pardes.turn.io orelse return;
+ var misses: u8 = 0;
+ while (!l.stopping.load(.acquire)) {
+ io.sleep(.fromMilliseconds(100), .awake) catch return;
+ _ = pardes.turn.take();
+ defer pardes.turn.give();
+ const core = l.core;
+ const h = core.fs.edit_hold orelse return;
+ if (h.slot != ticket.slot or h.seq != ticket.seq or h.asker != @as(*anyopaque, conn)) return;
+ if (conn.waiting(ticket) or l.writes_out.load(.acquire) != 0) {
+ misses = 0;
+ continue;
+ }
+ misses += 1;
+ if (misses < 3) continue;
+ if (h.done) core.fs.edit_hold = null else @import("edit_cmd.zig").cancel(core);
+ l.kick();
+ return;
+ }
}
/// A held read asked again, to make its answer while its park waits.
@@ -1910,6 +1992,16 @@ pub fn start(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listene
/// open to scripts and to mounted shells — while withholding `PARDES_PID`,
/// which is the whole of what `--nested` means.
pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, adopts: bool) void {
+ exportEnv(if (listener) |l| l.path() else null, serial, adopts);
+}
+
+/// exportPaneEnv for a command run off the loop (an Edit's filter), with
+/// this process's own socket: what a pane's command would be given.
+pub fn exportSessionEnv(serial: u32, adopts: bool) void {
+ exportEnv(if (own_socket_len > 0) own_socket[0..own_socket_len] else null, serial, adopts);
+}
+
+fn exportEnv(socket: ?[]const u8, serial: u32, adopts: bool) void {
var announced = false;
if (adopts) announcing: {
var buf: [16]u8 = undefined;
@@ -1919,13 +2011,13 @@ pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, adopts: bool) void
}
if (!announced) _ = unsetenv("PARDES_PID");
- if (listener) |l| exporting: {
+ if (socket) |own| exporting: {
var sock: [sun_path_len]u8 = undefined;
- const path = std.fmt.bufPrintSentinel(&sock, "{s}", .{l.path()}, 0) catch break :exporting;
+ const path = std.fmt.bufPrintSentinel(&sock, "{s}", .{own}, 0) catch break :exporting;
var buf: [16]u8 = undefined;
const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch break :exporting;
if (setenv("PARDES_9P", path, 1) != 0) break :exporting;
- exportMount(l.path());
+ exportMount(own);
if (setenv("PARDES_PANE", id, 1) == 0) return;
}
_ = unsetenv("PARDES_9P");
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 941ff2b1..7d3811b4 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,66 @@
# Changelog
+## 0.24
+
+- Edit runs all of acme's commands: `< | >` through your Shell (one undo
+ step, a failure changes nothing, its stderr in +Errors), `X` and `Y` over
+ the open panes, `b B D e r w f` and the `"re"` file address. A pane a
+ running filter changes refuses Del, Undo, Get and Zerox (busy), and a
+ flushed or dropped Edit write kills its commands.
+- Edit's `w` to a file changed on disk is refused once, as Save is
+ (`w again to overwrite`).
+- 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.
+- One Look path: Enter, Tab and a bare Look take what a click at the
+ cursor takes, a diff line's address included, and an event reader hears
+ them as clicks.
+- Get reads a pane's file again, asking once over unsaved edits; `Get file`
+ loads another, and Undo puts the old name and text back. Load is Restore.
+- Putall saves every pane with unsaved edits. Zerox opens a second pane on
+ the same buffer, kept across Dump and Restore.
+- `Tab N` sets the tab width, 1 to 16. `Incl dir` adds directories a look
+ tries for a name found nowhere else, `<stdio.h>` too.
+- Enter, `o` and `O` keep the line's indent as it is, tabs included, as
+ acme's autoindent does.
+- Back and Forward (Ctrl-o, Ctrl-i, SPC j o, SPC j i, the mouse's side
+ buttons) keep to the focused file first; `JumpScope all` walks every
+ place in order. `n` in a command pane walks its own errors first.
+- Alt/Option-click is B2 and Super/Cmd-click is B3, sweeps included, for
+ one-button mice and trackpads; a terminal passes only Alt.
+- Shift-Esc always backs out to the previous pane, in every mode; Ctrl-b
+ alone toggles raw. SPC q quits, as Exit.
+- A +Pager keeps its program's colours; `PagerColor off` pages it plain.
+- Every pane gets `$PARDES_MOUNT`, the session's directory under
+ `9ns --mntgen`.
+- Editing a terminal's text in normal mode says it edits a copy, which Tab
+ or B2 runs and the program's next drawing replaces.
+- A path typed into a tag survives Esc: Enter renames, a second Esc drops
+ it.
+- The init file reads past a BOM, says each failing line with its number,
+ skips settings only the other frontend has, and applies when saved in a
+ pane.
+- `pardes --wait` with no FILE or a gone session, and `--detach` over a
+ name in use, say so in words.
+- File reads say `is a directory`, `permission denied` or `not a regular
+ file`; a device is never read to the stream limit.
+- A dump no longer embeds a picture or PDF that is on disk; Restore reads
+ it from there.
+- Fixed: a mouse report past the window's top or left edge crashed the
+ terminal frontend, losing unsaved text.
+- Fixed: an unsaved pane restored from an older dump could Save over a
+ newer file without asking.
+- Fixed: a PDF with a JPEG image crashed ReleaseSafe and Debug builds.
+- Fixed: the window drew every frame after focus moved to a PDF while the
+ cursor glided.
+- cloud9's 9ns writes in parallel, so an Edit filter can write its own ctl
+ through a mount without waiting, and drops a dead mount's entry off its
+ serving thread.
+- The docs are a website, pardes.0x4200.cafe (`zig build site`): recordings, a builtins
+ glossary, a theme gallery, and static builds with checksums. pardes is
+ ISC-licensed.
+
## 0.23
- Restore reads a clean file pane from disk, so a file changed since the
diff --git a/src/Dir.zig b/src/Dir.zig
new file mode 100644
index 00000000..36895bda
--- /dev/null
+++ b/src/Dir.zig
@@ -0,0 +1,503 @@
+//! Directory panes, acme's directory windows (plan9port acme text.c,
+//! textload and textcolumnate): a look at a directory opens a pane named
+//! for it with a trailing `/`, its body the directory's entries in columns
+//! that fit the pane, a directory's marked with a `/`. A look at an entry
+//! is a look from that directory; Get, or a look at the directory again,
+//! reads it again. Otherwise it is a text pane like +Errors (Output's
+//! `.dir`): searched, selected and edited as any, and never dirty.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const panes = @import("panes.zig");
+const config = @import("config.zig");
+const exec = @import("exec.zig");
+const filesystem = @import("fs.zig");
+const limits = @import("memory.zig").limits;
+const Pardes = pardes.Pardes;
+const Pane = panes.Pane;
+const File = panes.File;
+
+/// What a directory pane keeps of its listing, as acme's Window keeps
+/// dlp/ndl: the entries, for a change of width to lay them out again.
+pub const State = struct {
+ /// The entries, sorted, each ending in a newline; gpa-owned.
+ names: []u8,
+ /// The text width they were laid out at (none yet: the most there is),
+ /// and the text's revision then: a body edited since is left as it is.
+ width: usize = std.math.maxInt(usize),
+ revision: u32 = 0,
+};
+
+/// acme's tab in a directory window (text.c TABDIR), in cells: a column is
+/// a whole number of them, at least a cell wider than its widest entry.
+const tab = 3;
+
+/// The directory a pane named `name` works in, as acme's dirname reads a
+/// window's name: a directory pane's (ending in `/`) is its own, a file's
+/// the one it is in.
+pub fn ofName(name: []const u8) []const u8 {
+ if (std.mem.endsWith(u8, name, "/")) {
+ const dir = std.mem.trimEnd(u8, name, "/");
+ return if (dir.len == 0) "/" else dir;
+ }
+ return std.fs.path.dirname(name) orelse "/";
+}
+
+/// The entries of directory `dir` on this host as acme lists them
+/// (plan9port lib9 dirread, acme's dircmp): every name but `.` and `..`,
+/// dotfiles too; a directory's, a link to one's too, ending in `/`; sorted
+/// bytewise, the `/` counted; each ending in a newline.
+pub fn read(gpa: std.mem.Allocator, dir: []const u8) ![]u8 {
+ if (comptime !filesystem.platform_has_fs) return error.FileNotFound;
+ const local = filesystem.localPath(dir) orelse return error.NotLocal;
+ // Its own copy: the caller's may be a pane's, gone while the turn is out.
+ var own: [4096]u8 = undefined;
+ if (local.len > own.len) return error.NameTooLong;
+ @memcpy(own[0..local.len], local);
+ var entries: std.ArrayList([]u8) = .empty;
+ defer {
+ for (entries.items) |entry| gpa.free(entry);
+ entries.deinit(gpa);
+ }
+ var total: usize = 0;
+ {
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ const io = std.Io.Threaded.global_single_threaded.io();
+ var d = try std.Io.Dir.cwd().openDir(io, own[0..local.len], .{ .iterate = true });
+ defer d.close(io);
+ var it = d.iterate();
+ while (try it.next(io)) |entry| {
+ if (std.mem.eql(u8, entry.name, ".") or std.mem.eql(u8, entry.name, "..")) continue;
+ const is_dir = switch (entry.kind) {
+ .directory => true,
+ // Followed, as dirread's stat is: a link to a directory is one.
+ .sym_link, .unknown => if (d.statFile(io, entry.name, .{})) |st| st.kind == .directory else |_| false,
+ else => false,
+ };
+ total += entry.name.len + 2;
+ if (total > limits.max_file_bytes) return error.FileTooLarge;
+ const name = try std.mem.concat(gpa, u8, &.{ entry.name, if (is_dir) "/" else "" });
+ entries.append(gpa, name) catch |err| {
+ gpa.free(name);
+ return err;
+ };
+ }
+ }
+ std.mem.sort([]u8, entries.items, {}, struct {
+ fn lt(_: void, a: []u8, b: []u8) bool {
+ return std.mem.lessThan(u8, a, b);
+ }
+ }.lt);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ try out.ensureTotalCapacity(gpa, total);
+ for (entries.items) |entry| {
+ out.appendSliceAssumeCapacity(entry);
+ out.appendAssumeCapacity('\n');
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+/// `entries` in columns at most `width` cells wide, as acme's
+/// textcolumnate lays them out with its 3-cell tabs: down each column
+/// first, every column as wide as the widest entry and a cell more,
+/// rounded up to a tab, and each row ending in a newline. Blanks pad where
+/// acme's tabs do: a pardes tab is a fixed width, not a stop.
+pub fn columnate(gpa: std.mem.Allocator, entries: []const []const u8, width: usize) ![]u8 {
+ var colw: usize = 0;
+ for (entries) |entry| {
+ const w = File.displayWidth(entry) + 1;
+ colw = @max(colw, (w + tab - 1) / tab * tab);
+ }
+ const ncol = if (colw == 0) 1 else @max(1, width / colw);
+ const nrow = (entries.len + ncol - 1) / ncol;
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ for (0..nrow) |row| {
+ var j = row;
+ while (j < entries.len) : (j += nrow) {
+ try out.appendSlice(gpa, entries[j]);
+ if (j + nrow >= entries.len) break;
+ try out.appendNTimes(gpa, ' ', colw - File.displayWidth(entries[j]));
+ }
+ try out.append(gpa, '\n');
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+/// The cells a directory pane `cols` wide lays its entries out in: what
+/// is left of a row after the line numbers and the cell a wrap keeps
+/// (File.wrapWidth), the numbers as wide as `count` lines would need.
+fn textWidth(cols: usize, count: usize) usize {
+ var lines = count + 1;
+ var digits: usize = 1;
+ while (lines >= 10) : (lines /= 10) digits += 1;
+ return cols -| @max(config.PREFIX_W, digits + 1) -| 1;
+}
+
+/// Lays the pane's entries out again `cols` wide, when the width they need
+/// changed and the body is still theirs: a body edited since is kept, as
+/// typed, until a Get or a look at the directory reads it again.
+pub fn fit(p: *Pardes, pane: *Pane, cols: usize) void {
+ const f = if (pane.file) |*file| file else return;
+ const l = if (f.listing) |*listing| listing else return;
+ const count = std.mem.count(u8, l.names, "\n");
+ const width = textWidth(cols, count);
+ if (f.revision != l.revision or width == l.width) return;
+ // Not the scratch arena: this runs from sync, on every event.
+ const entries = p.gpa.alloc([]const u8, count) catch return;
+ defer p.gpa.free(entries);
+ var it = std.mem.splitScalar(u8, l.names, '\n');
+ for (entries) |*entry| entry.* = it.next().?;
+ const text = columnate(p.gpa, entries, width) catch return;
+ if (std.mem.eql(u8, text, f.content)) p.gpa.free(text) else {
+ File.setContent(p, f, text);
+ panes.Output.resetBody(p, pane);
+ }
+ l.width = width;
+ l.revision = f.revision;
+}
+
+/// Reads pane `id`'s directory again and lays it out anew, its edits gone,
+/// as acme's Get does in a directory window.
+pub fn refresh(p: *Pardes, id: usize) !void {
+ const pane = p.panes[id] orelse return error.NoPane;
+ const serial = pane.serial;
+ const names = try read(p.gpa, ofName(pane.file.?.path));
+ // The pane may have closed while the turn was out.
+ const now = p.panes[id] orelse return p.gpa.free(names);
+ if (now.serial != serial) return p.gpa.free(names);
+ const f = &now.file.?;
+ const l = &f.listing.?;
+ p.gpa.free(l.names);
+ l.names = names;
+ l.width = std.math.maxInt(usize);
+ l.revision = f.revision;
+ fit(p, now, now.cols);
+ panes.Output.resetBody(p, now);
+}
+
+/// A look at directory `dir` (look.zig's `.dir`, with `DirLook pane`): its
+/// pane, read again, or a new one, placed as a document is.
+pub fn look(p: *Pardes, from: usize, dir: []const u8) void {
+ const name = (if (std.mem.endsWith(u8, dir, "/"))
+ p.gpa.dupe(u8, dir)
+ else
+ std.mem.concat(p.gpa, u8, &.{ dir, "/" })) catch |err| return p.reportError(from, "look", err);
+ for (p.panes, 0..) |slot, i| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ if (f.listing == null or !std.mem.eql(u8, f.path, name)) continue;
+ p.gpa.free(name);
+ return show(p, from, i);
+ }
+ var said: [limits.host_path_cap + 16]u8 = undefined;
+ const what = std.fmt.bufPrint(&said, "look: {s}", .{name[0..@min(name.len, limits.host_path_cap)]}) catch "look";
+ const names = read(p.gpa, dir) catch |err| {
+ p.gpa.free(name);
+ return p.reportError(from, what, err);
+ };
+ const free = p.freeSlot() orelse {
+ p.gpa.free(name);
+ p.gpa.free(names);
+ return p.reportError(from, "look", error.NoPaneSlots);
+ };
+ const pane = open(p, free, name, names) catch |err| return p.reportError(from, "look", err);
+ const failures = p.fs.failures;
+ exec.placeNew(p, from, from, free, .doc);
+ // Refused a place: that is the look's one failure.
+ if (p.fs.failures != failures) return;
+ fit(p, pane, @max(1, p.rects[free].w -| config.GUTTER));
+}
+
+/// A pane on directory `name` (ending in `/`) with its entries `names`,
+/// both taken, in slot `id`, not yet placed and laid out.
+pub fn open(p: *Pardes, id: usize, name: []u8, names: []u8) !*Pane {
+ errdefer p.gpa.free(name);
+ errdefer p.gpa.free(names);
+ const content = try p.gpa.dupe(u8, "");
+ errdefer p.gpa.free(content);
+ const history = try File.History.create(p.gpa);
+ errdefer p.gpa.destroy(history);
+ const pane = try p.newDocPane(id);
+ pane.file = .{
+ .path = name,
+ .content = content,
+ .output = .{ .from = .dir },
+ .history = history,
+ .listing = .{ .names = names },
+ };
+ pane.body.cur_pinned = true;
+ return pane;
+}
+
+/// The directory pane `id` read again and given the keyboard, a look at it
+/// coming from pane `from`.
+pub fn show(p: *Pardes, from: usize, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const serial = pane.serial;
+ var said: [limits.host_path_cap + 16]u8 = undefined;
+ const name = pane.file.?.path;
+ const what = std.fmt.bufPrint(&said, "look: {s}", .{name[0..@min(name.len, limits.host_path_cap)]}) catch "look";
+ refresh(p, id) catch |err| return p.reportError(from, what, err);
+ // Closed while the turn was out: nothing to give the keyboard to.
+ const now = p.panes[id] orelse return;
+ if (now.serial == serial) p.active = id;
+}
+
+// ---- tests ----
+
+const testing = std.testing;
+
+fn laidOut(names: []const []const u8, width: usize) ![]u8 {
+ return columnate(testing.allocator, names, width);
+}
+
+test "entries go down each column first, a column a tab wider than its widest entry, as acme's" {
+ // The widest, 5 cells, makes 6: two tabs. 20 cells take three columns.
+ const names = [_][]const u8{ "a", "bb", "ccc/", "ddddd", "e", "f", "g" };
+ const text = try laidOut(&names, 20);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings(
+ "a ddddd g\n" ++
+ "bb e\n" ++
+ "ccc/ f\n",
+ text,
+ );
+}
+
+test "a width that is a whole number of tabs takes one more cell, so a column never touches the next" {
+ // "abc" is 3 cells, a tab exactly: its column is 6, not 3.
+ const names = [_][]const u8{ "abc", "de", "f" };
+ const text = try laidOut(&names, 12);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings("abc f\nde\n", text);
+}
+
+test "one column when a row has room for no more, and nothing at all for an empty directory" {
+ const names = [_][]const u8{ "alpha", "beta" };
+ const narrow = try laidOut(&names, 4);
+ defer testing.allocator.free(narrow);
+ try testing.expectEqualStrings("alpha\nbeta\n", narrow);
+ const none = try laidOut(&.{}, 80);
+ defer testing.allocator.free(none);
+ try testing.expectEqualStrings("", none);
+}
+
+test "a wide character counts its cells, not its bytes" {
+ // 世界 is 4 cells: its column is 6 cells, padded with two blanks.
+ const names = [_][]const u8{ "世界", "x" };
+ const text = try laidOut(&names, 12);
+ defer testing.allocator.free(text);
+ try testing.expectEqualStrings("世界 x\n", text);
+}
+
+test "a directory reads as acme lists it: dotfiles kept, . and .. not, directories and links to them marked, sorted bytewise" {
+ if (comptime !filesystem.platform_has_fs) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const io = testing.io;
+ try tmp.dir.writeFile(io, .{ .sub_path = "b.txt", .data = "" });
+ try tmp.dir.writeFile(io, .{ .sub_path = ".hidden", .data = "" });
+ try tmp.dir.writeFile(io, .{ .sub_path = "B", .data = "" });
+ try tmp.dir.createDir(io, "a", .default_dir);
+ try tmp.dir.createDir(io, "a.d", .default_dir);
+ try tmp.dir.symLink(io, "a", "link", .{ .is_directory = true });
+ var buf: [4096]u8 = undefined;
+ const dir = buf[0..try tmp.dir.realPath(io, &buf)];
+ const names = try read(testing.allocator, dir);
+ defer testing.allocator.free(names);
+ // Bytewise, as dircmp's memcmp: `.` before capitals before small
+ // letters, and `a.d/` before `a/` because `.` is before `/`.
+ try testing.expectEqualStrings(".hidden\nB\na.d/\na/\nb.txt\nlink/\n", names);
+}
+
+test "a directory pane's directory is its own name, a file's the one it is in" {
+ try testing.expectEqualStrings("/a/b", ofName("/a/b/"));
+ try testing.expectEqualStrings("/", ofName("/"));
+ try testing.expectEqualStrings("/a", ofName("/a/b"));
+ try testing.expectEqualStrings("/", ofName("/a"));
+}
+
+/// A session on a file in a fresh directory holding `sub/inner.txt`,
+/// `top.txt` and `.dot`: the directory's path in `buf`.
+fn session(tmp: *std.testing.TmpDir, buf: *[4096]u8) !struct { p: *Pardes, dir: []const u8 } {
+ const io = testing.io;
+ try tmp.dir.createDir(io, "sub", .default_dir);
+ try tmp.dir.writeFile(io, .{ .sub_path = "sub/inner.txt", .data = "inner\n" });
+ try tmp.dir.writeFile(io, .{ .sub_path = "top.txt", .data = "top\n" });
+ try tmp.dir.writeFile(io, .{ .sub_path = ".dot", .data = "" });
+ const dir = buf[0..try tmp.dir.realPath(io, buf)];
+ var path_buf: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/top.txt", .{dir});
+ const p = try Pardes.init(testing.allocator, .{ .file = path, .tty_only = true, .cols = 80, .rows = 30 });
+ while (p.nextEffect()) |_| {}
+ return .{ .p = p, .dir = dir };
+}
+
+/// The pane named `name`, and how many are.
+fn named(p: *Pardes, name: []const u8) struct { id: ?usize, count: usize } {
+ var id: ?usize = null;
+ var count: usize = 0;
+ for (p.panes, 0..) |slot, i| if (slot) |pane| if (pane.file) |f| if (std.mem.eql(u8, f.path, name)) {
+ id = i;
+ count += 1;
+ };
+ return .{ .id = id, .count = count };
+}
+
+test "a look at a directory opens a pane named for it with a /, its entries in columns, never dirty, its directory its own" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ var name_buf: [4200]u8 = undefined;
+ const name = try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir});
+ const id = named(p, name).id orelse return error.NoDirectoryPane;
+ try testing.expectEqual(id, p.active);
+ const pane = p.panes[id].?;
+ try testing.expectEqualStrings(".dot sub/ top.txt\n", pane.file.?.content);
+ try testing.expectEqualStrings(@import("tagline.zig").dir_pane_tail, @import("tagline.zig").curTail(pane));
+ try testing.expectEqualStrings(s.dir, Pardes.paneDir(pane));
+ // Edited, still never dirty, and no Save writes it.
+ try testing.expect(p.executeBuiltinLine(id, "Edit ,d"));
+ try testing.expectEqualStrings("", pane.file.?.content);
+ try testing.expect(!@import("tagline.zig").dirty(pane));
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(id, "Save"));
+ try testing.expect(p.executeBuiltinLine(id, "Save elsewhere.txt"));
+ try testing.expectEqual(failures +% 2, p.fs.failures);
+ try testing.expect(p.panes[id].?.prompt == .none);
+}
+
+test "a look at an entry is a look from the directory, .. goes up to the open pane, and Get or a look again reads it again" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ var name_buf: [4200]u8 = undefined;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ const top = named(p, try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir})).id.?;
+ // An entry that is a directory: a pane of its own.
+ pardes.look.lookAt(p, top, "sub/");
+ p.sync();
+ const sub = named(p, try std.fmt.bufPrint(&name_buf, "{s}/sub/", .{s.dir})).id orelse return error.NoSubPane;
+ try testing.expectEqualStrings("inner.txt\n", p.panes[sub].?.file.?.content);
+ // A file in it, by its name alone: that directory's file.
+ pardes.look.lookAt(p, sub, "inner.txt");
+ p.sync();
+ try testing.expect(named(p, try std.fmt.bufPrint(&name_buf, "{s}/sub/inner.txt", .{s.dir})).id != null);
+ // `..`: the open pane above, read again, not a second one.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "new.txt", .data = "" });
+ pardes.look.lookAt(p, sub, "..");
+ p.sync();
+ const again = named(p, try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir}));
+ try testing.expectEqual(@as(usize, 1), again.count);
+ try testing.expectEqual(top, p.active);
+ try testing.expect(std.mem.indexOf(u8, p.panes[top].?.file.?.content, "new.txt") != null);
+ // Get, in its tag or its text: read again.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "sub/later.txt", .data = "" });
+ try testing.expect(p.executeBuiltinLine(sub, "Get"));
+ try testing.expectEqualStrings("inner.txt later.txt\n", p.panes[sub].?.file.?.content);
+}
+
+test "an exec in a directory pane runs in its directory, and its +Errors goes there" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ var name_buf: [4200]u8 = undefined;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ const sub_name = try std.fmt.bufPrint(&name_buf, "{s}/sub/", .{s.dir});
+ pardes.look.lookAt(p, p.active, sub_name);
+ p.sync();
+ const sub = named(p, sub_name).id.?;
+ try testing.expectEqualStrings(sub_name[0 .. sub_name.len - 1], p.execDir(sub));
+ _ = @import("ninep/pane.zig").appendErrors(p, sub, "oops\n");
+ var errors_buf: [4200]u8 = undefined;
+ try testing.expect(named(p, try std.fmt.bufPrint(&errors_buf, "{s}{s}", .{ sub_name, config.errors_buffer })).id != null);
+}
+
+test "DirLook terminal: a look at a directory opens a terminal there, no pane listing it" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ try testing.expect(p.executeBuiltinLine(0, "DirLook terminal"));
+ try testing.expectEqual(config.Runtime.DirLook.terminal, p.settings.dir_look);
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ var name_buf: [4200]u8 = undefined;
+ try testing.expectEqual(@as(usize, 0), named(p, try std.fmt.bufPrint(&name_buf, "{s}/", .{s.dir})).count);
+ try testing.expect(p.panes[p.active].?.isTerminal());
+ try testing.expectEqualStrings(s.dir, p.panes[p.active].?.cwdSlice());
+ // Bare, it steps back to pane, the default.
+ try testing.expect(p.executeBuiltinLine(0, "DirLook"));
+ try testing.expectEqual(config.Runtime.DirLook.pane, p.settings.dir_look);
+}
+
+test "over 9P a directory pane is a text pane whose name ends in /, its body the listing, ctl's isdir 1, and get reads it again" {
+ if (comptime !pardes.hosted) return;
+ const th = @import("ninep/testing.zig");
+ const tree = @import("ninep/tree.zig");
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ var line_buf: [4200]u8 = undefined;
+ _ = th.wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line_buf, "{s}\n", .{s.dir}));
+ p.sync();
+ const pane = p.panes[p.active].?;
+ const index = th.rd(p, @intFromEnum(tree.TopFile.index), 0, 8192).bytes;
+ try testing.expect(std.mem.indexOf(u8, index, try std.fmt.bufPrint(&line_buf, "{d} text 0 {s}/ ", .{ pane.serial, s.dir })) != null);
+ try testing.expectEqualStrings(".dot sub/ top.txt\n", th.rd(p, tree.Node.of(pane.serial, .body), 0, 4096).bytes);
+ var fields = std.mem.tokenizeScalar(u8, th.rd(p, tree.Node.of(pane.serial, .ctl), 0, 4096).bytes, ' ');
+ for (0..3) |_| _ = fields.next();
+ try testing.expectEqualStrings("1", fields.next().?);
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "zz", .data = "" });
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(pane.serial, .ctl), "get\n").reply.status);
+ const after = th.rd(p, tree.Node.of(pane.serial, .body), 0, 4096).bytes;
+ try testing.expect(std.mem.indexOf(u8, after, "zz") != null);
+ try testing.expectEqualStrings(pane.file.?.content, after);
+}
+
+test "a directory pane lays its entries out again when its width changes, unless its text was edited" {
+ if (comptime !pardes.hosted) return;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ const s = try session(&tmp, &buf);
+ defer s.p.deinit();
+ const p = s.p;
+ pardes.look.lookAt(p, 0, s.dir);
+ p.sync();
+ const pane = p.panes[p.active].?;
+ // 9 cells a column: two of them want 18 cells and the line numbers.
+ fit(p, pane, 24);
+ try testing.expectEqualStrings(".dot top.txt\nsub/\n", pane.file.?.content);
+ fit(p, pane, 80);
+ try testing.expectEqualStrings(".dot sub/ top.txt\n", pane.file.?.content);
+ try testing.expect(p.executeBuiltinLine(p.active, "Edit 1d"));
+ const edited = try testing.allocator.dupe(u8, pane.file.?.content);
+ defer testing.allocator.free(edited);
+ fit(p, pane, 24);
+ try testing.expectEqualStrings(edited, pane.file.?.content);
+}
diff --git a/src/File.zig b/src/File.zig
index b3c28290..875d0f46 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -3,6 +3,7 @@
const panes = @import("panes.zig");
const std = @import("std");
const vaxis = @import("vaxis");
+const builtin = @import("builtin");
const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
@@ -31,20 +32,101 @@ pub const Snapshot = struct {
cur_row: i32,
cur_col: i32,
vsel: Text.CharSel,
+ /// The pane's name then, as History.gen counts its renames, and whether
+ /// and as what the text was saved under it: an undo across a load into
+ /// the pane under another name (Get file, Edit's e) puts the name back
+ /// with the text, and the dirty state that went with them, as acme's
+ /// undo does. No allocation a step: a name is kept once, at its rename.
+ gen: u32 = 0,
+ clean: bool = false,
+ saved_hash: ?u64 = null,
+ saved_len: ?usize = null,
+
+ fn deinit(s: Snapshot, gpa: std.mem.Allocator) void {
+ gpa.free(s.content);
+ }
};
+/// The pane's text, place and name now, for an undo or redo step.
+fn snapshotOf(gpa: std.mem.Allocator, pane: *const Pane, f: *const State) ?Snapshot {
+ return .{
+ .content = gpa.dupe(u8, f.content) catch return null,
+ .cur_row = pane.body.cur_row,
+ .cur_col = pane.body.cur_col,
+ .vsel = pane.body.vsel,
+ .gen = f.history.gen,
+ .clean = f.revision == f.saved_revision,
+ .saved_hash = f.saved_hash,
+ .saved_len = f.saved_len,
+ };
+}
+
+/// A rename (ninep/pane.zig nameBuffer): the name it ends, kept for an undo
+/// back across it; `old` is the History's from here (owned), freed if it
+/// cannot be kept.
+pub fn noteRenamed(gpa: std.mem.Allocator, history: *History, old: []u8) void {
+ history.names.append(gpa, .{ .gen = history.gen, .path = old }) catch gpa.free(old);
+ history.gen +%= 1;
+}
+
+/// After a step's text went in: if it was under another name, that name
+/// back, and the step's saved state with it (Get file, Edit's e).
+fn restoreName(p: *Pardes, pane: *Pane, f: *State, snap: Snapshot) void {
+ const history = f.history;
+ if (snap.gen == history.gen) return;
+ // The latest name kept for that generation.
+ var at = history.names.items.len;
+ const path = while (at > 0) {
+ at -= 1;
+ if (history.names.items[at].gen == snap.gen) break history.names.items[at].path;
+ } else return;
+ if (std.mem.eql(u8, path, f.path)) {
+ history.gen = snap.gen;
+ return;
+ }
+ const id = for (p.panes, 0..) |slot, i| {
+ if (slot == pane) break i;
+ } else return;
+ // nameBuffer frees nothing of `path`: it keeps the name it ends.
+ const back = p.gpa.dupe(u8, path) catch return;
+ defer p.gpa.free(back);
+ pardes.ctlfs.pane.nameBuffer(p, id, back, false) catch return;
+ history.gen = snap.gen;
+ f.saved_hash = snap.saved_hash;
+ f.saved_len = snap.saved_len;
+ f.saved_revision = if (snap.clean) f.revision else f.revision -% 1;
+ f.disk_newer = null;
+ f.disk_newer_warned = false;
+ if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+}
+
pub const History = struct {
undo: [limits.undo_max]Snapshot = undefined,
undo_len: usize = 0,
redo: [limits.undo_max]Snapshot = undefined,
redo_len: usize = 0,
+ /// The names the file had, each with the rename generation it ended
+ /// (Snapshot.gen), and the generation now.
+ names: std.ArrayList(struct { gen: u32, path: []u8 }) = .empty,
+ gen: u32 = 0,
+
pub fn create(gpa: std.mem.Allocator) !*History {
const history = try gpa.create(History);
history.undo_len = 0;
history.redo_len = 0;
+ history.names = .empty;
+ history.gen = 0;
return history;
}
+
+ pub fn destroy(history: *History, gpa: std.mem.Allocator) void {
+ for (history.undo[0..history.undo_len]) |snap| snap.deinit(gpa);
+ for (history.redo[0..history.redo_len]) |snap| snap.deinit(gpa);
+ for (history.names.items) |name| gpa.free(name.path);
+ history.names.deinit(gpa);
+ gpa.destroy(history);
+ }
};
/// A file pane's backing: owned content, its derived caches, and undo history.
@@ -84,6 +166,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.
@@ -105,6 +189,10 @@ pub const State = struct {
/// to; gpa-owned ranges and the primary's index.
node_history: std.ArrayList(NodeStep) = .empty,
history: *History,
+ /// Zerox's group: the panes on one buffer share `content`, `history`
+ /// and (while it is the one index) `line_starts`, each with its own
+ /// scroll, cursor and syntax; 0, none. See `zerox`.
+ twin: u32 = 0,
};
pub const NodeStep = struct { ranges: []modal.Selection, primary: usize };
@@ -309,7 +397,7 @@ test "stacked location metadata requires matching adjacent preview ownership" {
}
pub fn graphemeDisplayWidth(grapheme: []const u8) usize {
- if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_now;
if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1;
if (std.unicode.utf8ValidateSlice(grapheme)) return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
// vaxis's gwidth counts back a replacement rune's 3 bytes over the one
@@ -344,7 +432,7 @@ test "an invalid byte in a grapheme is one cell, and never reaches vaxis's width
}
pub fn byteDisplayWidth(byte: u8) usize {
- return if (byte == '\t') config.tab_width else 1;
+ return if (byte == '\t') config.tab_now else 1;
}
pub fn displayWidth(text: []const u8) usize {
@@ -501,7 +589,7 @@ pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8
test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" {
const ref = struct {
fn width(grapheme: []const u8) usize {
- if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
+ if (std.mem.eql(u8, grapheme, "\t")) return config.tab_now;
return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
}
}.width;
@@ -943,6 +1031,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
@@ -982,10 +1078,17 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.output == null) for (p.panes, 0..) |slot, id| {
if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
};
+ // A twin's text, undo and shared index stay with the twins left.
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const ntwins = twinsOf(p, file, &twins);
+ const index_shared = for (twins[0..ntwins]) |id| {
+ if (p.panes[id].?.file.?.line_starts.ptr == file.line_starts.ptr) break true;
+ } else false;
p.gpa.free(file.path);
- p.gpa.free(file.content);
+ if (ntwins == 0) 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.line_starts.len > 0 and !index_shared) p.gpa.free(file.line_starts);
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
p.gpa.free(file.pager_spans);
locations.freeRows(p.gpa, file.location_rows);
@@ -993,9 +1096,8 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.node_tree) |tree| syntax.freeTree(tree);
for (file.node_history.items) |step| p.gpa.free(step.ranges);
file.node_history.deinit(p.gpa);
- for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content);
- for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content);
- p.gpa.destroy(file.history);
+ if (ntwins > 0) return;
+ file.history.destroy(p.gpa);
}
fn reportEdit(p: *Pardes, f: *State, new: []const u8) void {
@@ -1013,7 +1115,20 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
/// `setContent` for a splice that knows what it left alone at either end,
/// so the line index is not found again by comparing the whole text.
-pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
+pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span_in: ?Span) void {
+ // Twins (Zerox) take the same splice: where it is, found once here when
+ // the caller did not say, is what the line index wants anyway.
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const ntwins = twinsOf(p, f, &twins);
+ const span: ?Span = span_in orelse if (ntwins > 0) spanOf(f.content, new) else null;
+ var moved: [pardes.MAX_PANES]struct { cursor: usize, scroll: usize } = undefined;
+ for (twins[0..ntwins], 0..) |id, i| {
+ const t = p.panes[id].?;
+ const tf = &t.file.?;
+ const at = lineStart(p.gpa, tf, @intCast(@max(0, t.body.cur_row))) + @as(usize, @intCast(@max(0, t.body.cur_col)));
+ moved[i] = .{ .cursor = spliced(@min(at, f.content.len), f.content.len, new.len, span.?), .scroll = spliced(lineStart(p.gpa, tf, tf.scroll), f.content.len, new.len, span.?) };
+ }
+ const old_starts = f.line_starts.ptr;
locations.freeRows(p.gpa, f.location_rows);
f.location_rows = &.{};
p.gpa.free(f.pager_spans);
@@ -1050,6 +1165,257 @@ pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
f.highlight_start = 0;
f.syntax_dirty = true;
if (std.mem.endsWith(u8, f.path, "/+Pager")) f.pager_diff = looksLikeDiff(new[0..@min(new.len, 64 * 1024)]);
+ for (twins[0..ntwins], 0..) |id, i| {
+ const t = p.panes[id].?;
+ const tf = &t.file.?;
+ // Its own index, built while the shared one was empty, goes; the
+ // shared one, freed above, is replaced.
+ if (tf.line_starts.len > 0 and tf.line_starts.ptr != old_starts) p.gpa.free(tf.line_starts);
+ tf.content = f.content;
+ tf.line_starts = f.line_starts;
+ tf.revision = f.revision;
+ tf.saved_revision = f.saved_revision;
+ tf.saved_hash = f.saved_hash;
+ tf.saved_len = f.saved_len;
+ tf.mtime = f.mtime;
+ if (tf.highlights.len > 0) p.tree_sitter_gpa.free(tf.highlights);
+ tf.highlights = &.{};
+ tf.highlight_start = 0;
+ tf.syntax_dirty = true;
+ locations.freeRows(p.gpa, tf.location_rows);
+ tf.location_rows = &.{};
+ if (tf.mini) |*mini| mini.deinit(p.gpa);
+ tf.mini = null;
+ for (0..t.sel.len) |button| {
+ t.clearPointerSelection(button);
+ t.sel[button].state = .none;
+ }
+ t.body.vsel.active = false;
+ t.body.msel.active = false;
+ t.body.nsel = 0;
+ const cursor = positionIn(p.gpa, tf, moved[i].cursor);
+ t.body.cur_row = @intCast(cursor.row);
+ t.body.cur_col = @intCast(cursor.col);
+ tf.scroll = positionIn(p.gpa, tf, moved[i].scroll).row;
+ }
+}
+
+/// Where an offset into the old text is in the new, after the splice that
+/// kept `span` at either end: past it, moved by what it added; inside it,
+/// at its start, as acme moves a clone's q0 (textinsert, textdelete).
+fn spliced(at: usize, old_len: usize, new_len: usize, span: Span) usize {
+ if (at <= span.head) return at;
+ if (at >= old_len - span.tail) return at - (old_len - span.tail) + (new_len - span.tail);
+ return span.head;
+}
+
+/// What `old` and `new` agree on at either end.
+fn spanOf(old: []const u8, new: []const u8) Span {
+ const n = @min(old.len, new.len);
+ const head = std.mem.indexOfDiff(u8, old[0..n], new[0..n]) orelse n;
+ var tail: usize = 0;
+ while (tail < n - head and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1;
+ return .{ .head = head, .tail = tail };
+}
+
+fn positionIn(gpa: std.mem.Allocator, f: *State, at: usize) struct { row: usize, col: usize } {
+ const index = lineIndex(gpa, f) catch {
+ const c = modal.positionAt(f.content, at);
+ return .{ .row = c.row, .col = c.col };
+ };
+ const row = std.sort.upperBound(usize, index, at, struct {
+ fn order(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.order) -| 1;
+ return .{ .row = row, .col = at - index[row] };
+}
+
+/// The other panes on `f`'s buffer (Zerox), by id.
+pub fn twinsOf(p: *Pardes, f: *const State, out: *[pardes.MAX_PANES]usize) usize {
+ if (f.twin == 0) return 0;
+ var n: usize = 0;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const other = if (pane.file) |*file| file else continue;
+ if (other == f or other.twin != f.twin) continue;
+ out[n] = id;
+ n += 1;
+ }
+ return n;
+}
+
+/// acme's Zerox: pane `to` made on `from`'s buffer, its text, undo and
+/// dirty state shared, its scroll and cursor its own. An edit in either is
+/// spliced into both (setContentSpan, O(the edit) more); what Save and the
+/// disk watch change on one is copied to the others after each step
+/// (syncTwins). An event reader on one hears no edit made in another.
+pub fn zerox(p: *Pardes, from: usize, to: usize) !*Pane {
+ const src = p.panes[from].?;
+ const f = &src.file.?;
+ const path = try p.gpa.dupe(u8, f.path);
+ errdefer p.gpa.free(path);
+ if (f.twin == 0) {
+ const group = try p.gpa.dupe(u8, f.path);
+ errdefer p.gpa.free(group);
+ try p.twin_groups.append(p.gpa, .{ .id = p.next_twin, .saved_revision = f.saved_revision, .saved_hash = f.saved_hash, .saved_len = f.saved_len, .disk_newer = f.disk_newer, .disk_gone = f.disk_gone, .path = group });
+ f.twin = p.next_twin;
+ p.next_twin +%= 1;
+ }
+ const pane = try p.newDocPane(to);
+ pane.file = .{
+ .path = path,
+ .content = f.content,
+ .line_starts = f.line_starts,
+ .history = f.history,
+ .revision = f.revision,
+ .saved_revision = f.saved_revision,
+ .saved_hash = f.saved_hash,
+ .saved_len = f.saved_len,
+ .disk_newer = f.disk_newer,
+ .disk_gone = f.disk_gone,
+ .mtime = f.mtime,
+ .scroll = f.scroll,
+ .tree_context = f.tree_context,
+ .twin = f.twin,
+ };
+ pane.body.cur_row = src.body.cur_row;
+ pane.body.cur_col = src.body.cur_col;
+ pane.body.cur_pinned = true;
+ if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(to), .on = true } });
+ return pane;
+}
+
+/// A twin closed: it keeps a text of its own until it is torn down (a
+/// retired pane is drawn going), and the twins keep the shared one, its
+/// undo and its index.
+pub fn leaveTwins(p: *Pardes, f: *State) void {
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const n = twinsOf(p, f, &twins);
+ f.twin = 0;
+ if (n == 0) return;
+ const shared_index = for (twins[0..n]) |id| {
+ if (p.panes[id].?.file.?.line_starts.ptr == f.line_starts.ptr) break true;
+ } else false;
+ if (shared_index) f.line_starts = &.{};
+ f.content = p.gpa.dupe(u8, f.content) catch p.gpa.alloc(u8, 0) catch &.{};
+ f.history = History.create(p.gpa) catch f.history;
+ // The one left is no twin now (syncTwins drops the group).
+ if (n == 1) p.panes[twins[0]].?.file.?.twin = 0;
+}
+
+/// Restore's: pane `id`, restored on its own, back on pane `of`'s buffer
+/// as the Zerox twin it was dumped as (dump.File.twin_of). Not a file pane
+/// there (a dump edited by hand), it stays on its own.
+pub fn relink(p: *Pardes, of: usize, id: usize, dot: ?[2]u32) void {
+ const src = p.panes[of] orelse return;
+ const pane = p.panes[id] orelse return;
+ const sf = if (src.file) |*file| file else return;
+ const f = if (pane.file) |*file| file else return;
+ if (sf.output != null or f.output != null or sf.mini != null) return;
+ if (sf.twin == 0) {
+ const group = p.gpa.dupe(u8, sf.path) catch return;
+ p.twin_groups.append(p.gpa, .{ .id = p.next_twin, .saved_revision = sf.saved_revision, .saved_hash = sf.saved_hash, .saved_len = sf.saved_len, .disk_newer = sf.disk_newer, .disk_gone = sf.disk_gone, .path = group }) catch return p.gpa.free(group);
+ sf.twin = p.next_twin;
+ p.next_twin +%= 1;
+ }
+ p.gpa.free(f.content);
+ if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
+ f.history.destroy(p.gpa);
+ f.content = sf.content;
+ f.line_starts = sf.line_starts;
+ f.history = sf.history;
+ f.revision = sf.revision;
+ f.saved_revision = sf.saved_revision;
+ f.saved_hash = sf.saved_hash;
+ f.saved_len = sf.saved_len;
+ f.twin = sf.twin;
+ if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.syntax_dirty = true;
+ // Its dot as dumped, read now that its text is back.
+ if (dot) |d| if (d[0] <= d[1] and d[1] <= f.content.len) {
+ const a = modal.positionAt(f.content, d[0]);
+ const h = modal.positionAt(f.content, if (d[1] > d[0]) modal.prevGrapheme(f.content, d[1]) else d[0]);
+ pane.body.vsel = .{ .active = d[1] > d[0], .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ pane.body.cur_row = @intCast(h.row);
+ pane.body.cur_col = @intCast(h.col);
+ };
+}
+
+pub const TwinGroup = struct {
+ id: u32,
+ saved_revision: u32,
+ saved_hash: ?u64,
+ saved_len: ?usize,
+ disk_newer: ?u64,
+ disk_gone: bool,
+ path: []u8,
+};
+
+/// After each step: what a Save, a get, the disk watch or a rename changed
+/// on one twin is copied to the others; a group down to one pane is no
+/// group. O(panes) a step, and nothing at all with no twins.
+pub fn syncTwins(p: *Pardes) void {
+ var g: usize = 0;
+ while (g < p.twin_groups.items.len) {
+ const group = &p.twin_groups.items[g];
+ var members: [pardes.MAX_PANES]*State = undefined;
+ var member_panes: [pardes.MAX_PANES]*Pane = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = if (pane.file) |*file| file else continue;
+ if (f.twin == group.id) {
+ members[n] = f;
+ member_panes[n] = pane;
+ n += 1;
+ }
+ }
+ if (n < 2) {
+ if (n == 1) members[0].twin = 0;
+ p.gpa.free(group.path);
+ _ = p.twin_groups.swapRemove(g);
+ continue;
+ }
+ for (members[0..n]) |f| {
+ if (f.saved_revision == group.saved_revision and f.saved_hash == group.saved_hash and f.saved_len == group.saved_len and
+ f.disk_newer == group.disk_newer and f.disk_gone == group.disk_gone and std.mem.eql(u8, f.path, group.path)) continue;
+ // This one changed: the group, and every other twin, take it.
+ if (!std.mem.eql(u8, f.path, group.path)) {
+ const path = p.gpa.dupe(u8, f.path) catch break;
+ p.gpa.free(group.path);
+ group.path = path;
+ }
+ group.saved_revision = f.saved_revision;
+ group.saved_hash = f.saved_hash;
+ group.saved_len = f.saved_len;
+ group.disk_newer = f.disk_newer;
+ group.disk_gone = f.disk_gone;
+ for (members[0..n], member_panes[0..n]) |other, other_pane| {
+ if (other == f) continue;
+ other.saved_revision = f.saved_revision;
+ other.saved_hash = f.saved_hash;
+ other.saved_len = f.saved_len;
+ other.disk_newer = f.disk_newer;
+ other.disk_gone = f.disk_gone;
+ if (!std.mem.eql(u8, other.path, f.path)) {
+ const path = p.gpa.dupe(u8, f.path) catch continue;
+ p.gpa.free(other.path);
+ other.path = path;
+ // Renamed, each twin says so, as the one renamed did.
+ pardes.ctlfs.events.noteLog(p, .rename, other_pane);
+ }
+ }
+ break;
+ }
+ // Tests hold the twins to one text, one revision, every step.
+ if (builtin.is_test) for (members[1..n]) |other| {
+ std.debug.assert(other.content.ptr == members[0].content.ptr and other.content.len == members[0].content.len);
+ std.debug.assert(other.revision == members[0].revision);
+ };
+ g += 1;
+ }
}
/// Whether `head`, a text's first bytes, holds a diff: a `diff` header, or
@@ -1096,7 +1462,7 @@ pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void {
fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void {
if (len.* == slots.len) {
- gpa.free(slots[0].content);
+ slots[0].deinit(gpa);
std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
len.* -= 1;
}
@@ -1107,15 +1473,13 @@ fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Sna
pub fn pushUndo(p: *Pardes, pane: *Pane) void {
const f = if (pane.file) |*file| file else return;
const history = f.history;
- if (history.undo_len > 0 and std.mem.eql(u8, history.undo[history.undo_len - 1].content, f.content)) return;
- const snap: Snapshot = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.body.cur_row,
- .cur_col = pane.body.cur_col,
- .vsel = pane.body.vsel,
- };
+ if (history.undo_len > 0) {
+ const last = history.undo[history.undo_len - 1];
+ if (std.mem.eql(u8, last.content, f.content) and last.gen == history.gen) return;
+ }
+ const snap = snapshotOf(p.gpa, pane, f) orelse return;
pushHistory(p.gpa, &history.undo, &history.undo_len, snap);
- for (history.redo[0..history.redo_len]) |item| p.gpa.free(item.content);
+ for (history.redo[0..history.redo_len]) |item| item.deinit(p.gpa);
history.redo_len = 0;
}
@@ -1123,16 +1487,12 @@ pub fn undo(p: *Pardes, pane: *Pane) void {
const f = if (pane.file) |*file| file else return;
const history = f.history;
if (history.undo_len == 0) return;
- const current: Snapshot = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.body.cur_row,
- .cur_col = pane.body.cur_col,
- .vsel = pane.body.vsel,
- };
+ const current = snapshotOf(p.gpa, pane, f) orelse return;
pushHistory(p.gpa, &history.redo, &history.redo_len, current);
history.undo_len -= 1;
const previous = history.undo[history.undo_len];
setContent(p, f, previous.content);
+ restoreName(p, pane, f, previous);
restoreSnap(pane, f, previous);
}
@@ -1140,16 +1500,12 @@ pub fn redo(p: *Pardes, pane: *Pane) void {
const f = if (pane.file) |*file| file else return;
const history = f.history;
if (history.redo_len == 0) return;
- const current: Snapshot = .{
- .content = p.gpa.dupe(u8, f.content) catch return,
- .cur_row = pane.body.cur_row,
- .cur_col = pane.body.cur_col,
- .vsel = pane.body.vsel,
- };
+ const current = snapshotOf(p.gpa, pane, f) orelse return;
pushHistory(p.gpa, &history.undo, &history.undo_len, current);
history.redo_len -= 1;
const next = history.redo[history.redo_len];
setContent(p, f, next.content);
+ restoreName(p, pane, f, next);
restoreSnap(pane, f, next);
}
@@ -1182,6 +1538,9 @@ pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
if (std.mem.eql(u8, f.content, bytes)) return;
if (f.saved_hash) |saved| if (f.revision != f.saved_revision and std.hash.Wyhash.hash(0, f.content) != saved) {
const disk = std.hash.Wyhash.hash(0, bytes);
+ // The disk holds what the text was read or saved as: no news, only
+ // a watch looking again (an undo back to this name, a restart).
+ if (disk == saved) return;
if (f.disk_newer == disk) return;
f.disk_newer = disk;
f.disk_newer_warned = false;
diff --git a/src/Messages.zig b/src/Messages.zig
index 366445c7..2314939f 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -498,6 +498,9 @@ pub fn errorWords(err: anyerror, buf: []u8) []const u8 {
return switch (err) {
error.FileTooLarge => "too large",
error.NotAFile => "not a regular file",
+ // In the words a mount turns into EISDIR and EACCES (cloud9.fs.enameErrno).
+ error.IsDirectory => "is a directory",
+ error.PermissionDenied, error.AccessDenied => "permission denied",
error.ReadFailed => "could not be read",
error.ParseZon => "not valid ZON",
error.ThemeFileTooLarge => "too large: a theme file is at most 1 MiB",
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/Pipe.zig b/src/Pipe.zig
index 48e98b7b..850f9c64 100644
--- a/src/Pipe.zig
+++ b/src/Pipe.zig
@@ -8,6 +8,7 @@ const panes = @import("panes.zig");
const config = @import("config.zig");
const selection_pipe = @import("selection_pipe.zig");
const edit = @import("edit.zig");
+const edit_cmd = @import("edit_cmd.zig");
const Pane = panes.Pane;
const Text = panes.Text;
const Key = pardes.Key;
@@ -18,6 +19,17 @@ const Pipe = @This();
seq: u32 = 0,
/// The request in flight, and what it needs to apply its answer.
wait: ?PendingPipe = null,
+/// An Edit waiting on its `<`, `|` and `>` commands, which share the ids.
+edit_run: ?*edit_cmd.Pending = null,
+/// What an Edit's `w` took, for the `edit_write` effect to write.
+writes: std.ArrayList(edit_cmd.Write) = .empty,
+
+pub fn deinit(pipe: *Pipe, gpa: std.mem.Allocator) void {
+ if (pipe.wait) |*wait| wait.deinit(gpa);
+ if (pipe.edit_run) |pending| pending.deinit(gpa);
+ for (pipe.writes.items) |*w| w.deinit(gpa);
+ pipe.writes.deinit(gpa);
+}
fn nextPipeEffect(p: *Pardes) ?u32 {
while (p.nextEffect()) |effect| switch (effect) {
@@ -164,6 +176,7 @@ pub fn pipeRequest(pipe: *const Pipe, id: u32) ?selection_pipe.Request {
if (pipe.wait) |*wait| {
if (wait.id == id) return wait.request();
}
+ if (pipe.edit_run) |pending| if (pending.id == id) return pending.request;
return null;
}
@@ -172,26 +185,9 @@ fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Fai
defer out.deinit();
const w = &out.writer;
w.print("| {s}\n\n", .{wait.command}) catch return;
- if (failure) |fail| {
- if (wait.inputs.len > 1)
- w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return;
- switch (fail.kind) {
- .exit => w.print("exit status {d}\n", .{fail.code}) catch return,
- .signal => w.writeAll("killed by a signal\n") catch return,
- .timeout => w.print(
- "still running after {d} seconds, and stopped\n",
- .{selection_pipe.command_timeout_seconds},
- ) catch return,
- .too_large => w.writeAll("produced more output than a filter may return\n") catch return,
- .spawn => w.writeAll("could not be started\n") catch return,
- .io => w.writeAll("could not be read\n") catch return,
- }
- if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return;
- } else {
- // No diagnosis at all: nobody ran it. The detached daemon, the
- // browser and the board all leave `pipe` null on purpose.
- w.writeAll("this session cannot run filters\n") catch return;
- }
+ if (wait.inputs.len > 1) if (failure) |fail|
+ w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return;
+ writeFailure(w, failure) catch return;
const content = out.toOwnedSlice() catch return;
const was = p.active;
panes.Output.openErrors(p, wait.pane, content) catch |err| {
@@ -203,6 +199,28 @@ fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Fai
if (p.panes[was] != null) p.active = was;
}
+/// How a filter failed, a line, and the command's stderr after it: what
+/// +Errors says of a `|` and of an Edit's `< | >`.
+pub fn writeFailure(w: *std.Io.Writer, failure: ?selection_pipe.Failure) !void {
+ const fail = failure orelse {
+ // No diagnosis at all: nobody ran it. The detached daemon, the
+ // browser and the board all leave `pipe` null on purpose.
+ return w.writeAll("this session cannot run filters\n");
+ };
+ switch (fail.kind) {
+ .exit => try w.print("exit status {d}\n", .{fail.code}),
+ .signal => try w.writeAll("killed by a signal\n"),
+ .timeout => try w.print(
+ "still running after {d} seconds, and stopped\n",
+ .{selection_pipe.command_timeout_seconds},
+ ),
+ .too_large => try w.writeAll("produced more output than a filter may return\n"),
+ .spawn => try w.writeAll("could not be started\n"),
+ .io => try w.writeAll("could not be read\n"),
+ }
+ if (fail.stderr.len > 0) try w.print("\n{s}", .{fail.stderr});
+}
+
fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } {
const range = wait.ranges[i];
const lo = @min(range.anchor, range.head);
@@ -240,6 +258,7 @@ pub fn pipeResponse(
outputs: []const []const u8,
failure: ?selection_pipe.Failure,
) void {
+ if (p.pipe.edit_run) |pending| if (pending.id == id) return edit_cmd.finish(p, success, outputs, failure);
// A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is
// the answer to a question nobody is still asking.
if (p.pipe.wait == null or p.pipe.wait.?.id != id) return;
diff --git a/src/builtins.zig b/src/builtins.zig
index cca7bfb5..84904959 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -227,6 +227,8 @@ pub const registry = struct {
// are the default
.toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion, .shell => false,
.theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
+ // bare, it says the width (exec.applySettingBuiltin)
+ .tab => false,
};
unreachable;
}
@@ -516,7 +518,10 @@ pub const Look = struct {
// name on the message row would only be noise over that
pub const quiet = true;
pub fn run(c: Ctx) void {
- look.lookAt(c.p, c.id, c.arg orelse return);
+ // Bare (a key bound to it, a tag's Look with nothing held): the
+ // selection, else what Enter on the cursor takes, the one expansion.
+ const arg = c.arg orelse (exec.currentOperand(c.p, c.pane) orelse return);
+ look.lookAt(c.p, c.id, arg);
}
};
@@ -550,9 +555,9 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
/// Which panes a refusal asks about: every one (Exit, Restore), one (Del),
/// or a column's (Delcol).
-const Asked = union(enum) { all, pane: usize, column: usize };
+pub const Asked = union(enum) { all, pane: usize, column: usize };
-fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
+pub fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
// Every pane asked about is an `unsaved <serial> <name>` record, as
// acme's rowclean names them all (wind.c:511); the refusal a write
// gets names the one, or counts them, never a list cut short.
@@ -570,6 +575,22 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
.column => |col| if ((layout.findPane(c.p, id) orelse continue).col != col) continue,
}
const f = pane.file orelse continue;
+ // Twins (Zerox) hold one text: closing one while another stays
+ // loses nothing, and the text is asked about once.
+ if (f.twin != 0) {
+ const asked_elsewhere = for (c.p.panes, 0..) |other_slot, other_id| {
+ const other = other_slot orelse continue;
+ const of = other.file orelse continue;
+ if (other_id == id or of.twin != f.twin) continue;
+ const closing = switch (which) {
+ .all => true,
+ .pane => |only| other_id == only,
+ .column => |col| if (layout.findPane(c.p, other_id)) |at| at.col == col else false,
+ };
+ if (!closing or other_id < id) break true;
+ } else false;
+ if (asked_elsewhere) continue;
+ }
// A +New scratch saves nowhere, but what is typed in it is as lost;
// under 100 bytes it is not asked about, as acme's winclean does not
// whine about a small unnamed window, nor about a command's output.
@@ -1113,6 +1134,129 @@ pub const ClipReplace = struct {
// ---- panes and columns ----
+/// acme's Get: the pane's file read again, its unsaved edits refused once
+/// as Del refuses them. `Get file` loads that file into the pane instead,
+/// the pane taking its name.
+pub const Get = struct {
+ pub const takes_arg = true;
+ pub fn run(c: Ctx) void {
+ const ctl = @import("ninep/ctl.zig");
+ if (@import("edit_cmd.zig").busyOn(c.p, c.id)) return c.p.reportFailure(c.id, "Get: " ++ @import("edit_cmd.zig").e_busy);
+ const f = if (c.pane.file) |*file| file else return c.p.reportFailure(c.id, "Get: only a file pane takes it");
+ const typed = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ // A directory pane: the directory read again (Dir.zig), as acme's
+ // Get is in a directory window; a file is a Look's from there.
+ if (f.listing != null) {
+ if (typed.len > 0) return c.p.reportFailure(c.id, "Get: a directory pane reads its directory; a Look at the file opens it");
+ return panes.Dir.show(c.p, c.id, c.id);
+ }
+ if (!panes.Output.fileTraits(f.output).saves) return c.p.reportFailure(c.id, "Get: only a file pane takes it");
+ var failed: anyerror = error.Unexpected;
+ var home_buf: [4096]u8 = undefined;
+ const path = if (typed.len == 0) c.pane.file.?.path else std.fs.path.resolvePosix(c.p.scratch.allocator(), &.{ Pardes.paneDir(c.pane), @import("fs.zig").expandHome(typed, &home_buf) }) catch return;
+ // A file that is not there, or a directory, is said before unsaved
+ // edits are asked about: there would be nothing to load over them.
+ if (comptime pardes.hosted) if (typed.len > 0) if (@import("fs.zig").localPath(path)) |local| {
+ var said: [320]u8 = undefined;
+ const kind = exec.kindOf(local);
+ if (kind == null or kind.? == .directory)
+ return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (kind == null) "no such file" else "is a directory" }) catch "Get: no such file");
+ };
+ if (ctl.getRefused(c.p, c.pane, "Get")) |said| return c.p.reportFailure(c.id, said);
+ const errno = if (typed.len == 0) ctl.get(c.p, c.pane, &failed) else ctl.getFile(c.p, c.pane, path, &failed);
+ if (errno != 0) {
+ var said: [320]u8 = undefined;
+ var words: [128]u8 = undefined;
+ const why = if (failed == error.FileNotFound) "no such file" else pardes.Messages.errorWords(failed, &words);
+ c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Get: {s}: {s}", .{ path[0..@min(path.len, 256)], why }) catch "Get: cannot be read");
+ }
+ }
+};
+
+/// acme's Putall: every pane holding unsaved edits to a file is saved, each
+/// as its own Save; one that is refused (changed on disk since, a write
+/// that fails) says so and the rest are saved all the same.
+pub const Putall = struct {
+ pub const scope: Scope = .session;
+ pub fn run(c: Ctx) void {
+ for (c.p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ if (f.output != null or f.listing != null or f.revision == f.saved_revision) continue;
+ // A Zerox pair is one buffer: saved once, by its first pane.
+ if (f.twin != 0) if (for (c.p.panes[0..id]) |earlier| {
+ if ((earlier orelse continue).file) |ef| if (ef.twin == f.twin) break true;
+ } else false) continue;
+ exec.saveFile(c.p, id);
+ }
+ }
+};
+
+/// acme's Incl, for the session: `Incl dir ...` adds directories a look at a
+/// name found nowhere else is tried in (`stdio.h`, `<foo.h>`), bare lists
+/// them, `Incl -` empties the list.
+pub const Incl = struct {
+ pub const scope: Scope = .session;
+ pub const takes_arg = true;
+ pub fn run(c: Ctx) void {
+ const p = c.p;
+ const typed = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (std.mem.eql(u8, typed, "-")) {
+ for (p.incl.items) |dir| p.gpa.free(dir);
+ p.incl.clearRetainingCapacity();
+ return p.setMessage(c.id, "Incl: none");
+ }
+ // All checked before any is taken: `-` clears, alone; a directory
+ // is absolute (or ~) and there.
+ var check = std.mem.tokenizeAny(u8, typed, " \t");
+ while (check.next()) |dir_word| {
+ var said: [320]u8 = undefined;
+ const shown = dir_word[0..@min(dir_word.len, 200)];
+ if (std.mem.eql(u8, dir_word, "-")) return p.reportFailure(c.id, "Incl: invalid: - clears the list, alone");
+ if (dir_word[0] != '/' and dir_word[0] != '~')
+ return p.reportFailure(c.id, std.fmt.bufPrint(&said, "Incl: invalid directory: {s} is relative", .{shown}) catch "Incl: invalid directory");
+ var home_buf: [4096]u8 = undefined;
+ const expanded = @import("fs.zig").expandHome(dir_word, &home_buf);
+ if (comptime pardes.hosted) if (@import("fs.zig").localPath(expanded)) |local| if (!exec.isDirectory(local))
+ return p.reportFailure(c.id, if (exec.kindOf(local) != null)
+ std.fmt.bufPrint(&said, "Incl: not a directory: {s}", .{shown}) catch "Incl: not a directory"
+ else
+ std.fmt.bufPrint(&said, "Incl: no such directory: {s}", .{shown}) catch "Incl: no such directory");
+ }
+ var words = std.mem.tokenizeAny(u8, typed, " \t");
+ while (words.next()) |dir_word| {
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(c.pane), @import("fs.zig").expandHome(dir_word, &home_buf) }) catch return;
+ if (!std.fs.path.isAbsolute(full)) return p.reportFailure(c.id, "Incl: invalid directory");
+ for (p.incl.items) |dir| {
+ if (std.mem.eql(u8, dir, full)) break;
+ } else {
+ const copy = p.gpa.dupe(u8, full) catch return;
+ p.incl.append(p.gpa, copy) catch return p.gpa.free(copy);
+ }
+ }
+ if (typed.len > 0) return;
+ var list: std.Io.Writer.Allocating = .init(p.scratch.allocator());
+ list.writer.writeAll("Incl:") catch return;
+ for (p.incl.items) |dir| list.writer.print(" {s}", .{dir}) catch return;
+ if (p.incl.items.len == 0) list.writer.writeAll(" none") catch return;
+ p.setMessage(c.id, list.written());
+ }
+};
+
+/// acme's Zerox: a second pane on this pane's file, the same text, undo and
+/// unsaved state, with a scroll and a cursor of its own (File.zerox).
+pub const Zerox = struct {
+ pub fn run(c: Ctx) void {
+ if (@import("edit_cmd.zig").busyOn(c.p, c.id)) return c.p.reportFailure(c.id, "Zerox: " ++ @import("edit_cmd.zig").e_busy);
+ const f = c.pane.file orelse return c.p.reportFailure(c.id, "Zerox: only a file pane takes it");
+ if (f.output != null or f.listing != null or f.mini != null) return c.p.reportFailure(c.id, "Zerox: only a file pane takes it");
+ const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Zerox", error.NoPaneSlots);
+ _ = panes.File.zerox(c.p, c.id, free) catch |err| return c.p.reportError(c.id, "Zerox", err);
+ exec.placeNew(c.p, c.id, c.id, free, .doc);
+ }
+};
+
/// Write the pane's text to its file, or to the file its argument names.
pub const Save = struct {
pub const takes_arg = true;
@@ -1122,6 +1266,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:
@@ -1154,6 +1304,7 @@ pub const Del = struct {
pub const takes_arg = true;
pub fn run(c: Ctx) void {
const side = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (@import("edit_cmd.zig").busyOn(c.p, c.id)) return c.p.reportFailure(c.id, "Del: " ++ @import("edit_cmd.zig").e_busy);
// acme's Del asks winclean first (exec.c del): unsaved text is warned
// about once, and the same Del again, nothing edited since, closes.
if (warnModifiedIn(c, .Del, .{ .pane = c.id })) return;
@@ -1213,36 +1364,15 @@ pub const Togglettymode = struct {
};
/// acme's Edit (exec.c `edit`, edit.c): sam's command language over the
-/// pane's body, from a tag, a pane ctl or exec. One undo step; a failure
-/// changes nothing and says why. src/sam_edit.zig has what is left out.
+/// open text panes, from a tag, a pane ctl or exec, starting in the pane's
+/// body. One undo step in each file it changes; a failure changes nothing
+/// and says why. src/edit_cmd.zig runs it; src/sam_edit.zig has what is
+/// left out.
pub const Edit = struct {
pub const takes_arg = true;
pub const requires_arg = true;
pub fn run(c: Ctx) void {
- const sam = @import("sam_edit.zig");
- const ninep_pane = @import("ninep/pane.zig");
- const f = ninep_pane.fileOf(c.pane) orelse return c.p.reportFailure(c.id, "Edit: only a text pane is edited");
- const arena = c.p.scratch.allocator();
- var why: sam.Why = .{};
- var said: [260]u8 = undefined;
- const res = sam.run(arena, f.content, ninep_pane.dotOf(c.pane), f.path, c.arg orelse "", &why) catch |err| switch (err) {
- error.Edit => return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Edit: {s}", .{why.text()}) catch "Edit: failed"),
- error.OutOfMemory => return c.p.reportError(c.id, "Edit", err),
- };
- if (res.undo != 0) {
- for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(c.p, c.pane) else panes.File.redo(c.p, c.pane);
- return;
- }
- if (res.ops.len > 0) {
- const new = sam.apply(c.p.gpa, f.content, res.ops) catch |err| return c.p.reportError(c.id, "Edit", err);
- // `s/a/a/` changes nothing: no undo step, no new revision.
- if (std.mem.eql(u8, new, f.content)) c.p.gpa.free(new) else {
- panes.File.pushUndo(c.p, c.pane);
- panes.File.setContent(c.p, f, new);
- }
- }
- ninep_pane.setDot(c.pane, sam.moveDot(res.dot, res.ops));
- if (res.printed.len > 0) _ = ninep_pane.appendErrors(c.p, c.id, res.printed);
+ @import("edit_cmd.zig").run(c.p, c.id, c.arg orelse "");
}
};
@@ -1253,6 +1383,7 @@ pub const Edit = struct {
/// limits.undo_max steps (256).
pub const Undo = struct {
pub fn run(c: Ctx) void {
+ if (@import("edit_cmd.zig").busyOn(c.p, c.id)) return c.p.reportFailure(c.id, "Undo: " ++ @import("edit_cmd.zig").e_busy);
if (c.pane.file) |f| if (f.history.undo_len == 0) return c.p.setMessage(c.id, "Undo: nothing to undo");
edit.doUndo(c.p, &c.pane.body);
}
@@ -1261,6 +1392,7 @@ pub const Undo = struct {
/// Redo the last undone edit.
pub const Redo = struct {
pub fn run(c: Ctx) void {
+ if (@import("edit_cmd.zig").busyOn(c.p, c.id)) return c.p.reportFailure(c.id, "Redo: " ++ @import("edit_cmd.zig").e_busy);
if (c.pane.file) |f| if (f.history.redo_len == 0) return c.p.setMessage(c.id, "Redo: nothing to redo");
edit.doRedo(c.p, &c.pane.body);
}
@@ -1502,7 +1634,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);
}
};
@@ -2082,7 +2214,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/builtins_json.zig b/src/builtins_json.zig
new file mode 100644
index 00000000..7f998388
--- /dev/null
+++ b/src/builtins_json.zig
@@ -0,0 +1,97 @@
+//! Every builtin as JSON, for the docs' glossary (docs/typ/builtins-tty.json
+//! and builtins-gui.json, a build's list each; style.typ merges them):
+//! the rows the `Help` builtin draws (pardes.builtin_rows: the leader path
+//! and the other shortcuts) with what /commands says of each word (its doc
+//! comment's first sentence, or a setting's doc). One source, so the
+//! glossary and Help cannot drift; the test below fails when the committed
+//! copy is stale, and `zig build builtins-json` rewrites it.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const builtins = @import("builtins.zig");
+const config = @import("config.zig");
+const ctl = @import("ninep/ctl.zig");
+
+fn str(w: *std.Io.Writer, s: []const u8) !void {
+ try w.writeByte('"');
+ for (s) |c| switch (c) {
+ '"' => try w.writeAll("\\\""),
+ '\\' => try w.writeAll("\\\\"),
+ '\n' => try w.writeAll("\\n"),
+ 0...9, 11...0x1f => try w.print("\\u{x:0>4}", .{c}),
+ else => try w.writeByte(c),
+ };
+ try w.writeByte('"');
+}
+
+const Entry = struct { name: []const u8, row: usize };
+
+fn lessName(_: void, a: Entry, b: Entry) bool {
+ return std.mem.lessThan(u8, a.name, b.name);
+}
+
+pub fn write(w: *std.Io.Writer) !void {
+ var order: [pardes.builtin_rows.len]Entry = undefined;
+ for (pardes.builtin_rows, 0..) |row, i| order[i] = .{ .name = @tagName(row.cmd), .row = i };
+ std.mem.sort(Entry, &order, {}, lessName);
+ try w.writeAll("[\n");
+ for (order, 0..) |e, n| {
+ const row = pardes.builtin_rows[e.row];
+ const b = row.cmd;
+ const name = @tagName(b);
+ try w.writeAll(" {\"name\": ");
+ try str(w, name);
+ try w.writeAll(", \"leader\": ");
+ if (row.path) |p| {
+ try w.writeAll("\"SPC");
+ for (p) |c| try w.print(" {c}", .{c});
+ try w.writeByte('"');
+ } else try w.writeAll("null");
+ // Help's line ends in the other shortcuts, after the name's column.
+ try w.writeAll(", \"also\": [");
+ const at = std.mem.indexOf(u8, row.line, name).? + name.len;
+ const rest = std.mem.trim(u8, row.line[at..], " ");
+ var parts = std.mem.splitSequence(u8, rest, ", ");
+ var first = true;
+ while (parts.next()) |part| if (part.len > 0) {
+ if (!first) try w.writeAll(", ");
+ first = false;
+ try str(w, part);
+ };
+ try w.writeAll("], \"arg\": ");
+ try w.writeAll(if (builtins.registry.takesArg(b)) "true" else "false");
+ try w.writeAll(", \"scope\": ");
+ try str(w, if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane");
+ const setting = config.Runtime.find(name);
+ try w.writeAll(", \"setting\": ");
+ try w.writeAll(if (setting != null) "true" else "false");
+ try w.writeAll(", \"choices\": ");
+ if (setting) |s| if (config.Runtime.choices(s.action)) |v| try str(w, v) else try w.writeAll("null") else try w.writeAll("null");
+ var buf: [2048]u8 = undefined;
+ const doc = if (setting) |s| s.doc else ctl.summaryOf(name, &buf);
+ try w.writeAll(", \"doc\": ");
+ try str(w, doc);
+ try w.writeAll(if (n + 1 < order.len) "},\n" else "}\n");
+ }
+ try w.writeAll("]\n");
+}
+
+test "docs/typ/builtins-<platform>.json is what the registry says now" {
+ // a terminal build checks its file, a window build its own
+ const file = switch (pardes.platform) {
+ .tty => "builtins-tty.json",
+ .gui => "builtins-gui.json",
+ else => return error.SkipZigTest,
+ };
+ const gpa = std.testing.allocator;
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try write(&out.writer);
+ var dir = try std.Io.Dir.cwd().openDir(std.testing.io, @import("doc_typ").dir, .{});
+ defer dir.close(std.testing.io);
+ const committed = try dir.readFileAlloc(std.testing.io, file, gpa, .limited(1 << 20));
+ defer gpa.free(committed);
+ if (!std.mem.eql(u8, committed, out.written())) {
+ std.debug.print("docs/typ/{s} is stale: run `zig build builtins-json -Dplatform={s} --prefix <dir>`\n", .{ file, @tagName(pardes.platform) });
+ return error.StaleBuiltinsJson;
+ }
+}
diff --git a/src/config.zig b/src/config.zig
index e9595a2a..d6ea7358 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -58,6 +58,11 @@ pub const leader_path = paths: {
.Repl = null,
.Kill = null,
.Exit = "q",
+ .Get = null,
+ .Putall = null,
+ .Incl = null,
+ .Tab = null,
+ .Zerox = null,
.ClipYank = "y",
.ClipYankMain = "Y",
.ClipPaste = "p",
@@ -81,9 +86,11 @@ pub const leader_path = paths: {
.Placement = "cp",
.BootShell = null,
.LookWord = null,
+ .DirLook = null,
.Pager = null,
.PagerColor = null,
.TermImages = null,
+ .JumpScope = null,
.Debug = "td",
.Msg = null,
.Colors = "tc",
@@ -296,6 +303,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 = " ";
@@ -394,7 +403,15 @@ pub fn defaultShell() []const u8 {
// Minimum file gutter width, including the space after the line number.
pub const PREFIX_W: u16 = 5;
+/// acme's Incl's C defaults: where a look at `stdio.h` finds it.
+pub const default_incl = [_][]const u8{ "/usr/include", "/usr/local/include" };
+
pub const tab_width: u16 = 4;
+/// The width a tab is drawn and counted at now: `Tab N` (Runtime.tab) sets
+/// it, a new session starts it at tab_width. ponytail: one for the process,
+/// as the session's setting is one; per pane would mean handing a pane to
+/// every width function (File.graphemeDisplayWidth and its ~130 callers).
+pub var tab_now: u16 = tab_width;
comptime {
if (tab_width == 0) @compileError("config.tab_width must be greater than zero");
}
@@ -774,6 +791,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.
@@ -785,15 +805,24 @@ pub const Runtime = struct {
/// (yazi's previews): `real`, as pixels where the shell can, or
/// `petscii`, as glyph art. The Petscii builtin flips one pane.
term_images: TermImages = .real,
+ /// What Back and Forward keep to (Pardes.jumpBy): `file`, the places in
+ /// the focused pane's file first, other files only once those run out
+ /// that way; `all`, every place in the order it was jumped to.
+ jump_scope: JumpScope = .file,
+ /// acme's Tab: a tab's width in cells, in every pane (config.tab_now
+ /// is what the drawing and the column arithmetic read).
+ tab: u16 = tab_width,
pub const ShaderAnimation = enum { off, on, always };
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 };
+ pub const JumpScope = enum { file, all };
/// 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, jump_scope };
/// 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
@@ -980,6 +1009,7 @@ pub const Runtime = struct {
/// A percentage, 0 through 100.
inactive_dim,
grip_width,
+ tab,
transition: animation.Transition,
scene: Scene,
/// A Shadertoy file into the post chain or out of it; `off` empties
@@ -1058,9 +1088,12 @@ 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." },
+ .{ .word = "JumpScope", .action = .{ .choice = .jump_scope }, .doc = "What Back and Forward keep to: file, the focused pane's file until it runs out that way, or all, every place in order." },
+ .{ .word = "Tab", .action = .tab, .doc = "A tab's width in cells, 1 through 16, in every pane; bare, it says the width." },
};
pub fn find(name: []const u8) ?Setting {
@@ -1261,6 +1294,12 @@ pub const Runtime = struct {
if (percent < 50 or percent > 300) return false;
state.grip_width = percent;
},
+ .tab => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ const width = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (width < 1 or width > 16) return false;
+ state.tab = width;
+ },
.theme, .font => return false,
}
return true;
@@ -1296,6 +1335,7 @@ pub const Runtime = struct {
return choices(action) orelse switch (action) {
.inactive_dim, .window_opacity, .window_blur => "0-100 (a percentage)",
.grip_width => "50-300 (a percentage)",
+ .tab => "1-16 (cells)",
.tagline_size => std.fmt.comptimePrint("{d}-{d} (a percentage)", .{ tagline_percent_min, tagline_percent_max }),
.message_ms => std.fmt.comptimePrint("0-{d} (milliseconds)", .{message_ms_max}),
.shell => "a path of at most 255 bytes; bare, $SHELL, else /bin/sh",
@@ -1458,6 +1498,7 @@ pub const Runtime = struct {
try out.print("# {s} unsupported\n", .{setting.word}),
.motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }),
.inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
+ .tab => try out.print("{s} {d}\n", .{ setting.word, state.tab }),
.grip_width => if (setting.enabled(context.capabilities))
try out.print("{s} {d}\n", .{ setting.word, state.grip_width })
else
@@ -1745,13 +1786,16 @@ pub const Runtime = struct {
.placement => "pardes",
.boot_shell => "replace",
.look_word => "list",
+ .dir_look => "terminal",
.pager => "off",
.term_images => "petscii",
+ .jump_scope => "all",
},
.lift => "rim",
.motion => "playful",
.inactive_dim => "35",
.grip_width => "200",
+ .tab => "8",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1817,13 +1861,16 @@ 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",
+ .jump_scope => "JumpScope all\n",
},
.lift => "Lift rim\n",
.motion => "Motion playful\n",
.inactive_dim => "InactiveDim 35\n",
.grip_width => "GripWidth 200\n",
+ .tab => "Tab 8\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
@@ -2101,9 +2148,16 @@ const doc_typ = struct {
}
}
+ /// The other build's builtins (docs/typ/builtins-*.json), so the docs
+ /// may name a window-only word in a terminal build's test, and back.
+ var other_build: []const u8 = "";
+
fn word(w: []const u8) bool {
const name = w[0 .. std.mem.indexOfScalar(u8, w, '+') orelse w.len];
- return std.meta.stringToEnum(Builtin, name) != null;
+ if (std.meta.stringToEnum(Builtin, name) != null) return true;
+ var buf: [96]u8 = undefined;
+ const needle = std.fmt.bufPrint(&buf, "{{\"name\": \"{s}\",", .{name}) catch return false;
+ return std.mem.indexOf(u8, other_build, needle) != null;
}
fn chord(tok: []const u8) ?Chord {
@@ -2181,7 +2235,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;
}
@@ -2195,6 +2249,10 @@ test "docs/typ names only words, keys and default tags that exist" {
var dir = try std.Io.Dir.cwd().openDir(io, @import("doc_typ").dir, .{ .iterate = true });
defer dir.close(io);
var found: [3]std.ArrayList([]const u8) = @splat(.empty);
+ for ([_][]const u8{ "builtins-tty.json", "builtins-gui.json" }) |f| {
+ const text = dir.readFileAlloc(io, f, gpa, .limited(1 << 20)) catch continue;
+ doc_typ.other_build = try std.mem.concat(gpa, u8, &.{ doc_typ.other_build, text });
+ }
var it = dir.iterate();
while (try it.next(io)) |entry| if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".typ")) {
const source = try dir.readFileAlloc(io, entry.name, gpa, .limited(1 << 20));
@@ -2331,3 +2389,61 @@ test "the tutor names only words, keys, paths and default tags that exist" {
}
}
}
+
+test "reference.typ's #fstree is the tree the session serves, file for file" {
+ const tree = @import("ninep/tree.zig");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const gpa = arena.allocator();
+ const io = std.testing.io;
+ var dir = try std.Io.Dir.cwd().openDir(io, @import("doc_typ").dir, .{});
+ defer dir.close(io);
+ const source = try dir.readFileAlloc(io, "reference.typ", gpa, .limited(1 << 20));
+ const open = "#fstree(```";
+ const start = (std.mem.indexOf(u8, source, open) orelse return error.NoFsTree) + open.len;
+ const end = std.mem.indexOfPos(u8, source, start, "```)") orelse return error.NoFsTree;
+
+ // what the reference draws: a path per line, from its indent
+ var drawn: std.StringArrayHashMapUnmanaged(void) = .empty;
+ var stack: [8][]const u8 = undefined;
+ var lines = std.mem.tokenizeScalar(u8, source[start..end], '\n');
+ while (lines.next()) |line| {
+ const body = std.mem.trimStart(u8, line, " ");
+ if (body.len == 0) continue;
+ const depth = (line.len - body.len) / 2;
+ const name = body[0 .. std.mem.indexOfScalar(u8, body, ' ') orelse body.len];
+ stack[depth] = name;
+ var path: std.ArrayList(u8) = .empty;
+ for (stack[0 .. depth + 1]) |part| try path.appendSlice(gpa, part);
+ try drawn.put(gpa, path.items, {});
+ }
+
+ // what the session serves: tree.zig's files, and fs.zig's os/ and src/
+ var served: std.StringArrayHashMapUnmanaged(void) = .empty;
+ for (std.enums.values(tree.TopFile)) |f| switch (f) {
+ .root => {},
+ .new => try served.put(gpa, "pane/new", {}),
+ else => try served.put(gpa, if (f.dir()) try std.fmt.allocPrint(gpa, "{s}/", .{f.fileName()}) else f.fileName(), {}),
+ };
+ try served.put(gpa, "os/", {});
+ try served.put(gpa, "src/", {});
+ try served.put(gpa, "pane/<n>/", {});
+ for (std.enums.values(tree.PaneFile)) |f| if (f != .dir) {
+ const under = if (f.inPty() and f != .pty) "pane/<n>/pty/" else "pane/<n>/";
+ try served.put(gpa, try std.fmt.allocPrint(gpa, "{s}{s}{s}", .{ under, f.fileName(), if (f.isDir()) "/" else "" }), {});
+ };
+ try served.put(gpa, "col/<n>/", {});
+ for (std.enums.values(tree.ColFile)) |f| if (f != .dir)
+ try served.put(gpa, try std.fmt.allocPrint(gpa, "col/<n>/{s}", .{f.fileName()}), {});
+
+ var drift = false;
+ for (served.keys()) |k| if (!drawn.contains(k)) {
+ std.debug.print("reference.typ's tree lacks {s}\n", .{k});
+ drift = true;
+ };
+ for (drawn.keys()) |k| if (!served.contains(k)) {
+ std.debug.print("reference.typ's tree has {s}, which the session does not serve\n", .{k});
+ drift = true;
+ };
+ if (drift) return error.FsTreeDrift;
+}
diff --git a/src/draw.zig b/src/draw.zig
index 0ea0b35e..9fcbb15f 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -225,7 +225,15 @@ fn glideCursor(p: *Pardes, s: *Surface) void {
s.chrome.cursor_alpha = 1;
s.chrome.cursor_idle_ms = std.math.maxInt(u32);
if (!builtins.capabilities.lift) return;
- const box = focusedCursorBox(p, s) orelse return;
+ const box = focusedCursorBox(p, s) orelse {
+ // No cursor drawn (a PDF or picture took focus, a program hid its
+ // own): a glide under way has nowhere to land, and its springs,
+ // never stepped again, held continuous() and the frames on for
+ // ever. It stops; the next cursor lands where it is.
+ p.cursor_glide = .{};
+ p.cursor_crossing = false;
+ return;
+ };
const motion = animation.Motion.of(p.settings.motion);
var snap = if (s.cursor) |cursor| cursor.bar else false;
var frame: PaneFrame = .{};
@@ -2341,6 +2349,35 @@ test "focus moved to another column glides from where the cursor was drawn" {
try std.testing.expect(!p.cursor_glide.moving() and !p.cursor_crossing);
}
+test "focus moving to a pane with no cursor while the cursor glides lets the session go idle" {
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 });
+ defer p.deinit();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.settings.motion = .smooth;
+ try twoColumns(p, &arena);
+ p.active = 0;
+ p.advance(3 * std.time.ns_per_s);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ p.active = 1;
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(p.cursor_glide.moving());
+ // Mid-glide, the pane focused draws no cursor (as a PDF pane does).
+ p.panes[1].?.body.mode = .tty;
+ p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?25l" } });
+ p.advance(3 * std.time.ns_per_s + 20 * std.time.ns_per_ms);
+ _ = arena.reset(.retain_capacity);
+ const hidden = try p.render(arena.allocator());
+ try std.testing.expect(hidden.cursor == null);
+ p.advance(5 * std.time.ns_per_s);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.cursor_glide.moving() and !p.continuous());
+}
+
test "a pane focus reaches brightens on the same spring the one it leaves dims on, and Motion off snaps" {
if (!builtins.capabilities.lift) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
diff --git a/src/dump.zig b/src/dump.zig
index 84321457..201f5bf2 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -232,6 +232,9 @@ pub const File = struct {
/// Its dot, q0 and q1 in bytes, as acme's dump keeps a window's; null
/// in older dumps.
dot: ?[2]u32 = null,
+ /// A Zerox twin: the index, in `panes`, of the earlier pane whose
+ /// buffer it shares, its text kept there once; null for none.
+ twin_of: ?u32 = null,
};
pub const ImagePalette = enum {
@@ -302,6 +305,8 @@ pub const State = struct {
/// config are kept on their own, and the font is the frontend's. Absent
/// in older dumps.
settings: []const []const u8 = &.{},
+ /// Incl's directories, in order; null in older dumps (the defaults).
+ incl: ?[]const []const u8 = null,
};
/// A header is a text like any other: it may hold newlines, but not a
@@ -765,6 +770,22 @@ pub fn dumpState(p: *Pardes) !void {
dump_panes[panes_len] = dp;
panes_len += 1;
}
+ // Zerox's twins: the text once, in the first; each other names it.
+ for (p.panes, 0..) |slot, id| {
+ const f = (slot orelse continue).file orelse continue;
+ if (f.twin == 0) continue;
+ const i = slot_to_pane[id] orelse continue;
+ for (p.panes[0..id], 0..) |earlier, j_id| {
+ const of = (earlier orelse continue).file orelse continue;
+ if (of.twin != f.twin) continue;
+ const j = slot_to_pane[j_id] orelse break;
+ if (dump_panes[i].file) |*df| {
+ df.twin_of = @intCast(j);
+ df.content_b64 = "";
+ }
+ break;
+ }
+ }
var columns: [MAX_COLS]Column = undefined;
var columns_len: usize = 0;
@@ -798,6 +819,7 @@ pub fn dumpState(p: *Pardes) !void {
.panes = dump_panes[0..panes_len],
.mounts = mounts[0..p.fs.mounts.items.len],
.settings = try changedSettings(p, arena),
+ .incl = p.incl.items,
};
try validate(state);
var out: std.Io.Writer.Allocating = .init(p.gpa);
@@ -842,6 +864,7 @@ fn applySettings(p: *Pardes, lines: []const []const u8) void {
}
const setting = config.Runtime.find(line[0 .. sp orelse line.len]) orelse continue;
_ = p.settings.apply(setting, if (sp) |s| line[s + 1 ..] else null);
+ if (setting.action == .tab) config.tab_now = p.settings.tab;
}
}
@@ -902,6 +925,14 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
p.locations_config = st.locations_config;
p.settings.tree_context_tag_style = st.tree_context_tag_style;
applySettings(p, st.settings);
+ if (st.incl) |dirs| {
+ for (p.incl.items) |dir| p.gpa.free(dir);
+ p.incl.clearRetainingCapacity();
+ for (dirs) |dir| if (dir.len > 0 and dir[0] == '/' and std.mem.indexOfScalar(u8, dir, 0) == null) {
+ const copy = p.gpa.dupe(u8, dir) catch continue;
+ p.incl.append(p.gpa, copy) catch p.gpa.free(copy);
+ };
+ }
// A dump naming a ThemeFile asks the host to load it again: until it
// has, and for good if it fails, the theme this session showed goes on
// (a failed ThemeFile changes nothing, a Restore's included), unless
@@ -952,7 +983,12 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
if (t.petscii and pardes.panes.terminal.petscii(restored) == false) pardes.panes.terminal.togglePetscii(restored);
break :terminal restored;
},
- .file => try pardes.panes.File.restore(p, i, src),
+ .file => file: {
+ const restored = try pardes.panes.File.restore(p, i, src);
+ // A twin: on the earlier pane's buffer again.
+ if (src.file.?.twin_of) |j| if (j < i) pardes.panes.File.relink(p, j, i, src.file.?.dot);
+ break :file restored;
+ },
.image => restore_image: {
const im = src.image.?;
if (pardes.panes.Pdf.isPath(im.path)) {
@@ -1028,14 +1064,17 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor
defer p.deinit();
try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes"));
try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "JumpScope all"));
try dumpState(p);
try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"Placement pardes\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"JumpScope all\"") != null);
// What is left at its default is not written.
try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"BootShell keep\"") == null);
const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
defer restored.deinit();
try std.testing.expectEqual(config.Runtime.Placement.pardes, restored.settings.placement);
try std.testing.expect(!restored.settings.verbose);
+ try std.testing.expectEqual(config.Runtime.JumpScope.all, restored.settings.jump_scope);
}
test "a session with every column it may hold dumps and restores them all" {
diff --git a/src/edit.zig b/src/edit.zig
index 1305d66f..b2909d87 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -500,7 +500,7 @@ pub fn setEditTextEnd(p: *Pardes, t: *Text, new: []u8, known_end: ?modal.Cursor)
// caller reads the new text back, as a body's is kept.
defer retireEditText(p, new);
const pane = t.pane() orelse return;
- const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return;
+ const prefix = tagline.pathPrefix(p, p.scratch.allocator(), pane) catch return;
if (!std.mem.startsWith(u8, new, prefix)) {
t.refused = true;
return;
@@ -899,11 +899,18 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
t.show();
return;
}
- const indent = modal.newlineIndentWidth(line, c.col);
+ // The line's own indent, as it is (tabs stay tabs), and a
+ // level more after an opening delimiter: a tab where the
+ // indent is tabs, else spaces.
+ const lead = modal.indentText(line);
+ const more = modal.newlineIndentWidth(line, c.col) - lead.len;
+ const tabbed = std.mem.indexOfScalar(u8, lead, '\t') != null;
+ const indent = lead.len + (if (more == 0) 0 else if (tabbed) 1 else more);
const arena = p.scratch.allocator();
const block = arena.alloc(u8, 1 + indent) catch return;
block[0] = '\n';
- @memset(block[1..], ' ');
+ @memcpy(block[1..][0..lead.len], lead);
+ @memset(block[1 + lead.len ..], if (tabbed) '\t' else ' ');
// a tag keeps its blanks: they part its words, and the last
// may be its read-only prefix's
const kept = if (t.what == .body) std.mem.trimEnd(u8, before, " \t").len else c.col;
@@ -2136,6 +2143,41 @@ test "insert newline adds one indent level after a closing call" {
try std.testing.expectEqual(@as(i32, 8), pane.body.cur_col);
}
+test "Enter in insert mode keeps a tab-indented line's tabs, as o and O do" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("\t\tvalue = 1;\n\tif (x) {\n");
+ pane.body.mode = .insert;
+ pane.body.cur_col = 12;
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqualStrings("\t\tvalue = 1;\n\t\t\n\tif (x) {\n", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 2), pane.body.cur_col);
+ // After an opening brace, a level more: a tab, as the indent is tabs.
+ pane.body.cur_row = 2;
+ pane.body.cur_col = 9;
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqualStrings("\t\tvalue = 1;\n\t\t\n\tif (x) {\n\t\t\n", pane.file.?.content);
+ // o opens a line with the indent as it is.
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ pane.body.cur_row = 0;
+ p.update(.{ .key = .{ .cp = 'o' } });
+ try std.testing.expect(std.mem.startsWith(u8, pane.file.?.content, "\t\tvalue = 1;\n\t\t\n"));
+}
+
+test "insert on a terminal's text says it edits a copy the program's next drawing replaces" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ if (!pane.isTerminal()) return error.SkipZigTest;
+ pane.body.mode = .normal;
+ p.update(.{ .key = .{ .cp = 'i' } });
+ p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "a copy of the terminal's text") != null);
+}
+
test "insert and normal modes edit complete Unicode graphemes" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
diff --git a/src/edit_cmd.zig b/src/edit_cmd.zig
new file mode 100644
index 00000000..83cc7e4f
--- /dev/null
+++ b/src/edit_cmd.zig
@@ -0,0 +1,797 @@
+//! acme's Edit in the editor (exec.c `edit`, edit.c editcmd): the panes an
+//! Edit sees and what it does to them. src/sam_edit.zig runs the commands
+//! over the open text panes; here they are handed over, and what came of
+//! them applied: each file's changes as one undo step, the dots, `w`'s
+//! writes, the names `e` and `f` gave, `D`'s closes, and what was printed,
+//! to +Errors.
+//!
+//! An Edit with `<`, `|` or `>` runs their commands off the loop, as the
+//! `|` key's filter does (selection_pipe.zig, Pipe.zig): the commands go out
+//! as one pipe request, each in its file's directory through the session's
+//! Shell, and the Edit is applied when they have all answered -- or not at
+//! all, when one fails or a file it changes was edited meanwhile. One Edit
+//! waits at a time; a 9P write that ran it is answered when it is done
+//! (9p_io, `fs.edit_hold`), the connection serving on meanwhile; a flush
+//! of that write stops the commands and changes nothing.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const sam = @import("sam_edit.zig");
+const panes = @import("panes.zig");
+const layout = @import("layout.zig");
+const look = @import("look.zig");
+const builtins = @import("builtins.zig");
+const filesystem = @import("fs.zig");
+const selection_pipe = @import("selection_pipe.zig");
+const Pipe = @import("Pipe.zig");
+const ninep_pane = @import("ninep/pane.zig");
+const Pardes = pardes.Pardes;
+
+/// An Edit whose commands are running: what it came to, and the request
+/// for them, all in `arena`.
+pub const Pending = struct {
+ id: u32 = 0,
+ arena: std.heap.ArenaAllocator,
+ /// The pane the Edit ran from, which hears how it went.
+ origin: usize,
+ origin_serial: u32,
+ res: sam.Result = undefined,
+ /// Each file's pane serial and text revision when the Edit read it.
+ serials: []u32 = &.{},
+ revisions: []u32 = &.{},
+ request: selection_pipe.Request = undefined,
+
+ pub fn deinit(pd: *Pending, gpa: std.mem.Allocator) void {
+ pd.arena.deinit();
+ gpa.destroy(pd);
+ }
+};
+
+/// What `w` took, written by the `edit_write` effect (pardes.zig perform).
+pub const Write = struct {
+ pane: u8,
+ serial: u32,
+ path: []u8,
+ bytes: []u8,
+ /// The pane comes clean if its text is still this revision.
+ clean: ?u32 = null,
+ /// A scratch written whole is that file from then on (exec.saveTo).
+ promote: bool = false,
+
+ pub fn deinit(w: *Write, gpa: std.mem.Allocator) void {
+ gpa.free(w.path);
+ gpa.free(w.bytes);
+ }
+};
+
+/// `B`, `e` and `r` through the editor.
+const World = struct {
+ p: *Pardes,
+ from: usize,
+ arena: std.mem.Allocator,
+
+ fn check(ctx: *anyopaque, path: []const u8, why: *sam.Why) bool {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ for (w.p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = ninep_pane.fileOf(pane) orelse continue;
+ if (std.mem.eql(u8, f.path, path)) return true;
+ }
+ if (comptime !pardes.hosted) return true;
+ const local = filesystem.localPath(path) orelse return true;
+ if (@import("exec.zig").isDirectory(local)) return said(bool, why, "B: {s} is a directory", .{path}) orelse false;
+ var buf: [4096]u8 = undefined;
+ if (filesystem.resolveOs(local, &buf) == null) return said(bool, why, "B: {s}: no such file", .{path}) orelse false;
+ return true;
+ }
+
+ fn refuseGet(ctx: *anyopaque, id: usize, why: *sam.Why) bool {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ const words = @import("ninep/ctl.zig").getRefused(w.p, w.p.panes[id] orelse return false, "e") orelse return false;
+ return said(bool, why, "{s}", .{words}) orelse true;
+ }
+
+ fn open(ctx: *anyopaque, path: []const u8, why: *sam.Why) ?sam.File {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ const p = w.p;
+ if (comptime pardes.hosted) if (filesystem.localPath(path)) |local| if (@import("exec.zig").isDirectory(local))
+ return said(sam.File, why, "B: {s} is a directory", .{path});
+ // A look opens it, or finds it open, where a look would; the
+ // keyboard stays where the Edit ran.
+ const was = p.active;
+ look.lookAt(p, w.from, path);
+ if (p.panes[was] != null) p.active = was;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const f = ninep_pane.fileOf(pane) orelse continue;
+ if (!std.mem.eql(u8, f.path, path)) continue;
+ return fileOf(w.arena, pane, id) catch said(sam.File, why, "B: out of memory", .{});
+ }
+ return said(sam.File, why, "B: cannot open {s}", .{path});
+ }
+
+ fn read(ctx: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *sam.Why) ?[]const u8 {
+ const w: *World = @ptrCast(@alignCast(ctx));
+ if (comptime pardes.hosted) if (filesystem.localPath(path)) |local| if (@import("exec.zig").isDirectory(local))
+ return said([]const u8, why, "{s} is a directory", .{path});
+ const bytes = filesystem.read(w.p, path) catch |err| {
+ // In the words Get says them (builtins.Get).
+ var buf: [128]u8 = undefined;
+ const words = if (err == error.FileNotFound) "no such file" else pardes.Messages.errorWords(err, &buf);
+ return said([]const u8, why, "can't open {s}: {s}", .{ path, words });
+ };
+ defer w.p.gpa.free(bytes);
+ return arena.dupe(u8, bytes) catch return said([]const u8, why, "can't read {s}: out of memory", .{path});
+ }
+
+ fn said(comptime T: type, why: *sam.Why, comptime fmt: []const u8, args: anytype) ?T {
+ why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0;
+ return null;
+ }
+};
+
+/// What the Edit sees of a pane. Its name is a copy in `arena`: the pane
+/// may be gone by the time a running Edit's end says it. Its text is the
+/// pane's own, read only while the Edit runs its commands' parse.
+fn fileOf(arena: std.mem.Allocator, pane: *panes.Pane, id: usize) !sam.File {
+ const f = ninep_pane.fileOf(pane).?;
+ return .{
+ .name = try arena.dupe(u8, f.path),
+ .text = f.content,
+ .dot = ninep_pane.dotOf(pane),
+ .dirty = ninep_pane.dirtyOf(pane),
+ .scratch = f.output != null,
+ .id = id,
+ };
+}
+
+/// `Edit command` from pane `id`, a text pane.
+pub fn run(p: *Pardes, id: usize, command: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (ninep_pane.fileOf(pane) == null) return p.reportFailure(id, "Edit: only a text pane is edited");
+ const pd = p.gpa.create(Pending) catch |err| return p.reportError(id, "Edit", err);
+ pd.* = .{ .arena = .init(p.gpa), .origin = id, .origin_serial = pane.serial };
+ var keep = false;
+ defer if (!keep) pd.deinit(p.gpa);
+ const arena = pd.arena.allocator();
+
+ // Every text pane in the order they were made, as /index lists them,
+ // so X goes the same way whatever the layout; the Edit starts in its own.
+ var files: std.ArrayList(sam.File) = .empty;
+ for (p.panes, 0..) |slot, i| {
+ const q = slot orelse continue;
+ if (q.file == null) continue;
+ files.append(arena, fileOf(arena, q, i) catch |err| return p.reportError(id, "Edit", err)) catch |err| return p.reportError(id, "Edit", err);
+ }
+ std.mem.sort(sam.File, files.items, p, struct {
+ fn before(core: *Pardes, a: sam.File, b: sam.File) bool {
+ return core.panes[a.id].?.serial < core.panes[b.id].?.serial;
+ }
+ }.before);
+ const cur = for (files.items, 0..) |f, i| {
+ if (f.id == id) break i;
+ } else unreachable;
+
+ var world: World = .{ .p = p, .from = id, .arena = arena };
+ var why: sam.Why = .{};
+ var text: [260]u8 = undefined;
+ const res = sam.run(arena, files.items, cur, command, .{ .ctx = &world, .check = World.check, .open = World.open, .read = World.read, .refuseGet = World.refuseGet }, &why) catch |err| switch (err) {
+ error.Edit => return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: {s}", .{why.text()}) catch "Edit: failed"),
+ error.OutOfMemory => return p.reportError(id, "Edit", err),
+ };
+ // acme's Edit blocks the editor while its commands run; here the editor
+ // goes on, and so does any Edit that runs no command and leaves alone
+ // the files the running one will change. A second Edit with commands,
+ // or one that would change such a file, is refused, busy.
+ if (p.pipe.edit_run) |running| {
+ if (res.jobs.len > 0) return p.reportFailure(id, "Edit: busy: another Edit's commands are running; one Edit runs commands at a time");
+ for (res.files, 0..) |f, i| {
+ const touches = if (res.undo != 0) f.id == id else changes(f) or for (res.writes) |w| {
+ if (w.file == i) break true;
+ } else false;
+ if (!touches) continue;
+ const q = p.panes[f.id] orelse continue;
+ for (running.res.files, 0..) |g, j| if (g.id == f.id and running.serials[j] == q.serial and changes(g))
+ return p.reportFailure(id, std.fmt.bufPrint(&text, "Edit: busy: {s}: an Edit's commands are running on it", .{f.name}) catch "Edit: busy");
+ }
+ }
+ if (res.undo != 0) {
+ for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(p, pane) else panes.File.redo(p, pane);
+ return;
+ }
+ pd.res = res;
+ // What each file was, to know at the end that it still is.
+ pd.serials = arena.alloc(u32, res.files.len) catch |err| return p.reportError(id, "Edit", err);
+ pd.revisions = arena.alloc(u32, res.files.len) catch |err| return p.reportError(id, "Edit", err);
+ for (res.files, 0..) |f, i| {
+ const q = p.panes[f.id].?;
+ pd.serials[i] = q.serial;
+ pd.revisions[i] = q.file.?.revision;
+ }
+ if (res.jobs.len == 0) return commit(p, pd, &.{});
+
+ // The commands, as one pipe request: each with its input copied (the
+ // text may change while they run, and the Edit is then refused), in
+ // its file's directory.
+ const n = res.jobs.len;
+ const commands = arena.alloc([]const u8, n) catch |err| return p.reportError(id, "Edit", err);
+ const cwds = arena.alloc([]const u8, n) catch |err| return p.reportError(id, "Edit", err);
+ const inputs = arena.alloc(selection_pipe.Input, n) catch |err| return p.reportError(id, "Edit", err);
+ for (res.jobs, 0..) |job, i| {
+ const f = res.files[job.file];
+ commands[i] = job.command;
+ cwds[i] = arena.dupe(u8, Pardes.paneDir(p.panes[f.id].?)) catch |err| return p.reportError(id, "Edit", err);
+ inputs[i] = .{ .bytes = if (job.c == '<') "" else arena.dupe(u8, f.text[job.q0..job.q1]) catch |err| return p.reportError(id, "Edit", err) };
+ }
+ p.pipe.seq +%= 1;
+ pd.id = p.pipe.seq;
+ pd.request = .{
+ .id = pd.id,
+ .command = commands[0],
+ .cwd = cwds[0],
+ .inputs = inputs,
+ .commands = commands,
+ .cwds = cwds,
+ .shell = arena.dupe(u8, p.shellBin()) catch |err| return p.reportError(id, "Edit", err),
+ .token = selection_pipe.newToken(),
+ // What a pane's command is given: $PARDES_MOUNT, $PARDES_9P,
+ // $PARDES_PID, $winid as the Edit's pane.
+ .env = if (comptime pardes.hosted) @import("host_io.zig").commandEnv(arena, pane.serial, !p.opts.nested) catch &.{} else &.{},
+ };
+ keep = true;
+ p.pipe.edit_run = pd;
+ p.emit(.{ .pipe = .{ .id = pd.id } });
+ // A 9P write that ran it is held, and answered once it is done
+ // (9p_io, fs.edit_hold); the connection serves on meanwhile.
+ if (p.fs.serving) p.fs.edit_started = true;
+}
+
+/// What a pane whose Edit's commands are running refuses to do beside them:
+/// close, undo, redo, Get, Zerox (busyOn).
+pub const e_busy = "busy: an Edit's commands are running on it";
+
+/// Whether pane `id` (or a Zerox twin of it) is one the Edit whose commands
+/// are running will change or run a command on.
+pub fn busyOn(p: *Pardes, id: usize) bool {
+ const pd = p.pipe.edit_run orelse return false;
+ const pane = p.panes[id] orelse return false;
+ const twin = if (pane.file) |f| f.twin else 0;
+ for (pd.res.files, 0..) |f, i| {
+ const used = changes(f) or for (pd.res.jobs) |job| {
+ if (job.file == i) break true;
+ } else false;
+ if (!used) continue;
+ const q = p.panes[f.id] orelse continue;
+ if (q.serial != pd.serials[i]) continue;
+ if (f.id == id) return true;
+ if (twin != 0) if (q.file) |qf| if (qf.twin == twin) return true;
+ }
+ return false;
+}
+
+/// Whether the Edit changes file `f`: its text, its name, or its pane.
+fn changes(f: sam.File) bool {
+ return f.ops.items.len > 0 or f.get != null or f.close or f.renamed;
+}
+
+/// The Edit waiting on its commands, stopped: its write was flushed, or
+/// its connection hung up (9p_io). Its commands are killed, and nothing
+/// changes.
+pub fn cancel(p: *Pardes) void {
+ p.fs.edit_hold = null;
+ const pd = p.pipe.edit_run orelse return;
+ p.pipe.edit_run = null;
+ selection_pipe.stop(pd.request.token);
+ if (p.panes[pd.origin]) |pane| if (pane.serial == pd.origin_serial)
+ p.setMessage(pd.origin, "Edit: interrupted; nothing changed");
+ pd.deinit(p.gpa);
+}
+
+/// The held 9P write that ran the Edit has its answer: done, failed in the
+/// words said on pane `at` when `failed`.
+fn answerHold(p: *Pardes, at: usize, failed: bool) void {
+ const h = if (p.fs.edit_hold) |*held| held else return;
+ h.done = true;
+ p.fs.news = true;
+ if (!failed) return;
+ const words = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0)
+ p.fs.said[0..p.fs.said_len]
+ else if (p.panes[at]) |pane| pane.msg[0..pane.msg_len] else "Edit: failed";
+ const kept = @import("Messages.zig").clip(words, h.said.len);
+ @memcpy(h.said[0..kept.len], kept);
+ h.said_len = @intCast(kept.len);
+ h.errno = @import("ninep/ctl.zig").failureErrno(kept);
+ // The write's err record, as a refusal answered at once has.
+ pardes.ctlfs.events.noteError(p, .{ .tag = h.tag, .op = .write, .node = h.node, .handle = h.handle }, @import("ninep/tree.zig").failText(h.tag, h.errno, h.said[0..h.said_len]));
+}
+
+/// The commands answered (pipe_resp): the Edit applied, or why not.
+pub fn finish(p: *Pardes, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure) void {
+ const pd = p.pipe.edit_run orelse return;
+ p.pipe.edit_run = null;
+ defer pd.deinit(p.gpa);
+ const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active;
+ // Whatever fails from here fails the 9P write that ran it too.
+ const failures = p.fs.failures;
+ defer answerHold(p, at, p.fs.failures != failures);
+ if (success and outputs.len == pd.res.jobs.len) return commit(p, pd, outputs);
+
+ // Which command, in +Errors with its own words; the Edit changed nothing.
+ const job = if (failure) |f| (if (f.index < pd.res.jobs.len) pd.res.jobs[f.index] else pd.res.jobs[0]) else pd.res.jobs[0];
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ defer out.deinit();
+ const w = &out.writer;
+ var line: [256]u8 = undefined;
+ report: {
+ w.print("Edit: {c} {s}\n\n", .{ job.c, job.command }) catch break :report;
+ if (pd.res.jobs.len > 1) if (failure) |f| w.print("command {d} of {d}: ", .{ f.index + 1, pd.res.jobs.len }) catch break :report;
+ const why_at = out.written().len;
+ Pipe.writeFailure(w, failure) catch break :report;
+ const why = std.mem.sliceTo(out.written()[why_at..], '\n');
+ const said = std.fmt.bufPrint(&line, "Edit: {c} {s}: {s}", .{ job.c, job.command[0..@min(job.command.len, 120)], why }) catch "Edit: a command failed";
+ const was = p.active;
+ _ = ninep_pane.appendErrors(p, at, out.written());
+ if (p.panes[was] != null) p.active = was;
+ return p.reportFailure(at, said);
+ }
+ p.reportFailure(at, "Edit: a command failed");
+}
+
+/// Apply what the Edit came to, `outputs` its jobs' answers: refused, all
+/// of it, if a pane it changes is not the one it read any more.
+fn commit(p: *Pardes, pd: *Pending, outputs: []const []const u8) void {
+ const res = pd.res;
+ const at = if (p.panes[pd.origin]) |pane| (if (pane.serial == pd.origin_serial) pd.origin else p.active) else p.active;
+ var text: [320]u8 = undefined;
+ for (res.files, 0..) |f, i| {
+ if (f.ops.items.len == 0 and !f.close and !f.renamed and !f.moved and f.get == null) continue;
+ const pane = p.panes[f.id] orelse return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed");
+ if (pane.serial != pd.serials[i]) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} closed while its commands ran; nothing changed", .{f.name}) catch "Edit: a pane closed");
+ if ((f.ops.items.len > 0 or f.get != null) and pane.file.?.revision != pd.revisions[i])
+ return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: {s} changed while its commands ran; nothing changed", .{f.name}) catch "Edit: a file changed");
+ }
+ // e first, Get's way: read before anything is changed, so one that
+ // cannot be read changes nothing; its own undo step puts the old name
+ // back with the old text, and it leaves the pane clean.
+ for (res.files) |f| {
+ const path = f.get orelse continue;
+ var failed: anyerror = error.Unexpected;
+ const errno = @import("ninep/ctl.zig").getFile(p, p.panes[f.id].?, path, &failed);
+ if (errno != 0) return p.reportFailure(at, std.fmt.bufPrint(&text, "Edit: e {s}: {s}", .{ path, if (errno == @import("ninep/tree.zig").E.NOENT) "no such file" else "cannot be read" }) catch "Edit: e failed");
+ }
+ // `w`'s bytes, written after this step, before anything else is. To
+ // its own file changed on disk since it was read, as Save is: refused
+ // once, said, and written by the next (exec.saveFile); the others go.
+ var refused: std.Io.Writer.Allocating = .init(p.gpa);
+ defer refused.deinit();
+ for (res.writes) |wr| {
+ const f = res.files[wr.file];
+ const pane = p.panes[f.id].?;
+ if (pane.file) |*pf| if (!f.scratch and std.mem.eql(u8, wr.path, pf.path)) if (pf.disk_newer != null) {
+ if (!pf.disk_newer_warned) {
+ pf.disk_newer_warned = true;
+ refused.writer.print("{s}{s}", .{ if (refused.written().len > 0) ", " else "", pf.path }) catch {};
+ continue;
+ }
+ pf.disk_newer = null;
+ };
+ const path = p.gpa.dupe(u8, wr.path) catch |err| return p.reportError(at, "Edit", err);
+ const bytes = p.gpa.dupe(u8, wr.bytes) catch |err| {
+ p.gpa.free(path);
+ return p.reportError(at, "Edit", err);
+ };
+ const own = std.mem.eql(u8, wr.path, pane.file.?.path);
+ p.pipe.writes.append(p.gpa, .{
+ .pane = @intCast(f.id),
+ .serial = pane.serial,
+ .path = path,
+ .bytes = bytes,
+ // Its own name, all of it, and nothing after the w changes it:
+ // clean, as acme's putfile leaves it.
+ .clean = if (own and wr.whole and !f.scratch and f.ops.items.len == 0) pane.file.?.revision else null,
+ .promote = wr.whole and f.scratch,
+ }) catch |err| {
+ p.gpa.free(path);
+ p.gpa.free(bytes);
+ return p.reportError(at, "Edit", err);
+ };
+ }
+ if (res.writes.len > 0) p.emit(.edit_write);
+ for (res.files) |f| {
+ const pane = p.panes[f.id] orelse continue;
+ const pf = ninep_pane.fileOf(pane) orelse continue;
+ const ops = f.ops.items;
+ for (ops) |*o| if (o.job) |j| {
+ o.text = outputs[j];
+ };
+ if (ops.len > 0) {
+ const new = sam.apply(p.gpa, pf.content, ops) catch |err| return p.reportError(at, "Edit", err);
+ // `s/a/a/` changes nothing: no undo step, no new revision.
+ if (std.mem.eql(u8, new, pf.content)) p.gpa.free(new) else {
+ panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, pf, new);
+ }
+ }
+ if (ops.len > 0 or f.moved) ninep_pane.setDot(pane, sam.moveDot(f.dot, ops));
+ if (f.renamed) ninep_pane.nameBuffer(p, f.id, f.name, false) catch |err| p.reportError(f.id, "Edit", err);
+ }
+ // What was printed, `>`'s output in its place among it.
+ if (res.printed.len > 0 or outputs.len > 0) {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(p.gpa);
+ var from: usize = 0;
+ for (res.jobs, 0..) |job, j| {
+ if (job.c != '>') continue;
+ out.appendSlice(p.gpa, res.printed[from..job.at]) catch return;
+ out.appendSlice(p.gpa, outputs[j]) catch return;
+ from = job.at;
+ }
+ out.appendSlice(p.gpa, res.printed[from..]) catch return;
+ // Printed, the keyboard stays where it was: a +Errors made for it
+ // does not take it, so the next Edit lands where this one did.
+ const was = p.active;
+ if (out.items.len > 0) _ = ninep_pane.appendErrors(p, at, out.items);
+ if (p.panes[was] != null) p.active = was;
+ }
+ if (refused.written().len > 0) {
+ var said: [320]u8 = undefined;
+ p.reportFailure(at, std.fmt.bufPrint(&said, "Edit: w: {s} modified on disk since read (w again to overwrite)", .{refused.written()[0..@min(refused.written().len, 200)]}) catch "Edit: w: modified on disk since read (w again to overwrite)");
+ }
+ // D's panes last, each as Del closes one: an edited one is warned about
+ // once, and closes on the next.
+ for (res.files) |f| {
+ if (!f.close) continue;
+ const pane = p.panes[f.id] orelse continue;
+ if (builtins.warnModifiedIn(.{ .p = p, .pane = pane, .id = f.id, .txt = "", .arg = null }, .Del, .{ .pane = f.id })) continue;
+ p.removePane(f.id, null) catch |err| p.reportError(f.id, "Edit", err);
+ }
+}
+
+const testing = std.testing;
+const th = @import("ninep/testing.zig");
+const tree = @import("ninep/tree.zig");
+
+/// The answer of the Edit waiting on its commands: as a host's worker
+/// brings it home, run for real when `real`.
+fn answer(p: *Pardes, outputs: []const []const u8) void {
+ const pd = p.pipe.edit_run.?;
+ p.update(.{ .pipe_resp = .{ .id = pd.id, .success = true, .outputs = outputs } });
+}
+
+fn runReal(p: *Pardes) !void {
+ const pd = p.pipe.edit_run.?;
+ const job = try selection_pipe.Job.copy(testing.allocator, pd.request);
+ defer job.deinit(testing.allocator);
+ var response = selection_pipe.runJob(testing.allocator, testing.io, job);
+ defer response.deinit(testing.allocator);
+ p.update(.{ .pipe_resp = .{ .id = pd.id, .success = response.success, .outputs = response.outputs, .failure = response.failure } });
+}
+
+/// Whether any +Errors holds `needle`.
+fn errorsHas(p: *Pardes, needle: []const u8) bool {
+ for (p.panes) |slot| {
+ const q = slot orelse continue;
+ const qf = q.file orelse continue;
+ const o = qf.output orelse continue;
+ if (std.meta.activeTag(o.from) == .errors and std.mem.indexOf(u8, qf.content, needle) != null) return true;
+ }
+ return false;
+}
+
+fn errorsText(p: *Pardes) ?[]const u8 {
+ for (p.panes) |slot| {
+ const q = slot orelse continue;
+ const qf = q.file orelse continue;
+ const o = qf.output orelse continue;
+ if (std.meta.activeTag(o.from) == .errors) return qf.content;
+ }
+ return null;
+}
+
+test "Edit , | sort runs off the loop, through the session's shell in the file's directory, and is one undo step" {
+ const p = try th.withFile(testing.allocator, "b\na\nc\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Edit , | sort\n").reply.status);
+ // The listener is told to hold the write.
+ try testing.expect(p.fs.edit_started);
+ // Nothing changed yet: the command is out.
+ try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content);
+ const req = p.pipe.pipeRequest(p.pipe.edit_run.?.id).?;
+ try testing.expectEqualStrings("sort", req.commands[0]);
+ try testing.expectEqualStrings("b\na\nc\n", req.inputs[0].bytes);
+ try testing.expectEqualStrings("/", req.cwds[0]);
+ try testing.expectEqualStrings(p.shellBin(), req.shell);
+ // An Edit that would change the file being filtered is busy.
+ try testing.expectEqual(tree.E.BUSY, th.wr(p, tree.Node.of(serial, .ctl), "Edit ,d\n").errno());
+ answer(p, &.{"a\nb\nc\n"});
+ try testing.expect(p.pipe.edit_run == null);
+ try testing.expectEqualStrings("a\nb\nc\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(serial, .ctl), "Undo\n").reply.status);
+ try testing.expectEqualStrings("b\na\nc\n", p.panes[0].?.file.?.content);
+ // Another line in its write would run before its commands answer: the
+ // whole write is refused before any line runs, the one before it too.
+ for ([_][]const u8{ "Edit , | sort\nSave\n", "Msg before\nEdit , | sort\nMsg after\n" }) |two| {
+ const after = th.wr(p, tree.Node.of(serial, .ctl), two);
+ try testing.expectEqual(tree.E.INVAL, after.errno());
+ try testing.expectEqualStrings(@import("ninep/ctl.zig").e_after_edit, after.reply.ename);
+ try testing.expect(p.pipe.edit_run == null and !th.logHas(p, "before"));
+ }
+ try testing.expectEqual(tree.E.INVAL, th.wr(p, @intFromEnum(tree.TopFile.exec), "Msg before\nEdit , | sort\n").errno());
+}
+
+test "the held write asked again is parked again, then answered once, and a flush stops the Edit" {
+ const p = try th.withFile(testing.allocator, "b\na\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ const req: tree.Req = .{ .tag = 7, .op = .write, .node = ctl, .handle = 0, .data = "Edit , | sort\n" };
+ try testing.expectEqual(tree.Status.ok, p.serveFs(req).status);
+ // As 9p_io holds it.
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 7, .node = ctl, .handle = 0, .written = @intCast(req.data.len) };
+ // A retry of its park: still waiting, never run a second time.
+ try testing.expectEqual(tree.Status.again, p.serveFs(req).status);
+ try testing.expect(p.pipe.edit_run != null);
+ answer(p, &.{"a\nb\n"});
+ try testing.expect(p.fs.edit_hold.?.done);
+ const done = p.serveFs(req);
+ try testing.expectEqual(tree.Status.ok, done.status);
+ try testing.expectEqual(@as(u32, @intCast(req.data.len)), done.written);
+ try testing.expect(p.fs.edit_hold == null);
+ try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content);
+ // A failure is the held write's answer, in the Edit's words.
+ _ = p.serveFs(.{ .tag = 8, .op = .write, .node = ctl, .data = "Edit , | false\n" });
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 2, .tag = 8, .node = ctl, .handle = 0, .written = 15 };
+ p.update(.{ .pipe_resp = .{ .id = p.pipe.edit_run.?.id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 1 } } });
+ const failed = filesystem.EditHold.answer(&p.fs);
+ try testing.expectEqual(tree.Status.err, failed.status);
+ try testing.expectEqualStrings("Edit: | false: exit status 1", failed.ename);
+ // Flushed (or hung up): its commands stopped, nothing changed, and the
+ // next Edit is free to run; the stopped one's late answer is no one's.
+ _ = p.serveFs(.{ .tag = 9, .op = .write, .node = ctl, .data = "Edit , | sort -r\n" });
+ const stale = p.pipe.edit_run.?.id;
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 3, .tag = 9, .node = ctl, .handle = 0, .written = 18 };
+ cancel(p);
+ try testing.expect(p.pipe.edit_run == null and p.fs.edit_hold == null);
+ p.update(.{ .pipe_resp = .{ .id = stale, .success = true, .outputs = &.{"b\na\n"} } });
+ try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status);
+}
+
+test "Edit's < | and > with a real shell: a command per address, > printed to +Errors" {
+ if (comptime !pardes.hosted) return error.SkipZigTest;
+ const p = try th.withFile(testing.allocator, "one two\nthree\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit ,x/[a-z]+/ | tr a-z A-Z\n").reply.status);
+ try testing.expectEqual(@as(usize, 3), p.pipe.edit_run.?.request.inputs.len);
+ try runReal(p);
+ try testing.expectEqualStrings("ONE TWO\nTHREE\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(usize, 1), p.panes[0].?.file.?.history.undo_len);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit 2 < printf new\n").reply.status);
+ try runReal(p);
+ try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , > wc -l\n").reply.status);
+ try runReal(p);
+ // The +Errors it opened did not take the keyboard.
+ try testing.expectEqual(@as(usize, 0), p.active);
+ try testing.expectEqualStrings("ONE TWO\nnew", p.panes[0].?.file.?.content);
+ try testing.expectEqualStrings("1", std.mem.trim(u8, errorsText(p).?, " \n"));
+}
+
+test "a failing command changes nothing and says why, its stderr in +Errors" {
+ if (comptime !pardes.hosted) return error.SkipZigTest;
+ const p = try th.withFile(testing.allocator, "keep\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | echo boom >&2; exit 3\n").reply.status);
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 };
+ try runReal(p);
+ try testing.expectEqualStrings("keep\n", p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(usize, 0), p.panes[0].?.file.?.history.undo_len);
+ // The held write's answer is the failure, in its words.
+ try testing.expectEqualStrings("Edit: | echo boom >&2; exit 3: exit status 3", filesystem.EditHold.answer(&p.fs).ename);
+ const report = errorsText(p).?;
+ try testing.expect(std.mem.indexOf(u8, report, "Edit: | echo boom") != null);
+ try testing.expect(std.mem.indexOf(u8, report, "boom\n") != null);
+}
+
+test "an Edit whose file is edited while its commands run is refused whole" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | cat\n").reply.status);
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 };
+ panes.File.setContent(p, &p.panes[0].?.file.?, try testing.allocator.dupe(u8, "typed\n"));
+ answer(p, &.{"ABC\n"});
+ try testing.expectEqualStrings("typed\n", p.panes[0].?.file.?.content);
+ try testing.expectEqualStrings("Edit: /test.txt changed while its commands ran; nothing changed", filesystem.EditHold.answer(&p.fs).ename);
+}
+
+test "X over open files, w, e, r, f, B and D on panes and the disk" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ const gpa = testing.allocator;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.c", .data = "int foo;\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.h", .data = "foo\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.c", .data = "foo bar\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "data", .data = "DATA\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "n.c", .data = "new\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try th.withFile(gpa, "scratch\n");
+ defer p.deinit();
+ var path: [4200]u8 = undefined;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/a.c", .{dir}));
+ const a = p.active;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/b.h", .{dir}));
+ p.active = 0;
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ // X: every open file whose line matches, each from its own dot.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X/\\.[ch]$/ ,s/foo/FOO/g\n").reply.status);
+ try testing.expectEqualStrings("int FOO;\n", p.panes[a].?.file.?.content);
+ try testing.expectEqualStrings("scratch\n", p.panes[0].?.file.?.content);
+ // X alone lists them in the order they were opened, as /index does.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit X\n").reply.status);
+ const listed = errorsText(p).?;
+ try testing.expect(std.mem.indexOf(u8, listed, "/test.txt").? < std.mem.indexOf(u8, listed, "/a.c").?);
+ try testing.expect(std.mem.indexOf(u8, listed, "/a.c").? < std.mem.indexOf(u8, listed, "/b.h").?);
+ // B opens a file, which a `"re"` address then edits, from pane 0.
+ var cmd: [4400]u8 = undefined;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/c.c\n", .{dir})).reply.status);
+ try testing.expectEqual(@as(usize, 0), p.active);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit \"c\\.c$\" 1 s/bar/BAR/\n").reply.status);
+ const c = for (p.panes, 0..) |slot, i| {
+ if (slot) |q| if (q.file) |f| if (std.mem.endsWith(u8, f.path, "/c.c")) break i;
+ } else return error.NotOpened;
+ try testing.expectEqualStrings("foo BAR\n", p.panes[c].?.file.?.content);
+ // w of all of a file to its own name leaves it clean; r reads into dot.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[a].?.serial, .ctl), "Edit w\n").reply.status);
+ p.perform(.edit_write); // the effect th.wr took off the queue
+ try testing.expectEqualStrings("int FOO;\n", p.fallback.get(p.panes[a].?.file.?.path).?);
+ try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?));
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit 1 r data\n").reply.status);
+ try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content);
+ // e of an edited file is asked about once, as Get asks; again, it is
+ // read in Get's way, which leaves the pane clean and named after it.
+ const c_ctl = tree.Node.of(p.panes[c].?.serial, .ctl);
+ try testing.expect(th.wr(p, c_ctl, "Edit e data\n").reply.status == .err);
+ try testing.expectEqualStrings("DATA\n", p.panes[c].?.file.?.content);
+ try testing.expect(ninep_pane.dirtyOf(p.panes[c].?));
+ try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit e data\n").reply.status);
+ try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/data"));
+ try testing.expect(!ninep_pane.dirtyOf(p.panes[c].?));
+ // B checks every name first: a directory among them opens none.
+ const before = p.next_serial;
+ try testing.expect(th.wr(p, ctl, try std.fmt.bufPrint(&cmd, "Edit B {s}/n.c {s}\n", .{ dir, dir })).reply.status == .err);
+ try testing.expectEqual(before, p.next_serial);
+ // f names one and prints it.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, c_ctl, "Edit 1 c/edited/\n").reply.status);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[c].?.serial, .ctl), "Edit f d.c\n").reply.status);
+ try testing.expect(std.mem.endsWith(u8, p.panes[c].?.file.?.path, "/d.c"));
+ // In its directory's +Errors, or the log when the screen has no room
+ // left for one.
+ try testing.expect(errorsHas(p, "'+. ") or th.logHas(p, "'+. "));
+ // D closes a pane by name, an edited one (b.h, by the X) on the
+ // second asking, as Del does.
+ const b_serial = for (p.panes) |slot| {
+ if (slot) |q| if (q.file) |f| if (std.mem.endsWith(u8, f.path, "/b.h")) break q.serial;
+ } else return error.NotOpened;
+ const a_ctl = tree.Node.of(p.panes[a].?.serial, .ctl);
+ try testing.expect(th.wr(p, a_ctl, "Edit D b.h\n").reply.status == .err);
+ try testing.expect(p.paneBySerial(b_serial) != null);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, a_ctl, "Edit D b.h\n").reply.status);
+ try testing.expect(p.paneBySerial(b_serial) == null);
+}
+
+test "an Edit's w to a file changed on disk is refused once, as Save is, and the others are written" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "a\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "b\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try th.withFile(testing.allocator, "scratch\n");
+ defer p.deinit();
+ var path: [4200]u8 = undefined;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/a.txt", .{dir}));
+ const a = p.active;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/b.txt", .{dir}));
+ const b = p.active;
+ p.active = 0;
+ for ([_]usize{ a, b }) |i| try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[i].?.serial, .ctl), "Edit $a/more/\n").reply.status);
+ // a.txt changed on disk since it was read (the watch says so).
+ p.panes[a].?.file.?.disk_newer = 1;
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ const first = th.wr(p, ctl, "Edit X/'/w\n");
+ try testing.expect(first.reply.status == .err);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "a.txt modified on disk since read (w again to overwrite)") != null);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "b.txt") == null);
+ // b.txt is written all the same; a.txt is not.
+ try testing.expectEqual(@as(usize, 1), p.pipe.writes.items.len);
+ try testing.expect(std.mem.endsWith(u8, p.pipe.writes.items[0].path, "/b.txt"));
+ p.perform(.edit_write);
+ try testing.expect(ninep_pane.dirtyOf(p.panes[a].?));
+ // The same w again overwrites it, as a second Save does.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(p.panes[a].?.serial, .ctl), "Edit w\n").reply.status);
+ try testing.expectEqual(@as(usize, 1), p.pipe.writes.items.len);
+ p.perform(.edit_write);
+ try testing.expect(!ninep_pane.dirtyOf(p.panes[a].?));
+}
+
+test "an Edit with no command runs beside one whose commands run, unless it touches what that one changes" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "x\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try th.withFile(testing.allocator, "b\na\n");
+ defer p.deinit();
+ var path: [4200]u8 = undefined;
+ look.lookAt(p, 0, try std.fmt.bufPrint(&path, "{s}/other.txt", .{dir}));
+ const other = p.active;
+ p.active = 0;
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ const other_ctl = tree.Node.of(p.panes[other].?.serial, .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit , | sort\n").reply.status);
+ // Another pane's plain s runs while the filter does.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, other_ctl, "Edit ,s/x/y/\n").reply.status);
+ try testing.expectEqualStrings("y\n", p.panes[other].?.file.?.content);
+ // One that changes the filtered file, or runs commands of its own, is
+ // busy, EBUSY; a print of the filtered file is no change, and runs.
+ for ([_][]const u8{ "Edit ,s/a/A/\n", "Edit u\n" }) |line| {
+ const busy = th.wr(p, ctl, line);
+ try testing.expectEqual(tree.E.BUSY, busy.errno());
+ try testing.expect(std.mem.indexOf(u8, busy.reply.ename, "busy: ") != null);
+ }
+ try testing.expectEqual(tree.E.BUSY, th.wr(p, other_ctl, "Edit , | cat\n").errno());
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit 1p\n").reply.status);
+ answer(p, &.{"a\nb\n"});
+ try testing.expectEqualStrings("a\nb\n", p.panes[0].?.file.?.content);
+}
+
+test "a pane an Edit's commands will change is not closed under them, and one closed anyway is named, not read freed" {
+ const p = try th.withFile(testing.allocator, "b\na\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const ctl = tree.Node.of(serial, .ctl);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Edit ,|sleep 0.8; cat\n").reply.status);
+ // Del, acme's delete, rmdir, Undo, Redo, Get, Zerox: busy, EBUSY.
+ for ([_][]const u8{ "delete\n", "Del\n", "Undo\n", "Redo\n", "get\n", "Get\n", "Zerox\n" }) |line| {
+ const busy = th.wr(p, ctl, line);
+ try testing.expectEqual(tree.E.BUSY, busy.errno());
+ try testing.expect(std.mem.indexOf(u8, busy.reply.ename, e_busy) != null);
+ }
+ try testing.expectEqual(tree.E.BUSY, th.rmdir(p, tree.Node.of(serial, .dir)).errno());
+ try testing.expect(p.paneBySerial(serial) != null);
+ // Closed by a way that does not ask (a session's own teardown of one
+ // pane): the Edit's end names it from its own copy.
+ p.fs.edit_hold = .{ .asker = @ptrCast(p), .slot = 0, .seq = 1, .tag = 2, .node = ctl, .handle = 0, .written = 1 };
+ try p.removePane(0, null);
+ answer(p, &.{"a\nb\n"});
+ try testing.expectEqualStrings("Edit: /test.txt closed while its commands ran; nothing changed", filesystem.EditHold.answer(&p.fs).ename);
+}
+
+test "a session that ends while an Edit's commands run lets the Edit go" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit ,|sleep 5; cat\n").reply.status);
+ try testing.expect(p.pipe.edit_run != null);
+ p.deinit(); // the testing allocator says if anything is kept
+}
+
+test "e and r say a file that cannot be read as Get says it" {
+ if (comptime !pardes.hosted or !filesystem.platform_has_fs) return error.SkipZigTest;
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const ctl = tree.Node.of(th.serialOf(p), .ctl);
+ for ([_][2][]const u8{
+ .{ "Edit 0r /dev/zero\n", "not a regular file" },
+ .{ "Edit 0r /tmp\n", "is a directory" },
+ .{ "Edit 0r /nonexistent-pardes-file\n", "no such file" },
+ }) |c| {
+ const r = th.wr(p, ctl, c[0]);
+ try testing.expect(r.reply.status == .err);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, c[1]) != null);
+ }
+}
diff --git a/src/esp32p4.zig b/src/esp32p4.zig
index 4e1fe036..6772e75d 100644
--- a/src/esp32p4.zig
+++ b/src/esp32p4.zig
@@ -455,8 +455,9 @@ fn apply(c: *pardes.Pardes, ev: vaxis.Event) void {
.motion => .motion,
.drag => .drag,
},
- .col = @intCast(m.col),
- .row = @intCast(m.row),
+ // Before the first cell (a drag past the edge): the edge.
+ .col = @intCast(@max(0, m.col)),
+ .row = @intCast(@max(0, m.row)),
.ctrl = m.mods.ctrl,
} });
dirty = true;
diff --git a/src/exec.zig b/src/exec.zig
index f3819a83..2a6bd55c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -108,6 +108,25 @@ pub const PointerOperand = struct {
};
pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand {
+ return operandAt(p, pane, clicked, true, true);
+}
+
+/// The body's explicit selection, else what a look at its cursor takes.
+pub fn currentOperand(p: *Pardes, pane: *Pane) ?[]const u8 {
+ if (pane.file == null and !pane.isTerminal()) return null;
+ if ((pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active) return edit.currentSelText(p, pane);
+ return operandAt(p, pane, look.cursorWordSel(p, pane), false, true).text;
+}
+
+/// What a look or an exec at `clicked` takes: the ONE expansion every
+/// entry point uses (a B2/B3 click, Enter and Tab in normal mode, a bare
+/// Look), so a diff line, a hunk line's code word and a file word expand
+/// alike however they are reached (look.zig's "every entry point" test).
+/// `borrow`: a gesture inside a kept or modal selection takes it; the
+/// keyboard has said which selection it means already. `for_look`: only a
+/// look takes a diff line's address; an exec there takes the word, as
+/// anywhere else.
+pub fn operandAt(p: *Pardes, pane: *Pane, clicked: Pane.Sel, borrow: bool, for_look: bool) PointerOperand {
const visible = clicked.r0 - @as(i32, pane.tag_rows);
const wrapped = pane.wrapAt(visible);
const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows;
@@ -126,48 +145,49 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
else
clicked.c0;
var result: PointerOperand = .{ .row = row, .col = col };
-
- const kept = pane.sel[sel_slot];
- const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: {
- if (clicked.r0 < pane.tag_rows) break :inside false;
- if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false;
- const source_col: usize = @intCast(@max(0, col));
- for (rows) |span| {
- const target = if (span.raw_terminal)
- (if (raw) span.row else pane.surfRow(span.row))
- else
- (if (raw) pane.gridRow(span.row) else span.row);
- var lo = span.lo;
- var hi = span.hi;
- if (span.raw_terminal and !raw) {
- lo -|= span.prompt_bytes;
- hi -|= span.prompt_bytes;
- } else if (!span.raw_terminal and raw) {
- const prefix = panes.terminal.promptPrefixBytes(pane, row, raw_line);
- lo += prefix;
- hi += prefix;
+ if (borrow) {
+ const kept = pane.sel[sel_slot];
+ const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: {
+ if (clicked.r0 < pane.tag_rows) break :inside false;
+ if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false;
+ const source_col: usize = @intCast(@max(0, col));
+ for (rows) |span| {
+ const target = if (span.raw_terminal)
+ (if (raw) span.row else pane.surfRow(span.row))
+ else
+ (if (raw) pane.gridRow(span.row) else span.row);
+ var lo = span.lo;
+ var hi = span.hi;
+ if (span.raw_terminal and !raw) {
+ lo -|= span.prompt_bytes;
+ hi -|= span.prompt_bytes;
+ } else if (!span.raw_terminal and raw) {
+ const prefix = panes.terminal.promptPrefixBytes(pane, row, raw_line);
+ lo += prefix;
+ hi += prefix;
+ }
+ if (target == row and source_col >= lo and source_col < hi)
+ break :inside true;
}
- if (target == row and source_col >= lo and source_col < hi)
- break :inside true;
+ break :inside false;
+ } else edit.spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1);
+ if (kept.state == .done and inside_kept) {
+ result.text = edit.selectionText(p, pane, kept) catch null;
+ result.preview = kept;
+ return result;
+ }
+ if (pane.body.vsel.active and pane.body.vsel.explicit and
+ edit.spanHas(row, col, pane.body.vsel.row, pane.body.vsel.col, pane.body.cur_row, pane.body.cur_col))
+ {
+ result.text = edit.currentSelText(p, pane);
+ return result;
+ }
+ if (pane.body.msel.active and row >= @min(pane.body.msel.r0, pane.body.msel.r1) and
+ row <= @max(pane.body.msel.r0, pane.body.msel.r1))
+ {
+ result.text = edit.currentSelText(p, pane);
+ return result;
}
- break :inside false;
- } else edit.spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1);
- if (kept.state == .done and inside_kept) {
- result.text = edit.selectionText(p, pane, kept) catch null;
- result.preview = kept;
- return result;
- }
- if (pane.body.vsel.active and pane.body.vsel.explicit and
- edit.spanHas(row, col, pane.body.vsel.row, pane.body.vsel.col, pane.body.cur_row, pane.body.cur_col))
- {
- result.text = edit.currentSelText(p, pane);
- return result;
- }
- if (pane.body.msel.active and row >= @min(pane.body.msel.r0, pane.body.msel.r1) and
- row <= @max(pane.body.msel.r0, pane.body.msel.r1))
- {
- result.text = edit.currentSelText(p, pane);
- return result;
}
// In a diff, a file or hunk header, and a hunk line pointed at on its
// `+`/`-`/` ` prefix, expand to the whole line (the hover shows it) and
@@ -175,7 +195,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
// look from there. A hunk line's code is words, looked at as ever:
// the code's, the prefix never one of them.
var word_from: usize = 0;
- if (clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: {
+ if (for_look and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: {
var address_buf: [4096 + 32]u8 = undefined;
const at: usize = @intCast(row);
const click = look.diffClick(p, pane, at, @intCast(@max(0, col)), &address_buf) orelse break :diff;
@@ -925,21 +945,16 @@ pub fn namesBuiltin(txt: []const u8) bool {
pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint: []const u8 = "" }{
.{ .acme = "Put", .pardes = "Save" },
.{ .acme = "Delete", .pardes = "Del" },
- .{ .acme = "Get", .pardes = null, .hint = ": write get to the pane's ctl" },
- .{ .acme = "Putall", .pardes = null, .hint = ": Save each pane" },
.{ .acme = "Snarf", .pardes = null, .hint = ": y yanks the selection" },
.{ .acme = "Cut", .pardes = null, .hint = ": d deletes the selection into a register" },
.{ .acme = "Paste", .pardes = null, .hint = ": p puts a register" },
- .{ .acme = "Zerox", .pardes = null },
.{ .acme = "Sort", .pardes = null },
- .{ .acme = "Load", .pardes = null, .hint = ": Restore loads a dump" },
+ .{ .acme = "Load", .pardes = "Restore" },
.{ .acme = "ID", .pardes = null },
.{ .acme = "Send", .pardes = null },
- .{ .acme = "Tab", .pardes = null },
- .{ .acme = "Indent", .pardes = null },
+ .{ .acme = "Indent", .pardes = null, .hint = ": Enter, o and O keep the indent always" },
.{ .acme = "Local", .pardes = null },
- .{ .acme = "Incl", .pardes = null },
- .{ .acme = "Abort", .pardes = null },
+ .{ .acme = "Abort", .pardes = null, .hint = ": a debugging word that crashes acme" },
};
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
@@ -1006,7 +1021,9 @@ fn applyInitText(p: *Pardes, text: []const u8, settings_only: bool) void {
// (`#ff0000`).
if (word.len == 0 or isComment(word)) continue;
const setting = config.Runtime.find(word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len]);
- if (settings_only and setting == null) continue;
+ // Saved in a pane, its settings run again and its commands do not:
+ // a word that is neither (a typo) is still said, as at startup.
+ if (settings_only and setting == null and std.meta.stringToEnum(Builtin, word[0 .. std.mem.indexOfAny(u8, word, " \t+") orelse word.len]) != null) continue;
// One init file serves the window and the terminal: a setting only
// the other has (Font, GripWidth in a terminal) is no failure here.
if (setting) |s| if (!s.enabled(builtins.capabilities)) continue;
@@ -1103,6 +1120,15 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
}
if (!p.settings.apply(setting, want)) p.reportFailure(p.active, "Shell: takes " ++ comptime config.Runtime.takes(.shell).?);
},
+ // Bare, it says the width, as acme's Tab does.
+ .tab => if (arg == null) {
+ var said: [32]u8 = undefined;
+ return p.setMessage(p.active, std.fmt.bufPrint(&said, "Tab {d}", .{p.settings.tab}) catch "Tab");
+ } else if (!p.settings.apply(setting, arg)) {
+ p.reportFailure(p.active, "Tab: invalid tab width: takes " ++ comptime config.Runtime.takes(.tab).?);
+ } else {
+ config.tab_now = p.settings.tab;
+ },
// Said with what it takes: its words, its range.
else => if (!p.settings.apply(setting, arg)) {
var text: [160]u8 = undefined;
@@ -1116,7 +1142,7 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (pane.file) |*file| file.syntax_dirty = true;
}
}
- if (p.settings.tree_context_tag_style != previous_context_style) {
+ if (p.settings.tree_context_tag_style != previous_context_style or setting.action == .tab) {
for (p.panes, 0..) |slot, id| {
p.surface.body_layers[id].rows = 0;
const pane = slot orelse continue;
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 46764a73..4aba6c39 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -469,6 +469,14 @@ pub fn reloadPane(
.text => |accepted| if (accepted == hash) return false,
.pdf => {},
}
+ // The pane holds the disk's text already (a Zerox twin saved, its
+ // other twin watching the same file): nothing reloaded, nothing said.
+ if (std.mem.eql(u8, file.content, bytes)) {
+ if (watches[id]) |*live| if (live.serial == watched.serial) {
+ live.generation = .{ .text = hash };
+ };
+ return false;
+ }
if (!core.reloadWatchedFile(@intCast(id), bytes)) return false;
const current = core.panes[id] orelse return false;
if (current.serial != watched.serial) return false;
@@ -713,6 +721,38 @@ test "restored file watches preserve snapshots and accept later disk changes" {
}
}
+test "a Zerox twin's save wakes its other twin's watch, which says nothing: their text is the file's already" {
+ if (comptime !supported or !filesystem.platform_has_fs) return;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "twinned", .data = "before\n" });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/twinned", .{tmp.sub_path});
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ const pane = try core.setTestFile("before\n");
+ core.gpa.free(pane.file.?.path);
+ pane.file.?.path = try core.gpa.dupe(u8, path);
+ try std.testing.expect(core.executeBuiltinLine(0, "Zerox"));
+ const twin: u8 = for (core.panes, 0..) |slot, id| {
+ if (id != 0) if (slot) |other| if (other.file != null) break @intCast(id);
+ } else return error.NoTwin;
+ const fd = init(true);
+ if (fd < 0) return error.NoWatcher;
+ defer _ = libc.close(fd);
+ var watches: Table = @splat(null);
+ defer watchPane(fd, &watches, twin, null, core.panes[twin].?.serial, .{ .text = 0 });
+ watchPane(fd, &watches, twin, path, core.panes[twin].?.serial, .{ .text = std.hash.Wyhash.hash(0, "before\n") });
+ // Edited in the first and saved: the file is the twins' text now.
+ pardes.panes.File.setContent(core, &pane.file.?, try core.gpa.dupe(u8, "after\n"));
+ try tmp.dir.writeFile(io, .{ .sub_path = "twinned", .data = "after\n" });
+ _ = reloadPane(core, io, &watches, twin, true);
+ const other = core.panes[twin].?;
+ try std.testing.expect(std.mem.indexOf(u8, other.msg[0..other.msg_len], "reloaded") == null);
+ try std.testing.expectEqualStrings("after\n", other.file.?.content);
+}
+
test "ordinary file watches still reconcile changes between open and watch" {
if (comptime !supported or !filesystem.platform_has_fs) return;
const io = std.testing.io;
diff --git a/src/fs.zig b/src/fs.zig
index 7a56d387..fb76cee3 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -85,6 +85,14 @@ pub const WriteError = error{
NoSpaceLeft,
OpenFailed,
WriteFailed,
+ // The rest are open(2)'s errno, named so errorWords says them.
+ NoSuchFileOrDirectory,
+ NotDir,
+ InvalidArgument,
+ FileBusy,
+ SymLinkLoop,
+ NoSuchDeviceOrAddress,
+ FileTooBig,
};
/// The host takes the bytes. The caller has given the turn up (`write`),
@@ -102,6 +110,13 @@ pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void {
.ROFS => error.ReadOnlyFilesystem,
.NOSPC, .DQUOT => error.NoSpaceLeft,
.NAMETOOLONG => error.PathTooLong,
+ .NOENT => error.NoSuchFileOrDirectory,
+ .NOTDIR => error.NotDir,
+ .INVAL => error.InvalidArgument,
+ .BUSY, .TXTBSY => error.FileBusy,
+ .LOOP => error.SymLinkLoop,
+ .NXIO, .NODEV => error.NoSuchDeviceOrAddress,
+ .FBIG => error.FileTooBig,
else => error.OpenFailed,
};
var off: usize = 0;
@@ -1506,6 +1521,13 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
};
defer _ = libc.close(fd);
+ // A device (/dev/zero, a tty) is no file to read whole: said so, not
+ // read to the stream limit and called too large.
+ if (ninep_io.statNoFollow(path_z)) |facts| {
+ const kind = facts.mode & 0o170000;
+ if (kind == 0o040000) return error.IsDirectory;
+ if (kind == 0o020000 or kind == 0o060000) return error.NotAFile;
+ }
const end = libc.lseek(fd, 0, libc.SEEK.END);
// Not seekable: a pipe or a FIFO is no file; a regular file that is a
// stream (a 9ns mount's log or screen, opened nonseekable) is read to
@@ -1554,6 +1576,36 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
return buf;
}
+/// A 9P write of an Edit held while its commands run (Namespace.edit_hold).
+pub const EditHold = struct {
+ /// The listener's: the connection that asked, and its park.
+ asker: *anyopaque,
+ slot: u16,
+ seq: u64,
+ tag: u64,
+ node: u64,
+ handle: u32,
+ written: u32,
+ /// The Edit is done: applied, or failed with `said`.
+ done: bool = false,
+ errno: u16 = 0,
+ said: [256]u8 = undefined,
+ said_len: u16 = 0,
+
+ pub fn names(h: *const EditHold, req: tree.Req) bool {
+ return req.op == .write and req.tag == h.tag and req.node == h.node and req.handle == h.handle;
+ }
+
+ /// Its answer, the held record let go: the words copied out first.
+ pub fn answer(ns: *Namespace) tree.Reply {
+ const h = ns.edit_hold.?;
+ ns.edit_hold = null;
+ if (h.errno == 0) return .{ .tag = h.tag, .written = h.written };
+ @memcpy(ns.edit_said[0..h.said_len], h.said[0..h.said_len]);
+ return tree.failText(h.tag, h.errno, ns.edit_said[0..h.said_len]);
+ }
+};
+
pub const Namespace = struct {
socket_path: []const u8 = "",
tcp_address: ?std.Io.net.IpAddress = null,
@@ -1613,6 +1665,15 @@ pub const Namespace = struct {
/// A 9P write asked a language server something: the answer count it
/// waits to change (`turn.lsp_answers`), so its failure is the write's.
lsp_answer_at: ?u64 = null,
+ /// The request being served started an Edit whose `<`, `|` or `>`
+ /// commands run off the loop (edit_cmd.zig): the listener holds it.
+ edit_started: bool = false,
+ /// That write, held: answered once `done` (9p_io answerHeld), or
+ /// asked again by a retry of its park (Pardes.serveFs); a flush or a
+ /// hang-up stops the Edit (9p_io watchEdit).
+ edit_hold: ?EditHold = null,
+ /// The words a held Edit write was refused with, kept for its answer.
+ edit_said: [256]u8 = undefined,
/// The pane a language answer filled (a Rename's preview): what the
/// waiting 9P write answers once it is in (9p_io).
lsp_result: ?u32 = null,
@@ -1754,3 +1815,11 @@ test {
test "the tree's README lists src/ only when the build serves it" {
try std.testing.expectEqual(limits.embedded_sources, std.mem.indexOf(u8, help, "src/") != null);
}
+
+test "a write open(2) refuses says its errno in words" {
+ if (comptime @import("builtin").os.tag != .linux or !pardes.hosted) return error.SkipZigTest;
+ // /proc takes no new file: ENOENT, which used to read "open failed".
+ try std.testing.expectError(error.NoSuchFileOrDirectory, writeFile("/proc/pardes-no-such-file", "x"));
+ var words: [128]u8 = undefined;
+ try std.testing.expectEqualStrings("no such file or directory", pardes.Messages.errorWords(error.NoSuchFileOrDirectory, &words));
+}
diff --git a/src/host_io.zig b/src/host_io.zig
index 8ff55d74..7314affc 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.* = .{
@@ -1469,6 +1469,20 @@ test "the child environment replaces the launcher's terminal identity exactly on
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+/// The environment a pane's command is given (exportPaneEnv, `$winid`,
+/// ChildEnv), as `NAME=value` lines in `arena`, for a command that is not a
+/// pane's: an Edit's filters, run from pane `serial`.
+pub fn commandEnv(arena: std.mem.Allocator, serial: u32, adopts: bool) ![]const []const u8 {
+ ninep_io.exportSessionEnv(serial, adopts);
+ exportWinid(serial);
+ var slots: ChildEnv.Slots = undefined;
+ const envp = ChildEnv.build(&slots, .command) orelse return error.EnvironmentTooLarge;
+ var out: std.ArrayList([]const u8) = .empty;
+ var i: usize = 0;
+ while (envp[i]) |entry| : (i += 1) try out.append(arena, try arena.dupe(u8, std.mem.span(entry)));
+ return out.items;
+}
+
fn exportWinid(serial: u32) void {
var buf: [16]u8 = undefined;
if (serial != 0) if (std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0)) |text| {
diff --git a/src/image.zig b/src/image.zig
index 4a8bcdf4..72520ad6 100644
--- a/src/image.zig
+++ b/src/image.zig
@@ -919,13 +919,15 @@ pub fn toggleAscii(state: *State) void {
/// Image renderer choices are pane-local, not global Config values. Keep them
/// queryable where they apply: the live tag beside the image's path.
-pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 {
+/// `glyphs`: whether the picture is drawn as glyph art, which a terminal
+/// with no graphics does whatever `glyph_art` says.
+pub fn tagPrefix(arena: std.mem.Allocator, state: *const State, glyphs: bool) ![]u8 {
return std.fmt.allocPrint(
arena,
"{s} petscii:{s} palette:{s} ascii:{s} {s}",
.{
config.tag_image,
- if (state.glyph_art) "on" else "off",
+ if (glyphs) "on" else "off",
@tagName(state.pmode),
if (state.ascii) "on" else "off",
state.path,
diff --git a/src/look.zig b/src/look.zig
index 115dabe1..41cfdc0c 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -792,10 +792,16 @@ fn lookStand(pane: *Pane) LookFrom {
fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize {
var n: usize = 0;
+ // A command pane walked from (`make`'s errors): its own places first.
+ if (commandPaneActive(p)) {
+ out[0] = p.active;
+ n = 1;
+ }
var i = p.n_look_src;
while (i > 0) {
i -= 1;
const id = p.paneBySerial(p.look_src[i]) orelse continue;
+ if (n > 0 and out[0] == id) continue;
out[n] = id;
n += 1;
}
@@ -890,11 +896,18 @@ fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usiz
return null;
}
+/// A command pane has the keyboard: `n` there walks its own output.
+fn commandPaneActive(p: *Pardes) bool {
+ const pane = p.panes[p.active] orelse return false;
+ return pane.command != null and pane.file == null and !p.header_focus;
+}
+
pub fn lookWalk(p: *Pardes, delta: i32) void {
var buf: [MAX_PANES]usize = undefined;
const order = lookWalkPanes(p, &buf);
if (order.len == 0) return;
- const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null;
+ // From a command pane the walk starts there, whoever walked last.
+ const owner = if (commandPaneActive(p)) p.active else if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null;
const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0;
const at = if (owner) |wanted|
std.mem.indexOfScalar(usize, order, wanted) orelse active_at
@@ -1141,6 +1154,13 @@ fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8)
// `.`, `..`, `./x` are this pane's directory's, not another's.
if (found != .none or pl.path.len == 0 or pl.path[0] == '/' or filesystem.dotRelative(pl.path)) break;
}
+ // Found nowhere else, a relative name is tried in Incl's directories,
+ // as acme's includename tries its (look.c:500).
+ if (found == .none and pl.path.len > 0 and pl.path[0] != '/' and !filesystem.dotRelative(pl.path))
+ for (p.incl.items) |dir| {
+ found = resolve(p, txt, dir, realbuf);
+ if (found != .none) break;
+ };
return found;
}
@@ -1734,7 +1754,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
const txt = p.scratch.allocator().dupe(u8, looked) catch return;
// A trailing `:` (`a.txt:2:3:`, as a compiler's message ends) is no
// part of the place, as a click's expansion leaves it off.
- const trimmed = std.mem.trimEnd(u8, std.mem.trim(u8, txt, " \t\r\n"), ":");
+ const bare = std.mem.trimEnd(u8, std.mem.trim(u8, txt, " \t\r\n"), ":");
+ // `<stdio.h>` written whole: the name inside, as a click on it takes.
+ const trimmed = if (bare.len > 2 and bare[0] == '<' and bare[bare.len - 1] == '>') bare[1 .. bare.len - 1] else bare;
// acme's `file:addr` and `:addr` (look.c:450-543): an address after
// the colon, evaluated in that file from its dot (openfile, look.c:675).
// `file:3:0`, and `file:0:0` too: columns count from 1, as addr's do,
@@ -1787,6 +1809,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 +1824,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);
@@ -2414,7 +2441,7 @@ test "a click, a 9P whole-line look and an empty event write-back take one expan
// An empty record written back to event, at the same byte.
const body = pane.file.?.content;
const line_start = modal.lineStartOffset(body, at.row);
- const written = pardes.ctlfs.events.expandAt(p, pane, body, line_start + at.col, true);
+ const written = pardes.ctlfs.events.expandAt(p, pane, body, line_start + at.col, true, true);
try std.testing.expectEqualStrings(at.want, written);
// A 9P look of the whole line.
if (at.whole_line) {
@@ -2426,6 +2453,249 @@ test "a click, a 9P whole-line look and an empty event write-back take one expan
}
}
+test "every entry point to Look takes one expansion: a diff line and a file:12:5: line land in one place by each" {
+ if (!platform_has_fs) return;
+ const numbered = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nline 12 here\nl13\n";
+ const diff_text =
+ \\diff --git a/src/x.zig b/src/x.zig
+ \\--- a/src/x.zig
+ \\+++ b/src/x.zig
+ \\@@ -11,2 +11,3 @@
+ \\ l11
+ \\-old
+ \\+line 12 here
+ \\
+ ;
+ const log_text = "src/x.zig:12:5: error: bad\n";
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/x.zig", numbered }}, diff_text);
+ defer fx.tmp.cleanup();
+ const th = @import("ninep/testing.zig");
+ // `column` -1: wherever a click puts it, every other way the same.
+ const Case = struct { text: []const u8, name: []const u8, row: u16, col: u16, sweep: [2]u16, line: i32, column: i32 };
+ const cases = [_]Case{
+ // `+line 12 here` pointed at on its `+`: the hunk's new line 12.
+ .{ .text = diff_text, .name = "change.diff", .row = 6, .col = 0, .sweep = .{ 0, 12 }, .line = 11, .column = -1 },
+ // A compiler's place, its trailing `:` dropped: line 12, column 5.
+ .{ .text = log_text, .name = "build.log", .row = 0, .col = 2, .sweep = .{ 0, 14 }, .line = 11, .column = 4 },
+ };
+ for (cases) |case| {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, case.text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try std.fs.path.join(p.gpa, &.{ fx.dir, case.name });
+ pane.tag_expanded = false;
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const Entry = enum { click, sweep, enter, bare_look, look_file, event_write_back };
+ var column = case.column;
+ for (std.enums.values(Entry)) |entry| {
+ // From the pane, its cursor on the place, nothing selected,
+ // the target (once open) moved off it.
+ for (p.panes[1..]) |slot| if (slot) |other| {
+ other.body.cur_row = 0;
+ other.body.cur_col = 0;
+ };
+ p.active = 0;
+ pane.body.mode = .normal;
+ pane.body.vsel.active = false;
+ pane.body.msel.active = false;
+ pane.body.cur_row = case.row;
+ pane.body.cur_col = case.col;
+ pane.body.cur_pinned = true;
+ pane.file.?.scroll = 0;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x0 = rect.x + config.GUTTER + panes.File.gutterWidth(pane);
+ const y = p.bodyTop(pane, rect) + case.row;
+ switch (entry) {
+ .click => {
+ p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = x0 + case.col, .row = y } });
+ p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = x0 + case.col, .row = y } });
+ },
+ .sweep => {
+ p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = x0 + case.sweep[0], .row = y } });
+ p.update(.{ .mouse = .{ .button = .right, .kind = .drag, .col = x0 + case.sweep[1], .row = y } });
+ p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = x0 + case.sweep[1], .row = y } });
+ },
+ .enter => p.update(.{ .key = .{ .cp = Key.enter } }),
+ .bare_look => _ = p.executeBuiltinLine(0, "Look"),
+ .look_file => {
+ const line = modal.lineSlice(case.text, case.row);
+ // A diff line written whole; a place, as a script writes it.
+ const written = if (std.mem.eql(u8, case.name, "change.diff")) line else line[case.sweep[0] .. case.sweep[1] + 1];
+ _ = th.wr(p, @intFromEnum(pardes.ctlfs.TopFile.look), written);
+ },
+ .event_write_back => {
+ const body = pane.file.?.content;
+ const text = pardes.ctlfs.events.expandAt(p, pane, body, modal.lineStartOffset(body, case.row) + case.col, true, true);
+ lookAt(p, 0, p.scratch.allocator().dupe(u8, text) catch unreachable);
+ },
+ }
+ const landed = p.panes[p.active].?;
+ const f = landed.file orelse return error.NothingOpened;
+ if (column < 0) column = landed.body.cur_col;
+ if (!std.mem.endsWith(u8, f.path, "/src/x.zig") or landed.body.cur_row != case.line or landed.body.cur_col != column) {
+ std.debug.print("{s} by {s}: at {s} {d}:{d}\n", .{ case.name, @tagName(entry), f.path, landed.body.cur_row, landed.body.cur_col });
+ return error.EntryPointsDiffer;
+ }
+ }
+ }
+}
+
+test "a tag word looked at by a click and by Enter is one record to an event reader; an exec on a diff line takes its word, never the address" {
+ if (!platform_has_fs) return;
+ const diff_text =
+ \\diff --git a/src/x.zig b/src/x.zig
+ \\--- a/src/x.zig
+ \\+++ b/src/x.zig
+ \\@@ -1,1 +1,1 @@
+ \\-old
+ \\+line one
+ \\
+ ;
+ var fx: DiffFixture = undefined;
+ try fx.init(true, &.{.{ "src/x.zig", "line one\n" }}, diff_text);
+ defer fx.tmp.cleanup();
+ const th = @import("ninep/testing.zig");
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try fx.open(p, diff_text);
+ pane.tag_expanded = true;
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ p.sync();
+ _ = try p.render(frame.allocator());
+
+ // An exec on the `+` of a hunk line takes the word there; a look, the
+ // address the line names.
+ pane.body.cur_row = 5;
+ pane.body.cur_col = 0;
+ try std.testing.expectEqualStrings("+line", exec.operandAt(p, pane, cursorWordSel(p, pane), false, false).text.?);
+ try std.testing.expectEqualStrings("src/x.zig:1", exec.operandAt(p, pane, cursorWordSel(p, pane), false, true).text.?);
+ // An exec written back to event (`MX0 0`) takes the word too, on a
+ // header, a hunk header and a marker column; a look, the address.
+ const body = pane.file.?.content;
+ const events = pardes.ctlfs.events;
+ try std.testing.expectEqualStrings("diff", events.expandAt(p, pane, body, 0, true, false));
+ try std.testing.expectEqualStrings("@@", events.expandAt(p, pane, body, modal.lineStartOffset(body, 3), true, false));
+ try std.testing.expectEqualStrings("+line", events.expandAt(p, pane, body, modal.lineStartOffset(body, 5), true, false));
+ try std.testing.expectEqualStrings("src/x.zig", events.expandAt(p, pane, body, 0, true, true));
+ try std.testing.expectEqualStrings("src/x.zig:1", events.expandAt(p, pane, body, modal.lineStartOffset(body, 5), true, true));
+
+ // An event reader on the pane.
+ const event = pardes.ctlfs.Node.of(pane.serial, .event);
+ const opened = th.call(p, .{ .tag = 1, .op = .open, .node = event });
+ defer _ = th.call(p, .{ .tag = 9, .op = .release, .node = event, .handle = opened.reply.handle });
+ const tag = try tagline.tagText(p, p.scratch.allocator(), pane);
+ const at: u16 = @intCast(std.mem.lastIndexOf(u8, tag, "Del").?);
+ // A B3 click on the tag's Del, where the tag as shown has it...
+ const shown = (try tagline.shownTag(p, p.scratch.allocator(), pane)).text;
+ const shown_at = std.mem.lastIndexOf(u8, shown, "Del").?;
+ const shown_row: u16 = @intCast(std.mem.count(u8, shown[0..shown_at], "\n"));
+ const line_start = if (std.mem.lastIndexOfScalar(u8, shown[0..shown_at], '\n')) |nl| nl + 1 else 0;
+ const x = p.rects[0].x + TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(shown[line_start..shown_at]))) + 1;
+ const y = p.rects[0].y + shown_row;
+ p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = x, .row = y } });
+ var clicked_buf: [128]u8 = undefined;
+ const clicked = th.rd(p, event, 0, 4096).bytes;
+ @memcpy(clicked_buf[0..clicked.len], clicked);
+ // ...and Enter with the tag's cursor on it.
+ tagline.enterTag(p, pane);
+ pane.tag.mode = .normal;
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = at + 1;
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const keyed = th.rd(p, event, 0, 4096).bytes;
+ // The same record but for its origin, mouse or keyboard.
+ try std.testing.expect(clicked.len > 2);
+ try std.testing.expectEqualStrings(clicked_buf[1..clicked.len], keyed[1..]);
+ try std.testing.expect(p.panes[0] != null);
+}
+
+test "Incl's directories find a name found nowhere else, <name> too; bare Incl lists them, Incl - empties them, and a dump keeps them" {
+ if (!platform_has_fs) return;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(std.testing.io, "inc");
+ try tmp.dir.createDirPath(std.testing.io, "src");
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "inc/pardes_incl_probe.h", .data = "#define X 1\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const root = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ // C's two by default.
+ try std.testing.expectEqualStrings("/usr/include", p.incl.items[0]);
+ const pane = try p.setTestFile("#include <pardes_incl_probe.h>\n");
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try std.fs.path.join(p.gpa, &.{ root, "src", "main.c" });
+ var line: [4200]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Incl {s}/inc", .{root})));
+ for ([_][]const u8{ "pardes_incl_probe.h", "<pardes_incl_probe.h>" }) |word| {
+ p.active = 0;
+ p.look_missed = false;
+ lookAt(p, 0, word);
+ try std.testing.expect(!p.look_missed);
+ try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/inc/pardes_incl_probe.h"));
+ }
+ // Bare, it lists them.
+ try std.testing.expect(p.executeBuiltinLine(0, "Incl"));
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "/inc") != null);
+ // A dump keeps them.
+ try pardes.dump.dumpState(p);
+ const restored = try pardes.dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ try std.testing.expectEqual(p.incl.items.len, restored.incl.items.len);
+ try std.testing.expect(std.mem.endsWith(u8, restored.incl.items[restored.incl.items.len - 1], "/inc"));
+ // Emptied, the name is found nowhere.
+ try std.testing.expect(p.executeBuiltinLine(0, "Incl -"));
+ try std.testing.expectEqual(@as(usize, 0), p.incl.items.len);
+}
+
+test "n in a command pane walks its own file:line places, whoever walked last" {
+ if (!platform_has_fs) return;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "x.c", .data = "a\nb\nc\n" });
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "y.c", .data = "a\nb\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const doc = try p.setTestFile("x\n");
+ p.gpa.free(doc.file.?.path);
+ doc.file.?.path = try std.fs.path.join(p.gpa, &.{ dir, "main.c" });
+ while (p.nextEffect()) |_| {}
+ const dst = exec.execute(p, 0, "make") orelse return error.NoCommandPane;
+ while (p.nextEffect()) |_| {}
+ p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "x.c:3:1: error: bad\r\ny.c:2:1: warning: odd\r\n" } });
+ p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 2 } });
+ p.update(.{ .eof = .{ .pane = @intCast(dst) } });
+ const cmd = p.panes[dst].?;
+ // A walk last made elsewhere, and another pane with places in it.
+ try std.testing.expect(p.executeBuiltinLine(0, "New"));
+ const scratch = p.panes[p.active].?;
+ _ = pardes.ctlfs.pane.spliceBody(p, scratch, 0, 0, "x.c:1:1 elsewhere\n") orelse return error.NoScratch;
+ p.look_walk_owner = scratch.serial;
+ p.active = dst;
+ cmd.focus = .body;
+ cmd.body.mode = .normal;
+ for ([_]u21{ 'g', 'g' }) |cp| p.update(.{ .key = .{ .cp = cp } });
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(dst, p.active);
+ const first = cmd.look_at orelse return error.NoSpot;
+ const lines = try edit.paneCursorLines(p, &cmd.body);
+ try std.testing.expect(std.mem.startsWith(u8, lines[@intCast(first.row)][@intCast(first.col0)..], "x.c:3:1"));
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(dst, p.active);
+ const second = cmd.look_at orelse return error.NoSpot;
+ try std.testing.expect(std.mem.startsWith(u8, lines[@intCast(second.row)][@intCast(second.col0)..], "y.c:2:1"));
+}
+
test "Look outside a diff pane expands and looks as ever" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
defer p.deinit();
diff --git a/src/main.zig b/src/main.zig
index f6a3a3ec..ed70b624 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -466,6 +466,10 @@ fn nativeMain(init: std.process.Init) !void {
opts.mounts = mounts[0..mounts_len];
if (detach != null and attach != null)
badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{});
+ // --wait waits on the pane a FILE opens: with none there is nothing to
+ // wait for (EDITOR=pardes --wait is given the file).
+ if (wait and positional == null)
+ badArgs(init.io, "--wait needs a FILE: it returns when that file's pane is closed", .{});
if (opts.ninep_name.len != 0 and attach != null)
badArgs(init.io, "--9p names a session's own socket, and --attach has none of its own", .{});
if (opts.ninep_tcp != null and attach != null)
@@ -709,7 +713,18 @@ fn nativeMain(init: std.process.Init) !void {
name
else
try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))});
- return @import("detached/server.zig").run(init, opts, named);
+ // Said plainly, never a Zig error trace: a name another session's
+ // socket holds already (a plain session is named by its pid too).
+ return @import("detached/server.zig").run(init, opts, named) catch |err| {
+ var words: [128]u8 = undefined;
+ var said: [512]u8 = undefined;
+ const why = if (err == error.ListenFailed)
+ "its socket is taken: another session (a plain one is named by its pid) holds that name; choose another"
+ else
+ pardes.Messages.errorWords(err, &words);
+ std.Io.File.stderr().writeStreamingAll(init.io, std.fmt.bufPrint(&said, "pardes: --detach={s}: {s}\n", .{ named, why }) catch "pardes: --detach failed\n") catch {};
+ std.process.exit(1);
+ };
}
if (attach != null and pardes.platform != .tty and pardes.platform != .gui) {
try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty or gui shell\n");
diff --git a/src/mini.zig b/src/mini.zig
index a6aef15a..8af64201 100644
--- a/src/mini.zig
+++ b/src/mini.zig
@@ -239,9 +239,9 @@ test "Mini uses display cells for tabs combining text and wide characters" {
}
const tabs = try generate(gpa, "\tx\n", "");
defer tabs.deinit(gpa);
- const spaces = config.tab_width / 2;
+ const spaces = config.tab_now / 2;
for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte);
- try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]);
+ try std.testing.expectEqualStrings(if (config.tab_now % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]);
}
test "Mini chooses highlighted dots over plain ink with stable color ties" {
diff --git a/src/modal.zig b/src/modal.zig
index 11eda7b9..316a66fa 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -1773,12 +1773,10 @@ pub fn indentWidth(line: []const u8) usize {
return w;
}
-/// full indent LEVELS of a line as spaces (helix indent_level_for_line:
-/// partial levels round down) — what o/O/insert-newline copy.
+/// A line's indent as it is, tabs and spaces both, as acme's autoindent
+/// copies it: what o/O/insert-newline give the new line.
pub fn indentText(line: []const u8) []const u8 {
- const level = indentWidth(line) / INDENT_W;
- const max = " "; // 8 levels is plenty (ponytail)
- return max[0..@min(level * INDENT_W, max.len)];
+ return line[0 .. std.mem.indexOfNone(u8, line, " \t") orelse line.len];
}
/// Copy full indent levels and add one after (, [, {, or ), without parsing.
diff --git a/src/mouse.zig b/src/mouse.zig
index 21961cf8..8020fda8 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -965,6 +965,21 @@ fn reportGesture(
operand: exec.PointerOperand,
chord_arg: ?[]const u8,
) bool {
+ if ((p.panes[id] orelse return false).fs.readers == 0) return false;
+ return reportRange(p, id, cmd, text, on_tag, gestureRange(p, id, text, on_tag, operand), chord_arg);
+}
+
+/// An event reader told of a look or an exec of `text` at `range` of the
+/// tag or body, by a click or a key alike: false when the pane has none.
+pub fn reportRange(
+ p: *Pardes,
+ id: usize,
+ cmd: Builtin,
+ text: []const u8,
+ on_tag: bool,
+ range: ctlfs.pane.State.Range,
+ chord_arg: ?[]const u8,
+) bool {
const scripted = p.panes[id] orelse return false;
if (scripted.fs.readers == 0) return false;
const is_look = cmd == config.look_cmd;
@@ -973,7 +988,6 @@ fn reportGesture(
else
(if (on_tag) .tag_exec else .body_exec);
const named = exec.namesBuiltin(text);
- const range = gestureRange(p, id, text, on_tag, operand);
// No flag 2: the record already carries the word a click took, its
// range and text, and in acme flag 2 promises a second record with them
// (editors/acme/exec.c:154-183, look.c:42-68), which none follows here.
@@ -1044,7 +1058,9 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) };
}
-fn dispatchPointerBuiltin(
+/// A look or exec of what an operand took (exec.operandAt), from a
+/// click or a key: an event reader is told, else it runs.
+pub fn dispatchPointerBuiltin(
p: *Pardes,
id: usize,
cmd: Builtin,
@@ -1291,7 +1307,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
return;
}
};
- var operand = exec.pointerOperand(p, pane, clk);
+ var operand = exec.operandAt(p, pane, clk, true, s.button == config.look_button);
if (operand.expanded) |expanded|
pane.sel[b] = expanded
else if (operand.text == null)
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index adfc91fa..105de07c 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -193,6 +193,7 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep
// The root's lines run at the keyboard's pane: a refusal before any runs
// is that pane's too, in its err record.
p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 0;
+ if (editNotAlone(p, req.data)) return tree.failText(req.tag, E.INVAL, e_after_edit);
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
@@ -259,6 +260,26 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply {
pub const e_control = "invalid command line: it holds a control character (or DEL) other than a tab";
+/// An Edit whose `<`, `|` or `>` commands run answers its write when they
+/// are done (edit_cmd.zig); a line after it in the same write would run
+/// before them, on the text they are about to change. Refused up front,
+/// before any line runs.
+pub const e_after_edit = "invalid command line: an Edit that runs commands is a write of its own";
+
+/// Whether `data` holds an Edit that runs commands and any other line.
+fn editNotAlone(p: *Pardes, data: []const u8) bool {
+ const sam = @import("../sam_edit.zig");
+ var lines: usize = 0;
+ var runs = false;
+ var it = Messages.init(p, data);
+ while (it.next()) |line| {
+ if (line.len == 0 or exec_line.isComment(line)) continue;
+ lines += 1;
+ if (std.mem.startsWith(u8, line, "Edit") and sam.runsCommands(p.scratch.allocator(), line["Edit".len..])) runs = true;
+ }
+ return runs and lines > 1;
+}
+
/// Runs one click (`run`) as a 9P write's: a builtin that fails there fails
/// the write, with its words and an err record, and no msg, as a ctl line
/// does (runBuiltin). The refusal, or null.
@@ -297,7 +318,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i
/// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else
/// EIO.
pub fn failureErrno(failure: []const u8) u16 {
+ // Refused while something else runs (an Edit's commands): EBUSY, the
+ // word 9ns reads as it.
+ if (std.mem.indexOf(u8, failure, "busy: ") != null) return E.BUSY;
if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT;
+ // As 9ns reads the same words (cloud9.fs.enameErrno).
+ if (std.mem.indexOf(u8, failure, "not a dir") != null) return E.NOTDIR;
+ if (std.mem.indexOf(u8, failure, "is a dir") != null) return E.ISDIR;
+ if (std.mem.indexOf(u8, failure, "permission denied") != null) return 13; // EACCES
// A pattern refused (Edit's too) is malformed input, as 9ns reads it;
// so is a word this build has not (`invalid: acme's Put ...`).
if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.indexOf(u8, failure, "invalid") != null or
@@ -385,10 +413,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 +425,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();
@@ -407,7 +437,7 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
const f = pane_files.fileOf(pane);
const undo = if (f) |file| file.history.undo_len > 0 else false;
const redo = if (f) |file| file.history.redo_len > 0 else false;
- w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
+ w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_now, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
// A terminal bound as a REPL ends with its id, `python-a`.
var idbuf: [48]u8 = undefined;
const id = exec_line.replId(&idbuf, pane);
@@ -522,6 +552,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", arg);
},
.font => config.Runtime.FontSpec.parse(arg) != null,
+ // Bare, it says the width, as from a pane's exec.
+ .tab => arg.len == 0 or (std.fmt.parseInt(u16, arg, 10) catch 0) -% 1 < 16,
else => probe: {
var probe = p.settings;
break :probe probe.apply(setting, if (arg.len > 0) arg else null);
@@ -630,6 +662,7 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
.motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }),
.inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }),
.grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
+ .tab => try w.print("{s} {d}\n", .{ word, s.tab }),
}
}
}
@@ -789,7 +822,7 @@ fn sizeOf(line: []const u8) ?[2]u16 {
/// itself, so a new builtin is listed the moment it exists.
/// A builtin's doc comment, its first sentence, from the source itself: a
/// line of /commands says what each word does without a second copy of it.
-fn summaryOf(word: []const u8, buf: []u8) []const u8 {
+pub fn summaryOf(word: []const u8, buf: []u8) []const u8 {
const source = @embedFile("../builtins.zig");
var needle_buf: [64]u8 = undefined;
const needle = std.fmt.bufPrint(&needle_buf, "\npub const {s} = struct", .{word}) catch return "";
@@ -950,6 +983,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
const serial = pane.serial;
// Checked whole before anything applies, so a write that must wait for
// the lock has done nothing yet when it goes again.
+ if (editNotAlone(p, req.data)) return tree.failText(req.tag, E.INVAL, e_after_edit);
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
@@ -969,8 +1003,9 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
}
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
+ if (@import("../edit_cmd.zig").busyOn(p, p.paneBySerial(serial).?)) return tree.failText(req.tag, E.BUSY, "get: " ++ @import("../edit_cmd.zig").e_busy);
// Quoting the line, as a builtin's refusal (Save's) does.
- if (getRefused(p, pane)) |said_in_ename| {
+ if (getRefused(p, pane, "get")) |said_in_ename| {
// refuse writes ename, where the words are: copy them out.
var said_buf: [320]u8 = undefined;
const said = said_buf[0..said_in_ename.len];
@@ -1125,9 +1160,14 @@ const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
/// acme's get asks winclean first (exec.c:513): unsaved text is warned
/// about once, and the same get again, nothing edited since, discards it.
-fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
+/// Asked once over unsaved edits, by `word` (ctl's `get`, the Get builtin):
+/// the words of the refusal, in ename; null to go ahead.
+pub fn getRefused(p: *Pardes, pane: *Pane, word: []const u8) ?[]const u8 {
const f = pane_files.fileOf(pane) orelse return null;
if (!panes.Output.fileTraits(f.output).saves or f.revision == f.saved_revision) return null;
+ // A scratch under 100 bytes is not asked about, as Del and Exit do not
+ // ask (builtins.warnModifiedIn): nothing worth a question to lose.
+ if (f.output != null and f.content.len < 100) return null;
// Dirty by a rename alone is no text to lose: only edits are asked about.
const saved = f.saved_hash orelse return null;
if (std.hash.Wyhash.hash(0, f.content) == saved) return null;
@@ -1138,16 +1178,57 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
// write's err record logs it, not a msg too.
// And logged `unsaved <serial> <name>`, as the other refusals are.
tree.events.noteLog(p, .unsaved, pane);
- const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null;
+ const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\n{s} again to discard\n", .{ f.path, word }) catch null;
if (listed) |content| panes.Output.openUnsaved(p, id, content);
+ var notice: [64]u8 = undefined;
p.fs.unlogged = true;
- p.setMessage(id, "1 unsaved pane \u{2014} get again to discard");
+ p.setMessage(id, std.fmt.bufPrint(&notice, "1 unsaved pane \u{2014} {s} again to discard", .{word}) catch "1 unsaved pane");
p.fs.unlogged = false;
- return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
+ return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified ({s} again to discard)", .{ f.path, word }) catch "Modified (get again to discard)";
+}
+
+/// acme's `Get file`, and Edit's `e file`: the file at `path` (absolute)
+/// loaded into the pane, which takes its name, in one undo step that puts
+/// the old name back with the old text. Read first, so one that cannot be
+/// read changes nothing; loaded, the pane is clean. Asking about unsaved
+/// edits is the caller's (getRefused). Returns an errno, 0 when loaded.
+pub fn getFile(p: *Pardes, pane: *Pane, path: []const u8, failed: *anyerror) u16 {
+ const f = pane_files.fileOf(pane) orelse return E.INVAL;
+ const id = p.paneBySerial(pane.serial) orelse return E.NOENT;
+ const bytes = fs.read(p, path) catch |err| {
+ failed.* = err;
+ return switch (err) {
+ error.FileNotFound => E.NOENT,
+ else => E.IO,
+ };
+ };
+ panes.File.pushUndo(p, pane);
+ pane_files.nameBuffer(p, id, path, false) catch |err| {
+ p.gpa.free(bytes);
+ failed.* = err;
+ return E.INVAL;
+ };
+ panes.File.setContent(p, f, bytes);
+ f.saved_revision = f.revision;
+ f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.saved_len = f.content.len;
+ f.disk_newer = null;
+ f.disk_newer_warned = false;
+ f.disk_gone = false;
+ if (fs.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return 0;
}
-fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 {
+pub 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;
@@ -1215,7 +1296,7 @@ test "pane ctl read is acme's fields -- index's five, width in cells, font, tab
var want: std.ArrayList(u8) = .empty;
defer want.deinit(gpa);
try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} {d:>11} {d:>11} current ", .{
- pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0,
+ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_now, 0, 0,
});
try testing.expectEqualStrings(want.items, a.bytes);
try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size);
@@ -1765,6 +1846,348 @@ test "get reloads the pane from disk and reports a missing file without losing d
try testing.expect(pane_files.dirtyOf(pane));
}
+test "Get reads the pane's file again, refusing unsaved edits once, and Get file loads that file into the pane" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "the other\n" });
+ var directory_buf: [4096]u8 = undefined;
+ const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
+ const p = try withFile(testing.allocator, "in memory\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/note.txt\n", .{directory}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqualStrings("from disk\n", pane.file.?.content);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ // Edited: asked once, then read again.
+ _ = wr(p, Node.of(serial, .body), "unsaved\n");
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expect(p.fs.failures != failures);
+ try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqualStrings("from disk\n", pane.file.?.content);
+ // Get file: that file, by a name from the pane's directory.
+ try testing.expect(p.executeBuiltinLine(0, "Get other.txt"));
+ try testing.expectEqualStrings("the other\n", pane.file.?.content);
+ try testing.expect(std.mem.endsWith(u8, pane.file.?.path, "/other.txt"));
+ try testing.expect(!pane_files.dirtyOf(pane));
+}
+
+test "Putall saves every pane with unsaved edits to a file, one each, and leaves the rest" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ _ = wr(p, Node.of(first, .name), "/tmp/pardes-putall-a.txt\n");
+ _ = wr(p, Node.of(first, .body), "edited\n");
+ const second = p.freeSlot().?;
+ _ = try p.newDocPane(second);
+ p.panes[second].?.file = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-putall-b.txt"), .content = try p.gpa.dupe(u8, "clean\n"), .history = try panes.File.History.create(p.gpa) };
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.executeBuiltinLine(0, "Putall"));
+ var saves: usize = 0;
+ while (p.nextEffect()) |e| if (e == .save_file) {
+ saves += 1;
+ try testing.expectEqual(@as(u16, 0), e.save_file.pane);
+ };
+ try testing.expectEqual(@as(usize, 1), saves);
+}
+
+test "Zerox makes a second pane on one buffer: one text, undo and dirty state, its own cursor; Save cleans both, Del of one asks nothing" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(first, .name), try std.fmt.bufPrint(&name, "{s}/z.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ const twin_id = for (p.panes, 0..) |slot, id| {
+ if (id != 0) if (slot) |pane| if (pane.file != null) break id;
+ } else return error.NoTwin;
+ const a = p.panes[0].?;
+ const b = p.panes[twin_id].?;
+ // Both in the index, each its serial, one name.
+ const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes;
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, index, "/z.txt"));
+ // b's cursor on `three`; a line written above it in a moves it down.
+ b.body.cur_row = 2;
+ b.body.cur_col = 1;
+ _ = wr(p, Node.of(first, .addr), "0");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(first, .data), "zero\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ try testing.expectEqual(a.file.?.content.ptr, b.file.?.content.ptr);
+ try testing.expectEqual(@as(i32, 3), b.body.cur_row);
+ try testing.expectEqual(@as(i32, 1), b.body.cur_col);
+ try testing.expect(pane_files.dirtyOf(a) and pane_files.dirtyOf(b));
+ // A write through b is a's too; Undo in a takes it back from both.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "four\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfour\n", a.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ // Saved from b: both come clean.
+ try testing.expect(p.executeBuiltinLine(twin_id, "Save"));
+ while (p.nextEffect()) |e| p.perform(e);
+ try testing.expect(!pane_files.dirtyOf(a) and !pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", p.fallback.get(b.file.?.path).?);
+ // Edited again, Del of a asks nothing: b holds the text.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "five\n").reply.status);
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Del"));
+ p.sync();
+ try testing.expectEqual(failures, p.fs.failures);
+ try testing.expect(p.paneBySerial(first) == null);
+ try testing.expect(pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfive\n", b.file.?.content);
+ // Alone now, it is no twin; Del asks about it as about any pane.
+ try testing.expectEqual(@as(u32, 0), b.file.?.twin);
+ try testing.expect(p.executeBuiltinLine(p.paneBySerial(b.serial).?, "Del"));
+ try testing.expect(p.fs.failures != failures);
+}
+
+test "Undo after a load under another name (Get file, Edit e) puts the old name back with the old text and its clean state; Putall then writes nothing over the loaded file" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "other\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "keep.txt", .data = "keep\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var other_buf: [4200]u8 = undefined;
+ const other = try std.fmt.bufPrint(&other_buf, "{s}/other.txt", .{dir});
+ var keep_buf: [4200]u8 = undefined;
+ const keep = try std.fmt.bufPrint(&keep_buf, "{s}/keep.txt", .{dir});
+ for ([_][]const u8{ "Get keep.txt", "Edit e keep.txt" }) |load| {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [4300]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{other}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqualStrings("other\n", pane.file.?.content);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ // Loaded: keep.txt's text under its name.
+ try testing.expect(p.executeBuiltinLine(0, load));
+ try testing.expectEqualStrings("keep\n", pane.file.?.content);
+ try testing.expectEqualStrings(keep, pane.file.?.path);
+ if (std.mem.startsWith(u8, load, "Get")) try testing.expect(!pane_files.dirtyOf(pane));
+ // Undo: other.txt's text, other.txt's name, clean as it was.
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("other\n", pane.file.?.content);
+ try testing.expectEqualStrings(other, pane.file.?.path);
+ try testing.expect(!pane_files.dirtyOf(pane));
+ // Putall saves nothing, and nothing lands on keep.txt.
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.executeBuiltinLine(0, "Putall"));
+ while (p.nextEffect()) |e| try testing.expect(e != .save_file);
+ // Redo: keep.txt again.
+ try testing.expect(p.executeBuiltinLine(0, "Redo"));
+ try testing.expectEqualStrings(keep, pane.file.?.path);
+ try testing.expectEqualStrings("keep\n", pane.file.?.content);
+ }
+}
+
+test "a Get file that fails changes nothing: no such file, a directory, or unsaved edits asked about once" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "here.txt", .data = "here\n" });
+ try tmp.dir.createDirPath(testing.io, "sub");
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [4300]u8 = undefined;
+ const here = try std.fmt.bufPrint(&name, "{s}/here.txt\n", .{dir});
+ _ = wr(p, Node.of(serial, .name), here);
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ const undo_len = pane.file.?.history.undo_len;
+ for ([_][]const u8{ "Get nosuch.txt", "Get sub" }) |line| {
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, line));
+ try testing.expect(p.fs.failures != failures);
+ try testing.expectEqualStrings(here[0 .. here.len - 1], pane.file.?.path);
+ try testing.expectEqualStrings("here\n", pane.file.?.content);
+ try testing.expectEqual(undo_len, pane.file.?.history.undo_len);
+ }
+ // Edited, the first Get asks and renames nothing.
+ _ = wr(p, Node.of(serial, .body), "more\n");
+ try testing.expect(p.executeBuiltinLine(0, "Get other.txt"));
+ try testing.expectEqualStrings(here[0 .. here.len - 1], pane.file.?.path);
+ try testing.expectEqualStrings("here\nmore\n", pane.file.?.content);
+}
+
+test "Zerox twins dump their text once and come back on one buffer from a Restore" {
+ const p = try withFile(testing.allocator, "one\ntwo\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ _ = wr(p, Node.of(first, .name), "/tmp/pardes-zerox-dump.txt\n");
+ _ = wr(p, Node.of(first, .body), "unsaved " ** 64 ++ "\n");
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ try pardes.dump.dumpState(p);
+ const text = p.dump_out.?;
+ // The text is kept once though three panes show it.
+ const encoded = try pardes.dump.encodeBytes(p.scratch.allocator(), p.panes[0].?.file.?.content);
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, encoded));
+ const restored = try pardes.dump.restore(p, text, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ var files: [3]*pardes.panes.File.State = undefined;
+ var n: usize = 0;
+ for (restored.panes) |slot| if (slot) |pane| if (pane.file) |*f| if (std.mem.eql(u8, f.path, "/tmp/pardes-zerox-dump.txt")) {
+ files[n] = f;
+ n += 1;
+ };
+ try testing.expectEqual(@as(usize, 3), n);
+ for (files[1..]) |f| {
+ try testing.expectEqual(files[0].content.ptr, f.content.ptr);
+ try testing.expectEqual(files[0].history, f.history);
+ try testing.expect(f.twin != 0 and f.twin == files[0].twin);
+ }
+ try testing.expect(std.mem.startsWith(u8, files[0].content, "one\ntwo\nunsaved "));
+}
+
+test "a bad Tab width and a bad Incl directory are refused invalid, changing nothing; a twin's rename is each twin's" {
+ defer config.tab_now = config.tab_width;
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ for ([_][]const u8{ "Tab 0\n", "Tab 17\n", "Tab x\n" }) |line| {
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, line).errno());
+ // Run from a pane, as a tag click runs it: said invalid too.
+ const r = wr(p, Node.of(serialOf(p), .exec), line);
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "invalid tab width") != null);
+ }
+ try testing.expectEqual(config.tab_width, config.tab_now);
+ const before = p.incl.items.len;
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Incl rel/dir\n").errno());
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Incl - /usr/include\n").errno());
+ if (comptime pardes.hosted) try testing.expectEqual(E.NOENT, wr(p, root_ctl, "Incl /tmp/pardes-surely-no-such-dir\n").errno());
+ try testing.expectEqual(before, p.incl.items.len);
+ // A twin renamed: a rename record for each.
+ const first = serialOf(p);
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ const twin = for (p.panes) |slot| {
+ if (slot) |pane| if (pane.serial != first and pane.file != null) break pane.serial;
+ } else return error.NoTwin;
+ _ = wr(p, Node.of(first, .name), "/tmp/pardes-twin-renamed.txt\n");
+ var rec: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /tmp/pardes-twin-renamed.txt\n", .{first})));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /tmp/pardes-twin-renamed.txt\n", .{twin})));
+}
+
+test "Undo to unsaved text under its old name says nothing changed on disk when nothing did, and Save then writes" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "e.txt", .data = "e\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "b\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ for ([_][]const u8{ "Get b.txt", "Edit e b.txt" }) |load| {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var name: [4300]u8 = undefined;
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/e.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ _ = wr(p, Node.of(serial, .body), "appended\n");
+ // Asked once, then loaded.
+ try testing.expect(p.executeBuiltinLine(0, load));
+ try testing.expect(p.executeBuiltinLine(0, load));
+ try testing.expectEqualStrings("b\n", pane.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("e\nappended\n", pane.file.?.content);
+ try testing.expect(pane_files.dirtyOf(pane));
+ // The watch looks again at e.txt, as it was all along.
+ pardes.panes.File.changed(p, 0, "e\n");
+ try testing.expectEqual(@as(?u64, null), pane.file.?.disk_newer);
+ try testing.expect(!th.logHas(p, "changed on disk"));
+ // A change there is still news.
+ pardes.panes.File.changed(p, 0, "someone else\n");
+ try testing.expect(pane.file.?.disk_newer != null);
+ }
+}
+
+test "round 39's small refusals: one name an open, small scratches unasked, twins saved once, bare Tab on ctl, and errnos in words a mount reads" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "noread.txt", .data = "secret\n" });
+ try tmp.dir.createDirPath(testing.io, "sub");
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const pane = p.panes[0].?;
+ var line: [4300]u8 = undefined;
+ // One name an open: a second line, in the write or after, is refused.
+ try testing.expectEqual(E.INVAL, wr(p, Node.of(serial, .name), "/tmp/pardes-a\nb").errno());
+ const before = try testing.allocator.dupe(u8, pane.file.?.path);
+ defer testing.allocator.free(before);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .name), .omode = 1 }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = Node.of(serial, .name), .handle = h, .data = "/tmp/pardes-one.txt\n" }).reply.status);
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 3, .op = .write, .node = Node.of(serial, .name), .handle = h, .data = "b" }).errno());
+ _ = call(p, .{ .tag = 4, .op = .release, .node = Node.of(serial, .name), .handle = h, .opened = true });
+ tree.runClosedLines(p);
+ try testing.expectEqualStrings("/tmp/pardes-one.txt", pane.file.?.path);
+ // Bare Tab on the root ctl says the width, as from a pane.
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Tab\n").reply.status);
+ // Errnos a mount reads from the words.
+ _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Get"));
+ try testing.expectEqual(E.ISDIR, wr(p, Node.of(serial, .exec), "Get sub\n").errno());
+ if (comptime pardes.hosted) {
+ var noread_buf: [4300]u8 = undefined;
+ _ = std.c.chmod(try std.fmt.bufPrintSentinel(&noread_buf, "{s}/noread.txt", .{dir}, 0), 0);
+ try testing.expectEqual(@as(u16, 13), wr(p, Node.of(serial, .exec), "Get noread.txt\n").errno());
+ try testing.expectEqual(E.NOTDIR, wr(p, Node.of(serial, .exec), try std.fmt.bufPrint(&line, "Incl {s}/f.txt\n", .{dir})).errno());
+ const zero = wr(p, Node.of(serial, .look), "/dev/zero\n");
+ _ = zero;
+ try testing.expect(th.logHas(p, "not a regular file"));
+ }
+ // Twins saved once by Putall.
+ _ = wr(p, Node.of(serial, .body), "edited\n");
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.executeBuiltinLine(0, "Putall"));
+ var saves: usize = 0;
+ while (p.nextEffect()) |e| if (e == .save_file) {
+ saves += 1;
+ };
+ try testing.expectEqual(@as(usize, 1), saves);
+ // A scratch of 2 bytes is loaded over unasked.
+ try testing.expect(p.executeBuiltinLine(0, "New"));
+ const scratch = p.panes[p.active].?;
+ _ = pane_files.spliceBody(p, scratch, 0, 0, "hi") orelse return error.NoScratch;
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Get {s}/f.txt", .{dir})));
+ try testing.expectEqual(failures, p.fs.failures);
+ try testing.expectEqualStrings("f\n", scratch.file.?.content);
+}
+
+test "Get of a file not there says so without asking about unsaved edits; the init file saved says a bad line as startup does" {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .startup_config = "Theme dusk\n", .startup_config_path = "/tmp/pardes-init-test/init" });
+ defer p.deinit();
+ const pane = try p.setTestFile("x\n");
+ _ = wr(p, Node.of(pane.serial, .body), "unsaved\n");
+ try testing.expect(p.executeBuiltinLine(0, "Get /tmp/pardes-surely-no-such-file.txt"));
+ try testing.expect(th.logHas(p, "no such file"));
+ try testing.expect(!th.logHas(p, "again to discard"));
+ try testing.expect(pane.discard_warned == null);
+ pardes.exec.reapplyInitFile(p, "/tmp/pardes-init-test/init", "Theme acme\nnosuchword 1\nNewcol\n");
+ try testing.expect(th.logHas(p, "err - init file line 2: unknown word \"nosuchword 1\""));
+ try testing.expect(!th.logHas(p, "init file line 3"));
+}
+
test "a body write to a terminal is typed input, not a paste, even to a program that asked for bracketed paste" {
const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30 });
defer p.deinit();
@@ -1855,6 +2278,15 @@ test "a builtin for one kind of pane is refused on another, and still acts on it
const glyphs = doc.image.?.glyph_art;
try testing.expectEqual(Status.ok, wr(p, Node.of(doc.serial, .ctl), "Petscii\n").reply.status);
try testing.expect(doc.image.?.glyph_art != glyphs);
+ // Its tag says what is drawn: with no graphics, glyph art whatever
+ // Petscii chose; with them, what it chose.
+ for ([_]bool{ false, true }) |art| {
+ doc.image.?.glyph_art = art;
+ p.native_images = false;
+ try testing.expect(std.mem.indexOf(u8, try pardes.tagline.tagPrefix(p, doc), " petscii:on ") != null);
+ p.native_images = true;
+ try testing.expect(std.mem.indexOf(u8, try pardes.tagline.tagPrefix(p, doc), if (art) " petscii:on " else " petscii:off ") != null);
+ }
}
test "exec Save writes the named file through the host" {
@@ -2411,7 +2843,8 @@ test "Shell refuses a path that is no executable, and bare it goes back to the d
try testing.expectEqual(Status.err, refused.reply.status);
try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: shell \"/nonexistent/zzsh\" not found") != null);
try testing.expect(th.logHas(p, "shell \"/nonexistent/zzsh\" not found"));
- try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno());
+ // Its words say a directory, which a mount reads as EISDIR too.
+ try testing.expectEqual(E.ISDIR, wr(p, root_ctl, "Shell /etc\n").errno());
try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory"));
try testing.expectEqualStrings("", p.settings.shell.requested.get());
try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status);
@@ -2652,6 +3085,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" } });
@@ -2722,7 +3156,7 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot
// The host's own failure is the waiting write's (late_failure).
p.fs.late_failure_len = 0;
p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied);
- try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expectEqualStrings("shell: permission denied", p.fs.late_failure[0..p.fs.late_failure_len]);
}
test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" {
@@ -3137,13 +3571,16 @@ test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask)
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
const serial = serialOf(p);
- for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n", "Tab\n", "Indent\n", "Local\n", "Incl\n", "Abort\n" }) |line| {
+ for ([_][]const u8{ "Snarf\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
const r = wr(p, node, line);
try testing.expectEqual(E.INVAL, r.errno());
try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is not a pardes builtin") != null);
}
}
+ // Load is Restore: whatever it says, never acme's word refused.
+ const load = wr(p, Node.of(serial, .exec), "Load /tmp/pardes-surely-no-dump.zon\n");
+ try testing.expect(std.mem.indexOf(u8, load.reply.ename, "is not a pardes builtin") == null);
// Put saves, as Save does.
_ = wr(p, Node.of(serial, .name), "/tmp/pardes-put.txt\n");
const put = wr(p, Node.of(serial, .exec), "Put\n");
@@ -3556,6 +3993,53 @@ test "Back and Forward over a closed file's entries: one reopen is that file's p
try testing.expectEqual(c_serial, p.panes[p.active].?.serial);
}
+test "Back and Forward written to exec keep to the focused file, and JumpScope all, written to ctl, takes every place in order" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "row\n" ** 100 });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "row\n" ** 100 });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ // Each a look, as a user's jumps are: b1 a1 b40 a40 b80 a80.
+ for ([_][]const u8{ "b.txt:1", "a.txt:1", "b.txt:40", "a.txt:40", "b.txt:80", "a.txt:80" }) |place| {
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/{s}\n", .{ dir, place })).reply.status);
+ p.sync();
+ }
+ const Step = struct { word: []const u8, file: []const u8, line: i32 };
+ const walk = struct {
+ fn run(q: *Pardes, steps: []const Step) !void {
+ for (steps) |s| {
+ var cmd: [16]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(q, root_exec, try std.fmt.bufPrint(&cmd, "{s}\n", .{s.word})).reply.status);
+ q.sync();
+ const pane = q.panes[q.active].?;
+ try testing.expect(std.mem.endsWith(u8, pane.file.?.path, s.file));
+ try testing.expectEqual(s.line, pane.body.cur_row + 1);
+ }
+ }
+ }.run;
+ // a's places first, b only once a has none older; Forward mirrors it
+ // from b and ends at a80, where it began.
+ try walk(p, &.{
+ .{ .word = "Back", .file = "/a.txt", .line = 40 },
+ .{ .word = "Back", .file = "/a.txt", .line = 1 },
+ .{ .word = "Back", .file = "/b.txt", .line = 1 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 40 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 80 },
+ .{ .word = "Forward", .file = "/a.txt", .line = 80 },
+ });
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "JumpScope all\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.ctl), 0, 8192).bytes, "JumpScope all\n") != null);
+ try walk(p, &.{
+ .{ .word = "Back", .file = "/b.txt", .line = 80 },
+ .{ .word = "Back", .file = "/a.txt", .line = 40 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 80 },
+ });
+}
+
test "Recent keeps only files on disk: not a name never saved, nor the served tree's" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 10e91138..3910ac05 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -728,11 +728,13 @@ fn scanNumber(data: []const u8, i: *usize, overflow: *bool) ?u32 {
/// An empty range written back: what a click at `at` takes, as the mouse's
/// does (exec.pointerOperand). In a diff pane's body that is look.diffClick's
/// answer, the line's address or its code's words.
-pub fn expandAt(p: *Pardes, pane: *Pane, whole: []const u8, at: usize, in_body: bool) []const u8 {
+/// `for_look`: only a look in a body takes a diff line's address
+/// (exec.operandAt); an exec takes the word there, as anywhere.
+pub fn expandAt(p: *Pardes, pane: *Pane, whole: []const u8, at: usize, in_body: bool, for_look: bool) []const u8 {
const start = if (std.mem.lastIndexOfScalar(u8, whole[0..at], '\n')) |nl| nl + 1 else 0;
const end = std.mem.indexOfScalarPos(u8, whole, at, '\n') orelse whole.len;
var word_from: usize = 0;
- if (in_body) diff: {
+ if (in_body and for_look) diff: {
const row = std.mem.count(u8, whole[0..start], "\n");
// Only when the served line is the one the diff reads there.
const lines = look.diffLines(p, pane) orelse break :diff;
@@ -794,7 +796,7 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply {
// An empty range with no text is a click at that point: expanded as
// the click would be, to the word or file name under it (acme's
// expand, look.c).
- const said = if (r.q0 == r.q1) r.text orelse expandAt(p, pane, whole, lo, !r.action.onTag()) else whole[lo..hi];
+ const said = if (r.q0 == r.q1) r.text orelse expandAt(p, pane, whole, lo, !r.action.onTag(), r.action == .body_look or r.action == .tag_look) else whole[lo..hi];
const text = p.scratch.allocator().dupe(u8, said) catch continue;
switch (r.action) {
// A body's click written back is done as the click would have
@@ -1403,7 +1405,7 @@ test "a click in the body reports the offsets of the word it takes, so the recor
const rect = p.rects[id];
const x0 = rect.x + config.GUTTER + config.PREFIX_W;
const y0 = if (p.settings.tag_bottom) rect.y else rect.y + p.panes[id].?.tag_rows;
- const tab: u16 = config.tab_width;
+ const tab: u16 = config.tab_now;
const Click = struct { x: u16, y: u16, look: []const u8, exec: []const u8, scroll: i32 = 0 };
// Where the wrapped line's `qty` landed, as drawn.
const surface = try p.render(frame.allocator());
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index ae4dcd71..3b70d62f 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -751,7 +751,7 @@ pub fn applyTagTruncation(p: *Pardes, pane: *Pane) void {
pane.tag.own = empty;
// what is left is the prefix; the cursor goes after it
pane.tag.cur_row = 0;
- pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
+ pane.tag.cur_col = if (tagline.pathPrefix(p, p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
pane.tag.vsel.active = false;
pane.tag.nsel = 0;
pf.tag_rewrite = true;
@@ -982,7 +982,8 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
if (std.mem.indexOfAny(u8, full, "\x00\n") != null) return error.InvalidName;
if (std.mem.eql(u8, f.path, full)) return;
const copy = try p.gpa.dupe(u8, full);
- p.gpa.free(f.path);
+ // The name it ends is kept for an undo back across this rename.
+ panes.File.noteRenamed(p.gpa, f.history, f.path);
f.path = copy;
if (panes.Output.fileTraits(f.output).saves) {
f.output = null;
@@ -1014,7 +1015,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/ninep/tree.zig b/src/ninep/tree.zig
index 1990d09e..8d3c599f 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -937,6 +937,8 @@ fn remove(p: *Pardes, req: Req) Reply {
};
if (t.file != .dir) return Reply.fail(req.tag, E.PERM);
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
+ // A pane an Edit's running commands will change stays until they end.
+ if (@import("../edit_cmd.zig").busyOn(p, id)) return failText(req.tag, E.BUSY, @import("../edit_cmd.zig").e_busy);
p.removePane(id, null) catch return Reply.fail(req.tag, E.IO);
return .{ .tag = req.tag };
}
@@ -1270,6 +1272,16 @@ fn writeLines(p: *Pardes, req: Req, target: Target) Reply {
data = data[nl + 1 ..];
if (data.len == 0) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+ // One name an open, on one line: anything after its newline, in this
+ // write or a later one, is refused by the write that brings it, never
+ // left to rename the pane again at the close (`printf 'a\nb' > name`).
+ if (target == .pane and target.pane.file == .name) {
+ const past_newline = if (std.mem.indexOfScalar(u8, data, '\n')) |nl| nl + 1 < data.len else false;
+ if (o.named or past_newline) {
+ o.pending.clearRetainingCapacity();
+ return failText(req.tag, E.INVAL, "invalid file name: one name a write, on one line");
+ }
+ }
o.pending.appendSlice(p.gpa, data) catch return Reply.fail(req.tag, E.NOMEM);
// A line runs when its newline comes, or, the last one, when its open
// is let go (release). Nothing is inferred from a write's size: a mount
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 38373266..2a74e441 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -46,6 +46,8 @@ pub const Region = @import("surface.zig").Region;
pub const Chrome = @import("surface.zig").Chrome;
pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS;
pub const builtins = @import("builtins.zig");
+/// Every builtin as JSON, for the docs (tools/gen_builtins_json.zig).
+pub const builtins_json = @import("builtins_json.zig");
const limits = memory.limits;
const selection_pipe = @import("selection_pipe.zig");
pub const config = @import("config.zig");
@@ -218,6 +220,7 @@ pub const Turn = struct {
while (t.lsp_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return;
}
+
pub fn awaitRestored(t: *Turn, restores: u64) void {
while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return;
}
@@ -477,9 +480,11 @@ pub const Loc = struct {
test {
_ = @import("tagline.zig");
+ _ = @import("builtins_json.zig");
_ = @import("exec.zig");
_ = @import("look.zig");
_ = @import("sam_edit.zig");
+ _ = @import("edit_cmd.zig");
_ = @import("mouse.zig");
_ = @import("normal.zig");
_ = @import("regexp.zig");
@@ -1983,6 +1988,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;
@@ -3145,6 +3151,74 @@ test "Unicode display cells map back to body and tag byte cursors" {
try std.testing.expectEqualStrings(before, tagline.curTail(pane));
}
+test "Tab N draws every pane's tab N cells wide and clicks count past it; bare Tab says the width; the ctl read and a Dump keep it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ // The width is the process's: the next test starts from the default.
+ defer config.tab_now = config.tab_width;
+ const pane = try p.setTestFile("\tx = 1\n");
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab 8"));
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const text_x = rect.x + config.GUTTER + config.PREFIX_W;
+ const body_y = rect.y + BOX_H;
+ try std.testing.expectEqualStrings("x", surface.at(text_x + 8, body_y).grapheme());
+ // A click on the x is on its byte, past the tab.
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = text_x + 8, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 8, .row = body_y } });
+ try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col);
+ // Bare, it says the width.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab"));
+ try std.testing.expectEqualStrings("Tab 8", pane.msg[0..pane.msg_len]);
+ // Out of range: refused, the width kept.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab 0"));
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+ // The ctl read's tab column, and a Dump's settings.
+ const ctl_text = pardes_ctlfs_ctlText(p, pane);
+ try std.testing.expect(std.mem.indexOf(u8, ctl_text, " 8 ") != null);
+ try dump.dumpState(p);
+ const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ try std.testing.expectEqual(@as(u16, 8), restored.settings.tab);
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+}
+
+fn pardes_ctlfs_ctlText(p: *Pardes, pane: *Pane) []const u8 {
+ const th = @import("ninep/testing.zig");
+ return th.rd(p, ctlfs.Node.of(pane.serial, .ctl), 0, 4096).bytes;
+}
+
+test "a name typed into the tag's path survives Esc, pending, and the next Enter renames; a second Esc drops it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("x\n");
+ for ([_]bool{ true, false }) |enter| {
+ tagline.enterTag(p, pane);
+ pane.tag.mode = .insert;
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = 1;
+ p.update(.{ .key = .{ .cp = 'z', .text = "z" } });
+ try std.testing.expect(pane.prompt == .name);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(pane.prompt == .name);
+ // A stray key changes nothing while it waits.
+ p.update(.{ .key = .{ .cp = 'q', .text = "q" } });
+ if (enter) {
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqualStrings("/ztest.txt", pane.file.?.path);
+ } else {
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(pane.prompt != .name);
+ try std.testing.expectEqualStrings("/ztest.txt", pane.file.?.path);
+ }
+ }
+}
+
test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -3657,6 +3731,8 @@ pub const Effect = union(enum) {
/// Snapshot the matching request with pipeRequest(id), then run it away
/// from the UI/event loop and answer with pipe_resp.
pipe: struct { id: u32 },
+ /// Write what an Edit's `w` took (`Pipe.writes`), in order.
+ edit_write,
watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile },
theme_file: struct { generation: u32, on: bool },
/// Write the build-time theme ring below the per-user config directory.
@@ -4320,6 +4396,14 @@ pub const Pardes = struct {
/// The files opened lately, closed ones too (recent.zig): `Recent`,
/// /recent, and the jumplist's way back to a closed pane.
recent: recent_files.List = .{},
+ /// acme's Incl: the directories a look at a name that is nowhere else
+ /// (`stdio.h`, `<foo.h>`) is tried in, in order. The session's, seeded
+ /// with C's (/usr/include, /usr/local/include), kept in a dump.
+ incl: std.ArrayList([]u8) = .empty,
+ /// Zerox's groups of panes on one buffer (File.syncTwins), and the id
+ /// the next takes.
+ twin_groups: std.ArrayList(panes.File.TwinGroup) = .empty,
+ next_twin: u32 = 1,
/// The kept list was read (or there was none): only then is it written,
/// so a session never writes over one it did not read.
recent_loaded: bool = false,
@@ -4610,6 +4694,8 @@ pub const Pardes = struct {
}
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ // A new session draws a tab at the default until `Tab` says otherwise.
+ config.tab_now = config.tab_width;
const p = try create(gpa, opts);
errdefer p.deinit();
p.boot = Boot.of(opts);
@@ -4705,6 +4791,13 @@ pub const Pardes = struct {
}
p.sync();
p.loadRecent();
+ for (config.default_incl) |dir| {
+ const copy = try gpa.dupe(u8, dir);
+ p.incl.append(gpa, copy) catch |err| {
+ gpa.free(copy);
+ return err;
+ };
+ }
exec.applyStartupConfig(p);
colors.finishThemeInitialization(p);
p.sync();
@@ -4812,6 +4905,10 @@ pub const Pardes = struct {
for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane);
p.saveRecent();
p.recent.deinit(p.recentGpa());
+ for (p.incl.items) |dir| p.gpa.free(dir);
+ p.incl.deinit(p.gpa);
+ for (p.twin_groups.items) |group| p.gpa.free(group.path);
+ p.twin_groups.deinit(p.gpa);
p.fs.batch.bytes.deinit(p.gpa);
if (p.stand_in) |pane| p.teardownPane(pane);
look.cancelLookHover(p);
@@ -4841,7 +4938,7 @@ pub const Pardes = struct {
if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
if (p.chord_arg) |a| gpa.free(a);
for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir);
- if (p.pipe.wait) |*wait| wait.deinit(gpa);
+ p.pipe.deinit(gpa);
p.locations_cache.deinit(p.tree_sitter_gpa);
p.fs.deinit(gpa);
p.shell_rows.reset(gpa);
@@ -4946,6 +5043,7 @@ pub const Pardes = struct {
// to it reopen it there.
p.noteClosed(pane);
panes.Output.dropUnsaved(p, pane);
+ if (pane.file) |*file| panes.File.leaveTwins(p, file);
const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf();
for (p.panes, 0..) |slot, id| if (slot == pane) {
if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
@@ -5622,6 +5720,8 @@ pub const Pardes = struct {
}
pub fn perform(p: *Pardes, e: Effect) void {
+ // A Save performed on one twin is every twin's (File.syncTwins).
+ defer panes.File.syncTwins(p);
const v = p.host.vtable;
switch (e) {
// The host reads the pane (its command, its shell) as it forks,
@@ -5726,6 +5826,37 @@ pub const Pardes = struct {
f(p.host.ctx, q.id)
else
p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }),
+ .edit_write => {
+ // Each write takes the bytes `w` took when it ran: the text
+ // as it was before the Edit's changes.
+ var writes = p.pipe.writes;
+ p.pipe.writes = .empty;
+ defer {
+ for (writes.items) |*w| w.deinit(p.gpa);
+ writes.deinit(p.gpa);
+ }
+ for (writes.items) |w| {
+ const was = if (p.panes[w.pane]) |pane| (if (pane.file) |f| f.saved_revision else 0) else 0;
+ const failures = p.fs.failures;
+ makeParents(w.path);
+ p.hostWriteFile(w.pane, w.path, w.bytes);
+ const pane = p.panes[w.pane] orelse continue;
+ if (pane.serial != w.serial) continue;
+ const f = if (pane.file) |*file| file else continue;
+ // Failed, it changes nothing, as save_text's does not.
+ if (p.fs.failures != failures) {
+ f.saved_revision = was;
+ continue;
+ }
+ if (w.promote) {
+ p.promoteSaved(w.pane, w.path);
+ } else if (w.clean != null and w.clean.? == f.revision) {
+ f.saved_revision = f.revision;
+ f.disk_gone = false;
+ ctlfs.events.noteLog(p, .save, pane);
+ } else ctlfs.events.noteLogAs(p, .save, pane, w.path);
+ }
+ },
.watch => |w| if (v.watch_file) |f|
f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode)
else {
@@ -5802,6 +5933,13 @@ pub const Pardes = struct {
/// that changes a pane costs a frame, which is why a round trip on a
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
+ // The held write of an Edit whose commands run, asked again by a
+ // retry of its park: still waiting, or its answer, never run twice.
+ if (p.fs.edit_hold) |*h| if (h.names(req)) {
+ if (!h.done) return .{ .tag = req.tag, .status = .again };
+ return filesystem.EditHold.answer(&p.fs);
+ };
+ defer panes.File.syncTwins(p);
if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
// An address, a limit or the mark flag is nothing drawn: a script's
// addr write then its close costs no frame between them.
@@ -5811,6 +5949,7 @@ pub const Pardes = struct {
look.cancelLookHover(p);
p.fs.serving = true;
p.fs.lsp_answer_at = null;
+ p.fs.edit_started = false;
const reply = ctlfs.handle(p, req);
p.fs.serving = false;
// The one-failure rule is a write's: a refused open (its OTRUNC's
@@ -5832,6 +5971,7 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
+ defer panes.File.syncTwins(p);
defer p.reportFocus();
// A tick with nothing animating is the one event that cannot change
// the screen.
@@ -6242,16 +6382,28 @@ pub const Pardes = struct {
}
if (header == null and pane.prompt == .name) {
// Enter or the execute key commits the draft and the keyboard
- // goes back to the body; Esc drops it.
+ // goes back to the body. Esc while typing keeps the draft,
+ // pending, as Esc keeps any edit: Enter then renames, and a
+ // second Esc drops it. Pending, no other key changes it.
if (hit(key, config.look_key) or hit(key, config.exec_key)) {
if (tagline.commitTagName(p, pane)) pane.focus = .body;
return;
}
+ const pending = pane.input.mode != .insert;
+ if (hit(key, config.escape) and !pending) {
+ pane.input.mode = .normal;
+ if (p.paneIdOf(pane)) |id| p.setMessage(id, "name drafted: Enter renames the buffer, Esc drops the draft");
+ return;
+ }
if (hit(key, config.escape)) {
pane.closePrompt();
pane.focus = .body;
return;
}
+ if (pending) {
+ if (p.paneIdOf(pane)) |id| p.setMessage(id, "name drafted: Enter renames the buffer, Esc drops the draft");
+ return;
+ }
edit.handleInsert(p, &pane.input, key);
return;
}
@@ -6292,12 +6444,13 @@ pub const Pardes = struct {
const range = t.primaryRange(shown, 0);
const lo = @min(range.anchor, range.head);
const hi = @max(range.anchor, range.head);
- const txt = if ((t.vsel.active and t.vsel.explicit) or t.msel.active)
- shown[lo..hi]
+ const span: [2]usize = if ((t.vsel.active and t.vsel.explicit) or t.msel.active)
+ .{ lo, hi }
else word: {
const b = config.wordBounds(shown, modal.selectionCursor(shown, range));
- break :word shown[b.lo..b.hi];
+ break :word .{ b.lo, b.hi };
};
+ const txt = shown[span[0]..span[1]];
// A column tag's command acts on that column, empty or not.
p.exec_column = if (header != null) p.header_column else null;
defer p.exec_column = null;
@@ -6306,7 +6459,12 @@ pub const Pardes = struct {
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
- if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt);
+ if (txt.len == 0) return;
+ // A pane's tag word: an event reader hears it as a click on
+ // it, at its place in the tag (flatSurface is the tag's text).
+ if (header == null and t.what == .tag and
+ mouse.reportRange(p, p.active, cmd, txt, true, .{ .q0 = @intCast(span[0]), .q1 = @intCast(span[1]) }, null)) return;
+ exec.runBuiltin(p, cmd, p.active, "", txt);
return;
}
edit.pinPaneCursor(p, &pane.body);
@@ -6323,9 +6481,11 @@ pub const Pardes = struct {
return;
}
}
- const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return;
- const word = edit.selectionText(p, pane, sel) catch return;
- exec.runBuiltinFrom(p, cmd, p.active, "", word, true);
+ // The cursor's word as a click there takes it: one expansion
+ // (exec.operandAt), a diff line's address and all, and an
+ // event reader told as a click tells it.
+ const operand = exec.operandAt(p, pane, look.cursorWordSel(p, pane), false, cmd == config.look_cmd);
+ mouse.dispatchPointerBuiltin(p, p.active, cmd, operand.text, .{ .on_tag = false, .operand = operand });
return;
}
if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
@@ -6428,7 +6588,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 "/";
@@ -6802,7 +6962,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;
@@ -7067,12 +7227,42 @@ pub const Pardes = struct {
p.njumps += 1;
}
+ /// One step through the jumplist, older for a negative `delta`, newer
+ /// otherwise: Back and Forward from every key, mouse button, builtin and
+ /// 9P exec. `jcur` is the entry where you are; the step moves it to the
+ /// entry it lands on, and the entries it passed over stay where they are.
+ ///
+ /// JumpScope file, from a pane with a file: the nearest entry that way in
+ /// this pane, else in this file in any pane (a closed one's too), else,
+ /// with none of this file left that way, the nearest of any file, as
+ /// JumpScope all always takes. Forward mirrors Back, so it retraces a run
+ /// of Backs inside one file step for step, and Forwards end, as they
+ /// always have, at the newest entry.
pub fn jumpBy(p: *Pardes, delta: i32) void {
- const next = @as(i64, @intCast(p.jcur)) + delta;
+ const older = delta < 0;
// At an end of the list: said, not a step that silently does nothing.
- if (p.njumps == 0 or next < 0 or next >= p.njumps)
- return p.setMessage(p.active, if (delta < 0) "Back: nothing further back" else "Forward: nothing further on");
- const j = p.jumps[@intCast(next)];
+ if (p.njumps == 0 or (older and p.jcur == 0) or (!older and p.jcur + 1 >= p.njumps))
+ return p.setMessage(p.active, if (older) "Back: nothing further back" else "Forward: nothing further on");
+ var next = if (older) p.jcur - 1 else p.jcur + 1;
+ // A terminal or a scratch has no file to keep to: the step is global.
+ if (p.settings.jump_scope == .file) if (p.panes[p.active]) |here| if (recentPlace(here)) |place| if (place.path.len > 0) {
+ const hash = if (recentDot(here)) |d| std.hash.Wyhash.hash(0, d.path) else 0;
+ // The nearest of this pane's own wins over a nearer one of this
+ // file in another pane: the step does not move the keyboard.
+ var pick: ?usize = null;
+ var i = p.jcur;
+ while (if (older) i > 0 else i + 1 < p.njumps) {
+ i = if (older) i - 1 else i + 1;
+ const k = p.jumps[i];
+ if (k.pane == p.active and k.serial == here.serial) {
+ pick = i;
+ break;
+ }
+ if (pick == null and hash != 0 and k.path_hash == hash) pick = i;
+ }
+ next = pick orelse next;
+ };
+ const j = p.jumps[next];
if (j.pane >= MAX_PANES) return;
// A closed file's entry: opened again at its dot, placed as a look
// places it, and the entry is that pane from here on.
@@ -7111,14 +7301,14 @@ pub const Pardes = struct {
k.pane = @intCast(p.active);
k.serial = opened.serial;
};
- p.jcur = @intCast(next);
+ p.jcur = next;
return;
}
const pane = p.panes[j.pane] orelse return;
if (pane.serial != j.serial) return;
if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source);
if (p.active != j.pane) look.clearNavigationSelection(p, pane);
- p.jcur = @intCast(next);
+ p.jcur = next;
look.focusPaneLine(p, j.pane, .{ .line = j.line, .col = j.col }, .center);
}
@@ -7133,7 +7323,7 @@ pub const Pardes = struct {
var wrap_room: [32 * 1024]u8 = undefined;
var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
const wa = wrap_fba.allocator();
- const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows;
+ const prefix = tagline.prefixIn(p, wa, pane) catch return pane.tag_rows;
const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows;
const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows;
return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS)));
@@ -7192,7 +7382,7 @@ pub const Pardes = struct {
// every event may take from the scratch arena.
var room: [2 * limits.host_path_cap + 256]u8 = undefined;
var measure = std.heap.FixedBufferAllocator.init(&room);
- const lead = if (tagline.prefixIn(measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead;
+ const lead = if (tagline.prefixIn(p, measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead;
if (lead != pane.tag_lead) {
const t = &pane.tag;
const old: i32 = @intCast(pane.tag_lead);
@@ -7212,7 +7402,7 @@ pub const Pardes = struct {
var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
var wanted = lines;
var caret_row: usize = @intCast(@max(0, pane.tag.cur_row));
- if (tagline.prefixIn(wa, pane)) |prefix| {
+ if (tagline.prefixIn(p, wa, pane)) |prefix| {
if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| {
if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| {
lines = shown.rows();
@@ -7255,6 +7445,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
@@ -8690,6 +8883,167 @@ test "jump history clears selections and lands at the recorded cursor" {
}
}
+/// A session for the JumpScope tests: four panes of 100 lines in one
+/// column, A (pane 0) and B each on a file of its own, C on A's file, and O
+/// an output buffer, no file behind it. `order` is the jumplist, a letter a
+/// place, each letter's n-th place its line 10 + 40n: "ABABA" is A10 B10 A50
+/// B50 A90. The keyboard is on the last place, as after jumping there.
+const JumpsTest = struct {
+ p: *Pardes,
+ ids: [4]usize = .{ 0, 0, 0, 0 },
+
+ const letters = "ABCO";
+ const Way = enum { call, key, builtin, mouse };
+
+ fn init(order: []const u8) !JumpsTest {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 80 });
+ errdefer p.deinit();
+ const text = "row\n" ** 100;
+ _ = try p.setTestFile(text);
+ var t: JumpsTest = .{ .p = p };
+ for ([_][]const u8{ "/b.txt", "/test.txt", "" }, 1..) |path, n| {
+ const id = p.freeSlot().?;
+ const pane = try p.newDocPane(id);
+ pane.file = .{
+ .path = try p.gpa.dupe(u8, path),
+ .content = try p.gpa.dupe(u8, text),
+ .history = try panes.File.History.create(p.gpa),
+ };
+ if (path.len == 0) pane.file.?.output = .{ .from = .search };
+ layout.insert(p, 0, n, id);
+ t.ids[n] = id;
+ }
+ layout.compute(p);
+ var seen: [4]u32 = @splat(0);
+ for (order, 0..) |letter, i| {
+ const which = std.mem.indexOfScalar(u8, letters, letter).?;
+ const pane = p.panes[t.ids[which]].?;
+ p.jumps[i] = .{
+ .pane = @intCast(t.ids[which]),
+ .serial = pane.serial,
+ .line = 10 + 40 * seen[which],
+ .col = 1,
+ .path_hash = if (letter == 'O') 0 else std.hash.Wyhash.hash(0, pane.file.?.path),
+ };
+ seen[which] += 1;
+ }
+ p.njumps = order.len;
+ p.jcur = order.len - 1;
+ look.focusPaneLine(p, p.jumps[p.jcur].pane, .{ .line = p.jumps[p.jcur].line, .col = 1 }, .center);
+ p.sync();
+ return t;
+ }
+
+ /// One step `delta` through `way`: the function, Ctrl-o or Ctrl-i, the
+ /// mouse's side button pressed and let go, or the builtin's word.
+ fn step(t: JumpsTest, way: Way, delta: i32) void {
+ const back = delta < 0;
+ switch (way) {
+ .call => t.p.jumpBy(delta),
+ .key => t.p.update(.{ .key = .{ .cp = if (back) 'o' else 'i', .ctrl = true } }),
+ .builtin => _ = t.p.executeBuiltinLine(t.p.active, if (back) "Back" else "Forward"),
+ .mouse => for ([_]Mouse.Kind{ .press, .release }) |kind|
+ t.p.update(.{ .mouse = .{ .button = if (back) .back else .forward, .kind = kind, .col = 0, .row = 0 } }),
+ }
+ t.p.sync();
+ }
+
+ /// Steps `delta` until the list ends that way, saying so; the places
+ /// landed on, as "A50 A10 B10".
+ fn walk(t: JumpsTest, way: Way, delta: i32, buf: []u8) ![]const u8 {
+ var out: std.Io.Writer = .fixed(buf);
+ for (0..2 * MAX_JUMPS) |_| {
+ const before = t.p.jcur;
+ t.step(way, delta);
+ if (t.p.jcur == before) break;
+ const pane = t.p.panes[t.p.active].?;
+ const which = std.mem.indexOfScalar(usize, &t.ids, t.p.active).?;
+ try out.print("{s}{c}{d}", .{ if (out.end > 0) " " else "", letters[which], pane.body.cur_row + 1 });
+ } else return error.NoEnd;
+ const pane = t.p.panes[t.p.active].?;
+ const said = pane.msg[0..pane.msg_len];
+ try std.testing.expect(std.mem.indexOf(u8, said, if (delta < 0) "nothing further back" else "nothing further on") != null);
+ return out.buffered();
+ }
+};
+
+test "JumpScope file: Back keeps to the focused file, A50 then A10, and leaves it only once it runs out that way" {
+ const t = try JumpsTest.init("BABABA");
+ defer t.p.deinit();
+ try std.testing.expectEqual(config.Runtime.JumpScope.file, t.p.settings.jump_scope);
+ var buf: [128]u8 = undefined;
+ // B90, B50 passed over, kept in their places: A has nothing older than
+ // A10, so the step after it is the nearest place of any file, B10.
+ try std.testing.expectEqualStrings("A50 A10 B10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqual(@as(usize, 6), t.p.njumps);
+ // Forward is the mirror, from B10 in B: B's own places first, then A90,
+ // the newest, where the Backs started.
+ try std.testing.expectEqualStrings("B50 B90 A90", try t.walk(.call, 1, &buf));
+ try std.testing.expectEqual(@as(usize, 5), t.p.jcur);
+}
+
+test "JumpScope file: Forward retraces a run of Backs inside one file step for step, back to where it began" {
+ const t = try JumpsTest.init("ABABA");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ // A1 B1 A2 B2 A3 from A3: nothing older than A1 at all, so Back ends there.
+ try std.testing.expectEqualStrings("A50 A10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqual(@as(usize, 0), t.p.jcur);
+ try std.testing.expectEqualStrings("A50 A90", try t.walk(.call, 1, &buf));
+ try std.testing.expectEqual(@as(usize, 4), t.p.jcur);
+ // One Back, one Forward: the same entry, whichever were passed over.
+ t.step(.call, -1);
+ try std.testing.expectEqual(@as(usize, 2), t.p.jcur);
+ t.step(.call, 1);
+ try std.testing.expectEqual(@as(usize, 4), t.p.jcur);
+ try std.testing.expectEqual(@as(usize, 5), t.p.njumps);
+}
+
+test "JumpScope all: Back and Forward take every place in the order it was jumped to" {
+ const t = try JumpsTest.init("ABABA");
+ defer t.p.deinit();
+ try std.testing.expect(t.p.executeBuiltinLine(t.p.active, "JumpScope all"));
+ try std.testing.expectEqual(config.Runtime.JumpScope.all, t.p.settings.jump_scope);
+ var buf: [128]u8 = undefined;
+ try std.testing.expectEqualStrings("B50 A50 B10 A10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqualStrings("B10 A50 B50 A90", try t.walk(.call, 1, &buf));
+ // Bare, the word steps back to file.
+ try std.testing.expect(t.p.executeBuiltinLine(t.p.active, "JumpScope"));
+ try std.testing.expectEqual(config.Runtime.JumpScope.file, t.p.settings.jump_scope);
+}
+
+test "JumpScope file: from a pane with no file, Back and Forward are global" {
+ const t = try JumpsTest.init("OAOBO");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ // O90 keeps to nothing: B10, the next older of all. From B, nothing of
+ // B is older, so O50; from O, A10; from A, O10.
+ try std.testing.expectEqualStrings("B10 O50 A10 O10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqualStrings("A10 O50 B10 O90", try t.walk(.call, 1, &buf));
+}
+
+test "JumpScope file: a file in two panes keeps to the pane first, then the file in the other pane" {
+ // C is A's file in a second pane: C10 B10 A10 C50 A50.
+ const t = try JumpsTest.init("CBACA");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ // From A50: A10 in this pane, over the nearer C50; then none of A's
+ // own is older, so the file's in C, C10, over B10.
+ try std.testing.expectEqualStrings("A10 C10", try t.walk(.call, -1, &buf));
+ // From C10 in C: C50, its own, then the file's newest, A50.
+ try std.testing.expectEqualStrings("C50 A50", try t.walk(.call, 1, &buf));
+}
+
+test "Back and Forward take the same steps from Ctrl-o and Ctrl-i, the mouse's side buttons, the builtins and jumpBy" {
+ for ([_]JumpsTest.Way{ .call, .key, .builtin, .mouse }) |way| {
+ const t = try JumpsTest.init("BABABA");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ try std.testing.expectEqualStrings("A50 A10 B10", try t.walk(way, -1, &buf));
+ try std.testing.expectEqualStrings("B50 B90 A90", try t.walk(way, 1, &buf));
+ }
+}
+
test "jump history terminal rectangle follows output and clears on reflow" {
if (comptime !terminal_panes) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true });
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index 9b055ef9..d0c51bdb 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -1,18 +1,30 @@
//! acme's Edit (editors/acme/edit.c, ecmd.c, elog.c): sam's command
-//! language over one pane's body. The commands are parsed as edit.c parses
-//! them, run as ecmd.c runs them against the text as it was before the Edit,
-//! and their changes collected in order, as elog.c logs them, then applied
-//! at once: an Edit is one undo step, and one that fails changes nothing.
+//! language over the open text panes. The commands are parsed as edit.c
+//! parses them, run as ecmd.c runs them against the text as it was before
+//! the Edit, and their changes collected in order, a log a file, as elog.c
+//! logs them, then applied at once: an Edit is one undo step in each file it
+//! changes, and one that fails changes nothing.
//!
//! Addresses are the `addr` file's (src/ninep/addr.zig), so they take
//! pardes's `line:col` too; regular expressions are src/regexp.zig's, mvzr
//! searched as sam searches, with its step budget.
//!
-//! Left out: the file commands (`b B D e r w f X Y`), the pipes (`< | >`),
-//! the `'` and `"` addresses, and `\1`..`\9` in `s` (mvzr has no
-//! submatches). `u` undoes or redoes only as a whole Edit. acme warns about
-//! changes out of sequence (elog.c:130) and applies them anyway; here the
-//! Edit is refused.
+//! What touches more than the text is the caller's (src/EditCmd.zig): `<`,
+//! `|` and `>` are collected as jobs, a command to run per address, whose
+//! output fills the change they hold the place of; `w` as the bytes to
+//! write; `D`, `e`'s load and the name `f` sets, as marks on the file; `B`
+//! and the reads of `e` and `r` through `World`, at once, as acme does them.
+//!
+//! Left out: the `'` address, `B <cmd` and `D <cmd` (a file list from a
+//! command), and `\1`..`\9` in `s` and sam's longest of alternatives (mvzr
+//! keeps no submatches and takes the first alternative that matches). `u`
+//! undoes or redoes only as a whole Edit. acme warns about changes out of
+//! sequence (elog.c:130) and applies them anyway; here the Edit is refused.
+//! A command that fails (`|`, `<`, `>`) refuses the whole Edit, where sam
+//! warns and puts in what it wrote; `D` of a file the Edit changed is
+//! refused rather than drop the changes; `e` loads its file once the Edit
+//! is done, by Get's way (ninep/ctl.zig getFile), so the file takes no
+//! other command in the same Edit.
const std = @import("std");
const regexp = @import("regexp.zig");
const addr_lang = @import("ninep/addr.zig");
@@ -26,14 +38,70 @@ fn stepRune(text: []const u8, p: usize) usize {
pub const Range = addr_lang.Range;
-pub const Op = struct { q0: u32, q1: u32, text: []const u8 };
+/// A change to the text as it was: `job`, when set, is a `<` or `|` whose
+/// command's output is the text, known once it has run.
+pub const Op = struct { q0: u32, q1: u32, text: []const u8, job: ?u32 = null };
+
+/// One open text pane, as an Edit sees and leaves it.
+pub const File = struct {
+ /// Its absolute path; after `e` or `f`, the name they gave it.
+ name: []const u8,
+ text: []const u8,
+ dot: Range = .{},
+ /// Edited since it was saved: the `'` of acme's file line.
+ dirty: bool = false,
+ /// No file of its own (a +Errors, a +New): `e`, `r` and `w` want a name.
+ scratch: bool = false,
+ /// The caller's: which pane it is.
+ id: usize = 0,
+ ops: std.ArrayList(Op) = .empty,
+ /// `e` or `f` named it `name`.
+ renamed: bool = false,
+ /// `D` closes it once the Edit is done.
+ close: bool = false,
+ /// `e` loads this file into it once the Edit is done (the editor's Get
+ /// path, which renames the pane and leaves it clean); the file takes no
+ /// more commands in the Edit.
+ get: ?[]const u8 = null,
+ /// A command left a dot in it.
+ moved: bool = false,
+};
+
+/// A command for `<`, `|` or `>` (`c`), run in `file`'s directory, its
+/// stdin `file`'s text in `q0..q1` (none for `<`). `>`'s output is printed
+/// at `at` in `Result.printed`.
+pub const Job = struct { c: u8, command: []const u8, file: u32, q0: u32, q1: u32, at: usize };
+
+/// `w`: `bytes` to `path`, `whole` when they are all of `file`'s text.
+pub const Write = struct { file: u32, path: []const u8, bytes: []const u8, whole: bool };
-/// What an Edit came to: the changes in order, the dot it leaves (in the
-/// text before them), and what `p` and `=` printed.
+/// What the editor does for `B`, `e` and `r` while an Edit runs.
+pub const World = struct {
+ ctx: *anyopaque,
+ /// Whether `B` could open the absolute `path`: false, and `why` set,
+ /// when it could not, before any is opened.
+ check: *const fn (ctx: *anyopaque, path: []const u8, why: *Why) bool,
+ /// The text pane holding the absolute `path`, opened if it is not open:
+ /// `B`. Null, and `why` set, when it cannot be.
+ open: *const fn (ctx: *anyopaque, path: []const u8, why: *Why) ?File,
+ /// The bytes of the absolute `path`, in `arena`: `e` and `r`.
+ read: *const fn (ctx: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *Why) ?[]const u8,
+ /// `e` over unsaved edits in the pane `id`: asked once, as Get asks;
+ /// true, and `why` set, when it is refused this time.
+ refuseGet: *const fn (ctx: *anyopaque, id: usize, why: *Why) bool,
+};
+
+/// What an Edit came to: every file (those `B` opened after the ones it
+/// was given), each with its changes in order and the dot it leaves (in the
+/// text before them); the jobs and writes; and what `p`, `=`, `b`, `f` and
+/// `X` printed.
pub const Result = struct {
- ops: []const Op,
- dot: Range,
- printed: []const u8,
+ files: []File,
+ /// The current file at the end (`b` moves it).
+ cur: usize,
+ jobs: []const Job = &.{},
+ writes: []const Write = &.{},
+ printed: []const u8 = "",
/// A bare `u`/`u-3`: undo (positive) or redo (negative) that many.
undo: i32 = 0,
};
@@ -49,9 +117,17 @@ pub const Why = struct {
}
};
+/// The most commands an Edit's `<`, `|` and `>` may run: each is a process.
+pub const max_jobs = 1024;
+
const Cmd = struct {
c: u8,
addr: ?[]const u8 = null,
+ /// A `"re"` address: the file whose line matches.
+ file_re: ?[]const u8 = null,
+ /// A `,` after it, whose right side acme evaluates in the current file
+ /// (ecmd.c cmdaddress): only `;` carries the file over.
+ file_comma: bool = false,
re: ?[]const u8 = null,
text: []const u8 = "",
sub: ?*Cmd = null,
@@ -61,9 +137,10 @@ const Cmd = struct {
to: ?[]const u8 = null,
};
-/// Runs `command` over `text` from `dot`, the file named `name` (for `=`).
+/// Runs `command` over `files` from `files[cur]`, whose dots are where
+/// each starts. `world` answers `B`, `e` and `r`; with none they fail.
/// Everything returned lives in `arena`.
-pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const u8, command: []const u8, why: *Why) Failure!Result {
+pub fn run(arena: std.mem.Allocator, files: []const File, cur: usize, command: []const u8, world: ?World, why: *Why) Failure!Result {
var src = try arena.alloc(u8, command.len + 1);
@memcpy(src[0..command.len], command);
src[command.len] = '\n';
@@ -80,23 +157,32 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
// reaches Edit unfinished was cut short (a write, an open closed), and
// half of it inserted is worse than none.
if (ps.open_text) return fail(why, "a, c or i text not ended by a . line", .{});
- const cmds = first orelse return .{ .ops = &.{}, .dot = dot, .printed = "" };
+ var ex: Exec = .{ .arena = arena, .why = why, .world = world, .cur = cur };
+ try ex.files.appendSlice(arena, files);
+ const cmds = first orelse return .{ .files = ex.files.items, .cur = cur };
// acme's u runs at once (ecmd.c:532), outside the edit log; here it is
// an Edit of its own.
if (cmds.c == 'u') {
if (cmds.next != null) return fail(why, "u is only an Edit of its own here", .{});
- return .{ .ops = &.{}, .dot = dot, .printed = "", .undo = cmds.num };
+ return .{ .files = ex.files.items, .cur = cur, .undo = cmds.num };
}
- var ex: Exec = .{ .arena = arena, .text = text, .name = name, .why = why };
- var d = dot;
+ // editthread: each command from the current file, which `b` changes.
var it: ?*Cmd = cmds;
- while (it) |c| : (it = c.next) d = try ex.exec(c, d);
- return .{ .ops = ex.ops.items, .dot = d, .printed = ex.out.items };
+ while (it) |c| : (it = c.next) {
+ const fi = ex.cur;
+ const d = try ex.exec(c, fi, ex.files.items[fi].dot);
+ ex.files.items[fi].dot = d;
+ ex.files.items[fi].moved = true;
+ }
+ return .{
+ .files = ex.files.items,
+ .cur = ex.cur,
+ .jobs = ex.jobs.items,
+ .writes = ex.writes.items,
+ .printed = ex.out.items,
+ };
}
-/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text
-/// block still waiting for its `.` line: a writer of lines (a ctl, exec)
-/// hands an Edit the lines after it until it does not.
/// What an Edit command still waits for: nothing, the `.` line that ends
/// an a, c or i text block, or the `}` of a group.
pub const Wait = enum { none, text, group };
@@ -131,6 +217,9 @@ fn groupOpen(command: []const u8) bool {
return depth > 0;
}
+/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text
+/// block still waiting for its `.` line: a writer of lines (a ctl, exec)
+/// hands an Edit the lines after it until it does not.
pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
var why: Why = .{};
const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
@@ -141,6 +230,28 @@ pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
}
}
+/// Whether `command` holds a `<`, `|` or `>`: its write is answered only
+/// when they have run (ninep/ctl.zig refuses other lines beside it).
+pub fn runsCommands(arena: std.mem.Allocator, command: []const u8) bool {
+ var why: Why = .{};
+ const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
+ var ps: Parser = .{ .arena = arena, .src = src, .why = &why };
+ const Walk = struct {
+ fn any(c: ?*Cmd) bool {
+ var it = c;
+ while (it) |each| : (it = each.next) {
+ if (std.mem.indexOfScalar(u8, "<|>", each.c) != null) return true;
+ if (any(each.sub)) return true;
+ }
+ return false;
+ }
+ };
+ while (true) {
+ const c = (ps.parse(0) catch return false) orelse return false;
+ if (Walk.any(c)) return true;
+ }
+}
+
fn fail(why: *Why, comptime fmt: []const u8, args: anytype) Failure {
why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0;
return error.Edit;
@@ -158,6 +269,8 @@ const Parser = struct {
braced: bool = false,
/// A text block of `a`, `c` or `i` ran out before its `.` line.
open_text: bool = false,
+ /// The address being read had a `,` after a `"re"`.
+ file_comma: bool = false,
why: *Why,
fn getch(ps: *Parser) ?u8 {
@@ -207,8 +320,9 @@ const Parser = struct {
}
/// An address's text, blanks dropped, or null: the syntax of edit.c's
- /// compoundaddr and simpleaddr, evaluated later by addr.zig.
- fn address(ps: *Parser) Failure!?[]const u8 {
+ /// compoundaddr and simpleaddr, evaluated later by addr.zig. A `"re"`
+ /// first is the file the rest addresses, kept in `file_re`.
+ fn address(ps: *Parser, file_re: ?*?[]const u8) Failure!?[]const u8 {
var out: std.ArrayList(u8) = .empty;
while (true) {
const c = ps.skipbl() orelse break;
@@ -216,6 +330,7 @@ const Parser = struct {
'0'...'9', '#', '.', '$', '+', '-', ',', ';' => {
ps.i += 1;
try out.append(ps.arena, c);
+ if (c == ',' and file_re != null and file_re.?.* != null) ps.file_comma = true;
// pardes's line:col
if (c >= '0' and c <= '9' and ps.nextc() == ':' and ps.i + 1 < ps.src.len and
ps.src[ps.i + 1] >= '0' and ps.src[ps.i + 1] <= '9')
@@ -242,7 +357,13 @@ const Parser = struct {
}
},
'\'' => return fail(ps.why, "can't handle '", .{}),
- '"' => return fail(ps.why, "a file address is no address pardes's Edit takes", .{}),
+ '"' => {
+ // Only first, as the file the rest of it addresses.
+ const into = file_re orelse return fail(ps.why, "bad address", .{});
+ if (out.items.len > 0 or into.* != null) return fail(ps.why, "bad address", .{});
+ ps.i += 1;
+ into.* = try ps.getregexp('"');
+ },
else => break,
}
}
@@ -322,13 +443,33 @@ const Parser = struct {
return s;
}
+ /// edit.c:404, collecttoken: up to a byte of `end`, blanks before it
+ /// kept (the command trims them); a word wants its newline after it.
+ fn collecttoken(ps: *Parser, end: []const u8) Failure![]const u8 {
+ const start = ps.i;
+ while (ps.nextc()) |c| : (ps.i += 1) {
+ if (c != ' ' and c != '\t') break;
+ }
+ while (ps.nextc()) |c| : (ps.i += 1) {
+ if (std.mem.indexOfScalar(u8, end, c) != null) break;
+ }
+ const s = ps.src[start..ps.i];
+ if (ps.nextc() == '\n') ps.i += 1 else try ps.atnl();
+ return s;
+ }
+
/// edit.c:467, parsecmd.
fn parse(ps: *Parser, nest: u32) Failure!?*Cmd {
- const a = try ps.address();
+ var file_re: ?[]const u8 = null;
+ ps.file_comma = false;
+ const a = try ps.address(&file_re);
_ = ps.skipbl() orelse return null;
const c = ps.getch() orelse return null;
const cmd = try ps.arena.create(Cmd);
- cmd.* = .{ .c = c, .addr = a };
+ cmd.* = .{ .c = c, .addr = a, .file_re = file_re, .file_comma = ps.file_comma };
+ // aNo in edit.c's cmdtab.
+ if ((a != null or file_re != null) and std.mem.indexOfScalar(u8, "bBDefuXY", c) != null)
+ return fail(ps.why, "command takes no address", .{});
switch (c) {
'\n' => {},
'a', 'c', 'i' => cmd.text = try ps.collecttext(),
@@ -362,10 +503,11 @@ const Parser = struct {
}
try ps.atnl();
},
- 'x', 'y', 'g', 'v' => {
- // x without a pattern is every line (ecmd.c:568, linelooper).
+ 'x', 'y', 'g', 'v', 'X', 'Y' => {
+ // x without a pattern is every line (ecmd.c:568,
+ // linelooper), X every file.
const n = ps.nextc();
- if (c != 'x' or (n != ' ' and n != '\t' and n != '\n')) {
+ if ((c != 'x' and c != 'X') or (n != ' ' and n != '\t' and n != '\n')) {
_ = ps.skipbl();
const delim = ps.getch() orelse '\n';
if (delim == '\n') return fail(ps.why, "no address", .{});
@@ -375,21 +517,23 @@ const Parser = struct {
if (ps.skipbl() == '\n') {
ps.i += 1;
const p = try ps.arena.create(Cmd);
- p.* = .{ .c = 'p' };
+ p.* = .{ .c = if (c == 'X' or c == 'Y') 'f' else 'p' };
cmd.sub = p;
} else {
cmd.sub = (try ps.parse(nest)) orelse return fail(ps.why, "defcmd", .{});
}
},
'm', 't' => {
- cmd.to = (try ps.address()) orelse return fail(ps.why, "bad address", .{});
+ cmd.to = (try ps.address(null)) orelse return fail(ps.why, "bad address", .{});
try ps.atnl();
},
'u' => {
- if (a != null) return fail(ps.why, "command takes no address", .{});
cmd.num = ps.getnum(2);
try ps.atnl();
},
+ // edit.c's cmdtab: a line for these, a word for those.
+ 'b', 'B', 'D', '<', '|', '>' => cmd.text = try ps.collecttoken("\n"),
+ 'e', 'f', 'r', 'w' => cmd.text = try ps.collecttoken(" \t\n"),
'{' => {
var last: ?*Cmd = null;
while (true) {
@@ -410,7 +554,6 @@ const Parser = struct {
if (nest == 0) return fail(ps.why, "right brace with no left brace", .{});
return null;
},
- 'b', 'B', 'D', 'e', 'r', 'w', 'f', 'X', 'Y', '<', '|', '>' => return fail(ps.why, "{c} is a sam command pardes's Edit leaves out (it has no file or shell commands)", .{c}),
else => return fail(ps.why, "unknown command {c}", .{c}),
}
return cmd;
@@ -419,23 +562,35 @@ const Parser = struct {
// ---- running: ecmd.c, logging: elog.c ----
+const e_noname = "no file name given";
+
const Exec = struct {
arena: std.mem.Allocator,
- text: []const u8,
- name: []const u8,
why: *Why,
- ops: std.ArrayList(Op) = .empty,
+ world: ?World,
+ files: std.ArrayList(File) = .empty,
+ /// The current file: where the next top-level command runs.
+ cur: usize,
+ jobs: std.ArrayList(Job) = .empty,
+ writes: std.ArrayList(Write) = .empty,
out: std.ArrayList(u8) = .empty,
nest: u32 = 0,
+ /// Inside an X or Y, which do not nest (ecmd.c Glooping).
+ looping: bool = false,
fn clip(n: usize) u32 {
return std.math.cast(u32, n) orelse std.math.maxInt(u32);
}
- /// An address from `dot`, or `dot` when there is none (aDot).
- fn address(ex: *Exec, expr: ?[]const u8, dot: Range) Failure!Range {
+ fn text(ex: *const Exec, fi: usize) []const u8 {
+ return ex.files.items[fi].text;
+ }
+
+ /// An address in file `fi` from `dot`, or `dot` when there is none
+ /// (aDot).
+ fn address(ex: *Exec, fi: usize, expr: ?[]const u8, dot: Range) Failure!Range {
const e = expr orelse return dot;
- var a: addr_lang.Addr = .{ .text = ex.text, .lim = null, .expr = e };
+ var a: addr_lang.Addr = .{ .text = ex.text(fi), .lim = null, .expr = e };
// A miss names the pattern that missed, not the whole Edit line.
const r = a.address(dot) orelse return if (std.mem.eql(u8, a.err, addr_lang.e_no_match))
fail(ex.why, "no match for regexp {s}", .{e})
@@ -448,13 +603,14 @@ const Exec = struct {
/// elog.c's order: each change at or after the end of the one before,
/// since they all address the text as it was.
- fn log(ex: *Exec, q0: u32, q1: u32, text: []const u8) Failure!void {
- if (q0 == q1 and text.len == 0) return;
- if (ex.ops.items.len > 0) {
- const prev = ex.ops.items[ex.ops.items.len - 1];
+ fn log(ex: *Exec, fi: usize, q0: u32, q1: u32, bytes: []const u8, job: ?u32) Failure!void {
+ if (q0 == q1 and bytes.len == 0 and job == null) return;
+ const ops = &ex.files.items[fi].ops;
+ if (ops.items.len > 0) {
+ const prev = ops.items[ops.items.len - 1];
if (q0 < prev.q1 or q0 < prev.q0) return fail(ex.why, "changes out of sequence", .{});
}
- try ex.ops.append(ex.arena, .{ .q0 = q0, .q1 = q1, .text = text });
+ try ops.append(ex.arena, .{ .q0 = q0, .q1 = q1, .text = bytes, .job = job });
}
fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex {
@@ -464,7 +620,10 @@ const Exec = struct {
error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}),
error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}),
error.Mixed => fail(ex.why, "{s}", .{regexp.Regex.e_mixed}),
- error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}),
+ error.Bad => if (c == '"' or c == 'X' or c == 'Y')
+ fail(ex.why, "bad regexp in file match", .{})
+ else
+ fail(ex.why, "bad regexp in {c} command", .{c}),
};
}
@@ -472,84 +631,179 @@ const Exec = struct {
/// (addr.zig): mvzr matches bytes, so `.` would take one byte of `é`
/// and x, y and s would split it. From `from` on only, so a match never
/// reaches back into what the last one took.
- fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match {
+ fn find(ex: *Exec, rx: *regexp.Regex, in: []const u8, from: usize, hi: usize) Failure!?regexp.Regex.Match {
if (from > hi) return null;
// The budget is each search's own: an x over 100k lines makes 100k
// searches, none of which is the slow one.
rx.steps = regexp.Regex.budget;
- const m = (rx.find(ex.text, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null;
- var start = modal.runeStart(ex.text, m.start);
- if (start < from) start = modal.runeEnd(ex.text, m.start);
- return .{ .start = start, .end = @max(start, modal.runeEnd(ex.text, m.end)) };
+ const m = (rx.find(in, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null;
+ var start = modal.runeStart(in, m.start);
+ if (start < from) start = modal.runeEnd(in, m.start);
+ return .{ .start = start, .end = @max(start, modal.runeEnd(in, m.end)) };
+ }
+
+ /// ecmd.c pfilename: `'` if edited, `+`, `.` if current, and the name.
+ fn fileLine(ex: *Exec, fi: usize) Failure![]const u8 {
+ const f = &ex.files.items[fi];
+ return std.fmt.allocPrint(ex.arena, "{c}+{c} {s}\n", .{
+ @as(u8, if (f.dirty and !f.scratch) '\'' else ' '),
+ @as(u8, if (fi == ex.cur) '.' else ' '),
+ f.name,
+ });
+ }
+
+ /// ecmd.c filematch: the file's line matches `pat`.
+ fn fileMatch(ex: *Exec, fi: usize, pat: []const u8) Failure!bool {
+ var rx = try ex.compile(pat, 'X');
+ const line = try ex.fileLine(fi);
+ return (try ex.find(&rx, line, 0, line.len)) != null;
+ }
+
+ /// ecmd.c matchfile: the one open file whose line matches `pat`.
+ fn matchFile(ex: *Exec, pat: []const u8) Failure!usize {
+ var found: ?usize = null;
+ for (ex.files.items, 0..) |f, i| {
+ if (f.close or f.scratch) continue;
+ if (!try ex.fileMatch(i, pat)) continue;
+ if (found != null) return fail(ex.why, "too many files match \"{s}\"", .{pat});
+ found = i;
+ }
+ return found orelse fail(ex.why, "no file matches \"{s}\"", .{pat});
+ }
+
+ /// `name` as a path: itself when absolute, else in `fi`'s directory
+ /// (acme's dirname).
+ fn pathIn(ex: *Exec, fi: usize, name_in: []const u8) Failure![]const u8 {
+ // `~` and `~user` as Get, Incl and Save read them.
+ var home: [4096]u8 = undefined;
+ const name = try ex.arena.dupe(u8, @import("fs.zig").expandHome(name_in, &home));
+ if (name.len > 0 and name[0] == '/') return name;
+ const dir = std.fs.path.dirnamePosix(ex.files.items[fi].name) orelse "/";
+ return std.fs.path.resolvePosix(ex.arena, &.{ dir, name });
+ }
+
+ /// The open file named `path`, a scratch too.
+ fn lookFile(ex: *Exec, path: []const u8) ?usize {
+ for (ex.files.items, 0..) |f, i| if (!f.close and std.mem.eql(u8, f.name, path)) return i;
+ return null;
+ }
+
+ /// ecmd.c cmdname: the name a file command means: the one given, in the
+ /// file's directory, else the file's own (none for a scratch). `set`
+ /// names the file so (`e`, `f`), as an unnamed one always is.
+ fn cmdName(ex: *Exec, fi: usize, given: []const u8, set: bool) Failure!?[]const u8 {
+ const s = std.mem.trim(u8, given, " \t");
+ const f = &ex.files.items[fi];
+ if (s.len == 0) return if (f.scratch) null else f.name;
+ const full = try ex.pathIn(fi, s);
+ if (set and !std.mem.eql(u8, full, f.name)) {
+ if (ex.lookFile(full)) |other| if (other != fi)
+ try ex.out.print(ex.arena, "warning: duplicate file name \"{s}\"\n", .{full});
+ f.name = full;
+ f.renamed = true;
+ f.scratch = false;
+ }
+ return full;
+ }
+
+ fn readFile(ex: *Exec, path: []const u8) Failure![]const u8 {
+ const w = ex.world orelse return fail(ex.why, "can't open {s}: no files here", .{path});
+ return w.read(w.ctx, ex.arena, path, ex.why) orelse error.Edit;
+ }
+
+ /// ecmd.c:62, cmdexec: runs `c` in file `fi` from `dot`, answering the
+ /// dot it leaves there.
+ fn exec(ex: *Exec, c: *Cmd, fi_in: usize, dot_in: Range) Failure!Range {
+ var fi = fi_in;
+ var dot = dot_in;
+ if (ex.files.items[fi].close) return fail(ex.why, "no current window", .{});
+ if (ex.files.items[fi].get) |path| return fail(ex.why, "{s} is read in by e at the Edit's end: no more commands in it", .{path});
+ // A `"re"` address: the command runs in that file, from its dot.
+ if (c.file_re) |pat| {
+ fi = try ex.matchFile(pat);
+ dot = ex.files.items[fi].dot;
+ if (c.file_comma and fi != fi_in) return fail(ex.why, "addresses in different files", .{});
+ }
+ // w's default address is the whole file (aAll).
+ const r = if (c.c == 'w' and c.addr == null)
+ Range{ .q0 = 0, .q1 = clip(ex.text(fi).len) }
+ else
+ try ex.address(fi, c.addr, dot);
+ const d = try ex.execIn(c, fi, r);
+ if (fi == fi_in) return d;
+ ex.files.items[fi].dot = d;
+ ex.files.items[fi].moved = true;
+ return dot_in;
}
- /// ecmd.c:62, cmdexec: runs `c` from `dot`, answering the dot it leaves.
- fn exec(ex: *Exec, c: *Cmd, dot: Range) Failure!Range {
- const r = try ex.address(c.addr, dot);
+ /// The command itself, at `r` in file `fi`.
+ fn execIn(ex: *Exec, c: *Cmd, fi: usize, r: Range) Failure!Range {
+ const txt = ex.text(fi);
switch (c.c) {
'\n' => return r,
'a' => {
- try ex.log(r.q1, r.q1, c.text);
+ try ex.log(fi, r.q1, r.q1, c.text, null);
return .{ .q0 = r.q1, .q1 = r.q1 };
},
'i' => {
- try ex.log(r.q0, r.q0, c.text);
+ try ex.log(fi, r.q0, r.q0, c.text, null);
return .{ .q0 = r.q0, .q1 = r.q0 };
},
'c' => {
- try ex.log(r.q0, r.q1, c.text);
+ try ex.log(fi, r.q0, r.q1, c.text, null);
return .{ .q0 = r.q0, .q1 = r.q0 };
},
'd' => {
- try ex.log(r.q0, r.q1, "");
+ try ex.log(fi, r.q0, r.q1, "", null);
return .{ .q0 = r.q0, .q1 = r.q0 };
},
'p' => {
- try ex.out.appendSlice(ex.arena, ex.text[r.q0..r.q1]);
+ try ex.out.appendSlice(ex.arena, txt[r.q0..r.q1]);
return r;
},
'=' => {
// ecmd.c:665, printposn
+ const name = ex.files.items[fi].name;
if (c.text.len > 0) {
- try ex.out.print(ex.arena, "{s}:#{d}", .{ ex.name, r.q0 });
+ try ex.out.print(ex.arena, "{s}:#{d}", .{ name, r.q0 });
if (r.q1 != r.q0) try ex.out.print(ex.arena, ",#{d}", .{r.q1});
} else {
- const l1 = 1 + std.mem.count(u8, ex.text[0..r.q0], "\n");
- var l2 = l1 + std.mem.count(u8, ex.text[r.q0..r.q1], "\n");
- if (r.q1 > r.q0 and ex.text[r.q1 - 1] == '\n') l2 -= 1;
- try ex.out.print(ex.arena, "{s}:{d}", .{ ex.name, l1 });
+ const l1 = 1 + std.mem.count(u8, txt[0..r.q0], "\n");
+ var l2 = l1 + std.mem.count(u8, txt[r.q0..r.q1], "\n");
+ if (r.q1 > r.q0 and txt[r.q1 - 1] == '\n') l2 -= 1;
+ try ex.out.print(ex.arena, "{s}:{d}", .{ name, l1 });
if (l2 != l1) try ex.out.print(ex.arena, ",{d}", .{l2});
}
try ex.out.append(ex.arena, '\n');
return r;
},
- 's' => return ex.substitute(c, r),
+ 's' => return ex.substitute(c, fi, r),
'x', 'y' => {
- const ranges = if (c.re) |pat| try ex.looper(pat, r, c.c == 'x') else try ex.lines(r);
+ const ranges = if (c.re) |pat| try ex.looper(pat, fi, r, c.c == 'x') else try ex.lines(fi, r);
ex.nest += 1;
defer ex.nest -= 1;
var d = r;
- for (ranges) |each| d = try ex.exec(c.sub.?, each);
+ for (ranges) |each| d = try ex.exec(c.sub.?, fi, each);
return d;
},
'g', 'v' => {
var rx = try ex.compile(c.re.?, c.c);
- const hit = (try ex.find(&rx, r.q0, r.q1)) != null;
- if (hit != (c.c == 'v')) return ex.exec(c.sub.?, r);
+ const hit = (try ex.find(&rx, txt, r.q0, r.q1)) != null;
+ if (hit != (c.c == 'v')) return ex.exec(c.sub.?, fi, r);
return r;
},
'm', 't' => {
- const to = try ex.address(c.to, dot);
- const bytes = ex.text[r.q0..r.q1];
+ const to = try ex.address(fi, c.to, r);
+ const bytes = txt[r.q0..r.q1];
if (c.c == 't') {
- try ex.log(to.q1, to.q1, bytes);
+ try ex.log(fi, to.q1, to.q1, bytes, null);
} else if (r.q1 <= to.q0) {
// ecmd.c:408, move
- try ex.log(r.q0, r.q1, "");
- try ex.log(to.q1, to.q1, bytes);
+ try ex.log(fi, r.q0, r.q1, "", null);
+ try ex.log(fi, to.q1, to.q1, bytes, null);
} else if (r.q0 >= to.q1) {
- try ex.log(to.q1, to.q1, bytes);
- try ex.log(r.q0, r.q1, "");
+ try ex.log(fi, to.q1, to.q1, bytes, null);
+ try ex.log(fi, r.q0, r.q1, "", null);
} else if (!(r.q0 == to.q0 and r.q1 == to.q1)) {
return fail(ex.why, "move overlaps itself", .{});
}
@@ -558,28 +812,156 @@ const Exec = struct {
'u' => return fail(ex.why, "u is only an Edit of its own here", .{}),
'{' => {
var it = c.sub;
- while (it) |each| : (it = each.next) _ = try ex.exec(each, r);
+ while (it) |each| : (it = each.next) _ = try ex.exec(each, fi, r);
+ return r;
+ },
+ 'b' => {
+ // ecmd.c b_cmd, tofile: the file named so, exactly or in
+ // the current file's directory.
+ const name = std.mem.trim(u8, c.text, " \t");
+ const at = ex.lookFile(name) orelse ex.lookFile(try ex.pathIn(fi, name));
+ const to = if (at) |i| (if (ex.files.items[i].scratch) null else i) else null;
+ const target = to orelse return fail(ex.why, "no such file \"{s}\"", .{name});
+ // The `.` goes with it in the line printed.
+ ex.cur = target;
+ if (ex.nest == 0) try ex.out.appendSlice(ex.arena, try ex.fileLine(target));
+ return r;
+ },
+ 'B' => {
+ const list = std.mem.trim(u8, c.text, " \t");
+ if (list.len > 0 and list[0] == '<') return fail(ex.why, "B <command is not supported: give the names", .{});
+ if (list.len == 0) return fail(ex.why, e_noname, .{});
+ const w = ex.world orelse return fail(ex.why, "B: no panes to open here", .{});
+ // Every name first: one that cannot open opens none.
+ var checks = std.mem.tokenizeAny(u8, list, " \t");
+ while (checks.next()) |name| if (!w.check(w.ctx, try ex.pathIn(fi, name), ex.why)) return error.Edit;
+ var names = std.mem.tokenizeAny(u8, list, " \t");
+ while (names.next()) |name| {
+ const path = try ex.pathIn(fi, name);
+ const opened = w.open(w.ctx, path, ex.why) orelse return error.Edit;
+ if (for (ex.files.items) |f| {
+ if (f.id == opened.id and !f.close) break true;
+ } else false) continue;
+ try ex.files.append(ex.arena, opened);
+ }
+ return r;
+ },
+ 'D' => {
+ const list = std.mem.trim(u8, c.text, " \t");
+ if (list.len > 0 and list[0] == '<') return fail(ex.why, "D <command is not supported: give the names", .{});
+ if (list.len == 0) {
+ try ex.closeFile(fi);
+ return r;
+ }
+ var names = std.mem.tokenizeAny(u8, list, " \t");
+ while (names.next()) |name| {
+ const path = try ex.pathIn(fi, name);
+ try ex.closeFile(ex.lookFile(path) orelse return fail(ex.why, "no such file {s}", .{path}));
+ }
+ return r;
+ },
+ 'r' => {
+ // ecmd.c e_cmd: r reads a file over dot.
+ const path = (try ex.cmdName(fi, c.text, false)) orelse return fail(ex.why, e_noname, .{});
+ try ex.log(fi, r.q0, r.q1, try ex.readFile(path), null);
+ return r;
+ },
+ 'e' => {
+ // ecmd.c e_cmd: a file read over all of this one, which takes
+ // its name: Get's way (ctl.getFile), once the Edit is done,
+ // asking first, once, over unsaved edits.
+ const f = &ex.files.items[fi];
+ if (f.ops.items.len > 0) return fail(ex.why, "e: {s} has changes pending in this Edit", .{f.name});
+ const given = std.mem.trim(u8, c.text, " \t");
+ const path = if (given.len == 0)
+ (if (f.scratch) return fail(ex.why, e_noname, .{}) else f.name)
+ else
+ try ex.pathIn(fi, given);
+ const w = ex.world orelse return fail(ex.why, "can't open {s}: no files here", .{path});
+ if (w.refuseGet(w.ctx, f.id, ex.why)) return error.Edit;
+ _ = try ex.readFile(path);
+ f.get = path;
+ f.name = path;
+ return r;
+ },
+ 'w' => {
+ if (ex.files.items[fi].ops.items.len > 0) return fail(ex.why, "can't write file with pending modifications", .{});
+ const path = (try ex.cmdName(fi, c.text, false)) orelse return fail(ex.why, "no name specified for 'w' command", .{});
+ try ex.writes.append(ex.arena, .{
+ .file = @intCast(fi),
+ .path = path,
+ .bytes = try ex.arena.dupe(u8, txt[r.q0..r.q1]),
+ .whole = r.q0 == 0 and r.q1 == txt.len,
+ });
+ return r;
+ },
+ 'f' => {
+ _ = try ex.cmdName(fi, c.text, true);
+ try ex.out.appendSlice(ex.arena, try ex.fileLine(fi));
+ return r;
+ },
+ 'X', 'Y' => {
+ // ecmd.c filelooper: the files whose line matches (X) or
+ // does not (Y), each from its own dot; X alone is every
+ // file with a name.
+ if (ex.looping) return fail(ex.why, "can't nest {c} command", .{c.c});
+ ex.looping = true;
+ defer ex.looping = false;
+ ex.nest += 1;
+ defer ex.nest -= 1;
+ var chosen: std.ArrayList(usize) = .empty;
+ for (ex.files.items, 0..) |f, i| {
+ if (f.close) continue;
+ if (c.re == null and f.scratch) continue;
+ if (c.re == null or (try ex.fileMatch(i, c.re.?)) == (c.c == 'X')) try chosen.append(ex.arena, i);
+ }
+ for (chosen.items) |i| {
+ const d = try ex.exec(c.sub.?, i, ex.files.items[i].dot);
+ ex.files.items[i].dot = d;
+ ex.files.items[i].moved = true;
+ }
+ return r;
+ },
+ '<', '|', '>' => {
+ // ecmd.c runpipe: the command's output replaces dot (`<`,
+ // `|`), dot is its input (`|`, `>`), and `>`'s output is
+ // printed.
+ const command = std.mem.trim(u8, c.text, " \t");
+ if (command.len == 0) return fail(ex.why, "no command specified for {c}", .{c.c});
+ if (ex.jobs.items.len >= max_jobs) return fail(ex.why, "too many commands: more than {d} for one Edit", .{max_jobs});
+ const job: u32 = @intCast(ex.jobs.items.len);
+ try ex.jobs.append(ex.arena, .{ .c = c.c, .command = command, .file = @intCast(fi), .q0 = r.q0, .q1 = r.q1, .at = ex.out.items.len });
+ if (c.c != '>') try ex.log(fi, r.q0, r.q1, "", job);
return r;
},
else => return fail(ex.why, "unknown command {c}", .{c.c}),
}
}
+ /// ecmd.c D1: closed once the Edit is done. One with changes pending
+ /// in this Edit is refused rather than have them dropped.
+ fn closeFile(ex: *Exec, fi: usize) Failure!void {
+ const f = &ex.files.items[fi];
+ if (f.ops.items.len > 0 or f.get != null) return fail(ex.why, "D: {s} has changes pending in this Edit", .{f.name});
+ f.close = true;
+ }
+
/// ecmd.c:443, s_cmd.
- fn substitute(ex: *Exec, c: *Cmd, r: Range) Failure!Range {
+ fn substitute(ex: *Exec, c: *Cmd, fi: usize, r: Range) Failure!Range {
+ const txt = ex.text(fi);
var rx = try ex.compile(c.re.?, 's');
var n = c.num;
var op: ?usize = null;
var p1: usize = r.q0;
var did = false;
while (p1 <= r.q1) {
- const m = (try ex.find(&rx, p1, r.q1)) orelse break;
+ const m = (try ex.find(&rx, txt, p1, r.q1)) orelse break;
if (m.start == m.end) {
if (op != null and m.start == op.?) {
- p1 = stepRune(ex.text, p1);
+ p1 = stepRune(txt, p1);
continue;
}
- p1 = stepRune(ex.text, m.end);
+ p1 = stepRune(txt, m.end);
} else p1 = m.end;
op = m.end;
n -= 1;
@@ -594,10 +976,10 @@ const Exec = struct {
if (e >= '1' and e <= '9') return fail(ex.why, "no \\{c}: mvzr keeps no submatches", .{e});
try buf.append(ex.arena, e);
} else if (ch == '&') {
- try buf.appendSlice(ex.arena, ex.text[m.start..m.end]);
+ try buf.appendSlice(ex.arena, txt[m.start..m.end]);
} else try buf.append(ex.arena, ch);
}
- try ex.log(clip(m.start), clip(m.end), buf.items);
+ try ex.log(fi, clip(m.start), clip(m.end), buf.items, null);
did = true;
if (!c.global) break;
}
@@ -609,7 +991,8 @@ const Exec = struct {
}
/// ecmd.c:796, looper: the matches (x) or what lies between them (y).
- fn looper(ex: *Exec, pat: []const u8, r: Range, xy: bool) Failure![]Range {
+ fn looper(ex: *Exec, pat: []const u8, fi: usize, r: Range, xy: bool) Failure![]Range {
+ const txt = ex.text(fi);
var rx = try ex.compile(pat, if (xy) 'x' else 'y');
var out: std.ArrayList(Range) = .empty;
var op: ?usize = if (xy) null else r.q0;
@@ -617,13 +1000,13 @@ const Exec = struct {
while (p <= r.q1) {
var tr: Range = undefined;
var sel_end: usize = undefined;
- if (try ex.find(&rx, p, r.q1)) |m| {
+ if (try ex.find(&rx, txt, p, r.q1)) |m| {
if (m.start == m.end) {
if (op != null and m.start == op.?) {
- p = stepRune(ex.text, p);
+ p = stepRune(txt, p);
continue;
}
- p = stepRune(ex.text, m.end);
+ p = stepRune(txt, m.end);
} else p = m.end;
tr = if (xy) .{ .q0 = clip(m.start), .q1 = clip(m.end) } else .{ .q0 = clip(op.?), .q1 = clip(m.start) };
sel_end = m.end;
@@ -642,12 +1025,13 @@ const Exec = struct {
/// ecmd.c:840, linelooper: each line in the range, the first from where
/// the range starts, the last cut where it ends.
- fn lines(ex: *Exec, r: Range) Failure![]Range {
+ fn lines(ex: *Exec, fi: usize, r: Range) Failure![]Range {
+ const txt = ex.text(fi);
var out: std.ArrayList(Range) = .empty;
var p: usize = r.q0;
while (p < r.q1) {
- const nl = std.mem.indexOfScalarPos(u8, ex.text, p, '\n');
- const e = @min(if (nl) |i| i + 1 else ex.text.len, r.q1);
+ const nl = std.mem.indexOfScalarPos(u8, txt, p, '\n');
+ const e = @min(if (nl) |i| i + 1 else txt.len, r.q1);
if (e <= p) break;
try out.append(ex.arena, .{ .q0 = clip(p), .q1 = clip(e) });
p = e;
@@ -656,7 +1040,8 @@ const Exec = struct {
}
};
-/// The text after `ops`, which are in order and do not overlap.
+/// The text after `ops`, which are in order and do not overlap (a job's
+/// with its output in place).
pub fn apply(gpa: std.mem.Allocator, text: []const u8, ops: []const Op) ![]u8 {
var n: usize = text.len;
for (ops) |o| n = n - (o.q1 - o.q0) + o.text.len;
@@ -675,9 +1060,9 @@ pub fn apply(gpa: std.mem.Allocator, text: []const u8, ops: []const Op) ![]u8 {
return out;
}
-/// Where `dot` lands after `ops`: each end moved by the changes before it;
-/// an empty dot where a change starts selects what it put there, as
-/// elogapply does (elog.c:283).
+/// Where `dot` lands after `ops` (their texts final): each end moved by the
+/// changes before it; an empty dot where a change starts selects what it
+/// put there, as elogapply does (elog.c:283).
pub fn moveDot(dot: Range, ops: []const Op) Range {
var shift: i64 = 0;
for (ops) |o| {
@@ -702,11 +1087,11 @@ fn edited(text: []const u8, command: []const u8) ![]u8 {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
var why: Why = .{};
- const res = run(arena_state.allocator(), text, .{}, "t", command, &why) catch |err| {
+ const res = run(arena_state.allocator(), &.{.{ .name = "t", .text = text }}, 0, command, null, &why) catch |err| {
if (err == error.Edit) std.debug.print("Edit: {s}\n", .{why.text()});
return err;
};
- return apply(std.testing.allocator, text, res.ops);
+ return apply(std.testing.allocator, text, res.files[0].ops.items);
}
fn expectEdit(text: []const u8, command: []const u8, want: []const u8) !void {
@@ -768,13 +1153,21 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words"
.{ "}", "right brace with no left brace" },
.{ ",x/foo/{", "unmatched `{'" },
.{ ",x/foo/{\nd", "unmatched `{'" },
- .{ "w /tmp/x", "w is a sam command pardes's Edit leaves out (it has no file or shell commands)" },
+ .{ "B <ls", "B <command is not supported: give the names" },
+ .{ "1d\nD", "D: t has changes pending in this Edit" },
+ .{ "X X p", "can't nest X command" },
+ .{ "3 b t", "command takes no address" },
+ .{ ",|", "no command specified for |" },
+ .{ "b nowhere", "no such file \"nowhere\"" },
+ .{ "1d\nw /tmp/x", "can't write file with pending modifications" },
+ .{ "e /tmp/x", "can't open /tmp/x: no files here" },
+ .{ "\"zz\" p", "no file matches \"zz\"" },
.{ ",s/(a)/\\1/", "no \\1: mvzr keeps no submatches" },
.{ "1 m 1,2", "move overlaps itself" },
.{ ",x/(^|\\n)foo/d", "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n" },
}) |c| {
var why: Why = .{};
- try std.testing.expectError(error.Edit, run(arena_state.allocator(), "foo a\nfoo\n", .{}, "t", c[0], &why));
+ try std.testing.expectError(error.Edit, run(arena_state.allocator(), &.{.{ .name = "t", .text = "foo a\nfoo\n" }}, 0, c[0], null, &why));
try std.testing.expectEqualStrings(c[1], why.text());
}
}
@@ -808,3 +1201,197 @@ test "x, y and s match whole runes: a byte pattern never splits one" {
try expectEdit("a\x81b", ",x/./a/|/", "a|\x81|b|");
try expectEdit("\u{e9}", ",s/$/!/", "\u{e9}!");
}
+
+/// Three files and a +Errors, the way the editor hands them over, and a
+/// world whose disk holds /src/new.c and /src/data.
+const Fixture = struct {
+ arena_state: std.heap.ArenaAllocator,
+ why: Why = .{},
+
+ const files = [_]File{
+ .{ .name = "/src/a.c", .text = "int foo;\nint bar;\n", .id = 0 },
+ .{ .name = "/src/b.h", .text = "foo bar foo\n", .id = 1, .dirty = true },
+ .{ .name = "/src/c.c", .text = "bar\n", .id = 2 },
+ .{ .name = "/src/+Errors", .text = "foo\n", .id = 3, .scratch = true },
+ };
+
+ var opened: usize = 0;
+
+ fn check(_: *anyopaque, path: []const u8, why: *Why) bool {
+ if (std.mem.eql(u8, path, "/src/new.c")) return true;
+ why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0;
+ return false;
+ }
+
+ /// The edited file, b.h, is asked about: refused.
+ fn refuseGet(_: *anyopaque, id: usize, why: *Why) bool {
+ if (id != 1) return false;
+ why.len = (std.fmt.bufPrint(&why.buf, "/src/b.h: Modified (e again to discard)", .{}) catch unreachable).len;
+ return true;
+ }
+
+ fn open(_: *anyopaque, path: []const u8, why: *Why) ?File {
+ opened += 1;
+ if (std.mem.eql(u8, path, "/src/new.c")) return .{ .name = "/src/new.c", .text = "new foo\n", .id = 9 };
+ why.len = if (std.fmt.bufPrint(&why.buf, "B: cannot open {s}", .{path})) |said| said.len else |_| 0;
+ return null;
+ }
+
+ fn read(_: *anyopaque, arena: std.mem.Allocator, path: []const u8, why: *Why) ?[]const u8 {
+ if (std.mem.eql(u8, path, "/src/data")) return arena.dupe(u8, "DATA\n") catch null;
+ why.len = if (std.fmt.bufPrint(&why.buf, "can't open {s}: file does not exist", .{path})) |said| said.len else |_| 0;
+ return null;
+ }
+
+ fn init() Fixture {
+ return .{ .arena_state = .init(std.testing.allocator) };
+ }
+
+ fn deinit(fx: *Fixture) void {
+ fx.arena_state.deinit();
+ }
+
+ fn edit(fx: *Fixture, from: usize, command: []const u8) !Result {
+ var ctx: u8 = 0;
+ const world: World = .{ .ctx = &ctx, .check = check, .open = open, .read = read, .refuseGet = refuseGet };
+ return run(fx.arena_state.allocator(), &files, from, command, world, &fx.why);
+ }
+
+ /// File `i`'s text after the Edit.
+ fn after(fx: *Fixture, res: Result, i: usize) ![]const u8 {
+ return apply(fx.arena_state.allocator(), res.files[i].text, res.files[i].ops.items);
+ }
+};
+
+test "X and Y run a command in each open file whose line matches, or does not" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ // ecmd.c filelooper: each file from its own dot, the current one too.
+ const x = try fx.edit(0, "X/\\.c$/ ,s/foo/FOO/g");
+ try std.testing.expectEqualStrings("int FOO;\nint bar;\n", try fx.after(x, 0));
+ try std.testing.expectEqual(@as(usize, 0), x.files[1].ops.items.len);
+ try std.testing.expectEqual(@as(usize, 0), x.files[2].ops.items.len); // no foo: s in a loop is quiet
+ const y = try fx.edit(0, "Y/\\.c$/ ,s/foo/FOO/g");
+ try std.testing.expectEqualStrings("FOO bar FOO\n", try fx.after(y, 1));
+ try std.testing.expectEqualStrings("FOO\n", try fx.after(y, 3));
+ try std.testing.expectEqual(@as(usize, 0), y.files[0].ops.items.len);
+ // X alone is every file with a name, f its default: acme's file lines,
+ // `'` for the edited one and `.` for the current.
+ const all = try fx.edit(2, "X");
+ try std.testing.expectEqualStrings(" + /src/a.c\n'+ /src/b.h\n +. /src/c.c\n", all.printed);
+ // The line is what is matched: X/'/ is every edited file.
+ const dirty = try fx.edit(0, "X/'/ =");
+ try std.testing.expectEqualStrings("/src/b.h:1\n", dirty.printed);
+}
+
+test "b moves the current file, \"re\" addresses another, and f names one" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ const b = try fx.edit(0, "b /src/c.c\n,c/C/");
+ try std.testing.expectEqualStrings(" +. /src/c.c\n", b.printed);
+ try std.testing.expectEqualStrings("C", try fx.after(b, 2));
+ try std.testing.expectEqual(@as(usize, 0), b.files[0].ops.items.len);
+ try std.testing.expectEqual(@as(usize, 2), b.cur);
+ // A name in the current file's directory will do.
+ try std.testing.expectEqual(@as(usize, 1), (try fx.edit(0, "b b.h")).cur);
+ // `"re"`: that file's line, the command in it, the current file kept.
+ const q = try fx.edit(0, "\"b\\.h\" 1 s/bar/BAR/");
+ try std.testing.expectEqualStrings("foo BAR foo\n", try fx.after(q, 1));
+ try std.testing.expectEqual(@as(usize, 0), q.cur);
+ // Only `;` carries the file to the right of a `,`, as in acme.
+ try std.testing.expectError(error.Edit, fx.edit(0, "\"b\\.h\" ,s/bar/BAR/"));
+ try std.testing.expectEqualStrings("addresses in different files", fx.why.text());
+ try std.testing.expectEqualStrings("FOO", (try fx.after(try fx.edit(0, "\"b\\.h\"0;$ s/foo bar foo/FOO/"), 1))[0..3]);
+ try std.testing.expectError(error.Edit, fx.edit(0, "\"\\.c\" p"));
+ try std.testing.expectEqualStrings("too many files match \"\\.c\"", fx.why.text());
+ const f = try fx.edit(0, "f other.c");
+ try std.testing.expectEqualStrings(" +. /src/other.c\n", f.printed);
+ try std.testing.expect(f.files[0].renamed);
+}
+
+test "e and r read a file into the text, w writes it, D and B close and open panes" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ // r replaces dot; e loads a file over all of it at the end, by Get's
+ // way, which names the pane after it.
+ const r = try fx.edit(0, "2 r data");
+ try std.testing.expectEqualStrings("int foo;\nDATA\n", try fx.after(r, 0));
+ try std.testing.expect(!r.files[0].renamed);
+ const e = try fx.edit(0, "e data");
+ try std.testing.expectEqualStrings("/src/data", e.files[0].get.?);
+ try std.testing.expectEqual(@as(usize, 0), e.files[0].ops.items.len);
+ // ...and takes no more commands, nor follows another change.
+ try std.testing.expectError(error.Edit, fx.edit(0, "e data\n,d"));
+ try std.testing.expectError(error.Edit, fx.edit(0, "1d\ne data"));
+ // e of an edited file is asked about, as Get asks.
+ try std.testing.expectError(error.Edit, fx.edit(1, "e"));
+ try std.testing.expectEqualStrings("/src/b.h: Modified (e again to discard)", fx.why.text());
+ // `~` is the home directory, as Get and Save read it.
+ const home = try fx.edit(0, "w ~/t.txt");
+ try std.testing.expect(home.writes[0].path[0] == '/' and std.mem.indexOfScalar(u8, home.writes[0].path, '~') == null);
+ try std.testing.expectError(error.Edit, fx.edit(0, "r nothing"));
+ try std.testing.expectEqualStrings("can't open /src/nothing: file does not exist", fx.why.text());
+ // w: all of it by default, to its own name; an address and a name.
+ const w = try fx.edit(0, "w\n2 w part");
+ try std.testing.expectEqual(@as(usize, 2), w.writes.len);
+ try std.testing.expectEqualStrings("/src/a.c", w.writes[0].path);
+ try std.testing.expect(w.writes[0].whole);
+ try std.testing.expectEqualStrings("/src/part", w.writes[1].path);
+ try std.testing.expectEqualStrings("int bar;\n", w.writes[1].bytes);
+ try std.testing.expect(!w.writes[1].whole);
+ try std.testing.expectError(error.Edit, fx.edit(3, "w"));
+ try std.testing.expectEqualStrings("no name specified for 'w' command", fx.why.text());
+ // D marks the panes to close once the Edit is done; a closed one takes
+ // no more commands.
+ const d = try fx.edit(0, "D c.c b.h");
+ try std.testing.expect(d.files[2].close and d.files[1].close and !d.files[0].close);
+ try std.testing.expectError(error.Edit, fx.edit(0, "D\n,d"));
+ try std.testing.expectEqualStrings("no current window", fx.why.text());
+ // B opens a pane, which the rest of the Edit can then use.
+ const b = try fx.edit(0, "B new.c\nb /src/new.c\n,s/new/old/");
+ try std.testing.expectEqual(@as(usize, 5), b.files.len);
+ try std.testing.expectEqualStrings("old foo\n", try fx.after(b, 4));
+ try std.testing.expectError(error.Edit, fx.edit(0, "B gone.c"));
+ try std.testing.expectEqualStrings("B: cannot open /src/gone.c", fx.why.text());
+ // Every name is checked first: one that cannot open opens none.
+ Fixture.opened = 0;
+ try std.testing.expectError(error.Edit, fx.edit(0, "B new.c gone.c"));
+ try std.testing.expectEqual(@as(usize, 0), Fixture.opened);
+}
+
+test "<, | and > are jobs: a command per address, its output the change" {
+ var fx: Fixture = .init();
+ defer fx.deinit();
+ const res = try fx.edit(0, ",x/foo|bar/ | tr a-z A-Z\n$ > wc -c\n$ < date");
+ try std.testing.expectEqual(@as(usize, 4), res.jobs.len);
+ try std.testing.expectEqual(@as(u8, '|'), res.jobs[0].c);
+ try std.testing.expectEqualStrings("tr a-z A-Z", res.jobs[0].command);
+ try std.testing.expectEqualStrings("foo", res.files[0].text[res.jobs[0].q0..res.jobs[0].q1]);
+ try std.testing.expectEqualStrings("bar", res.files[0].text[res.jobs[1].q0..res.jobs[1].q1]);
+ try std.testing.expectEqual(@as(u8, '>'), res.jobs[2].c);
+ try std.testing.expectEqual(@as(u8, '<'), res.jobs[3].c);
+ // `>` changes nothing; `|` and `<` hold their output's place.
+ const ops = res.files[0].ops.items;
+ try std.testing.expectEqual(@as(usize, 3), ops.len);
+ try std.testing.expectEqual(@as(?u32, 0), ops[0].job);
+ try std.testing.expectEqual(@as(?u32, 1), ops[1].job);
+ try std.testing.expectEqual(@as(?u32, 3), ops[2].job);
+ // ...out of order with the x before it: `1 <` reaches back.
+ try std.testing.expectError(error.Edit, fx.edit(0, ",x/bar/ | cat\n1 < date"));
+ try std.testing.expectEqualStrings("changes out of sequence", fx.why.text());
+ // Across files with X: each job is its file's.
+ const x = try fx.edit(0, "X/\\.c$/ , | sort");
+ try std.testing.expectEqual(@as(usize, 2), x.jobs.len);
+ try std.testing.expectEqual(@as(u32, 2), x.jobs[1].file);
+}
+
+test "an Edit that runs commands is told from one that does not" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+ try std.testing.expect(runsCommands(a, ", | sort"));
+ try std.testing.expect(runsCommands(a, ",x/a/{\n< date\n}"));
+ try std.testing.expect(runsCommands(a, "X/c/ , > wc"));
+ try std.testing.expect(!runsCommands(a, ",s/|/>/g"));
+ try std.testing.expect(!runsCommands(a, ",x/</d"));
+}
diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig
index 44cc1709..c3e5e3a6 100644
--- a/src/selection_pipe.zig
+++ b/src/selection_pipe.zig
@@ -18,11 +18,23 @@ pub const command_timeout_seconds: u64 = 10;
pub const Input = struct { bytes: []const u8 };
/// Borrowed view exposed by the core while an effect is being drained.
+/// An Edit's `<`, `|` and `>` (src/edit_cmd.zig) run a command of their own
+/// per input, each in its file's directory: `commands` and `cwds`, when
+/// given, name input i's, and `shell` (the session's Shell) runs them,
+/// /bin/sh when it is empty.
pub const Request = struct {
id: u32,
command: []const u8,
cwd: []const u8,
inputs: []const Input,
+ commands: []const []const u8 = &.{},
+ cwds: []const []const u8 = &.{},
+ shell: []const u8 = "",
+ /// Nonzero: `stop(token)` from another thread kills its commands.
+ token: u32 = 0,
+ /// The commands' environment, `NAME=value` each, when given; else this
+ /// process's.
+ env: []const []const u8 = &.{},
};
/// Worker-owned snapshot. `copy` is intentionally called synchronously while
@@ -33,12 +45,19 @@ pub const Job = struct {
command: []u8,
cwd: []u8,
inputs: [][]u8,
+ /// Input i's own command and directory, when the request gave them.
+ commands: [][]u8 = &.{},
+ cwds: [][]u8 = &.{},
+ shell: []u8 = &.{},
+ token: u32 = 0,
+ env: [][]u8 = &.{},
pub fn copy(gpa: std.mem.Allocator, request: Request) !*Job {
const job = try gpa.create(Job);
errdefer gpa.destroy(job);
job.* = .{
.id = request.id,
+ .token = request.token,
.command = try gpa.dupe(u8, request.command),
.cwd = &.{},
.inputs = &.{},
@@ -46,22 +65,48 @@ pub const Job = struct {
errdefer gpa.free(job.command);
job.cwd = try gpa.dupe(u8, filesystem.localPath(request.cwd) orelse request.cwd);
errdefer gpa.free(job.cwd);
- job.inputs = try gpa.alloc([]u8, request.inputs.len);
- errdefer gpa.free(job.inputs);
+ job.inputs = try dupeAll(gpa, request.inputs.len, request.inputs, false);
+ errdefer freeAll(gpa, job.inputs);
+ job.commands = try dupeAll(gpa, request.commands.len, request.commands, false);
+ errdefer freeAll(gpa, job.commands);
+ job.cwds = try dupeAll(gpa, request.cwds.len, request.cwds, true);
+ errdefer freeAll(gpa, job.cwds);
+ job.shell = try gpa.dupe(u8, request.shell);
+ errdefer gpa.free(job.shell);
+ job.env = try dupeAll(gpa, request.env.len, request.env, false);
+ return job;
+ }
+
+ /// Owned copies of `items` (Inputs or strings); a directory as the
+ /// host's own path, as `cwd` is.
+ fn dupeAll(gpa: std.mem.Allocator, n: usize, items: anytype, dirs: bool) ![][]u8 {
+ const out = try gpa.alloc([]u8, n);
var made: usize = 0;
- errdefer for (job.inputs[0..made]) |input| gpa.free(input);
- for (request.inputs, 0..) |input, i| {
- job.inputs[i] = try gpa.dupe(u8, input.bytes);
+ errdefer {
+ for (out[0..made]) |item| gpa.free(item);
+ gpa.free(out);
+ }
+ for (items) |item| {
+ const bytes: []const u8 = if (@TypeOf(item) == Input) item.bytes else item;
+ out[made] = try gpa.dupe(u8, if (dirs) filesystem.localPath(bytes) orelse bytes else bytes);
made += 1;
}
- return job;
+ return out;
+ }
+
+ fn freeAll(gpa: std.mem.Allocator, items: [][]u8) void {
+ for (items) |item| gpa.free(item);
+ gpa.free(items);
}
pub fn deinit(job: *Job, gpa: std.mem.Allocator) void {
gpa.free(job.command);
gpa.free(job.cwd);
- for (job.inputs) |input| gpa.free(input);
- gpa.free(job.inputs);
+ freeAll(gpa, job.inputs);
+ freeAll(gpa, job.commands);
+ freeAll(gpa, job.cwds);
+ gpa.free(job.shell);
+ freeAll(gpa, job.env);
gpa.destroy(job);
}
};
@@ -169,17 +214,53 @@ pub const Tasks = struct {
}
};
+/// The stdin writer's own: its copy of the input, freed by the writer when
+/// it is done, so a writer stuck on a command that never reads (and cannot
+/// be killed, below) can be let go of without its input dying under it.
const WriterContext = struct {
+ gpa: std.mem.Allocator,
io: std.Io,
file: std.Io.File,
- input: []const u8,
- ok: bool = false,
+ input: []u8,
};
fn writeInput(context: *WriterContext) void {
- defer context.file.close(context.io);
+ defer {
+ context.file.close(context.io);
+ context.gpa.free(context.input);
+ context.gpa.destroy(context);
+ }
context.file.writeStreamingAll(context.io, context.input) catch return;
- context.ok = true;
+}
+
+/// Kills the command's group, then `abandon`s it.
+fn abandonNow(io: std.Io, child: *std.process.Child, pid: std.posix.pid_t) void {
+ // Before the shell is reaped, while its group id cannot be another's.
+ std.posix.kill(-pid, .KILL) catch {};
+ std.posix.kill(pid, .KILL) catch {};
+ abandon(io, child.*);
+ child.id = null;
+ child.stdout = null;
+ child.stderr = null;
+}
+
+/// A command given up on, killed and reaped where nothing waits for it: a
+/// process in uninterruptible sleep (a write to a file of this very
+/// session, through its mount, waiting on the request whose answer is
+/// waiting on this command) dies only when that request is answered, and
+/// the answer must not wait on its death.
+fn abandon(io: std.Io, child: std.process.Child) void {
+ const Reap = struct {
+ fn run(i: std.Io, c: std.process.Child) void {
+ var reaped = c;
+ reaped.kill(i);
+ }
+ };
+ const thread = std.Thread.spawn(.{}, Reap.run, .{ io, child }) catch {
+ var c = child;
+ return c.kill(io);
+ };
+ thread.detach();
}
/// Run one POSIX shell command with exact stdin, concurrently draining stdout
@@ -194,45 +275,146 @@ pub fn runOne(
cwd: []const u8,
input: []const u8,
) Outcome {
+ return runIn(gpa, io, "", command, cwd, input, 0, &.{});
+}
+
+/// The commands running for a stoppable request (`Request.token`), so
+/// another thread can stop them: each is a process group of its own, which
+/// `stop` kills whole, a `sleep` under the shell too.
+const Running = struct {
+ var lock: std.atomic.Mutex = .unlocked;
+ var procs: [64]struct { token: u32, pid: std.posix.pid_t } = undefined;
+ var len: usize = 0;
+ /// Tokens stopped lately: a command not started yet is not.
+ var stopped: [16]u32 = @splat(0);
+ var next: usize = 0;
+
+ fn take() void {
+ while (!lock.tryLock()) std.atomic.spinLoopHint();
+ }
+
+ fn isStopped(token: u32) bool {
+ return std.mem.indexOfScalar(u32, &stopped, token) != null;
+ }
+
+ /// Whether `pid` may run on: false once its token was stopped.
+ fn add(token: u32, pid: std.posix.pid_t) bool {
+ take();
+ defer lock.unlock();
+ if (isStopped(token)) return false;
+ if (len < procs.len) {
+ procs[len] = .{ .token = token, .pid = pid };
+ len += 1;
+ }
+ return true;
+ }
+
+ fn remove(pid: std.posix.pid_t) void {
+ take();
+ defer lock.unlock();
+ for (procs[0..len], 0..) |p, i| if (p.pid == pid) {
+ procs[i] = procs[len - 1];
+ len -= 1;
+ return;
+ };
+ }
+};
+
+/// Stops the request `token` names: its running commands' process groups
+/// are killed, and no command of it starts after. From any thread.
+pub fn stop(token: u32) void {
+ if (token == 0) return;
+ Running.take();
+ defer Running.lock.unlock();
+ Running.stopped[Running.next] = token;
+ Running.next = (Running.next + 1) % Running.stopped.len;
+ for (Running.procs[0..Running.len]) |p| if (p.token == token) {
+ std.posix.kill(-p.pid, .KILL) catch {};
+ };
+}
+
+/// A process-unique token for a stoppable request.
+pub fn newToken() u32 {
+ const t = next_token.fetchAdd(1, .monotonic);
+ return if (t == 0) next_token.fetchAdd(1, .monotonic) else t;
+}
+var next_token: std.atomic.Value(u32) = .init(1);
+
+/// `runOne` through `shell -c` (/bin/sh when it is empty).
+pub fn runIn(
+ gpa: std.mem.Allocator,
+ io: std.Io,
+ shell: []const u8,
+ command: []const u8,
+ cwd: []const u8,
+ input: []const u8,
+ token: u32,
+ env: []const []const u8,
+) Outcome {
const fail = struct {
fn k(kind: Failure.Kind) Outcome {
return .{ .failed = .{ .kind = kind } };
}
};
if (std.mem.indexOfScalar(u8, command, 0) != null or
- std.mem.indexOfScalar(u8, cwd, 0) != null) return fail.k(.spawn);
+ std.mem.indexOfScalar(u8, cwd, 0) != null or
+ std.mem.indexOfScalar(u8, shell, 0) != null) return fail.k(.spawn);
// std's spawn runs no code in the child: this thread's mask, which the
// child inherits, is cleared across the fork (only the tty's SIGWINCH is
// ever blocked, and its default is to be ignored). A handler resets at
// exec by itself, and nothing here is ignored.
+ var environ: std.process.Environ.Map = .init(gpa);
+ defer environ.deinit();
+ for (env) |line| {
+ const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue;
+ environ.put(line[0..eq], line[eq + 1 ..]) catch return fail.k(.spawn);
+ }
const none = std.posix.sigemptyset();
var kept: std.posix.sigset_t = undefined;
std.posix.sigprocmask(std.posix.SIG.SETMASK, &none, &kept);
defer std.posix.sigprocmask(std.posix.SIG.SETMASK, &kept, null);
var child = std.process.spawn(io, .{
- .argv = &.{ "/bin/sh", "-c", command },
+ .argv = &.{ if (shell.len == 0) "/bin/sh" else shell, "-c", command },
.cwd = if (cwd.len == 0) .inherit else .{ .path = cwd },
+ .environ_map = if (env.len > 0) &environ else null,
.stdin = .pipe,
.stdout = .pipe,
.stderr = .pipe,
+ // A group of its own: a stop or a timeout kills what it started too.
+ .pgid = 0,
}) catch return fail.k(.spawn);
+ const pid = child.id.?;
- var writer_context: WriterContext = .{
+ const writer_context = gpa.create(WriterContext) catch {
+ abandonNow(io, &child, pid);
+ return fail.k(.spawn);
+ };
+ writer_context.* = .{
+ .gpa = gpa,
.io = io,
.file = child.stdin.?,
- .input = input,
+ .input = gpa.dupe(u8, input) catch {
+ gpa.destroy(writer_context);
+ abandonNow(io, &child, pid);
+ return fail.k(.spawn);
+ },
};
child.stdin = null; // writer_context owns and closes this endpoint
- var writer: ?std.Thread = std.Thread.spawn(.{}, writeInput, .{&writer_context}) catch {
+ var writer: ?std.Thread = std.Thread.spawn(.{}, writeInput, .{writer_context}) catch {
writer_context.file.close(io);
- child.kill(io);
+ gpa.free(writer_context.input);
+ gpa.destroy(writer_context);
+ abandonNow(io, &child, pid);
return fail.k(.spawn);
};
- // On every early return kill first, unblocking a command which never read
- // stdin, then join the short-lived writer before its borrowed input dies.
- defer if (writer) |thread| thread.join();
- defer child.kill(io);
+ // On every early return (a timeout, a ceiling) the answer goes at once:
+ // the group is killed and the shell left to be reaped elsewhere
+ // (abandon), and the writer, which owns its input, let go.
+ defer if (writer) |thread| thread.detach();
+ defer if (child.id != null) abandonNow(io, &child, pid);
+ defer if (token != 0) Running.remove(pid);
+ if (token != 0 and !Running.add(token, pid)) return fail.k(.signal);
var multi_reader_buffer: std.Io.File.MultiReader.Buffer(2) = undefined;
var multi_reader: std.Io.File.MultiReader = undefined;
@@ -259,6 +441,7 @@ pub fn runOne(
if (stdout_reader.buffered().len > max_stdout_bytes or
stderr_reader.buffered().len > max_stderr_bytes) return fail.k(.too_large);
multi_reader.checkAnyError() catch return fail.k(.io);
+ if (token != 0) Running.remove(pid);
const term = child.wait(io) catch return fail.k(.io);
writer.?.join();
writer = null;
@@ -292,6 +475,12 @@ pub fn runOne(
return .{ .ok = stdout };
}
+fn tokenStopped(token: u32) bool {
+ Running.take();
+ defer Running.lock.unlock();
+ return Running.isStopped(token);
+}
+
/// Invoke the command independently for every selection. The response is all
/// or nothing: one failure frees every earlier stdout and returns a failed,
/// empty answer for the core to ignore.
@@ -301,7 +490,13 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response {
var made: usize = 0;
var total: usize = 0;
for (job.inputs, 0..) |input, i| {
- const output = switch (runOne(gpa, io, job.command, job.cwd, input)) {
+ const command = if (job.commands.len > 0) job.commands[i] else job.command;
+ const cwd = if (job.cwds.len > 0) job.cwds[i] else job.cwd;
+ if (job.token != 0 and tokenStopped(job.token)) {
+ response.failure = .{ .kind = .signal, .index = @intCast(i) };
+ break;
+ }
+ const output = switch (runIn(gpa, io, job.shell, command, cwd, input, job.token, job.env)) {
.ok => |bytes| bytes,
.failed => |f| {
// WHICH selection, because with several cursors "it failed" is
@@ -400,3 +595,53 @@ test "native pipe runner preserves stdin/stdout bytes and reports how it failed"
.failed => return error.EarlyStdinCloseShouldNotFail,
}
}
+
+test "a request may give each input its own command and directory, run by the shell it names" {
+ const gpa = std.testing.allocator;
+ const job = try Job.copy(gpa, .{
+ .id = 3,
+ .command = "",
+ .cwd = "",
+ .inputs = &.{ .{ .bytes = "" }, .{ .bytes = "abc" } },
+ .commands = &.{ "pwd", "tr a-z A-Z" },
+ .cwds = &.{ "/tmp", "/" },
+ .shell = "/bin/sh",
+ });
+ defer job.deinit(gpa);
+ var response = runJob(gpa, std.testing.io, job);
+ defer response.deinit(gpa);
+ try std.testing.expect(response.success);
+ try std.testing.expectEqualStrings("/tmp\n", response.outputs[0]);
+ try std.testing.expectEqualStrings("ABC", response.outputs[1]);
+}
+
+test "stop kills a running request's commands, the whole group, and starts none after" {
+ const gpa = std.testing.allocator;
+ const token = newToken();
+ const job = try Job.copy(gpa, .{
+ .id = 1,
+ .command = "",
+ .cwd = "",
+ .inputs = &.{ .{ .bytes = "" }, .{ .bytes = "" } },
+ .commands = &.{ "sleep 30; echo late", "echo never" },
+ .token = token,
+ });
+ defer job.deinit(gpa);
+ const Run = struct {
+ fn go(j: *const Job, out: *Response) void {
+ out.* = runJob(std.testing.allocator, std.testing.io, j);
+ }
+ };
+ var response: Response = undefined;
+ const started = std.Io.Clock.awake.now(std.testing.io);
+ const thread = try std.Thread.spawn(.{}, Run.go, .{ job, &response });
+ std.Io.sleep(std.testing.io, .fromMilliseconds(300), .awake) catch {};
+ stop(token);
+ thread.join();
+ defer response.deinit(gpa);
+ try std.testing.expect(!response.success);
+ try std.testing.expectEqual(Failure.Kind.signal, response.failure.?.kind);
+ try std.testing.expectEqual(@as(u32, 0), response.failure.?.index);
+ const took = started.durationTo(std.Io.Clock.awake.now(std.testing.io));
+ try std.testing.expect(took.toMilliseconds() < 5000);
+}
diff --git a/src/surface.zig b/src/surface.zig
index a08d6831..f7cc6010 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -589,7 +589,7 @@ pub const Surface = struct {
var cp = decoded orelse 0xFFFD;
if (cp == '\r') continue;
if (cp == '\t') {
- const spaces = @min(config.tab_width, end - col);
+ const spaces = @min(config.tab_now, end - col);
s.fill(col, y, spaces, 1, style);
col += spaces;
continue;
@@ -714,7 +714,7 @@ test "the ASCII fast path in surface print paints what the general arm paints" {
var cp = decoded orelse 0xFFFD;
if (cp == '\r') continue;
if (cp == '\t') {
- const spaces = @min(config.tab_width, end - col);
+ const spaces = @min(config.tab_now, end - col);
s.fill(col, y, spaces, 1, style);
col += spaces;
continue;
diff --git a/src/tagline.zig b/src/tagline.zig
index 5a9e2346..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;
@@ -94,10 +95,11 @@ fn shownDir(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
/// The computed start of a pane's tag -- its path and dirty marker, a
/// PDF's page, a terminal's directory -- without the space that parts it
/// from the tag's own text. It is never stored, and never edited.
-pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+pub fn pathPrefix(p: *const Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
if (comptime pdf_enabled) if (pane.pdf) |pv|
return std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count });
- if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
+ // What is drawn: with no graphics, glyph art whatever `Petscii` says.
+ if (pane.image) |*state| return panes.Image.tagPrefix(arena, state, state.glyph_art or !p.native_images);
// A command pane: its directory, the line it runs, and how that went.
// Its first line only, cut to 48 bytes at a character's start: a tag's
// first row is where the prefix ends, and a line that ran over to a
@@ -127,18 +129,21 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
if (pane.file) |f| return if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
// A terminal showing kitty graphics says how it draws them, as an image
// pane does, and so does one drawing them as glyph art by choice.
- if (panes.terminal.petscii(pane)) |on| if (on or panes.terminal.hasImages(pane))
- return std.fmt.allocPrint(arena, "{s} petscii:{s}", .{ (try shownDir(arena, pane)), if (on) "on" else "off" });
+ if (panes.terminal.petscii(pane)) |chosen| {
+ const on = chosen or !p.native_images;
+ if (chosen or panes.terminal.hasImages(pane))
+ return std.fmt.allocPrint(arena, "{s} petscii:{s}", .{ (try shownDir(arena, pane)), if (on) "on" else "off" });
+ }
return (try shownDir(arena, pane));
}
pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
- return prefixIn(p.scratch.allocator(), pane);
+ return prefixIn(p, p.scratch.allocator(), pane);
}
/// The prefix as the tag shows it, built in `arena`.
-pub fn prefixIn(arena: std.mem.Allocator, pane: *Pane) ![]u8 {
- const prefix = try pathPrefix(arena, pane);
+pub fn prefixIn(p: *const Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
+ const prefix = try pathPrefix(p, arena, pane);
// The space between the prefix and the tag's own text is the text's
// (every default starts with one), but the prefix is computed, so it
// cannot trust the text to keep it: with it deleted, Look and Exec
@@ -158,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/src/terminal.zig b/src/terminal.zig
index 80b9455c..3bb1fbda 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -1637,6 +1637,11 @@ pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
if (pane.ovl) |overlay| p.gpa.free(overlay.text);
pane.ovl = .{ .row = row0, .rows = covered, .text = text };
}
+ // A terminal's text is its program's: edited here it is a copy, to run
+ // with Tab or B2, gone when the program draws again. Said at its first
+ // edit, so it never looks like an edit that was lost.
+ if (fresh) for (p.panes, 0..) |slot, id| if (slot == pane)
+ p.setMessage(id, "a copy of the terminal's text: Tab or B2 runs it; what the program draws next replaces it (Ctrl-b types to the program)");
return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row };
}
@@ -3072,8 +3077,8 @@ test "without pixels, or by the Petscii builtin, a terminal's image is glyph art
try std.testing.expectEqual(@as(usize, 0), imagePlaces(s).len);
try std.testing.expect(painted(s.at(tx, y)) and painted(s.at(tx + 1, y)));
try std.testing.expect(!painted(s.at(tx + 2, y)));
- // The tag says so, as an image pane's does.
- try std.testing.expect(std.mem.indexOf(u8, try tagline.tagPrefix(p, pane), " petscii:off") != null);
+ // The tag says what is drawn, glyph art, as an image pane's does.
+ try std.testing.expect(std.mem.indexOf(u8, try tagline.tagPrefix(p, pane), " petscii:on") != null);
// Pixels where they can be drawn; the builtin flips the pane to glyphs
// and back.
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index a067ffce..ca2cb939 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -2452,6 +2452,9 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
.wheel_down => .wheel_down,
.wheel_left => .wheel_left,
.wheel_right => .wheel_right,
+ // A mouse's side buttons, as xterm reports them: Back and Forward.
+ .button_8 => .back,
+ .button_9 => .forward,
.none => .none, // button-less motion: hover tracking
else => return null,
};
@@ -2463,14 +2466,41 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
.motion => .motion,
.drag => .drag,
},
- .col = @intCast(m.col),
- .row = @intCast(m.row),
+ .col = cellOf(m.col),
+ .row = cellOf(m.row),
.ctrl = m.mods.ctrl,
.shift = m.mods.shift,
.alt = m.mods.alt,
} };
}
+/// A report's cell, 0-based: one before the first (SGR's 0, a drag past
+/// the window's top or left edge, in cells or in 1016's pixels) is the
+/// first, never a panic; past the far edge the core clamps it.
+pub fn cellOf(v: i16) u16 {
+ return @intCast(@max(0, v));
+}
+
+test "a mouse report at row or column 0, or before it, is the window's edge, never a panic" {
+ const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ for ([_][]const u8{ "\x1b[<1;42;0M", "\x1b[<0;0;5M", "\x1b[<32;0;0M", "\x1b[<0;0;0m" }) |sequence| {
+ var parser: vaxis.Parser = .{};
+ const result = try parser.parse(sequence, std.testing.allocator);
+ const m = result.event.?.mouse;
+ const event = mouseEvent(m) orelse continue;
+ try std.testing.expect(event.mouse.col <= 41 and event.mouse.row <= 4);
+ p.update(event);
+ }
+}
+
+test "a mouse's side buttons, xterm's 8 and 9, are Back and Forward, as SDL's X1 and X2 are" {
+ for ([_]vaxis.Mouse.Button{ .button_8, .button_9 }, [_]pardes.Mouse.Button{ .back, .forward }) |from, to| {
+ const event = mouseEvent(.{ .col = 3, .row = 2, .button = from, .mods = .{}, .type = .press }).?;
+ try std.testing.expectEqual(to, event.mouse.button);
+ }
+}
+
/// A blank whose ink alone changed is not sent: vaxis's render skips it
/// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows
/// nowhere. Deciding that here cost every cell of every frame a look at the
diff --git a/src/tutor.txt b/src/tutor.txt
index dc9ceb87..0f3e3359 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -1,6 +1,5 @@
PARDES TUTOR
- tmux + vi + acme, by doing.
This pane is +Tutor, a copy: edit it freely; there is no need to
Save it (a bare Save asks for a path to write a copy to).
@@ -10,11 +9,11 @@
The guide (docs/typ/guide.typ) explains; this is the hands-on part.
Each lesson is a few things to do, then names its guide section.
- Words in backticks, like `Save`, are builtins. Two carry the rest:
- `Exec` (middle-click, B2) runs the builtin a word names, or else
- runs it as a shell line; `Look` (right-click, B3) opens what the
- text names, or else finds it. Type a builtin in any tag and `Exec`
- it. Lines starting with "| " are tags.
+ Words in backticks, like `Save`, are builtins.
+ `Exec` (B2) runs the builtin a word names, or else runs it as a
+ shell line; `Look` (B3) opens what the text names, or else finds
+ it. Type a builtin in any tag and `Exec` it. Lines starting with
+ "| " are tags.
Press j until you reach part 1.
@@ -29,9 +28,10 @@
2. Press Esc once more, in NORMAL: the keyboard goes back to the
pane you were in before (not what helix does). Come back with
Esc again.
- 3. Press Alt-n: a terminal opens, in NORMAL mode. Press Ctrl-b: its
- box shows $ (RAW), and keys go to the shell. At its empty prompt,
- Esc brings you back here; the terminal stays $.
+ 3. Press Alt-n: a terminal opens, in NORMAL mode, and pardes types
+ ls at its first prompt. Press Ctrl-b: its box shows $ (RAW), and
+ keys go to the shell. At its empty prompt, Esc brings you back
+ here; the terminal stays $.
-> Guide: Modes
@@ -67,8 +67,8 @@
| Tty Collapse Del
1. Press : here: the keyboard goes to this pane's tag, on `Save`.
- Press A, type a space and Help, press Esc, then `Exec`
- `Help` there. Press : again to come back.
+ Press A, type a space and Help, press Esc, then Tab: `Help`
+ runs and its list takes the keyboard. Esc brings you back.
2. Click `Collapse` in this tag: the pane folds to its tag. Click
it again.
3. Drag the box left of the tag down a few rows, then back.
@@ -86,6 +86,8 @@
takes the keyboard.
3. Ctrl-w h, j, k, l move the keyboard between panes; Ctrl-o and
Ctrl-i walk back and forward through where you have been.
+ Ctrl-i needs the kitty keyboard protocol or the GUI; elsewhere it
+ is Tab, so use SPC j i (and SPC j o), or the mouse's side buttons.
4. `Del` the scratch and the command pane, each in its own tag.
-> Guide: Where commands run and panes go
@@ -96,9 +98,11 @@
=================================================================
1. `Look` :20 here: the cursor goes to line 20 of this pane.
- 2. `Look` :/UNSAVED/ to go to the next match of UNSAVED.
+ 2. Drag across :/^=.*UNSAVED/ with B3 held: `Look` takes exactly
+ what you swept, a regex here, and goes to Part 7's heading.
3. Press /, type keys, press Enter: a +Search lists the lines with
- keys and the cursor goes to the first. n and N step through.
+ keys. n moves into it and walks its rows; Enter on a row goes
+ to that line here.
-> Guide: Look
@@ -107,9 +111,9 @@
= PART 6 — TERMINALS =
=================================================================
- 1. In the terminal from part 1 (or Alt-n again), type ls and press
- Enter in RAW mode. Then Ctrl-b to NORMAL: move over its output
- with j and k, select a name with x, yank it with y.
+ 1. In the terminal from part 1 (or Alt-n again), its ls has listed
+ the directory. Press Ctrl-b to NORMAL, put the cursor on a name
+ with j, k and w, select it with m i W, and yank it with y.
2. Come back here (Ctrl-w, or click) and press p: the name is
pasted; yank and paste are shared by every pane.
3. `Del` the terminal: it closes with its shell.
@@ -175,12 +179,11 @@ abcdef
# after
abcdef
- A number typed FIRST is a count, and only some keys take one,
- among them the motions, f/F/t/T and the Alt-. that repeats them,
- gg/gj/gk, x, o/O, > and <, p/P, R, Ctrl-a/Ctrl-x, the cursor-list
- keys C and ( ), q and the . repeat. Elsewhere it is swallowed: 3d
- deletes once, 3i types once. 0 is always line start and never starts a
- count. Ctrl-d/u/f ignore counts: a page is a page.
+ 3l moves three: a number typed FIRST is a count. The motions,
+ f/F/t/T and Alt-., gg/gj/gk, x, o/O, > <, p/P, R, Ctrl-a/Ctrl-x,
+ C ( ), q and . take one; any other key swallows it (3d deletes
+ once, 3i types once). 0 is always line start; Ctrl-d/u/f ignore
+ counts.
# keys: 3 l i Z esc
# before
@@ -276,12 +279,11 @@ typed
= 9.6 SPC — THE LEADER =
-----------------------------------------------------------------
- Nearly every builtin has a NAME you can `Exec` wherever text lives
- and a KEY PATH you can press. SPC in NORMAL mode starts the path;
- what you have typed shows on the message row until it fires. Esc
- abandons it, and so does any key that leads nowhere.
+ SPC f s is `Save`: nearly every builtin has a key path as well as
+ a name. What you have typed shows on the message row; Esc, or a key
+ that leads nowhere, abandons it.
- SPC ? list every path
+ SPC ? `Help`: every builtin and its keys
SPC d `Del` SPC q `Exit`
SPC f s `Save` SPC f f `Find` SPC f n `New`
SPC y yank to the clipboard SPC p paste from it
@@ -292,6 +294,7 @@ typed
SPC h t this tutor
-> Guide: Keys
+ -> Every helix key: https://docs.helix-editor.com/keymap.html
=================================================================
@@ -299,8 +302,7 @@ typed
=================================================================
Every session is a virtual filesystem, served over 9P: a program that
- opens files IS an extension, with no plugin API. Pane shells get
- $PARDES_9P (the socket) and $PARDES_PANE (their pane's serial).
+ opens files IS an extension.
9ns --mntgen mount every session (9ns is cloud9's)
m=$PARDES_MOUNT the session, under 9ns
@@ -322,18 +324,17 @@ typed
=================================================================
MODES blank NORMAL, ^ INSERT, $ RAW; each pane keeps its own
- Esc and Shift-Esc: see the guide's Modes table
- (Shift-Esc needs the kitty keyboard protocol or the
- GUI; elsewhere it is a plain Esc)
+ Esc in NORMAL goes back a pane (the guide's Modes table)
Ctrl-b toggles RAW and NORMAL
MOUSE L select/focus M `Exec` R `Look`
- 1-2 cut 1-3 paste 1-2 then 1-3 copy
+ 1-2 cut 1-3 paste both in turn: copy
2-1 `Exec` the middle word with the left selection
keyboard: Enter = `Look`, Tab = `Exec`
PANES Ctrl-w h/j/k/l focus a neighbour (NORMAL mode)
Ctrl-o / Ctrl-i jump history back / forward
+ (SPC j o / SPC j i where Ctrl-i is Tab)
Alt-n new terminal Alt-c pane into a new column
KEYS h j k l w b e 0 gl ^ gg ge f F t T v x d c y p
@@ -343,7 +344,7 @@ typed
bare G does nothing; ge is the START of the last line
s / S regex cursors; m i/a textobjects; m s/r/d surround
/ is a case-insensitive SUBSTRING search, not a regex
- SPC is the leader (SPC ? lists every path)
+ SPC is the leader (SPC ? is `Help`)
vs HELIX selection is LINE-first (x), v for characters; / is a
substring search; n/N walk what `Look` would open.