From c3d0b84b7961ae26d2d654e7120821cc2d83d20d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 24 Aug 2026 10:56:05 -0300 Subject: host: the core owns the event loop; every platform becomes a vtable of optional methods --- src/tty/tty.zig | 1086 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 599 insertions(+), 487 deletions(-) (limited to 'src/tty/tty.zig') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index a0159f46..404fe582 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -11,7 +11,6 @@ const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); -const temp_file = @import("../temp_file.zig"); const shell_bin = @import("../shell_bin.zig"); const message = @import("../message.zig"); const file_watch = @import("../file_watch.zig"); @@ -19,6 +18,7 @@ const user_config = @import("../user_config.zig"); const selection_pipe = @import("../selection_pipe.zig"); const nested = @import("../nested.zig"); const panel_compositor = @import("panel_compositor.zig"); +const host_api = @import("../host.zig"); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; @@ -445,6 +445,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; + // the 16 MiB static buffer behind every per-frame Surface + options.frame_allocator = allocs.frame; // SIGWINCH must never run vaxis's signal handler: it posts the winsize // event through std.Io.Mutex/Condition, and when the signal lands on a @@ -499,9 +501,6 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); - var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); - defer frame_arena.deinit(); - // Private, complete before any fork and retained until the last possible // spawn; children borrow only these stable in-struct path buffers. var prompt_rcs = shell_bin.PromptRcs.init(); @@ -516,54 +515,56 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.deinit(); } - var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: a reused pane id ignores the old shell's - // late pty_eof (which would otherwise close the NEW pty on that slot) - var gens: [pardes.MAX_PANES]u32 = @splat(0); - // ...and the core's one way to ask about those ptys: is a pane's tty still - // the prompt we forked? Installed here rather than polled per frame (see - // the cwd loop) and re-installed on a replay core, which owns a fresh set - // of panes over this same table. - core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; - // the single in-flight language query (see the .lsp effect) - var lsp_task: ?std.Io.Future(anyerror!void) = null; - // Selection filters may overlap: a second submit supersedes the first in - // core without synchronously canceling a possibly slow shell command. - var pipe_tasks: PipeTasks = .{}; - // One inotify instance for every watched pane, opened here — before any - // thread exists — so the pre-loop effect drain below can already mark the - // file a positional path argument opened. -1 off linux: watchPane goes - // quiet and the core simply never gets a file_changed event. - var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; - var watches: file_watch.Table = @splat(null); - var watch_task: ?std.Io.Future(anyerror!void) = null; + var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); + defer frame_arena.deinit(); + var paste_buf: std.Io.Writer.Allocating = .init(gpa); + defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); + + var sh: Shell = .{ + .io = io, + .gpa = gpa, + .lsp_gpa = allocs.lsp, + .core = core, + .prompt_rcs = &prompt_rcs, + .loop = &loop, + .vx = &vx, + .tty = &tty, + .kitty = &kitty_handles, + .frame = &frame_arena, + .paste_buf = &paste_buf, + // One inotify instance for every watched pane, opened here — before + // any thread exists — so the pre-loop effect drain below can already + // mark the file a positional path argument opened. -1 off linux: + // watchPane goes quiet and the core simply never gets a file_changed. + .inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1, + }; defer { // reap the reader tasks (cancel interrupts a blocked read) before // closing the masters — the runtime joins those threads on exit and a // reader stuck in read(2) would hang the process — then drain the // queue: leftover events own gpa bytes and would dump as leaks. - for (&ptys) |*slot| if (slot.*) |*pt| { + for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; }; // join the query worker BEFORE the drain below, or its late post // lands in a queue nobody empties again and the rows leak - if (lsp_task) |*t| { + if (sh.lsp_task) |*t| { t.cancel(io) catch {}; - lsp_task = null; + sh.lsp_task = null; } - pipe_tasks.cancelAll(io); + sh.pipe_tasks.cancelAll(io); // same contract as the pty readers: cancel unblocks the watcher's // read, and only then is the fd safe to close - if (watch_task) |*t| { + if (sh.watch_task) |*t| { t.cancel(io) catch {}; - watch_task = null; + sh.watch_task = null; } - if (inotify_fd >= 0) { - _ = libc.close(inotify_fd); - inotify_fd = -1; + if (sh.inotify_fd >= 0) { + _ = libc.close(sh.inotify_fd); + sh.inotify_fd = -1; } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), @@ -577,6 +578,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { else => {}, }; } + const host = sh.host(); // The socket a pardes launched inside this one connects to (nested.zig). // Declared AFTER the drain above so its teardown runs BEFORE it — the @@ -588,8 +590,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer nested.unlisten(sock_fd); // Perform the initial spawns BEFORE any worker thread exists: forkpty from - // a multithreaded process can wedge the child before exec. - drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false); + // a multithreaded process can wedge the child before exec. `pump` installs + // the host on every pass; this drain runs outside it, so install it here. + core.host = host; + while (core.nextEffect()) |effect| core.perform(effect); try loop.start(); defer loop.stop(); @@ -624,8 +628,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // terminals answer in order, so when vaxis's reader flips queries_done // every earlier reply is already applied — no window left to miss at any // latency, and the enable lands before the next frame (see caps_pending - // in the render path). A terminal that never answers keeps the defaults, - // which is what the 2ms timeout produced anyway, and with no caps + // in pollFrame). A terminal that never answers keeps the defaults, which + // is what the 2ms timeout produced anyway, and with no caps // enableDetectedFeatures writes no bytes — the snapshot goldens, where // nothing ever answers, do not move. Startup gets 2ms faster, not slower. // @@ -640,202 +644,23 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // caps.unicode — is never called. If that ever changes, wake the loop on // vx.query_futex from a one-shot thread instead. try vx.queryTerminalSend(tty.writer()); - var caps_pending = true; // now threads are fine: start a reader task per pty - for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { - pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop }); + sh.threads_ok = true; + for (&sh.ptys, 0..) |*slot, id| if (slot.*) |*pt| { + pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.gens[id], &loop }); }; // ...and the one file watcher. Started here rather than lazily on the // first watched pane because the fd already exists and an unwatched // inotify instance just parks in read(2) — one thread for the process, // however many panes come and go. - if (inotify_fd >= 0) watch_task = io.concurrent(watchFiles, .{ io, inotify_fd, &loop }) catch null; + if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, &loop }) catch null; - var check_files = false; - var pending: ?@TypeOf(Command.value) = .tick; - // Where a bracketed paste is assembled. It has to outlive one drain pass: - // the burst arrives over as many passes as the terminal takes to write it, - // and the markers are the only thing that says where it ends. - var paste_buf: std.Io.Writer.Allocating = .init(gpa); - defer paste_buf.deinit(); - var in_paste = false; - // 4 MiB ceiling, past which the tail is dropped rather than grown into. A - // paste that large is a mis-click on a file, not an edit, and the core - // would have to hold the whole of it as one undo entry. - const max_paste_bytes: usize = 4 << 20; + // The core owns the loop ORDER (see Pardes.pump); the outer `while` stays + // here rather than being `core.run` for one reason: Restore swaps the + // whole core, and a core cannot replace itself from inside its own frame. while (!core.quit) { - var event = if (pending) |ev| blk: { - pending = null; - break :blk ev; - } else try loop.nextEvent(); - const tz_event = tracy.zone(@src(), "event"); - // Apply every queued INPUT event, then render ONCE — the gui shell - // drains SDL's queue the same way. Without this a wheel flick is fifty - // full render+repaint (and re-highlight) cycles instead of one. A - // native PDF page crossing is the one input boundary below: its next - // wheel report needs geometry produced by this render. Pty output also - // keeps a frame per chunk so progress paints as it arrives. - var stop = false; - var batch: usize = 0; - while (true) { - var output = false; - var native_pdf_page_changed = false; - switch (event) { - .nop => {}, - .tick => core.update(.tick), - .quit => stop = true, - .focus_in => {}, - .focus_out => core.update(.pointer_leave), - .winsize => |ws| { - try vx.resize(gpa, tty.writer(), ws); - if (comptime pardes.pdf_enabled) - updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) - else - core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); - }, - .pty_read => |pr| { - core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); - gpa.free(pr.bytes); - output = true; - }, - .pty_eof => |e| if (gens[e.id] == e.gen) { - if (ptys[e.id]) |*pt| { - pt.reader.await(io) catch {}; // reader just finished; join it or its future leaks - _ = libc.close(pt.file.handle); - ptys[e.id] = null; - } - core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); - }, - .key_press => |key| if (in_paste) { - // Between the markers a key is DATA, never a command. Same - // two inputs as the dispatch below, so a pasted character - // is exactly the character the core would have been given. - const text = key.text orelse ""; - const cp = mapKey(effCp(key)); - const bytes: []const u8 = if (text.len > 0) - text - else if (cp == pardes.Key.tab) - "\t" - else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) - // vaxis gives control bytes no text at all: a line - // break inside a paste reaches the ground parser as a - // bare CR (-> Key.enter) or, from a terminal that does - // not translate them, a bare LF — which that parser - // reports as ctrl+j. Nothing in here is a real - // keypress, so both of them are just a newline. - "\n" - else - // arrows, F-keys, a stray escape: noise a paste has no - // business carrying, dropped rather than smuggled in. - ""; - const room = max_paste_bytes -| paste_buf.written().len; - paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; - } else core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = key.text orelse "", - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - .shift = key.mods.shift, - } }), - .mouse => |m| { - const pdf_before = nativePdfWheelTarget(core, m); - const button: ?pardes.Mouse.Button = switch (m.button) { - .left => .left, - .middle => .middle, - .right => .right, - .wheel_up => .wheel_up, - .wheel_down => .wheel_down, - .wheel_left => .wheel_left, - .wheel_right => .wheel_right, - .none => .none, // button-less motion: hover tracking - else => null, - }; - if (button) |b| core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(m.col), - .row = @intCast(m.row), - .ctrl = m.mods.ctrl, - } }); - if (pdf_before) |before| { - if (core.panes[before.pane]) |pane| { - if (pane.pdfPage()) |page| - native_pdf_page_changed = page != before.page; - } - } - }, - .paste => |bytes| { - core.update(.{ .paste = bytes }); - gpa.free(@constCast(bytes)); - }, - .paste_start => { - in_paste = true; - paste_buf.clearRetainingCapacity(); - }, - .paste_end => { - in_paste = false; - // ONE event for the whole paste — the core borrows the - // bytes for the call, exactly like the OSC 52 arm above. - const pasted = paste_buf.written(); - if (pasted.len > 0) core.update(.{ .paste = pasted }); - paste_buf.clearRetainingCapacity(); - }, - .command => |line| { - core.update(.{ .command = line }); - gpa.free(line); - }, - // Coalesced on purpose: a burst of writes (a formatter, a - // build, a `git checkout`) collapses into ONE pass below, so - // it cannot queue a reload — or an undo entry — per write. - .files_changed => check_files = true, - .lsp_done => |d| { - core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - allocs.lsp.free(d.rows); - // the worker is finished; join it so its future does not - // leak (same contract as pty_eof above) - if (lsp_task) |*t| { - t.await(io) catch {}; - lsp_task = null; - } - }, - .pipe_done => |response_value| { - var response = response_value; - core.update(.{ .pipe_resp = .{ - .id = response.id, - .success = response.success, - .outputs = response.outputs, - } }); - response.deinit(gpa); - pipe_tasks.finish(io, response_value.id); - }, - } - batch += 1; - // A paste in flight keeps draining WITHOUT rendering: a hundred - // thousand pasted characters are one edit, not a hundred thousand - // render-worthy events. That cannot spin — the drain still ends - // the moment the queue runs dry (tryEvent below) — so a terminal - // which sends paste_start and never paste_end costs one frame. - if (stop or output or native_pdf_page_changed or (!in_paste and batch >= 64)) break; - event = (try loop.tryEvent()) orelse break; - } - tz_event.end(); - if (stop) break; - - if (check_files) { - check_files = false; - if (file_watch.reloadChanged(core, io, gpa, &watches)) - loop.postEvent(.files_changed) catch {}; - } - - drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true); - if (core.quit) break; - + try core.pump(host); // Restore builtin: swap in a core rebuilt from the dump; the live // shells die with their masters (readers canceled, gens bumped so // their late eofs never touch the replay panes) @@ -846,31 +671,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { o.cols = core.screen_w; o.rows = core.screen_h; // pre-size: dump panes never greet const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; - for (&ptys, 0..) |*slot, pid| if (slot.*) |*pt| { + for (&sh.ptys, 0..) |*slot, pid| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; - gens[pid] +%= 1; + sh.gens[pid] +%= 1; }; // the replay core's pane ids mean new things, and the dying core's // `watch off` effects go into a queue nobody drains — drop the lot // here. The new core emits its own `on`s as it builds its panes. for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( - inotify_fd, - &watches, + sh.inotify_fd, + &sh.watches, @intCast(wid), null, 0, .{ .text = 0 }, ); - _ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false); + _ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false); var image_iterator = kitty_handles.valueIterator(); while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.clearRetainingCapacity(); nc.native_images = vx.caps.kitty_graphics; - nc.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; core.deinit(); core = nc; + sh.core = nc; if (comptime pardes.pdf_enabled) { // A restored core did not receive the terminal's earlier // winsize event. Reapply both the grid and physical cells @@ -879,14 +704,304 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } + } +} + +/// Everything the terminal shell owns and the core cannot: the ptys, the +/// inotify table, the worker futures, and the one thread allowed to touch +/// `tty.writer()`. This struct IS the `Host.ctx`. +const Shell = struct { + io: std.Io, + gpa: std.mem.Allocator, + lsp_gpa: std.mem.Allocator, + /// live core; Restore replaces it (see run) + core: *pardes.Pardes, + prompt_rcs: *const shell_bin.PromptRcs, + loop: *Loop, + vx: *vaxis.Vaxis, + tty: *vaxis.Tty, + kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), + frame: *std.heap.ArenaAllocator, + /// Where a bracketed paste is assembled. It has to outlive one drain pass: + /// the burst arrives over as many passes as the terminal takes to write + /// it, and the markers are the only thing that says where it ends. + paste_buf: *std.Io.Writer.Allocating, + inotify_fd: c_int, + ptys: [pardes.MAX_PANES]?Pty = @splat(null), + /// per-slot spawn generation: a reused pane id ignores the old shell's + /// late pty_eof (which would otherwise close the NEW pty on that slot) + gens: [pardes.MAX_PANES]u32 = @splat(0), + watches: file_watch.Table = @splat(null), + watch_task: ?std.Io.Future(anyerror!void) = null, + /// the single in-flight language query (see the lsp method) + lsp_task: ?std.Io.Future(anyerror!void) = null, + /// Selection filters may overlap: a second submit supersedes the first in + /// core without synchronously canceling a possibly slow shell command. + pipe_tasks: PipeTasks = .{}, + /// false during the pre-loop drain: no worker exists to answer to yet + threads_ok: bool = false, + caps_pending: bool = true, + check_files: bool = false, + in_paste: bool = false, + /// the tracks the frame in flight was composed from + tracks: []const pardes.panel_animation.Track = &.{}, + + /// 4 MiB ceiling, past which the tail is dropped rather than grown into. A + /// paste that large is a mis-click on a file, not an edit, and the core + /// would have to hold the whole of it as one undo entry. + const max_paste_bytes: usize = 4 << 20; + + fn of(ctx: ?*anyopaque) *Shell { + return @ptrCast(@alignCast(ctx.?)); + } + + fn host(s: *Shell) host_api.Host { + return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; + } + + /// An ISOLATED build (`zig build run-isolated`): the terminal this draws on + /// and the keys it reads, and nothing else. Every other method stays null, + /// so the core answers it itself — the embedded source filesystem, the + /// in-process clipboard, silent ptys. Nothing is forked, nothing on disk is + /// opened or written, and no clipboard leaves the process. The option is + /// comptime, so the other vtable is not even built into that binary. + const isolated_vtable: host_api.Host.VTable = .{ + .pull_wait_input = waitInput, + .push_present = present, + .push_post_present = postPresent, + }; + + const vtable: host_api.Host.VTable = .{ + .pull_wait_input = waitInput, + .push_present = present, + .push_post_present = postPresent, + .push_poll_frame = pollFrame, + .push_spawn = spawn, + .push_pty_write = ptyWrite, + .push_pty_resize = ptyResize, + .pull_tty_taken = ttyTaken, + .push_write_file = writeFile, + .push_write_dump = writeDump, + .push_watch_file = watchFile, + .push_watch_theme = watchTheme, + .push_dump_themes = dumpThemes, + .push_set_clipboard = setClipboard, + .pull_read_clipboard = readClipboard, + .push_open_link = openLink, + .pull_lsp = lsp, + .pull_pipe = pipe, + }; + + // ---- input ------------------------------------------------------------ + + /// Block for one event, then apply the whole pending batch. Everything + /// goes through `core.update` rather than `postEvent`: a pty chunk borrows + /// its bytes for the call, and mixing queued with immediate delivery would + /// reorder a keystroke against the output it caused. + fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { + const s = of(ctx); + var batch: usize = 0; + if (timeout_ms == 0) { + // A failed read is a DEAD event source — the reader is gone and no + // event can ever arrive again, so nothing could set `quit` and the + // outer `while (!core.quit)` would spin at full speed. End the + // session, exactly as the pre-vtable `try loop.nextEvent()` did. + const first = s.loop.nextEvent() catch { + s.core.quit = true; + return s.reloadWatched(); + }; + batch = 1; + if (s.apply(first)) return s.reloadWatched(); + } else { + // Animating: the frame clock IS the wait, and the `.tick` that + // spends it is ours to post — `pump` cannot, because only this + // host knows when a real frame interval has passed. Anything that + // queues during the short sleep is drained below, in this pass. + std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; + _ = s.apply(.tick); + batch = 1; + } + // Apply every queued INPUT event, then render ONCE — the gui shell + // drains SDL's queue the same way. Without this a wheel flick is fifty + // full render+repaint (and re-highlight) cycles instead of one. A + // paste in flight keeps draining WITHOUT rendering: a hundred thousand + // pasted characters are one edit. That cannot spin — the drain still + // ends the moment the queue runs dry. + while (s.in_paste or batch < 64) { + const ev = (s.loop.tryEvent() catch null) orelse break; + batch += 1; + if (s.apply(ev)) break; + } + s.reloadWatched(); + } + + /// Apply one vaxis event. Returns true when the batch must end here: the + /// session is over, a pty chunk wants its own frame so progress paints as + /// it arrives, or a native PDF page crossing needs geometry this render + /// has not produced yet. + fn apply(s: *Shell, event: @TypeOf(Command.value)) bool { + const core = s.core; + const tz_event = tracy.zone(@src(), "event"); + defer tz_event.end(); + switch (event) { + .nop => {}, + .tick => core.update(.tick), + .quit => { + core.quit = true; + return true; + }, + .focus_in => {}, + .focus_out => core.update(.pointer_leave), + .winsize => |ws| { + s.vx.resize(s.gpa, s.tty.writer(), ws) catch {}; + if (comptime pardes.pdf_enabled) + updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) + else + core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); + }, + .pty_read => |pr| { + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + s.gpa.free(pr.bytes); + 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 + _ = libc.close(pt.file.handle); + s.ptys[e.id] = null; + } + core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); + }, + .key_press => |key| if (s.in_paste) { + // Between the markers a key is DATA, never a command. Same + // two inputs as the dispatch below, so a pasted character + // is exactly the character the core would have been given. + const text = key.text orelse ""; + const cp = mapKey(effCp(key)); + const bytes: []const u8 = if (text.len > 0) + text + else if (cp == pardes.Key.tab) + "\t" + else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) + // vaxis gives control bytes no text at all: a line + // break inside a paste reaches the ground parser as a + // bare CR (-> Key.enter) or, from a terminal that does + // not translate them, a bare LF — which that parser + // reports as ctrl+j. Nothing in here is a real + // keypress, so both of them are just a newline. + "\n" + else + // arrows, F-keys, a stray escape: noise a paste has no + // business carrying, dropped rather than smuggled in. + ""; + const room = max_paste_bytes -| s.paste_buf.written().len; + s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; + } else core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }), + .mouse => |m| { + const pdf_before = nativePdfWheelTarget(core, m); + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, + .none => .none, // button-less motion: hover tracking + else => null, + }; + if (button) |b| core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + .ctrl = m.mods.ctrl, + } }); + if (pdf_before) |before| { + if (core.panes[before.pane]) |pane| { + if (pane.pdfPage()) |page| { + if (page != before.page) return true; + } + } + } + }, + .paste => |bytes| { + core.update(.{ .paste = bytes }); + s.gpa.free(@constCast(bytes)); + }, + .paste_start => { + s.in_paste = true; + s.paste_buf.clearRetainingCapacity(); + }, + .paste_end => { + s.in_paste = false; + // ONE event for the whole paste — the core borrows the + // bytes for the call, exactly like the OSC 52 arm above. + const pasted = s.paste_buf.written(); + if (pasted.len > 0) core.update(.{ .paste = pasted }); + s.paste_buf.clearRetainingCapacity(); + }, + .command => |line| { + core.update(.{ .command = line }); + s.gpa.free(line); + }, + // Coalesced on purpose: a burst of writes (a formatter, a build, a + // `git checkout`) collapses into ONE pass below, so it cannot + // queue a reload — or an undo entry — per write. + .files_changed => s.check_files = true, + .lsp_done => |d| { + core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); + s.lsp_gpa.free(d.rows); + // the worker is finished; join it so its future does not + // leak (same contract as pty_eof above) + if (s.lsp_task) |*t| { + t.await(s.io) catch {}; + s.lsp_task = null; + } + }, + .pipe_done => |response_value| { + var response = response_value; + core.update(.{ .pipe_resp = .{ + .id = response.id, + .success = response.success, + .outputs = response.outputs, + } }); + response.deinit(s.gpa); + s.pipe_tasks.finish(s.io, response_value.id); + }, + } + return false; + } + + fn reloadWatched(s: *Shell) void { + if (!s.check_files) return; + s.check_files = false; + if (file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) + s.loop.postEvent(.files_changed) catch {}; + } + // ---- the frame --------------------------------------------------------- + + fn pollFrame(ctx: ?*anyopaque) void { + const s = of(ctx); // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the // pane's tty still belongs to the prompt we forked is NOT polled here — // it is a walk through /proc and nothing draws it, so the core pulls it - // through ttyQuery below, at the Exec that cares. - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { + // through tty_taken instead, at the Exec that cares. + for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); }; // The handshake landed (vaxis's reader flips queries_done on DA1, the @@ -897,27 +1012,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // matters as much as the enable: earlier frames were drawn under the // pre-handshake caps, and vaxis's shadow grid has to be re-established // under the new ones or it keeps skipping cells it thinks are current. - if (caps_pending and vx.queries_done.load(.unordered)) { - caps_pending = false; - vx.enableDetectedFeatures(tty.writer()) catch {}; + if (s.caps_pending and s.vx.queries_done.load(.unordered)) { + s.caps_pending = false; + s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; // The core was constructed before the asynchronous handshake. // Advertise native pixels only now, after every reply preceding // DA1 has updated the capability set. - core.native_images = vx.caps.kitty_graphics; - vx.queueRefresh(); + s.core.native_images = s.vx.caps.kitty_graphics; + s.vx.queueRefresh(); } + } - // ---- render: surface -> vaxis, cell for cell ---- - _ = frame_arena.reset(.retain_capacity); - const tz_core = tracy.zone(@src(), "core.render"); - const canonical = try core.render(frame_arena.allocator()); - tz_core.end(); - const surface = try panel_compositor.compose( - frame_arena.allocator(), - canonical, - canonical.panelTracks(), - core.theme().bg, - ); + /// The canonical surface -> vaxis, cell for cell, with no interpretation. + fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void { + const s = of(ctx); + const vx = s.vx; + s.tracks = canonical.panelTracks(); + _ = s.frame.reset(.retain_capacity); + // compose only READS the canonical surface; the mutable pointer is so + // it can hand it straight back when no panel is mid-transition. + const surface = panel_compositor.compose( + s.frame.allocator(), + @constCast(canonical), + s.tracks, + s.core.theme().bg, + ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); win.clear(); @@ -949,16 +1068,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { if (!kittyImageRepresentable(place)) continue; } const key = place.cacheKey(); - if (!kitty_handles.contains(key) and vx.caps.kitty_graphics) { + if (!s.kitty.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; - if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { - defer gpa.free(b64); + if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { + defer s.gpa.free(b64); _ = enc.encode(b64, place.rgba); - if (vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| - kitty_handles.put(key, handle) catch vx.freeImage(tty.writer(), handle.id); + if (vx.transmitPreEncodedImage(s.tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| + s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id); } else |_| {} } - if (kitty_handles.get(key)) |cached| { + if (s.kitty.get(key)) |cached| { if (comptime !pardes.pdf_enabled) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; @@ -990,15 +1109,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; - var image_iterator = kitty_handles.iterator(); + var image_iterator = s.kitty.iterator(); while (image_iterator.next()) |entry| { if (surfaceHasKittyKey(surface, entry.key_ptr.*)) continue; stale[stale_len] = entry.key_ptr.*; stale_len += 1; if (stale_len == stale.len) break; } - for (stale[0..stale_len]) |key| if (kitty_handles.fetchRemove(key)) |removed| - vx.freeImage(tty.writer(), removed.value.id); + for (stale[0..stale_len]) |key| if (s.kitty.fetchRemove(key)) |removed| + vx.freeImage(s.tty.writer(), removed.value.id); if (stale_len < stale.len) break; } if (surface.cursor) |cur| { @@ -1007,231 +1126,235 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { win.setCursorShape(if (cur.bar) .beam else .default); } const tz_render = tracy.zone(@src(), "vx.render"); - try vx.render(tty.writer()); + vx.render(s.tty.writer()) catch {}; tz_render.end(); - core.acknowledgePanelPresentation(canonical.panelTracks()); + } + + fn postPresent(ctx: ?*anyopaque) void { + const s = of(ctx); + s.core.acknowledgePanelPresentation(s.tracks); tracy.frameMark(); + } - // Active core animation is the only reason this otherwise event-driven - // frontend redraws on a clock. Sleep on the loop thread, then make the - // next pass a tick; input and worker events can queue during the short - // sleep and are drained in that pass. No timer worker can outlive the - // loop, and at the exact endpoint we return to an indefinite wait. - if (core.animationActive()) { - std.Io.sleep(io, .fromMilliseconds(pardes.animation.frame_ms), .awake) catch {}; - pending = .tick; + // ---- pseudo-terminals --------------------------------------------------- + + fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { + const s = of(ctx); + // the core reuses pane ids and there is no close effect: a deleted + // pane's shell lives in its slot until a respawn lands here — reap + // it (cancel joins the reader; its late eof is ignored by gen) + if (s.ptys[pane]) |*old| { + old.reader.cancel(s.io) catch {}; + _ = libc.close(old.file.handle); + s.ptys[pane] = null; + } + s.gens[pane] +%= 1; + var cwd_buf: [256:0]u8 = undefined; + var cwd_z: ?[*:0]const u8 = null; + if (cwd.len > 0 and cwd.len < cwd_buf.len) { + @memcpy(cwd_buf[0..cwd.len], cwd); + cwd_buf[cwd.len] = 0; + cwd_z = @ptrCast(&cwd_buf); + } + const child = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w); + s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; + // report the pane's starting directory back to the core (tags). The + // slot needs no occupancy reset: nothing is remembered, and the next + // Exec asks about the shell that is there now. + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + if (s.threads_ok) { + if (s.ptys[pane]) |*pt| { + pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader; + } } } -} -fn drainEffects( - core: *pardes.Pardes, - prompt_rcs: *const shell_bin.PromptRcs, - ptys: *[pardes.MAX_PANES]?Pty, - gens: *[pardes.MAX_PANES]u32, - io: std.Io, - gpa: std.mem.Allocator, - lsp_allocator: std.mem.Allocator, - loop: *Loop, - vx: *vaxis.Vaxis, - tty: *vaxis.Tty, - lsp_task: *?std.Io.Future(anyerror!void), - pipe_tasks: *PipeTasks, - inotify_fd: c_int, - watches: *file_watch.Table, - threads_ok: bool, -) void { - while (core.nextEffect()) |effect| switch (effect) { - .spawn => |sp| { - // the core reuses pane ids and there is no close effect: a deleted - // pane's shell lives in its slot until a respawn lands here — reap - // it (cancel joins the reader; its late eof is ignored by gen) - if (ptys[sp.pane]) |*old| { - old.reader.cancel(io) catch {}; - _ = libc.close(old.file.handle); - ptys[sp.pane] = null; - } - gens[sp.pane] +%= 1; - const cwd = sp.cwd.slice(); - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const child = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w); - ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; - // report the pane's starting directory back to the core (tags). The - // slot needs no occupancy reset: nothing is remembered, and the next - // Exec asks about the shell that is there now. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); - if (threads_ok) { - if (ptys[sp.pane]) |*pt| { - pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), gens[sp.pane], loop }) catch pt.reader; - } - } - }, - .write => |w| { - if (ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice()); - }, - .resize_pty => |rs| { - if (ptys[rs.pane]) |pt| { - const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); - } - }, - .open_link => |url| look.openLink(url.slice()), // desktop browser - .save_file => |sf| { - const pane = core.panes[sf.pane] orelse continue; - const f = pane.file orelse continue; - var pathbuf: [4096:0]u8 = undefined; - if (f.path.len >= pathbuf.len) continue; - @memcpy(pathbuf[0..f.path.len], f.path); - pathbuf[f.path.len] = 0; - const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) continue; - writeFd(fd, f.content); - _ = libc.close(fd); - // our own write is about to come back as a watch event: restamp - // from the bytes we just put there so it reads as "no change" - if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) { - .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) }, + fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { + const s = of(ctx); + if (s.ptys[pane]) |pt| writeFd(pt.file.handle, bytes); + } + + fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { + const s = of(ctx); + if (s.ptys[pane]) |pt| { + const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); + } + } + + /// Is a pane's tty still the prompt we forked? Lazy by construction — it + /// runs only where the core is about to type a command line, so the /proc + /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a + /// document, a slot whose shell already died) is not a terminal a program + /// can be holding. + fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { + const s = of(ctx); + const pt = s.ptys[pane] orelse return false; + return look.ttyTaken(pt.pid, pt.file.handle); + } + + // ---- the filesystem ----------------------------------------------------- + + fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { + const s = of(ctx); + var pathbuf: [4096:0]u8 = undefined; + if (path.len >= pathbuf.len) return; + @memcpy(pathbuf[0..path.len], path); + pathbuf[path.len] = 0; + const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + writeFd(fd, bytes); + _ = libc.close(fd); + // our own write is about to come back as a watch event: restamp from + // the bytes we just put there so it reads as "no change". Only when + // this IS the pane's watched file — a `Save ` must not + // silence a real change to the file the pane has open. + if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { + if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { + .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; - // ...and say so on the pane's message row. AFTER the write, not - // beside it: every `continue` above is a save that did not happen - // and must not be reported as one. - var mbuf: [256]u8 = undefined; - core.setMessage(sf.pane, message.stamp(&mbuf, "saved", f.path)); - }, - .new_file => |request| { - var path_buf: [4096:0]u8 = undefined; - const made = temp_file.create(&path_buf) orelse continue; - if (core.openNewFile(request.pane, request.serial, made.path)) - made.adopt() - else - made.discard(); - }, - .write_dump => { - const out = core.dump_out orelse continue; - var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse continue; - const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) continue; - writeFd(fd, out); - _ = libc.close(fd); - core.setLastDump(path); - }, - .dump_themes => |request| { - const config_dir = core.opts.config_dir orelse continue; - const out_dir = user_config.dumpThemes(io, gpa, config_dir, pardes.themes) catch |err| { - core.reportError(request.pane, "dump themes", err); - continue; - }; - defer gpa.free(out_dir); - var mbuf: [256]u8 = undefined; - core.setMessage(request.pane, message.stamp(&mbuf, "dumped themes", out_dir)); - }, - .set_clipboard => { - // mirror the core's yank register out via OSC 52 - if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; - }, - .read_clipboard => { - // ...and the other direction, OSC 52 read. The answer arrives on - // vaxis's reader thread as an ordinary `.paste` event and reaches - // the core through the same path an outer bracketed paste does — - // this request is the only wiring it needs. "The answer arrives" - // is the optimistic reading: a clipboard READ is an exfiltration - // primitive and terminals treat it as one (ghostty prompts by - // default, xterm ships it off, a multiplexer or ssh link may eat - // it), and a refusal looks exactly like silence. So the core's - // pending request is dropped by the next keystroke rather than - // pasting minutes late, and `SPC p` in a locked-down terminal - // honestly does nothing. - vx.requestSystemClipboard(tty.writer()) catch {}; - }, - .lsp => |q| { - if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet - const pane = core.panes[q.pane] orelse continue; - // a pane with no file still asks `status` (it is about the backend, - // not the buffer): empty path and source, root from the pane's cwd - const f = pane.file; - const job = lsp_allocator.create(LspJob) catch continue; - job.* = .{ - .id = q.id, - .kind = q.kind, - .offset = q.offset, - .path = lsp_allocator.dupe(u8, if (f) |ff| ff.path else "") catch { - lsp_allocator.destroy(job); - continue; - }, - .source = lsp_allocator.dupeZ(u8, if (f) |ff| ff.content else "") catch { - lsp_allocator.free(job.path); - lsp_allocator.destroy(job); - continue; - }, - .arg = lsp_allocator.dupe(u8, q.arg.slice()) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.destroy(job); - continue; - }, - .root = lsp_allocator.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { - lsp_allocator.free(job.path); - lsp_allocator.free(job.source); - lsp_allocator.free(job.arg); - lsp_allocator.destroy(job); - continue; - }, - }; - // ponytail: ONE query in flight, so one future slot. Replacing it - // cancels-then-joins the previous worker, which for a backend that - // ignores cancellation means waiting out a query the user already - // abandoned. Queries are milliseconds; make this a real pool the - // day a backend takes long enough to notice. - if (lsp_task.*) |*old| { - old.cancel(io) catch {}; - lsp_task.* = null; - } - lsp_task.* = io.concurrent(lspWorker, .{ lsp_allocator, job, loop }) catch { - job.free(lsp_allocator); - continue; - }; - }, - .pipe => |request| { - if (!threads_ok) continue; - if (pipe_tasks.full()) { - core.update(.{ .pipe_resp = .{ .id = request.id, .success = false, .outputs = &.{} } }); - continue; - } - const view = core.pipeRequest(request.id) orelse continue; - const job = selection_pipe.Job.copy(gpa, view) catch continue; - const future = io.concurrent(pipeWorker, .{ io, gpa, job, loop }) catch { - job.deinit(gpa); - continue; - }; - pipe_tasks.add(.{ .id = request.id, .future = future }); - }, - .watch => |w| { - if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on)) - loop.postEvent(.files_changed) catch {}; - }, - .theme_file => |request| { - if (file_watch.applyThemeEffect( - core, - gpa, - inotify_fd, - watches, - request.generation, - request.on, - threads_ok, - )) loop.postEvent(.files_changed) catch {}; - }, - .quit => {}, - }; -} + }; + // ...and say so on the pane's message row. AFTER the write, not beside + // it: every early return above is a save that did not happen and must + // not be reported as one. + var mbuf: [256]u8 = undefined; + s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); + } + + fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { + const s = of(ctx); + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf) orelse return; + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + writeFd(fd, bytes); + _ = libc.close(fd); + s.core.setLastDump(path); + } + + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { + const s = of(ctx); + if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, &s.watches, pane, on)) + s.loop.postEvent(.files_changed) catch {}; + } + + fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { + const s = of(ctx); + if (file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, generation, on, s.threads_ok)) + s.loop.postEvent(.files_changed) catch {}; + } + + fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { + const s = of(ctx); + const config_dir = s.core.opts.config_dir orelse return; + const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + s.core.reportError(pane, "dump themes", err); + return; + }; + defer s.gpa.free(out_dir); + var mbuf: [256]u8 = undefined; + s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); + } + + // ---- the desktop -------------------------------------------------------- + + /// mirror the core's yank register out via OSC 52 + fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { + const s = of(ctx); + if (text.len == 0) return; + s.vx.copyToSystemClipboard(s.tty.writer(), text, s.gpa) catch {}; + } + + /// ...and the other direction, OSC 52 read. The answer arrives on vaxis's + /// reader thread as an ordinary `.paste` event and reaches the core + /// through the same path an outer bracketed paste does — this request is + /// the only wiring it needs. "The answer arrives" is the optimistic + /// reading: a clipboard READ is an exfiltration primitive and terminals + /// treat it as one (ghostty prompts by default, xterm ships it off, a + /// multiplexer or ssh link may eat it), and a refusal looks exactly like + /// silence. So the core's pending request is dropped by the next keystroke + /// rather than pasting minutes late, and `SPC p` in a locked-down terminal + /// honestly does nothing. + fn readClipboard(ctx: ?*anyopaque) void { + const s = of(ctx); + s.vx.requestSystemClipboard(s.tty.writer()) catch {}; + } + + fn openLink(_: ?*anyopaque, url: []const u8) void { + look.openLink(url); // desktop browser + } + + // ---- work that must leave the loop -------------------------------------- + + fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { + const s = of(ctx); + if (!s.threads_ok) return; // pre-loop drain: nothing to answer to yet + const pane = s.core.panes[req.pane] orelse return; + // a pane with no file still asks `status` (it is about the backend, + // not the buffer): empty path and source, root from the pane's cwd + const f = pane.file; + const a = s.lsp_gpa; + const job = a.create(LspJob) catch return; + job.* = .{ + .id = req.id, + .kind = req.kind, + .offset = req.offset, + .path = a.dupe(u8, if (f) |ff| ff.path else "") catch { + a.destroy(job); + return; + }, + .source = a.dupeZ(u8, if (f) |ff| ff.content else "") catch { + a.free(job.path); + a.destroy(job); + return; + }, + .arg = a.dupe(u8, req.arg) catch { + a.free(job.path); + a.free(job.source); + a.destroy(job); + return; + }, + .root = a.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { + a.free(job.path); + a.free(job.source); + a.free(job.arg); + a.destroy(job); + return; + }, + }; + // ponytail: ONE query in flight, so one future slot. Replacing it + // cancels-then-joins the previous worker, which for a backend that + // ignores cancellation means waiting out a query the user already + // abandoned. Queries are milliseconds; make this a real pool the day a + // backend takes long enough to notice. + if (s.lsp_task) |*old| { + old.cancel(s.io) catch {}; + s.lsp_task = null; + } + s.lsp_task = s.io.concurrent(lspWorker, .{ a, job, s.loop }) catch { + job.free(a); + return; + }; + } + + fn pipe(ctx: ?*anyopaque, id: u32) void { + const s = of(ctx); + if (!s.threads_ok) return; + if (s.pipe_tasks.full()) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return; + } + const view = s.core.pipeRequest(id) orelse return; + const job = selection_pipe.Job.copy(s.gpa, view) catch return; + const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch { + job.deinit(s.gpa); + return; + }; + s.pipe_tasks.add(.{ .id = id, .future = future }); + } +}; /// Answer a language query off the event loop and post the rows back. This is /// the whole async execution model: the same shape as readPty — do the slow @@ -1331,17 +1454,6 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -/// The core's `tty_query`: answer for one pane out of this host's pty table. -/// Lazy by construction — it runs only where the core is about to type a -/// command line, so the /proc walk costs nothing on an ordinary frame. A pane -/// with no pty of ours (a document, a slot whose shell already died) is not a -/// terminal a program can be holding. -fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool { - const table: *const [pardes.MAX_PANES]?Pty = @ptrCast(@alignCast(ctx orelse return false)); - const pt = table[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); -} - fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { var master: c_int = undefined; // resolved BEFORE the fork, into this frame, which the child inherits: -- cgit v1.3