summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig80
-rw-r--r--src/File.zig15
-rw-r--r--src/Macro.zig176
-rw-r--r--src/Messages.zig105
-rw-r--r--src/Output.zig2
-rw-r--r--src/Pipe.zig69
-rw-r--r--src/Registers.zig106
-rw-r--r--src/Terminal.zig15
-rw-r--r--src/Text.zig65
-rw-r--r--src/body_layer.zig25
-rw-r--r--src/builtins.zig213
-rw-r--r--src/colors.zig13
-rw-r--r--src/config.zig50
-rw-r--r--src/detached/client.zig3
-rw-r--r--src/detached/server.zig77
-rw-r--r--src/detached/wire.zig6
-rw-r--r--src/draw.zig16
-rw-r--r--src/dump.zig10
-rw-r--r--src/edit.zig629
-rw-r--r--src/esp32p4.zig2
-rw-r--r--src/exec.zig351
-rw-r--r--src/fs-help.txt18
-rw-r--r--src/fs.zig6
-rw-r--r--src/grammar_manifest.zig43
-rw-r--r--src/gui/gui.zig67
-rw-r--r--src/host_io.zig276
-rw-r--r--src/layout.zig20
-rw-r--r--src/look.zig15
-rw-r--r--src/macos.zig50
-rw-r--r--src/memory.zig4
-rw-r--r--src/modal.zig176
-rw-r--r--src/mouse.zig28
-rw-r--r--src/ninep/addr.zig47
-rw-r--r--src/ninep/ctl.zig170
-rw-r--r--src/ninep/events.zig110
-rw-r--r--src/ninep/pane.zig34
-rw-r--r--src/ninep/pty.zig36
-rw-r--r--src/normal.zig428
-rw-r--r--src/panes.zig52
-rw-r--r--src/pardes.zig799
-rw-r--r--src/syntax.zig266
-rw-r--r--src/tagline.zig52
-rw-r--r--src/tty/tty.zig50
-rw-r--r--src/tutor.txt2
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.
diff --git a/src/fs.zig b/src/fs.zig
index 0b620c9f..b2e2751d 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -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