diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1103 |
1 files changed, 873 insertions, 230 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 968b73fb..405cc3f2 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -46,6 +46,13 @@ pub const allocators = @import("allocators.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); +/// The host seam: one struct of optional function pointers, with in-core +/// defaults for every method a host leaves null. See src/host.zig. +const host_mod = @import("host.zig"); +pub const Host = host_mod.Host; +pub const Fanout = host_mod.Fanout; +pub const Fallback = host_mod.Fallback; +pub const fallback_dump_path = host_mod.fallback_dump_path; /// Tracy's frame boundary, re-exported so a host that is not a shell — the /// fling benchmark — can delimit the same frames the tty loop delimits without /// reaching around the core for src/tracy.zig and its build options. A no-op @@ -55,6 +62,12 @@ pub const frameMark = tracy.frameMark; pub const Platform = enum { tty, gui, web, macos }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); +/// A build with no host but its display: the embedded source filesystem, the +/// in-process clipboard, silent ptys. Comptime, and its own option module +/// rather than a `pardes_config` field, because it is the one setting that +/// produces a SECOND executable from the same graph — see `run-isolated`. +pub const isolated = @import("pardes_isolation").isolated; + /// Frontends that draw their own text, and can therefore be told which face to /// wear. On the tty the font belongs to the terminal emulator and in the /// browser it belongs to the page, so there the Font builtins are not @@ -147,6 +160,11 @@ const dirty_marker = " *"; // parser recognizes them as defaults while new dumps carry an explicit tail. const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; +// The defaults from the release before Newtty joined every tagline. Recognized +// so a dump written then upgrades instead of keeping the old word as a tail. +const prev_pane_tail = " New Del"; +const prev_file_pane_tail = " Save New Del"; +const prev_terminal_pane_tail = " New Del Filter"; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from execute), no matter where the name @@ -570,7 +588,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(pane)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; @@ -580,19 +598,22 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { // the rendered tag rather than assumed to be at its left edge. const rendered = try p.tagText(p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1; + var panes_before: usize = 0; + for (p.panes) |s| if (s != null) { + panes_before += 1; + }; p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = new_x, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = new_x, .row = tag_y } }); - var saw_new = false; while (p.nextEffect()) |effect| switch (effect) { - .new_file => |request| { - try std.testing.expectEqual(@as(u8, 0), request.pane); - try std.testing.expectEqual(pane.serial, request.serial); - saw_new = true; - }, .write => return error.NewFellThroughToPty, else => {}, }; - try std.testing.expect(saw_new); + var panes_after: usize = 0; + for (p.panes) |s| if (s != null) { + panes_after += 1; + }; + try std.testing.expectEqual(panes_before + 1, panes_after); + try std.testing.expect(p.panes[p.active].?.file.?.output != null); // the scratch // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. @@ -629,7 +650,6 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, - .new_file => return error.UnknownCommandRanNew, else => {}, }; try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); @@ -684,7 +704,7 @@ test "an untouched tagline ends where its layout column's widest one does" { const f = p.layoutFindTerm(p.active).?; p.layoutInsert(f.col, f.idx + 1, below_id); p.splitBelow(p.active, below); - p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/off/the/end/of/its/own/tagline"); + p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} @@ -696,7 +716,7 @@ test "an untouched tagline ends where its layout column's widest one does" { // path, which is the whole point (equal at tw - tag_right_pad would prove // nothing: that is where both sat before) try std.testing.expectEqual(below_tag.len, above_tag.len); - try std.testing.expectEqualStrings(" New Del Filter", above_tag[above_tag.len - " New Del Filter".len ..]); + try std.testing.expectEqualStrings(" New Newtty Del Filter", above_tag[above_tag.len - " New Newtty Del Filter".len ..]); try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); @@ -745,7 +765,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_old); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal)); // The immediately preceding release used the generic current default on // terminals. It upgrades too, including any saved layout padding. @@ -754,7 +774,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_previous); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal)); const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), @@ -786,7 +806,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. @@ -808,7 +828,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); - try std.testing.expectEqualStrings(" Save New Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), @@ -832,7 +852,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { // with only `img PATH`. Their custom tails still migrate through the // pane-specific legacy-prefix recognizer. const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -843,6 +863,54 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice()); } +test "Joincol folds the active column into its right neighbor, keeping its panes" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + const right = p.freeSlot().?; + _ = try p.newShell(right, ""); + try std.testing.expect(p.layoutSplitColumn(0, right, false)); + while (p.nextEffect()) |_| {} + try std.testing.expectEqual(@as(usize, 2), p.ncol); + + p.active = 0; // the left column is current + p.joinCol(); + try std.testing.expectEqual(@as(usize, 1), p.ncol); + const lf = p.layoutFindTerm(0) orelse return error.LostLeftPane; + const rf = p.layoutFindTerm(right) orelse return error.LostRightPane; + try std.testing.expectEqual(lf.col, rf.col); + + p.joinCol(); // no right neighbor left: inert + try std.testing.expectEqual(@as(usize, 1), p.ncol); +} + +test "Newtty spawns a raw shell in the caller's directory" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.setCwd(0, "/tmp/newtty-dir"); + + p.spawnTty(0); + const free = p.active; + try std.testing.expect(free != 0); + const nt = p.panes[free] orelse return error.NoNewPane; + try std.testing.expect(nt.isTerminal()); + try std.testing.expect(nt.mode == .tty); + + var found = false; + while (p.nextEffect()) |effect| switch (effect) { + .spawn => |s| if (@as(usize, s.pane) == free) { + try std.testing.expectEqualStrings("/tmp/newtty-dir", s.cwd.slice()); + found = true; + }, + else => {}, + }; + try std.testing.expect(found); +} + test "an unsaved file marker sits between its path and builtins until Save" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -909,11 +977,13 @@ const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, + const vtable: Host.VTable = .{ .pull_tty_taken = answer }; + fn install(f: *FakeTtyQuery, p: *Pardes) void { - p.tty_query = .{ .ctx = f, .taken = &answer }; + p.host = .{ .ctx = f, .vtable = &vtable }; } - fn answer(ctx: ?*anyopaque, pane: usize) bool { + fn answer(ctx: ?*anyopaque, pane: u8) bool { const f: *FakeTtyQuery = @ptrCast(@alignCast(ctx.?)); f.asked += 1; return f.taken[pane]; @@ -1186,145 +1256,201 @@ test "the host is asked about a tty only where a command line is about to go" { try std.testing.expectEqual(@as(usize, 2), host.asked); } -test "New completes as an empty watched file in the calling column and focuses it" { +test "New opens an empty scratch below the caller, inheriting its directory" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); - while (p.nextEffect()) |_| {} // initial shell spawns + while (p.nextEffect()) |_| {} const source: usize = 2; // the right column; active starts in the left + p.setCwd(source, "/tmp/pardes-scratch-dir"); const source_col = p.layoutFindTerm(source).?.col; - try std.testing.expect(p.executeBuiltinLine(source, "New")); - var request: ?Effect = null; - while (p.nextEffect()) |effect| switch (effect) { - .new_file => request = effect, - else => {}, - }; - const new_effect = request orelse return error.MissingNewFileEffect; - const req = new_effect.new_file; - try std.testing.expectEqual(@as(u8, @intCast(source)), req.pane); - try std.testing.expectEqual(p.panes[source].?.serial, req.serial); - // Focus changes while the shell is doing IO. Placement must still use the - // effect's calling-pane token, never this later-active left column. - p.active = 0; - try std.testing.expect(p.openNewFile(req.pane, req.serial, "/tmp/pardes-test-new-a")); + try std.testing.expect(p.executeBuiltinLine(source, "New")); + // no shell IO: the scratch is created in-core, focused, below the caller + while (p.nextEffect()) |_| {} const id = p.active; - try std.testing.expect(id != 0 and id != source); + try std.testing.expect(id != source); try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); - const sf = p.layoutFindTerm(source).?; - try std.testing.expectEqual(id, p.col_terms[sf.col][sf.idx + 1]); + const np = p.panes[id].?; + try std.testing.expect(np.file.?.output != null); // an output buffer, empty + try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); + // its directory is a LIVE link to the opener, not a snapshot taken at New + try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); + p.setCwd(source, "/tmp/pardes-moved"); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); +} - const file = p.panes[id].?.file orelse return error.NewIsNotAFile; - try std.testing.expectEqualStrings("/tmp/pardes-test-new-a", file.path); - try std.testing.expectEqual(@as(usize, 0), file.content.len); - try std.testing.expect(file.output == null); - try std.testing.expect(!p.panes[id].?.isTerminal()); +test "Save on a scratch asks for a path in its inherited dir and makes it a file" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} - var watched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |w| if (w.pane == id and w.on) { - watched = true; - }, - else => {}, - }; - try std.testing.expect(watched); + const source: usize = 2; + p.setCwd(source, "/tmp/pardes-save-dir"); + try std.testing.expect(p.executeBuiltinLine(source, "New")); + const id = p.active; + const np = p.panes[id].?; + p.sync(); // the frame boundary that gives the new pane its geometry + while (p.nextEffect()) |_| {} - // It is an ordinary file from here on: Save uses the normal file effect, - // and the dump contains its path and empty content. + // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); + try std.testing.expect(np.hasSavePrompt()); + try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); + + // typing the filename and submitting converts it into an ordinary file + try std.testing.expect(np.appendTag("note.txt")); + p.submitSave(id); + try std.testing.expect(np.file.?.output == null); + try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); var saved = false; while (p.nextEffect()) |effect| switch (effect) { - .save_file => |s| if (s.pane == id) { + .save_file => |sf| if (@as(usize, sf.pane) == id) { saved = true; }, else => {}, }; try std.testing.expect(saved); - try p.dumpState(); - const state = try dump.readZon(std.testing.allocator, p.dump_out.?, "New-test"); - defer dump.free(std.testing.allocator, state); - var dumped = false; - for (state.panes) |dp| if (dp.file) |df| { - if (std.mem.eql(u8, df.path, "/tmp/pardes-test-new-a")) { - try std.testing.expectEqual(@as(usize, 0), df.content.len); - dumped = true; - } - }; - try std.testing.expect(dumped); - while (p.nextEffect()) |_| {} // write_dump +} + +test "Save on a terminal writes its plaintext scrollback and stays a terminal" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + p.setCwd(0, "/tmp/pardes-tty-save"); + p.update(.{ .output = .{ .pane = 0, .bytes = "hello scrollback\r\n" } }); + while (p.nextEffect()) |_| {} - // Del closes and unwatches, but there is intentionally no unlink effect: - // adopted temporary documents preserve user data and dump restorability. - try std.testing.expect(p.executeBuiltinLine(id, "Del")); - try std.testing.expect(p.panes[id] == null); - var unwatched = false; + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + try std.testing.expect(pane.hasSavePrompt()); + try std.testing.expect(pane.appendTag("log.txt")); + p.submitSave(0); + + // the pane is untouched: still a terminal, no file behind it + try std.testing.expect(pane.isTerminal()); + try std.testing.expect(pane.file == null); + const st = p.save_text orelse return error.MissingSaveText; + try std.testing.expectEqualStrings("/tmp/pardes-tty-save/log.txt", st.path); + try std.testing.expect(std.mem.indexOf(u8, st.content, "hello scrollback") != null); + var asked = false; while (p.nextEffect()) |effect| switch (effect) { - .watch => |w| if (w.pane == id and !w.on) { - unwatched = true; - }, + .save_text => asked = true, else => {}, }; - try std.testing.expect(unwatched); + try std.testing.expect(asked); } -test "New requests repeat, stay anchored, and reject stale or full completions" { - const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); +test "a host with no methods at all is a complete in-process pardes" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const source: usize = 2; - const source_col = p.layoutFindTerm(source).?.col; - try std.testing.expect(p.executeBuiltinLine(source, "New")); - try std.testing.expect(p.executeBuiltinLine(source, "New")); - var requests: [2]Effect = undefined; - var n: usize = 0; - while (p.nextEffect()) |effect| switch (effect) { - .new_file => if (n < requests.len) { - requests[n] = effect; - n += 1; - }, - else => {}, + // A pump against the empty host renders a frame and blocks on nothing. + try p.pump(.{}); + try std.testing.expect(!p.quit); + + // the absent child is SILENT: the bytes are dropped, and nothing appears + // on the pane's screen to suggest a program answered + const before = p.panes[0].?.vt.screens.active.cursor.y; + p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); + try std.testing.expectEqual(before, p.panes[0].?.vt.screens.active.cursor.y); + + // the clipboard round-trips through the in-process one + p.yank = try gpa.dupe(u8, "copied"); + p.perform(.set_clipboard); + try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); + + // ...and a save with no filesystem lands in the virtual one + const doc = try p.hxOpenFileContent("body\n"); + var doc_id: u8 = 0; + for (p.panes, 0..) |slot, i| if (slot == doc) { + doc_id = @intCast(i); }; - try std.testing.expectEqual(requests.len, n); + p.perform(.{ .save_file = .{ .pane = doc_id } }); + try std.testing.expectEqualStrings("body\n", p.fallback.files.get(doc.file.?.path).?); - p.active = 0; - try std.testing.expect(p.openNewFile(requests[0].new_file.pane, requests[0].new_file.serial, "/tmp/pardes-test-new-1")); - const first = p.active; - p.active = 1; // change focus again before the second completion - try std.testing.expect(p.openNewFile(requests[1].new_file.pane, requests[1].new_file.serial, "/tmp/pardes-test-new-2")); - const second = p.active; - try std.testing.expect(first != second); - try std.testing.expectEqual(source_col, p.layoutFindTerm(first).?.col); - try std.testing.expectEqual(source_col, p.layoutFindTerm(second).?.col); - try std.testing.expect(!std.mem.eql(u8, p.panes[first].?.file.?.path, p.panes[second].?.file.?.path)); - while (p.nextEffect()) |_| {} + // a language backend nobody supplied answers nothing rather than hanging + p.perform(.{ .lsp = .{ .id = 7, .kind = .hover, .pane = doc_id, .offset = 0, .arg = .{} } }); + + p.perform(.quit); + try std.testing.expect(p.quit); +} + +const RecordHost = struct { + gpa: std.mem.Allocator, + writes: std.ArrayListUnmanaged(u8) = .empty, + /// What this host would answer a clipboard read with, and whether it was + /// ever asked — a pull must reach exactly one host. + clipboard: []const u8 = "", + asked: usize = 0, + core: ?*Pardes = null, - // A reused pane slot cannot answer an old request, and a malformed shell - // answer cannot manufacture a nameless file. - const old_serial = p.panes[source].?.serial; - p.panes[source].?.serial +%= 1; - try std.testing.expect(!p.openNewFile(@intCast(source), old_serial, "/tmp/pardes-test-stale")); - try std.testing.expect(!p.openNewFile(@intCast(source), p.panes[source].?.serial, "")); + const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard }; - // Exhaust every remaining slot after a request was issued: completion - // rejects it, which tells the shell to unlink the just-created candidate. - const live_serial = p.panes[source].?.serial; - for (0..MAX_PANES) |id| { - if (p.panes[id] == null) _ = try p.newDocPane(id); + fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { + _ = pane; + const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); + self.writes.appendSlice(self.gpa, bytes) catch {}; } - try std.testing.expect(!p.openNewFile(@intCast(source), live_serial, "/tmp/pardes-test-full")); - try std.testing.expect(p.executeBuiltinLine(source, "New")); - var emitted_when_full = false; - while (p.nextEffect()) |effect| switch (effect) { - .new_file => emitted_when_full = true, - else => {}, + + fn readClipboard(ctx: ?*anyopaque) void { + const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); + self.asked += 1; + self.core.?.update(.{ .paste = self.clipboard }); + } + + fn host(self: *RecordHost) Host { + return .{ .ctx = self, .vtable = &vt }; + } +}; + +test "a fan-out host reaches every wrapped host, each with its own state" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); + defer p.deinit(); + + var a: RecordHost = .{ .gpa = gpa }; + defer a.writes.deinit(gpa); + var b: RecordHost = .{ .gpa = gpa }; + defer b.writes.deinit(gpa); + + const wrapped = [_]Host{ a.host(), b.host() }; + var fan: Fanout = .init(&wrapped); + p.host = fan.host(); + p.perform(.{ .write = .{ .pane = 0, .bytes = .from("echo hi\r") } }); + + try std.testing.expectEqualStrings("echo hi\r", a.writes.items); + try std.testing.expectEqualStrings("echo hi\r", b.writes.items); + + // A PULL reaches ONE host, and the name is what says so. Fanned out, both + // would answer and the core would paste the clipboard twice for one Ctrl-V. + a.core = p; + b.core = p; + a.clipboard = "from-a"; + b.clipboard = "from-b"; + const doc = p.panes[p.active].?; + doc.mode = .normal; + p.perform(.read_clipboard); + try std.testing.expectEqual(@as(usize, 1), a.asked); + try std.testing.expectEqual(@as(usize, 0), b.asked); + + // a method NO wrapped host implements still falls back per-method + p.perform(.{ .open_link = .from("https://example.invalid") }); + try std.testing.expectEqualStrings("https://example.invalid", p.fallback.link.items); +} + +test "a builtin that needs a pane reports capacity failure when every slot is full" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + for (0..MAX_PANES) |id| if (p.panes[id] == null) { + _ = try p.newDocPane(id); }; - try std.testing.expect(!emitted_when_full); - // Builtins that need another pane surface capacity/allocation failures on - // the asking pane instead of silently doing nothing. const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; - p.runBuiltin(help, source, "", null); - try std.testing.expect(std.mem.indexOf(u8, p.panes[source].?.msg[0..p.panes[source].?.msg_len], "help: NoPaneSlots") != null); + p.runBuiltin(help, 0, "", null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); } test "Msg writes the transient row by hand, bare or with text, and input ends it" { @@ -1701,10 +1827,12 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // not a derivation — see it there for why each word is in or out. This is the // check that a rename cannot silently rot it. comptime { - @setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin + @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); - while (it.next()) |w| { - if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); + words: while (it.next()) |w| { + for (@typeInfo(Builtin).@"enum".fields) |f| + if (std.mem.eql(u8, f.name, w)) continue :words; + @compileError("topbar word is not a builtin: " ++ w); } } @@ -2943,22 +3071,6 @@ pub const Event = union(enum) { tick, }; -/// The one thing the core PULLS from the host instead of being pushed or -/// emitting an effect: is a pane's terminal still the prompt the host forked, -/// or has a program (vim, a pager, an agent) taken its tty? -/// -/// An effect cannot answer it — `execute` has to choose a destination inside -/// the update that asked, and an effect is drained after. A pushed fact could, -/// and did, but only by having every host probe every pane's processes on every -/// frame to answer a question that is asked when a human middle-clicks a word. -/// So the host leaves a way to be asked, and the core asks where it decides -/// (see `Pardes.takesCommandLine`). `ctx` is the host's own pty table; the -/// answer must not re-enter the core. -pub const TtyQuery = struct { - ctx: ?*anyopaque, - taken: *const fn (ctx: ?*anyopaque, pane: usize) bool, -}; - /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { @@ -2969,11 +3081,10 @@ pub const Effect = union(enum) { /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, - /// Create an empty, uniquely-named temporary file. `pane` + `serial` - /// identify the pane/column that issued New even if focus moves before the - /// shell completes the IO. The shell calls openNewFile with the path and - /// unlinks it itself when that completion rejects the request. - new_file: struct { pane: u8, serial: u32 }, + /// write plaintext to a path WITHOUT converting the pane (a terminal's + /// scrollback save): the bytes and path are stashed in core.save_text, + /// read off the core exactly like save_file reads a file pane. + save_text: struct { pane: u8 }, /// a serialized state dump is ready in core.dump_out; write it to the /// path dump.outPath resolves (acme-style: another instance loads it /// with -l, or the Restore builtin loads it into this one) @@ -3155,9 +3266,14 @@ const Prompt = union(enum) { none, search: u16, pipe: u16, + /// Save on a scratch buffer or a terminal: the tail is a path to write to. + save: u16, }; pub const Pane = struct { + /// A pane's working directory. `.inherited` is a live `*Pane` link kept + /// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup. + pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 }; vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, /// The same allocator Pardes holds. A pane already owns heap (its content, @@ -3263,10 +3379,11 @@ pub const Pane = struct { ed_undo_len: usize = 0, ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined, ed_redo_len: usize = 0, - /// live working directory, shell-reported (setCwd); shown in the tag and - /// the base for look-resolution of relative paths. - cwd: [1024]u8 = undefined, - cwd_len: u16 = 0, + /// Working directory: shell-reported bytes (.owned, in cwd_buf), a live + /// link to the pane it was opened from (.inherited), or unknown (.none). + /// The inherited pointer is kept valid by deferred pane teardown + fixup. + cwd: Cwd = .none, + cwd_buf: [1024]u8 = undefined, /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, /// or the terminal's shell rows with its edit buffer standing in). Tracks /// the shell cursor until pinned by a click or a key. @@ -3334,7 +3451,7 @@ pub const Pane = struct { fn promptAt(p: *const Pane) ?u16 { return switch (p.prompt) { .none => null, - .search, .pipe => |at| at, + .search, .pipe, .save => |at| at, }; } @@ -3352,6 +3469,13 @@ pub const Pane = struct { }; } + fn hasSavePrompt(p: *const Pane) bool { + return switch (p.prompt) { + .save => true, + else => false, + }; + } + fn appendTag(p: *Pane, text: []const u8) bool { if (text.len > p.tag_tail.len - p.tag_tail_len) return false; @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); @@ -3374,7 +3498,18 @@ pub const Pane = struct { } pub fn cwdSlice(p: *const Pane) []const u8 { - return p.cwd[0..p.cwd_len]; + return switch (p.cwd) { + .none => "", + .owned => |dir| dir, + .inherited => |src| src.cwdSlice(), + }; + } + + /// Shell-reported directory: own the bytes in cwd_buf. + pub fn setOwnedCwd(pane: *Pane, dir: []const u8) void { + const n = @min(dir.len, pane.cwd_buf.len); + @memcpy(pane.cwd_buf[0..n], dir[0..n]); + pane.cwd = .{ .owned = pane.cwd_buf[0..n] }; } pub fn isTerminal(pane: *const Pane) bool { @@ -3382,6 +3517,18 @@ pub const Pane = struct { return pane.file == null and pane.image == null and no_pdf; } + /// The one coloring choice keyed on what a pane IS, so the highlight + /// producer (refreshHighlights) and the render pass agree on the algorithm. + pub const ColorAlgo = enum { none, tty, source, diff }; + pub fn colorAlgo(pane: *const Pane) ColorAlgo { + if (pane.isTerminal()) return .tty; + if (pane.file) |f| { + if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; + return .source; + } + return .none; + } + pub fn pdfPath(pane: *const Pane) ?[]const u8 { if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; return null; @@ -4689,6 +4836,9 @@ pub const Options = struct { image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, tree_sitter_allocator: ?std.mem.Allocator = null, + /// Where each frame's Surface text is built. Hosts pass a purpose-built + /// stack-fallback arena; null means the general allocator. + frame_allocator: ?std.mem.Allocator = null, /// Initial grid. Shell contract: for LIVE sessions leave these at the /// defaults and deliver the real size as the first resize EVENT — the core /// defers an integrated shell's greeting until after a resize AND its OSC @@ -4742,6 +4892,32 @@ pub const Haptic = enum { none, exec, look }; /// the field, so no other shell carries it. const HapticSlot = if (platform == .macos) Haptic else void; +/// Deferred pane teardown. A dropped pane's memory outlives the frame it died +/// in: it is doomed here and actually torn down one full frame later, so any +/// `*Pane` captured that frame — an effect, another pane's inherited cwd — +/// stays valid long enough for the per-frame fixup pass to repair it. `fresh` +/// holds this frame's drops, `stale` the previous frame's, freed next reap. +pub const PaneAllocator = struct { + fresh: [2 * MAX_PANES]?*Pane = @splat(null), + stale: [2 * MAX_PANES]?*Pane = @splat(null), + + fn doom(a: *PaneAllocator, pane: *Pane) void { + for (&a.fresh) |*slot| if (slot.* == null) { + slot.* = pane; + return; + }; + // One update runs one builtin, and the widest drop is a whole column; + // 2*MAX_PANES cannot fill in a single frame. + unreachable; + } + + fn isDoomed(a: *const PaneAllocator, pane: *const Pane) bool { + for (a.fresh) |s| if (s == pane) return true; + for (a.stale) |s| if (s == pane) return true; + return false; + } +}; + pub const Pardes = struct { gpa: std.mem.Allocator, image_gpa: std.mem.Allocator, @@ -4754,6 +4930,8 @@ pub const Pardes = struct { resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), + /// Deferred teardown of dropped panes (see PaneAllocator). + pane_alloc: PaneAllocator = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, /// Fixed-point horizontal proportions. Integer sums make splitting W @@ -4879,11 +5057,6 @@ pub const Pardes = struct { /// terminal, GPU textures in SDL). Image panes dynamically fall back to /// the PETSCII matcher without it. native_images: bool = false, - /// How the core asks the host the one question about a pane it cannot - /// answer itself — see `takesCommandLine`, the only caller. Installed once - /// by the native hosts (and again after a dump replay builds a second - /// core); left null by the web shell, which has no processes. - tty_query: ?TtyQuery = null, quit: bool = false, /// The Look or Exec that has happened and not yet been felt, taken by the /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs @@ -4982,6 +5155,19 @@ pub const Pardes = struct { effects_head: usize = 0, effects_len: usize = 0, + /// WHO SERVES THIS CORE. Every method optional; a null one is answered by + /// `fallback` below, so a `Host{}` is a complete in-process pardes. + host: Host = .{}, + /// The in-program answers behind every unimplemented host method. Per + /// instance, so several cores behind one fan-out host stay independent. + fallback: Fallback, + /// Input the loop has not consumed yet. Single-threaded: a host's worker + /// threads keep their own thread-safe inbox and post from the loop thread, + /// which is what keeps this ring lock-free. + in_q: [64]Event = undefined, + in_head: usize = 0, + in_len: usize = 0, + /// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and /// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the /// two commands that cross that line. @@ -4992,6 +5178,9 @@ pub const Pardes = struct { clip_pending: ?ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, + /// A terminal scrollback save awaiting the host: bytes + path, gpa-owned, + /// read by the save_text effect and cleared once written. At most one. + save_text: ?struct { content: []const u8, path: []u8 } = null, /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore <path>` last_dump: ?[]const u8 = null, @@ -5003,6 +5192,9 @@ pub const Pardes = struct { surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update scratch: std.heap.ArenaAllocator, + /// The arena each frame's Surface text is built in, reset by `pump`. Hosts + /// that still own their loop pass their own arena to `render` instead. + frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built /// for — see term_pane.RowsCache for the lifetime rule shell_rows: term_pane.RowsCache = .{}, @@ -5021,6 +5213,8 @@ pub const Pardes = struct { .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), + .frame_arena = .init(opts.frame_allocator orelse gpa), + .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); if (opts.file) |path| { @@ -5078,18 +5272,32 @@ pub const Pardes = struct { pub fn deinit(p: *Pardes) void { p.cancelLookHover(); for (&p.panes) |*slot| if (slot.*) |pane| { - p.deinitPane(pane); + p.teardownPane(pane); + slot.* = null; + }; + for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + p.teardownPane(pane); + slot.* = null; + }; + for (&p.pane_alloc.fresh) |*slot| if (slot.*) |pane| { + p.teardownPane(pane); slot.* = null; }; const gpa = p.gpa; if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); + if (p.save_text) |s| { + gpa.free(s.content); + gpa.free(s.path); + } + p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); if (p.pipe_wait) |*wait| wait.deinit(gpa); p.shell_rows.reset(gpa); p.scratch.deinit(); + p.frame_arena.deinit(); gpa.free(p.surface.cells); if (p.presented_cells.len > 0) gpa.free(p.presented_cells); if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); @@ -5120,7 +5328,24 @@ pub const Pardes = struct { return config.topbar_str; } + /// Drop a pane: its slot is freed for reuse now, but the allocation is + /// doomed and actually torn down a frame later (reapPanes), so pointers to + /// it survive the frame. Callers still null `panes[id]` themselves. pub fn deinitPane(p: *Pardes, pane: *Pane) void { + // Logical close, while the pane is still installed: stop the file/PDF + // watch keyed to this slot and drop any hover it owns. The heap + // teardown is deferred (reapPanes) so pointers to it survive the frame. + const watched = (if (pane.file) |f| f.output == null else false) or hasPdf(pane); + if (watched) for (p.panes, 0..) |slot, id| { + if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + }; + if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); + p.pane_alloc.doom(pane); + } + + /// The real teardown, run by reapPanes once the pane has been doomed for a + /// full frame (or at deinit). Frees every heap payload the pane owns. + fn teardownPane(p: *Pardes, pane: *Pane) void { if (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); @@ -5140,6 +5365,32 @@ pub const Pardes = struct { p.gpa.destroy(pane); } + /// Once a frame: repair live panes' pointers to doomed panes, then free the + /// panes doomed a full frame ago. Fixup runs first so no pointer outlives + /// the memory. `stale` (last frame's dead) is freed; `fresh` becomes stale. + fn reapPanes(p: *Pardes) void { + for (p.panes) |slot| if (slot) |pane| p.fixupPaneRefs(pane); + for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + p.teardownPane(pane); + slot.* = null; + }; + p.pane_alloc.stale = p.pane_alloc.fresh; + p.pane_alloc.fresh = @splat(null); + } + + /// Visit each Pane field that holds a pane pointer; when it names a doomed + /// pane, snapshot that pane's directory into our own bytes so the link can + /// die with it. One field carries a pointer today (the inherited cwd); the + /// comptime walk keeps that honest as fields come and go. + fn fixupPaneRefs(p: *Pardes, pane: *Pane) void { + inline for (@typeInfo(Pane).@"struct".fields) |f| { + if (f.type == Pane.Cwd) switch (@field(pane, f.name)) { + .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)), + else => {}, + }; + } + } + /// Put a fully constructed pane in a free slot and give it the monotonic /// identity every slot-reuse guard relies on. Pane kinds construct their /// own payloads; this registration rule remains a core invariant. @@ -5181,6 +5432,38 @@ pub const Pardes = struct { return pane; } + /// An empty output buffer opened FROM `from_id`: no file behind it, its cwd + /// a live link to the opener so Save can prefill that directory. New and + /// Newcol place it (below, or in a column). Installed in slot `free`. + fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane { + const src = p.panes[from_id] orelse return error.MissingPane; + const content = try p.gpa.dupe(u8, ""); + errdefer p.gpa.free(content); + const np = try output_pane.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); + np.cwd = .{ .inherited = src }; + np.cur_pinned = true; + return np; + } + + /// New: a scratch below the calling pane, in its column. + pub fn newScratchBelow(p: *Pardes, from_id: usize) void { + const free = p.freeSlot() orelse return; + const sf = p.layoutFindTerm(from_id) orelse return; + const np = p.newScratch(from_id, free) catch return; + p.layoutInsert(sf.col, sf.idx + 1, free); + p.splitBelow(from_id, np); + p.active = free; + } + + /// Newcol: a scratch in a fresh column beside the calling pane. + pub fn newScratchColumn(p: *Pardes, from_id: usize) void { + const free = p.freeSlot() orelse return; + if (!p.layoutCanSplitColumn(from_id)) return; + _ = p.newScratch(from_id, free) catch return; + std.debug.assert(p.layoutSplitColumn(from_id, free, false)); + p.active = free; + } + pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null) break i; @@ -5244,11 +5527,14 @@ pub const Pardes = struct { /// changes it observes, e.g. via /proc/<pid>/cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; - const n = @min(cwd.len, pane.cwd.len); - if (pane.cwd_len == n and std.mem.eql(u8, pane.cwd[0..n], cwd[0..n])) return; + const n = @min(cwd.len, pane.cwd_buf.len); + const cur = switch (pane.cwd) { + .owned => |dir| dir, + else => "", + }; + if (cur.len == n and std.mem.eql(u8, cur, cwd[0..n])) return; if (p.lookHoverPane() == id) p.cancelLookHover(); - @memcpy(pane.cwd[0..n], cwd[0..n]); - pane.cwd_len = @intCast(n); + pane.setOwnedCwd(cwd[0..n]); } /// Can a command line be typed into this pane RIGHT NOW: a terminal whose @@ -5273,44 +5559,7 @@ pub const Pardes = struct { fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; - const q = p.tty_query orelse return true; - return !q.taken(q.ctx, id); - } - - /// Complete a `new_file` effect without doing IO in the core. The shell - /// has already created `path` as an empty file; we model those known empty - /// bytes directly, insert immediately below the still-live calling pane in - /// ITS column (never whichever column became active), focus it, and start - /// the same watch every ordinary file pane has. False leaves ownership - /// with the shell, which closes and unlinks the unadopted file. - pub fn openNewFile(p: *Pardes, source: u8, serial: u32, path: []const u8) bool { - if (path.len == 0 or source >= MAX_PANES) return false; - const src = p.panes[source] orelse return false; - if (src.serial != serial) return false; - const sf = p.layoutFindTerm(source) orelse return false; - const free = p.freeSlot() orelse return false; - - const path_copy = p.gpa.dupe(u8, path) catch return false; - const content = p.gpa.dupe(u8, "") catch { - p.gpa.free(path_copy); - return false; - }; - const pane = p.newDocPane(free) catch { - p.gpa.free(content); - p.gpa.free(path_copy); - return false; - }; - pane.file = .{ .path = path_copy, .content = content }; - pane.cur_pinned = true; - - // sf was captured from SOURCE, not active. Nothing above this point - // mutates layout, so it is still the exact calling column/index. - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(source, pane); - p.active = free; - p.emit(.{ .watch = .{ .pane = @intCast(free), .on = true } }); - p.sync(); - return true; + return !p.hostTtyTaken(id); } /// What a shell should exec for the next terminal — a bare name to be @@ -5505,8 +5754,8 @@ pub const Pardes = struct { } /// Post the transient message on `id`'s last row. Called by a SHELL once - /// the IO it narrates has actually happened, exactly as openNewFile above - /// completes a `new_file` effect: the core neither writes files nor owns a + /// the IO it narrates has actually happened, exactly as the shell completes + /// a `save_file` effect: the core neither writes files nor owns a /// clock, so both the outcome and the wall time in `text` come from there /// (message.zig spells it, once, for both native shells). /// @@ -5586,6 +5835,149 @@ pub const Pardes = struct { return e; } + /// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an + /// event that carries a borrowed slice cannot survive the trip, so this + /// asserts rather than documents it. Hand those to `update` directly inside + /// the host's borrow window instead — which is also what keeps the pty read + /// path copy-free. + pub fn postEvent(p: *Pardes, ev: Event) void { + switch (ev) { + .key => |k| std.debug.assert(k.text.len == 0), + .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command => unreachable, + else => {}, + } + if (p.in_len == p.in_q.len) return; + p.in_q[(p.in_head + p.in_len) % p.in_q.len] = ev; + p.in_len += 1; + } + + fn nextQueued(p: *Pardes) ?Event { + if (p.in_len == 0) { + p.in_head = 0; + return null; + } + const ev = p.in_q[p.in_head]; + p.in_head = (p.in_head + 1) % p.in_q.len; + p.in_len -= 1; + return ev; + } + + /// Has a program taken this pane's tty? A host that cannot tell says no, + /// which is how pardes behaved before the probe existed. + fn hostTtyTaken(p: *const Pardes, id: usize) bool { + const f = p.host.vtable.pull_tty_taken orelse return false; + return f(p.host.ctx, @intCast(id)); + } + + fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { + if (p.host.vtable.push_write_file) |f| return f(p.host.ctx, pane, path, bytes); + p.fallback.writeFile(path, bytes); + } + + /// The path a watch is about: a real file's, or a PDF's. + fn watchPath(p: *const Pardes, id: u8) ?[]const u8 { + const pane = p.panes[id] orelse return null; + if (pane.file) |f| return if (f.output == null) f.path else null; + return pane.pdfPath(); + } + + /// Perform one effect through the host, falling back per METHOD (not per + /// host) to the in-process implementation. This is the switch that used to + /// be copied into all four shells. + pub fn perform(p: *Pardes, e: Effect) void { + const v = p.host.vtable; + switch (e) { + .spawn => |s| if (v.push_spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + p.fallback.spawned[s.pane] = true; + }, + // A pane with no child is silent: nothing invents output on its + // screen, and the bytes are dropped rather than transcribed. + .write => |w| if (v.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), + .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), + .open_link => |u| if (v.push_open_link) |f| + f(p.host.ctx, u.slice()) + else + p.fallback.setLink(u.slice()), + .save_file => |sf| { + const pane = p.panes[sf.pane] orelse return; + const f = pane.file orelse return; + p.hostWriteFile(sf.pane, f.path, f.content); + }, + .save_text => |st| { + const s = p.save_text orelse return; + p.hostWriteFile(st.pane, s.path, s.content); + p.clearSaveText(); + }, + .write_dump => { + const out = p.dump_out orelse return; + if (v.push_write_dump) |f| { + f(p.host.ctx, out); + } else { + // A real host reports where it landed, which is what puts + // `Restore <path>` in the topbar; the virtual one owes the + // same, or the bytes it holds are unreachable. + p.fallback.writeFile(fallback_dump_path, out); + p.setLastDump(fallback_dump_path); + } + }, + .set_clipboard => { + const text = p.yank orelse ""; + if (v.push_set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); + }, + // No desktop to ask: answer from the in-process clipboard at once, + // which is the same shape as a host answering later. + .read_clipboard => if (v.pull_read_clipboard) |f| + f(p.host.ctx) + else + p.update(.{ .paste = p.fallback.clipboard.items }), + .lsp => |q| if (v.pull_lsp) |f| + f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) + else + p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), + .pipe => |q| if (v.pull_pipe) |f| + f(p.host.ctx, q.id) + else + p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), + .watch => |w| if (v.push_watch_file) |f| + f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on) + else { + p.fallback.watched[w.pane] = w.on; + }, + .theme_file => |t| if (v.push_watch_theme) |f| f(p.host.ctx, t.generation, t.on), + .dump_themes => |d| if (v.push_dump_themes) |f| f(p.host.ctx, d.pane), + // the loop's own condition; a host tears down after its own loop + .quit => p.quit = true, + } + } + + /// ONE ITERATION OF THE LOOP, and the reason the core owns it: the ORDER + /// here — wait, apply input, perform effects, poll, render, present — was + /// copied into four shells and drifted in each. A host supplies the parts + /// only it can (blocking, pixels, processes) and nothing else. + /// + /// It is one PUMP and never a `while`: no host gives up its outer loop. + /// AppKit owns NSApplication's run loop, the browser owns the frame + /// callback, and both Linux hosts keep a thin one so Restore can swap the + /// whole core between frames. + pub fn pump(p: *Pardes, h: Host) !void { + p.host = h; + const v = h.vtable; + if (v.pull_wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); + while (p.nextQueued()) |ev| p.update(ev); + while (p.nextEffect()) |e| p.perform(e); + // A quitting frame has already freed what it would draw. + if (p.quit) return; + if (v.push_poll_frame) |f| f(h.ctx); + _ = p.frame_arena.reset(.retain_capacity); + const surface = try p.render(p.frame_arena.allocator()); + if (v.push_present) |f| f(h.ctx, surface); + if (v.push_post_present) |f| f(h.ctx); + // Animation TIME is not spent here. `wait_input` was told how long it + // may sleep; a display clock wakes faster than that on input, so only + // the host knows when a real frame interval has passed. Each spends it + // by handing back one `.tick`. + } + pub fn update(p: *Pardes, ev: Event) void { // Free a motion surface that went bad during the LAST update, before // anything in this one can ask for it. Nothing frees it mid-update: @@ -5785,6 +6177,26 @@ pub const Pardes = struct { p.emit(.read_clipboard); } + /// Type text at a pane's program, the way a terminal emulator pastes: + /// bracketed when the app set mode 2004 (readline/vim/helix strip the + /// markers and refuse to run what arrives), else with `\n` turned to `\r`, + /// because a raw newline in an unbracketed paste IS the Enter key and a + /// multi-line paste would run every line but the last. + pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { + if (text.len == 0) return; + if (pane.vt.modes.get(.bracketed_paste)) { + p.emitWrite(id, "\x1b[200~"); + p.emitWrite(id, text); + p.emitWrite(id, "\x1b[201~"); + return; + } + const cp = p.scratch.allocator().dupe(u8, text) catch return; + for (cp) |*ch| { + if (ch.* == '\n') ch.* = '\r'; + } + p.emitWrite(id, cp); + } + /// The shell answered with system-clipboard text — or the desktop pasted /// into us unasked. Either way the bytes are pasted WITHOUT going through /// the register: helix's clipboard commands and the default register are @@ -5798,6 +6210,11 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; + // A pane in tty mode has no editable buffer to paste INTO — the pty + // owns its screen. Type the bytes at the program instead, which is + // also what makes a desktop paste (Ctrl-Shift-V, middle click, the + // window manager's own) reach a shell at all. + if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => p.pasteText(pane, bytes, false), .before => p.pasteText(pane, bytes, true), @@ -6645,6 +7062,15 @@ pub const Pardes = struct { } else term_pane.enterTty(p, p.active); return; } + // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. + if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active)) + return p.runBuiltin(.Last, p.active, "", null); + if (pane.isTerminal() and pane.mode == .tty) { + // Clipboard first: `hit` ignores a shift no binding asked for, so + // the plain Ctrl-V below would otherwise swallow Ctrl-Shift-V. + if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after); + if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return); + } // `|` owns the same visible one-line tag input as search, but Enter // snapshots an asynchronous shell filter. Escape is a pure cancel: // restore the old tail and never emit a request. @@ -6657,6 +7083,19 @@ pub const Pardes = struct { pane.pending = 0; return; } + // a save input in flight (scratch or terminal): Enter writes the path, + // Esc abandons; both drop the prompt text and return to the body. + if (pane.hasSavePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { + const prompt_at = pane.promptAt().?; + if (hit(key, config.search_submit)) + p.submitSave(p.active) + else + pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); + exitTagEdit(pane); + pane.mode = .normal; + pane.pending = 0; + return; + } // a search input in flight (`/` or Find): Enter searches, Esc abandons; // both restore the tag tail and hand focus back to the body. if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { @@ -8366,6 +8805,12 @@ pub const Pardes = struct { const SearchStart = enum { top, cursor }; fn paneDir(pane: *const Pane) []const u8 { + // an inherited cwd is a live link that outranks a scratch buffer's own + // synthetic path: follow it to the pane it was opened from. + switch (pane.cwd) { + .inherited => |src| return paneDir(src), + else => {}, + } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fs.path.dirname(pv.path) orelse "/"; @@ -8390,6 +8835,89 @@ pub const Pardes = struct { pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); } + /// Save on a scratch buffer or a terminal arms a PATH input in the tag, + /// prefilled with the pane's directory (an inherited scratch follows the + /// pane it was opened from). submitSave writes it. + pub fn startSavePrompt(p: *Pardes, pane: *Pane) void { + p.seedTail(pane); + if (!pane.tag_init) return; + const prompt_at: u16 = @intCast(pane.tag_tail_len); + if (!pane.appendTag(config.save_marker)) return; + const dir = paneDir(pane); + _ = pane.appendTag(dir); + if (dir.len == 0 or dir[dir.len - 1] != '/') _ = pane.appendTag("/"); + pane.prompt = .{ .save = prompt_at }; + pane.tag_edit = true; + pane.tag_sel = false; + pane.mode = .insert; + pane.pending = 0; + pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); + } + + /// Enter on a save input: the path is everything past the marker. + fn submitSave(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const at = switch (pane.prompt) { + .save => |a| a, + else => return, + }; + const armed = pane.tag_tail[at..pane.tag_tail_len]; + pane.tag_tail_len = at; // drop the prompt back to the seeded tail + if (!std.mem.startsWith(u8, armed, config.save_marker)) return; + const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); + if (path.len == 0) return; + p.performSave(id, path); + } + + /// Commit a prompted path. A scratch buffer's own bytes go there and it + /// BECOMES an ordinary file pane; a terminal's plaintext scrollback goes + /// there and it stays a terminal. + fn performSave(p: *Pardes, id: usize, path: []const u8) void { + const pane = p.panes[id] orelse return; + if (pane.file) |*f| { + const owned = p.gpa.dupe(u8, path) catch return; + p.gpa.free(f.path); + f.path = owned; + f.output = null; // an ordinary file pane from here on + pane.cwd = .none; // its directory is now its own path's dirname + f.saved_revision = f.revision; + pane.tag_init = false; // re-derive the tag as a plain file + pane.tag_tail_len = 0; + p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); + return; + } + p.saveScrollback(id, path); + } + + /// A terminal's plaintext scrollback to `path`, leaving the pane a terminal. + /// The core cannot do IO, so it stashes the bytes and asks the host to + /// write them (save_text) the same way save_file hands off a file pane. + fn saveScrollback(p: *Pardes, id: usize, path: []const u8) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal()) return; + const content = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch return; + const owned = p.gpa.dupe(u8, path) catch { + p.gpa.free(content); + return; + }; + if (p.save_text) |old| { + p.gpa.free(old.content); + p.gpa.free(old.path); + } + p.save_text = .{ .content = content, .path = owned }; + p.emit(.{ .save_text = .{ .pane = @intCast(id) } }); + } + + /// The host has consumed the pending scrollback save: free and clear it. + pub fn clearSaveText(p: *Pardes) void { + if (p.save_text) |s| { + p.gpa.free(s.content); + p.gpa.free(s.path); + } + p.save_text = null; + } + /// Enter on an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the /// search — " Find /" walks the filesystem for NAMES, " Grep /" for @@ -11466,17 +11994,7 @@ pub const Pardes = struct { p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb }); } } - if (m.get(.bracketed_paste)) { - p.emitWrite(s.id, "\x1b[200~"); - p.emitWrite(s.id, y); - p.emitWrite(s.id, "\x1b[201~"); - } else { - const cp = p.scratch.allocator().dupe(u8, y) catch return; - for (cp) |*c| { - if (c.* == '\n') c.* = '\r'; - } - p.emitWrite(s.id, cp); - } + p.typeToTty(s.id, pane, y); return; } if (!s.chorded) { @@ -11568,6 +12086,40 @@ pub const Pardes = struct { } } + /// Newtty: a shell in the caller's directory, raw from the first frame, + /// stacked below the caller like Alt-n's new shell. + pub fn spawnTty(p: *Pardes, from: usize) void { + const src = p.panes[from] orelse return; + const free = p.freeSlot() orelse return; + const nt = p.newShell(free, paneDir(src)) catch return; + nt.greet = true; + nt.mode = .tty; + const parent = p.splitParent(from); + const f = p.layoutFindTerm(parent).?; + p.layoutInsert(f.col, f.idx + 1, free); + p.splitBelow(parent, nt); + p.active = free; + } + + /// Joincol: fold the active pane's column into the one on its right, + /// carrying its panes and width across. Inert without a right neighbour. + pub fn joinCol(p: *Pardes) void { + const f = p.layoutFindTerm(p.active) orelse return; + const c = f.col; + if (c + 1 >= p.ncol) return; + const dst = c + 1; + p.col_weight[dst] +|= p.col_weight[c]; + for (0..p.col_n[c]) |k| p.col_terms[dst][p.col_n[dst] + k] = p.col_terms[c][k]; + p.col_n[dst] += p.col_n[c]; + var j = c; + while (j + 1 < p.ncol) : (j += 1) { + p.col_terms[j] = p.col_terms[j + 1]; + p.col_n[j] = p.col_n[j + 1]; + p.col_weight[j] = p.col_weight[j + 1]; + } + p.ncol -= 1; + } + /// Whether `source_id` can donate half its width to a new column. This is /// public so callers that must create a pane first can reject before that /// creation emits any native-side work. @@ -12274,6 +12826,15 @@ pub const Pardes = struct { /// left, shells right). Only the calling/source column donates half its /// width; every other column keeps its boundary. A full column bar, or a /// result list whose source died, stacks into the leftmost. + /// A new file opens a column only when the split leaves both sides wide + /// enough to read; otherwise it stacks as a pane. Halving is exact, so the + /// narrower side is floor(width/2) >= min_cells iff width >= 2*min_cells. + fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { + const f = p.layoutFindTerm(source_id) orelse return false; + p.computeGeom(); + return p.col_w[f.col] >= min_cells * 2; + } + pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; @@ -12335,7 +12896,7 @@ pub const Pardes = struct { p.active = free; return; } - if (doc and p.ncol < MAX_COLS) { + if (doc and p.ncol < MAX_COLS and p.columnFitsHalves(from_id, 100)) { if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt); p.active = free; return; @@ -12437,6 +12998,8 @@ pub const Pardes = struct { .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), + .frame_arena = .init(opts.frame_allocator orelse gpa), + .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); const st = try dump.readZon(gpa, zon_bytes, "load"); @@ -12586,12 +13149,12 @@ pub const Pardes = struct { // content, and a default that came back padded is still a default. const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { - .generic => &.{ pane_tail, legacy_pane_tail }, - .savable_file => &.{ file_pane_tail, legacy_file_pane_tail }, + .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, + .savable_file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, // Terminal dumps written before Filter used the generic current - // tail, and the earliest version used just Del. Both upgrade; - // other pane classes still treat this new default as user text. - .terminal => &.{ terminal_pane_tail, pane_tail, legacy_pane_tail }, + // tail, and the earliest version used just Del. All upgrade; other + // pane classes still treat these as user text. + .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; @@ -12740,6 +13303,7 @@ pub const Pardes = struct { /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { + p.reapPanes(); p.computeGeom(); p.syncPanelAnimations(); p.trackJump(); @@ -13836,18 +14400,22 @@ pub const Pardes = struct { _ = s.print(tx, body_y + i, tw, line, body_style); } - // the file-only passes and the terminal-only one: each is the - // whole reason its pane kind renders differently at all, so each lives - // with its kind. Order is load-bearing — gutter, then syntax over the - // content, then the emulator's own colors — and the selection/cursor - // passes below still win over all three. - if (pane.file) |*f| { - file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); - if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); - file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + // Coloring is one algorithm per pane, chosen by title (colorAlgo): the + // terminal projects its own ANSI, a file lays tree-sitter or diff + // shading over its content. source and diff share this pass because + // both feed f.highlights, which refreshHighlights filled with whichever + // this same choice named. Order is load-bearing — gutter, recolor, then + // wrap markers; the selection/cursor passes below win over all three. + switch (pane.colorAlgo()) { + .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h), + .source, .diff => { + const f = &pane.file.?; + file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); + file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + }, + .none => {}, } - const off = pane.scroll(); - if (p.settings.colors and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off); // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; @@ -14363,6 +14931,81 @@ test "only the SPC clipboard commands cross to the system clipboard" { try std.testing.expect(p.clip_pending == null); } +/// Every `.write` a keystroke produced, joined — what the pane's program would +/// actually have read off its pty. +fn drainWrites(p: *Pardes, buf: []u8) []const u8 { + var n: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + const b = w.bytes.slice(); + if (n + b.len > buf.len) break; + @memcpy(buf[n..][0..b.len], b); + n += b.len; + }, + else => {}, + }; + return buf[0..n]; +} + +test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + var buf: [256]u8 = undefined; + _ = drainWrites(p, &buf); + + const pane = p.panes[0].?; + term_pane.enterTty(p, 0); + try std.testing.expectEqual(Mode.tty, pane.mode); + p.setYank("one\ntwo"); + + // Ctrl-V types the DEFAULT REGISTER at the program. Unbracketed, so the + // newline becomes Enter's \r — a raw \n would run `one` and leave `two` + // half-typed. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf)); + // and it asked the desktop for nothing on the way + try std.testing.expect(p.clip_pending == null); + + // Under mode 2004 the same keystroke brackets instead, which is what stops + // readline from RUNNING a multi-line paste. + pane.vt.modes.set(.bracketed_paste, true); + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf)); + + // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the + // answer lands at the program rather than in an edit buffer. + pane.vt.modes.set(.bracketed_paste, false); + p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); + try std.testing.expect(p.clip_pending != null); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + p.update(.{ .paste = "from-desktop" }); + try std.testing.expectEqualStrings("from-desktop", drainWrites(p, &buf)); + // the clipboard did not clobber the register on its way through + try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); +} + +test "an unasked desktop paste reaches a tty pane's program, not its buffer" { + if (platform == .web) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + var buf: [256]u8 = undefined; + _ = drainWrites(p, &buf); + + const pane = p.panes[0].?; + term_pane.enterTty(p, 0); + // No request behind it: the window manager's own paste, or SDL answering a + // Ctrl-Shift-V the desktop handled. It still has to reach the shell. + p.update(.{ .paste = "ls -la" }); + try std.testing.expectEqualStrings("ls -la", drainWrites(p, &buf)); + // and nothing was typed into an overlay the pty does not know about + try std.testing.expect(pane.ovl == null); +} + test "leaving tty hides the prompt and keeps the command typed at it" { if (platform == .web) return; const gpa = std.testing.allocator; |
