//! Output buffers (+Errors, search and jump results, help, messages, the //! changelog): their rows, traits and look targets. const panes = @import("panes.zig"); const std = @import("std"); const pardes = @import("pardes.zig"); const exec = @import("exec.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; const modal = @import("modal.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const builtins = @import("builtins.zig"); const effect_sources = @import("effect_sources.zig"); const build_options = @import("pardes_config"); const lsp = @import("lsp/lsp.zig"); const look = @import("look.zig"); const locations = @import("locations.zig"); const Pane = panes.Pane; const File = panes.File; const Builtin = builtins.registry.Builtin(); const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; pub const Origin = union(enum) { cmd: Builtin, query: lsp.Kind, search, errors, pdf_links, /// The panes a refusal (Exit, Restore, Del, Delcol, get) is over, kept /// in +Unsaved rather than a notice that goes. unsaved, }; pub const max_arg = dump.max_origin_arg; pub const State = struct { from: Origin, arg_buf: [max_arg]u8 = undefined, arg_len: u16 = 0, pub fn arg(o: *const State) []const u8 { return o.arg_buf[0..o.arg_len]; } }; const RowDecoration = struct { location_end: usize = 0, prefix_end: usize = 0, declaration: bool = false, mark_start: usize = 0, mark_end: usize = 0, severity: enum { none, err, warning, info, hint } = .none, /// Nothing in the row is decorated: most rows of most files. pub fn plain(row: RowDecoration) bool { return !row.declaration and row.location_end == 0 and row.prefix_end == 0 and row.mark_end <= row.mark_start; } /// `chrome` is the theme's own (Pardes.bodyChrome): body paper changes /// immediately, so its ink must change with it even while the /// surrounding tags are still fading. Once a frame, never a grapheme: /// working it out takes the contrast searches' pow calls. pub fn styleAt(row: RowDecoration, chrome: *const pardes.ChromeTheme, byte: usize) ?pardes.CellStyle { if (row.plain()) return null; if (row.declaration or byte < @max(row.location_end, row.prefix_end)) return .{ .fg = .{ .rgb = chrome.lineno } }; if (byte < row.mark_start or byte >= row.mark_end) return null; if (row.severity == .none) return .{ .fg = .{ .rgb = chrome.search_fg }, .bg = .{ .rgb = chrome.search_bg }, }; return .{ .fg = .{ .rgb = switch (row.severity) { .err => chrome.diagnostic_error, .warning => chrome.diagnostic_warning, .info => chrome.diagnostic_info, .hint => chrome.diagnostic_hint, .none => unreachable, } }, .bold = true }; } }; fn rowDecoration(output: ?State, line: []const u8) RowDecoration { return decorateRow(output, line, null); } pub fn decorateRow(output: ?State, line: []const u8, metadata: ?locations.Row) RowDecoration { const origin = (output orelse return .{}).from; if (!traits(origin).locations) return .{}; const target = locations.parse(line); const spot = if (metadata) |m| m.at else target.at; if (spot.line == 0) return .{}; var row: RowDecoration = .{ .location_end = if (metadata) |m| m.location_end else target.end, .prefix_end = if (metadata) |m| m.code_start else target.end, .declaration = if (metadata) |m| m.declaration else false, }; const source_start = if (metadata) |m| m.code_start else target.code_start; if (source_start >= line.len) return row; if (metadata) |m| if (m.kind == .context) return row; const mark_occurrence = switch (origin) { .search => true, .cmd => |cmd| cmd == .Grep, .query => |kind| switch (kind) { .definition, .declaration, .type_definition, .implementation, .references => true, else => false, }, else => false, }; if (mark_occurrence and spot.col > 0 and spot.end_line == spot.line and spot.end_col >= spot.col) { row.mark_start = source_start +| (spot.col - 1); row.mark_end = @min(line.len, source_start +| spot.end_col); } const is_diagnostic = switch (origin) { .query => |kind| kind == .diagnostics or kind == .workspace_diagnostics, else => false, }; if (is_diagnostic) { const text = line[source_start..]; inline for (.{ .{ "error:", .err }, .{ "warning:", .warning }, .{ "info:", .info }, .{ "hint:", .hint } }) |label| { if (std.mem.startsWith(u8, text, label[0])) { row.mark_start = source_start; row.mark_end = source_start + label[0].len; row.severity = label[1]; } } } return row; } test "result decoration follows source byte ranges and diagnostic provenance" { const row = rowDecoration(.{ .from = .search }, "src/a.rs:3:5-7 let abc = 1;"); try std.testing.expectEqualStrings("abc", "src/a.rs:3:5-7 let abc = 1;"[row.mark_start..row.mark_end]); const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose"); try std.testing.expectEqual(@as(usize, 0), prose.mark_end); const diagnostic = rowDecoration(.{ .from = .{ .query = .diagnostics } }, "src/a.rs:3:5-7 warning: unused"); try std.testing.expectEqual(.warning, diagnostic.severity); try std.testing.expectEqualStrings("warning:", "src/a.rs:3:5-7 warning: unused"[diagnostic.mark_start..diagnostic.mark_end]); const malformed = rowDecoration(.{ .from = .search }, "src/a.rs:3:99999999999999999999-99999999999999999999 text"); try std.testing.expect(malformed.mark_end <= "src/a.rs:3:99999999999999999999-99999999999999999999 text".len); const snippet = "src/a.rs:3:14-16 \t\tfoo + 界 + foo"; for ([_]lsp.Kind{ .definition, .declaration, .type_definition, .implementation, .references }) |kind| { const occurrence = rowDecoration(.{ .from = .{ .query = kind } }, snippet); try std.testing.expectEqualStrings("foo", snippet[occurrence.mark_start..occurrence.mark_end]); } for ([_]lsp.Kind{ .document_symbols, .workspace_symbols, .completion, .incoming_calls, .outgoing_calls, .supertypes, .subtypes, .rename }) |kind| { const label = rowDecoration(.{ .from = .{ .query = kind } }, snippet); try std.testing.expectEqual(@as(usize, 0), label.mark_end); } } test "stacked empty previews distinguish separators from trailing newlines" { var path = [_]u8{ 'x', '.', 'c' }; const rows = [_]locations.Row{ .{ .kind = .match, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 7, .code_start = 7 }, .{ .kind = .preview, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 0, .code_start = 0 }, }; try std.testing.expect(locations.sameInput("x.c:1:1 ", "x.c:1:1\n", &rows, .stacked)); try std.testing.expect(locations.sameInput("x.c:1:1 \n", "x.c:1:1\n\n", &rows, .stacked)); try std.testing.expect(!locations.sameInput("x.c:1:1 ", "x.c:1:1\n\n", &rows, .stacked)); try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked)); } test "Lspinfo and Lspwhy fill one +Lsp in a directory" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("x\n"); try fillResults(p, 0, "/work", .{ .query = .status }, "", try p.gpa.dupe(u8, "servers\n"), null); try fillResults(p, 0, "/work", .{ .query = .explain }, "", try p.gpa.dupe(u8, "why\n"), null); var lsp_panes: usize = 0; for (p.panes) |slot| { const f = (slot orelse continue).file orelse continue; if (std.mem.eql(u8, f.path, "/work/+Lsp")) { lsp_panes += 1; try std.testing.expectEqualStrings("why\n", f.content); } } try std.testing.expectEqual(@as(usize, 1), lsp_panes); } test "plain output does not opt into location decoration" { const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on"); try std.testing.expectEqualDeep(RowDecoration{}, report); const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose"); try std.testing.expectEqualDeep(RowDecoration{}, prose); const errors = rowDecoration(.{ .from = .errors }, "src/a.rs:3:5-7 error: prose"); try std.testing.expectEqualDeep(RowDecoration{}, errors); try std.testing.expect(!traits(.{ .cmd = .Find }).locations); try std.testing.expect(traits(.{ .cmd = .Grep }).locations); try std.testing.expect(traits(.{ .query = .completion }).locations); } pub fn setArg(o: *State, text: []const u8) error{ArgumentTooLong}!void { if (text.len > max_arg) return error.ArgumentTooLong; o.arg_len = @intCast(text.len); @memcpy(o.arg_buf[0..o.arg_len], text); } pub const Traits = builtins.OutputTraits; const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; pub fn traits(o: Origin) Traits { return switch (o) { // rows are `location text`, so n/N walk them .search => .{ .name = config.search_buffer, .steps = true, .locations = true }, .errors => .{ .name = config.errors_buffer, .doc = true }, .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = true }, .unsaved => .{ .name = config.unsaved_buffer, .doc = true }, .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, // prose: an action list, a diff, a report about the backend .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, .rename => .{ .name = config.search_buffer, .steps = true, .locations = true }, .definition, .declaration, .type_definition, .implementation, .references => .{ .name = config.search_buffer, .steps = true, .locations = true, .jumps = true, }, // The hierarchy kinds behave like references: a list of places, // and a lone answer (one caller, one subtype) is a jump. .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ .name = config.search_buffer, .steps = true, .locations = true, .jumps = true, }, .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ .name = config.search_buffer, .steps = true, .locations = true, }, }, }; } pub fn usesLocationsConfig(from: Origin) bool { const tr = traits(from); return tr.steps and !tr.commands and from != .pdf_links and !std.meta.eql(from, Origin{ .cmd = .Find }) and !std.meta.eql(from, Origin{ .query = .completion }); } pub fn fileTraits(out: ?State) Traits { return traits((out orelse return file_row).from); } pub const Grain = enum { word, line, whole, }; pub fn grain(out: ?State) Grain { const tr = fileTraits(out); if (tr.commands) return .whole; return if (tr.steps) .line else .word; } pub fn word(o: Origin) []const u8 { return switch (o) { .cmd => |b| @tagName(b), .query => |k| @tagName(k), .search => "/", .errors => config.errors_buffer, .pdf_links => config.pdf_links_buffer, .unsaved => config.unsaved_buffer, }; } pub fn fromWord(w: []const u8) ?Origin { if (w.len == 0) return null; if (std.mem.eql(u8, w, "/")) return .search; if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links; if (std.mem.eql(u8, w, config.unsaved_buffer)) return .unsaved; if (std.meta.stringToEnum(Builtin, w)) |b| if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; return null; } pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { const rp = p.panes[pane.search_pane orelse return false] orelse return false; const f = rp.file orelse return false; const o = f.output orelse return false; return std.meta.eql(o.from, from); } pub const Location = struct { path: []const u8, at: look.Spot, end: usize, fn order(a: Location, b: Location) std.math.Order { const path = std.mem.order(u8, a.path, b.path); if (path != .eq) return path; if (a.at.line != b.at.line) return std.math.order(a.at.line, b.at.line); return std.math.order(a.at.col, b.at.col); } }; pub fn location(line: []const u8) Location { const target = locations.parse(std.mem.trimEnd(u8, line, " \t\r\n")); return .{ .path = target.path, .at = target.at, .end = target.end }; } pub fn isResultRow(file: *const File.State, row: usize) bool { if (row < file.location_rows.len) return file.location_rows[row].kind == .match; return !locations.isContextLine(modal.lineSlice(file.content, row)); } fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?usize) !?usize { switch (from) { .search => {}, .cmd => |cmd| switch (cmd) { .Find, .Grep => {}, else => return anchor, }, .query => |kind| switch (kind) { .definition, .declaration, .type_definition, .implementation, .references, .incoming_calls, .outgoing_calls, .supertypes, .subtypes, .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .rename, => {}, else => return anchor, }, .errors, .pdf_links, .unsaved => return anchor, } if (content.len == 0) return anchor; const path_only = std.meta.eql(from, Origin{ .cmd = .Find }); const trailing_newline = content[content.len - 1] == '\n'; const body = content[0 .. content.len - @intFromBool(trailing_newline)]; var lines = std.mem.splitScalar(u8, body, '\n'); var previous: ?Location = null; var sorted = true; var count: usize = 0; while (lines.next()) |line| { const current: Location = if (path_only) .{ .path = line, .at = .{}, .end = line.len } else location(line); if (!path_only and current.at.line == 0) return anchor; if (previous) |last| if (last.order(current) == .gt) { sorted = false; }; previous = current; count += 1; } if (sorted) return anchor; const Row = struct { text: []const u8, target: Location, original: usize, fn lessThan(_: void, a: @This(), b: @This()) bool { return switch (a.target.order(b.target)) { .lt => true, .eq => a.original < b.original, .gt => false, }; } }; const rows = try arena.alloc(Row, count); const copy = try arena.dupe(u8, body); lines = std.mem.splitScalar(u8, copy, '\n'); for (rows, 0..) |*row, original| { const text = lines.next().?; const target: Location = if (path_only) .{ .path = text, .at = .{}, .end = text.len } else location(text); row.* = .{ .text = text, .target = target, .original = original, }; } std.mem.sort(Row, rows, {}, Row.lessThan); var mapped = anchor; var offset: usize = 0; for (rows, 0..) |row, i| { if (anchor == row.original) mapped = i; @memcpy(content[offset..][0..row.text.len], row.text); offset += row.text.len; if (i + 1 < rows.len or trailing_newline) { content[offset] = '\n'; offset += 1; } } std.debug.assert(offset == content.len); return mapped; } pub fn nextResult(content: []const u8, rows: []const locations.Row, path: []const u8, at: look.Spot) usize { const current: Location = .{ .path = path, .at = at, .end = 0 }; var lines = std.mem.splitScalar(u8, content, '\n'); var first: ?usize = null; var row: usize = 0; while (lines.next()) |line| : (row += 1) { if (row < rows.len) { if (rows[row].kind != .match) continue; } else if (locations.isContextLine(line)) continue; const target = location(line); if (target.at.line == 0) continue; if (first == null) first = row; if (target.order(current) == .gt) return row; } return first orelse 0; } pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), traits(from).name, }); errdefer p.gpa.free(path); var out: State = .{ .from = from }; try setArg(&out, arg); const history = try File.History.create(p.gpa); errdefer p.gpa.destroy(history); const pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content, .output = out, .history = history }; pane.body.cur_pinned = true; return pane; } /// Find and Grep share one +Search a directory, each run replacing what /// the last showed, as acme reuses a directory's +Errors; a pane's own /// search (LookWord list) keeps a pane a pattern. /// Whether `dir` has its Find/Grep +Search open. pub fn hasFileSearch(p: *Pardes, dir: []const u8) bool { for (p.panes) |slot| { const f = (slot orelse continue).file orelse continue; const o = f.output orelse continue; if (fileSearch(o.from) and std.mem.eql(u8, std.fs.path.dirname(f.path) orelse "", dir)) return true; } return false; } /// Whether `from` fills its directory's one +Search, whatever filled it /// last (Find, Grep, Symbols, Diagnostics, any language server's list), /// each run replacing the last. A plain word's search (in a file or a PDF) /// keeps a +Search a pattern: n/N walk from one such list to the next. fn fileSearch(from: Origin) bool { return from != .search and std.mem.eql(u8, traits(from).name, config.search_buffer); } /// A language server's prose (Lspinfo, Lspwhy, a code action list, a /// format diff): one `+Lsp` a directory holds for all of them. fn lspProse(from: Origin) bool { return from == .query and std.mem.eql(u8, traits(from).name, config.lsp_buffer); } pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void { var result_content = content; errdefer p.gpa.free(result_content); var result_rows: []locations.Row = &.{}; errdefer locations.freeRows(p.gpa, result_rows); const pane = p.panes[id] orelse return error.MissingPane; if (arg.len > max_arg) return error.ArgumentTooLong; var anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor); const by_arg = std.meta.activeTag(from) != .query; if (p.locations_config.context == 0 and !p.locations_config.tscontext) { for (p.panes, 0..) |slot, i| { // A directory's +Search is found by its directory, whatever // pane ran the search: that +Search itself too. if (i == id and !fileSearch(from)) continue; const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const output = if (rf.output) |*o| o else continue; if (!std.meta.eql(output.from, from)) continue; if (by_arg and !std.mem.eql(u8, output.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; if (!locations.sameInput(content, rf.content, rf.location_rows, p.locations_config.layout)) continue; try setArg(output, arg); p.gpa.free(content); p.fs.refilled = rp.serial; p.active = id; if (traits(from).steps) { pane.search_pane = i; pane.search_row = anchor; look.armLookWalk(p, i); } return; } } if (usesLocationsConfig(from)) { const formatted = try locations.format(p, dir, content, anchor, true); p.gpa.free(result_content); result_content = formatted.content; result_rows = formatted.rows; anchor = formatted.anchor; } for (p.panes, 0..) |slot, i| { const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = if (rf.output) |*x| x else continue; const shared = (fileSearch(from) and fileSearch(o.from)) or (lspProse(from) and lspProse(o.from)); if (i == id and !shared) continue; if (!shared and !std.meta.eql(o.from, from)) continue; if (!shared and by_arg and !std.mem.eql(u8, o.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; o.from = from; p.fs.refilled = rp.serial; try setArg(o, arg); if (std.mem.eql(u8, rf.content, result_content)) { p.gpa.free(result_content); } else { File.setContent(p, rf, result_content); resetBody(p, rp); } if (locations.equalRows(rf.location_rows, result_rows)) { locations.freeRows(p.gpa, result_rows); } else { locations.freeRows(p.gpa, rf.location_rows); rf.location_rows = result_rows; if (rf.highlights.len > 0) p.tree_sitter_gpa.free(rf.highlights); rf.highlights = &.{}; rf.highlight_start = 0; rf.syntax_dirty = true; } p.active = id; if (traits(from).steps) { pane.search_pane = i; pane.search_row = anchor; look.armLookWalk(p, i); } return; } const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, result_content); np.file.?.location_rows = result_rows; exec.placeNew(p, id, id, free, .doc); p.active = id; if (traits(from).steps) { pane.search_pane = free; pane.search_row = anchor; look.armLookWalk(p, free); } } pub fn openJumps(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); for (p.jumps[0..p.njumps]) |j| { // A closed file's entry: marked, and a look at it opens it again. if (j.serial == 0) { const e = p.recentByHash(j.path_hash) orelse continue; try out.writer.print("{s}:{d}:{d} (closed)\n", .{ e.path, @max(1, j.line), @max(1, j.col) }); continue; } const jp = p.panes[j.pane] orelse continue; var idbuf: [16]u8 = undefined; const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; const has_path = if (jp.file) |f| f.output == null else pdf_path != null; const loc: []const u8 = if (has_path) (if (jp.file) |f| f.path else pdf_path.?) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{jp.serial}) catch unreachable; const what: []const u8 = if (jp.file) |f| std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") else if (jp.image) |iv| iv.path else if (pdf_path) |path| path else jp.cwdSlice(); var cut = @min(what.len, 120); while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; if (j.line == 0) try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) else try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); } const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .Jumplist }, content); } /// The recent list as rows: `:: open|closed`, most recent /// first; a look at a row opens the file there, as a +Jumps row does. pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void { for (p.recent.entries.items) |e| { var is_open = false; for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output == null and std.mem.eql(u8, f.path, e.path)) { is_open = true; }; const state: []const u8 = if (is_open) "open" else "(closed)"; if (!look_rows) { // A name as /index shows it: one line, a newline in it `\n`. var name_buf: [4 * 4096]u8 = undefined; try w.print("{s} {s}\n", .{ if (is_open) "open" else "closed", pardes.ctlfs.events.shown(e.path, &name_buf) }); } else if (e.line == 0) { try w.print("{s} {s}\n", .{ e.path, state }); } else if (e.aline != 0 and (e.aline != e.line or e.acol != e.col)) { // A selection: both its ends, in order, `path:L:C-L2:C2`. const a_first = e.aline < e.line or (e.aline == e.line and e.acol < e.col); const s = if (a_first) [2]u32{ e.aline, e.acol } else [2]u32{ e.line, e.col }; const t = if (a_first) [2]u32{ e.line, e.col } else [2]u32{ e.aline, e.acol }; try w.print("{s}:{d}:{d}-{d}:{d} {s}\n", .{ e.path, s[0], s[1], t[0], t[1], state }); } else { try w.print("{s}:{d}:{d} {s}\n", .{ e.path, e.line, e.col, state }); } } } /// A refusal's list of unsaved panes, kept in the one +Unsaved: filled /// again when it is there (as +Search is), else opened below `id`. Best /// effort: with no room for it, the notice and the log still say it. pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void { for (p.panes) |slot| { const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = rf.output orelse continue; if (o.from != .unsaved) continue; File.setContent(p, rf, content); resetBody(p, rp); return; } const pane = p.panes[id] orelse return p.gpa.free(content); const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return p.gpa.free(content); _ = open(p, free, dir, .unsaved, "", content) catch return; exec.placeNew(p, id, id, free, .doc); p.active = id; } /// Pane `gone` closed: its row goes from +Unsaved, which lists only panes /// still there to lose. pub fn dropUnsaved(p: *Pardes, gone: *const Pane) void { const gf = gone.file orelse return; if (gf.output != null and !(gf.output.?.from == .cmd and gf.output.?.from.cmd == .New)) return; for (p.panes) |slot| { const rp = slot orelse continue; if (rp == gone) continue; const rf = if (rp.file) |*f| f else continue; const o = rf.output orelse continue; if (o.from != .unsaved) continue; var scratch_name: [limits.host_path_cap + 24]u8 = undefined; const as_scratch = std.fmt.bufPrint(&scratch_name, "{s} (pane {d}): Modified", .{ gf.path, gone.serial }) catch ""; var out: std.ArrayList(u8) = .empty; var lines = std.mem.splitScalar(u8, rf.content, '\n'); var dropped = false; var first = true; while (lines.next()) |line| { const is_it = std.mem.startsWith(u8, line, gf.path) and std.mem.eql(u8, line[gf.path.len..], ": Modified"); if (is_it or (as_scratch.len > 0 and std.mem.eql(u8, line, as_scratch))) { dropped = true; continue; } if (!first) out.append(p.gpa, '\n') catch return out.deinit(p.gpa); out.appendSlice(p.gpa, line) catch return out.deinit(p.gpa); first = false; } if (!dropped) return out.deinit(p.gpa); const content = out.toOwnedSlice(p.gpa) catch return out.deinit(p.gpa); File.setContent(p, rf, content); resetBody(p, rp); return; } } /// Recent: the one +Recent, filled again when it is there (as Find and /// Grep's +Search is), else opened below the asking pane. pub fn openRecent(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try recentText(p, &out.writer, true); const content = try out.toOwnedSlice(); for (p.panes, 0..) |slot, i| { const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = rf.output orelse continue; if (!std.meta.eql(o.from, Origin{ .cmd = .Recent })) continue; File.setContent(p, rf, content); resetBody(p, rp); p.fs.refilled = rp.serial; if (p.panes[id]) |pane| { pane.search_pane = i; pane.search_row = null; } look.armLookWalk(p, i); return; } try openStepped(p, id, .{ .cmd = .Recent }, content); } pub fn openThemes(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); for (pardes.themes, 0..) |t, i| { if (pardes.colors.groupAt(i)) |title| try out.writer.print("{s}# {s}\n", .{ if (i == 0) "" else "\n", title }); try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name}); } const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .Themes }, content); } pub fn isWalkRow(file: *const File.State, row: usize, line: []const u8) bool { const state = file.output orelse return true; if (usesLocationsConfig(state.from)) return isResultRow(file, row); if (!std.meta.eql(state.from, Origin{ .cmd = .Themes })) return true; // Section labels are for reading, not stops in the command walk. return std.mem.startsWith(u8, std.mem.trimStart(u8, line, " \t"), comptime config.Runtime.findAction(.theme).?.word ++ " "); } pub fn openFonts(p: *Pardes, id: usize) !void { if (builtins.capabilities.font_picker) { const arena = p.scratch.allocator(); const font_list = fonts.list(arena, null); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); for (font_list) |f| try out.writer.print(comptime config.Runtime.findAction(.font).?.word ++ " {s}\n", .{f.name}); const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .Fonts }, content); } } fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { errdefer p.gpa.free(content); const pane = p.panes[id] orelse return error.MissingPane; const own = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); // A terminal whose shell has not said where it is yet (an init file's // word at boot): the session's directory, not `/`. const dir = if (own.len == 0) p.sessionDir() else own; const free = p.freeSlot() orelse return error.NoPaneSlots; _ = try open(p, free, dir, from, "", content); exec.placeNew(p, id, id, free, .doc); p.active = id; pane.search_pane = free; pane.search_row = null; look.armLookWalk(p, free); } fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n"; const group_header = "pardes builtins under SPC"; const language_footer = "\nlanguage keys (motions, not words):\n" ++ "gd gD gy gi gr goto: definition,\n" ++ " declaration, type-def,\n" ++ " implementation, refs\n" ++ "]d [d ]D [D diagnostics: next,\n" ++ " prev, last, first\n" ++ "= format (applies, one\n" ++ " undo step)\n" ++ "Tab after a . completion, in insert\n" ++ "C-left-click definition, by mouse\n" ++ "SPC l ... hover, rename, symbols,\n" ++ " calls, types: above\n"; var len: usize = if (prefix.len == 0) full_header.len + language_footer.len else group_header.len + prefix.len * 2 + 2; for (pardes.builtin_rows) |row| { // a path-less builtin filters as the empty path: in the full listing // (which starts with nothing) and out of every group if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; len += row.line.len + 1; } const content = try gpa.alloc(u8, len); var at: usize = 0; if (prefix.len == 0) { @memcpy(content[0..full_header.len], full_header); at = full_header.len; } else { @memcpy(content[0..group_header.len], group_header); at = group_header.len; for (prefix) |c| { content[at] = ' '; content[at + 1] = c; at += 2; } content[at] = '\n'; content[at + 1] = '\n'; at += 2; } for (pardes.builtin_rows) |row| { if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; @memcpy(content[at..][0..row.line.len], row.line); at += row.line.len; content[at] = '\n'; at += 1; } if (prefix.len == 0) { @memcpy(content[at..][0..language_footer.len], language_footer); at += language_footer.len; } std.debug.assert(at == content.len); return content; } pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { const content = try helpContent(p.gpa, prefix); // content is handed off unfreed on purpose: openRead adopts it or frees // it, and nothing between the alloc above and this line can fail. return openRead(p, id, .{ .cmd = .Help }, prefix, content); } test "full Help renders every builtin row, then the language keys" { const content = try helpContent(std.testing.allocator, ""); defer std.testing.allocator.free(content); // FIRST blank line: the end of the header (the footer opens with one too) const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse return error.MissingHelpHeader) + 2 ..]; var lines = std.mem.splitScalar(u8, body, '\n'); for (pardes.builtin_rows) |row| try std.testing.expectEqualStrings(row.line, lines.next() orelse return error.MissingBuiltinHelpRow); // ...and after the last row, the language-keys section: the one part of // the keymap no builtin row can carry, closing the page. try std.testing.expectEqualStrings("", lines.next() orelse return error.MissingLanguageKeys); try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse return error.MissingLanguageKeys); try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null); // the group view stays a pure filter: no footer under a prefix const group = try helpContent(std.testing.allocator, "l"); defer std.testing.allocator.free(group); try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null); } pub fn openConfig(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var dir_buf: [1024]u8 = undefined; const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4); var located: std.Io.Writer.Allocating = .init(p.gpa); defer located.deinit(); try p.locations_config.write(&located.writer); try config.Runtime.writeReport(&out.writer, .{ .locations = located.written(), .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "", .startup_config_path = p.opts.startup_config_path, .platform = @tagName(pardes.platform), .theme_name = p.theme().name, .compiled_default_shell = config.defaultShell(), .hover_delay_frames = config.look_preview_delay_frames, .native_images = p.native_images, .capabilities = builtins.capabilities, .state = &p.settings, }); const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .DumpConfig }, "", content); } /// The message-row log, oldest first — the lines that were said in passing and /// then cleared by the next keystroke. pub fn openMessages(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var i: usize = 0; while (p.messages.at(i)) |m| : (i += 1) { if (m.serial != 0) try out.writer.print("{d}: ", .{m.serial}); try out.writer.writeAll(m.slice()); if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); try out.writer.writeByte('\n'); } if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .Messages }, "", content); } /// The version banner plus the embedded CHANGELOG, so an installed binary can /// say what it is and what changed without a repository beside it. pub fn openChangelog(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try out.writer.print("pardes {s}\n\n", .{build_options.version}); try out.writer.writeAll(@embedFile("CHANGELOG.md")); const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .Changelog }, "", content); } pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { const name = std.mem.trim(u8, argument, " \t\r\n"); const setting = config.Runtime.find(name) orelse return error.UnknownEffect; switch (setting.action) { .transition, .scene => {}, else => return error.NotAnEffect, } if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; // Without -Dembed-sources the paths would name nothing this build can open. if (!limits.embedded_sources) return error.EffectUnavailable; const paths = effect_sources.forSetting(setting) orelse return error.EffectUnavailable; var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); try out.writer.writeByte('\n'); for (paths) |path| try out.writer.print("/virtual/{s}\n", .{path}); const content = try out.toOwnedSlice(); return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content); } pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void { return openRead(p, id, .errors, "", content); } pub fn resetBody(p: *Pardes, pane: *Pane) void { pane.file.?.scroll = 0; pane.body.cur_row = 0; pane.body.cur_col = 0; pane.body.cur_pinned = true; pane.hscroll = 0; pane.wrap_n = 0; pane.body.msel = .{}; pane.body.vsel = .{}; pane.body.nsel = 0; pane.nsel_snap = 0; pane.body.select = false; pane.sel = @splat(.{}); pane.body.sticky_col = -1; pane.body.restore_cursor = false; pane.body.normal.clear(); pane.look_at = null; if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) { p.look_hover_wait = null; }; if (p.look_hover_preview) |preview| if (preview.serial == pane.serial) { p.look_hover_preview = null; }; if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none; } fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { errdefer p.gpa.free(content); if (p.panes[id] == null) return error.MissingPane; for (p.panes, 0..) |slot, i| { const hp = slot orelse continue; const hf = if (hp.file) |*f| f else continue; const ho = if (hf.output) |*o| o else continue; if (!std.meta.eql(ho.from, from)) continue; try setArg(ho, arg); File.setContent(p, hf, content); resetBody(p, hp); p.active = i; return; } // Where the word ran: the pane's directory, or from a column's or the // workspace's tag, the session's. const dir = p.execDir(id); const free = p.freeSlot() orelse return error.NoPaneSlots; _ = try open(p, free, dir, from, arg, content); exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc); p.active = free; }