diff options
Diffstat (limited to 'src')
44 files changed, 4252 insertions, 525 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig index d6eef9d4..2ca11a87 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -174,6 +174,16 @@ pub const Listener = struct { core: *pardes.Pardes, runner: Runner = undefined, stopping: std.atomic.Value(bool) = .init(false), + /// A connection wrote the Restore and has yet to answer it. + restore_writer: std.atomic.Value(bool) = .init(false), + /// 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 + /// connection's task as it opens, read with the turn. + accepts: std.atomic.Value(u64) = .init(0), + accepted: [max_conns]std.atomic.Value(u64) = @splat(.init(0)), + /// Connections accepted before this count are being cut by a Restore. + cut_before: u64 = 0, tcp_address: ?std.Io.net.IpAddress = null, quic: if (quic_enabled) ?quic.Listener else void = if (quic_enabled) null else {}, quic_address: ?std.Io.net.IpAddress = null, @@ -207,6 +217,16 @@ pub const Listener = struct { /// answers. A request that would change a pane while the editor is out /// in a syscall mid-step is parked in the engine instead, and retried /// when the turn is next given up between steps (`wakeParked`). + fn onOpened(ctx: ?*anyopaque, conn: *Runner.Conn) void { + const l = of(ctx); + l.accepted[conn.index].store(l.accepts.fetchAdd(1, .acq_rel) + 1, .release); + } + + /// A live connection accepted before the Restore under way. + fn cutting(l: *Listener, conn: *Runner.Conn) bool { + return conn.live() and l.accepted[conn.index].load(.acquire) <= l.cut_before and l.cut_before != 0; + } + fn onServe(ctx: ?*anyopaque, conn: *Runner.Conn, req: pardes.ctlfs.Req) void { const l = of(ctx); // Once `stop` has begun the editor is tearing down and may never @@ -217,6 +237,12 @@ pub const Listener = struct { } const quiet = pardes.turn.take(); defer pardes.turn.give(); + // A connection a Restore is cutting (`reset`) is the old editor's + // client: its fids name the old editor's panes and opens. + if (l.cutting(conn)) { + const refused = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO); + return conn.reply(&refused, ""); + } if (!quiet and pardes.ctlfs.needsQuiet(req)) { pardes.turn.parked = true; const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again }; @@ -267,13 +293,20 @@ pub const Listener = struct { // and `deinit` lets the answer out before it cuts the connections. if (core.quit) return conn.reply(&reply, ""); 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); + defer if (restoring) l.restore_writer.store(false, .release); if (core.effects_len != 0) pardes.turn.awaitSettled(epoch); + // The Restore's own write is answered once it is done, and before + // `reset` hangs every connection up, this one too, so the writer + // hears that it happened rather than a cut; a failed one leaves the + // connection and says why on the message row and in the log. + if (restoring) { + if (l.core == core) pardes.turn.awaitRestored(restores); + return conn.reply(&reply, ""); + } // Once a wait returns `core` may be gone: a Restore meanwhile put a - // replacement in (`reset`) and is hanging this connection up, which - // is a Restore's answer. Only one that failed leaves this connection - // here to answer. - if (l.core != core) return; - if (restoring) pardes.turn.awaitRestored(restores); + // replacement in and is hanging this connection up. if (l.core != core) return; conn.reply(&reply, ""); } @@ -484,9 +517,9 @@ pub const Listener = struct { /// Hangs every connection up, so that `replacement` starts with no /// client holding anything. From here requests are answered from the /// replacement -- a client that connects meanwhile is a client of the - /// replacement -- and every task waiting on the old core is let go: it - /// finds the core changed and answers nothing, its connection being - /// hung up. The connections pay their releases on their own tasks, so + /// replacement -- and every task waiting on the old core is let go: the + /// Restore's own writer answers, the rest find the core changed and + /// answer nothing, their connections being hung up. The connections pay their releases on their own tasks, so /// the editor rests while they do. pub fn reset(l: *Listener, replacement: *pardes.Pardes) void { for (0..quic_slots) |i| l.drop(@intCast(i)); @@ -496,13 +529,38 @@ pub const Listener = struct { replacement.fs.tcp_address = l.tcp_address; replacement.fs.quic_address = l.quic_address; if (comptime supported) { - l.runner.closeAll(); + // The Restore's writer answers as it wakes (`onServe`), and its + // answer gets 200 ms to leave before the cut; every other request + // from the old editor's clients meanwhile is refused, since the + // fids it names are the old editor's. A client that connects now + // is the replacement's, and stays. + l.cut_before = l.accepts.load(.acquire); pardes.turn.settle(); pardes.turn.restoreSettled(); pardes.turn.rest(); + const flush_by = nowMs() +| 200; + while (nowMs() < flush_by) { + const pending = l.restore_writer.load(.acquire) or for (&l.runner.conns) |*conn| { + if (!l.cutting(conn)) continue; + conn.lock(); + const n = conn.engine.output().len; + conn.unlock(); + if (n != 0) break true; + } else false; + if (!pending) break; + Client.nap(1); + } + for (&l.runner.conns) |*conn| if (l.cutting(conn)) conn.close(); const deadline = nowMs() +| 2000; - while (l.runner.count() != 0 and nowMs() < deadline) Client.nap(1); + while (nowMs() < deadline) { + const left = for (&l.runner.conns) |*conn| { + if (l.cutting(conn)) break true; + } else false; + if (!left) break; + Client.nap(1); + } pardes.turn.wake(); + l.cut_before = 0; } l.collectOs(); for (&l.conns) |*conn| conn.accepted_ms = 0; @@ -758,7 +816,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ l.runner.init(.{ .io = io, .root = pardes.ctlfs.root, - .handler = .{ .ctx = l, .serve = Listener.onServe }, + .handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened }, .greet_timeout_ms = Listener.greet_deadline_ms, }); const entry_name = if (named.len != 0) named else fallback; diff --git a/src/File.zig b/src/File.zig index 26585f77..b8368482 100644 --- a/src/File.zig +++ b/src/File.zig @@ -70,9 +70,19 @@ pub const State = struct { tree_context: bool = false, context_declarations: []syntax.ContextDeclaration = &.{}, context_revision: ?u32 = null, + /// The whole file's parse for the keys that walk its nodes (Alt-o and + /// kin), made on the first of them after an edit and kept until the next + /// one: `syntax.parseTree`'s, opaque here. + node_tree: ?*anyopaque = null, + node_tree_revision: u32 = 0, + /// helix's object_selections: what Alt-o grew from, for Alt-i to go back + /// to; gpa-owned ranges and the primary's index. + node_history: std.ArrayList(NodeStep) = .empty, history: *History, }; +pub const NodeStep = struct { ranges: []modal.Selection, primary: usize }; + pub fn dumpPane( arena: std.mem.Allocator, pane: *const Pane, @@ -817,6 +827,9 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); locations.freeRows(p.gpa, file.location_rows); if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations); + 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); @@ -1130,7 +1143,7 @@ pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, const goff = pane.scroll(); const gcur = Terminal.gridCursor(pane); const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff; - const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side); + const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice); const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); // the body's first row, the way renderPane derives it (Tagbottom) const body_y = p.bodyTop(pane, r); diff --git a/src/Macro.zig b/src/Macro.zig new file mode 100644 index 00000000..7475992e --- /dev/null +++ b/src/Macro.zig @@ -0,0 +1,176 @@ +//! Keys kept to be typed again. `Q` records the keys that follow into a +//! register until the next `Q`, and `q` types them again (helix macros); +//! `.` types again the last insert session together with the normal command +//! that began it (helix repeat_last_insert). A macro is kept in its register +//! as helix writes one, in key notation (`xt,S=<ret>_<A-(>`), so it can be +//! pasted, edited and yanked back like any text. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Key = pardes.Key; + +const Macro = @This(); + +/// The register `Q` is recording into, 0 when it is not. +recording: u21 = 0, +recorded: std.ArrayList(Stored) = .empty, +/// The keys of the normal command under way (a count, `"<reg>`, a prefix +/// and the key that completes it), dropped when it completes without +/// entering insert mode. +command: std.ArrayList(Stored) = .empty, +/// `.`'s: the command that began the last insert session and every key +/// typed in it, the one that left it included. +last_insert: std.ArrayList(Stored) = .empty, +/// Where in `last_insert` the typed keys begin, after the command's. +typed_from: usize = 0, +/// Keys are going into `last_insert`. +inserting: bool = false, +/// How deep macros are being typed again; `.`'s replay is `repeating`. +replaying: u8 = 0, +repeating: bool = false, + +/// A key as it can be typed again: what it was, not the text it came with. +pub const Stored = struct { + cp: u21, + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + + pub fn of(key: Key) Stored { + return .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }; + } + + /// The key again, its text made from its codepoint into `buf`. + pub fn typed(s: Stored, buf: *[4]u8) Key { + var k: Key = .{ .cp = s.cp, .ctrl = s.ctrl, .alt = s.alt, .shift = s.shift }; + if (!s.ctrl and !s.alt and s.cp >= 0x20 and s.cp != Key.backspace and s.cp < 0xF0000) { + const n = std.unicode.utf8Encode(s.cp, buf) catch return k; + k.text = buf[0..n]; + } + return k; + } +}; + +pub fn deinit(m: *Macro, gpa: std.mem.Allocator) void { + m.recorded.deinit(gpa); + m.command.deinit(gpa); + m.last_insert.deinit(gpa); + m.* = .{}; +} + +/// A typed key, each of its characters one key: what a host delivers as +/// one key event with a run of text is that run typed. +pub fn append(list: *std.ArrayList(Stored), gpa: std.mem.Allocator, key: Key) void { + const view = std.unicode.Utf8View.init(key.text) catch null; + if (key.text.len == 0 or key.ctrl or key.alt or view == null) { + list.append(gpa, .of(key)) catch {}; + return; + } + var it = view.?.iterator(); + while (it.nextCodepoint()) |cp| list.append(gpa, .{ .cp = cp, .shift = key.shift }) catch {}; +} + +const names = [_]struct { cp: u21, name: []const u8 }{ + .{ .cp = Key.escape, .name = "esc" }, + .{ .cp = Key.enter, .name = "ret" }, + .{ .cp = Key.tab, .name = "tab" }, + .{ .cp = Key.backspace, .name = "backspace" }, + .{ .cp = Key.delete, .name = "del" }, + .{ .cp = ' ', .name = "space" }, + .{ .cp = '<', .name = "lt" }, + .{ .cp = '>', .name = "gt" }, + .{ .cp = '-', .name = "minus" }, + .{ .cp = Key.up, .name = "up" }, + .{ .cp = Key.down, .name = "down" }, + .{ .cp = Key.left, .name = "left" }, + .{ .cp = Key.right, .name = "right" }, + .{ .cp = Key.home, .name = "home" }, + .{ .cp = Key.end, .name = "end" }, + .{ .cp = Key.page_up, .name = "pageup" }, + .{ .cp = Key.page_down, .name = "pagedown" }, +}; + +/// Keys in helix's notation: a plain character as itself, anything else +/// in angle brackets with its modifiers (`<C-r>`, `<A-(>`, `<ret>`). +pub fn encode(gpa: std.mem.Allocator, keys: []const Stored) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + for (keys) |k| { + var name: ?[]const u8 = null; + for (names) |n| if (n.cp == k.cp) { + name = n.name; + }; + // `-` is only spelled out after a modifier, where helix needs it + if (k.cp == '-' and !k.ctrl and !k.alt) name = null; + var buf: [4]u8 = undefined; + const bare = if (name) |n| n else buf[0..(std.unicode.utf8Encode(k.cp, &buf) catch continue)]; + if (!k.ctrl and !k.alt and name == null) { + try out.appendSlice(gpa, bare); + continue; + } + try out.append(gpa, '<'); + if (k.ctrl) try out.appendSlice(gpa, "C-"); + if (k.alt) try out.appendSlice(gpa, "A-"); + try out.appendSlice(gpa, bare); + try out.append(gpa, '>'); + } + return out.toOwnedSlice(gpa); +} + +/// The keys helix notation names; what it cannot read ends the list. +pub fn decode(gpa: std.mem.Allocator, text: []const u8) ![]Stored { + var out: std.ArrayList(Stored) = .empty; + errdefer out.deinit(gpa); + var i: usize = 0; + while (i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch break; + if (i + len > text.len) break; + if (text[i] != '<') { + try out.append(gpa, .{ .cp = std.unicode.utf8Decode(text[i..][0..len]) catch break }); + i += len; + continue; + } + const close = std.mem.indexOfScalarPos(u8, text, i + 1, '>') orelse break; + var name = text[i + 1 .. close]; + i = close + 1; + var k: Stored = .{ .cp = 0 }; + while (name.len > 2 and name[1] == '-') : (name = name[2..]) switch (name[0]) { + 'C' => k.ctrl = true, + 'A' => k.alt = true, + 'S' => k.shift = true, + else => break, + }; + for (names) |n| if (std.mem.eql(u8, n.name, name)) { + k.cp = n.cp; + }; + if (k.cp == 0) k.cp = std.unicode.utf8Decode(name) catch break; + try out.append(gpa, k); + } + return out.toOwnedSlice(gpa); +} + +test "a macro's keys go into helix notation and come back the same" { + const gpa = std.testing.allocator; + const keys = [_]Stored{ + .{ .cp = 'x' }, + .{ .cp = 't' }, + .{ .cp = ',' }, + .{ .cp = 'S' }, + .{ .cp = '=' }, + .{ .cp = Key.enter }, + .{ .cp = '_' }, + .{ .cp = '(', .alt = true }, + .{ .cp = 'r', .ctrl = true }, + .{ .cp = '<' }, + .{ .cp = ' ' }, + .{ .cp = '-' }, + .{ .cp = '-', .alt = true }, + .{ .cp = 'λ' }, + .{ .cp = Key.escape }, + }; + const text = try encode(gpa, &keys); + defer gpa.free(text); + try std.testing.expectEqualStrings("xt,S=<ret>_<A-(><C-r><lt><space>-<A-minus>λ<esc>", text); + const back = try decode(gpa, text); + defer gpa.free(back); + try std.testing.expectEqualSlices(Stored, &keys, back); +} diff --git a/src/Messages.zig b/src/Messages.zig index b7153e5c..ea62041f 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -181,10 +181,12 @@ fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void { pane.pushOlderMessage(); if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot(); pane.msg_announcement = false; + pane.msg_sent = false; pane.msg_status = !stack; const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving; for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c; pane.msg_len = @intCast(n); + pane.msg_said_ms = nowMs(); p.needs_frame = true; if (arrives) { pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown }; @@ -198,6 +200,35 @@ fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void { } } +fn nowMs() i64 { + if (comptime !pardes.hosted) return 0; + var ts: std.c.timespec = undefined; + if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); +} + +/// With no frontend attached no input dismisses a message, and a detached +/// session's /screen and messages kept what was said an hour ago: each +/// pane's lines go once its newest has been up `message_linger_ms`, on the +/// clock, checked as the core is stepped and by the detached session's loop +/// between steps -- not as a 9P request is served, since a read is answered +/// while a step is out and must change nothing. +/// ponytail: a pane's older lines go with its newest, not each on its own +/// clock; the render pipeline's clock will give every line its own. +pub fn expireUnattended(p: *Pardes) void { + if (!p.unattended) return; + const now = nowMs(); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.msg_len == 0 and pane.msg_older_len == 0) continue; + if (now - pane.msg_said_ms < p.settings.message_linger_ms) continue; + pane.msg_len = 0; + pane.msg_life = .{}; + pane.msg_older_len = 0; + p.needs_frame = true; + } +} + /// The key or click that used to clear the message row now starts each /// line's exit: it stays `message_linger_ms`, then dissolves. A line /// already on its way out keeps its own timing. @@ -410,23 +441,51 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage { return &m.log[(first + i) % limits.message_log]; } +/// `<operation>: <the error in words>`: FileNotFound reads "file not found". pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; - const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation; - reportFailure(p, id, text); + var w = std.Io.Writer.fixed(&buf); + w.print("{s}: ", .{operation}) catch {}; + for (@errorName(err), 0..) |c, i| { + if (std.ascii.isUpper(c) and i > 0) w.writeByte(' ') catch break; + w.writeByte(std.ascii.toLower(c)) catch break; + } + reportFailure(p, id, w.buffered()); +} + +/// The longest start of `text` that fits `max` bytes and ends between +/// words (or, with no space to cut at, between characters). +pub fn clip(text: []const u8, max: usize) []const u8 { + if (text.len <= max) return text; + if (std.mem.lastIndexOfScalar(u8, text[0 .. max + 1], ' ')) |sp| return text[0..sp]; + var n = max; + while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1; + return text[0..n]; } /// reportError with the words already chosen. pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { - // A builtin a ctl write runs: its first error is also the write's. + // A builtin a ctl write runs: its first error is also the write's, cut + // between words. if (p.fs.no_prompt and p.fs.failure_len == 0) { - const n = @min(text.len, p.fs.failure.len); - @memcpy(p.fs.failure[0..n], text[0..n]); + const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text; + @memcpy(p.fs.failure[0..kept.len], kept); + var n = kept.len; + if (kept.len < text.len) { + @memcpy(p.fs.failure[n..][0..3], "..."); + n += 3; + } p.fs.failure_len = @intCast(n); } setMessage(p, id, text); } +test "an error reads as words and a failure is cut between them" { + try std.testing.expectEqualStrings("go", clip("go haskell", 4)); + try std.testing.expectEqualStrings("go haskell", clip("go haskell", 10)); + try std.testing.expectEqualStrings("\u{e9}", clip("\u{e9}\u{e9}", 3)); +} + /// The one place that decides which transient lines a pane is showing and /// in what order. Everything downstream reads the answer: the body layer /// so it does not paint under them, and the paint pass so it can stack @@ -528,14 +587,14 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it // ...on the pane that ran it, and nowhere else try std.testing.expectEqualStrings("", row(p, 0)); - // Bare, it reports ITSELF through reportError — the `<operation>: <Error>` + // Bare, it reports ITSELF through reportError — the `<operation>: <error in words>` // every failed save and refused Look arrives in. try std.testing.expect(p.executeBuiltinLine(2, "Msg")); - try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); + try std.testing.expectEqualStrings("Msg: no message", row(p, 2)); // Input starts its exit: it lingers, dissolves, and is gone. p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); + try std.testing.expectEqualStrings("Msg: no message", row(p, 2)); for (0..200) |_| p.update(.tick); try std.testing.expectEqualStrings("", row(p, 2)); // ...and with neither linger nor animation it is as old as your last input. @@ -554,6 +613,31 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len); } +test "with no frontend attached a message goes once it has lingered, on the clock" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = p.panes[p.active].?; + p.setMessage(p.active, "said"); + p.setMessage(p.active, "said again"); + // Attached: it waits for input, however long. + pane.msg_said_ms -= 60_000; + p.update(.tick); + try std.testing.expect(pane.msg_len > 0); + // Detached with nobody attached: past its linger it is gone, and the + // line above it with it. + p.unattended = true; + // A read served meanwhile changes nothing; the session's loop expires. + _ = p.serveFs(.{ .tag = 1, .op = .read, .node = @intFromEnum(ctlfs.TopFile.index), .size = 64 }); + try std.testing.expect(pane.msg_len > 0); + expireUnattended(p); + try std.testing.expectEqual(@as(u16, 0), pane.msg_len); + try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len); + // A fresh one stays until its linger is up. + p.setMessage(p.active, "fresh"); + p.update(.tick); + try std.testing.expect(pane.msg_len > 0); +} + test "a message falls in, lingers past the input that dismisses it, and dissolves" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -733,6 +817,11 @@ test "a builtin announces itself on the message row, and Verbose silences it" { // for its announcement, which is correct and not what this is testing. try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap")); try std.testing.expectEqualStrings("Wrap", pane.msg[0..pane.msg_len]); + // A value it does not take is said, in place of the announcement. + const wrapped = p.settings.wrap; + try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap maybe")); + try std.testing.expectEqualStrings("Wrap: takes on or off", pane.msg[0..pane.msg_len]); + try std.testing.expectEqual(wrapped, p.settings.wrap); // The notice list is what the paint pass reads, so the announcement has to // reach it, not just the buffer. diff --git a/src/Output.zig b/src/Output.zig index 6e1765a7..201b5491 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -749,7 +749,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void { pane.body.select = false; pane.sel = @splat(.{}); pane.body.sticky_col = -1; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.normal.clear(); pane.look_at = null; if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) { diff --git a/src/Pipe.zig b/src/Pipe.zig index 1ad7b826..48e98b7b 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -35,6 +35,7 @@ pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { .ignore => config.pipe_marker_to, .insert => config.pipe_marker_insert, .append => config.pipe_marker_append, + .keep => config.pipe_marker_keep, }; } @@ -54,10 +55,10 @@ pub const PendingPipe = struct { cwd: []u8, inputs: []selection_pipe.Input, ranges: [panes.Text.max_selections]modal.Selection, - primary: u8, + primary: u16, explicit: bool, how: modal.Normal.PipeBehavior, - nranges: u8, + nranges: u16, pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { gpa.free(wait.command); @@ -117,7 +118,13 @@ pub fn submitPipe(p: *Pardes, id: usize) void { p.gpa.free(inputs); return; } - const command_copy = p.gpa.dupe(u8, command) catch { + // `$` wants a verdict per selection where the runner's is all or + // nothing: the command runs in a subshell whose exit status becomes the + // output, so the runner always succeeds and the answer says which kept + const command_copy = (if (how == .keep) + std.fmt.allocPrint(p.gpa, "(\n{s}\n) >/dev/null; echo $?", .{command}) + else + p.gpa.dupe(u8, command)) catch { for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; @@ -202,7 +209,7 @@ fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo const hi = @max(range.anchor, range.head); if (hi > content.len) return null; return switch (wait.how) { - .replace, .ignore => .{ .lo = lo, .hi = hi }, + .replace, .ignore, .keep => .{ .lo = lo, .hi = hi }, .insert => .{ .lo = lo, .hi = lo }, .append => .{ .lo = hi, .hi = hi }, }; @@ -260,6 +267,21 @@ pub fn pipeResponse( if (changed) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); if (wait.how == .ignore) return; + if (wait.how == .keep) { + // helix shell_keep_pipe: the primary stays if kept, else the last + // kept range takes over; keeping none changes nothing + var kept: [panes.Text.max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: ?usize = null; + for (0..wait.nranges) |i| { + if (!std.mem.eql(u8, std.mem.trim(u8, outputs[i], " \n"), "0")) continue; + if (i == wait.primary) pri = m; + kept[m] = wait.ranges[i]; + m += 1; + } + if (m == 0) return; + return t.setRanges(content, kept[0..m], &.{}, pri orelse m - 1, wait.explicit); + } const n = wait.nranges; var total_output: usize = 0; @@ -366,6 +388,43 @@ test "| in a tag pipes the tag's own text, and undo there brings it back" { try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " Save ")); } +test "$ keeps the selections the command exits 0 on, and a real shell gives that verdict" { + 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("aa bb cc\n"); + const ranges = [_]modal.Selection{ + .{ .anchor = 0, .head = 2 }, + .{ .anchor = 3, .head = 5 }, + .{ .anchor = 6, .head = 8 }, + }; + pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 1, true); + p.update(.{ .key = .{ .cp = '$' } }); + try std.testing.expectEqualStrings(config.pipe_marker_keep, pane.promptText().?); + p.update(.{ .key = .{ .cp = 'g', .text = "grep -v b" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest; + try std.testing.expectEqual(@as(usize, 3), request.inputs.len); + // the wrapper turns each run's status into its output, as the runner sees it + const job = try selection_pipe.Job.copy(gpa, request); + defer job.deinit(gpa); + var response = selection_pipe.runJob(gpa, std.testing.io, job); + defer response.deinit(gpa); + try std.testing.expect(response.success); + try std.testing.expectEqualStrings("0\n", response.outputs[0]); + try std.testing.expectEqualStrings("1\n", response.outputs[1]); + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = response.outputs } }); + try std.testing.expectEqualStrings("aa bb cc\n", pane.file.?.content); + var out: [panes.Text.max_selections]modal.Selection = undefined; + const got = pane.body.ranges(pane.file.?.content, 0, &out); + try std.testing.expectEqual(@as(usize, 2), got.n); + try std.testing.expectEqual(@as(usize, 6), out[1].anchor); + // the primary was dropped: the last kept range takes over + try std.testing.expectEqual(@as(usize, 1), got.pri); +} + test "selection pipe replaces all ranges atomically and undo restores them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -394,7 +453,7 @@ test "selection pipe replaces all ranges atomically and undo restores them" { p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 6), pane.body.sels[0].col); try std.testing.expect(!denied.has_induced_failure); diff --git a/src/Registers.zig b/src/Registers.zig new file mode 100644 index 00000000..d2a42434 --- /dev/null +++ b/src/Registers.zig @@ -0,0 +1,106 @@ +//! helix's registers: named stores of text, each holding one VALUE PER RANGE +//! of the selection that wrote it. `"` is the default, `+` the one the +//! system clipboard is copied from, `/` the last s/S pattern, `@` the last +//! macro; any other character names a register of its own. The registers +//! computed from the editor rather than stored (`_`, `#`, `.`, `%`) and the +//! clipboard's paste side are read in edit.zig, which has the pane at hand. +const std = @import("std"); + +const Registers = @This(); + +list: std.ArrayList(Reg) = .empty, + +pub const Reg = struct { + name: u21, + /// gpa-owned, one per range, in document order + values: [][]u8, + /// the values joined by newlines, for the readers that take one text + /// (the acme chords, a paste into a terminal, the clipboard); gpa-owned, + /// made on first read + joined: ?[]u8 = null, +}; + +pub const default = '"'; + +pub fn deinit(r: *Registers, gpa: std.mem.Allocator) void { + for (r.list.items) |*reg| freeReg(gpa, reg); + r.list.deinit(gpa); + r.* = .{}; +} + +fn freeReg(gpa: std.mem.Allocator, reg: *Reg) void { + for (reg.values) |v| gpa.free(v); + gpa.free(reg.values); + if (reg.joined) |j| gpa.free(j); +} + +pub fn get(r: *Registers, name: u21) ?*Reg { + for (r.list.items) |*reg| if (reg.name == name) return reg; + return null; +} + +/// Value `i` of register `name`: a register holding fewer values than there +/// are ranges repeats its last (helix paste_impl). +pub fn value(r: *Registers, name: u21, i: usize) ?[]const u8 { + const reg = r.get(name) orelse return null; + if (reg.values.len == 0) return null; + return reg.values[@min(i, reg.values.len - 1)]; +} + +/// Store value `i` of `n`. `fresh` starts the register over with `n` empty +/// values; the replay across ranges passes it on its first pass only, so +/// every range fills its own slot. +pub fn put(r: *Registers, gpa: std.mem.Allocator, name: u21, bytes: []const u8, i: usize, n: usize, fresh: bool) void { + const copy = gpa.dupe(u8, bytes) catch return; + const reg = r.get(name) orelse blk: { + r.list.append(gpa, .{ .name = name, .values = &.{} }) catch { + gpa.free(copy); + return; + }; + break :blk &r.list.items[r.list.items.len - 1]; + }; + if (fresh or reg.values.len != n) { + const values = gpa.alloc([]u8, n) catch { + gpa.free(copy); + return; + }; + for (values) |*v| v.* = &.{}; + freeReg(gpa, reg); + reg.* = .{ .name = name, .values = values }; + } + gpa.free(reg.values[i]); + reg.values[i] = copy; + if (reg.joined) |j| gpa.free(j); + reg.joined = null; +} + +/// The whole register as one text: its values joined by newlines, no +/// newline added after a value that already ends in one. +pub fn text(r: *Registers, gpa: std.mem.Allocator, name: u21) ?[]const u8 { + const reg = r.get(name) orelse return null; + if (reg.joined) |j| return j; + var out: std.ArrayList(u8) = .empty; + for (reg.values, 0..) |v, i| { + if (i > 0 and !std.mem.endsWith(u8, reg.values[i - 1], "\n")) out.append(gpa, '\n') catch return null; + out.appendSlice(gpa, v) catch return null; + } + reg.joined = out.toOwnedSlice(gpa) catch return null; + return reg.joined; +} + +test "a register holds a value per range and joins them for one-text readers" { + const gpa = std.testing.allocator; + var r: Registers = .{}; + defer r.deinit(gpa); + try std.testing.expect(r.value(default, 0) == null); + // a replay fills the last range first + r.put(gpa, default, "two", 1, 2, true); + r.put(gpa, default, "one\n", 0, 2, false); + try std.testing.expectEqualStrings("one\n", r.value(default, 0).?); + try std.testing.expectEqualStrings("two", r.value(default, 1).?); + try std.testing.expectEqualStrings("two", r.value(default, 5).?); + try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?); + r.put(gpa, 'a', "x", 0, 1, true); + try std.testing.expectEqualStrings("x", r.text(gpa, 'a').?); + try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?); +} diff --git a/src/Terminal.zig b/src/Terminal.zig index 62a77ca8..91c23305 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -718,7 +718,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { if (comptime !enabled) return; if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.body.append_at != null or pane.look_at != null or + pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so @@ -844,7 +844,7 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } - if (pane.body.append_at) |*at| { + if (pane.body.last_edit) |*at| { extra[nextra] = &at.row; nextra += 1; } @@ -933,6 +933,13 @@ test "raw terminal output needs no position tracking allocations" { try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); } +/// A full-screen program has the emulator on its alternate screen. +pub fn onAlternateScreen(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.screens.active_key == .alternate; +} + pub fn promptInputReady(pane: *const Pane) bool { if (comptime !enabled) return false; const state = pane.terminal orelse return false; @@ -1118,7 +1125,7 @@ pub fn enterTty(p: *Pardes, id: usize) void { // keeps its modal endpoint for the next return to editor mode. pane.body.cur_pinned = selected; pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); } @@ -1380,7 +1387,7 @@ pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { /// Consume a rewritten overlay, freeing the terminal edit text it replaces. pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + const overlay = if (pane.ovl) |*value| value else return pardes.edit.retireEditText(p, new); for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; diff --git a/src/Text.zig b/src/Text.zig index f6780935..ab23bf8e 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -11,6 +11,7 @@ const std = @import("std"); const panes = @import("panes.zig"); const modal = @import("modal.zig"); const config = @import("config.zig"); +const memory = @import("memory.zig"); const Pane = panes.Pane; const File = panes.File; const Terminal = panes.Terminal; @@ -36,7 +37,7 @@ pub const CharSel = struct { explicit: bool = false, }; -pub const max_selections = 64; +pub const max_selections = memory.limits.selections; pub const SelRange = struct { row: i32, @@ -59,13 +60,26 @@ cur_row: i32 = 0, cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, -sels: [max_selections - 1]SelRange = undefined, -nsel: u8 = 0, +/// The ranges beside the primary, `nsel` of them: room for max_selections - 1 +/// made when a second range appears and given back when the selection is one +/// range again, so a single cursor holds nothing here. +sels: []SelRange = &.{}, +nsel: u16 = 0, +/// What a column's or the workspace's tag allocates its ranges from; a text +/// with a pane uses the pane's. +gpa: ?std.mem.Allocator = null, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. sticky_col: i32 = -1, -append_at: ?struct { row: i32, col: i32 } = null, +/// The insert session began with `a`: on Esc every range gives back the +/// one character it was stretched by (helix restore_cursor). +restore_cursor: bool = false, +/// Where the last edit ended, a deletion's start or an insertion's end, in +/// surface rows: for `g.` (helix last_edit_pos). +last_edit: ?struct { row: i32, col: i32 } = null, +/// insert `Ctrl-r` was pressed: the next key names the register to insert +reg_wait: bool = false, normal: modal.Normal.State = .{}, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, @@ -93,10 +107,39 @@ pub fn pane(t: *Text) ?*Pane { /// Free what a text owns: its characters and the buffers its undo keeps. pub fn deinit(t: *Text, gpa: std.mem.Allocator) void { + if (t.sels.len > 0) gpa.free(t.sels); if (t.own) |own| gpa.free(own); for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); - t.* = .{ .what = t.what }; + t.* = .{ .what = t.what, .gpa = t.gpa }; +} + +fn allocator(t: *Text) ?std.mem.Allocator { + return if (t.pane()) |w| w.gpa else t.gpa; +} + +/// Room for the ranges beside the primary; false when there is none to be had. +pub fn reserveSels(t: *Text) bool { + if (t.sels.len > 0) return true; + const gpa = t.allocator() orelse return false; + t.sels = gpa.alloc(SelRange, max_selections - 1) catch return false; + return true; +} + +/// The ranges beside the primary as they were (a refused edit's undoing). +pub fn restoreSels(t: *Text, kept: []const SelRange) void { + if (kept.len == 0) return t.dropSels(); + if (!t.reserveSels()) return; + @memcpy(t.sels[0..kept.len], kept); + t.nsel = @intCast(kept.len); +} + +/// Back to one range: the room for the others goes. +pub fn dropSels(t: *Text) void { + t.nsel = 0; + if (t.sels.len == 0) return; + if (t.allocator()) |gpa| gpa.free(t.sels); + t.sels = &.{}; } /// Bring the cursor into view (acme's textshow). Only a body scrolls to it; @@ -178,7 +221,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl .explicit = explicit or t.select, }; t.msel.active = false; - t.nsel = 0; // writing ONE range means the selection IS that range + t.dropSels(); // writing ONE range means the selection IS that range t.cur_pinned = true; t.sticky_col = -1; t.normal.clear(); @@ -255,6 +298,7 @@ pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, sticky } n = k + 1; setRange(t, text, 0, r[pri], explicit); + if (n > 1 and !t.reserveSels()) return; // no room: the primary alone t.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { @@ -324,6 +368,15 @@ pub fn multiSelAction(t: *Text, text: []const u8, kind: modal.Normal.Multi, cnt: } return setRanges(t, text, out[0..m], &.{}, pri, expl); } + if (kind == .ensure_forward) { + // helix ensure_selections_forward: every range points forward + for (rs[0..n]) |*r| { + const from = @min(r.anchor, r.head); + r.head = @max(r.anchor, r.head); + r.anchor = from; + } + return setRanges(t, text, rs[0..n], &.{}, got.pri, expl); + } if (kind == .split_newline) { // helix selection::split_on_newline — one range per line the // selection covers, the newlines themselves left out diff --git a/src/body_layer.zig b/src/body_layer.zig index e9407719..a0195fb8 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -408,9 +408,32 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane } } + // `gw`'s labels, two letters over the start of each word + if (pane.jump.items.len > 0) labels: { + const flat = edit.flatSurface(p, &pane.body) catch break :labels; + const letters = config.jump_label_alphabet; + const style: CellStyle = .{ .fg = .{ .rgb = th.sel_fg }, .bg = .{ .rgb = th.sel_bg }, .bold = true }; + for (pane.jump.items, 0..) |word, i| { + if (pane.jump_first) |first| if (i / letters.len != first) continue; + var at = @min(word.anchor, word.head); + for ([_]u8{ letters[i / letters.len], letters[i % letters.len] }) |letter| { + const pos = modal.positionAt(flat, at); + const row: i32 = @intCast(pos.row); + const wp = pane.wrapRow(row, @intCast(pos.col)); + const cx = if (pane.file != null) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, wp.at, @intCast(pos.col)) + else + panes.File.lineDisplayOffset(modal.lineSlice(flat, pos.row), @intCast(@max(0, wp.at)), pos.col); + if (wp.row >= 0 and wp.row < body_h and cx >= 0 and cx < tw) + s.set(tx + @as(u16, @intCast(cx)), body_y + @as(u16, @intCast(wp.row)), &.{letter}, style); + at = modal.nextGrapheme(flat, at); + } + } + } + // cursor: tracks the shell cursor until pinned by a click or a key // (the tag's or a prompt's cursor wins while that is being typed into) - if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side)) { + if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { if (pane.body.mode != .tty) { const cur = panes.Terminal.gridCursor(pane); const goff = panes.Terminal.gridOffset(pane); diff --git a/src/builtins.zig b/src/builtins.zig index 1864086f..23d853c8 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -44,6 +44,10 @@ pub const Ctx = struct { id: usize, txt: []const u8, arg: ?[]const u8, + /// Made by a gesture on the pane's body -- a middle click, the execute + /// key -- as acme's execute() asks of its Text (exec.c:122,169): an + /// Exec from a body may be a bound REPL's (exec.zig routeToRepl). + from_body: bool = false, }; fn isEnabled(comptime T: type) bool { @@ -145,6 +149,16 @@ pub const registry = struct { return true; } + /// Whether the word takes its argument joined by a `+` as one word a + /// tag can hold and a click take whole (`Tty+fish`). Only a builtin + /// that says `pub const plus_arg = true;`: others have arguments a `+` + /// may be in (`Dump+2024.zon` is a name, not Dump's argument). + pub fn plusArg(b: Builtin()) bool { + inline for (manualBuiltinList(), 0..) |T, i| + if (@intFromEnum(b) == i) return @hasDecl(T, "plus_arg") and T.plus_arg; + return false; + } + /// Whether the word means nothing without its argument (`Mount`, /// `Msg`, a setting's value), or would ask for it at a prompt (`Find`): /// a ctl refuses it bare before any line of the write runs. A builtin @@ -226,16 +240,57 @@ test "Exit asks once about unsaved text, and quits when asked again" { try std.testing.expect(p.quit); } +/// A dump file that exists, for a Restore to get past reading it. +fn testDump(buf: []u8) ![]const u8 { + var tmp = std.testing.tmpDir(.{}); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = ".{}" }); + var dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(std.testing.io, &dir)]; + return std.fmt.bufPrint(buf, "Restore {s}/d.zon", .{at}); +} + test "Restore asks about unsaved text as Exit does, and restores when asked again" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified - try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /tmp/some.dump.zon")); + var line_buf: [4200]u8 = undefined; + const line = try testDump(&line_buf); + try std.testing.expect(p.executeBuiltinLine(p.active, line)); try std.testing.expect(p.restore_req == null); try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Restore again to discard)")); - try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /tmp/some.dump.zon")); - try std.testing.expectEqualStrings("/tmp/some.dump.zon", p.restore_req.?); + try std.testing.expect(p.executeBuiltinLine(p.active, line)); + try std.testing.expectEqualStrings(line["Restore ".len..], p.restore_req.?); +} + +test "a Restore of a dump that cannot be read says so, before the unsaved-text warning" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("saved\n"); + pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified + p.fs.no_prompt = true; + defer p.fs.no_prompt = false; + try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /nope/missing.zon")); + try std.testing.expect(p.restore_req == null); + try std.testing.expectEqualStrings("Restore: /nope/missing.zon: no such file", p.fs.failure[0..p.fs.failure_len]); + // No warning spent: the Exit after it still asks. + try std.testing.expect(pane.discard_warned == null); +} + +test "a Restore's warning is not an Exit's: each word is warned on its own" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("saved\n"); + pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified + var line_buf: [4200]u8 = undefined; + try std.testing.expect(p.executeBuiltinLine(p.active, try testDump(&line_buf))); + try std.testing.expect(p.restore_req == null); + // The Restore warned; an Exit after it has not been, and asks. + try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); + try std.testing.expect(!p.quit); + try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)")); + try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); + try std.testing.expect(p.quit); } test "Kill stops the commands pardes typed, bare all and by name those that match, and never quits" { @@ -264,8 +319,9 @@ test "Kill stops the commands pardes typed, bare all and by name those that matc } }; p.host = .{ .vtable = &killed.vtable }; - // Nothing running: nothing to stop, and never the shell. + // Nothing running: nothing to stop, and never the shell; said. try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); + try std.testing.expectEqualStrings("Kill: nothing running", p.panes[term].?.msg[0..p.panes[term].?.msg_len]); _ = exec.execute(p, term, "make -j8"); while (p.nextEffect()) |_| {} // Typed but not started yet (no C mark): not running. @@ -346,7 +402,7 @@ pub const Exec = struct { pub fn run(c: Ctx) void { // the destination pane is Look's business (it focuses what answered); // an execute deliberately leaves you where you were - _ = exec.execute(c.p, c.id, c.arg orelse return); + _ = exec.executeFrom(c.p, c.id, c.arg orelse return, c.from_body); } }; @@ -356,21 +412,22 @@ pub const Exec = struct { /// what would throw unsaved text away: each pane holding some nobody has /// been told about says so, `<name>: Modified (Exit again to discard)`, /// naming the word asking, since repeating it throws the text away, and is -/// then clean for the -/// asking, as winclean clears `dirty`; so the same word again, with -/// nothing edited since, goes ahead. A scratch of under 100 bytes is no -/// loss worth the question, as winclean does not ask for one. -fn warnModified(c: Ctx, asking: []const u8) bool { +/// then clean for the asking, as winclean clears `dirty`; so the same word +/// again, with nothing edited since, goes ahead. Only the same word: a +/// Restore a script was refused does not spend the warning an Exit owes +/// the person at the keyboard. A scratch of under 100 bytes is no loss +/// worth the question, as winclean does not ask for one. +fn warnModified(c: Ctx, asking: Pane.Discarding) bool { var modified = false; for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; const f = pane.file orelse continue; if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) continue; if (f.output != null and f.content.len < 100) continue; - if (pane.discard_warned == f.revision) continue; - pane.discard_warned = f.revision; + if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue; + pane.discard_warned = .{ .revision = f.revision, .by = asking }; var buf: [limits.host_path_cap + 48]u8 = undefined; - c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, asking }) catch f.path); + c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, @tagName(asking) }) catch f.path); modified = true; } return modified; @@ -381,7 +438,7 @@ fn warnModified(c: Ctx, asking: []const u8) bool { pub const Exit = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { - if (warnModified(c, "Exit")) return; + if (warnModified(c, .Exit)) return; c.p.quit = true; c.p.emit(.quit); } @@ -403,6 +460,21 @@ pub const Kill = struct { var jobless = false; for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; + // A command pane's child is the command: the whole of its + // process group goes, as acme's Kill ends the process. + if (pane.command) |line| if (!pane.command_done) { + const said = std.mem.trim(u8, line, " \t"); + const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len]; + if (names.len > 0) { + var words = std.mem.tokenizeAny(u8, names, " \t"); + while (words.next()) |w| { + if (std.mem.eql(u8, w, first)) break; + } else continue; + } + c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .serial = pane.serial, .sig = .term } }); + stopped = true; + continue; + }; if (!panes.Terminal.commandRunning(pane)) continue; const sent = pane.sent_command.?; if (names.len > 0) { @@ -417,7 +489,10 @@ pub const Kill = struct { } if (stopped) return; if (jobless) return c.p.reportFailure(c.id, "Kill: no job to signal (its shell runs it without job control)"); - if (names.len > 0) c.p.reportError(c.id, "Kill", error.NoSuchCommand); + if (names.len > 0) + c.p.reportFailure(c.id, "Kill: no running command has that first word") + else // said, but no failure: a script's Kill has nothing left to do + c.p.setMessage(c.id, "Kill: nothing running"); } }; @@ -432,11 +507,27 @@ pub const Restore = struct { pub const scope: Scope = .session; pub const takes_arg = true; pub fn run(c: Ctx) void { - const path = c.arg orelse (c.p.last_dump orelse return); - if (path.len > c.p.restore_buf.len) return; + const path = c.arg orelse (c.p.last_dump orelse return c.p.reportFailure(c.id, "Restore: no dump yet; name one")); + if (path.len > c.p.restore_buf.len) return c.p.reportFailure(c.id, "Restore: that name is too long"); + // A dump that cannot be read is said now, and fails a ctl write: the + // host reads it only after this step, where nobody hears. First, + // so a Restore that could never happen spends no warning. + if (comptime pardes.hosted) { + const bytes = @import("fs.zig").readRestore(c.p.gpa, path, c.p.settings.dump_dir.get()) catch |err| { + var buf: [limits.host_path_cap + 64]u8 = undefined; + const why = switch (err) { + error.FileNotFound => "no such file", + error.PermissionDenied => "permission denied", + error.IsDirectory => "a directory, not a dump", + else => @errorName(err), + }; + return c.p.reportFailure(c.id, std.fmt.bufPrint(&buf, "Restore: {s}: {s}", .{ path, why }) catch "Restore: cannot read the dump"); + }; + c.p.gpa.free(bytes); + } // acme's Load adds a dump's windows to the ones there; a Restore // replaces them all, so it asks what Exit asks first. - if (warnModified(c, "Restore")) return; + if (warnModified(c, .Restore)) return; @memcpy(c.p.restore_buf[0..path.len], path); c.p.restore_req = c.p.restore_buf[0..path.len]; } @@ -703,7 +794,8 @@ pub const Newcol = struct { }; /// Close the pane. `Del k` gives its rows to the open pane above it and -/// `Del j` to the one below, each falling back to the other side. +/// `Del j` to the one below, each falling back to the other side. Focus on +/// it goes with its rows. pub const Del = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { @@ -716,7 +808,7 @@ pub const Del = struct { else if (std.mem.eql(u8, side, "j")) below orelse above else if (side.len > 0) - return c.p.reportError(c.id, "Del k|j", error.InvalidArgument) + return c.p.reportFailure(c.id, "Del: takes k, j or nothing") else null; // Someone at the keyboard with a real choice to make is asked, on the @@ -734,8 +826,6 @@ pub const Del = struct { return; } c.p.removePane(c.id, to) catch |err| return c.p.reportError(c.id, "close", err); - // Del k|j is a direction: focus lands where the rows went. - if (to) |gainer| c.p.active = gainer; } }; @@ -787,9 +877,24 @@ pub const DelBelow = struct { /// A shell in the calling pane's directory, raw from the first frame. On every /// pane's tagline: the fast path from wherever you are to a prompt there. +/// A terminal below the caller in its directory: the configured shell, or +/// the one named, `Tty fish`, as a terminal's own tag word `Tty+fish` says. pub const Tty = struct { + pub const takes_arg = true; + pub const plus_arg = true; pub fn run(c: Ctx) void { - exec.spawnTty(c.p, c.id); + const arg = std.mem.trim(u8, c.arg orelse "", " \t"); + // A shell that is not there is said, where the host would quietly + // start its fallback in its place. + if (comptime pardes.hosted) if (arg.len > 0) { + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (@import("host_io.zig").Shell.find(arg, &buf) == null) { + var said: [320]u8 = undefined; + return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: no shell \"{s}\" (a name on the usual paths, or a path to one)", .{arg[0..@min(arg.len, 200)]}) catch "Tty: no such shell"); + } + }; + const pane = exec.spawnTty(c.p, c.id) orelse return; + if (arg.len > 0) pane.shell = c.p.gpa.dupe(u8, arg) catch null; } }; @@ -802,10 +907,72 @@ pub const Tty9p = struct { }; /// Fold the active pane's column into the one on its right, keeping its panes. +/// Binds this terminal as a language's REPL, by the language's name as the +/// syntax table has it: an exec made on the body of a file in that +/// language -- a middle click, the execute key, a single word like `ls` +/// as much as a selection -- and one made on this terminal's own body, is +/// typed into it (bracketed paste if its program asked for that, else line +/// by line, then Enter) instead of run. `Repl python` binds (again, it is +/// already); `Repl -` unbinds; `Repl` bare says what it is bound as. With +/// several bound for a language, the exec asks which. +/// Still commands, whatever is bound: a word in a tag, `Exec <text>` run by +/// name (typed, a 2-1 chord onto Exec, a ctl line), and a command word +/// @`cmd` in the text, looked at or clicked. +pub const Repl = struct { + pub const takes_arg = true; + pub fn run(c: Ctx) void { + const pane = c.pane; + // A terminal with its emulator -- not a document pane -- and not + // a command pane whose command, the REPL it would be, is done. + if (!pane.isTerminal() or pane.terminal == null) return c.p.reportFailure(c.id, "Repl: only a terminal is a REPL"); + if (pane.command != null and pane.command_done) return c.p.reportFailure(c.id, "Repl: its command is done; a REPL is a running program"); + const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + var buf: [320]u8 = undefined; + var idbuf: [48]u8 = undefined; + if (arg.len == 0) { + if (pane.repl == null) return c.p.setMessage(c.id, "Repl: not bound"); + return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); + } + if (std.mem.eql(u8, arg, "-")) { + pane.repl = null; + return c.p.setMessage(c.id, "Repl: unbound"); + } + const manifest = @import("grammar_manifest.zig"); + const grammars = manifest.all; + // Named as a code fence names it: `py`, `Python`, `sh`. + const named: ?u8 = if (manifest.byName(arg)) |i| if (grammars[i].exts.len > 0) @intCast(i) else null else null; + const lang: u8 = named orelse { + var w = std.Io.Writer.fixed(&buf); + w.print("Repl: no language \"{s}\"; - or one of:", .{@import("Messages.zig").clip(arg, 24)}) catch {}; + for (grammars) |g| if (g.exts.len > 0) w.print(" {s}", .{g.name}) catch break; + return c.p.reportFailure(c.id, @import("Messages.zig").clip(w.buffered(), 256)); + }; + if (pane.repl) |r| if (r.lang == lang) + return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); + // The lowest letter no other REPL of the language has: freed ones + // come back. + var letter: u8 = 'a'; + while (letter <= 'z') : (letter += 1) { + const taken = for (c.p.panes) |slot| { + const other = slot orelse continue; + if (other != pane) if (other.repl) |r| if (r.lang == lang and r.letter == letter) break true; + } else false; + if (!taken) break; + } + if (letter > 'z') return c.p.reportFailure(c.id, "Repl: every letter of the language is taken"); + pane.repl = .{ .lang = lang, .letter = letter }; + pane.repl_warned = false; + c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); + } +}; + /// The horizontal mirror of the vertical stacking `New` does. pub const Joincol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { + // The keyboard's column joins the one to its right; the last has none. + const at = layout.findPane(c.p, c.p.active) orelse return; + if (at.col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right"); layout.joinCol(c.p); } }; diff --git a/src/colors.zig b/src/colors.zig index 0065d5ef..aeff7b00 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -317,6 +317,19 @@ fn invalidateThemeDependentRasters(p: *Pardes) void { }; } +/// Themes whose names share `name`'s first letter, a line of them, for a +/// Theme given a name there is none of: all of them (ThemeSel) are too many +/// for a message or a refused write. +pub fn themesNear(buf: []u8, name: []const u8) []const u8 { + var w = std.Io.Writer.fixed(buf); + for (themes) |t| { + if (name.len > 0 and std.ascii.toLower(t.name[0]) != std.ascii.toLower(name[0])) continue; + if (w.buffered().len + t.name.len + 1 > buf.len) break; + w.print("{s}{s}", .{ if (w.buffered().len > 0) " " else "", t.name }) catch break; + } + return w.buffered(); +} + pub fn setThemeIndex(p: *Pardes, index: usize) void { if (index >= themes.len or (p.custom_theme == null and p.theme_file_path.get().len == 0 and diff --git a/src/config.zig b/src/config.zig index d1e5f23d..186b29a1 100644 --- a/src/config.zig +++ b/src/config.zig @@ -49,6 +49,7 @@ pub const leader_path = paths: { .Mode = null, .Togglettymode = null, .Collapse = null, + .Repl = null, .Kill = null, .Exit = null, .ClipYank = "y", @@ -249,6 +250,7 @@ pub const pane_builtins_str = "Tty Collapse Del"; 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"; // Each mode badge is one codepoint. pub const tag_normal = " "; @@ -265,10 +267,15 @@ pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; +pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; +pub const macro_record: []const Chord = &.{.{ .cp = 'Q' }}; +pub const macro_replay: []const Chord = &.{.{ .cp = 'q' }}; +pub const repeat_insert: []const Chord = &.{.{ .cp = '.' }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; +pub const pipe_marker_keep = " $"; pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; // Del from the keyboard on a pane with open panes both above and below it @@ -408,6 +415,8 @@ pub const symbol_marker = " WsSymbols /"; pub const save_marker = " Save "; pub const select_marker = " Select /"; pub const split_marker = " Split /"; +pub const keep_marker = " Keep /"; +pub const remove_marker = " Remove /"; pub const pipe_marker = " |"; pub const search_buffer = "+Search"; @@ -432,6 +441,7 @@ pub const errors_buffer = "+Errors"; // Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; +pub const view_prefix_sticky = 'Z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; @@ -453,7 +463,7 @@ pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }}; pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }}; pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; -pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; +pub const line_end: []const Chord = &.{.{ .cp = Key.end }}; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; // Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; @@ -465,6 +475,7 @@ pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Ke pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const view_middle: []const Chord = &.{.{ .cp = 'm' }}; pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; @@ -479,6 +490,10 @@ pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; +pub const goto_word: []const Chord = &.{.{ .cp = 'w' }}; +/// the jump labels' letters, helix's jump_label_alphabet +pub const jump_label_alphabet = "abcdefghijklmnopqrstuvwxyz"; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; @@ -491,11 +506,19 @@ pub const match_around = 'a'; pub const surround_add = 's'; pub const surround_replace = 'r'; pub const surround_delete = 'd'; +pub const register_prefix = '"'; pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; +pub const goto_function: []const Chord = &.{.{ .cp = 'f' }}; +pub const goto_class: []const Chord = &.{.{ .cp = 't' }}; +pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }}; +pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }}; +pub const goto_test: []const Chord = &.{.{ .cp = 'T' }}; +pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }}; +pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }}; pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; @@ -525,6 +548,19 @@ pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; +pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }}; +pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }}; +pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }}; +// helix's tree-sitter selection; Alt-n stays the new terminal, so the next +// sibling is Alt-right alone +pub const expand_node: []const Chord = &.{ .{ .cp = 'o', .alt = true }, .{ .cp = Key.up, .alt = true } }; +pub const shrink_node: []const Chord = &.{ .{ .cp = 'i', .alt = true }, .{ .cp = Key.down, .alt = true } }; +pub const prev_sibling: []const Chord = &.{ .{ .cp = 'p', .alt = true }, .{ .cp = Key.left, .alt = true } }; +pub const next_sibling: []const Chord = &.{.{ .cp = Key.right, .alt = true }}; +pub const all_siblings: []const Chord = &.{.{ .cp = 'a', .alt = true }}; +pub const all_children: []const Chord = &.{.{ .cp = 'I', .alt = true }}; +pub const parent_node_end: []const Chord = &.{.{ .cp = 'e', .alt = true }}; +pub const parent_node_start: []const Chord = &.{.{ .cp = 'b', .alt = true }}; pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; @@ -537,13 +573,19 @@ pub const switch_case: []const Chord = &.{.{ .cp = '~' }}; pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }}; pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; +pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; +pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }}; +pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }}; +pub const align_selections: []const Chord = &.{.{ .cp = '&' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; -pub const undo: []const Chord = &.{.{ .cp = 'u' }}; -pub const redo: []const Chord = &.{.{ .cp = 'U' }}; +// pardes's history is linear, so helix's earlier/later (Alt-u, Alt-U) are +// undo and redo themselves +pub const undo: []const Chord = &.{ .{ .cp = 'u' }, .{ .cp = 'u', .alt = true } }; +pub const redo: []const Chord = &.{ .{ .cp = 'U' }, .{ .cp = 'U', .alt = true } }; pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; // Body-normal Esc runs Last; other modes cancel input or leave insert mode. @@ -552,6 +594,8 @@ pub const escape: []const Chord = &.{.{ .cp = Key.escape }}; pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }}; pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; +pub const insert_register: []const Chord = &.{.{ .cp = 'r', .ctrl = true }}; +pub const insert_checkpoint: []const Chord = &.{.{ .cp = 's', .ctrl = true }}; pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; diff --git a/src/detached/client.zig b/src/detached/client.zig index 9fa85fb4..8c9a2560 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -843,6 +843,7 @@ test "detached Restore keeps attached frontends and follows queued frames with a const fd = h.session.clients[c.slot].fd; try testing.expectError(error.BadDumpMagic, h.session.restore( ".{ .magic = \"not-a-pardes-dump\", .theme = \"dark\", .screen = .{ .cols = 60, .rows = 16 } }", + "test", )); try testing.expectEqual(before, h.session.core); try testing.expectEqual(fd, h.session.clients[c.slot].fd); @@ -853,7 +854,7 @@ test "detached Restore keeps attached frontends and follows queued frames with a defer testing.allocator.free(saved); while (h.core.nextEffect()) |_| {} _ = try h.core.setTestFile("changed after dump\n"); - try h.session.restore(saved); + try h.session.restore(saved, "test"); h.core = h.session.core; try testing.expect(h.core != before); try testing.expectEqual(fd, h.session.clients[c.slot].fd); diff --git a/src/detached/server.zig b/src/detached/server.zig index 9e18e125..5374bf73 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -70,8 +70,18 @@ const Pty = struct { pid: posix.pid_t = 0, kill_at: i64 = 0, out: std.ArrayListUnmanaged(u8) = .empty, + /// A command pane's child, watched to its exit (host_io.watchExit); + /// the pty stays open, unpolled after its end of file, until both its + /// end of file and its exit. + cmd: host_io.CommandWatch = .{}, }; +/// A watched child exited: wake the loop, which takes it (`takeExits`). +fn wakeForExit(ctx: ?*anyopaque) void { + const box: *Mailbox = @ptrCast(@alignCast(ctx.?)); + box.signal(); +} + const RetiredShell = struct { pid: posix.pid_t = 0, kill_at: i64 = 0 }; const Completion = union(enum) { @@ -190,6 +200,7 @@ pub const Session = struct { } s.mailbox.wake = pair; pardes.lsp.setStatusSink(s, lspStatus); + host_io.setExitWake(&s.mailbox, wakeForExit); } fn cancelWorkers(s: *Session) void { @@ -235,8 +246,8 @@ pub const Session = struct { return batch.len != 0 or batch.status != null; } - pub fn restore(s: *Session, bytes: []const u8) !void { - const replacement = try dump.restore(s.core, bytes); + pub fn restore(s: *Session, bytes: []const u8, from: []const u8) !void { + const replacement = try dump.restore(s.core, bytes, from); s.cancelWorkers(); for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); s.harvest(); @@ -257,6 +268,7 @@ pub const Session = struct { pub fn deinit(s: *Session) void { if (s.mailbox.wake[0] >= 0) pardes.lsp.setStatusSink(null, null); + if (s.mailbox.wake[0] >= 0) host_io.setExitWake(null, null); s.cancelWorkers(); for (s.mailbox.wake) |fd| if (fd >= 0) { _ = libc.close(fd); @@ -479,7 +491,8 @@ pub const Session = struct { s.core.screen_w, s.ninep, ) catch |err| return s.core.reportError(pane, "shell", err); - s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid }; + const command = if (s.core.panes[pane]) |pn| pn.command != null else false; + s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } }; setNonblock(child.file.handle); var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); @@ -518,7 +531,8 @@ pub const Session = struct { const s = of(ctx); if (pane >= s.ptys.len) return; const pt = s.ptys[pane]; - if (pt.fd < 0) return; + // A command whose exit is recorded is reaped: its group may be another's. + if (pt.fd < 0 or pt.cmd.exited) return; host_io.signalTty(pt.pid, pt.fd, sig); } @@ -687,11 +701,33 @@ pub const Session = struct { } fn paneEof(s: *Session, pane: u8) void { + const pt = &s.ptys[pane]; + // A command's pty stays open until its child has exited: closing + // it would hang up one that runs on without it. + // Its exit, if it came first, is told now its output is in. + if (pt.cmd.watched) return host_io.commandEof(s.core, &s.ptys, pane, s, closeWatched); + // Unwatched, a command's exit is read here, as its end. + const unwatched = if (s.core.panes[pane]) |pn| pn.command != null else false; + var status: ?u8 = null; + if (unwatched) { + status = host_io.exitStatus(pt.pid, 100); + if (status != null) pt.pid = 0; // reaped: no shell to retire + } s.closePty(pane); s.harvest(); + if (unwatched) s.core.update(.{ .exited = .{ .pane = pane, .status = status } }); s.core.update(.{ .eof = .{ .pane = pane } }); } + /// The command panes' exits, told once their output is in (host_io). + fn takeExits(s: *Session) void { + host_io.takeExits(s.core, &s.ptys, s, closeWatched); + } + + fn closeWatched(s: *Session, id: usize) void { + s.closePty(@intCast(id)); + } + fn harvest(s: *Session) void { const now = monotonicMs(); for (&s.retired_shells) |*shell| reapShell(&shell.pid, &shell.kill_at, now); @@ -780,6 +816,10 @@ pub const Session = struct { const completed = s.ninep_wake.swap(false, .acq_rel) or drained; for (&s.clients) |*c| if (c.fd >= 0) s.flush(c); const regridded = s.reconcile(); + // Nobody attached, nobody to dismiss a message: the loop expires + // them itself, between the editor's steps (expireUnattended). + s.core.unattended = s.primary() == null; + pardes.Messages.expireUnattended(s.core); const now = monotonicMs(); var fds: [poll_slots]libc.pollfd = undefined; @@ -807,7 +847,7 @@ pub const Session = struct { n += 1; } for (&s.ptys, 0..) |*pt, pane| { - if (pt.fd < 0) continue; + if (pt.fd < 0 or pt.cmd.eof) continue; fds[n] = .{ .fd = pt.fd, .events = if (pt.out.items.len != 0) poll_in | poll_out else poll_in, @@ -854,6 +894,7 @@ pub const Session = struct { virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*); }; _ = s.drainCompletions(true); + s.takeExits(); s.expire(monotonicMs()); s.harvest(); s.reloadWatched(); @@ -914,6 +955,11 @@ pub const Session = struct { due = if (due) |d| @min(d, at9) else at9; } } + // Unattended messages go on the clock: look again in a while. + if (s.core.unattended) for (s.core.panes) |slot| if (slot) |pane| if (pane.msg_len > 0) { + due = if (due) |d| @min(d, now + 100) else now + 100; + break; + }; const at = due orelse return null; return @intCast(@max(0, @min(at - now, std.math.maxInt(c_int)))); } @@ -1135,6 +1181,17 @@ pub const Session = struct { } }; +/// SIGTERM, SIGINT or SIGHUP: quit as Exit does, through the loop, so its +/// sockets are unlinked on the way out rather than left for the next +/// session to trip on. The handler only sets the flag and wakes the loop. +var quit_signalled = std.atomic.Value(bool).init(false); +var quit_wake: c_int = -1; + +fn onQuitSignal(_: std.posix.SIG) callconv(.c) void { + quit_signalled.store(true, .release); + if (quit_wake >= 0) _ = libc.send(quit_wake, "q", 1, nosignal); +} + pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void { const gpa = init.gpa; const allocs = pardes.memory.init(gpa); @@ -1186,7 +1243,11 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void core.host = h; while (core.nextEffect()) |effect| core.perform(effect); session.in_loop = true; - while (!session.core.quit) { + quit_wake = session.mailbox.wake[1]; + const on_quit: std.posix.Sigaction = .{ .handler = .{ .handler = onQuitSignal }, .mask = std.posix.sigemptyset(), .flags = 0 }; + for ([_]std.posix.SIG{ .HUP, .INT, .TERM }) |sig| std.posix.sigaction(sig, &on_quit, null); + defer quit_wake = -1; + while (!session.core.quit and !quit_signalled.load(.acquire)) { pardes.turn.restoreSettled(); try session.core.pump(h); if (session.core.quit) break; @@ -1196,7 +1257,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void break :restore; }; defer gpa.free(bytes); - session.restore(bytes) catch |err| session.core.reportError(session.core.active, "Restore", err); + session.restore(bytes, path) catch |err| session.core.reportError(session.core.active, "Restore", err); } } } @@ -1236,7 +1297,7 @@ test "detached queued results preserve current requests and are discarded before try std.testing.expect(s.pipe_tasks.add(.{ .id = 77, .future = .{ .any_future = null, .result = {} } })); s.mailbox.post(.{ .pipe = .{ .id = 77, .success = true, .outputs = outputs } }); Session.lspStatus(&s, "old status"); - try s.restore(saved); + try s.restore(saved, "test"); try std.testing.expect(s.lsp_task == null); try std.testing.expectEqual(@as(usize, 0), s.pipe_tasks.len); try std.testing.expect(!s.drainCompletions(true)); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 32cd5229..b1b81eda 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -884,7 +884,7 @@ fn clientTag(msg: ClientMsg) ClientTag { return switch (msg) { .event => |ev| switch (ev) { // Machine-local reports and 9P requests belong to the session owner. - .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, + .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, inline else => |_, t| @field(ClientTag, @tagName(t)), }, inline else => |_, t| @field(ClientTag, @tagName(t)), @@ -1011,7 +1011,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { .touch_scroll => |v| try w.putF32(v), .pointer_leave => {}, // See `clientTag`: no tag, so nothing to encode. - .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, + .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, }, } try finishMessage(&w, at); @@ -1141,7 +1141,7 @@ pub fn clientBound(msg: ClientMsg) usize { .paste => |b| b.len, .command => |line| line.len, // See `clientTag`: not on this wire in this direction. - .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, + .output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable, }, }; } diff --git a/src/draw.zig b/src/draw.zig index 0dbf5bae..2d757635 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -598,9 +598,10 @@ fn joinCells(s: *Surface, x: u16, y: u16, cells: []const Cell, from: usize, w: u fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { if (pane.isTerminal()) { - var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); + // `tag` is the first row; a prefix longer than it ran to a second. + var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n"); while (words.next()) |word| { - if (!std.mem.eql(u8, word, "Tty")) continue; + if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(u8, word, "Tty+")) continue; const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); return .{ .lo = lo, .hi = lo + word.len }; } @@ -611,7 +612,7 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { if (pane.prompt == .name) pane.input.own orelse file.path else file.path else if (pane.image) |state| blk: { // Image tags put renderer controls before the path. - start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null; + start = std.mem.lastIndexOf(u8, tag[0..@min(prefix_len, tag.len)], state.path) orelse return null; break :blk state.path; } else if (comptime pdf_enabled) blk: { break :blk if (pane.pdf) |state| state.path else return null; @@ -772,13 +773,16 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; const page = th.bg orelse chrome.tag_bg; const bg = Messages.blendRgb(chrome.tag_bg, page, motion.alpha); - const fg = Messages.blendRgb(chrome.tag_fg, page, motion.alpha); + // Where an exec was sent to a REPL reads in the tag's name tint, set + // apart from what the editor says. + const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg; + const fg = Messages.blendRgb(ink, page, motion.alpha); // Nothing to say, or too narrow for a layer: a blank chip on the grid. if (text.len == 0 or r.w <= TAG_TEXT_INSET) { s.fill(region.rect.x, region.rect.y, chip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); if (text.len == 0) return; const printed = Messages.printRight(s, region.rect.x, region.rect.y, chip -| 1, text, .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (kind != .prompt or id != p.active or pane.prompt == .del_side) return; + if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) return; const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); if (prompt_col < printed.dropped) return; s.cursor = .{ .x = @intCast(printed.left + (prompt_col - printed.dropped)), .y = region.rect.y, .bar = pane.input.mode == .insert }; @@ -807,7 +811,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) layer.slide = motion.slide; layer.fade = motion.alpha; // The caret rides the band, where the prompt's glyphs are. - if (kind == .prompt and id == p.active and pane.prompt != .del_side) caret: { + if (kind == .prompt and id == p.active and pane.prompt != .del_side and pane.prompt != .repl_choice) caret: { const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) break :caret; diff --git a/src/dump.zig b/src/dump.zig index 508e7300..954a3270 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -671,11 +671,15 @@ pub fn dumpState(p: *Pardes) !void { p.emit(.write_dump); } -pub fn restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { +/// The replacement core for a Restore of the dump at `from`, whose log says +/// so after its panes' `new`s, for a client that reconnects to read. +pub fn restore(p: *Pardes, zon_bytes: []const u8, from: []const u8) !*Pardes { var opts = p.opts; opts.cols = p.screen_w; opts.rows = p.screen_h; - return initDump(p.gpa, opts, zon_bytes, p); + const replacement = try initDump(p.gpa, opts, zon_bytes, p); + pardes.ctlfs.events.notePath(replacement, "restore", from); + return replacement; } pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { @@ -744,7 +748,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty; - if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .cwd = .from(t.cwd) } }); + if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } }); break :terminal restored; }, .file => try pardes.panes.File.restore(p, i, src), diff --git a/src/edit.zig b/src/edit.zig index 9044d7ed..84bbb292 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -13,6 +13,7 @@ const body_layer = @import("body_layer.zig"); const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const lsp = @import("lsp/lsp.zig"); +const syntax = @import("syntax.zig"); const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; @@ -26,23 +27,75 @@ const hit = pardes.hit; const sel_slot = pardes.sel_slot; const Effect = pardes.Effect; const Pardes = pardes.Pardes; +const Registers = pardes.Registers; +/// Write `text` as this range's value of the register the running command +/// names (`"<reg>`), the default one otherwise. `_` swallows it, and the +/// registers computed from the editor (`#`, `.`, `%`) cannot be written. +/// `+` and `*` are the system clipboard: the value is sent there too. pub fn setYank(p: *Pardes, text: []const u8) void { - if (p.multi_on and !p.multi_first) { - const old = p.yank orelse ""; - const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; - const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return; - if (p.yank) |y| p.gpa.free(y); - p.yank = joined; - return; + var name: u21 = if (p.register != 0) p.register else Registers.default; + switch (name) { + '_', '#', '.', '%' => return, + '*' => name = '+', + else => {}, } - if (p.yank) |y| p.gpa.free(y); - p.yank = p.gpa.dupe(u8, text) catch null; + const one = !p.multi_on; + p.registers.put(p.gpa, name, text, if (one) 0 else p.multi_index, if (one) 1 else p.multi_count, one or p.multi_first); + if (name == '+' and (one or p.multi_first)) p.emit(.{ .set_clipboard = {} }); } pub fn setClipboard(p: *Pardes, text: []const u8) void { + const named = p.register; + p.register = '+'; + defer p.register = named; setYank(p, text); - if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} }); +} + +/// Value `i` of register `name` as the command at range `i` reads it, the +/// computed registers included: `#` is the range's number from 1, `.` its +/// text, `%` the file's name, `_` nothing. Scratch- or register-owned. +pub fn registerValue(p: *Pardes, t: *Text, name: u21) ?[]const u8 { + const i = if (p.multi_on) p.multi_index else 0; + return switch (name) { + '_' => null, + '#' => std.fmt.allocPrint(p.scratch.allocator(), "{d}", .{i + 1}) catch null, + '.' => selText(p, t), + '%' => if (t.what == .body) if (t.pane()) |pane| (if (pane.file) |f| f.path else null) else null else null, + else => p.registers.value(name, i), + }; +} + +/// the text of the range under the cursor: the selection, or the cursor's +/// own character +fn selText(p: *Pardes, t: *Text) []const u8 { + if (t.msel.active) return yankRows(p, t, @min(t.msel.r0, t.msel.r1), @max(t.msel.r0, t.msel.r1)); + const save = t.vsel; + defer t.vsel = save; + if (!t.vsel.active) t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = false }; + return vselText(p, t); +} + +/// `p` / `P` / `R` from the register the command names: this range's value +/// of it. The clipboard's is asked of the shell and pasted when it answers, +/// once, at the primary. +pub fn pasteRegister(p: *Pardes, t: *Text, how: enum { after, before, replace }, count: usize) void { + const name: u21 = if (p.register != 0) p.register else Registers.default; + if (name == '+' or name == '*') { + if (!normal.multiOnce(p)) return; + const pane = t.pane() orelse return; + return clipRequest(p, p.paneIdOf(pane) orelse return, switch (how) { + .after => .after, + .before => .before, + .replace => .replace, + }); + } + const y = registerValue(p, t, name) orelse return; + switch (how) { + .after => pasteText(p, t, y, false, count), + .before => pasteText(p, t, y, true, count), + .replace => replaceWithText(p, t, y), + } } /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers. @@ -87,7 +140,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; - if (pane.prompt != .none and pane.prompt != .del_side) { + if (pane.prompt != .none and pane.prompt != .del_side and pane.prompt != .repl_choice) { // an answer is one line: a newline in it would only be submitting it const line = p.scratch.allocator().alloc(u8, bytes.len) catch return; var n: usize = 0; @@ -96,7 +149,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { n += 1; }; if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] }); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); return; } if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes); @@ -114,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { if (bytes.len == 0) return; const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes }; if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return; - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1); - if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + if (t.refused) { + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); + } } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { @@ -125,6 +184,10 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text); return; }; + // helix's `"+y`: the clipboard register, and only it + const named = p.register; + p.register = '+'; + defer p.register = named; if (pane.body.nsel > 0 and !main_only) { normal.replaySels(p, &pane.body, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { @@ -133,7 +196,6 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { normalYank(p, &pane.body); pane.body.nsel = others; } - p.emit(.{ .set_clipboard = {} }); } /// tag + '\n' + body: the full selectable pane text, a row to a screen row @@ -286,30 +348,24 @@ pub fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { pub fn exitInsert(p: *Pardes, t: *Text) void { t.mode = .normal; + t.reg_wait = false; t.msel.active = false; t.normal.clear(); - if (t.append_at) |aa| { - t.append_at = null; - const text = flatSurface(p, t) catch return; - const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); - const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); - const back = @max(a_off, modal.prevGrapheme(text, gap)); - const bc = modal.positionAt(text, back); - t.cur_row = @intCast(bc.row); - t.cur_col = @intCast(bc.col); - t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; - t.cur_pinned = true; - t.show(); - for (t.sels[0..t.nsel]) |*s| { - const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); - const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); - const bp = modal.positionAt(text, b2); - s.row = @intCast(bp.row); - s.col = @intCast(bp.col); - s.arow = s.row; - s.acol = s.col; - } + if (!t.restore_cursor) return; + t.restore_cursor = false; + // helix enter_normal_mode: every range, from its start to the char + // before its end when it points forward, else to its end + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const text = flatSurface(p, t) catch return; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, 0, &rs); + for (rs[0..got.n]) |*r| { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to; + r.* = .{ .anchor = from, .head = head }; } + t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl); } pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { @@ -415,7 +471,9 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { if (t.what == .tag) { // A tag is edited as it is shown, but only what follows its // prefix is its own: an edit that changed the prefix is refused. - defer p.gpa.free(new); + // The shown text stays alive until the next such edit, since every + // 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; if (!std.mem.startsWith(u8, new, prefix)) { @@ -432,11 +490,25 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { t.own = new; return; } - const pane = t.pane() orelse return p.gpa.free(new); + const pane = t.pane() orelse return retireEditText(p, new); + // what changed ended where the texts agree again from their ends + const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 }; + const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); + var back: usize = 0; + while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; + const end = modal.positionAt(new, new.len - back); + t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } +/// Take a text an edit made that nothing keeps, holding it until the next +/// one: the edit's caller still reads it to place the cursor. +pub fn retireEditText(p: *Pardes, new: []u8) void { + if (p.retired_edit) |old| p.gpa.free(old); + p.retired_edit = new; +} + pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; /// Files insert only inside existing rows and at grapheme boundaries. @@ -462,7 +534,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { // `u` undoes the whole session and restores what was selected pushUndo(p, t); t.select = false; - t.append_at = null; + t.restore_cursor = false; t.sticky_col = -1; const text = flatSurface(p, t) catch { t.mode = .insert; @@ -488,12 +560,25 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { const lo_col = if (b) |bb| bb.lo_col else t.cur_col; const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; - t.append_at = .{ .row = lo_row, .col = lo_col }; - const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); - const gc = modal.positionAt(text, gap); + t.restore_cursor = true; + const hi = modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }); + const gap = modal.nextGrapheme(text, hi); + // helix append_mode: a range that reaches the end of the text + // gets a newline after it to append before (a terminal's text + // is not the editor's to grow) + const terminal = t.what == .body and if (t.pane()) |pane| pane.isTerminal() else false; + var now = text; + if (hi < text.len and gap >= text.len and !terminal) { + const grown = std.mem.concat(p.gpa, u8, &.{ text, "\n" }) catch return; + setEditText(p, t, grown); + now = grown; // what setEditText keeps; `text` may be gone + } + const gc = modal.positionAt(now, gap); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); - t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; + // helix append_mode: the range reaches one past its end, which + // is where typing goes; a bare cursor becomes a range too + t.vsel = .{ .active = true, .row = lo_row, .col = lo_col, .explicit = false }; t.cur_pinned = true; }, .line_start => { @@ -565,6 +650,87 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return insertKey(p, t, .{ .cp = Key.delete }); + // Ctrl-r <reg>: type this range's value of the register (helix + // insert_register) + if (t.reg_wait) { + t.reg_wait = false; + if (key.ctrl or key.alt or key.cp >= 0xF0000) return; + const y = registerValue(p, t, key.cp) orelse return; + if (y.len > 0) insertKey(p, t, .{ .cp = key.cp, .text = y }); + return; + } + if (hit(key, config.insert_register)) { + t.reg_wait = true; + return; + } + // helix commit_undo_checkpoint: `u` after the session goes back to here + if (hit(key, config.insert_checkpoint)) return pushUndo(p, t); + // helix maps every range through each insert-mode edit (Range::map): + // the edits below work on the bare cursor, and the range the cursor + // heads is carried through what they did to the text afterwards + if (!t.vsel.active) return insertEdit(p, t, key); + const old = flatSurface(p, t) catch return insertEdit(p, t, key); + const r = t.primaryRange(old, 0); + const c = modal.selectionCursor(old, r); + const old_len = old.len; + const explicit = t.vsel.explicit; + t.vsel.active = false; + insertEdit(p, t, key); + if (t.mode != .insert) return; + const new = flatSurface(p, t) catch return; + const c2 = modal.offsetAt(new, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); + // every insert-mode edit happens at the cursor: an insertion there, or + // a deletion that ends there (backward) or starts there (forward) + if (key.cp == Key.enter) { + // helix insert_newline: an appending range stretches by what the + // cursor moved, any other slides by it + const d = @as(isize, @intCast(c2)) - @as(isize, @intCast(c)); + const head: usize = @intCast(@as(isize, @intCast(r.head)) + d); + const anchor: usize = if (c > r.anchor) r.anchor else @intCast(@as(isize, @intCast(r.anchor)) + d); + return setInsertRange(t, new, .{ .anchor = anchor, .head = head }, explicit); + } + var at: usize = c; + var del: usize = 0; + var ins: usize = 0; + if (new.len > old_len and c2 == c + (new.len - old_len)) { + ins = new.len - old_len; + } else if (new.len < old_len and c2 + (old_len - new.len) == c) { + at = c2; + del = old_len - new.len; + } else if (new.len < old_len and c2 == c) { + del = old_len - new.len; + } else if (new.len != old_len or c2 != c) { + return; // a motion: the range collapses onto the cursor (helix) + } + // helix Assoc: the head sticks after an insertion at it when the range + // points backward or is empty, the anchor when it points forward + const fwd = r.anchor < r.head; + setInsertRange(t, new, .{ + .anchor = mapThroughEdit(r.anchor, at, del, ins, !fwd and r.anchor != r.head), + .head = mapThroughEdit(r.head, at, del, ins, fwd), + }, explicit); +} + +fn setInsertRange(t: *Text, new: []const u8, mapped: modal.Selection, explicit: bool) void { + const cells = Text.rangeCells(new, mapped); + const cc = modal.positionAt(new, cells.cur); + const ac = modal.positionAt(new, cells.anc); + t.cur_row = @intCast(cc.row); + t.cur_col = @intCast(cc.col); + t.vsel = .{ .active = cells.anc != cells.cur, .row = @intCast(ac.row), .col = @intCast(ac.col), .explicit = explicit }; +} + +/// A position through one edit at `at` that deleted `del` bytes and put +/// `ins` in (one of the two is 0). A position exactly at an insertion stays +/// before it when `before` is set, else moves past it. +fn mapThroughEdit(pos: usize, at: usize, del: usize, ins: usize, before: bool) usize { + if (pos < at) return pos; + if (del > 0) return if (pos < at + del) at else pos - del; + if (pos == at and before) return pos; + return pos + ins; +} + +fn insertEdit(p: *Pardes, t: *Text, key: Key) void { t.vsel.active = false; if (!t.cur_pinned) pinPaneCursor(p, t); _ = clampFileCursor(t); @@ -679,12 +845,29 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); + const before = line[0..@min(c.col, line.len)]; + // helix insert_newline: after nothing but blanks, the line + // itself moves down, blanks and all; else the blanks before the + // cursor go and the new line takes the indent + if (std.mem.indexOfNone(u8, before, " \t") == null) { + const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return; + setEditText(p, t, new); + t.cur_row += 1; + t.cur_pinned = true; + t.show(); + return; + } const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @memset(block[1..], ' '); - const new = modal.insertAt(p.gpa, text, c, block) catch return; + // 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; + const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return; + defer p.gpa.free(cut); + const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return; setEditText(p, t, new); t.cur_row += 1; t.cur_col = @intCast(indent); @@ -694,8 +877,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { Key.backspace => { if (t.cur_col > 0) { const line = modal.lineSlice(text, c.row); - const prev = modal.prevGrapheme(line, c.col); - const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return; + var prev = modal.prevGrapheme(line, c.col); + // in leading blanks, back to the previous indent stop (helix + // dedent): a whole unit when on one, else the part past it + const lead = line[0..@min(c.col, line.len)]; + if (std.mem.indexOfNone(u8, lead, " \t") == null and prev < line.len and line[prev] != '\t') { + var width: usize = 0; + for (lead) |ch| width += if (ch == '\t') modal.INDENT_W else 1; + var drop = width % modal.INDENT_W; + if (drop == 0) drop = modal.INDENT_W; + prev = c.col; + while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1; + } + const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return; setEditText(p, t, new); t.cur_col = @intCast(prev); t.cur_pinned = true; @@ -742,6 +936,17 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { p.lspRequest(p.active, .completion, ""); if (p.lsp_seq != seq) return; } + // helix smart_tab: after text, Tab leaves the syntax node the + // cursor is in; only where the file has a grammar, since the + // reference pins smart tab off, which a file without one agrees + // with. Shift-Tab always types the tab. + const after_text = std.mem.indexOfNone(u8, ln[0..@min(c.col, ln.len)], " \t") != null; + if (!key.shift and after_text and t.what == .body) smart: { + const pane = t.pane() orelse break :smart; + const f = pane.file orelse break :smart; + if (!syntax.supportsPath(f.path)) break :smart; + return normal.nodeSelect(p, pane, t, .parent_end); + } insertTab(p, t); }, else => {}, @@ -1121,31 +1326,45 @@ pub fn normalReplaceChar(p: *Pardes, t: *Text, ch: u21) void { /// `R`: replace the selection (or the cursor char) with the DEFAULT /// register. `SPC R` is the same verb over the system clipboard. -pub fn normalReplaceYank(p: *Pardes, t: *Text) void { - replaceWithText(p, t, p.yank orelse return); +/// The acme chords' replace: the default register as one text. +pub fn chordReplace(p: *Pardes, t: *Text) void { + replaceWithText(p, t, p.registers.text(p.gpa, Registers.default) orelse return); } -/// The pasted text becomes the selection, head on its last char (which for -/// a trailing-newline `y` is the '\n' cell of the last full line). +/// The pasted text becomes the selection, in the direction the replaced one +/// had: a forward head lands on its last char (which for a trailing-newline +/// `y` is the '\n' cell of the last full line), a backward one on its first. fn replaceWithText(p: *Pardes, t: *Text, y: []const u8) void { if (y.len == 0) return; t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); + const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col)); pushUndo(p, t); const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; setEditText(p, t, new); t.msel.active = false; - t.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); + var last_row: i32 = undefined; + var last_col: i32 = undefined; if (end.col > 0) { - t.cur_row = @as(i32, @intCast(end.row)) + eb.row0; - t.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); + last_row = @as(i32, @intCast(end.row)) + eb.row0; + last_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); } else { - // the yank ended in '\n': the cursor lands ON that newline - t.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; - t.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); + // the yank ended in '\n': its last cell is that newline + last_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; + last_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } + const first_row = @as(i32, @intCast(r.a.row)) + eb.row0; + const first_col: i32 = @intCast(r.a.col); + t.vsel = .{ + .active = modal.nextGrapheme(y, 0) < y.len, + .row = if (back) last_row else first_row, + .col = if (back) last_col else first_col, + .explicit = false, + }; + t.cur_row = if (back) first_row else last_row; + t.cur_col = if (back) first_col else last_col; t.cur_pinned = true; t.sticky_col = -1; t.show(); @@ -1201,53 +1420,213 @@ fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { return out; } -pub fn normalJoin(p: *Pardes, t: *Text) void { +/// `J` / `Alt-J`: join every range's lines with the next, in ONE edit +/// (helix join_selections): the newline and the next line's leading blanks +/// become one space, or nothing when that line is blank, and a line two +/// ranges share is joined once. `Alt-J` then selects the spaces it put in. +pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; // a join always eats the newline of its last line, so the buffer has - // to reach one row PAST the selection - const sr = selRows(t); - const eb = editText(p, t, sr.lo_row, sr.hi_row + 1, -1) orelse return; + // to reach one row PAST every range + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editText(p, t, b.lo_row, b.hi_row + 1, -1) orelse return; const text = eb.text; - const range = t.primaryRange(text, eb.row0); - const span = Text.rangeLineSpan(text, range); + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); const nlines = modal.cursorLineCount(text); - var end = span.end; - if (span.start == end) end = @min(end + 1, nlines - 1); - if (end <= span.start) return; const arena = p.scratch.allocator(); - const chs = arena.alloc(TextChange, end - span.start) catch return; + const chs = arena.alloc(TextChange, nlines) catch return; var chs_len: usize = 0; - var l = span.start; - while (l < end) : (l += 1) { - const from = modal.lineEndOffset(text, l); - var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); - while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; - const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; - chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; - chs_len += 1; + var next: usize = 0; // lines below this one are joined already + for (rs[0..got.n]) |r| { + const span = Text.rangeLineSpan(text, r); + var end = span.end; + if (span.start == end) end = @min(end + 1, nlines - 1); + var l = @max(span.start, next); + while (l < end) : (l += 1) { + const from = modal.lineEndOffset(text, l); + var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); + while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; + const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; + chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; + chs_len += 1; + } + next = @max(next, end); } if (chs_len == 0) return; pushUndo(p, t); - const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }); - const anc_off = if (t.vsel.active) - modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.vsel.col)) }) - else - cur_off; const new = applyChanges(p, text, chs[0..chs_len]) catch return; - const nc = mapThroughChanges(chs[0..chs_len], cur_off); - const na = mapThroughChanges(chs[0..chs_len], anc_off); + // every range's cells ride through the edit; Alt-J replaces them with + // the spaces, when it put any in + var cells: [Text.max_selections]Text.SelRange = undefined; + var n: usize = 0; + var pri = got.pri; + if (select_space) { + var delta: i64 = 0; + for (chs[0..chs_len]) |ch| { + const at: usize = @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); + if (ch.ins.len == 0 or n == cells.len) continue; + const pos = modal.positionAt(new, at); + cells[n] = .{ .row = @as(i32, @intCast(pos.row)) + eb.row0, .col = @intCast(pos.col), .arow = @as(i32, @intCast(pos.row)) + eb.row0, .acol = @intCast(pos.col) }; + n += 1; + } + pri = 0; + } + if (n == 0) { + for (rs[0..got.n]) |r| { + const c = Text.rangeCells(text, r); + const cc = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.cur)); + const ac = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.anc)); + cells[n] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + n += 1; + } + pri = got.pri; + } setEditText(p, t, new); - const cc = modal.positionAt(new, nc); - const ac = modal.positionAt(new, na); - t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; - t.cur_col = @intCast(cc.col); - if (t.vsel.active) { - t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; - t.vsel.col = @intCast(ac.col); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, pri, expl); +} + +/// `Alt-)` / `Alt-(`: rotate the TEXT of the selections forward / back by +/// one range (count ranges), in one edit (helix reorder_selection_contents). +/// Each range comes back over the text it now holds, in its own direction, +/// and the primary moves with the text it had. +pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); } - t.msel.active = false; - t.cur_pinned = true; - t.sticky_col = -1; - t.show(); + const eb = editTextEol(p, t, b) orelse return; + const text = eb.text; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); + const n = got.n; + if (n < 2) return; + const by = @min(cnt, n); + var chs: [Text.max_selections]TextChange = undefined; + for (rs[0..n], 0..) |r, i| { + const src = rs[if (forward) (i + n - by) % n else (i + by) % n]; + chs[i] = .{ + .from = @min(r.anchor, r.head), + .to = @max(r.anchor, r.head), + .ins = text[@min(src.anchor, src.head)..@max(src.anchor, src.head)], + }; + } + pushUndo(p, t); + const new = applyChanges(p, text, chs[0..n]) catch return; + var cells: [Text.max_selections]Text.SelRange = undefined; + var delta: i64 = 0; + for (chs[0..n], rs[0..n], 0..) |ch, r, i| { + const from: usize = @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); + const to = from + ch.ins.len; + const c = Text.rangeCells(new, if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }); + const cc = modal.positionAt(new, c.cur); + const ac = modal.positionAt(new, c.anc); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl); +} + +/// `&`: pad the selections into columns, in one edit (helix +/// align_selections). The k-th range of every line is a column; spaces go in +/// before each range so its head lands on the column's widest head, measured +/// in display cells. A range over several lines aligns nothing. +pub fn normalAlign(p: *Pardes, t: *Text) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editTextEol(p, t, b) orelse return; + const text = eb.text; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); + const n = got.n; + var row: [Text.max_selections]usize = undefined; + var col: [Text.max_selections]usize = undefined; + var widths: [Text.max_selections]usize = @splat(0); + var line: usize = std.math.maxInt(usize); + var k: usize = 0; + var run: usize = 0; + for (rs[0..n], 0..) |r, i| { + row[i] = modal.lineAtOffset(text, r.head); + if (modal.lineAtOffset(text, r.anchor) != row[i]) return; + const ls = modal.lineStartOffset(text, row[i]); + col[i] = panes.File.rawDisplayCol(text[ls..modal.lineEndOffset(text, row[i])], r.head - ls); + if (row[i] != line) { + line = row[i]; + k = 0; + run = 0; + } + widths[k] = @max(widths[k], col[i] - run); + run = col[i]; + k += 1; + } + t.select = false; // helix exit_select_mode + // each column's head position, left to right + for (1..n) |i| widths[i] += widths[i - 1]; + const arena = p.scratch.allocator(); + var chs: [Text.max_selections]TextChange = undefined; + var m: usize = 0; + line = std.math.maxInt(usize); + for (rs[0..n], 0..) |r, i| { + if (row[i] != line) { + line = row[i]; + k = 0; + run = 0; + } + const pad = widths[k] - col[i] - run; + k += 1; + run += pad; + if (pad == 0) continue; + const spaces = arena.alloc(u8, pad) catch return; + @memset(spaces, ' '); + const at = @min(r.anchor, r.head); + chs[m] = .{ .from = at, .to = at, .ins = spaces }; + m += 1; + } + if (m == 0) return; + pushUndo(p, t); + const new = applyChanges(p, text, chs[0..m]) catch return; + var cells: [Text.max_selections]Text.SelRange = undefined; + for (rs[0..n], 0..) |r, i| { + const c = Text.rangeCells(text, r); + const cc = modal.positionAt(new, mapThroughChanges(chs[0..m], c.cur)); + const ac = modal.positionAt(new, mapThroughChanges(chs[0..m], c.anc)); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, got.pri, expl); } pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { @@ -1439,9 +1818,12 @@ pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void { /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void { - const eb = editText(p, t, t.cur_row, t.cur_row, -1) orelse return; + // above the selection's first line, below its last (helix line_range) + const sr = selRows(t); + const at = if (below) sr.hi_row else sr.lo_row; + const eb = editText(p, t, at, at, -1) orelse return; pushUndo(p, t); - const row: usize = @intCast(@max(0, t.cur_row - eb.row0)); + const row: usize = @intCast(@max(0, at - eb.row0)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); @@ -1449,10 +1831,14 @@ pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void { const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; setEditText(p, t, new); if (!below) { - t.cur_row += @intCast(cnt); // the line moved down; stay on it - if (t.vsel.active and t.vsel.row >= @as(i32, @intCast(row))) t.vsel.row += @intCast(cnt); - } else if (t.vsel.active and t.vsel.row > @as(i32, @intCast(row))) { - t.vsel.row += @intCast(cnt); + // the whole selection moved down; stay on it + const n: i32 = @intCast(cnt); + t.cur_row += n; + if (t.vsel.active) t.vsel.row += n; + if (t.msel.active) { + t.msel.r0 += n; + t.msel.r1 += n; + } } t.cur_pinned = true; t.show(); @@ -1472,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void { const range = t.primaryRange(text, 0); + // helix's tree-sitter objects, from the grammar's textobjects query + const object: ?modal.Normal.Object = switch (obj) { + 'f' => .function, + 't' => .class, + 'a' => .parameter, + 'c' => .comment, + 'T' => .@"test", + 'e' => .entry, + 'x' => .xml_element, + else => null, + }; + if (object) |o| { + if (t.what != .body) return; + const pane = t.pane() orelse return; + const f = if (pane.file) |*file| file else return; + const tree = normal.fileTree(p, f) orelse return; + const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return; + return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false); + } switch (obj) { 'w', 'W' => { const r = modal.selectWord(text, range, around, obj == 'W'); @@ -1512,16 +1917,22 @@ pub fn surroundAdd(p: *Pardes, t: *Text, ch: u21) void { t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); + // the wrapped range keeps the direction it had (helix with_direction) + const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col)); pushUndo(p, t); const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col); var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return; setEditText(p, t, new); new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; setEditText(p, t, new); + const open_row = @as(i32, @intCast(r.a.row)) + eb.row0; + const open_col: i32 = @intCast(r.a.col); + const close_row = @as(i32, @intCast(r.b.row)) + eb.row0; + const close_at: i32 = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); t.msel.active = false; - t.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; - t.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; - t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); + t.vsel = .{ .active = true, .row = if (back) close_row else open_row, .col = if (back) close_at else open_col, .explicit = false }; + t.cur_row = if (back) open_row else close_row; + t.cur_col = if (back) open_col else close_at; t.cur_pinned = true; t.sticky_col = -1; t.show(); @@ -1760,16 +2171,20 @@ test "only the SPC clipboard commands cross to the system clipboard" { _ = drainedEffect(p, .set_clipboard); p.update(.{ .key = .{ .cp = 'y' } }); - try std.testing.expect(p.yank != null and p.yank.?.len > 0); + const plain = p.registers.text(gpa, Registers.default) orelse return error.MissingYank; + try std.testing.expect(plain.len > 0); try std.testing.expect(!drainedEffect(p, .set_clipboard)); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expect(!drainedEffect(p, .set_clipboard)); - // `SPC y` is the one that does, and it puts the SAME text there that `y` - // put in the register — it is the ordinary yank path plus the mirror. + // `SPC y` is the one that does: helix's `"+y`, the ordinary yank into + // the clipboard register instead of the default one. p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'y' } }); - const yanked = p.yank orelse return error.MissingYank; + const yanked = try gpa.dupe(u8, p.registers.text(gpa, Registers.default) orelse ""); + defer gpa.free(yanked); + const clip: []const u8 = p.registers.text(gpa, '+') orelse ""; + try std.testing.expect(clip.len > 0); try std.testing.expect(drainedEffect(p, .set_clipboard)); const before = pane.file.?.content.len; @@ -1783,14 +2198,14 @@ test "only the SPC clipboard commands cross to the system clipboard" { try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null); // ...and it did NOT land in the register on its way past, nor echo back // out to the clipboard it came from. - try std.testing.expectEqualStrings(yanked, p.yank orelse ""); + try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse ""); try std.testing.expect(!drainedEffect(p, .set_clipboard)); // An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V — // is the same event with no request behind it, and means paste after. p.update(.{ .paste = "UNASKED" }); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null); - try std.testing.expectEqualStrings(yanked, p.yank orelse ""); + try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse ""); // A request the shell never answers dies at the next keystroke rather // than firing late into whatever pane is focused by then. diff --git a/src/esp32p4.zig b/src/esp32p4.zig index c583de54..b7725b81 100644 --- a/src/esp32p4.zig +++ b/src/esp32p4.zig @@ -613,7 +613,7 @@ fn boardNow(_: ?*anyopaque) u64 { } /// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and -/// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message +/// answers false when the firmware brought none, which is what puts "gpio: no pads" on the message /// row rather than a trap. fn gpioToggle(_: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool { const f = host_gpio orelse return false; diff --git a/src/exec.zig b/src/exec.zig index ad0f7b9b..ee7c31e9 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -16,6 +16,11 @@ const colors = @import("colors.zig"); const themes = colors.themes; const builtins = @import("builtins.zig"); const config = @import("config.zig"); +const grammars = @import("grammar_manifest.zig").all; +const python_lang = blk: { + @setEvalBranchQuota(20_000); + break :blk @import("grammar_manifest.zig").byName("python").?; +}; const pdf_enabled = panes.Pdf.enabled; const font_picker = pardes.font_picker; const hosted = pardes.hosted; @@ -30,7 +35,8 @@ const Pardes = pardes.Pardes; pub fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; - if (!pane.isTerminal()) return false; + // A command pane's child is its command, never a shell to type at. + if (!pane.isTerminal() or pane.command != null) return false; // A mounted shell sits behind sudo's process supervisor. Its OSC 133 // prompt marks identify input readiness; the launcher's executable is // not the shell executable used by the ordinary process heuristic. @@ -67,7 +73,8 @@ pub fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { var primary_active: ?usize = null; for (texts[0..made], 0..) |txt, i| { - runBuiltin(p, cmd, id, "", txt); + // Every selection is the body's, each one sent as the first is. + runBuiltinFrom(p, cmd, id, "", txt, true); if (i == got.pri) primary_active = p.active; const still = p.panes[id] orelse break; if (still.serial != serial) break; @@ -290,8 +297,8 @@ fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { /// Tty: a shell in the caller's directory, raw from the first frame, /// stacked below the caller like Alt-n's new shell. -pub fn spawnTty(p: *Pardes, from: usize) void { - spawnTtyWithMount(p, from, false); +pub fn spawnTty(p: *Pardes, from: usize) ?*Pane { + return spawnTtyWithMount(p, from, false); } pub fn spawnV9fsTty(p: *Pardes, from: usize) void { @@ -299,13 +306,19 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void { return p.reportError(from, "Tty9p", error.LinuxRequired); if (p.fs.socket_path.len == 0) return p.reportError(from, "Tty9p", error.SessionSocketRequired); - spawnTtyWithMount(p, from, true); + _ = spawnTtyWithMount(p, from, true); } -fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { - const src = p.panes[from] orelse return; - const free = p.freeSlot() orelse return; - const nt = p.newShell(free, Pardes.paneDir(src)) catch return; +fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane { + const src = p.panes[from] orelse return null; + const free = p.freeSlot() orelse { + p.reportFailure(from, "Tty: no pane free"); + return null; + }; + const nt = p.newShell(free, Pardes.paneDir(src)) catch |err| { + p.reportError(from, "Tty", err); + return null; + }; nt.greet = !v9fs; nt.v9fs_on_spawn = v9fs; nt.body.mode = .tty; @@ -315,6 +328,7 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { layout.splitBelow(p, parent, nt); p.active = free; p.stillborn_joiner = nt.serial; + return nt; } /// when a doc lands in `col`, a lone pristine shell there is clutter — drop @@ -337,7 +351,7 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { panes.Terminal.scrollbar(tt).total > tt.rows) return; p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows - layout.absorbVWeight(p, tty_id, null); + _ = layout.absorbVWeight(p, tty_id, null); layout.removePane(p, tty_id); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; @@ -362,34 +376,156 @@ pub fn replaceStillborn(p: *Pardes) void { if (out.from != .cmd or out.from.cmd != .New) return; if (file.revision != 0 or file.content.len != 0) return; if (old.tag.own) |own| if (!std.mem.eql(u8, own, tagline.defaultTail(old))) return; - const was_active = p.active == old_id; layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); - if (was_active) p.active = new_id; } -fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { - for (p.panes, 0..) |slot, i| if (slot) |tt| { - if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; - if (takesCommandLine(p, i)) return i; +// ---- REPLs ---- +// +// A terminal is bound as a language's REPL by `Repl python` (builtins.zig). +// An exec made by a gesture on the body of a file in that language -- or on +// the REPL's own body -- is sent to it as input rather than run: all of it, +// a word like `ls` too. The tag's words, `Exec` by name and a command word +// `@`...`` still run as commands. + +/// A file pane's language, as grammar_manifest names it, by its index. +pub fn langOf(pane: *const Pane) ?u8 { + const f = pane.file orelse return null; + return @intCast(@import("grammar_manifest.zig").byPath(f.path) orelse return null); +} + +/// A bound REPL whose program is there to take input: a command pane's +/// while its command runs; an interactive terminal's while a program, not +/// the shell, has its tty (the host's `tty_taken`) -- a python3 left with +/// Ctrl-D leaves the shell, which would run `import os` as ImageMagick's. +pub fn replLive(p: *const Pardes, id: usize) bool { + const pane = p.panes[id] orelse return false; + if (pane.repl == null) return false; + if (pane.command != null) return !pane.command_done; + return p.hostTtyTaken(id); +} + +/// `python-a`. +pub fn replId(buf: []u8, pane: *const Pane) []const u8 { + const r = pane.repl orelse return ""; + return std.fmt.bufPrint(buf, "{s}-{c}", .{ grammars[r.lang].name, r.letter }) catch ""; +} + +const Routed = union(enum) { none, sent: usize, asked }; + +/// The REPL bound here has no program running to take the text: said, +/// and nothing sent or run. +fn replGone(p: *Pardes, from: usize, repl: *const Pane) Routed { + var idbuf: [48]u8 = undefined; + var said: [128]u8 = undefined; + p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {s} has no program running; nothing sent", .{replId(&idbuf, repl)}) catch "Repl: no program running"); + return .asked; +} + +/// Where an exec from `from`'s body goes when a REPL is bound for it: the +/// one there is, or a question when there are several. +fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed { + const src = p.panes[from] orelse return .none; + if (src.repl != null) { + if (!replLive(p, from)) return replGone(p, from, src); + sendToRepl(p, from, from, text); + return .{ .sent = from }; + } + const lang = langOf(src) orelse return .none; + var bound: [26]usize = undefined; + var n: usize = 0; + var gone: ?usize = null; + for (p.panes, 0..) |slot, i| if (slot) |pane| if (pane.repl) |r| if (r.lang == lang and n < bound.len) { + if (!replLive(p, i)) { + gone = i; + continue; + } + bound[n] = i; + n += 1; }; - const free = p.freeSlot() orelse return null; - const nt = p.newShell(free, dir) catch return null; - nt.greet = false; - const rc = if (p.ncol > 0) p.ncol - 1 else 0; - if (p.col_n[rc] > 0) { - const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); - const f = layout.findPane(p, src).?; - layout.splitBelow(p, src, nt); - layout.insert(p, f.col, f.idx + 1, free); - } else layout.insert(p, rc, p.col_n[rc], free); - return free; + if (n == 0) return if (gone) |i| replGone(p, from, p.panes[i].?) else .none; + if (n == 1) { + sendToRepl(p, from, bound[0], text); + return .{ .sent = bound[0] }; + } + // Several: asked on the pane's notice band, as Del asks, one key + // answering; nothing is remembered of the answer. + const owned = p.gpa.dupe(u8, text) catch return .none; + if (p.repl_ask) |old| p.gpa.free(old.text); + p.repl_ask = .{ .from = src.serial, .lang = lang, .text = owned }; + for (bound[0..n]) |i| p.repl_ask.?.offered[p.panes[i].?.repl.?.letter - 'a'] = p.panes[i].?.serial; + var w = std.Io.Writer.fixed(&src.question); + w.print("send to {s}:", .{grammars[lang].name}) catch {}; + for (bound[0..n]) |i| w.print(" {c}", .{p.panes[i].?.repl.?.letter}) catch {}; + w.writeAll(" (Esc: none)") catch {}; + src.question_len = @intCast(w.buffered().len); + src.prompt = .repl_choice; + p.active = from; + return .asked; +} + +/// Types `text` into the REPL in `to`, bracketed if its program asked for +/// that, then Enter; says so on `from`'s message row and in the log. +pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { + const dst = p.panes[to] orelse return; + const src = p.panes[from] orelse return; + var idbuf: [48]u8 = undefined; + const id = replId(&idbuf, dst); + // Lines end in \n alone (a \r\n would give Python a blank line that + // ends its block), and no ESC or other control byte but a tab goes: an + // ESC[201~ in the text would end a bracketed paste early. + const clean = p.scratch.allocator().alloc(u8, text.len) catch return; + var n: usize = 0; + for (text, 0..) |c, i| { + if (c == '\r') { + if (i + 1 < text.len and text[i + 1] == '\n') continue; + clean[n] = '\n'; + } else if (c < ' ' and c != '\t' and c != '\n' or c == 0x7f) continue else clean[n] = c; + n += 1; + } + // Sent whole or not at all: a send cut short would leave the REPL in a + // paste, or with half a block. + if (n + 16 > p.writeRoom(to)) { + var said: [128]u8 = undefined; + return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send"); + } + edit.typeToTty(p, to, dst, clean[0..n]); + p.emitWrite(to, "\r"); + // Line by line, a blank line inside a Python block ends the block. + if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and + std.mem.indexOfScalar(u8, text, '\n') != null) + { + dst.repl_warned = true; + var warn: [128]u8 = undefined; + p.setMessage(from, std.fmt.bufPrint(&warn, "Repl: {s} takes no bracketed paste; a blank line in a block ends it", .{id}) catch "Repl: no bracketed paste"); + } + var said: [64]u8 = undefined; + p.setMessage(from, std.fmt.bufPrint(&said, "\u{2192} {s}", .{id}) catch "sent"); + src.msg_sent = true; + var rec: [96]u8 = undefined; + pardes.ctlfs.events.notePath(p, "send", std.fmt.bufPrint(&rec, "{d} {d} {s}", .{ src.serial, dst.serial, id }) catch return); } +/// The longest line a command pane runs. +pub const command_max = 1024; + const max_exec_depth = 8; +/// A word no builtin knows (acme's exec.c run()). Clicked in an interactive +/// terminal, it is typed into that shell, whose state the click can see; +/// from anywhere else it runs as its own command pane, a terminal whose +/// child is `$SHELL -c` the line, which shows its output and `exit N` when +/// done, and which Kill signals. Answers the pane it went to. pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { + return executeFrom(p, id, txt, false); +} + +/// `execute` for text a gesture took from `id`'s body when `from_body`: +/// such text may be a bound REPL's. Run by name (`Exec ...`, whose line +/// comes back through `execute`) or written as a command word, never. +pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize { const pane = p.panes[id] orelse return null; + const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null; const cmd = commandText(txt); if (cmd.len == 0) return null; if (p.exec_depth == 0) p.noteHaptic(.exec); @@ -397,16 +533,129 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; - const dst = (if (takesCommandLine(p, id)) id else ttyForDir(p, Pardes.paneDir(pane))) orelse return null; - panes.Terminal.padOutputBelowEdits(p, dst); - panes.Terminal.noteCommand(p.panes[dst].?, cmd); - if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { + if (gesture) switch (routeToRepl(p, id, cmd)) { + .none => {}, + .sent => |to| return to, + .asked => return null, + }; + if (!takesCommandLine(p, id)) return runCommand(p, id, cmd); + panes.Terminal.padOutputBelowEdits(p, id); + panes.Terminal.noteCommand(pane, cmd); + if (panes.Terminal.queuePendingCommand(pane, cmd) catch |err| { p.reportError(id, "queue command", err); - return dst; - }) return dst; - p.emitWrite(dst, cmd); - p.emitWrite(dst, "\r"); - return dst; + return id; + }) return id; + p.emitWrite(id, cmd); + p.emitWrite(id, "\r"); + return id; +} + +/// Runs `line` in a command pane for `from`'s directory: `from` itself if +/// it is one whose command is done, else the directory's command pane that +/// is done, else a new one below the last pane of the rightmost column. +/// A pane reused keeps what it showed: the new command runs below a line +/// naming it, as acme appends to +Errors and never clears it (util.c:213). +fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { + // The board and the web page start no processes: a command pane there + // would say running for ever, and fill the slots. + if (comptime !hosted) { + p.reportFailure(from, "exec: not a builtin, and this build runs no commands"); + return null; + } + if (line.len > command_max) { + p.reportFailure(from, "exec: a command line is at most 1024 bytes"); + return null; + } + const src = p.panes[from] orelse return null; + const dir = Pardes.paneDir(src); + // Not one whose pty a job it left behind still prints to: reusing it + // would hang that job up. + const reuse: ?usize = if (src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| { + const other = slot orelse continue; + if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir)) break i; + } else null; + if (reuse) |id| { + const pane = p.panes[id].?; + const owned = p.gpa.dupe(u8, line) catch |err| { + p.reportError(from, "exec", err); + return null; + }; + p.gpa.free(pane.command.?); + pane.command = owned; + pane.command_done = false; + pane.command_status = null; + pane.command_pty = true; + pane.body.mode = .tty; + // What the last program left the emulator in goes first: the + // alternate screen left (only if it is there: leaving restores a + // saved cursor), mouse reports and bracketed paste off, the cursor + // shown, colours reset. (Not DECSTR: ghostty's stream does not + // implement it.) + if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l"); + panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); + echoCommand(p, pane, line); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } }); + noteRun(p, pane, "run", line); + return id; + } + const free = p.freeSlot() orelse { + p.reportFailure(from, "exec: no pane free for the command"); + return null; + }; + const pane = p.newCommand(free, dir, line) catch |err| { + p.reportError(from, "exec", err); + return null; + }; + echoCommand(p, pane, line); + const rc = if (p.ncol > 0) p.ncol - 1 else 0; + if (p.col_n[rc] > 0) { + const parent = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); + const f = layout.findPane(p, parent).?; + layout.splitBelow(p, parent, pane); + layout.insert(p, f.col, f.idx + 1, free); + } else layout.insert(p, rc, p.col_n[rc], free); + noteRun(p, pane, "run", line); + return free; +} + +/// `% line` above a command's output, every command, as a shell echoes +/// what it was given; its newlines the terminal's, so a multi-line command +/// does not staircase. +fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void { + var buf: [2 * command_max + 8]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + w.writeAll(if (panes.Terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {}; + for (line) |c| (if (c == '\n') w.writeAll("\r\n") else w.writeByte(c)) catch {}; + w.writeAll("\r\n") catch {}; + panes.Terminal.feedOutput(p, pane, w.buffered()); +} + +/// `run <serial> <word>` or `exit <serial> <N|?>` in the log. +/// The whole line, one record as `msg` carries its text: its control +/// characters, a newline too, become spaces (events.pushLog). A new pane is +/// announced first, so `new` comes before the `run` in it. +fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void { + pardes.ctlfs.events.announce(p); + var buf: [command_max + 16]u8 = undefined; + pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, std.mem.trim(u8, said, " \t\r\n") }) catch return); +} + +/// A command pane's child is gone: the pane stays, showing what it said +/// and how it ended, and is the next command's for its directory. +pub fn commandDone(p: *Pardes, id: usize, status: ?u8) void { + const pane = p.panes[id] orelse return; + pane.command_done = true; + // A REPL it was is gone with its command; the pane's next is not one. + pane.repl = null; + pane.command_status = status; + pane.body.mode = .normal; + var buf: [16]u8 = undefined; + const code = if (status) |n| std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?" else "?"; + var said: [32]u8 = undefined; + const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else ""; + panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n"); + noteRun(p, pane, "exit", code); + p.needs_frame = true; } /// Trim and unwrap clickable command-word notation once for every command @@ -420,7 +669,12 @@ pub fn commandText(txt: []const u8) []const u8 { pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const cmd = commandText(txt); if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; - const sp = std.mem.indexOfAny(u8, cmd, " \t"); + // `Word arg`, or for a word that says so, `Word+arg` (`Tty+fish`). + const plus = std.mem.indexOfScalar(u8, cmd, '+'); + var sp = std.mem.indexOfAny(u8, cmd, " \t"); + if (sp == null) if (plus) |at| if (std.meta.stringToEnum(Builtin, cmd[0..at])) |head| { + if (builtins.registry.plusArg(head)) sp = at; + }; const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; break :blk if (builtins.registry.takesArg(head)) head else null; @@ -441,6 +695,11 @@ pub fn applyStartupConfig(p: *Pardes) void { } pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { + runBuiltinFrom(p, b, id, txt, arg, false); +} + +/// `runBuiltin` saying whether a gesture on the pane's body made it. +pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8, from_body: bool) void { if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; // A builtin says its own name before it runs, so a tagline click is @@ -450,7 +709,7 @@ pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]co p.setMessage(id, @tagName(b)); pane.msg_announcement = true; } - const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; + const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg, .from_body = from_body }; builtins.registry.dispatch(b, c); } @@ -467,6 +726,12 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] colors.setThemeIndex(p, i); return; }; + // A config line is not answered on screen (applyStartupConfig + // runs with announce off); a click or a ctl write is. + if (!p.announce) return; + var near: [96]u8 = undefined; + var text: [200]u8 = undefined; + return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Theme: no theme \"{s}\"; like it: {s}; ThemeSel lists all", .{ want[0..@min(want.len, 40)], colors.themesNear(&near, want) }) catch "Theme: no such theme"); }, .font => { if (comptime !font_picker) return; @@ -476,7 +741,11 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; p.font_request_taken = false; }, - else => _ = p.settings.apply(setting, arg), + else => if (!p.settings.apply(setting, arg) and p.announce) { + var text: [96]u8 = undefined; + const takes = if (setting.action == .toggle) "takes on or off" else "does not take that value"; + p.reportFailure(p.active, std.fmt.bufPrint(&text, "{s}: {s}", .{ setting.word, takes }) catch takes); + }, } if (p.settings.colors != previous_colors) { for (p.panes) |slot| { @@ -570,7 +839,7 @@ test "the acme chords act once per selection, not once on the primary" { .{ .anchor = 7, .head = 13 }, }; pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); const before = p.ncol; p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec @@ -578,7 +847,7 @@ test "the acme chords act once per selection, not once on the primary" { // other cursor, so one keystroke over two cursors made one column. try std.testing.expectEqual(before + 2, p.ncol); // ...and the chord consumed the selection exactly as it does with one. - try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.body.nsel); + try std.testing.expectEqual(@as(u16, 0), p.panes[0].?.body.nsel); } test "a switch clicked with a selection held flips unless the selection says on or off" { diff --git a/src/fs-help.txt b/src/fs-help.txt index 689d8839..57ef7b62 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -5,18 +5,18 @@ index one line per pane: serial, kind (text|term|pdf|image), dirty flag, n status pid, version and pane count look write a line: a right click on it at the active pane; read: the serials it touched exec write a line: a middle click, an editor command word or a shell line; read the same -log events: new/del/rename/save <serial> <name>, msg <serial|-> <text>, err <serial|-> <why> +log events: new/del/rename/save <serial> <name>, run/exit/send <serial> ..., dump/restore <path>, msg, err screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses focus the serial of the pane with the keyboard; write a serial to give it the keyboard -ctl settings, one a line as a write takes them; or Newcol, Dump, Exit (quits), Kill (stops cmds) +ctl settings, one a line; or Newcol, Dump, Exit (quits; asks once), Kill [word] (stops what pardes ran, or what starts with word) commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it pane/new open it to make a pane (named +New); the read answers that pane's serial pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only) -Below, $m is the mount (/mnt/9p/pardes/<pid> via 9ns --mntgen; PARDES_MOUNT in a Tty9p shell): +Below, $m is the mount: $NINE_MOUNT/pardes/<pid> when 9ns --mntgen set $NINE_MOUNT; PARDES_MOUNT in a Tty9p shell. cat $m/index which panes exist n=$(cat $m/pane/new) make a pane, take its serial @@ -35,11 +35,11 @@ Below, $m is the mount (/mnt/9p/pardes/<pid> via 9ns --mntgen; PARDES_MOUNT in a Pitfalls, one each: Each open of pane/new makes another pane (ls, stat, find never do); rmdir closes even a dirty one. - addr takes #n,#m or sam-style /regexp/ (^ $ per line); a failed one makes data refuse until reset. + addr takes #n,#m or /regexp/ from the current address (^ $ per line); a failed one refuses data. data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all. - tag reads the path, then the tag's own text; writes append to that text; truncating clears it. + tag reads the path, then its own text; writes append; truncating clears it, default words too. A terminal's body is a history snapshot frozen per open; pty/data is the live stream. - exec: a word no builtin knows is typed into a terminal in the pane's directory, and not an error. - A refused write reads as EINVAL through a mount: the log's err record says why. - lock/unlock on a pane's ctl need a held fd (exec 3>ctl); a lock someone holds fails: retry. - Closing a column's last pane leaves a +New in its place; Delcol closes the column. + exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in. + Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why. + lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane -> +New. + Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands. @@ -1261,11 +1261,11 @@ pub const Namespace = struct { /// ctl answers a command's error (editors/acme/xfid.c:700). no_prompt: bool = false, refused: bool = false, - failure: [96]u8 = undefined, - failure_len: u8 = 0, + failure: [256]u8 = undefined, + failure_len: u16 = 0, /// A refusal that quotes the message it refuses, as Plan 9's cmderror /// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig). - ename: [128]u8 = undefined, + ename: [320]u8 = undefined, /// Something a held read may be waiting on changed since they were last /// answered: a record queued, a run answered, a pane gone. news: bool = false, diff --git a/src/grammar_manifest.zig b/src/grammar_manifest.zig index 0303af17..d6666729 100644 --- a/src/grammar_manifest.zig +++ b/src/grammar_manifest.zig @@ -2,6 +2,8 @@ //! runtime syntax registry. Keep target symbols and query values out of this //! file: the build runner imports it as host code. +const std = @import("std"); + pub const Tier = enum { zig, minimal, full }; pub const Grammar = struct { @@ -49,3 +51,44 @@ pub const all = [_]Grammar{ .{ .name = "typst", .dep = "ts_typst", .exts = &.{ ".typ", ".typst" }, .tier = .full, .scanner = true, .query = "queries/typst/highlights.scm" }, .{ .name = "zig", .dep = "ts_zig", .exts = &.{ ".zig", ".zon" }, .tier = .zig }, }; + +/// The names a language goes by besides its grammar's: a fence's `py`, +/// a `Repl sh`. +pub const aliases = [_]struct { []const u8, []const u8 }{ + .{ "js", "javascript" }, + .{ "jsx", "javascript" }, + .{ "mjs", "javascript" }, + .{ "py", "python" }, + .{ "sh", "bash" }, + .{ "shell", "bash" }, + .{ "zsh", "bash" }, + .{ "bash", "bash" }, + .{ "rs", "rust" }, + .{ "c++", "cpp" }, + .{ "cxx", "cpp" }, + .{ "cc", "cpp" }, + .{ "cs", "c_sharp" }, + .{ "kt", "kotlin" }, + .{ "rb", "ruby" }, + .{ "ml", "ocaml" }, + .{ "hs", "haskell" }, +}; + +/// The grammar a language name means: its own name or an alias, in any case. +pub fn byName(name: []const u8) ?usize { + var canonical = name; + for (aliases) |a| if (std.ascii.eqlIgnoreCase(name, a[0])) { + canonical = a[1]; + break; + }; + for (all, 0..) |g, i| if (std.ascii.eqlIgnoreCase(canonical, g.name)) return i; + return null; +} + +/// The grammar a path's extension selects, in any case. +pub fn byPath(path: []const u8) ?usize { + const ext = std.fs.path.extension(path); + if (ext.len == 0) return null; + for (all, 0..) |g, i| for (g.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) return i; + return null; +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index aa39cf32..5bc1ea6d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -816,8 +816,17 @@ const Pty = struct { kill_at: i64 = 0, reader: ?std.Thread = null, stop: [2]c_int = .{ -1, -1 }, + /// A command pane's child, watched to its exit (host_io.watchExit); + /// the pty stays open until both its end of file and its exit. + cmd: host_io.CommandWatch = .{}, }; +/// A watched child exited: wake the loop, which takes it (`takeExits`). +fn wakeForExit(ctx: ?*anyopaque) void { + const q: *Queue = @ptrCast(@alignCast(ctx.?)); + q.wake(); +} + const RetiredShell = struct { pid: libc.pid_t = 0, kill_at: i64 = 0 }; const Msg = union(enum) { @@ -1298,7 +1307,7 @@ test "GUI PTY Restore joins a real reader waiting for queue space" { try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); try PtyTests.waitBlocked(&queue); const old_serial = core.panes[0].?.serial; - const replacement = try dump.restore(core, core.dump_out.?); + const replacement = try dump.restore(core, core.dump_out.?, "test"); shell.stopPtys(); core.deinit(); core = replacement; @@ -1627,7 +1636,7 @@ test "GUI LSP worker limit rejects before allocation and recovers after owned wo try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "WorkersBusy") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "workers busy") != null); try std.testing.expectEqual(@as(usize, 0), failing.alloc_index); try std.testing.expect(!failing.has_induced_failure); try std.testing.expectEqual(rejected, queue.lsp_id.?); @@ -2617,6 +2626,8 @@ fn localSession( shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); + host_io.setExitWake(&queue, wakeForExit); + defer host_io.setExitWake(null, null); while (!core.quit) { pardes.turn.restoreSettled(); @@ -2642,7 +2653,7 @@ fn localSession( break :blk; }; defer gpa.free(bytes); - const nc = dump.restore(core, bytes) catch |err| { + const nc = dump.restore(core, bytes, rp) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; @@ -2942,6 +2953,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); + host_io.setExitWake(&queue, wakeForExit); + defer host_io.setExitWake(null, null); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort @@ -3999,8 +4012,20 @@ const Shell = struct { break :vt v; }; + /// The command panes' exits, told once their output is in (host_io). + fn takeExits(s: *Shell) void { + host_io.takeExits(s.core, s.ptys, s, closeWatched); + } + + fn closeWatched(s: *Shell, id: usize) void { + s.closePty(@intCast(id)); + s.saw_event = true; + } + fn drainQueue(s: *Shell) void { s.reconcilePtys(); + // After this batch's output, which an exit is told behind. + defer s.takeExits(); var msgs = s.queue.take(); var check_files = false; for (msgs.slice()) |m| switch (m) { @@ -4011,14 +4036,36 @@ const Shell = struct { s.saw_event = true; }, .eof => |e| { - if (s.gens[e.pane] == e.gen) { - s.closePty(e.pane); + if (s.gens[e.pane] == e.gen) eof: { if (e.failure) |err| { + s.closePty(e.pane); if (s.core.panes[e.pane]) |pane| { pane.body.mode = .normal; s.core.reportError(e.pane, "terminal reader", err); } - } else s.core.update(.{ .eof = .{ .pane = e.pane } }); + break :eof; + } + const pt = if (s.ptys[e.pane]) |*pt| pt else { + s.core.update(.{ .eof = .{ .pane = e.pane } }); + break :eof; + }; + // A command's pty stays open until its child has exited: + // closing it would hang up one that runs on without it. + // Its exit, if it came first, is told now its output is in. + if (pt.cmd.watched) { + host_io.commandEof(s.core, s.ptys, e.pane, s, closeWatched); + break :eof; + } + // Unwatched, a command's exit is read here, as its end. + const unwatched = if (s.core.panes[e.pane]) |pane| pane.command != null else false; + var status: ?u8 = null; + if (unwatched) { + status = host_io.exitStatus(pt.pid, 100); + if (status != null) pt.pid = 0; // reaped: no shell to retire + } + s.closePty(e.pane); + if (unwatched) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } }); + s.core.update(.{ .eof = .{ .pane = e.pane } }); } s.saw_event = true; }, @@ -4325,7 +4372,8 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy); s.gens[pane] +%= 1; const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); - const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial }; + const command = s.core.panes[pane].?.command != null; + const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } }; s.ptys[pane] = pt; var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); @@ -4353,7 +4401,8 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| if (pt.fd >= 0) host_io.signalTty(pt.pid, pt.fd, sig); + // A command whose exit is recorded is reaped: its group may be another's. + if (s.ptys[pane]) |pt| if (pt.fd >= 0 and !pt.cmd.exited) host_io.signalTty(pt.pid, pt.fd, sig); } fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { @@ -4491,7 +4540,7 @@ test "the headless grid host round-trips a yank back as a paste" { core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'y' } }); while (core.nextEffect()) |e| core.perform(e); - const yanked = core.yank orelse return error.MissingYank; + const yanked = core.registers.text(core.gpa, '+') orelse return error.MissingYank; try std.testing.expect(yanked.len > 0); const before = pane.file.?.content.len; diff --git a/src/host_io.zig b/src/host_io.zig index f1a4eb98..a019e15d 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -760,8 +760,13 @@ pub const Shell = struct { return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; } - fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { + /// A shell by its path or its name in the usual directories, or null. + /// A path may be under a mount this editor serves: the turn goes out + /// with the lookups (pardes.Turn.yield). + pub fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { if (bin.len == 0 or bin.len + 1 > buf.len) return null; + pardes.turn.yield(); + defer pardes.turn.back(); if (std.mem.indexOfScalar(u8, bin, '/') != null) { @memcpy(buf[0..bin.len], bin); buf[bin.len] = 0; @@ -966,6 +971,9 @@ test "the child environment replaces the launcher's terminal identity exactly on extern "c" fn unsetenv(name: [*:0]const u8) c_int; +/// The longest line a command pane runs (exec.zig refuses a longer one). +pub const exec_command_max = pardes.exec.command_max; + pub fn forkShell( core: ?*pardes.Pardes, pane: usize, @@ -979,6 +987,7 @@ pub fn forkShell( const native_cwd = filesystem.localPath(cwd) orelse cwd; if (std.mem.indexOfScalar(u8, native_cwd, 0) != null) return error.InvalidPath; var cwd_buf: [4096]u8 = undefined; + const serial = if (core) |c| if (c.panes[pane]) |pn| pn.serial else 0 else 0; const cwd_z: ?[:0]const u8 = if (native_cwd.len == 0) null else dir: { const path = std.fmt.bufPrintSentinel(&cwd_buf, "{s}", .{native_cwd}, 0) catch return error.NameTooLong; // A shell's directory may be inside a mount this editor serves. @@ -990,9 +999,32 @@ pub fn forkShell( }; var master: c_int = -1; var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const spawn = Shell.resolve(bin, &path_buf, prompt_rcs); + // A terminal opened on a shell of its own (`Tty fish`) runs that one, + // copied: looking it up gives the turn up, and the pane may go. + var own_buf: [256]u8 = undefined; + var own: ?[]const u8 = null; + if (core) |c| if (c.panes[pane]) |pn| if (pn.shell) |name| { + const n = @min(name.len, own_buf.len); + @memcpy(own_buf[0..n], name[0..n]); + own = own_buf[0..n]; + }; + var spawn = Shell.resolve(own orelse bin, &path_buf, prompt_rcs); + // The turn was given up for the stat and the lookup: a 9P client may + // have closed the pane, and another taken its slot, whose command this + // is not. + if (core) |c| if ((if (c.panes[pane]) |pn| pn.serial else 0) != serial) return error.PaneGone; + // A command pane's child is the shell running its one line, which has + // no prompt to mark (exec.zig runCommand). + const one_line = if (core) |c| if (c.panes[pane]) |pn| pn.command else null else null; + var command_buf: [exec_command_max + 1]u8 = undefined; + if (one_line) |line| { + if (line.len > exec_command_max or std.mem.indexOfScalar(u8, line, 0) != null) return error.InvalidCommand; + @memcpy(command_buf[0..line.len], line); + command_buf[line.len] = 0; + spawn.argv = .{ spawn.path, "-c", command_buf[0..line.len :0].ptr, null }; + } var helper_buf: [4096]u8 = undefined; - const mounted = if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false; + const mounted = one_line == null and if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false; const helper: ?[:0]u8 = if (mounted) blk: { if (comptime builtin.os.tag != .linux) return error.LinuxRequired; if (fs == null) return error.SessionSocketRequired; @@ -1033,7 +1065,7 @@ pub fn forkShell( _exit(127); } ninep_io.setCloexec(master); - if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); + if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), one_line == null and spawn.argv[1] != null); return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } @@ -1366,6 +1398,185 @@ pub fn retireShell(pid: libc.pid_t) void { thread.detach(); } +// ---- a command pane's child, watched to its exit ---- +// +// A command pane's command is done when its process exits, not when its pty +// reaches end of file: a job it put in the background can hold the pty open +// after it has gone, and a command can close its terminal and run on. A +// thread per command waits for the exit without reaping (WNOWAIT), so the +// pid is still the child's -- no Kill can reach another process that took +// it -- until the host, told through `takeExited`, reaps it in its own loop +// with `exitStatus` and tells the core. + +const waitid_p_pid: c_int = 1; +const waitid_wexited: c_int = 4; +const waitid_wnowait: c_int = if (builtin.os.tag == .linux) 0x01000000 else 0x20; +extern "c" fn waitid(idtype: c_int, id: c_uint, infop: *anyopaque, options: c_int) c_int; + +var exited: [pardes.MAX_PANES * 2]std.atomic.Value(libc.pid_t) = @splat(.init(0)); +var exit_lock: libc.pthread_mutex_t = .{}; +var exit_wake: ?struct { ctx: ?*anyopaque, f: *const fn (?*anyopaque) void } = null; + +/// Who wakes the host's loop when a watched child exits; null at shutdown, +/// after which a watcher still waiting tells nobody. +pub fn setExitWake(ctx: ?*anyopaque, f: ?*const fn (?*anyopaque) void) void { + _ = libc.pthread_mutex_lock(&exit_lock); + defer _ = libc.pthread_mutex_unlock(&exit_lock); + exit_wake = if (f) |wake| .{ .ctx = ctx, .f = wake } else null; +} + +/// Watches a command pane's child; false when no thread could be started, +/// and the host then reads its exit at end of file instead. +pub fn watchExit(pid: libc.pid_t) bool { + const thread = std.Thread.spawn(.{}, watchChild, .{pid}) catch return false; + thread.detach(); + return true; +} + +fn watchChild(pid: libc.pid_t) void { + var info: [128]u8 align(16) = undefined; + while (waitid(waitid_p_pid, @intCast(pid), &info, waitid_wexited | waitid_wnowait) < 0 and libc.errno(-1) == .INTR) {} + // A cell for it, waiting for one if the host is slow to take them. + place: while (true) { + for (&exited) |*cell| if (cell.cmpxchgStrong(0, pid, .acq_rel, .acquire) == null) break :place; + const ts: libc.timespec = .{ .sec = 0, .nsec = 5 * std.time.ns_per_ms }; + _ = libc.nanosleep(&ts, null); + } + wakeForExit(); +} + +fn wakeForExit() void { + _ = libc.pthread_mutex_lock(&exit_lock); + defer _ = libc.pthread_mutex_unlock(&exit_lock); + if (exit_wake) |wake| wake.f(wake.ctx); +} + +/// Where a host's command pane is between its child's exit and the end of +/// its output. The exit is told once the output before it is in: at the +/// pty's end of file; or, when something else still holds the pty (a job +/// the command left in the background), once nothing is waiting to be read +/// -- checked when the exit is seen and again after each chunk of output, +/// with no timer to miss. acme's waitthread tells an exit when it is +/// reaped (acme.c:587-700); a terminal also has output in flight. +pub const CommandWatch = struct { + watched: bool = false, + /// The master, to ask whether output is still waiting. + fd: c_int = -1, + eof: bool = false, + exited: bool = false, + told: bool = false, + status: ?u8 = null, + + /// Whether the exit is to be told now. + pub fn due(w: *const CommandWatch) bool { + if (!w.exited or w.told) return false; + if (w.eof) return true; + var pfd = [1]libc.pollfd{.{ .fd = w.fd, .events = libc.POLL.IN, .revents = 0 }}; + if (libc.poll(&pfd, 1, 0) < 0) return true; + // Every holder of its terminal gone: the end of file comes, after + // the rest of the output. + if (pfd[0].revents & libc.POLL.HUP != 0) return false; + return pfd[0].revents & libc.POLL.IN == 0; + } +}; + +/// A host's pty slot, whichever way it keeps it: an optional, or one whose +/// `fd` is -1 when empty. +fn SlotPty(comptime S: type) type { + return switch (@typeInfo(S)) { + .optional => |o| *o.child, + else => *S, + }; +} + +fn slotPty(slot: anytype) ?SlotPty(@TypeOf(slot.*)) { + return switch (@typeInfo(@TypeOf(slot.*))) { + .optional => if (slot.*) |*pt| pt else null, + else => if (slot.fd < 0) null else slot, + }; +} + +/// Each watched child that exited: reaped, and the core told once its +/// output is in, its pty closed (`close(ctx, id)`) if its end of file came +/// first. Every host runs this after an exit's wake and after each chunk +/// of a command's output; `ptys` is its slot array, each slot with a `pid` +/// and a `cmd`. +pub fn takeExits(core: *pardes.Pardes, ptys: anytype, ctx: anytype, comptime close: anytype) void { + while (takeExited()) |pid| for (ptys) |*slot| { + const pt = slotPty(slot) orelse continue; + if (!pt.cmd.watched or pt.pid != pid or pt.cmd.exited) continue; + pt.cmd.status = (reapExited(pid) orelse break).status; + pt.cmd.exited = true; + pt.pid = 0; // reaped: no signal or retire may reach whoever gets it next + break; + }; + for (ptys, 0..) |*slot, id| { + const pt = slotPty(slot) orelse continue; + if (!pt.cmd.watched or !pt.cmd.due()) continue; + pt.cmd.told = true; + const eof = pt.cmd.eof; + core.update(.{ .exited = .{ .pane = @intCast(id), .status = pt.cmd.status } }); + if (eof) close(ctx, id); + } +} + +/// A watched command's pty reached end of file: the core hears its output +/// is over; the pty is closed now if its exit was told, else when it is. +pub fn commandEof(core: *pardes.Pardes, ptys: anytype, id: usize, ctx: anytype, comptime close: anytype) void { + const pt = slotPty(&ptys[id]) orelse return; + pt.cmd.eof = true; + core.update(.{ .eof = .{ .pane = @intCast(id) } }); + if (pt.cmd.told) return close(ctx, id); + takeExits(core, ptys, ctx, close); +} + +/// Reaps `pid` if it has exited: its status (null: unknown, or not ours +/// to read), or null when it is still running -- a pid a watcher reported +/// may since have been reaped by another and taken by a new child. +pub fn reapExited(pid: libc.pid_t) ?struct { status: ?u8 } { + if (pid <= 0) return .{ .status = null }; + var status: c_int = 0; + const got = libc.waitpid(pid, &status, libc.W.NOHANG); + if (got == 0) return null; + if (got != pid) return .{ .status = null }; + const s: u32 = @bitCast(status); + if (libc.W.IFEXITED(s)) return .{ .status = @truncate(libc.W.EXITSTATUS(s)) }; + if (libc.W.IFSIGNALED(s)) return .{ .status = @truncate(128 + @as(u32, @intFromEnum(libc.W.TERMSIG(s)))) }; + return .{ .status = null }; +} + +/// A watched child that has exited, not yet reaped, or null. +pub fn takeExited() ?libc.pid_t { + for (&exited) |*cell| { + const pid = cell.swap(0, .acq_rel); + if (pid != 0) return pid; + } + return null; +} + +/// A pane's child at its pty's end of file: reaped, and how it exited +/// (its code, or 128 and the signal that ended it), if it is gone within +/// `wait_ms`; null if it is still running (a job it left holds nothing +/// open, but the child itself may be a moment behind its pty), which the +/// caller then retires as it would any shell. +pub fn exitStatus(pid: libc.pid_t, wait_ms: u32) ?u8 { + if (pid <= 0) return null; + var waited: u32 = 0; + while (true) : (waited += 2) { + var status: c_int = 0; + const got = libc.waitpid(pid, &status, libc.W.NOHANG); + if (got == pid) { + const s: u32 = @bitCast(status); + if (libc.W.IFEXITED(s)) return @truncate(libc.W.EXITSTATUS(s)); + if (libc.W.IFSIGNALED(s)) return @truncate(128 + @as(u32, @intFromEnum(libc.W.TERMSIG(s)))); + return null; + } + if (got < 0 or waited >= wait_ms) return null; + const ts: libc.timespec = .{ .sec = 0, .nsec = 2 * std.time.ns_per_ms }; + _ = libc.nanosleep(&ts, null); + } +} + fn reapShell(pid: libc.pid_t, grace_ms: u32) void { var waited: u32 = 0; while (waited < grace_ms) : (waited += 5) { @@ -1669,7 +1880,7 @@ fn haveFile(path: [*:0]const u8) bool { return true; } -fn nowMs() i64 { +pub fn nowMs() i64 { var ts: libc.timespec = undefined; _ = libc.clock_gettime(.MONOTONIC, &ts); return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); @@ -1728,6 +1939,61 @@ test "Kill stops the foreground job, never the shell, and finds none with job co try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG)); } +test "a command pane's child is its shell running the one line, and its exit status is read at the end" { + if (comptime !tty_probe_platform) return error.SkipZigTest; + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const id = core.freeSlot().?; + _ = try core.newCommand(id, "", "printf 'said '; exit 7"); + while (core.nextEffect()) |_| {} + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(core, id, &rcs, "/bin/sh", "", 24, 80, null); + defer _ = libc.close(child.file.handle); + var got: [256]u8 = undefined; + var n: usize = 0; + while (n < got.len) { + const r = libc.read(child.file.handle, got[n..].ptr, got.len - n); + if (r <= 0) break; // EIO once the child is gone: the pty's end + n += @intCast(r); + } + try std.testing.expect(std.mem.indexOf(u8, got[0..n], "said") != null); + try std.testing.expectEqual(@as(?u8, 7), exitStatus(child.pid, 2000)); +} + +test "a command's exit is its process's, told by its watcher, whether or not its pty has ended" { + if (comptime !tty_probe_platform) return error.SkipZigTest; + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const rcs: Shell.PromptFiles = .{}; + const Case = struct { line: []const u8, status: u8 }; + // A job left in the background holds the pty open past the command's + // exit; a command that lets go of its terminal runs on past its end of + // file. Either way the exit is the process's. + for ([_]Case{ + .{ .line = "sleep 3 & exit 4", .status = 4 }, + .{ .line = "exec </dev/null >/dev/null 2>&1; sleep 0.3; exit 5", .status = 5 }, + }) |c| { + const id = core.freeSlot().?; + _ = try core.newCommand(id, "", c.line); + while (core.nextEffect()) |_| {} + const child = try forkShell(core, id, &rcs, "/bin/sh", "", 24, 80, null); + defer _ = libc.close(child.file.handle); + try std.testing.expect(watchExit(child.pid)); + var got: ?libc.pid_t = null; + var waited: i64 = 0; + while (got == null and waited < 5000) : (waited += 10) { + got = takeExited(); + if (got == null) sleepMs(10); + } + try std.testing.expectEqual(@as(?libc.pid_t, child.pid), got); + // Told before it is reaped: the pid is still the child's. + try std.testing.expectEqual(@as(?u8, c.status), reapExited(child.pid).?.status); + try core.removePane(id, null); + } +} + test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" { const pid = libc.fork(); if (pid == 0) { diff --git a/src/layout.zig b/src/layout.zig index b642c52c..f0e6fd16 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -297,7 +297,7 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } } @@ -321,7 +321,7 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } @@ -350,7 +350,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { if (needs_rebase) old_weight *= 2; if (id == source_id) { if (p.col_n[source_col] <= 1) return false; - absorbVWeight(p, id, null); + _ = absorbVWeight(p, id, null); removePane(p, id); } else if (findPane(p, id) != null) return false; @@ -371,7 +371,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; - p.col_tags[c] = .{ .what = .column }; + p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; p.col_panes[c][0] = id; p.col_n[c] = 1; @@ -391,9 +391,11 @@ pub fn snapColWeights(p: *Pardes, c: usize) void { /// Hand a closing pane's rows to `to`. With none named they go up: past any /// results listing to the nearest pane above that is not one, or to the /// pane below when the closing one is first in its column. -pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) void { - const f = findPane(p, id) orelse return; - if (p.col_n[f.col] <= 1) return; +/// Give a closing pane's rows to `to`, or to the nearest document above it +/// (the pane below for the top one); the pane that got them is returned. +pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) ?usize { + const f = findPane(p, id) orelse return null; + if (p.col_n[f.col] <= 1) return null; snapColWeights(p, f.col); var sib = to orelse if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1]; var k = f.idx; @@ -401,7 +403,9 @@ pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) void { sib = p.col_panes[f.col][k - 1]; if (p.panes[sib]) |pp| if (if (pp.file) |ff| panes.Output.fileTraits(ff.output).doc else true) break; } - if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); + const s = p.panes[sib] orelse return null; + s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); + return sib; } pub fn splitParent(p: *Pardes, want: usize) usize { diff --git a/src/look.zig b/src/look.zig index 258cf8f5..2e27a001 100644 --- a/src/look.zig +++ b/src/look.zig @@ -503,7 +503,11 @@ const SearchStart = enum { top, cursor }; pub fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - if (normal.selRegexArmed(pane)) |a| return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| { + // helix keeps the pattern in `/` + if (a.pat.len > 0) p.registers.put(p.gpa, '/', a.pat, 0, 1, true); + return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); + } const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); @@ -1013,7 +1017,7 @@ pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void { pane.body.nsel = 0; pane.body.select = false; pane.body.normal.clear(); - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot); pane.sel = @splat(.{}); @@ -1163,6 +1167,13 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { pane.search_row = null; _ = searchStep(p, id, 1); } + // A path that is not there searches for its text, and a search + // that found nothing says so rather than leave an empty buffer. + const results = if (pane.search_pane) |rid| p.panes[rid] else null; + if (results) |r| if (r.file) |f| if (f.content.len == 0) { + var said: [96]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&said, "Look: no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}) catch "Look: no match"); + }; }, .pane => |t| { if (t.id >= MAX_PANES) return; diff --git a/src/macos.zig b/src/macos.zig index 6cd7838f..b472e6cc 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -125,8 +125,30 @@ const Pty = struct { pid: posix.pid_t, gen: u32, reader: std.Io.Future(anyerror!void), + /// A command pane's child, watched to its exit (host_io.watchExit); + /// the pty stays open until both its end of file and its exit. + cmd: host_io.CommandWatch = .{}, }; +/// A watched child exited: wake the host, which takes it (`takeExits`). +/// Called on a watcher thread holding host_io's exit lock, which +/// pardes_deinit takes to clear the wake: safe only because the runtime's +/// wakeup never waits for the main thread (AppDelegate's is a +/// DispatchQueue.main.async); one that did would deadlock the quit. +fn wakeForExit(ctx: ?*anyopaque) void { + const st: *State = @ptrCast(@alignCast(ctx orelse return)); + wake(st); +} + +/// The command panes' exits, told once their output is in (host_io). +fn takeExits(st: *State) void { + host_io.takeExits(st.core, &st.ptys, st, closeWatched); +} + +fn closeWatched(st: *State, id: usize) void { + reap(st, @intCast(id)); +} + const WatchedFile = struct { path: []u8, serial: u32, @@ -782,6 +804,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); pardes.lsp.setStatusSink(st, lspStatusSink); + host_io.setExitWake(st, wakeForExit); // From here the core is touched only inside the entry points above, // each of which takes the turn for its own duration; between them the // 9P connections have it. @@ -801,6 +824,7 @@ export fn pardes_deinit() void { st.ninep = null; } pardes.lsp.setStatusSink(null, null); + host_io.setExitWake(null, null); if (st.lsp_task) |*t| { t.future.cancel(st.io) catch {}; st.lsp_task = null; @@ -1268,6 +1292,8 @@ export fn pardes_watch_changed(pane: u8, generation: u32) void { fn drainInbox(st: *State) bool { var batch = st.inbox.take(st.io); + // After this batch's output, which an exit is told behind. + defer takeExits(st); var did = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); @@ -1279,6 +1305,24 @@ fn drainInbox(st: *State) bool { }, .eof => |e| { if (st.gens[e.pane] != e.gen) continue; + if (st.ptys[e.pane]) |*pt| { + // A command's pty stays open until its child has exited: + // closing it would hang up one that runs on without it. + // Its exit, if it came first, is told now its output is in. + if (pt.cmd.watched) { + host_io.commandEof(st.core, &st.ptys, e.pane, st, closeWatched); + continue; + } + // Unwatched, a command's exit is read here, as its end. + if (st.core.panes[e.pane]) |pane| if (pane.command != null) { + const status = host_io.exitStatus(pt.pid, 100); + if (status != null) pt.pid = 0; // reaped: no shell to retire + reap(st, e.pane); + st.core.update(.{ .exited = .{ .pane = e.pane, .status = status } }); + st.core.update(.{ .eof = .{ .pane = e.pane } }); + continue; + }; + } reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, @@ -1344,7 +1388,7 @@ fn restoreCore(st: *State) bool { return false; }; defer st.gpa.free(bytes); - const replacement = dump.restore(st.core, bytes) catch |err| { + const replacement = dump.restore(st.core, bytes, path) catch |err| { st.core.reportError(st.core.active, "Restore", err); return false; }; @@ -2403,6 +2447,7 @@ fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { .pid = child.pid, .gen = gen, .reader = .{ .any_future = null, .result = {} }, + .cmd = .{ .watched = (if (core.panes[pane]) |pn| pn.command != null else false) and host_io.watchExit(child.pid), .fd = child.file.handle }, }; var lbuf: [1024]u8 = undefined; if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); @@ -2423,7 +2468,8 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const st = hostState(ctx); - if (st.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); + // A command whose exit is recorded is reaped: its group may be another's. + if (st.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig); } fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { diff --git a/src/memory.zig b/src/memory.zig index 7b6ee2c0..abfd40f6 100644 --- a/src/memory.zig +++ b/src/memory.zig @@ -17,6 +17,9 @@ pub const limits = struct { pub const wrap_rows = if (board) 128 else 256; pub const undo_max = if (board) 16 else 256; pub const message_log = if (board) 16 else 128; + /// ranges one selection holds; helix has no limit, and a `%s` over a + /// file's words wants hundreds + pub const selections = if (board) 64 else 1024; /// /log's ring, which records whether or not anyone reads it. pub const log_bytes: usize = if (board) 4 * KiB else 64 * KiB; pub const max_tag_tail: usize = if (board) 512 else 4096; @@ -187,6 +190,7 @@ test "memory limits preserve desktop capacities" { try std.testing.expectEqual(256, limits.wrap_rows); try std.testing.expectEqual(256, limits.undo_max); try std.testing.expectEqual(128, limits.message_log); + try std.testing.expectEqual(1024, limits.selections); try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail); try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap); try std.testing.expectEqual(config.embed_sources, limits.embedded_sources); diff --git a/src/modal.zig b/src/modal.zig index 3827d11f..cc7f1ebd 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -15,6 +15,8 @@ pub const Normal = struct { prefix_replace, prefix_next, prefix_prev, + prefix_register, + prefix_view_sticky, goto_file_start, goto_last_line, @@ -27,6 +29,8 @@ pub const Normal = struct { goto_view_top, goto_view_center, goto_view_bottom, + goto_last_modification, + goto_word, goto_definition, goto_declaration, goto_type_definition, @@ -36,6 +40,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + view_middle, view_scroll_down, view_scroll_up, @@ -49,6 +54,13 @@ pub const Normal = struct { add_newline, goto_diagnostic, goto_diagnostic_end, + goto_function, + goto_class, + goto_parameter, + goto_comment, + goto_test, + goto_entry, + goto_xml_element, move_left, move_right, @@ -96,6 +108,17 @@ pub const Normal = struct { trim_sels, select_regex, split_regex, + keep_regex, + remove_regex, + ensure_forward, + expand_node, + shrink_node, + next_sibling, + prev_sibling, + all_siblings, + all_children, + parent_node_end, + parent_node_start, delete, delete_noyank, @@ -108,6 +131,10 @@ pub const Normal = struct { to_lowercase, to_uppercase, join_lines, + join_select_space, + rotate_contents_fwd, + rotate_contents_back, + align_selections, indent, unindent, format, @@ -123,6 +150,10 @@ pub const Normal = struct { pipe_selection_to, insert_output, append_output, + keep_pipe, + macro_record, + macro_replay, + repeat_insert, search, search_next, search_prev, @@ -156,6 +187,7 @@ pub const Normal = struct { replace, next, prev, + register, }; pub const MatchSub = enum(u8) { @@ -172,9 +204,14 @@ pub const Normal = struct { prefix: Prefix = .none, match_sub: MatchSub = .none, held_char: u21 = 0, + /// `"<reg>`: the register the next command names, 0 for none + register: u21 = 0, + /// `Z`: the view keys stay armed until Esc (helix's sticky view) + sticky: bool = false, + /// Drop what is pending; sticky view mode stays armed through it. pub fn clear(state: *State) void { - state.* = .{}; + state.* = if (state.sticky) .{ .sticky = true, .prefix = .view } else .{}; } }; @@ -188,13 +225,22 @@ pub const Normal = struct { insert, /// `A-!` — no stdin, and the output is appended AFTER each selection. append, + /// `$` — stdin is the selection; the output is discarded, and only the + /// selections the command exits 0 on are kept. + keep, /// Do the selections become stdin? helix's `pipe` flag. pub fn pipes(b: PipeBehavior) bool { - return b == .replace or b == .ignore; + return b == .replace or b == .ignore or b == .keep; } }; + /// helix's tree-sitter selection keys: where a range goes in the tree + /// helix's tree-sitter textobjects, by what their queries call them + pub const Object = enum { function, class, parameter, comment, @"test", entry, xml_element }; + pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start }; + /// what the s/S/K/Alt-K prompt does with its pattern's matches + pub const RegexMode = enum { select, split, keep, remove }; pub const Scope = enum { once, per_selection }; pub const Direction = enum { backward, forward }; pub const Motion = enum { @@ -221,8 +267,9 @@ pub const Normal = struct { view_top, view_center, view_bottom, + last_modification, }; - pub const View = enum { top, center, bottom, scroll_down, scroll_up }; + pub const View = enum { top, center, bottom, middle, scroll_down, scroll_up }; pub const Find = enum { forward, backward, till_forward, till_backward }; pub const Line = enum { start, end, first_nonws }; pub const Page = enum { half_down, half_up, down, up }; @@ -247,6 +294,7 @@ pub const Normal = struct { merge, merge_consecutive, trim, + ensure_forward, }; pub const Edit = enum { delete, @@ -260,6 +308,10 @@ pub const Normal = struct { lowercase, uppercase, join_lines, + join_select_space, + rotate_contents_forward, + rotate_contents_backward, + align_selections, indent, unindent, comment_toggle, @@ -295,7 +347,7 @@ pub const Normal = struct { insert: struct { kind: Insert, count: u32 }, select: struct { kind: Select, count: u32 }, multi: struct { kind: Multi, count: u32 }, - select_regex: bool, // false = select, true = split + select_regex: RegexMode, edit: struct { kind: Edit, count: u32 }, lsp: Lsp, adjust_number: i64, @@ -304,6 +356,12 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + goto_word, + macro_record, + macro_replay: u32, + node: NodeWalk, + object_jump: struct { object: Object, direction: Direction, count: u32 }, + repeat_insert: u32, pub fn scope(value: Action) Scope { return switch (value) { @@ -315,9 +373,22 @@ pub const Normal = struct { .pipe_selection, .search, .search_step, + .macro_record, + .macro_replay, + .node, + .goto_word, + .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { - .comment_toggle, .undo, .redo => .once, + .join_lines, + .join_select_space, + .rotate_contents_forward, + .rotate_contents_backward, + .align_selections, + .comment_toggle, + .undo, + .redo, + => .once, else => .per_selection, }, else => .per_selection, @@ -354,7 +425,10 @@ pub const Normal = struct { pub fn parse(state: *State, key: Input) Result { if (key.has(.escape)) { - state.clear(); + // Esc out of sticky view mode leaves that mode and nothing else + const sticky = state.sticky; + state.* = .{}; + if (sticky) return .ignored; return resultAction(.escape); } @@ -386,6 +460,8 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_word)) return resultAction(.goto_word); + if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); @@ -394,8 +470,9 @@ pub const Normal = struct { return .ignored; }, .view => { - state.prefix = .none; + if (!state.sticky) state.prefix = .none; if (key.has(.view_top)) return resultAction(.{ .view = .top }); + if (key.has(.view_middle)) return resultAction(.{ .view = .middle }); if (key.has(.view_center)) return resultAction(.{ .view = .center }); if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); @@ -473,14 +550,28 @@ pub const Normal = struct { if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + inline for (.{ .{ .goto_function, .function }, .{ .goto_class, .class }, .{ .goto_parameter, .parameter }, .{ .goto_comment, .comment }, .{ .goto_test, .@"test" }, .{ .goto_entry, .entry }, .{ .goto_xml_element, .xml_element } }) |pair| + if (key.has(pair[0])) return resultAction(.{ .object_jump = .{ .object = pair[1], .direction = direction, .count = count } }); return .ignored; }, + .register => { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + state.register = char; + // the count typed before `"` is still the command's + state.count = if (counted.explicit) count else 0; + return .pending; + }, .none => {}, } // Prefix setters retain the count for their continuation. if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); + if (key.has(.prefix_view_sticky)) { + state.sticky = true; + return armPrefix(state, .view, 0); + } if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); @@ -489,6 +580,7 @@ pub const Normal = struct { if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); if (key.has(.prefix_match)) return armPrefix(state, .match, 0); + if (key.has(.prefix_register)) return armPrefix(state, .register, if (counted.explicit) count else 0); if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); @@ -535,8 +627,19 @@ pub const Normal = struct { if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); - if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); - if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); + if (key.has(.select_regex)) return resultAction(.{ .select_regex = .select }); + if (key.has(.split_regex)) return resultAction(.{ .select_regex = .split }); + if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep }); + if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove }); + if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } }); + if (key.has(.expand_node)) return resultAction(.{ .node = .expand }); + if (key.has(.shrink_node)) return resultAction(.{ .node = .shrink }); + if (key.has(.next_sibling)) return resultAction(.{ .node = .next_sibling }); + if (key.has(.prev_sibling)) return resultAction(.{ .node = .prev_sibling }); + if (key.has(.all_siblings)) return resultAction(.{ .node = .all_siblings }); + if (key.has(.all_children)) return resultAction(.{ .node = .all_children }); + if (key.has(.parent_node_end)) return resultAction(.{ .node = .parent_end }); + if (key.has(.parent_node_start)) return resultAction(.{ .node = .parent_start }); if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); @@ -549,6 +652,10 @@ pub const Normal = struct { if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); + if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); + if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } }); + if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } }); + if (key.has(.align_selections)) return resultAction(.{ .edit = .{ .kind = .align_selections, .count = count } }); if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); if (key.has(.format)) return resultAction(.{ .lsp = .format }); @@ -564,6 +671,10 @@ pub const Normal = struct { if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); + if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); + if (key.has(.macro_record)) return resultAction(.macro_record); + if (key.has(.macro_replay)) return resultAction(.{ .macro_replay = count }); + if (key.has(.repeat_insert)) return resultAction(.{ .repeat_insert = count }); if (key.has(.search)) return resultAction(.search); if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); @@ -1287,6 +1398,53 @@ fn wmIsPrev(t: WordTarget) bool { } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. +/// helix jump_to_word's candidates: the words of two or more word +/// characters between `start` and `end`, nearest the cursor first, taking one +/// forward and one back in turn, each over its word pointing the way it was +/// found, as many as `out` holds. +pub fn jumpWords(text: []const u8, cursor: usize, start: usize, end: usize, out: []Selection) usize { + var fwd: Selection = .{ .anchor = cursor, .head = cursor }; + var rev = fwd; + if (cursor < text.len and !isWordWhitespace(wordClassAt(text, cursor))) { + const word_end = moveWord(text, fwd, 1, .next_word_end); + if (word_end.anchor == cursor) fwd = word_end; // a word of one grapheme + const word_start = moveWord(text, rev, 1, .prev_word_start); + if (word_start.anchor == nextGrapheme(text, cursor)) rev = word_start; + } + var n: usize = 0; + while (n < out.len) { + var changed = false; + while (fwd.head < end) { + fwd = moveWord(text, fwd, 1, .next_word_end); + // the two graphemes before the head are word characters + const one = prevGrapheme(text, fwd.head); + if (fwd.head == 0 or one == 0 or wordClassAt(text, one) != .word or wordClassAt(text, prevGrapheme(text, one)) != .word) continue; + changed = true; + var from = fwd.anchor; + while (from < fwd.head and wordClassAt(text, from) != .word) from = nextGrapheme(text, from); + fwd.anchor = from; + out[n] = fwd; + n += 1; + break; + } + if (n == out.len) break; + while (rev.head > start) { + rev = moveWord(text, rev, 1, .prev_word_start); + // the two graphemes from the head are word characters + if (wordClassAt(text, rev.head) != .word or wordClassAt(text, nextGrapheme(text, rev.head)) != .word) continue; + changed = true; + var to = rev.anchor; + while (to > rev.head and wordClassAt(text, prevGrapheme(text, to)) != .word) to = prevGrapheme(text, to); + rev.anchor = to; + out[n] = rev; + n += 1; + break; + } + if (!changed) break; + } + return n; +} + pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection { const is_prev = wmIsPrev(target); if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; diff --git a/src/mouse.zig b/src/mouse.zig index 5ab6fe2b..22eb3270 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -192,7 +192,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { else => false, }) for (p.panes) |slot| { const pane = slot orelse continue; - if (pane.prompt == .del_side) pane.prompt = .none; + if (pane.prompt == .del_side or pane.prompt == .repl_choice) pane.prompt = .none; }; if (m.button == .back or m.button == .forward) { if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1); @@ -717,12 +717,12 @@ fn dispatchPointerBuiltin( const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); - const operand = text orelse { - if (gesture) |g| _ = reportGesture(p, id, cmd, "", g.on_tag, g.operand, arg != null); - return; - }; + // A click that takes no text does nothing, and tells an event reader + // nothing, as acme's look3 and execute return on an empty expansion. + const operand = text orelse return; if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return; - exec.runBuiltin(p, cmd, id, "", exec.withArg(p, operand, arg)); + // A middle click on a body is the exec a bound REPL may take. + exec.runBuiltinFrom(p, cmd, id, "", exec.withArg(p, operand, arg), gesture != null and !gesture.?.on_tag); } fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { @@ -747,7 +747,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { pane.body.vsel.active = false; pane.body.nsel = 0; pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); edit.setYank(p, text); @@ -859,7 +859,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { pane.body.vsel.active = false; pane.body.nsel = 0; // a click says WHERE the one cursor is pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } @@ -963,8 +963,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { if (cut) { if (t.vsel.active) edit.normalDelete(p, t, true); } else if (t.vsel.active) { - edit.normalReplaceYank(p, t); - } else edit.pasteText(p, t, p.yank orelse return, true, 1); + edit.chordReplace(p, t); + } else edit.pasteText(p, t, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1); return; } if (pane.body.mode == .tty) { @@ -973,7 +973,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { pane.sel[sel_slot].state = .none; // drop the sweep highlight } if (cut) return; - const y = p.yank orelse return; + const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; if (panes.Terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); @@ -1016,11 +1016,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { if (cut) { if (pane.body.vsel.active) edit.normalDelete(p, &pane.body, true); } else if (pane.body.vsel.active) { - edit.normalReplaceYank(p, &pane.body); + edit.chordReplace(p, &pane.body); } else if (pane.file) |*f| { // bare cursor: splice the register in literally (no linewise // magic) so a paste right after a cut restores the text exactly - const y = p.yank orelse return; + const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; const at = modal.Cursor{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }; edit.pushUndo(p, &pane.body); @@ -1039,7 +1039,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { pane.body.cur_pinned = true; pane.body.sticky_col = -1; pane.ensureCursorVisible(); - } else edit.pasteText(p, &pane.body, p.yank orelse return, true, 1); + } else edit.pasteText(p, &pane.body, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1); } test "plain left click clears explicit modal selection" { diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index cc7ca632..0537c17a 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -14,8 +14,11 @@ fn clip(n: usize) u32 { pub const e_no_match = "no match for regexp"; pub const e_range = "address out of range"; pub const e_regexp = "bad regular expression"; -pub const e_slow = "regular expression search took too long"; +/// Not "took too long": 9ns reads errors by their words, and that would be +/// ENAMETOOLONG. +pub const e_slow = "regular expression search gave up, backtracking past its step budget"; pub const e_syntax = "bad address syntax"; +pub const e_order = "addresses out of order"; pub const Addr = struct { text: []const u8, @@ -93,7 +96,9 @@ pub const Addr = struct { size = .line; }, '/', '?' => { - const back = c == '?'; + // `-/re/` searches back as `?re?` does (acme's address() + // takes the direction a `-` set, addr.c). + const back = c == '?' or dir == '-'; r = a.regexp(r, a.pattern(c), back) orelse return null; dir = 0; size = .line; @@ -243,6 +248,44 @@ const Node = tree.Node; const E = tree.E; const Status = tree.Status; +test "an address is evaluated from the current one, as acme's are" { + const p = try th.withFile(testing.allocator, "a foo b foo c foo\n"); + defer p.deinit(); + const addr = Node.of(th.serialOf(p), .addr); + const Step = struct { expr: []const u8, q0: u32, q1: u32 }; + _ = th.wr(p, addr, "#0"); + for ([_]Step{ + // each search starts where the last address ended + .{ .expr = "/foo/", .q0 = 2, .q1 = 5 }, + .{ .expr = "/foo/", .q0 = 8, .q1 = 11 }, + // `.` is that address, and `-` before a pattern searches back + .{ .expr = ".", .q0 = 8, .q1 = 11 }, + .{ .expr = "-/foo/", .q0 = 2, .q1 = 5 }, + .{ .expr = "./foo/", .q0 = 8, .q1 = 11 }, + .{ .expr = "+/foo/", .q0 = 14, .q1 = 17 }, + // a number then a pattern: the search starts at the number + .{ .expr = "#9/foo/", .q0 = 14, .q1 = 17 }, + }) |c| { + try testing.expectEqual(Status.ok, th.wr(p, addr, c.expr).reply.status); + try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0); + try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1); + } + // A range whose end comes before its start is sam's error. + try testing.expectEqualStrings(e_order, th.wr(p, addr, "#10,#5").reply.ename); + + // `;` evaluates its right side from its left (acme's addr.c:170-171, + // `ar = r`), `,` from the address the whole started at. + const q = try th.withFile(testing.allocator, "b a b\n"); + defer q.deinit(); + const qaddr = Node.of(th.serialOf(q), .addr); + _ = th.wr(q, qaddr, "#0"); + try testing.expectEqual(Status.ok, th.wr(q, qaddr, "/a/;/b/").reply.status); + try testing.expectEqual(@as(u32, 2), q.panes[0].?.fs.addr.q0); + try testing.expectEqual(@as(u32, 5), q.panes[0].?.fs.addr.q1); + _ = th.wr(q, qaddr, "#0"); + try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename); +} + test "regular expressions search lines as sam's do, and a search wraps" { const p = try th.withFile(testing.allocator, "alpha beta\nbeta gamma\ngamma\n"); defer p.deinit(); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index cd327d03..a64521e7 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -53,8 +53,26 @@ fn noteResult(p: *Pardes, serial: u32) void { /// the pane it acted on (even one it closed). fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { const context = p.panes[id].?.serial; + // A pane an event reader holds hears its clicks instead of acting on + // them (editors/acme/look.c:53 and exec.c:173 send them to winevent), + // and a line written to a look or exec is a click: on that pane, or for + // the root's, on the pane with the keyboard. The reader writes the + // record back to have it done. + if (p.panes[id].?.fs.readers > 0) { + const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null; + var flag: u32 = pardes.ctlfs.events.flag_expansion; + if (named) flag |= pardes.ctlfs.events.flag_builtin; + if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename; + _ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text); + return noteResult(p, context); + } const before = Serials.snapshot(p); - if (exec) _ = pardes.exec.execute(p, id, text) else look.lookAt(p, id, text); + // An exec answers the pane it went to: a command pane it reused, or + // the terminal it typed into. + const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: { + look.lookAt(p, id, text); + break :blk null; + }; const after = Serials.snapshot(p); var created = false; for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) { @@ -62,6 +80,7 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { created = true; }; if (created) return; + if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial); noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial); } @@ -144,7 +163,7 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void { try w.writeByte('\''); } -pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len; +pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48; /// acme's winctlprint: serial, tag length, body length, a reserved zero, the /// dirty flag, then the width in cells, the font and the tab width. Then @@ -167,6 +186,10 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { // While a column's or the workspace's tag has the keyboard, no pane does. const current = p.panes[p.active] == pane and !p.header_focus; w.print(" {d:>11} {s} ", .{ config.tab_width, 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); + if (id.len > 0) w.print("{s} ", .{id}) catch {}; return w.buffered(); } @@ -177,7 +200,7 @@ const Builtin = builtins.registry.Builtin(); /// Refuses a control message, quoting it the way Plan 9's cmderror does /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { - const room = p.fs.ename.len - why.len - 3; + const room = p.fs.ename.len -| (why.len + 3); const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why; return tree.failText(req.tag, E.INVAL, text); } @@ -201,7 +224,12 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? const takes = switch (setting.action) { .theme => for (pardes.themes) |t| { if (std.mem.eql(u8, t.name, arg)) break true; - } else false, + } else { + // `refuse` quotes the line after it, in what is left of 128. + var near: [40]u8 = undefined; + var why: [104]u8 = undefined; + return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line); + }, .font => config.Runtime.FontSpec.parse(arg) != null, else => probe: { var probe = p.settings; @@ -220,7 +248,18 @@ fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { p.fs.refused = false; p.fs.failure_len = 0; defer p.fs.no_prompt = false; + // What a ctl write touched reads back from /look and /exec as a click's + // does (`run`), so a script that made a pane with `Tty` finds it there. + const context = p.panes[id].?.serial; + const before = Serials.snapshot(p); _ = exec_line.executeBuiltinLine(p, id, line); + const after = Serials.snapshot(p); + var created = false; + for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) { + noteResult(p, serial); + created = true; + }; + if (!created) noteResult(p, context); if (p.fs.refused) return refuse(p, req, e_prompt, line); if (p.fs.failure_len == 0) return null; const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line); @@ -292,6 +331,7 @@ pub fn readRoot(p: *Pardes, req: Req) Reply { /// runs, as the root's look and exec click there; all are checked first. pub fn writeRoot(p: *Pardes, req: Req) Reply { for ([2]bool{ false, true }) |apply| { + if (apply) p.fs.results_len = 0; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); @@ -361,7 +401,7 @@ pub fn writeFocus(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -const e_no_pane = "no such window"; +const e_no_pane = "no such pane"; pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { var buf: [pane_text_cap]u8 = undefined; @@ -399,6 +439,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { // Checked whole before anything applies, so a write that must wait for // the lock has done nothing yet when it goes again. for ([2]bool{ false, true }) |apply| { + if (apply) p.fs.results_len = 0; var held = !other and pane.fs.lock != null; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { @@ -615,7 +656,9 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi try testing.expectEqualStrings("not a session control message \"Del\"", wr(p, root_ctl, "Del").reply.ename); try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename); try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); - try testing.expectEqualStrings("bad value in control message \"Theme no-such-theme\"", wr(p, root_ctl, "Theme no-such-theme").reply.ename); + const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename; + try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n"); + try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\"")); try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename); try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename); // A bare required word fails the check, so the line before never runs. @@ -625,7 +668,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi // its line; the lines before it have taken effect, as in acme. const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off"); try testing.expectEqual(E.IO, failed.errno()); - try testing.expectEqualStrings("Mount name dial: MissingArgument \"Mount x\"", failed.reply.ename); + try testing.expectEqualStrings("Mount name dial: missing argument \"Mount x\"", failed.reply.ename); try testing.expect(p.settings.verbose); try testing.expect(!p.fs.no_prompt); try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status); @@ -860,3 +903,116 @@ test "exec Save writes the named file through the host" { try testing.expect(!pane_files.dirtyOf(pane)); try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); } + +test "a pane's own look and exec go to the event reader holding it, which writes them back to act" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg routed\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg routed\n").reply.status); + // Not done: the reader has it, as it would a middle click. + const pane = p.panes[0].?; + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null); + const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes; + try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record); + // Written back, it is done. + try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status); + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null); + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status); + try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); + // The root's is a click at the pane with the keyboard, this one. + try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status); + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null); + try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h }); + // With no reader, the root's and the pane's act at once. + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status); + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "direct") != null); +} + +test "look and exec read back what the last command touched, a ctl write's too" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + var want: [32]u8 = undefined; + _ = wr(p, root_exec, "Msg first\n"); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, root_exec, 0, 64).bytes); + // A pane made through ctl is what both read next, not the Msg's pane. + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status); + const made = p.panes[p.active].?.serial; + try testing.expect(made != serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes); + // A ctl write that makes nothing names the pane it ran at. + try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes); +} + +test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" { + // 9ns (cloud9's 9ns/src/nine.zig) maps an error to an errno by its + // words, and EIO when none of them match: these words must not. + const addressing = @import("addr.zig"); + const einval = [_][]const u8{ "control message", "invalid", "bad " }; + const other = [_][]const u8{ "interrupt", "shut down", "not exist", "not found", "no such", "exists", "not empty", "not a dir", "is a dir", "permission", "denied", "read-only", "read only", "no space", "not allowed", "not permitted", "cannot", "fid", "busy", "in use", "too long", "not supported", "unsupported" }; + const Case = struct { text: []const u8, malformed: bool }; + for ([_]Case{ + .{ .text = addressing.e_syntax, .malformed = true }, + .{ .text = addressing.e_regexp, .malformed = true }, + .{ .text = e_prompt, .malformed = true }, + .{ .text = addressing.e_no_match, .malformed = false }, + .{ .text = addressing.e_range, .malformed = false }, + .{ .text = addressing.e_order, .malformed = false }, + .{ .text = addressing.e_slow, .malformed = false }, + .{ .text = "/tmp/x.txt: Modified (Exit again to discard)", .malformed = false }, + }) |c| { + const says_einval = for (einval) |w| { + if (std.ascii.findIgnoreCase(c.text, w) != null) break true; + } else false; + try testing.expectEqual(c.malformed, says_einval); + for (other) |w| try testing.expect(std.ascii.findIgnoreCase(c.text, w) == null); + } +} + +test "Joincol with no column to the right and Theme with no such theme say so" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + const joined = wr(p, root_ctl, "Joincol\n"); + try testing.expectEqual(tree.Status.err, joined.reply.status); + try testing.expectStringStartsWith(joined.reply.ename, "Joincol: no column to the right"); + const themed = wr(p, root_ctl, "Theme drak\n"); + try testing.expectEqual(E.INVAL, themed.errno()); + try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: "); + try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null); + try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\"")); + // A click on the word says it on the message row. + _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n"); + const pane = p.panes[p.active].?; + try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null); +} + +test "a look that fills a column's stillborn scratch is answered with the scratch already gone" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" }); + var dir: [4096]u8 = undefined; + const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; + var line: [4200]u8 = undefined; + // Newcol makes a column of one empty +New; a file looked from it + // joins that column and takes the scratch's place. + try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); + const scratch = p.panes[p.active].?.serial; + try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); + try testing.expect(p.paneBySerial(scratch) == null); + var want: [32]u8 = undefined; + const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch}); + var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n'); + while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone)); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 69dc271d..ffdea95b 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -7,6 +7,7 @@ const look = @import("../look.zig"); const cloud9 = @import("cloud9"); const tree = @import("tree.zig"); const pane_files = @import("pane.zig"); +const dump = @import("../dump.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; @@ -155,6 +156,13 @@ pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void { /// kernel mapped the reply to (`Invalid argument`), and here is the reason. /// The log is the one place for it, as acme's `errors` file takes text and /// answers nothing: a per-pane readable error file would be a second. +/// `dump <path>` when a Dump is written, `restore <path>` first in a +/// Restore's replacement. +pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void { + var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined; + pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return); +} + pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno); var name: [16]u8 = undefined; @@ -460,7 +468,7 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { p.fs.news = true; } -const EventRecord = struct { action: Action, q0: u32, q1: u32 }; +const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, text: ?[]const u8 = null }; const EventReader = struct { data: []const u8, @@ -470,15 +478,27 @@ const EventReader = struct { if (er.i >= er.data.len) return null; var i = er.i; if (i + 2 > er.data.len) return null; + const origin = er.data[i]; i += 1; const action = Action.fromChar(er.data[i]) orelse return null; i += 1; const q0 = scanNumber(er.data, &i) orelse return null; const q1 = scanNumber(er.data, &i) orelse return null; while (i < er.data.len and er.data[i] == ' ') i += 1; + // acme takes only `origin action q0 q1`; the record whole, as read, + // is taken too, so a click with no place in the text (one written + // to a pane's own look or exec) can be written back and done. + var text: ?[]const u8 = null; + if (i < er.data.len and er.data[i] != '\n') { + _ = scanNumber(er.data, &i) orelse return null; + const n = scanNumber(er.data, &i) orelse return null; + if (i >= er.data.len or er.data[i] != ' ' or er.data.len - (i + 1) < n) return null; + text = er.data[i + 1 ..][0..n]; + i += 1 + n; + } if (i >= er.data.len or er.data[i] != '\n') return null; er.i = i + 1; - return .{ .action = action, .q0 = q0, .q1 = q1 }; + return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .text = text }; } }; @@ -517,9 +537,15 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { const whole = if (r.action.onTag()) pane_files.tagOf(p, pane) else pane_files.bodyOf(pane); const lo = @min(@as(usize, r.q0), whole.len); const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); - const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; + const said = if (r.q0 == r.q1) r.text orelse whole[lo..hi] else whole[lo..hi]; + const text = p.scratch.allocator().dupe(u8, said) catch continue; switch (r.action) { - .body_exec, .tag_exec => _ = exec.execute(p, live, text), + // A body's click written back is done as the click would have + // been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for + // it takes it. One that came from a write to exec (`F`) was + // never a click, and runs as the command it was. + .body_exec => _ = exec.executeFrom(p, live, text, r.origin != 'F'), + .tag_exec => _ = exec.execute(p, live, text), .body_look, .tag_look => look.lookAt(p, live, text), else => unreachable, } @@ -664,7 +690,8 @@ test "a pane deleted while its event file is open leaves no suppression behind" const b = call(p, .{ .tag = 19, .op = .open, .node = event, .omode = 1 }); try testing.expectEqual(@as(u16, 2), p.fs.listeners); - _ = wr(p, Node.of(serial, .exec), "Del\n"); + // Through ctl: a line written to its exec would go to the reader. + _ = wr(p, Node.of(serial, .ctl), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); // The reader's next read hears what acme says of a window gone under it. @@ -850,7 +877,7 @@ test "a refused or failed write is an err record in the log, saying which file a _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus"); try testing.expectEqualStrings("err - ctl: unknown control message \"Bogus\"\n", next.record(p, f, frozen)); _ = wr(p, @intFromEnum(tree.TopFile.focus), "99999"); - try testing.expectEqualStrings("err - focus: no such window\n", next.record(p, f, frozen)); + try testing.expectEqualStrings("err - focus: no such pane\n", next.record(p, f, frozen)); // A write that succeeds says nothing. _ = wr(p, Node.of(serial, .addr), "#0"); try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status); @@ -870,6 +897,21 @@ test "a refused or failed write is an err record in the log, saying which file a _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g }); } +test "a Dump written and a Restore made are in the log, with their files" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + p.setLastDump("/tmp/pardes.dump.zon"); + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); + defer restored.deinit(); + for ([_]*Pardes{ p, restored }, [_][]const u8{ "dump", "restore" }) |core, what| { + const log = try freezeLog(core); + defer core.gpa.free(log.bytes); + var want: [64]u8 = undefined; + try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what}))); + } +} + test "opens of the log share the open records, and a closed one frees its record" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); @@ -885,3 +927,59 @@ test "opens of the log share the open records, and a closed one frees its record for (handles) |h| _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h }); for (p.fs.opens) |o| try testing.expect(o.node == 0); } + +test "a click in the body reports the offsets of the word it takes, so the record can be written back" { + const config = @import("../config.zig"); + const gpa = testing.allocator; + const text = "alpha qty beta\n qty\n\tqty\ndef f(qty): return qty.x\n" ++ "w" ** 100 ++ " qty\n"; + const p = try withFile(gpa, text); + defer p.deinit(); + // A scratch made over 9P, clicked while another pane has the keyboard. + const scratch = try th.newPane(p); + _ = wr(p, Node.of(scratch, .body), text); + for ([_]u32{ serialOf(p), scratch }) |serial| { + const id = p.paneBySerial(serial).?; + p.active = p.paneBySerial(if (serial == scratch) serialOf(p) else scratch).?; + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + 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 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()); + var wrap_col: u16 = 0; + while (wrap_col < rect.w and !std.mem.eql(u8, surface.at(x0 + wrap_col, y0 + 5).grapheme(), "q")) wrap_col += 1; + for ([_]Click{ + .{ .x = x0 + 7, .y = y0, .look = "ML6 9 0 3 qty\n", .exec = "MX6 9 0 3 qty\n" }, + .{ .x = x0 + 5, .y = y0 + 1, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n" }, + .{ .x = x0 + tab + 1, .y = y0 + 2, .look = "ML24 27 0 3 qty\n", .exec = "MX24 27 0 3 qty\n" }, + .{ .x = x0 + 7, .y = y0 + 3, .look = "ML34 37 0 3 qty\n", .exec = "MX34 37 0 3 qty\n" }, + // Scrolled a line, the first row shows the second. + .{ .x = x0 + 5, .y = y0, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n", .scroll = 1 }, + // Wrapped: the word on the continuation row. + .{ .x = x0 + wrap_col, .y = y0 + 5, .look = "ML154 157 0 3 qty\n", .exec = "MX154 157 0 3 qty\n" }, + }) |c| for ([_]pardes.Mouse.Button{ config.look_button, config.exec_button }, [_][]const u8{ c.look, c.exec }) |button, want| { + // The pointer rests on the word first, as a hand's does, and + // the hover preview comes up under it. + p.panes[id].?.scrollBy(c.scroll); + _ = try p.render(frame.allocator()); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = c.x, .row = c.y } }); + for (0..200) |_| p.update(.tick); + p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = c.x, .row = c.y } }); + p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = c.x, .row = c.y } }); + try testing.expectEqualStrings(want, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes); + p.panes[id].?.scrollBy(-c.scroll); + }; + // A click on nothing expands to nothing, and acme then says + // nothing (look.c:37, exec.c:141). + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x0 + 60, .row = y0 + 1 } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x0 + 60, .row = y0 + 1 } }); + try testing.expectEqual(Status.again, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).reply.status); + _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); + } +} diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 0a511118..d2b2a791 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -194,6 +194,20 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const return take; } +test "a terminal's body stats as long as it reads" { + const gpa = testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.isTerminal()); + p.update(.{ .output = .{ .pane = 0, .bytes = "$ ls\r\nalpha beta\r\n$ " } }); + const body = Node.of(pane.serial, .body); + const whole = rd(p, body, 0, 1 << 16); + try testing.expect(whole.bytes.len > 0); + try testing.expectEqual(@as(u64, whole.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = body }).reply.attr.size); +} + // ---- what stat reports ---- /// A range file's text: acme's addr format, two right-aligned offsets, so @@ -204,7 +218,9 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; const pf = &pane.fs; return switch (f) { - .body, .data, .xdata => bodyOf(pane).len, + // A terminal's body reads its screen and history, as text. + .body => if (pane.file == null and pane.isTerminal()) panes.Terminal.screenTextLen(pane) else bodyOf(pane).len, + .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, .name => nameOf(pane).len + 1, .ctl => ctl: { @@ -453,16 +469,22 @@ pub const e_addr_failed = "no address: the last one written to addr failed"; /// A range file takes an address expression, or that pair of offsets. One /// that does not evaluate says why: `bad address syntax`, `no match for -/// regexp`, `address out of range`, `bad regular expression`. +/// regexp`, `address out of range`, `bad regular expression`, `addresses +/// out of order`. fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { const pf = &pane.fs; const text = bodyOf(pane); clampAddr(pf, text.len); const expr = std.mem.trimEnd(u8, req.data, "\n"); var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = if (pairOf(expr)) |pair| + var r = if (pairOf(expr)) |pair| State.Range{ .q0 = @min(pair.q0, clip(text.len)), .q1 = @min(pair.q1, clip(text.len)) } else if (a.address(pf.addr)) |found| (if (a.i < expr.len) null else found) else null; + // sam's check, which acme leaves out: `#100,#50` names no range. + if (r) |found| if (found.q0 > found.q1) { + a.err = addressing.e_order; + r = null; + }; const range = r orelse { if (file == .addr) pf.addr_failed = true; return tree.failText(req.tag, E.INVAL, a.err); @@ -485,7 +507,7 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { const word = std.mem.trim(u8, req.data, " \t\r\n"); - if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return Reply.fail(req.tag, E.INVAL); + if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "takes 1 or 0"); const on = word[0] == '1'; const pf = &pane.fs; switch (file) { @@ -851,8 +873,10 @@ test "the flag files read and write the state acme kept behind ctl verbs" { for ([_]u64{ dirty, mark, scroll }) |node| { try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); - for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| + for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| { try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); + try testing.expectEqualStrings("takes 1 or 0", wr(p, node, bad).reply.ename); + } } try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index dcf9a8a7..3599b612 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -43,7 +43,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply { while (it.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); + if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, e_bad_pty_ctl); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; @@ -65,13 +65,13 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { const which = signalNamed(words.next() orelse return false) orelse return false; if (words.next() != null) return false; if (!apply) return true; - p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); + p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .serial = pane.serial, .sig = which } }); }, .exec => { if (words.next() != null) return false; if (pane.cwdSlice().len > pardes.effect_path_cap) return false; if (!apply) return true; - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } }); }, } return true; @@ -92,7 +92,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // Busy is what a run written now would be told: a command is running // or a line is typed at the prompt. Before the shell's first prompt a // run waits for it rather than answer busy, so that is not busy. - const marks = if (pane.terminal) |state| &state.stream.handler else null; + // A build with no terminal panes (the board) has no marks to read. + const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); @@ -162,10 +163,14 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); + if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); const pf = &pane.fs; const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); const marks = &state.stream.handler; - if (pf.unmarked) { + if (pane.command != null) { + // Its child is the command, and no shell is there to take a line. + answer(p, slot, "error {s}", .{if (pane.command_done) "command done; not a shell" else "a command runs here, not a shell"}); + } else if (pf.unmarked) { answer(p, slot, "error no prompt marks", .{}); } else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) @@ -335,6 +340,7 @@ fn waitingRun(p: *Pardes, pane: *Pane) ?*Run { } const e_bad_line = "bad command line"; +const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec"; pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; @@ -505,6 +511,7 @@ test "every pty/ctl verb, and every refusal" { }) |bad| { const a = wr(p, ctl, bad); try testing.expectEqual(E.INVAL, a.errno()); + try testing.expectEqualStrings(e_bad_pty_ctl, a.reply.ename); // names what it takes try testing.expect(a.winsize == null); try testing.expect(a.signal == null); try testing.expect(!a.spawned); @@ -743,6 +750,25 @@ test "run answers how its command ended once the shell is back at a prompt" { for (p.fs.opens) |o| try testing.expect(o.node == 0); } +test "run on a command pane says no shell is there" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .pty_run); + const pane = p.panes[0].?; + pane.command = try gpa.dupe(u8, "make"); + for ([_]struct { bool, []const u8 }{ + .{ false, "error a command runs here, not a shell\n" }, + .{ true, "error command done; not a shell\n" }, + }) |case| { + pane.command_done = case[0]; + const o = call(p, .{ .tag = 1, .op = .open, .node = node }); + _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = o.reply.handle, .data = "ls\n" }); + try testing.expectEqualStrings(case[1], call(p, .{ .tag = 3, .op = .read, .node = node, .handle = o.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = o.reply.handle }); + } +} + test "a run's answer says cut when its output's start is gone, and reads a bounded tail" { const gpa = testing.allocator; const p = try withTerm(gpa); diff --git a/src/normal.zig b/src/normal.zig index da707e26..17ebaac6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -7,6 +7,7 @@ const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const regexp = @import("regexp.zig"); +const syntax = @import("syntax.zig"); const modal = @import("modal.zig"); const panes = @import("panes.zig"); const edit = @import("edit.zig"); @@ -18,6 +19,7 @@ const Key = pardes.Key; const hit = pardes.hit; const sel_slot = pardes.sel_slot; const Pardes = pardes.Pardes; +const Macro = pardes.Macro; fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; @@ -27,6 +29,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", + .prefix_view_sticky => "view_prefix_sticky", .prefix_match => "match_prefix", .prefix_find_fwd => "find_char_fwd", .prefix_find_back => "find_char_back", @@ -35,6 +38,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { .prefix_replace => "replace_prefix", .prefix_next => "next_prefix", .prefix_prev => "prev_prefix", + .prefix_register => "register_prefix", else => @tagName(role), }; } @@ -67,27 +71,38 @@ pub fn multiOnce(p: *Pardes) bool { return p.multi_first; } -fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, split: bool) void { +fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const got = t.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; - p.startPrompt(pane, t, .{ .search = if (split) config.split_marker else config.select_marker }); + p.startPrompt(pane, t, .{ .search = regexMarker(mode) }); } -pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { +fn regexMarker(mode: modal.Normal.RegexMode) []const u8 { + return switch (mode) { + .select => config.select_marker, + .split => config.split_marker, + .keep => config.keep_marker, + .remove => config.remove_marker, + }; +} + +pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, mode: modal.Normal.RegexMode } { if (pane.prompt != .search or pane.nsel_snap == 0) return null; const armed = pane.input.own orelse ""; - const split = std.mem.startsWith(u8, armed, config.split_marker); - if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; + const mode: modal.Normal.RegexMode = for (std.enums.values(modal.Normal.RegexMode)) |m| { + if (std.mem.startsWith(u8, armed, regexMarker(m))) break m; + } else return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; - return .{ .pat = armed[slash + 1 ..], .split = split }; + return .{ .pat = armed[slash + 1 ..], .mode = mode }; } -/// Select (or split on) `pat` inside the selections `t` had when s/S was -/// pressed; the prompt's snapshot of them lives on `pane`. -pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: bool) void { +/// Select (or split on, keep or remove by) `pat` inside the selections `t` +/// had when s/S/K/Alt-K was pressed; the prompt's snapshot of them lives on +/// `pane`. +pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Text.max_selections]modal.Selection = undefined; @@ -106,6 +121,17 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: const from = @min(r.anchor, r.head); const to = @min(@max(r.anchor, r.head), text.len); if (from >= to) continue; + if (mode == .keep or mode == .remove) { + // helix keep_or_remove_matches: the range stays when a match + // starts inside it (K), or when none does (Alt-K) + const hit_at = re.find(hay_all, from, to, to) catch null; + const found = if (hit_at) |h| h.start < to else false; + if (found == (mode == .keep) and m < Text.max_selections) { + out[m] = r; + m += 1; + } + continue; + } // Searched as sam searches, the way addr does (regexp.zig): the // text around the selection still says where its lines begin. var at = from; @@ -113,7 +139,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: while (at < to and m < Text.max_selections) { // A search too slow to finish keeps what it found so far. const hit_at = (re.find(hay_all, at, to, to) catch break) orelse break; - if (split) { + if (mode == .split) { out[m] = .{ .anchor = piece, .head = hit_at.start }; m += 1; piece = hit_at.end; @@ -124,7 +150,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } - if (split and piece < to and m < Text.max_selections) { + if (mode == .split and piece < to and m < Text.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } @@ -142,13 +168,158 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } +/// The file's parse, made on the first key that walks it after an edit and +/// kept until the next: never made again for a key that changed nothing. +pub fn fileTree(p: *Pardes, f: *panes.File.State) ?*anyopaque { + if (f.node_tree == null or f.node_tree_revision != f.revision) { + if (f.node_tree) |old| syntax.freeTree(old); + f.node_tree = syntax.parseTree(f.path, f.content); + f.node_tree_revision = f.revision; + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + } + return f.node_tree; +} + +/// `]f`/`[f` and kin (helix goto_ts_object): to the next object starting +/// after the cursor or the previous one ending before it, count times; select +/// mode stretches the range to it. +fn objectJump(p: *Pardes, pane: *Pane, t: *Text, text: []const u8, range: modal.Selection, object: modal.Normal.Object, forward: bool, count: u32) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; + var last = range; + for (0..count) |_| { + const s = syntax.objectNext(f.path, tree, text, object, forward, modal.selectionCursor(text, last)) orelse break; + const next: modal.Selection = .{ .anchor = s.from, .head = s.to }; + if (std.meta.eql(next, last)) break; + last = next; + } + if (std.meta.eql(last, range)) return; + if (t.select) { + const head = if (last.head < range.anchor) last.anchor else last.head; + return t.setRange(text, 0, .{ .anchor = range.anchor, .head = head }, false); + } + const out: modal.Selection = if (forward) last else .{ .anchor = last.head, .head = last.anchor }; + t.setRange(text, 0, out, false); +} + +/// helix's tree-sitter selection keys: every range walks the file's parse, +/// which is made on the first of these keys after an edit and kept, never +/// made again for a key that changed nothing. A range the tree has nothing +/// to say about stays as it was; a text without a grammar, all of them. +pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; + const text = f.content; + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const arena = p.scratch.allocator(); + const rs = arena.alloc(modal.Selection, Text.max_selections) catch return; + const got = t.ranges(text, 0, rs[0..Text.max_selections]); + if (walk == .shrink) if (f.node_history.pop()) |prev| { + defer p.gpa.free(prev.ranges); + // back to what Alt-o grew from, while the selection still holds it + const holds = for (prev.ranges) |pr| { + const pf = @min(pr.anchor, pr.head); + const pt = @max(pr.anchor, pr.head); + if (for (rs[0..got.n]) |r| { + if (@min(r.anchor, r.head) <= pf and pt <= @max(r.anchor, r.head)) break true; + } else false) continue; + break false; + } else true; + if (holds) return t.setRanges(text, prev.ranges, &.{}, prev.primary, expl); + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + }; + const out = arena.alloc(modal.Selection, Text.max_selections) catch return; + const spans = arena.alloc(syntax.Span, Text.max_selections) catch return; + var m: usize = 0; + for (rs[0..got.n]) |r| { + if (m == out.len) break; + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const back = r.head < r.anchor; + const n = syntax.walkNodes(tree, walk, from, to, modal.selectionCursor(text, r), spans[0 .. out.len - m]); + if (n == 0) { + out[m] = r; + m += 1; + continue; + } + for (spans[0..n]) |s| { + out[m] = switch (walk) { + .next_sibling => .{ .anchor = s.from, .head = s.to }, + .prev_sibling => .{ .anchor = s.to, .head = s.from }, + // helix move_parent_node_end: a one-character range on the + // bound, or stretched to it in select mode + .parent_end, .parent_start => bound: { + const head = s.from; + if (t.select) { + const anchor = r.anchor; + break :bound .{ .anchor = anchor, .head = if (head >= anchor) modal.nextGrapheme(text, head) else head }; + } + const next = modal.nextGrapheme(text, head); + break :bound if (back) .{ .anchor = next, .head = head } else .{ .anchor = head, .head = next }; + }, + else => if (back) .{ .anchor = s.to, .head = s.from } else .{ .anchor = s.from, .head = s.to }, + }; + m += 1; + } + } + const same = m == got.n and std.mem.eql(u8, std.mem.sliceAsBytes(out[0..m]), std.mem.sliceAsBytes(rs[0..m])); + if (walk == .expand and !same) remember: { + const ranges = p.gpa.dupe(modal.Selection, rs[0..got.n]) catch break :remember; + f.node_history.append(p.gpa, .{ .ranges = ranges, .primary = got.pri }) catch p.gpa.free(ranges); + } + t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); +} + +/// `gw` (helix goto_word): label the words in view, nearest the cursor first, +/// with two letters each; the next two keys choose one (`jumpKey`). +fn startJump(p: *Pardes, pane: *Pane, t: *Text) void { + if (t.what != .body) return; + const text = edit.flatSurface(p, t) catch return; + const cursor = modal.selectionCursor(text, t.primaryRange(text, 0)); + const top: usize = @intCast(@max(0, pane.scroll())); + const start = @min(modal.lineStartOffset(text, top), text.len); + const end = @min(modal.lineStartOffset(text, top + pane.rows), text.len); + const letters = config.jump_label_alphabet.len; + pane.jump.resize(p.gpa, letters * letters) catch return; + pane.jump.shrinkRetainingCapacity(modal.jumpWords(text, cursor, start, end, pane.jump.items)); + pane.jump_first = null; +} + +/// A key while `gw`'s labels are up: a label's first letter, then its +/// second, which selects that word; anything else takes the labels away. +pub fn jumpKey(p: *Pardes, pane: *Pane, t: *Text, key: Key) void { + const letters = config.jump_label_alphabet; + const at = if (key.ctrl or key.alt or key.cp > 0x7f) null else std.mem.indexOfScalar(u8, letters, @intCast(key.cp)); + const i = at orelse return pane.jump.clearRetainingCapacity(); + const first = pane.jump_first orelse { + if (i * letters.len > pane.jump.items.len) return pane.jump.clearRetainingCapacity(); + pane.jump_first = @intCast(i); + return; + }; + const chosen = first * letters.len + i; + defer pane.jump.clearRetainingCapacity(); + if (chosen >= pane.jump.items.len) return; + const r = pane.jump.items[chosen]; + const text = edit.flatSurface(p, t) catch return; + if (!t.select) return t.setRange(text, 0, .{ .anchor = @min(r.anchor, r.head), .head = @max(r.anchor, r.head) }, true); + // select mode stretches the primary to the word (helix Movement::Extend) + const pr = t.primaryRange(text, 0); + const anchor = if (r.anchor < r.head) @min(r.anchor, @min(pr.anchor, pr.head)) else @max(r.anchor, @max(pr.anchor, pr.head)); + t.setRange(text, 0, .{ .anchor = anchor, .head = r.head }, true); +} + const KeyState = struct { mode: Text.Mode, select: bool, normal: modal.Normal.State, find_op: u8, find_ch: u21, - append_at: @FieldType(Text, "append_at"), + restore_cursor: bool, + reg_wait: bool, fn of(t: *Text) KeyState { return .{ @@ -157,7 +328,8 @@ const KeyState = struct { .normal = t.normal, .find_op = t.find_op, .find_ch = t.find_ch, - .append_at = t.append_at, + .restore_cursor = t.restore_cursor, + .reg_wait = t.reg_wait, }; } @@ -167,7 +339,8 @@ const KeyState = struct { t.normal = s.normal; t.find_op = s.find_op; t.find_ch = s.find_ch; - t.append_at = s.append_at; + t.restore_cursor = s.restore_cursor; + t.reg_wait = s.reg_wait; } }; @@ -227,6 +400,8 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { while (i > 0) { i -= 1; p.multi_first = passes == 0; + p.multi_index = i; + p.multi_count = n; passes += 1; saved.into(t); t.nsel = 0; // the handler underneath sees ONE selection @@ -284,11 +459,16 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { } pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void { + // `"<reg>` is held by the parser until the command it names arrives + const named = t.normal.register; const parsed = modal.Normal.parse(&t.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, }; + t.normal.register = 0; + p.register = named; + defer p.register = 0; if (t.nsel == 0 or semantic.scope() == .once) return executeNormalAction(p, t, semantic); replaySels(p, t, .{ .normal = semantic }); @@ -313,6 +493,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void switch (semantic) { .escape => { t.select = false; + // typed again by a macro, Esc is only helix's + if (p.macro.replaying > 0) return; return exec.runBuiltin(p, .Last, p.active, "", null); }, .goto => |go| switch (go.target) { @@ -346,6 +528,10 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .view_top => return t.gotoWindow(text, range, .top, go.count), .view_center => return t.gotoWindow(text, range, .center, go.count), .view_bottom => return t.gotoWindow(text, range, .bottom, go.count), + .last_modification => { + const at = t.last_edit orelse return; + return t.pointMove(text, range, modal.offsetAt(text, .{ .row = @intCast(@max(0, at.row)), .col = @intCast(@max(0, at.col)) })); + }, }, .view => |view| if (t.what == .body) switch (view) { .top => { @@ -360,6 +546,15 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void pane.scrollBy(t.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, + .middle => { + // helix align_view_middle: the cursor's column to the middle + // of an unwrapped view + if (pane.body_wrapped) return; + const line = text[modal.lineStartOffset(text, modal.lineAtOffset(text, cur))..modal.lineEndOffset(text, modal.lineAtOffset(text, cur))]; + const w: usize = @intCast(@max(1, @as(i32, pane.cols) - @as(i32, panes.File.gutterWidth(pane)))); + const col = panes.File.rawDisplayCol(line, @intCast(@max(0, t.cur_col))); + pane.hscroll = @intCast(panes.File.rawAtDisplay(line, col -| w / 2)); + }, .scroll_down => return t.scrollViewMove(text, range, 1), .scroll_up => return t.scrollViewMove(text, range, -1), }, @@ -520,27 +715,28 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { - if (multi.kind == .keep_primary) { - t.nsel = 0; - return; - } + if (multi.kind == .keep_primary) return t.dropSels(); return t.multiSelAction(text, multi.kind, multi.count); }, // s, S and | answer for the text they are pressed in; `/` searches // the body wherever it is pressed, as acme's Look from a tag does. - .select_regex => |split| return startSelRegex(p, pane, t, split), + .select_regex => |mode| return startSelRegex(p, pane, t, mode), .edit => |op| switch (op.kind) { .delete => return edit.normalDelete(p, t, true), .delete_noyank => return edit.normalDelete(p, t, false), .change => return edit.normalChange(p, t), .yank => return edit.normalYank(p, t), - .replace_with_yank => return edit.normalReplaceYank(p, t), - .paste_after => return edit.pasteText(p, t, p.yank orelse return, false, op.count), - .paste_before => return edit.pasteText(p, t, p.yank orelse return, true, op.count), + .replace_with_yank => return edit.pasteRegister(p, t, .replace, op.count), + .paste_after => return edit.pasteRegister(p, t, .after, op.count), + .paste_before => return edit.pasteRegister(p, t, .before, op.count), .switch_case => return edit.normalCase(p, t, .toggle), .lowercase => return edit.normalCase(p, t, .lower), .uppercase => return edit.normalCase(p, t, .upper), - .join_lines => return edit.normalJoin(p, t), + .join_lines => return edit.normalJoin(p, t, false), + .join_select_space => return edit.normalJoin(p, t, true), + .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count), + .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count), + .align_selections => return edit.normalAlign(p, t), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), @@ -556,7 +752,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .format => .format, }, ""), .adjust_number => |delta| return edit.normalAdjustNumber(p, t, delta), - .leader => { + .leader => if (p.macro.replaying == 0) { p.leader_on = true; p.leader_n = 0; }, @@ -573,6 +769,46 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void p, if (direction == .forward) @as(i32, 1) else -1, ), + .node => |walk| return nodeSelect(p, pane, t, walk), + .goto_word => return startJump(p, pane, t), + .object_jump => |jump| return objectJump(p, pane, t, text, range, jump.object, jump.direction == .forward, jump.count), + .macro_record => { + const m = &p.macro; + if (m.recording == 0) { + m.recording = if (p.register != 0) p.register else '@'; + m.recorded.clearRetainingCapacity(); + return; + } + // the keys of this command (a count, `"<reg>`, the `Q`) are not + // the macro's + m.recorded.shrinkRetainingCapacity(m.recorded.items.len -| (m.command.items.len + 1)); + const keys = Macro.encode(p.gpa, m.recorded.items) catch return; + defer p.gpa.free(keys); + p.registers.put(p.gpa, m.recording, keys, 0, 1, true); + m.recording = 0; + }, + .macro_replay => |count| { + const name: u21 = if (p.register != 0) p.register else '@'; + const notation = p.registers.value(name, 0) orelse return; + const keys = Macro.decode(p.scratch.allocator(), notation) catch return; + p.register = 0; + for (0..count) |_| p.replayKeys(keys); + }, + .repeat_insert => |count| { + const m = &p.macro; + if (m.last_insert.items.len == 0 or m.repeating) return; + if (m.inserting) return; // the session is still open + const keys = p.scratch.allocator().dupe(Macro.Stored, m.last_insert.items) catch return; + m.repeating = true; + defer m.repeating = false; + // helix repeat_last_insert: the command once, what was typed + // count times, and out of insert mode the way it was left + const typed = @min(m.typed_from, keys.len); + p.replayKeys(keys[0..typed]); + if (keys.len == typed) return; + for (0..count) |_| p.replayKeys(keys[typed .. keys.len - 1]); + p.replayKeys(keys[keys.len - 1 ..]); + }, } } @@ -713,10 +949,11 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.cur_row = 1024; pane.body.cur_col = 0; pane.body.nsel = 1; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1025), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 3073), pane.body.sels[0].row); @@ -740,20 +977,20 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi defer p.deinit(); const text = "alpha x\nbeta alpha\nalphabet\n"; const pane = try p.setTestFile(text); - const Case = struct { pat: []const u8, split: bool, want: []const [2]usize }; + const Case = struct { pat: []const u8, mode: modal.Normal.RegexMode, want: []const [2]usize }; for ([_]Case{ // ^ at each line's start, not only the selection's - .{ .pat = "^alpha", .split = false, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, + .{ .pat = "^alpha", .mode = .select, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, // $ at each line's end - .{ .pat = "alpha$", .split = false, .want = &.{.{ 13, 18 }} }, + .{ .pat = "alpha$", .mode = .select, .want = &.{.{ 13, 18 }} }, // . stops at the newline: one selection per line, not one for all - .{ .pat = "a.*", .split = false, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, + .{ .pat = "a.*", .mode = .select, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, // S splits on a line's end - .{ .pat = "$", .split = true, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, + .{ .pat = "$", .mode = .split, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, }) |c| { pane.sel_snap[0] = .{ .anchor = 0, .head = text.len }; pane.nsel_snap = 1; - applySelRegex(p, pane, &pane.body, c.pat, c.split); + applySelRegex(p, pane, &pane.body, c.pat, c.mode); var out: [Text.max_selections]modal.Selection = undefined; const got = pane.body.ranges(text, 0, &out); try std.testing.expectEqual(c.want.len, got.n); @@ -763,3 +1000,132 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi } } } + +test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { + if (!syntax.supportsPath("a.json")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const text = "{\"a\": [1, 2, 3], \"b\": true}\n"; + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .keys = "10l\x1bo" ++ "d", .want = "{\"a\": , \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bod", .want = "{, \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1bod", .want = "\n" }, + .{ .keys = "10l\x1bo\x1bIix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1b>d", .want = "{\"a\": [1, 2, 3] \"b\": true}\n" }, + .{ .keys = "10l\x1bpd", .want = "{\"a\": [1 2, 3], \"b\": true}\n" }, + .{ .keys = "10l\x1baix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1bid", .want = "{\"a\": , \"b\": true}\n" }, + .{ .keys = "6lx\x1bid", .want = "\n" }, + .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" }, + .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" }, + // insert Tab after text leaves the node (smart tab) + .{ .keys = "8li\tX\x1b", .want = "{\"a\": [1, 2, 3]X, \"b\": true}\n" }, + }) |c| { + const pane = try p.setTestFile(text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, "/test.json"); + // ESC before a key is Alt on it; a lone ESC is Esc; `>` is Alt-right + var i: usize = 0; + while (i < c.keys.len) : (i += 1) { + const ch = c.keys[i]; + if (ch == 0x1b and i + 1 < c.keys.len) { + i += 1; + const k = c.keys[i]; + p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } }); + } else if (ch == 0x1b) { + p.update(.{ .key = .{ .cp = Key.escape } }); + } else if (ch == '\t') { + p.update(.{ .key = .{ .cp = Key.tab } }); + } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s}\n", .{c.keys}); + return err; + }; + } +} + +test "one cursor holds no room for other ranges; a second range makes it and one again gives it back" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\nalpha beta\n"); + for ("wxe%;gg") |cp| handleNormal(p, &pane.body, .{ .cp = cp }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + try std.testing.expect(pane.body.sels.len > 0); + handleNormal(p, &pane.body, .{ .cp = 'l' }); // replayed at both, still two + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + handleNormal(p, &pane.body, .{ .cp = ',' }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + handleNormal(p, &pane.body, .{ .cp = ';' }); + handleNormal(p, &pane.body, .{ .cp = 'x' }); // x at both: still two lines + handleNormal(p, &pane.body, .{ .cp = '_', .alt = true }); // merged to one + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); +} + +test "tree-sitter textobject keys do in Python, JSON and Zig what hx 25.07.1 does" { + if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json") or !syntax.supportsPath("a.zig")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { path: []const u8, text: []const u8, keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]fd", .want = "def alpha(a, b):\n return a + b\n\n\n\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]f]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "2]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "Gk[fd", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmifd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmafd", .want = "\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmiad", .want = "def alpha(, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmaad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]Td", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]cd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n \n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "v]fd", .want = "\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]td", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]ad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" }, + // Zig: the query is helix's, ported to pardes's zig grammar. No case + // walks call arguments: the reference helix counts them as + // parameters and the installed 25.07.1's query does not. + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmifd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 \n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmafd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmitd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmatd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]Td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\n\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jmiTd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" \n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmiad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmaad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add( b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]cd", .want = "const std = @import(\"std\");\n\n\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "4jmacd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jf2[ad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, ) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16j[fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]t]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\n\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + }) |c| { + const pane = try p.setTestFile(c.text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, c.path); + for (c.keys, 0..) |ch, i| { + p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s} on {s}\n", .{ c.keys, c.path }); + return err; + }; + } +} diff --git a/src/panes.zig b/src/panes.zig index d4b336d7..8049da4e 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -24,6 +24,9 @@ const Key = pardes.Key; pub const EditText = struct { text: []u8, row0: i32 }; pub const Pane = struct { + /// The words that throw unsaved text away (builtins.zig warnModified). + pub const Discarding = enum { Exit, Restore }; + /// One mouse selection (block-shaped), per button. c/r are text-area relative; /// r counts from the tag's first row (the body starts at Pane.tag_rows). pub const Sel = struct { @@ -69,6 +72,10 @@ pub const Pane = struct { /// Del from the keyboard with open panes both above and below: which /// one takes the rows. Nothing is typed; one key answers. del_side, + /// An exec from a file's body with several REPLs bound for its + /// language: which one takes it (exec.zig routeToRepl). One key + /// answers, the question being in `question`. + repl_choice, /// A new name for the file, drafted in place of the path on the /// tagline after a click there; Enter commits it, as a buffer name /// and never a disk rename. @@ -99,9 +106,39 @@ pub const Pane = struct { /// middle click, a pty/run): its first word, which Kill matches, and /// the shell's command count its start (C mark) reaches. sent_command: ?struct { word: [64]u8 = undefined, len: u8 = 0, seq: u32 = 0 } = null, + /// A command pane (exec.zig runCommand): the line its child runs as + /// `$SHELL -c`, owned, where an interactive terminal has null. + command: ?[]u8 = null, + /// The command's child has exited, and with what status (null: unknown, + /// shown `exit ?`). + command_done: bool = false, + command_status: ?u8 = null, + /// Its pty is still open: a job the command left in the background may + /// be printing to it, and a reuse would hang it up. + command_pty: bool = false, + /// The shell a terminal runs when it is not the configured one (`Tty + /// fish`), owned. + shell: ?[]u8 = null, + /// An interactive terminal's tag text naming its shell, ` Tty+fish ...` + /// (Pardes.acknowledgeShell); empty until the host says which it ran. + shell_tail: [96]u8 = undefined, + shell_tail_len: u8 = 0, + /// A terminal bound as a language's REPL (`Repl python`): an exec from + /// the body of a file in that language is sent to it (exec.zig + /// routeToRepl). `letter` makes its id, `python-a`, with the language's + /// name from grammar_manifest. + repl: ?struct { lang: u8, letter: u8 } = null, + /// It has been told that its program takes no bracketed paste. + repl_warned: bool = false, + /// A question with its answers in it (`repl_choice`). + question: [96]u8 = undefined, + question_len: u8 = 0, + /// The newest message says where an exec was sent: it is tinted. + msg_sent: bool = false, /// The revision an Exit or a Restore last warned this pane was modified - /// at (builtins.zig warnModified). - discard_warned: ?u32 = null, + /// at, and which of them: only that word goes ahead on the warning + /// (builtins.zig warnModified). + discard_warned: ?struct { revision: u32, by: Discarding } = null, /// Native host queues the Linux v9fs launcher in this pane's initial shell. v9fs_on_spawn: bool = false, file: ?File.State = null, @@ -120,9 +157,13 @@ pub const Pane = struct { search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, + /// `gw`'s words, labelled in order while it waits for a label to be + /// typed (gpa-owned); `jump_first` is the label's first letter once typed. + jump: std.ArrayList(modal.Selection) = .empty, + jump_first: ?u8 = null, sel_snap: [Text.max_selections]modal.Selection = undefined, - nsel_snap: u8 = 0, - sel_snap_pri: u8 = 0, + nsel_snap: u16 = 0, + sel_snap_pri: u16 = 0, sel_snap_expl: bool = false, /// The tag: what the user keeps after the computed prefix (the path, the /// dirty marker, a PDF's page), its cursor, selections and undo. The @@ -168,6 +209,8 @@ pub const Pane = struct { msg_len: u16 = 0, /// Where the newest message line is in its life. msg_life: MessageLife = .{}, + /// When it was said, in monotonic ms (Messages.expireUnattended). + msg_said_ms: i64 = 0, /// The row (among the notice rows) the newest line holds for its life. msg_slot: u8 = 0, /// Earlier message lines still on screen, oldest first, each running its @@ -340,6 +383,7 @@ pub const Pane = struct { return switch (p.prompt) { .none, .name => null, .del_side => config.del_question, + .repl_choice => p.question[0..p.question_len], else => p.input.own orelse "", }; } diff --git a/src/pardes.zig b/src/pardes.zig index 5fa3307b..f559b125 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -20,6 +20,8 @@ pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const draw = @import("draw.zig"); pub const Messages = @import("Messages.zig"); +pub const Registers = @import("Registers.zig"); +pub const Macro = @import("Macro.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; @@ -455,6 +457,8 @@ test { _ = @import("regexp.zig"); _ = @import("edit.zig"); _ = @import("Messages.zig"); + _ = @import("Registers.zig"); + _ = @import("Macro.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); @@ -555,7 +559,7 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le p.perform(.{ .detach = asked }); const pane = p.panes[0].?; - try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); + try std.testing.expectEqualStrings("detach: not attached", pane.msg[0..pane.msg_len]); try std.testing.expect(!p.quit); } @@ -626,8 +630,9 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { while (p.nextEffect()) |_| {} // initial shell spawn/geometry p.acknowledgeShell(0, "/bin/sh", false); + // The Tty word names the shell the host ran, and opens another of it. const pane = p.panes[0].?; - try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(pane)); + try std.testing.expectEqualStrings(" Tty+sh" ++ tagline.terminal_pane_tail[" Tty".len..], tagline.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; @@ -649,6 +654,7 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { }; try std.testing.expectEqual(panes_before + 1, panes_after); try std.testing.expect(p.panes[p.active].?.isTerminal()); + try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?); // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. @@ -1186,7 +1192,7 @@ test "first document fallback splits like Tty from the originating pane" { const free = p.freeSlot().?; const doc = try p.openTutorView(free); exec.placeDoc(p, from, free, doc); - exec.spawnTty(tty, from); + _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); try std.testing.expectEqual(@as(usize, 2), p.ncol); @@ -1277,34 +1283,35 @@ test "an unsaved file marker sits between its path and builtins until Save" { try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } -test "unknown Exec from an image writes to a terminal in the image directory" { +test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.acknowledgeShell(0, "/bin/sh", false); + // A terminal free at a prompt in the same directory is not borrowed. p.setCwd(0, "/tmp/pardes-image-dir"); const image_doc = try p.newDocPane(1); image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc)); - try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 1, "echo image-fallback")); + const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; + try std.testing.expect(dst != 0 and dst != 1); + try std.testing.expectEqualStrings("echo image-fallback", p.panes[dst].?.command.?); - var sent: [256]u8 = undefined; - var sent_len: usize = 0; + var spawned = false; while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - try std.testing.expectEqual(@as(u8, 0), w.pane); - try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; + .write => return error.TypedIntoATerminal, + .spawn => |sp| if (sp.pane == dst) { + try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); + spawned = true; }, - .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal, else => {}, }; - try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); + try std.testing.expect(spawned); } + const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, @@ -1322,7 +1329,7 @@ const FakeTtyQuery = struct { } }; -test "Exec in a terminal whose tty is taken spawns a shell instead of typing at the program" { +test "Exec in a terminal whose tty is taken runs as a command pane instead of typing at the program" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); @@ -1335,42 +1342,384 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at host.install(p); host.taken[0] = true; const dst = exec.execute(p, 0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; - // somewhere ELSE — and still without moving focus, which is execute's + // somewhere ELSE -- and still without moving focus, which is execute's // contract and the whole difference between it and a look try std.testing.expect(dst != 0); try std.testing.expectEqual(@as(usize, 0), p.active); - - var spawned: ?Effect = null; - var sent: [256]u8 = undefined; - var sent_len: usize = 0; + try std.testing.expectEqualStrings("echo taken-fallback", p.panes[dst].?.command.?); + var spawned = false; while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; + .write => return error.WroteACommandLineIntoATerminal, + .spawn => |sp| if (sp.pane == dst) { + // ...in the directory the command was about, the taken pane's own + try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); + spawned = true; }, - .spawn => spawned = effect, else => {}, }; - const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; - try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); - // ...in the directory the command was about, which is the taken pane's own - try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); - try std.testing.expectEqual(@as(usize, 0), sent_len); - p.acknowledgeShell(dst, "/bin/sh", false); - while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; - }, - else => {}, + try std.testing.expect(spawned); +} + +test "a command of several lines keeps its tag one row, and its echo does not staircase" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const doc = try p.newDocPane(1); + doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pic.ppm") }; + const two = "def greet(name):\n return name\n\nprint(greet(\"x\"))\n"; + const dst = exec.execute(p, 1, two) orelse return error.NoCommandPane; + while (p.nextEffect()) |_| {} + const pane = p.panes[dst].?; + const prefix = try tagline.tagPrefix(p, pane); + try std.testing.expect(std.mem.indexOfScalar(u8, prefix, '\n') == null); + try std.testing.expect(std.mem.indexOf(u8, prefix, "(def greet(name):...) running") != null); + // Rendered without a panic, however the tag wraps. + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 0 } }); + _ = exec.execute(p, 1, two); + while (p.nextEffect()) |_| {} + _ = try p.render(frame.allocator()); + const body = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(body); + try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null); +} + +test "a spawn is for the pane it was made for, not whatever took the slot since" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const Count = struct { + var spawns: usize = 0; + var command: [32]u8 = undefined; + var core: *Pardes = undefined; + const vtable: Host.VTable = .{ .spawn = spawn }; + fn spawn(_: ?*anyopaque, pane: u8, _: []const u8) void { + spawns += 1; + const line = core.panes[pane].?.command.?; + @memcpy(command[0..line.len], line); + } + }; + Count.core = p; + p.host = .{ .vtable = &Count.vtable }; + // A command pane closed and its slot taken, in one step, before the + // effects run: only the second's spawn is performed. + const slot = p.freeSlot().?; + _ = try p.newCommand(slot, "", "first"); + try p.removePane(slot, null); + try std.testing.expectEqual(slot, p.freeSlot().?); + _ = try p.newCommand(slot, "", "second"); + while (p.nextEffect()) |effect| p.perform(effect); + try std.testing.expectEqual(@as(usize, 1), Count.spawns); + try std.testing.expectEqualStrings("second", Count.command[0.."second".len]); +} + +test "a REPL bound for a language takes the exec made on a body in it; tags, Exec by name and command words still run" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const file = try p.setTestFile("print(1)\nls\n"); + gpa.free(file.file.?.path); + file.file.?.path = try gpa.dupe(u8, "/x.py"); + const Sent = struct { + var to: [MAX_PANES][128]u8 = undefined; + var len: [MAX_PANES]usize = @splat(0); + var spawned: bool = false; + fn drain(pp: *Pardes) void { + len = @splat(0); + spawned = false; + while (pp.nextEffect()) |effect| switch (effect) { + .write => |w| { + @memcpy(to[w.pane][len[w.pane]..][0..w.bytes.slice().len], w.bytes.slice()); + len[w.pane] += w.bytes.slice().len; + }, + .spawn => spawned = true, + else => {}, + }; + } + fn said(pane: usize) []const u8 { + return to[pane][0..len[pane]]; + } + }; + // The programs the REPLs run hold their terminals' ttys. + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[1] = true; + host.taken[2] = true; + // Only a terminal is a REPL; a language's name is the syntax table's. + try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); + try std.testing.expect(file.repl == null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl pithon")); + try std.testing.expect(p.panes[1].?.repl == null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[1].?.msg[0..p.panes[1].?.msg_len], "python") != null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); + // again, as a code fence may name it: no change + try std.testing.expect(p.executeBuiltinLine(1, "Repl PY")); + var idbuf: [48]u8 = undefined; + try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, p.panes[1].?), " python-a")); + Sent.drain(p); + + // A click on the file's body, a single word: typed into the REPL. + try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 0, "ls", true)); + Sent.drain(p); + try std.testing.expectEqualStrings("ls\r", Sent.said(1)); + try std.testing.expect(!Sent.spawned); + try std.testing.expect(file.msg_sent); + try std.testing.expectEqualStrings("\u{2192} python-a", file.msg[0..file.msg_len]); + // A body's click written back to event is done as the click: sent. A + // line written to exec came back as F and runs as the command it was. + { + const th = @import("ninep/testing.zig"); + const event = ctlfs.Node.of(file.serial, .event); + const h = th.call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + defer _ = th.call(p, .{ .tag = 2, .op = .release, .node = event, .handle = h }); + // The 9P harness performs the effects: what it wrote is its answer's. + const sent = th.wr(p, event, "MX0 8\n"); + try std.testing.expectEqualStrings("print(1)\r", sent.pty_buf[0..sent.pty_len]); + const ran = th.wr(p, event, "FX0 0 2 2 ls\n"); + try std.testing.expect(ran.pty_len == 0 and ran.spawned); + // The pane's ctl line ends with the id it is bound as. + var line: [ctlfs.ctl.pane_text_cap]u8 = undefined; + try std.testing.expect(std.mem.endsWith(u8, ctlfs.ctl.paneText(p, p.panes[1].?, &line), " python-a ")); + } + // What is sent is plain text: CRLF is LF, no ESC or other control. + _ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true); + Sent.drain(p); + try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1)); + // Several selections executed at once: each is sent, not the first. + { + const text = try edit.flatSurface(p, &file.body); + file.body.setRanges(text, &.{ .{ .anchor = 0, .head = 8 }, .{ .anchor = 9, .head = 11 } }, &.{}, 0, true); + try std.testing.expect(exec.chordEachSel(p, file, .Exec)); + Sent.drain(p); + try std.testing.expectEqualStrings("print(1)\rls\r", Sent.said(1)); + } + // Its own body too. + try std.testing.expectEqual(@as(?usize, 1), exec.executeFrom(p, 1, "x = 1", true)); + Sent.drain(p); + try std.testing.expectEqualStrings("x = 1\r", Sent.said(1)); + + // Escapes: a tag's word, Exec by name, a command word. Each a command. + _ = exec.execute(p, 0, "ls"); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + try std.testing.expect(p.executeBuiltinLine(0, "Exec ls")); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + _ = exec.executeFrom(p, 0, "@`ls`", true); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + + // Two bound: it asks, one key answers, Esc sends nowhere. + try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); + try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); + Sent.drain(p); + try std.testing.expectEqual(@as(?usize, null), exec.executeFrom(p, 0, "print(2)", true)); + try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); + try std.testing.expect(std.mem.indexOf(u8, file.promptText().?, "a b") != null); + p.update(.{ .key = .{ .cp = 'b' } }); + Sent.drain(p); + try std.testing.expectEqualStrings("print(2)\r", Sent.said(2)); + try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none); + _ = exec.executeFrom(p, 0, "print(3)", true); + p.update(.{ .key = .{ .cp = Key.escape } }); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + // The pane a letter offered is the one it answers for: unbound while + // the question stood, it is sent nothing, and that is said. + _ = exec.executeFrom(p, 0, "print(4)", true); + try std.testing.expect(p.executeBuiltinLine(2, "Repl -")); + p.update(.{ .key = .{ .cp = 'b' } }); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + try std.testing.expectEqualStrings("Repl: the REPL chosen is gone; nothing sent", file.msg[0..file.msg_len]); + try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); + try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); + + // A REPL whose program has left (Ctrl-D: the shell has the tty) is + // sent nothing, and nothing runs: it says so. + host.taken[1] = false; + host.taken[2] = false; + _ = exec.executeFrom(p, 0, "import os", true); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0 and !Sent.spawned); + try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "no program running") != null); + host.taken[1] = true; + host.taken[2] = true; + + // Unbound, its letter is free again; bare, it says what it is. + try std.testing.expect(p.executeBuiltinLine(1, "Repl -")); + try std.testing.expect(p.panes[1].?.repl == null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); + try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); + try std.testing.expect(p.executeBuiltinLine(2, "Repl")); + try std.testing.expectEqualStrings("Repl: python-b", p.panes[2].?.msg[0..p.panes[2].?.msg_len]); + + // A command pane is a REPL while its command runs: done, it is none, + // and a done one cannot be bound. A document pane is no terminal. + const cmd = p.freeSlot().?; + _ = try p.newCommand(cmd, "", "python3"); + try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python")); + try std.testing.expect(p.panes[cmd].?.repl != null); + p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 0 } }); + try std.testing.expect(p.panes[cmd].?.repl == null); + try std.testing.expect(p.executeBuiltinLine(cmd, "Repl python")); + try std.testing.expect(p.panes[cmd].?.repl == null); + const doc_slot = p.freeSlot().?; + const doc = try p.newDocPane(doc_slot); + try std.testing.expect(p.executeBuiltinLine(doc_slot, "Repl python")); + try std.testing.expect(doc.repl == null); +} + +test "a restored terminal's tag names neither the REPL it was nor the shell it had" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.acknowledgeShell(0, "/usr/bin/fish", true); + var host: FakeTtyQuery = .{}; + host.install(p); + try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "test"); + defer restored.deinit(); + const pane = restored.panes[0].?; + try std.testing.expect(pane.repl == null and pane.tag.own == null); + const shown = try tagline.tagText(restored, restored.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, shown, "python-a") == null); + try std.testing.expect(std.mem.indexOf(u8, shown, "Tty+fish") == null); +} + +test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + // sh, the one shell every host has. + try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh")); + try std.testing.expect(p.active != 0); + try std.testing.expectEqualStrings("sh", p.panes[p.active].?.shell.?); + p.acknowledgeShell(p.active, "/bin/sh", false); + try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+sh ")); + p.active = 0; + // A shell that is not there is said, not quietly replaced, and the + // pane that asked gets no shell of its own. + const before = p.active; + const caller = p.panes[0].?; + try std.testing.expect(p.executeBuiltinLine(0, "Tty /nonexistent")); + try std.testing.expect(p.active == before and caller.shell == null); + try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null); + try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); + try std.testing.expect(caller.shell == null); + // Only a word that says so splits at `+`: `Dump+x.zon` is no Dump. + try std.testing.expect(!p.executeBuiltinLine(0, "Dump+x.zon")); + try std.testing.expect(!p.executeBuiltinLine(0, "Msg+hello")); +} + +test "a command pane shows how its command ended, and the next command there runs below it" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{}); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.setCwd(0, "/tmp/pardes-cmd"); + const doc = try p.newDocPane(1); + doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-cmd/pic.ppm") }; + + const dst = exec.execute(p, 1, "make -j8") orelse return error.NoCommandPane; + while (p.nextEffect()) |_| {} + const pane = p.panes[dst].?; + // Its first command is echoed as every later one is. + { + const first = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(first); + try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8")); + } + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) running")); + // Running, a second command goes to a pane of its own. + const other = exec.execute(p, 1, "ls") orelse return error.NoCommandPane; + try std.testing.expect(other != dst); + while (p.nextEffect()) |_| {} + p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } }); + // Its exit comes first, a job it left still printing: done, and not + // the next command's until its pty closes. + p.update(.{ .exited = .{ .pane = @intCast(dst), .status = 2 } }); + try std.testing.expect(pane.command_done and pane.command_pty); + const busy = exec.execute(p, 1, "true") orelse return error.NoCommandPane; + try std.testing.expect(busy != dst); + while (p.nextEffect()) |_| {} + // Its output ending: now it may be reused. + p.update(.{ .eof = .{ .pane = @intCast(dst) } }); + try std.testing.expect(p.panes[dst] == pane and !pane.command_pty); + try std.testing.expect(p.panes[dst] == pane); + try std.testing.expect(pane.command_done); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) exit 2")); + // Kill leaves a finished one be. + try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); + while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) return error.SignalledAFinishedCommand; + // A program that crashed out of its full screen leaves the emulator + // in its modes; the next command starts clear of them. + p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } }); + try std.testing.expect(panes.Terminal.bracketedPaste(pane)); + // Done, the directory's next command runs there, below what it showed. + try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test")); + try std.testing.expect(!panes.Terminal.bracketedPaste(pane)); + var respawned = false; + while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) { + respawned = true; + }; + try std.testing.expect(respawned); + const body = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(body); + const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost; + const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost; + const next = std.mem.indexOf(u8, body, "% make test") orelse return error.NoSeparator; + try std.testing.expect(said < ended and ended < next); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make test) running")); + // Kill stops a running one: its whole process group, SIGTERM. + try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); + var signalled = false; + while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) { + try std.testing.expectEqual(PtySignal.term, effect.signal_pty.sig); + signalled = true; }; - try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]); + try std.testing.expect(signalled); + // The log says what ran and how it ended. + const log = try ctlfs.events.freezeLog(p); + defer gpa.free(log.bytes); + var want: [64]u8 = undefined; + // The whole line, after the `new` of the pane it runs in. + const ran = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make -j8\n", .{pane.serial})) orelse return error.NoRunRecord; + const made = std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} ", .{pane.serial})) orelse return error.NoNewRecord; + try std.testing.expect(made < ran); + try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "exit {d} 2\n", .{pane.serial})) != null); + // A signal for a pane is not performed on whoever takes its slot. + const Count = struct { + var signals: usize = 0; + const vtable: Host.VTable = .{ .pty_signal = signal }; + fn signal(_: ?*anyopaque, _: u8, _: PtySignal) void { + signals += 1; + } + }; + const host_before = p.host; + p.host = .{ .vtable = &Count.vtable }; + try std.testing.expect(p.executeBuiltinLine(0, "Kill make")); + try p.removePane(dst, null); + _ = try p.newCommand(dst, "", "other"); + while (p.nextEffect()) |effect| if (effect == .signal_pty) p.perform(effect); + try std.testing.expectEqual(@as(usize, 0), Count.signals); + p.host = host_before; + // A command line longer than a command pane takes is refused. + const long = "x" ** (exec.command_max + 1); + try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 1, long)); } + test "image dump restores source bytes renderer choices and exact custom tail" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -1423,50 +1772,6 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?); } -test "Exec from a document pane skips an occupied terminal in its directory and spawns" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{}); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - p.setCwd(0, "/tmp/pardes-image-dir"); - const image_doc = try p.newDocPane(1); - image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; - var host: FakeTtyQuery = .{}; - host.install(p); - host.taken[0] = true; - - const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; - try std.testing.expect(dst != 0 and dst != 1); - - var spawned: ?Effect = null; - var sent: [256]u8 = undefined; - var sent_len: usize = 0; - while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; - }, - .spawn => spawned = effect, - else => {}, - }; - const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; - try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); - try std.testing.expectEqual(@as(usize, 0), sent_len); - p.acknowledgeShell(dst, "/bin/sh", false); - while (p.nextEffect()) |effect| switch (effect) { - .write => |w| { - try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); - @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); - sent_len += w.bytes.slice().len; - }, - else => {}, - }; - try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); -} test "a Look on a directory does not type ls into an occupied terminal" { const gpa = std.testing.allocator; @@ -1569,11 +1874,13 @@ test "the host is asked about a tty only where a command line is about to go" { while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); + // ...and a taken one is asked once too: the line then runs as a command + // pane, with no other terminal to ask about. host.taken[0] = true; host.asked = 0; _ = exec.execute(p, 0, "echo lazy-again"); while (p.nextEffect()) |_| {} - try std.testing.expectEqual(@as(usize, 2), host.asked); + try std.testing.expectEqual(@as(usize, 1), host.asked); } test "New opens an empty scratch below the caller, inheriting its directory" { @@ -1626,14 +1933,14 @@ test "owned cwd preserves long paths aliases and failed updates" { try std.testing.expect(p.panes[free] == null); exec.saveTo(p, 0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null); const current = pane.cwdSlice(); allocator.fail_index = allocator.alloc_index; p.setCwd(0, "/replacement"); try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "out of memory") != null); allocator.fail_index = std.math.maxInt(usize); p.setCwd(0, "/replacement"); try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); @@ -1765,7 +2072,7 @@ test "owned cwd restore either copies the directory or preserves the old core" { for (0..256) |failure| { allocator.has_induced_failure = false; allocator.fail_index = allocator.alloc_index + failure; - const replacement = dump.restore(p, p.dump_out.?) catch { + const replacement = dump.restore(p, p.dump_out.?, "test") catch { try std.testing.expect(allocator.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); @@ -2155,7 +2462,7 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one - p.yank = try gpa.dupe(u8, "copied"); + p.registers.put(gpa, '+', "copied", 0, 1, true); p.perform(.set_clipboard); try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); @@ -2184,7 +2491,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu }; const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; exec.runBuiltin(p, help, 0, "", null); - try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null); } test "Font keeps requested and acknowledged faces as separate plain state" { @@ -2717,14 +3024,14 @@ test "editable workspace and column tags are typed into and persist" { try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); - const restored = try dump.restore(p, p.dump_out.?); + const restored = try dump.restore(p, p.dump_out.?, "test"); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); - const empty = try dump.restore(p, p.dump_out.?); + const empty = try dump.restore(p, p.dump_out.?, "test"); defer empty.deinit(); try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } @@ -2948,7 +3255,7 @@ test "s in a header selects in the header's own text" { const text = try edit.flatSurface(p, &p.global_tag); const got = p.global_tag.ranges(text, 0, &ranges); try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); } test "Enter in a header runs the word under its cursor, as the execute key does" { @@ -3076,7 +3383,13 @@ pub const Event = union(enum) { row_metrics: ?RowMetrics = null, }, output: struct { pane: u8, bytes: []const u8 }, - eof: struct { pane: u8 }, + /// The pane's pty reached end of file: its output is over. An + /// interactive terminal closes; a command pane waits for `exited`. + eof: struct { pane: u8, status: ?u8 = null }, + /// A command pane's child exited, with this status (null: unknown). It + /// is the command's end, whatever still holds its pty (a job it left in + /// the background prints on until end of file). + exited: struct { pane: u8, status: ?u8 }, lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, @@ -3114,10 +3427,14 @@ pub const WatchMode = enum { reconcile, baseline_disk }; pub const effect_path_cap = 256; pub const Effect = union(enum) { - spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, + /// A child for the pane in slot `pane` -- the one whose serial is + /// `serial`: a pane closed and its slot taken before the effect is + /// performed is not the one it was for (perform). + spawn: struct { pane: u8, serial: u32, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, - signal_pty: struct { pane: u8, sig: PtySignal }, + /// For the pane whose serial is `serial`, as `spawn` is (perform). + signal_pty: struct { pane: u8, serial: u32, sig: PtySignal }, /// The terminal pane in this slot is gone: the shell hangs up its pty /// and ends the shell, rather than leave it running with nobody to read. close_pty: struct { pane: u8 }, @@ -3801,6 +4118,9 @@ pub const Pardes = struct { /// it is dragged instead of once on release. native_image_patches: bool = false, quit: bool = false, + /// A detached session with no frontend attached: nothing renders, so + /// no input ever dismisses a message (Messages.expireUnattended). + unattended: bool = false, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: mouse.Drag = .none, hover_col: u16 = 0, @@ -3819,6 +4139,14 @@ pub const Pardes = struct { multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, + /// While a key is replayed across the ranges: which range this pass is, + /// in document order, and how many there are. A register value belongs + /// to its range by this index. + multi_index: usize = 0, + multi_count: usize = 1, + /// The register the running normal command names (`"<reg>`); 0 for the + /// default one. + register: u21 = 0, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, @@ -3839,6 +4167,11 @@ pub const Pardes = struct { chord_arg: ?[]u8 = null, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, + /// An exec waiting on the REPL question (`Pane.Prompt.repl_choice`): + /// the pane it came from, the language, and the text, owned. + /// `offered` holds, by letter, the serial of the pane each answer named + /// when asked: a letter answered later is that pane or nothing. + repl_ask: ?struct { from: u32, lang: u8, text: []u8, offered: [26]u32 = @splat(0) } = null, /// A key press is being handled: someone is at the keyboard to answer, /// so a builtin may ask rather than guess. Nothing else sets it -- not /// a click, a 9P write, a startup line or a pane's shell exiting. @@ -3901,7 +4234,10 @@ pub const Pardes = struct { in_head: usize = 0, in_len: usize = 0, - yank: ?[]u8 = null, + registers: Registers = .{}, + macro: Macro = .{}, + /// an edited text nothing kept (a tag's shown text): see edit.retireEditText + retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -3947,6 +4283,8 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .col_tags = @splat(.{ .what = .column, .gpa = gpa }), + .global_tag = .{ .what = .workspace, .gpa = gpa }, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, @@ -4055,6 +4393,7 @@ pub const Pardes = struct { pub fn deinit(p: *Pardes) void { look.cancelLookHover(p); + if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); @@ -4071,7 +4410,9 @@ pub const Pardes = struct { const gpa = p.gpa; for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); - if (p.yank) |y| gpa.free(y); + p.registers.deinit(gpa); + p.macro.deinit(gpa); + if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); @@ -4097,6 +4438,7 @@ pub const Pardes = struct { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| p.gpa.free(old); p.last_dump = copy; + ctlfs.events.notePath(p, "dump", path); } /// the shell polls this each frame: a pending Restore's dump path, or null @@ -4181,6 +4523,8 @@ pub const Pardes = struct { p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); panes.Terminal.deinitPendingCommand(pane); + if (pane.command) |line| p.gpa.free(line); + if (pane.shell) |bin| p.gpa.free(bin); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } @@ -4190,6 +4534,7 @@ pub const Pardes = struct { pane.body.deinit(p.gpa); pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); + pane.jump.deinit(p.gpa); panes.Terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); @@ -4222,7 +4567,27 @@ pub const Pardes = struct { const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } }); + return pane; + } + + /// A command pane: a terminal whose child is `$SHELL -c line` rather + /// than an interactive shell, left showing its output and `exit N` when + /// it is done (exec.zig runCommand). + pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane { + std.debug.assert(p.panes[id] == null); + if (cwd.len > effect_path_cap) return error.PathTooLong; + const owned = try p.gpa.dupe(u8, line); + errdefer p.gpa.free(owned); + const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + pane.command = owned; + pane.command_pty = true; + pane.body.mode = .tty; + // Its directory is the one it was run for, and says which of the + // directory's commands reuse it; the child's own cd does not move it. + pane.setOwnedCwd(cwd) catch {}; + p.installPane(id, pane); + p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } }); return pane; } @@ -4300,6 +4665,14 @@ pub const Pardes = struct { if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); } + /// How many bytes `emitWrite` can take for pane `id` now without + /// dropping any: the effects left, then what the pending buffer holds. + pub fn writeRoom(p: *const Pardes, id: usize) usize { + const pending = limits.pending_write_cap -| p.pending_write_bytes; + if (p.pending_write[id] != null) return pending; + return (p.effects.len - p.effects_len) * 64 + pending; + } + fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { if (comptime limits.pending_write_cap == 0) return; if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; @@ -4322,6 +4695,8 @@ pub const Pardes = struct { /// changes it observes, e.g. via /proc/<pid>/cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; + // A command pane stays the directory's it was run for (newCommand). + if (pane.command != null) return; const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", @@ -4343,6 +4718,15 @@ pub const Pardes = struct { if (id < MAX_PANES) if (p.panes[id]) |pane| { ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran pane.fs.unmarked = !prompt_marks; + // The tag's Tty word names the shell the host ran, and a click + // on it (`Tty+fish`) opens another of the same. + if (pane.command == null) { + const base = std.fs.path.basename(executable); + const rest = tagline.terminal_pane_tail[" Tty".len..]; + if (std.fmt.bufPrint(&pane.shell_tail, " Tty+{s}{s}", .{ base, rest })) |tail| { + pane.shell_tail_len = @intCast(tail.len); + } else |_| {} + } }; if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) @@ -4557,14 +4941,18 @@ pub const Pardes = struct { pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { - .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + // The host reads the pane (its command, its shell) as it forks, + // so the slot must still hold the pane the spawn was for. + .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), - .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), + // A signal for a pane that closed, its slot retaken in the same + // step, would reach another pane's child. + .signal_pty => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), .close_pty => |c| if (v.close_pty) |f| f(p.host.ctx, c.pane), .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) @@ -4604,7 +4992,7 @@ pub const Pardes = struct { } }, .set_clipboard => { - const text = p.yank orelse ""; + const text = p.registers.text(p.gpa, '+') orelse ""; if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, @@ -4691,6 +5079,10 @@ pub const Pardes = struct { look.cancelLookHover(p); const reply = ctlfs.handle(p, req); if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply); + // A pane that joined a column holding only a stillborn scratch takes + // it now, before the answer: one read of index after the look shows + // the replacement done, and the log has its del with the new. + exec.replaceStillborn(p); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -4707,6 +5099,7 @@ pub const Pardes = struct { // the screen. p.needs_frame = p.needs_frame or ev != .tick; p.shell_rows.sweep(p.gpa); + Messages.expireUnattended(p); // An announcement stands in only until its own step says more. for (p.panes) |slot| if (slot) |pane| { pane.msg_announcement = false; @@ -4786,8 +5179,14 @@ pub const Pardes = struct { panes.Terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); }, - .eof => |e| p.removePane(e.pane, null) catch |err| { - if (p.panes[e.pane]) |pane| pane.body.mode = .normal; + .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) { + exec.commandDone(p, e.pane, e.status); + }, + .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) { + // Output is over; the command is over when its child exits. + pane.command_pty = false; + } else p.removePane(e.pane, null) catch |err| { + pane.body.mode = .normal; p.reportError(e.pane, "terminal exited; Del retries close", err); }, .lsp_resp => |r| p.lspResponse(r.id, r.rows), @@ -4896,11 +5295,42 @@ pub const Pardes = struct { } else panes.Terminal.enterTty(p, id); } + /// Type `keys` again (a macro, `.`): through the modal handling as + /// typed, except that no key leaves the text for pardes's own acts -- + /// Esc does not hop panes, and Enter and Tab neither look nor execute. + pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void { + if (p.macro.replaying >= 8) return; // a macro typing itself + p.macro.replaying += 1; + defer p.macro.replaying -= 1; + for (keys) |stored| { + var buf: [4]u8 = undefined; + p.handleKey(stored.typed(&buf)); + } + } + fn handleKey(p: *Pardes, key: Key) void { + if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key); // A question waiting on a pane takes the next key, whatever mode the // pane or a header edit is in: that key is its answer. for (p.panes, 0..) |slot, id| { const asked = slot orelse continue; + if (asked.prompt == .repl_choice) { + asked.prompt = .none; + const ask = p.repl_ask orelse return; + p.repl_ask = null; + defer p.gpa.free(ask.text); + // Its letter sends it there; anything else takes it back. + if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return; + const serial = ask.offered[key.cp - 'a']; + if (serial == 0) return; + // The pane offered, still that REPL and still running: it + // may have closed or been rebound while the question stood. + if (p.paneBySerial(serial)) |to| if (p.panes[to].?.repl) |r| + if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(p, to)) + return exec.sendToRepl(p, id, to, ask.text); + p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent"); + return; + } if (asked.prompt != .del_side) continue; asked.prompt = .none; if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k"); @@ -4934,7 +5364,7 @@ pub const Pardes = struct { // in it the chord is the program's -- vim's visual block, // readline's quoted-insert -- and swallowing it would make // Ctrl-V a black hole in every full-screen application. - if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text); + if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text); } return panes.Terminal.forwardKey(p, p.active, key); } @@ -5011,8 +5441,8 @@ pub const Pardes = struct { .search => if (submit) look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| if (target) |answered| - normal.applySelRegex(p, pane, answered, "", false), - .none, .del_side, .name => unreachable, + normal.applySelRegex(p, pane, answered, "", .select), + .none, .del_side, .repl_choice, .name => unreachable, } pane.closePrompt(); if (target) |answered| { @@ -5022,10 +5452,11 @@ pub const Pardes = struct { return; } edit.handleInsert(p, &pane.input, key); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } - if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { + if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key); + if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. const header_word = t.what == .column or t.what == .workspace; @@ -5058,32 +5489,59 @@ pub const Pardes = struct { pane.body.vsel.active = false; pane.body.msel.active = false; pane.body.select = false; - exec.runBuiltin(p, cmd, p.active, "", txt); + // The execute key on a body: text a bound REPL may take. + exec.runBuiltinFrom(p, cmd, p.active, "", txt, true); return; } } const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return; const word = edit.selectionText(p, pane, sel) catch return; - exec.runBuiltin(p, cmd, p.active, "", word); + exec.runBuiltinFrom(p, cmd, p.active, "", word, true); return; } if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; // A tag's prefix is read-only: a key whose edit reached into it // changes nothing, not even where the cursor is. - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; defer if (t.refused) { - t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); }; + const was = t.mode; switch (t.mode) { .normal => normal.handleNormal(p, t, key), - .insert => { - if (hit(key, config.escape)) return edit.exitInsert(p, t); - edit.handleInsert(p, t, key); - }, + .insert => if (hit(key, config.escape) and !t.reg_wait) + edit.exitInsert(p, t) + else + edit.handleInsert(p, t, key), .tty => panes.Terminal.forwardKey(p, p.active, key), } + // `.`'s log: the keys of the normal command that entered insert + // mode, then everything typed until it is left + if (p.macro.repeating) return; + const m = &p.macro; + switch (was) { + .normal => { + Macro.append(&m.command, p.gpa, key); + if (t.mode == .insert) { + m.last_insert.clearRetainingCapacity(); + m.last_insert.appendSlice(p.gpa, m.command.items) catch {}; + m.typed_from = m.last_insert.items.len; + m.inserting = true; + m.command.clearRetainingCapacity(); + } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity(); + }, + .insert => if (m.inserting) { + Macro.append(&m.last_insert, p.gpa, key); + if (t.mode != .insert) m.inserting = false; + }, + .tty => {}, + } } fn leaderKey(p: *Pardes, key: Key) void { @@ -5480,7 +5938,10 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. + /// sends them when that is null. Focus on it follows its rows: to the + /// pane that grew into them, or to the scratch left standing in an + /// emptied column. Every route that closes a pane comes through here or + /// removeColumn, so none picks focus for itself, and none by recency. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; // A column outlives its panes. Closing the last one leaves an empty @@ -5507,10 +5968,11 @@ pub const Pardes = struct { layout.insert(p, f.col, f.idx + 1, free); break :heir free; }; - layout.absorbVWeight(p, id, to); + const gainer = layout.absorbVWeight(p, id, to); + const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse { + if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { p.quit = true; p.emit(.quit); return; @@ -5519,6 +5981,11 @@ pub const Pardes = struct { pub fn removeColumn(p: *Pardes, id: usize) !void { const place = layout.findPane(p, id) orelse return; + const had_focus = layout.findPane(p, p.active); + const beside = if (had_focus != null and had_focus.?.col == place.col) + p.besideColumn(place.col, p.active) + else + null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; const count = p.col_n[place.col]; @@ -5532,7 +5999,7 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { p.quit = true; p.emit(.quit); return; @@ -5555,16 +6022,26 @@ pub const Pardes = struct { return null; } - pub fn prevFocus(p: *Pardes, closing: usize) ?usize { - var i = p.njumps; - while (i > 0) { - i -= 1; - const id = p.jumps[i].pane; - if (id != closing and p.panes[id] != null) return id; - } - for (p.panes, 0..) |slot, k| { - if (slot != null and k != closing) return k; + /// The pane that takes over `id`'s place when column `col` goes: in the + /// column layout.removePane gives the width to, the one level with the + /// middle of `id`. Asked before the column goes, while rects still hold. + fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + if (p.ncol < 2) return null; + const next = if (col > 0) col - 1 else col + 1; + const r = p.rects[id]; + const mid = r.y + r.h / 2; + var pick: ?usize = null; + for (p.col_panes[next][0..p.col_n[next]]) |k| { + if (p.panes[k] == null) continue; + pick = k; + if (p.rects[k].y +| p.rects[k].h > mid) break; } + return pick; + } + + /// Any live pane: the last resort when nothing took the closed one's place. + fn anyPane(p: *const Pardes) ?usize { + for (p.panes, 0..) |slot, k| if (slot != null) return k; return null; } @@ -6251,6 +6728,7 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { // A word that is not a side closes nothing. try std.testing.expect(p.executeBuiltinLine(again, "Del x")); + try std.testing.expectEqualStrings("Del: takes k, j or nothing", p.panes[again].?.msg[0..p.panes[again].?.msg_len]); try std.testing.expect(p.panes[again] != null); // DelAbove is Del k, and a line that is not a key never asks. try std.testing.expect(p.executeBuiltinLine(again, "DelAbove")); @@ -6278,6 +6756,59 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { try std.testing.expect(p.panes[low] == null); } +test "a Look that finds nothing says so" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const id = p.active; + p.sync(); + look.lookAt(p, id, "/no/such/zzqq"); + try std.testing.expectEqualStrings("Look: no match for \"/no/such/zzqq\"", p.panes[id].?.msg[0..p.panes[id].?.msg_len]); +} + +test "focus on a closed pane goes to the pane that takes its place, not the one focused before" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); + defer p.deinit(); + const shell = p.active; + p.newScratchColumn(shell); + const right = p.active; + p.sync(); + p.newScratchBelow(shell); + const low = p.active; + p.sync(); + // The dogfood case: `right` was focused just before `low`, but `shell` + // grows into `low`'s rows, so `shell` gets the focus. + try std.testing.expect(p.executeBuiltinLine(low, "Del")); + p.sync(); + try std.testing.expect(p.panes[low] == null); + try std.testing.expectEqual(shell, p.active); + + // Closing a pane that has no focus leaves the focus where it is. + p.newScratchBelow(shell); + const other = p.active; + p.sync(); + p.active = right; + p.sync(); + try std.testing.expect(p.executeBuiltinLine(other, "Del")); + try std.testing.expectEqual(right, p.active); + + // Delcol: the column that takes the width holds, level with the closed + // pane, the pane that takes its place; `right` was focused just before. + p.newScratchBelow(right); + const right_low = p.active; + p.newScratchBelow(shell); + const left_low = p.active; + p.active = right; + p.sync(); + p.active = left_low; + p.sync(); + try p.removeColumn(left_low); + p.sync(); + try std.testing.expectEqual(right_low, p.active); +} + test "closing the last pane in a column leaves an empty one in its place" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); @@ -6431,7 +6962,7 @@ test "raw tty Escape only leaves at a shell prompt" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); - exec.spawnTty(p, 0); + _ = exec.spawnTty(p, 0); const shell = p.active; const pane = p.panes[shell].?; var buf: [256]u8 = undefined; @@ -6462,7 +6993,7 @@ test "raw tty Ctrl-B switches to editor mode without sending child input" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" }); defer p.deinit(); - exec.spawnTty(p, 0); + _ = exec.spawnTty(p, 0); const shell = p.active; p.sync(); var buf: [256]u8 = undefined; @@ -6902,7 +7433,7 @@ test "jump history clears selections and lands at the recorded cursor" { try std.testing.expectEqual(@as(usize, 2), p.njumps); try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line); try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(.none, pane.sel[0].state); try std.testing.expect(pane.pointer_selections[0] == null); try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row); diff --git a/src/syntax.zig b/src/syntax.zig index 9c1a4f4b..8ed00bfa 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -3,6 +3,7 @@ const config = @import("pardes_config"); const tracy = @import("tracy.zig"); const grammar_manifest = @import("grammar_manifest.zig"); const look = @import("look.zig"); +const regexp = @import("regexp.zig"); const c_heap = @import("c_heap"); pub const enabled = config.syntax_highlighting; @@ -27,6 +28,10 @@ const Spec = struct { exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, + /// helix's textobjects.scm for the grammar (vendor/queries), "" for none + objects_src: []const u8 = "", + objects: ?*ts.Query = null, + objects_failed: bool = false, selected: ?Selected = null, capture_styles: [256]u8 = undefined, }; @@ -131,6 +136,7 @@ fn initSpecs() [specCount()]Spec { .exts = g.exts, .language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }), .query_src = querySrc(g), + .objects_src = @field(ts_queries, g.name ++ "_textobjects"), }; i += 1; } @@ -173,6 +179,9 @@ pub fn stop() void { selected.query.destroy(); } spec.selected = null; + if (spec.objects) |q| q.destroy(); + spec.objects = null; + spec.objects_failed = false; } ts_set_allocator(null, null, null, null); syntax_started = false; @@ -226,35 +235,8 @@ fn forExt(ext: []const u8) !?Selected { return null; } -const LangAlias = struct { []const u8, []const u8 }; -const lang_aliases = [_]LangAlias{ - .{ "js", "javascript" }, - .{ "jsx", "javascript" }, - .{ "mjs", "javascript" }, - .{ "py", "python" }, - .{ "sh", "bash" }, - .{ "shell", "bash" }, - .{ "zsh", "bash" }, - .{ "bash", "bash" }, - .{ "rs", "rust" }, - .{ "c++", "cpp" }, - .{ "cxx", "cpp" }, - .{ "cc", "cpp" }, - .{ "cs", "c_sharp" }, - .{ "kt", "kotlin" }, - .{ "rb", "ruby" }, - .{ "ml", "ocaml" }, - .{ "hs", "haskell" }, -}; - fn forLang(name: []const u8) !?Selected { - var canonical = name; - for (lang_aliases) |a| { - if (std.ascii.eqlIgnoreCase(name, a[0])) { - canonical = a[1]; - break; - } - } + const canonical = if (grammar_manifest.byName(name)) |i| grammar_manifest.all[i].name else name; for (&specs) |*spec| { if (std.ascii.eqlIgnoreCase(canonical, spec.name)) return try ensure(spec); } @@ -337,6 +319,234 @@ pub fn supportsPath(path: []const u8) bool { return false; } +/// A whole file's parse, kept by the file for the keys that walk its nodes +/// (helix's Alt-o and kin). Opaque outside this file; null without a grammar. +pub fn parseTree(path: []const u8, content: []const u8) ?*anyopaque { + if (!enabled) return null; + const selected = (forExt(std.fs.path.extension(path)) catch return null) orelse return null; + const tree = selected.parser.parseString(content, null) orelse return null; + return @ptrCast(tree); +} + +pub fn freeTree(tree: *anyopaque) void { + if (!enabled) return; + const t: *ts.Tree = @ptrCast(@alignCast(tree)); + t.destroy(); +} + +/// Where a node walk goes from a range (helix object.rs and movement.rs). +pub const NodeWalk = @import("modal.zig").Normal.NodeWalk; +pub const Span = struct { from: usize, to: usize }; + +/// The spans `walk` takes the byte range [from, to) to, into `out`; `at` is +/// the range's cursor. None when the tree has nothing to say there. +pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, at: usize, out: []Span) usize { + if (!enabled or out.len == 0) return 0; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const lo: u32 = @intCast(@min(from, std.math.maxInt(u32))); + const hi: u32 = @intCast(@min(to, std.math.maxInt(u32))); + if (walk == .parent_end or walk == .parent_start) { + var node = tree.rootNode().namedDescendantForByteRange(lo, hi) orelse return 0; + if (walk == .parent_end) { + out[0] = .{ .from = node.endByte(), .to = node.endByte() }; + return 1; + } + // already at the node's lo: its first ancestor that starts earlier + if (node.startByte() == at) { + const first = node.startByte(); + while (node.startByte() >= first or !node.isNamed()) node = node.parent() orelse break; + } + out[0] = .{ .from = node.startByte(), .to = node.startByte() }; + return 1; + } + var cursor = tree.walk(); + defer cursor.destroy(); + // helix TreeCursor.reset_to_byte_range: the smallest node holding the range + while (true) { + const node = cursor.node(); + if (lo < node.startByte() or hi > node.endByte()) { + _ = cursor.gotoParent(); + break; + } + if (cursor.gotoFirstChildForByte(lo) == null) break; + } + switch (walk) { + .expand => while (cursor.node().startByte() == lo and cursor.node().endByte() == hi) { + if (!cursor.gotoParent()) break; + }, + .shrink => _ = cursor.gotoFirstChild(), + .next_sibling => while (!cursor.gotoNextSibling()) { + if (!cursor.gotoParent()) break; + }, + .prev_sibling => while (!cursor.gotoPreviousSibling()) { + if (!cursor.gotoParent()) break; + }, + .all_siblings, .all_children => { + if (walk == .all_siblings) { + while (true) { + if (!cursor.gotoParent()) return 0; + if (cursor.node().childCount() > 1) break; + } + } + // the named children, or nothing to say + var n: usize = 0; + if (!cursor.gotoFirstChild()) return 0; + while (true) { + const child = cursor.node(); + if (child.isNamed() and n < out.len) { + out[n] = .{ .from = child.startByte(), .to = child.endByte() }; + n += 1; + } + if (!cursor.gotoNextSibling()) break; + } + return n; + }, + .parent_end, .parent_start => unreachable, + } + const node = cursor.node(); + out[0] = .{ .from = node.startByte(), .to = node.endByte() }; + return 1; +} + + +/// helix's textobjects, which its `]f`, `mif` and kin select: the name a +/// query's captures begin with (`function.around`, `test.inside`). +pub const Object = @import("modal.zig").Normal.Object; + +fn objectName(o: Object) []const u8 { + return if (o == .xml_element) "xml-element" else @tagName(o); +} + +/// The grammar's textobject query for `path`, compiled the first time; null +/// when the grammar has none. +fn objectQuery(path: []const u8) ?*ts.Query { + const ext = std.fs.path.extension(path); + for (&specs) |*spec| for (spec.exts) |choice| { + if (!std.ascii.eqlIgnoreCase(ext, choice)) continue; + if (spec.objects) |q| return q; + if (spec.objects_src.len == 0 or spec.objects_failed) return null; + var error_offset: u32 = 0; + spec.objects = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + spec.objects_failed = true; + return null; + }; + return spec.objects; + }; + return null; +} + +fn captureId(q: *const ts.Query, name: []const u8) ?u32 { + for (0..q.captureCount()) |i| if (std.mem.eql(u8, q.captureNameForId(@intCast(i)) orelse "", name)) return @intCast(i); + return null; +} + +/// A match's `capture`, as one span over all its nodes (helix's grouped +/// capture), when the match's `#eq?` and `#match?` hold. +fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) ?Span { + var span: ?Span = null; + for (match.captures) |c| if (c.index == capture) { + const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() }; + span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s; + }; + var found = span orelse return null; + // a node that takes its line's newline (this zig grammar's comments) + // is the line without it, as helix's grammars have it + if (found.to > found.from + 1 and found.to <= text.len and text[found.to - 1] == '\n') found.to -= 1; + const steps = q.predicatesForPattern(match.pattern_index); + var i: usize = 0; + while (i < steps.len) { + var end = i; + while (end < steps.len and steps[end].type != .done) end += 1; + const pred = steps[i..end]; + i = end + 1; + if (pred.len != 3 or pred[0].type != .string or pred[1].type != .capture) continue; + const op = q.stringValueForId(pred[0].value_id) orelse continue; + const subject = for (match.captures) |c| { + if (c.index == pred[1].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + const want = if (pred[2].type == .string) q.stringValueForId(pred[2].value_id) orelse continue else for (match.captures) |c| { + if (c.index == pred[2].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + if (std.mem.eql(u8, op, "eq?")) { + if (!std.mem.eql(u8, subject, want)) return null; + } else if (std.mem.eql(u8, op, "match?")) { + var re = regexp.Regex.compile(want) catch continue; + if ((re.find(subject, 0, subject.len, subject.len) catch null) == null) return null; + } + } + return found; +} + +/// `mi`/`ma`: the smallest `object.inside` (or `.around`) holding byte `at` +/// (helix textobject_treesitter). None without a query or such an object. +pub fn objectAt(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, around: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), if (around) "around" else "inside" }) catch return null; + const capture = captureId(q, want) orelse return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (!(s.from <= at and at < s.to)) continue; + if (best == null or s.to - s.from < best.?.to - best.?.from) best = s; + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +/// `]f`/`[f` and kin: the next object starting after byte `at`, or the +/// previous one ending before it (helix goto_treesitter_object), from the +/// first of `.movement`, `.around`, `.inside` the query names. +pub fn objectNext(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, forward: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const capture = for ([_][]const u8{ "movement", "around", "inside" }) |kind| { + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), kind }) catch return null; + if (captureId(q, want)) |id| break id; + } else return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (forward) { + if (s.from <= at) continue; + if (best == null or s.from < best.?.from or (s.from == best.?.from and s.to > best.?.to)) best = s; + } else { + if (s.to >= at) continue; + if (best == null or s.to > best.?.to or (s.to == best.?.to and s.from < best.?.from)) best = s; + } + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +test "every vendored textobject query compiles against its grammar" { + if (!enabled) return; + var failed: usize = 0; + for (&specs) |*spec| { + if (spec.objects_src.len == 0) continue; + var error_offset: u32 = 0; + const q = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + std.debug.print("textobjects for {s} do not compile (offset {d})\n", .{ spec.name, error_offset }); + failed += 1; + continue; + }; + q.destroy(); + } + try std.testing.expectEqual(@as(usize, 0), failed); +} + /// Owned source analysis. Both slices use the allocator passed to analyzeSource. /// The parsed tree is released before returning; callers can cache this result. pub const SourceAnalysis = struct { diff --git a/src/tagline.zig b/src/tagline.zig index 92c18510..59c51b4d 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -7,6 +7,7 @@ const modal = @import("modal.zig"); const ctlfs = @import("ninep/tree.zig"); const panes = @import("panes.zig"); const edit = @import("edit.zig"); +const exec = @import("exec.zig"); const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const dump = @import("dump.zig"); @@ -21,6 +22,7 @@ pub const file_pane_tail = " " ++ config.file_pane_builtins_str; 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; const dirty_marker = " *"; pub const legacy_pane_tail = " Del"; pub const legacy_file_pane_tail = " Save Del"; @@ -109,6 +111,29 @@ pub fn pathPrefix(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); + // 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 + // second (acme runs a multi-line selection as one command) would move + // it (body_layer.zig tagTintRange). + if (pane.command) |line| { + const first = line[0 .. std.mem.indexOfAny(u8, line, "\r\n") orelse line.len]; + var cut = @min(first.len, 48); + while (cut < first.len and cut > 0 and first[cut] & 0xC0 == 0x80) cut -= 1; + const shown = first[0..cut]; + const more = if (shown.len < line.len) "..." else ""; + var idbuf: [48]u8 = undefined; + const id = exec.replId(&idbuf, pane); + const gap = if (id.len > 0) " " else ""; + if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id }); + if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ pane.cwdSlice(), shown, more, n, gap, id }); + return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id }); + } + // A bound REPL says what it is bound as, beside its Tty word. + if (pane.repl != null) { + var idbuf: [48]u8 = undefined; + return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) }); + } if (pane.file) |f| { const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) @@ -148,7 +173,8 @@ pub fn defaultTail(pane: *const Pane) []const u8 { if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; if (panes.File.supportsContext(pane)) return context_pane_tail; if (pane.file != null) return file_pane_tail; - if (pane.isTerminal()) return terminal_pane_tail; + if (pane.command != null) return command_pane_tail; + if (pane.isTerminal()) return if (pane.shell_tail_len > 0) pane.shell_tail[0..pane.shell_tail_len] else terminal_pane_tail; return pane_tail; } @@ -200,7 +226,7 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { t.msel.active = false; t.nsel = 0; t.select = false; - t.append_at = null; + t.restore_cursor = false; t.sticky_col = -1; t.mode = .normal; t.normal.clear(); @@ -306,7 +332,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.msel.active = false; t.nsel = 0; t.select = false; - t.append_at = null; + t.restore_cursor = false; t.mode = .normal; t.normal.clear(); } @@ -390,8 +416,26 @@ pub fn restoreTailAt( }, .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; + // A terminal's saved tag carries what was live about it -- the REPL id + // after its directory, the shell in its Tty word -- which is neither + // dumped nor true of the new shell: its tail is still the default. + var plain = said; + var plain_buf: [256]u8 = undefined; + if (class == .terminal) { + const first = plain[0 .. std.mem.indexOfScalar(u8, plain, ' ') orelse plain.len]; + if (std.mem.lastIndexOfScalar(u8, first, '-')) |dash| if (dash + 2 == first.len and first[dash + 1] >= 'a' and first[dash + 1] <= 'z') { + for (@import("grammar_manifest.zig").all) |g| if (std.mem.eql(u8, g.name, first[0..dash])) { + plain = std.mem.trimStart(u8, plain[first.len..], " "); + break; + }; + }; + if (std.mem.startsWith(u8, plain, "Tty+")) { + const word_end = std.mem.indexOfScalar(u8, plain, ' ') orelse plain.len; + plain = std.fmt.bufPrint(&plain_buf, "Tty{s}", .{plain[word_end..]}) catch plain; + } + } for (defaults) |default| - if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; + if (std.mem.eql(u8, plain, std.mem.trimStart(u8, default, " "))) return; pane.tag.own = p.gpa.dupe(u8, rest) catch return; } diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 6083f609..822c2d7f 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -318,8 +318,17 @@ const Pty = struct { file: std.Io.File, pid: posix.pid_t, reader: std.Io.Future(anyerror!void), + /// A command pane's child, watched to its exit (host_io.watchExit); + /// the pty stays open until both its end of file and its exit. + cmd: host_io.CommandWatch = .{}, }; +/// A watched child exited: wake the loop, which takes it (`takeExits`). +fn wakeForExit(ctx: ?*anyopaque) void { + const loop: *Loop = @ptrCast(@alignCast(ctx.?)); + _ = loop.tryPostEvent(.nop) catch {}; +} + const KittyPlacement = struct { cell_x: u16, cell_y: u16, @@ -732,7 +741,9 @@ fn localSession( .test_clock = host_io.testClock(), }; pardes.lsp.setStatusSink(&sh, lspStatusSink); + host_io.setExitWake(loop, wakeForExit); defer { + host_io.setExitWake(null, null); pardes.lsp.setStatusSink(null, null); for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; @@ -805,7 +816,7 @@ fn localSession( break :blk; }; defer gpa.free(bytes); - const nc = dump.restore(core, bytes) catch |err| { + const nc = dump.restore(core, bytes, rp) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; @@ -818,6 +829,7 @@ fn localSession( for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); + host_io.retireShell(pt.pid); slot.* = null; }; for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( @@ -991,6 +1003,9 @@ const Shell = struct { batch += 1; } if (motion) |m| _ = s.apply(m); + // An exit's wake (a .nop) or output an exit waited behind: told + // here, inside the step, so the frame after it shows it. + s.takeExits(); s.reloadWatched(); } @@ -1020,14 +1035,27 @@ const Shell = struct { if (s.gens[pr.id] == pr.gen) core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); s.gpa.free(pr.bytes); + // An exit waiting behind this output may be told now. + s.takeExits(); return true; }, .pty_eof => |e| if (s.gens[e.id] == e.gen) { if (s.ptys[e.id]) |*pt| { pt.reader.await(s.io) catch {}; // reader just finished; join it or its future leaks + // A command's pty stays open until its child has exited: + // closing it would hang up one that runs on without it. + // Its exit, if it came first, is told now its output is in. + if (pt.cmd.watched) { + host_io.commandEof(core, &s.ptys, e.id, s, closeWatched); + return true; + } _ = libc.close(pt.file.handle); - host_io.retireShell(pt.pid); + // Unwatched, a command's exit is read here, as its end. + const command = if (core.panes[e.id]) |pn| pn.command != null else false; + const status = if (command) host_io.exitStatus(pt.pid, 100) else null; + if (status == null) host_io.retireShell(pt.pid); s.ptys[e.id] = null; + if (command) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } }); } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, @@ -1216,7 +1244,8 @@ const Shell = struct { closePty(ctx, pane); // a shell still in the slot goes first, reaped s.gens[pane] +%= 1; const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); - s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; + const command = if (s.core.panes[pane]) |pn| pn.command != null else false; + s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} }, .cmd = .{ .watched = command and host_io.watchExit(child.pid), .fd = child.file.handle } }; var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); if (s.threads_ok) { @@ -1241,7 +1270,9 @@ const Shell = struct { fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); - if (s.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); + // A command whose exit is recorded is reaped: its group may be + // another's by now. + if (s.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig); } /// The pane is gone: hang its pty up, which the kernel passes on to the @@ -1263,6 +1294,17 @@ const Shell = struct { return host_io.ttyTaken(pt.pid, pt.file.handle); } + /// The command panes' exits, told once their output is in (host_io). + fn takeExits(s: *@This()) void { + host_io.takeExits(s.core, &s.ptys, s, closeWatched); + } + + fn closeWatched(s: *@This(), id: usize) void { + const pt = &(s.ptys[id] orelse return); + _ = libc.close(pt.file.handle); + s.ptys[id] = null; + } + fn killJob(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; diff --git a/src/tutor.txt b/src/tutor.txt index 507ce40e..0893415e 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -461,7 +461,7 @@ typed N frontends share ONE screen at the smallest common grid the default 9P socket works in a daemon too - KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p + KEYS h j k l w b e 0 gl ^ gg ge f F t T v x d c y p i a I A o O u undo U redo motions SELECT, so `i` types at the selection's start and `;` collapses first |
