const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const dump = @import("../dump.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); const selection_pipe = @import("../selection_pipe.zig"); const ninep_io = @import("../9p_io.zig"); const panel_compositor = @import("panel_compositor.zig"); const detached_client = @import("../detached/client.zig"); const detached_server = @import("../detached/server.zig"); const wire = @import("../detached/wire.zig"); const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); /// The terminal answered the shared-memory probe (`kittyShmProbe`): it can /// read a raster from a POSIX shm object, so a page costs a name on the wire /// instead of its whole base64 body. Set by the input reader, which sees the /// reply before vaxis' parser swallows every graphics APC alike. var kitty_shm = std.atomic.Value(bool).init(false); /// Only a reply that arrives while the probe is out counts. The id is one /// vaxis' own image numbering (1, 2, ...) never reaches, because a terminal /// answers every transmission `i=;OK` and one of those must not pass. var kitty_shm_probing = std.atomic.Value(bool).init(false); const kitty_shm_probe_id = 0x7fff_fff1; var kitty_shm_serial: u32 = 0; fn kittyShmName(buf: []u8, n: u32) ![:0]const u8 { return std.fmt.bufPrintSentinel(buf, "/pardes-{d}-{d}", .{ libc.getpid(), n }, 0); } /// Write `bytes` to a fresh shm object; the terminal unlinks it after reading. fn kittyShmWrite(name: [:0]const u8, bytes: []const u8) bool { const fd = libc.shm_open(name, @bitCast(libc.O{ .ACCMODE = .RDWR, .CREAT = true, .EXCL = true }), @as(c_uint, 0o600)); if (fd < 0) return false; defer _ = libc.close(fd); if (libc.ftruncate(fd, @intCast(bytes.len)) != 0) { _ = libc.shm_unlink(name); return false; } var off: usize = 0; while (off < bytes.len) { const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); if (n <= 0) { _ = libc.shm_unlink(name); return false; } off += @intCast(n); } return true; } /// Ask with a 1x1 RGB image in shm whether the terminal reads that medium. A /// remote terminal cannot see our shm and answers an error (or nothing). fn kittyShmProbe(w: *std.Io.Writer) void { var buf: [64]u8 = undefined; const name = kittyShmName(&buf, 0) catch return; if (!kittyShmWrite(name, &.{ 0, 0, 0 })) return; var b64: [64]u8 = undefined; const enc = std.base64.standard.Encoder.encode(&b64, name); kitty_shm_probing.store(true, .release); w.print("\x1b_Gi={d},s=1,v=1,a=q,t=s,f=24;{s}\x1b\\", .{ kitty_shm_probe_id, enc }) catch return; w.flush() catch {}; } pub const Command = struct { pub var value: union(enum) { nop, quit, tick, key_press: vaxis.Key, pty_read: struct { id: usize, gen: u32, bytes: []u8 }, pty_eof: struct { id: usize, gen: u32 }, winsize: vaxis.Winsize, mouse: vaxis.Mouse, focus_in, focus_out, paste: []const u8, paste_start, paste_end, lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, pipe_done: selection_pipe.Response, files_changed, fs_ready, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); fn startInput(loop: *Loop, cache: *vaxis.GraphemeCache) !void { if (comptime builtin.os.tag == .windows) return loop.start(); if (loop.thread != null) return; loop.thread = try loop.io.concurrent(inputThread, .{ loop, cache }); } fn stopInput(loop: *Loop) void { if (comptime builtin.os.tag == .windows) return loop.stop(); if (loop.thread) |*thread| { thread.cancel(loop.io); loop.thread = null; } } fn inputThread(loop: *Loop, cache: *vaxis.GraphemeCache) void { inputReader(loop, loop.tty, cache); } fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void { readInput(loop, tty, cache) catch |err| { if (err == error.Canceled) return; std.log.err("terminal input: {s}", .{@errorName(err)}); }; loop.postEvent(.quit) catch {}; } fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { try loop.postEvent(.{ .winsize = try tty.getWinsize() }); var parser: vaxis.Parser = .{}; var buf: [1024]u8 = undefined; var carried: usize = 0; while (!loop.should_quit) { if (carried == buf.len) return error.InputSequenceTooLong; const received = try tty.read(buf[carried..]); if (received == 0) return; const end = carried + received; if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| { kitty_shm_probing.store(false, .release); kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release); // Answered either way: a terminal that could not read it left it. var name_buf: [64]u8 = undefined; if (kittyShmName(&name_buf, 0)) |name| _ = libc.shm_unlink(name) else |_| {} }; var parse_end = end; var lead = end; while (lead > 0 and buf[lead - 1] & 0xc0 == 0x80) lead -= 1; if (lead > 0) { const scalar_len = std.unicode.utf8ByteSequenceLength(buf[lead - 1]) catch 1; if (scalar_len > end - (lead - 1)) parse_end = lead - 1; } var consumed: usize = 0; while (consumed < parse_end) { const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); if (result.n == 0) break; consumed += result.n; if (result.event) |event| vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| { if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste)); return err; }; } carried = end - consumed; std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); } } test "terminal input preserves fragmented keys queries paste and text after EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var env = try std.testing.environ.createMap(gpa); defer env.deinit(); var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); var output: std.Io.Writer.Allocating = .init(gpa); defer output.deinit(); defer vx.deinit(gpa, &output.writer); vx.queries_done.store(false, .unordered); var tty: vaxis.Tty = undefined; var loop: Loop = .init(io, &tty, &vx); var cache: vaxis.GraphemeCache = .{}; const Reader = struct { parts: []const []const u8, next: usize = 0, fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } fn read(self: *@This(), buf: []u8) !usize { if (self.next == self.parts.len) return 0; const part = self.parts[self.next]; self.next += 1; @memcpy(buf[0..part.len], part); return part.len; } }; var reader: Reader = .{ .parts = &.{ "plain \x1b[?62;", "4c\x1b[", "A\x1b[200", "~caf\xc3", "\xa9 \xe7", "\x95", "\x8c \xf0\x9f", "\x98\x80", "\x1b[201", "~\x1b]52;c;Y2", "xpcA==\x07tail", "\x1b", } }; try readInput(&loop, &reader, &cache); try std.testing.expect(vx.queries_done.load(.unordered)); var text: std.ArrayList(u8) = .empty; defer text.deinit(gpa); var resized = false; var up = false; var in_paste = false; var pasted = false; var clipboard = false; var escape = false; while (try loop.tryEvent()) |event| switch (event) { .winsize => |size| { try std.testing.expect(!resized); resized = true; try std.testing.expectEqual(@as(u16, 80), size.cols); }, .key_press => |key| { try std.testing.expect(resized); if (key.codepoint == vaxis.Key.up) { up = true; } else if (key.codepoint == vaxis.Key.escape) { escape = true; } else if (key.text) |bytes| { if (in_paste) try std.testing.expect(up); try text.appendSlice(gpa, bytes); } else return error.UnexpectedInputKey; }, .paste_start => { try std.testing.expect(up and !in_paste); in_paste = true; }, .paste_end => { try std.testing.expect(in_paste); in_paste = false; pasted = true; }, .paste => |bytes| { defer gpa.free(@constCast(bytes)); try std.testing.expect(pasted and !in_paste); try std.testing.expectEqualStrings("clip", bytes); clipboard = true; }, else => return error.UnexpectedInputEvent, }; try std.testing.expectEqualStrings("plain café 界 😀tail", text.items); try std.testing.expect(resized and up and pasted and clipboard and escape); } test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; var env = try std.testing.environ.createMap(gpa); defer env.deinit(); var vx = try vaxis.init(io, gpa, &env, .{}); var output: std.Io.Writer.Allocating = .init(gpa); defer output.deinit(); defer vx.deinit(gpa, &output.writer); const Reader = struct { file: std.Io.File, eof: bool, entered: std.Io.Event = .unset, release: std.Io.Event = .unset, returned: std.Io.Event = .unset, fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } fn read(self: *@This(), buf: []u8) !usize { self.entered.set(std.testing.io); if (self.eof) { try self.release.wait(std.testing.io); self.returned.set(std.testing.io); return 0; } return self.file.readStreaming(std.testing.io, &.{buf}); } fn run(loop: *Loop, reader: *@This(), cache: *vaxis.GraphemeCache) void { inputReader(loop, reader, cache); } }; const Case = enum { blocked_read, cancel_eof, deliver_eof }; for (std.enums.values(Case)) |case| { const eof = case != .blocked_read; var fds: [2]c_int = undefined; if (libc.pipe(&fds) != 0) return error.PipeFailed; defer _ = libc.close(fds[0]); defer _ = libc.close(fds[1]); var reader: Reader = .{ .file = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }, .eof = eof }; var tty: vaxis.Tty = undefined; var loop: Loop = .init(io, &tty, &vx); var cache: vaxis.GraphemeCache = .{}; loop.thread = try io.concurrent(Reader.run, .{ &loop, &reader, &cache }); defer stopInput(&loop); try std.testing.expectEqual(.winsize, std.meta.activeTag(try loop.nextEvent())); try reader.entered.wait(io); if (eof) { for (0..512) |_| try loop.postEvent(.nop); reader.release.set(io); try reader.returned.wait(io); } if (case == .deliver_eof) { try std.testing.expectEqual(.nop, std.meta.activeTag(try loop.nextEvent())); loop.thread.?.await(io); } stopInput(&loop); try std.testing.expect(loop.thread == null); var count: usize = 0; while (try loop.tryEvent()) |event| { const expected: std.meta.Tag(@TypeOf(Command.value)) = if (case == .deliver_eof and count == 511) .quit else .nop; try std.testing.expectEqual(expected, std.meta.activeTag(event)); count += 1; } try std.testing.expectEqual(@as(usize, if (eof) 512 else 0), count); } } const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; const Pty = struct { file: std.Io.File, pid: posix.pid_t, reader: std.Io.Future(anyerror!void), }; const KittyPlacement = struct { cell_x: u16, cell_y: u16, cell_cols: u16, cell_rows: u16, options: vaxis.Image.DrawOptions, }; fn terminalCellPixels(total_pixels: u16, cells: u16, fallback: u32) u32 { if (total_pixels == 0 or cells == 0) return fallback; return @max(1, @as(u32, total_pixels) / cells); } fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u16, pixel_h: u16) void { core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = @intCast(terminalCellPixels(pixel_w, cols, 8)), .h = @intCast(terminalCellPixels(pixel_h, rows, 16)), }, } }); } fn kittyPlacement( place: pardes.ImagePlace, screen_cols: u16, screen_rows: u16, screen_pixel_w: u16, screen_pixel_h: u16, ) ?KittyPlacement { if (comptime pardes.pdf_enabled) { if (place.native.fit == .contain) return null; const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8); const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16); const body_w = std.math.mul(u32, place.w, cell_w) catch return null; const body_h = std.math.mul(u32, place.h, cell_h) catch return null; const geometry = place.native.geometry orelse pardes.image.nativeGeometry( place.iw, place.ih, body_w, body_h, place.native.fit, place.native.pan_x, place.native.pan_y, ) orelse return null; const safe_rows_u32 = geometry.dst.h / cell_h; if (safe_rows_u32 == 0) return null; const safe_pixel_h = safe_rows_u32 * cell_h; var safe_src_h: u32 = geometry.src.h; var declared_cols: u32 = 0; var declared_rows: u32 = 0; switch (place.native.fit) { .width => { declared_cols = @intCast(@max( @as(u64, 1), (@as(u64, geometry.dst.w) + cell_w - 1) / cell_w, )); const declared_pixel_w = @as(u64, declared_cols) * cell_w; const max_src_h: u32 = @intCast( @as(u64, safe_pixel_h) * geometry.src.w / declared_pixel_w, ); if (max_src_h == 0) return null; safe_src_h = @min(safe_src_h, max_src_h); const aspect_pixel_h: u32 = @intCast( (@as(u64, safe_src_h) * declared_pixel_w + geometry.src.w - 1) / geometry.src.w, ); declared_rows = @intCast( (@as(u64, aspect_pixel_h) + cell_h - 1) / cell_h, ); }, .height => { declared_rows = safe_rows_u32; safe_src_h = @max(@as(u32, 1), @as(u32, @intCast( @as(u64, safe_pixel_h) * geometry.src.h / geometry.dst.h, ))); safe_src_h = @min(safe_src_h, geometry.src.h); const aspect_pixel_w: u32 = @intCast( (@as(u64, geometry.src.w) * safe_pixel_h + safe_src_h - 1) / safe_src_h, ); declared_cols = @intCast( (@as(u64, aspect_pixel_w) + cell_w - 1) / cell_w, ); }, .contain => unreachable, } if (declared_rows == 0 or declared_rows > safe_rows_u32) return null; const src_x = std.math.cast(u16, geometry.src.x) orelse return null; const src_y = std.math.cast(u16, geometry.src.y) orelse return null; const src_w = std.math.cast(u16, geometry.src.w) orelse return null; const src_h = std.math.cast(u16, safe_src_h) orelse return null; const cell_x = std.math.cast(u16, geometry.dst.x / cell_w) orelse return null; const cell_y = std.math.cast(u16, geometry.dst.y / cell_h) orelse return null; if (cell_x >= place.w or cell_y >= place.h) return null; const pixel_x = std.math.cast(u16, geometry.dst.x % cell_w) orelse return null; const pixel_y = std.math.cast(u16, geometry.dst.y % cell_h) orelse return null; const cell_cols = std.math.cast(u16, @min( @as(u32, place.w - cell_x), (@as(u64, pixel_x) + @as(u64, declared_cols) * cell_w + cell_w - 1) / cell_w, )) orelse return null; const cell_rows = std.math.cast(u16, @min( @as(u32, place.h - cell_y), (@as(u64, pixel_y) + declared_rows * cell_h + cell_h - 1) / cell_h, )) orelse return null; if (cell_cols == 0 or cell_rows == 0) return null; return .{ .cell_x = cell_x, .cell_y = cell_y, .cell_cols = cell_cols, .cell_rows = cell_rows, .options = .{ .clip_region = .{ .x = src_x, .y = src_y, .width = src_w, .height = src_h }, .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = pixel_x, .y = pixel_y } else null, .size = switch (place.native.fit) { .width => .{ .cols = std.math.cast(u16, declared_cols) orelse return null }, .height => .{ .rows = std.math.cast(u16, declared_rows) orelse return null }, .contain => unreachable, }, }, }; } else { return null; } } test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { if (comptime !pardes.pdf_enabled) return; const base = pardes.ImagePlace{ .pane = 0, .serial = 1, .native = .{ .revision = 1, .page = 0, .fit = .width, .geometry = .{ .src = .{ .w = 96, .h = 10 }, .dst = .{ .y = 195, .w = 304, .h = 29 }, }, }, .x = 0, .y = 0, .w = 38, .h = 14, .rgba = &.{}, .iw = 96, .ih = 64, }; const width = kittyPlacement(base, 80, 16, 640, 256) orelse return error.MissingSafeKittyWidthFragment; const width_clip = width.options.clip_region.?; const width_size = width.options.size.?; try std.testing.expectEqual(@as(u16, 5), width_clip.height.?); try std.testing.expectEqual(@as(u16, 38), width_size.cols.?); try std.testing.expectEqual(@as(u16, 3), width.options.pixel_offset.?.y); const aspect_pixels = (@as(u32, width_size.cols.?) * 8 * width_clip.height.? + width_clip.width.? - 1) / width_clip.width.?; const inferred_rows = (aspect_pixels + 15) / 16; try std.testing.expect(inferred_rows * 16 <= base.native.geometry.?.dst.h); var height = base; height.native.fit = .height; height.native.geometry.?.src.h = 9; const height_fragment = kittyPlacement(height, 80, 16, 640, 256) orelse return error.MissingSafeKittyHeightFragment; try std.testing.expectEqual(@as(u16, 1), height_fragment.options.size.?.rows.?); try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <= height.native.geometry.?.dst.h); height.native.geometry.?.dst.h = 13; try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null); } test "host watch closes initial race and reloads rename-over PDF while idle" { if (comptime !file_watch.supported or !pardes.pdf_enabled) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const original = try pardes.pdf.makeOutlineTestPdf(gpa); defer gpa.free(original); const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa); defer gpa.free(replacement); try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original }); try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement }); try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original }); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/live.pdf", .{tmp.sub_path}); const fd = file_watch.init(true); if (fd < 0) return error.WatchInitFailed; defer _ = libc.close(fd); var watches: file_watch.Table = @splat(null); defer for (0..pardes.MAX_PANES) |id| file_watch.watchPane( fd, &watches, @intCast(id), null, 0, .{ .text = 0 }, ); const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); defer core.deinit(); try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count); try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io); var armed = false; while (core.nextEffect()) |effect| switch (effect) { .watch => |watch| if (watch.pane == 0 and watch.on) { armed = true; try std.testing.expect(!file_watch.applyEffect(core, io, fd, &watches, 0, true, watch.mode)); }, else => {}, }; try std.testing.expect(armed and watches[0] != null); try std.testing.expectEqual(@as(usize, 1), core.panes[0].?.pdf.?.page_count); try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io); try std.testing.expect(file_watch.drain(fd)); try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches)); const pane = core.panes[0].?; try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count); const disk_identity = try file_watch.identify(io, path); switch (watches[0].?.generation) { .pdf => |accepted| try std.testing.expect(accepted != null and accepted.?.eql(disk_identity)), .text => return error.PdfWatchStoredTextGeneration, } try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "reloaded") != null); core.native_images = true; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const surface = try core.render(frame.allocator()); try std.testing.expect(surface.nimages > 0); try std.testing.expectEqual(@as(u32, 0), surface.images[0].?.native.page); } fn kittyImageRepresentable(place: pardes.ImagePlace) bool { return place.iw > 0 and place.ih > 0 and place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16); } fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (comptime pardes.pdf_enabled) if (!kittyImageRepresentable(place)) continue; if (place.cacheKey().eql(key)) return true; } return false; } const PdfWheelTarget = struct { pane: usize, page: usize }; fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWheelTarget { if (comptime !pardes.pdf_enabled) return null; if (!core.native_images or mouse.type != .press or (mouse.button != .wheel_up and mouse.button != .wheel_down) or mouse.col < 0 or mouse.row < 0) return null; const col: u16 = @intCast(mouse.col); const row: u16 = @intCast(mouse.row); for (core.panes, 0..) |slot, id| { const pane = slot orelse continue; const rect = core.rects[id]; if (col < rect.x or col >= rect.x + rect.w or row < rect.y or row >= rect.y + rect.h) continue; const page = pane.pdfPage() orelse return null; return .{ .pane = id, .page = page }; } return null; } pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void { const io = init.io; const gpa = init.gpa; var attach_end: AttachEnd = .none; defer attach_end.report(io); var name_buf: [detached_server.path_max]u8 = undefined; var attach_name: []const u8 = &.{}; if (attach) |requested| switch (detached_client.resolve(&name_buf, requested)) { .name => |resolved| attach_name = resolved, .none => { attach_end = if (requested.len != 0) .{ .no_session = requested } else .nothing_detached; return; }, .ambiguous => |found| { attach_end = .{ .ambiguous = found }; return; }, }; var winch_set = posix.sigemptyset(); posix.sigaddset(&winch_set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null); var tty_buf: [0x10000]u8 = undefined; var tty = try vaxis.Tty.init(io, &tty_buf); defer tty.deinit(); // restore cooked termios LAST, after vx flushed its resets var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa }); defer vx.deinit(gpa, tty.writer()); try vx.enterAltScreen(tty.writer()); defer vx.exitAltScreen(tty.writer()) catch {}; try vx.setMouseMode(tty.writer(), true); try vx.setBracketedPaste(tty.writer(), true); if (attach != null) { attach_end = attachSession(init, attach_name, &tty, &vx); return; } var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa); defer { clearNativeImages(&kitty_handles, &vx, &tty); kitty_handles.deinit(); } var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); var input_cache: vaxis.GraphemeCache = .{}; var attached: ?detached_client.Client = null; defer if (attached != null) { stopInput(&loop); drainAttachedQueue(&loop, gpa); }; try localSession(init, opts, &tty, &vx, &loop, &input_cache, &kitty_handles, &paste_buf, &attached); if (attached) |*client| { defer client.detach(); var a: Attach = .{ .gpa = gpa, .client = client, .loop = &loop, .vx = &vx, .tty = &tty, .paste_buf = &paste_buf, }; attach_end = attachLoop(&a); } } fn localSession( init: std.process.Init, opts: pardes.Options, tty: *vaxis.Tty, vx: *vaxis.Vaxis, loop: *Loop, input_cache: *vaxis.GraphemeCache, kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), paste_buf: *std.Io.Writer.Allocating, attached: *?detached_client.Client, ) !void { const io = init.io; const gpa = init.gpa; const allocs = pardes.memory.init(gpa); defer pardes.memory.deinit(); var options = opts; options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; options.frame_allocator = allocs.frame; pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } var core = if (options.load_path) |lp| blk: { const bytes = try filesystem.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.dump.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); var fs = ninep_io.start(io, gpa, core); defer if (fs) |f| f.deinit(gpa); 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, .inotify_fd = file_watch.init(false), .fs = fs, }; pardes.lsp.setStatusSink(&sh, lspStatusSink); defer { pardes.lsp.setStatusSink(null, null); for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); if (attached.* != null) { _ = libc.kill(pt.pid, posix.SIG.KILL); _ = libc.waitpid(pt.pid, null, 0); } slot.* = null; }; if (sh.lsp_task) |*t| { t.future.cancel(io) catch {}; sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); file_watch.stop(sh.inotify_fd); if (sh.watch_task) |*t| { t.cancel(io) catch {}; sh.watch_task = null; } 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), .paste => |b| gpa.free(@constCast(b)), .lsp_done => |d| if (d.rows) |rows| allocs.lsp.free(rows), .lsp_status => |text| allocs.lsp.free(text), .pipe_done => |response_value| { var response = response_value; response.deinit(gpa); }, else => {}, }; } const host = sh.host(); core.host = host; while (core.nextEffect()) |effect| core.perform(effect); try startInput(loop, input_cache); defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach(); if (fs) |f| try f.watch(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); 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 }); }; if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null; // A terminal unlinks what it reads; whatever it never read (the probe // unanswered, a transmission lost with the terminal) is ours to remove. defer { var name_buf: [64]u8 = undefined; var n: u32 = 0; while (n <= kitty_shm_serial) : (n += 1) { if (kittyShmName(&name_buf, n)) |name| _ = libc.shm_unlink(name) else |_| {} } } frames: while (!core.quit) { pardes.turn.restoreSettled(); try core.pump(host); if (core.takeRestore()) |rp| blk: { const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; defer gpa.free(bytes); const nc = dump.restore(core, bytes) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; if (sh.lsp_task) |*task| { task.future.cancel(io) catch {}; sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); for (&sh.gens) |*generation| generation.* +%= 1; for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; }; for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( sh.inotify_fd, &sh.watches, @intCast(wid), null, 0, .{ .text = 0 }, ); _ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false); clearNativeImages(kitty_handles, vx, tty); nc.native_images = vx.caps.kitty_graphics; if (fs) |f| f.reset(nc); core.deinit(); core = nc; sh.core = nc; if (comptime pardes.pdf_enabled) { updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } if (core.takeAttach()) |req| { var attempt = detached_client.attempt(gpa, req.name, core.screen_w, core.screen_h); switch (attempt) { .greeted => |client| { attached.* = client; break :frames; }, else => { var mbuf: [256]u8 = undefined; core.setMessage(req.pane, attemptEnd(&attempt, req.name).row(&mbuf)); }, } } } if (fs) |f| { f.deinit(gpa); fs = null; sh.fs = null; } } fn clearNativeImages( kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), vx: *vaxis.Vaxis, tty: *vaxis.Tty, ) void { var iterator = kitty_handles.valueIterator(); while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.clearRetainingCapacity(); } fn drainAttachedQueue(loop: *Loop, gpa: std.mem.Allocator) void { while (loop.tryEvent() catch null) |ev| switch (ev) { .paste => |b| gpa.free(@constCast(b)), else => {}, }; } const Shell = struct { io: std.Io, gpa: std.mem.Allocator, lsp_gpa: std.mem.Allocator, core: *pardes.Pardes, prompt_rcs: *const host_io.Shell.PromptFiles, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), frame: *std.heap.ArenaAllocator, paste_buf: *std.Io.Writer.Allocating, inotify_fd: c_int, fs: ?*ninep_io.Listener = null, ptys: [pardes.MAX_PANES]?Pty = @splat(null), gens: [pardes.MAX_PANES]u32 = @splat(0), watches: file_watch.Table = @splat(null), watch_task: ?std.Io.Future(anyerror!void) = null, lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: PipeTasks = .{}, threads_ok: bool = false, caps_pending: bool = true, check_files: bool = false, in_paste: bool = false, tracks: []const pardes.layout.Track = &.{}, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } fn host(s: *Shell) host_io.Host { return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; } const isolated_vtable: host_io.Host.VTable = .{ .wait_input = waitInput, .present = present, .post_present = postPresent, }; const vtable: host_io.Host.VTable = .{ .wait_input = waitInput, .present = present, .post_present = postPresent, .poll_frame = pollFrame, .spawn = spawn, .pty_write = ptyWrite, .pty_resize = ptyResize, .pty_signal = ptySignal, .tty_taken = ttyTaken, .write_file = writeFile, .write_dump = writeDump, .watch_file = watchFile, .watch_theme = watchTheme, .dump_themes = dumpThemes, .set_clipboard = setClipboard, .read_clipboard = readClipboard, .open_link = openLink, .lsp = lsp, .pipe = pipe, }; fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; // The wait is the 9P connections' turn with the core. Animating, the // frame's tick arrives through the same queue (`tickWatch`). if (timeout_ms != 0) tick_request.set(s.io); pardes.turn.rest(); const first = s.loop.nextEvent() catch { pardes.turn.wake(); s.core.quit = true; return s.reloadWatched(); }; pardes.turn.wake(); batch = 1; // A run of queued motions is one move to its last cell: each would // otherwise redo hover and selection work the next one undoes. var motion: ?@TypeOf(Command.value) = null; var ev = first; while (true) { if (ev == .mouse and (ev.mouse.type == .motion or ev.mouse.type == .drag)) { if (motion) |m| if (m.mouse.button != ev.mouse.button or m.mouse.type != ev.mouse.type or @as(u3, @bitCast(m.mouse.mods)) != @as(u3, @bitCast(ev.mouse.mods))) { _ = s.apply(m); }; motion = ev; } else { if (motion) |m| _ = s.apply(m); motion = null; if (s.apply(ev)) break; } if (!s.in_paste and batch >= 64) break; ev = (s.loop.tryEvent() catch null) orelse break; batch += 1; } if (motion) |m| _ = s.apply(m); s.reloadWatched(); } 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| { if (s.gens[pr.id] == pr.gen) 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) { const bytes = pasteBytes(key); 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(keyEvent(key)), .mouse => |m| { const pdf_before = nativePdfWheelTarget(core, m); if (mouseEvent(m)) |ev| core.update(ev); 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; const pasted = s.paste_buf.written(); if (pasted.len > 0) core.update(.{ .paste = pasted }); s.paste_buf.clearRetainingCapacity(); }, .files_changed => s.check_files = true, .fs_ready => {}, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); if (d.rows) |rows| s.lsp_gpa.free(rows); if (s.lsp_task) |*t| if (t.id == d.id) { t.future.await(s.io) catch {}; s.lsp_task = null; }; }, .lsp_status => |text| { var mbuf: [256]u8 = undefined; core.setStatus(core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_gpa.free(text); }, .pipe_done => |response_value| { var response = response_value; core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, .failure = response.failure, } }); 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 {}; } fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); s.core.native_image_patches = kitty_shm.load(.acquire); if (s.fs) |f| if (f.tick().pending) { _ = s.loop.tryPostEvent(.fs_ready) catch {}; }; for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); }; if (s.caps_pending and s.vx.queries_done.load(.unordered)) { s.caps_pending = false; s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; s.core.native_images = s.vx.caps.kitty_graphics; if (s.vx.caps.kitty_graphics) kittyShmProbe(s.tty.writer()); s.vx.queueRefresh(); } } 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); 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(); paintCells(win, surface.cells, surface.cols, surface.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue; if (comptime pardes.pdf_enabled) { if (!kittyImageRepresentable(place)) continue; } const key = place.cacheKey(); if (!s.kitty.contains(key) and vx.caps.kitty_graphics and kitty_shm.load(.acquire)) shm: { var buf: [64]u8 = undefined; kitty_shm_serial +%= 1; const name = kittyShmName(&buf, kitty_shm_serial) catch break :shm; if (!kittyShmWrite(name, place.rgba)) break :shm; if (vx.transmitLocalImagePath(s.gpa, s.tty.writer(), name, @intCast(place.iw), @intCast(place.ih), .shared_mem, .rgba) catch null) |handle| s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id) else _ = libc.shm_unlink(name); } if (!s.kitty.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { defer s.gpa.free(b64); _ = enc.encode(b64, place.rgba); 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 (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 {}; } else { if (place.native.fit == .contain) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else if (kittyPlacement( place, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix, )) |placement| { const child = win.child(.{ .x_off = @as(i17, place.x) + placement.cell_x, .y_off = @as(i17, place.y) + placement.cell_y, .width = placement.cell_cols, .height = placement.cell_rows, }); cached.draw(child, placement.options) catch {}; } } } } while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; 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 (s.kitty.fetchRemove(key)) |removed| vx.freeImage(s.tty.writer(), removed.value.id); if (stale_len < stale.len) break; } if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); vx.render(s.tty.writer()) catch {}; tz_render.end(); } fn postPresent(ctx: ?*anyopaque) void { const s = of(ctx); s.core.acknowledgePanelPresentation(s.tracks); tracy.frameMark(); } fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); if (s.ptys[pane]) |*old| { old.reader.cancel(s.io) catch {}; _ = libc.close(old.file.handle); s.ptys[pane] = null; } s.gens[pane] +%= 1; const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); if (s.threads_ok) { 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 ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = of(ctx); if (s.ptys[pane]) |pt| _ = host_io.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)); } } fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); if (s.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; return host_io.ttyTaken(pt.pid, pt.file.handle); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); 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 => {}, }; }; 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, s.core.settings.dump_dir.get()) orelse return; filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = of(ctx); if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, &s.watches, pane, on, mode)) 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 = pardes.config.User.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)); } fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); copyToClipboard(s.vx, s.tty, s.gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { const s = of(ctx); requestClipboard(s.vx, s.tty); } fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly } fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = of(ctx); if (!s.threads_ok) { s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); return s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); } const job = host_io.Lsp.snapshot(s.lsp_gpa, s.core, req) catch |err| { s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); return s.core.reportError(req.pane, "lsp", err); }; if (s.lsp_task) |*old| { old.future.cancel(s.io) catch {}; s.lsp_task = null; } const future = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch |err| { job.free(s.lsp_gpa); s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); return s.core.reportError(req.pane, "lsp", err); }; s.lsp_task = .{ .id = req.id, .future = future }; } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = of(ctx); if (!s.threads_ok) { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); } if (s.pipe_tasks.full()) { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } const view = s.core.pipe.pipeRequest(id) orelse return; const job = selection_pipe.Job.copy(s.gpa, view) catch |err| { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return s.core.reportError(s.core.active, "pipe", err); }; const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch |err| { job.deinit(s.gpa); s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return s.core.reportError(s.core.active, "pipe", err); }; std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); } }; test "TTY queued results reject old PTY generations and LSP request IDs" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer core.deinit(); var shell: Shell = .{ .io = std.testing.io, .gpa = gpa, .lsp_gpa = gpa, .core = core, .prompt_rcs = undefined, .loop = undefined, .vx = undefined, .tty = undefined, .kitty = undefined, .frame = undefined, .paste_buf = undefined, .inotify_fd = -1, }; shell.gens[0] = 2; _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 1, .bytes = try gpa.dupe(u8, "old session\n") } }); _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 2, .bytes = try gpa.dupe(u8, "current session\n") } }); const text = try pardes.panes.Terminal.screenTextAlloc(core.panes[0].?, gpa); defer gpa.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "old session") == null); try std.testing.expect(std.mem.indexOf(u8, text, "current session") != null); core.lspRequest(0, .status, ""); const old_id = core.lsp_wait.?.id; core.lspRequest(0, .status, ""); const current_id = core.lsp_wait.?.id; shell.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; _ = shell.apply(.{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); try std.testing.expect(shell.lsp_task != null); try std.testing.expectEqual(current_id, shell.lsp_task.?.id); try std.testing.expectEqual(current_id, core.lsp_wait.?.id); _ = shell.apply(.{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); try std.testing.expect(shell.lsp_task == null); try std.testing.expect(core.lsp_wait == null); } test "TTY worker setup failures finish matching LSP and pipe requests" { const gpa = std.testing.allocator; var failing_vtable = std.testing.io.vtable.*; failing_vtable.concurrent = std.Io.failingConcurrent; const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; for (0..2) |failure| { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("abc"); var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), }); var shell: Shell = .{ .io = failing_io, .gpa = failing.allocator(), .lsp_gpa = failing.allocator(), .core = core, .prompt_rcs = undefined, .loop = undefined, .vx = undefined, .tty = undefined, .kitty = undefined, .frame = undefined, .paste_buf = undefined, .inotify_fd = -1, .threads_ok = true, }; core.lspRequest(core.active, .status, ""); const req: host_io.Lsp.Request = .{ .id = core.lsp_wait.?.id, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "", }; Shell.lsp(&shell, req); try std.testing.expect(core.lsp_wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); pane.body.cur_col = 2; pane.body.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; core.update(.{ .key = .{ .cp = '|' } }); core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); core.update(.{ .key = .{ .cp = pardes.Key.enter } }); const pipe_id = core.pipe.wait.?.id; Shell.pipe(&shell, pipe_id); try std.testing.expect(core.pipe.wait == null); try std.testing.expectEqualStrings("abc", pane.file.?.content); try std.testing.expect(shell.lsp_task == null); try std.testing.expectEqual(@as(usize, 0), shell.pipe_tasks.len); } } fn copyToClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty, gpa: std.mem.Allocator, text: []const u8) void { if (text.len == 0) return; vx.copyToSystemClipboard(tty.writer(), text, gpa) catch {}; } fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { vx.requestSystemClipboard(tty.writer()) catch {}; } fn lspWorker(allocator: std.mem.Allocator, job: *host_io.Lsp.Job, loop: *Loop) anyerror!void { var sink: LspRowSink = .{ .allocator = allocator, .loop = loop }; host_io.Lsp.work(allocator, job, &sink, LspRowSink.take); } const LspRowSink = struct { allocator: std.mem.Allocator, loop: *Loop, fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return)); s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch { if (rows) |owned| s.allocator.free(owned); }; } }; fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const s: *Shell = @ptrCast(@alignCast(ctx orelse return)); const copy = s.lsp_gpa.dupe(u8, text) catch return; const posted = s.loop.tryPostEvent(.{ .lsp_status = copy }) catch false; if (!posted) s.lsp_gpa.free(copy); } fn pipeWorker( io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, loop: *Loop, ) anyerror!void { defer job.deinit(gpa); var response = selection_pipe.runJob(gpa, io, job); loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa); } fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { if (comptime builtin.os.tag != .linux) { while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break; return; } const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; var read_buf: [4096]u8 = undefined; var reader = file.readerStreaming(io, &read_buf); while (true) { var buf: [4096]u8 = undefined; var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; loop.postEvent(.files_changed) catch break; } } fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); while (true) { var sig: c_int = 0; if (libc.sigwait(&set, &sig) != 0) continue; if (vx.state.in_band_resize) continue; // terminal reports via CSI 48 const ws = tty.getWinsize() catch continue; loop.postEvent(.{ .winsize = ws }) catch {}; } } /// Set by an animating frame's wait: post one `.tick` a frame from now. The /// tick joins the input queue, so a key or a click that lands inside the /// frame is handled at once instead of after a blind sleep. var tick_request: std.Io.Event = .unset; fn tickWatch(io: std.Io, loop: *Loop) void { while (true) { tick_request.waitUncancelable(io); tick_request.reset(); var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms }; _ = libc.nanosleep(&ts, null); loop.postEvent(.tick) catch {}; } } fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; } fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: u32, loop: *Loop) anyerror!void { var read_buf: [0x10000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); while (true) { var buf: [0x10000]u8 = undefined; var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; const bytes = try gpa.dupe(u8, buf[0..n]); loop.postEvent(.{ .pty_read = .{ .id = id, .gen = gen, .bytes = bytes } }) catch { gpa.free(bytes); break; }; } _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {}; } fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; var it = view.iterator(); if (it.nextCodepoint()) |c| { if (it.nextCodepoint() == null) return c; } } return key.shifted_codepoint orelse key.codepoint; } fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.kp_enter => pardes.Key.enter, vaxis.Key.up => pardes.Key.up, vaxis.Key.down => pardes.Key.down, vaxis.Key.left => pardes.Key.left, vaxis.Key.right => pardes.Key.right, vaxis.Key.home => pardes.Key.home, vaxis.Key.end => pardes.Key.end, vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, else => cp, }; } const max_paste_bytes: usize = 4 << 20; fn pasteBytes(key: vaxis.Key) []const u8 { const text = key.text orelse ""; if (text.len > 0) return text; const cp = mapKey(effCp(key)); if (cp == pardes.Key.tab) return "\t"; if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n"; return ""; } fn keyEvent(key: vaxis.Key) pardes.Event { return .{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }; } test "keypad Enter matches Return for editor input terminal writes and paste" { for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| { const key: vaxis.Key = .{ .codepoint = codepoint }; const event = keyEvent(key); try std.testing.expectEqual(pardes.Key.enter, event.key.cp); try std.testing.expectEqualStrings("\n", pasteBytes(key)); { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("ab"); core.update(.{ .key = .{ .cp = 'i' } }); core.update(event); try std.testing.expectEqualStrings("\nab", pane.file.?.content); } { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); core.panes[0].?.body.mode = .tty; while (core.nextEffect()) |_| {} core.update(event); var written: std.Io.Writer.Allocating = .init(std.testing.allocator); defer written.deinit(); while (core.nextEffect()) |effect| { if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice()); } try std.testing.expectEqualStrings("\r", written.written()); } } } fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { 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 => return null, }; return .{ .mouse = .{ .button = button, .kind = switch (m.type) { .press => .press, .release => .release, .motion => .motion, .drag => .drag, }, .col = @intCast(m.col), .row = @intCast(m.row), .ctrl = m.mods.ctrl, } }; } fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; while (y < rows) : (y += 1) { var x: u16 = 0; while (x < cols) : (x += 1) { const cell = &cells[@as(usize, y) * cols + x]; if (cell.default) continue; win.writeCell(x, y, .{ .char = .{ .grapheme = cell.grapheme() }, .style = vaxisStyle(cell.style), }); } } } fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { win.showCursor(x, y); win.setCursorShape(if (bar) .beam else .default); } fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { return .{ .fg = vaxisColor(s.fg), .bg = vaxisColor(s.bg), .bold = s.bold, .dim = s.dim, .italic = s.italic, .blink = s.blink, .reverse = s.reverse, .invisible = s.invisible, .strikethrough = s.strikethrough, .ul_style = switch (s.ul) { .off => .off, .single => .single, .double => .double, .curly => .curly, .dotted => .dotted, .dashed => .dashed, }, }; } fn vaxisColor(c: pardes.Color) vaxis.Color { return switch (c) { .default => .default, .index => |i| .{ .index = i }, .rgb => |rgb| .{ .rgb = rgb }, }; } const AttachEnd = union(enum) { none, no_session: []const u8, nothing_detached, ambiguous: usize, refused: wire.Refusal, lost: anyerror, rejected, silent, detached: []const u8, fn report(e: AttachEnd, io: std.Io) void { var buf: [512]u8 = undefined; var ok = false; const text: []const u8 = switch (e) { .none => return, .detached => |name| blk: { ok = true; break :blk if (name.len != 0) std.fmt.bufPrint( &buf, "pardes: detached from '{s}', which is still running — come back with `pardes --attach={s}`\n", .{ name, name }, ) catch "pardes: detached; that session is still running\n" else "pardes: detached; that session is still running — come back with `pardes --attach`\n"; }, .no_session => |name| std.fmt.bufPrint( &buf, "pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n", .{ name, name }, ) catch "pardes: no detached session under that name\n", .nothing_detached => "pardes: no detached session is running (start one with `pardes --detach`)\n", .ambiguous => |n| std.fmt.bufPrint( &buf, "pardes: {d} detached sessions are running; say which with --attach=\n", .{n}, ) catch "pardes: several detached sessions are running; say which with --attach=\n", .refused => |why| switch (why) { .version => "pardes: that session speaks a different wire version — it is another build of pardes\n", .full => "pardes: that session already has every frontend slot taken\n", .quitting => "pardes: that session is ending\n", }, .lost => |err| std.fmt.bufPrint(&buf, "pardes: detached session lost: {t}\n", .{err}) catch "pardes: detached session lost\n", .rejected => "pardes: that session hung up on the connect — every frontend slot is taken (32), or it is shutting down. `PARDES_LOG=1` on the session names which\n", .silent => "pardes: that session accepted the connection and then never greeted it — it is wedged inside its own loop, or something else is listening at that path. `PARDES_LOG=1` on the session says which\n", }; const out = if (ok) std.Io.File.stdout() else std.Io.File.stderr(); out.writeStreamingAll(io, text) catch {}; if (!ok) std.process.exit(1); } fn row(e: AttachEnd, buf: []u8) []const u8 { return switch (e) { .none => "attach: that session ended", .no_session => |name| std.fmt.bufPrint(buf, "attach: no session '{s}'", .{name}) catch "attach: no session under that name", .nothing_detached => "attach: no detached session is running", .ambiguous => |n| std.fmt.bufPrint(buf, "attach: {d} sessions running, name one", .{n}) catch "attach: several sessions running, name one", .refused => |why| switch (why) { .version => "attach: that session is another build of pardes", .full => "attach: that session has every frontend slot taken", .quitting => "attach: that session is ending", }, .lost => |err| std.fmt.bufPrint(buf, "attach: {t}", .{err}) catch "attach: link lost", .rejected => "attach: that session hung up on the connect", .silent => "attach: that session accepted and never greeted", .detached => "attach: detached from that session", }; } }; const Attach = struct { gpa: std.mem.Allocator, client: *detached_client.Client, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_buf: *std.Io.Writer.Allocating, session: []const u8 = &.{}, caps_pending: bool = true, in_paste: bool = false, dirty: bool = false, fn send(a: *Attach, ev: pardes.Event) ?AttachEnd { a.client.send(.{ .event = ev }) catch |err| switch (err) { error.Overlong, error.NoSpace => return null, else => return .{ .lost = err }, }; return null; } fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd { switch (msg) { .welcome => {}, .refuse => |why| return .{ .refused = why }, .frame => a.dirty = true, .quit => return .none, .detach => return .{ .detached = a.session }, .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text), .read_clipboard => requestClipboard(a.vx, a.tty), .open_link => |url| look.openLink(url), } return null; } fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { switch (event) { .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, .focus_in => {}, .focus_out => return a.send(.pointer_leave), .winsize => |ws| { a.vx.resize(a.gpa, a.tty.writer(), ws) catch {}; a.dirty = true; a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err }; }, .key_press => |key| if (a.in_paste) { const bytes = pasteBytes(key); const room = max_paste_bytes -| a.paste_buf.written().len; a.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; } else return a.send(keyEvent(key)), .mouse => |m| if (mouseEvent(m)) |ev| return a.send(ev), .paste => |bytes| { defer a.gpa.free(@constCast(bytes)); return a.send(.{ .paste = bytes }); }, .paste_start => { a.in_paste = true; a.paste_buf.clearRetainingCapacity(); }, .paste_end => { a.in_paste = false; defer a.paste_buf.clearRetainingCapacity(); const pasted = a.paste_buf.written(); if (pasted.len > 0) return a.send(.{ .paste = pasted }); }, } return null; } fn enableCaps(a: *Attach) void { if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return; a.caps_pending = false; a.vx.enableDetectedFeatures(a.tty.writer()) catch {}; a.vx.queueRefresh(); a.dirty = true; } fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); a.vx.render(a.tty.writer()) catch {}; } }; fn attemptEnd(a: *const detached_client.Attempt, requested: []const u8) AttachEnd { return switch (a.*) { .greeted => .none, .no_session => if (requested.len != 0) .{ .no_session = requested } else .nothing_detached, .ambiguous => |found| .{ .ambiguous = found }, .refused => |why| .{ .refused = why }, .silent => .silent, .lost => |err| if (err == error.Closed) .rejected else .{ .lost = err }, }; } fn attachLoop(a: *Attach) AttachEnd { while (true) { const link = a.client.wait(detached_client.poll_ms); while (true) { const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break; if (a.handle(msg)) |end| return end; } link catch |err| return .{ .lost = err }; var batch: usize = 0; while (a.in_paste or batch < 64) { const ev = (a.loop.tryEvent() catch |err| return .{ .lost = err }) orelse break; batch += 1; if (a.apply(ev)) |end| return end; } a.enableCaps(); if (a.dirty) a.paint(); } } fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd { const io = init.io; const gpa = init.gpa; const ws = tty.getWinsize() catch |err| return .{ .lost = err }; if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize }; vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err }; var attempt = detached_client.attempt(gpa, name, ws.cols, ws.rows); var client = switch (attempt) { .greeted => |c| c, else => return attemptEnd(&attempt, name), }; defer client.detach(); var loop: Loop = .init(io, tty, vx); var input_cache: vaxis.GraphemeCache = .{}; var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); var a: Attach = .{ .gpa = gpa, .client = &client, .loop = &loop, .vx = vx, .tty = tty, .paste_buf = &paste_buf, .session = name, }; defer drainAttachedQueue(&loop, gpa); startInput(&loop, &input_cache) catch |err| return .{ .lost = err }; defer stopInput(&loop); (std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach(); vx.queryTerminalSend(tty.writer()) catch {}; return attachLoop(&a); }