summaryrefslogtreecommitdiff
path: root/src/tty/tty.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty/tty.zig')
-rw-r--r--src/tty/tty.zig1086
1 files changed, 599 insertions, 487 deletions
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();
+ }
+
+ // ---- pseudo-terminals ---------------------------------------------------
- // 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;
+ 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 <elsewhere>` 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: