diff options
Diffstat (limited to 'src/exec.zig')
| -rw-r--r-- | src/exec.zig | 351 |
1 files changed, 310 insertions, 41 deletions
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" { |
