diff options
Diffstat (limited to 'src')
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; @@ -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(¬ice, "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. |
