summaryrefslogtreecommitdiff
path: root/src/output_pane.zig
blob: 593af3d3abdb5662c8d27a05da0b9475c449faf3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! Output panes: acme's +Errors, a file pane with no file behind it, holding
//! text the core produced itself (+Search results, +Help, the LSP answer
//! buffers). It IS a file pane — every mode, motion, chord and look works for
//! free — and that reuse is the point.
//!
//! What it is NOT any more is a file pane with a bool on it. An output buffer
//! remembers THE COMMAND THAT OPENED IT (`Origin`), and every special case it
//! gets is one `traits` lookup on that field. Before this, a dozen places
//! re-derived what a pane was from the outside, each asking a different wrong
//! question: `endsWith(path, "+Search")` (the NAME decided what a pane WAS,
//! which is backwards — a name is a consequence), a `search_kind` field on the
//! pane that ran the search rather than on the buffer that answered it, and
//! `f.output` booleans sprinkled through kind-agnostic layout code. Now the
//! buffer knows, and the table below is the whole answer: one screen you read
//! top to bottom to see every way an output pane differs from a file, and one
//! row to add to introduce another kind.
const std = @import("std");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const File = pardes.File;
const file_pane = @import("file_pane.zig");
const modal = @import("modal.zig");
const builtins = @import("builtins.zig");
const Builtin = builtins.registry.Builtin();
const config = @import("config.zig");
const lsp = @import("lsp/lsp.zig");
/// the installed fonts, for openFonts. GUI only, behind the same comptime
/// branch builtins.zig imports it through — see the note there.
const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {};

/// What opened this buffer — THE field, and the only input to `traits`.
///
/// Two vocabularies because the core has exactly two: a `Builtin` is a word
/// you can execute anywhere, and that covers Find, Grep, Help and every
/// language query that has a name (Hover, Diagnostics...). The rest are KEYS —
/// helix binds the five gotos and `=` as motions and `/` as a search input,
/// and a motion has no word to click. Recording the key's `lsp.Kind` (or
/// `.search` for the bare `/`) is not a parallel tag enum: both are the values
/// the caller already holds when it opens the buffer.
pub const Origin = union(enum) {
    cmd: Builtin,
    query: lsp.Kind,
    /// the bare `/` — the pane's own text, searched in core
    search,
};

/// The cap on a recorded argument, matching the one lspRequest already puts on
/// an effect payload. ponytail: a longer pattern is TRUNCATED here, because
/// `arg` is a record of what was asked and never the text anything re-runs; if
/// something ever re-runs it, this becomes gpa-owned like `content`.
pub const max_arg = 128;

/// An output buffer's own state, hung off `File.output`.
pub const Output = struct {
    from: Origin,
    /// the command's ARGUMENT: the pattern a Grep matched, the new name a
    /// Rename took, the SPC prefix a Help lists. Inline rather than allocated
    /// — it is a fixed-size record, and an owned slice would buy a free in
    /// deinitPane and an errdefer at every open site for nothing.
    arg_buf: [max_arg]u8 = undefined,
    arg_len: u8 = 0,

    pub fn arg(o: *const Output) []const u8 {
        return o.arg_buf[0..o.arg_len];
    }
};

pub fn setArg(o: *Output, text: []const u8) void {
    o.arg_len = @intCast(@min(text.len, max_arg));
    @memcpy(o.arg_buf[0..o.arg_len], text[0..o.arg_len]);
}

/// Every way an output pane differs from a file pane. One struct so the
/// QUESTIONS are visible even where today every buffer answers alike: a column
/// that never varies still says "this is decided here", which is what makes
/// the next kind of buffer a row rather than a hunt.
pub const Traits = struct {
    /// the buffer's name (`+Search`...). DERIVED from the command, never the
    /// thing that identifies it — that inversion is what this file undoes.
    name: []const u8,
    /// n/N walk the rows: each is a `path:LINE:COL text` location the ordinary
    /// look path resolves, so the buffer IS helix's picker. Prose (a hover
    /// blurb, a formatting diff) has nowhere to step to.
    steps: bool = false,
    /// ...and what a step DOES with the row it lands on. Off, the row is a
    /// LOCATION and its leading word is LOOKED. On, the row is a COMMAND LINE
    /// and the whole of it is EXECUTED — so walking the list runs each row in
    /// turn, which is what makes a picker over things that take effect
    /// immediately (ThemeSel) a plain list of the words you would have typed.
    /// Both go through the ordinary builtin (config.look_cmd / exec_cmd), so a
    /// row does exactly what the matching mouse button on it would.
    ///
    /// Nothing about this is output-pane specific, which is why it is a column
    /// here and not a branch in searchStep: `file_row` answers it too, so a
    /// file pane whose lines happen to be commands is one word away from
    /// behaving the same.
    executes: bool = false,
    /// an answer of exactly ONE row jumps straight there instead of opening
    /// this buffer at all — helix: the gotos jump on a single location and
    /// show a picker on several, a symbol list is always a picker.
    jumps: bool = false,
    /// a DOCUMENT for layout purposes: claims a column of its own, is a split
    /// parent, pays for a split. A result list is not — it belongs to the pane
    /// that asked for it, lands directly below it and takes its rows from
    /// there, so opening or closing one never resizes a bystander.
    doc: bool = false,
    /// there is a file behind it to write. Also what its tag says: no Save to
    /// offer means the plain pane tail rather than the file one.
    saves: bool = false,
};

/// A REAL file pane, as a row of the same table — so kind-agnostic code asks
/// one question and gets one answer whichever it is holding. `name` is unused:
/// a file already has a path.
const file_row: Traits = .{ .name = "", .doc = true, .saves = true };

/// THE TABLE. Everything above, answered from the command that opened the
/// buffer. Exhaustive on purpose: a new `lsp.Kind` or a new output-opening
/// builtin should not compile until someone has said what its buffer does.
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 },
        .cmd => |b| blk: {
            const meta = builtins.registry.outputTraits(b) orelse
                break :blk .{ .name = config.search_buffer, .steps = true };
            break :blk .{
                .name = meta.name,
                .steps = meta.steps,
                .jumps = meta.jumps,
                .executes = meta.executes,
            };
        },
        .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 responses are edits consumed before an output can open;
            // the exhaustive table still records the otherwise-unused trait.
            .rename => .{ .name = config.lsp_buffer },
            .definition, .declaration, .type_definition, .implementation, .references => .{
                .name = config.search_buffer,
                .steps = true,
                .jumps = true,
            },
            // completion lists WHAT COULD GO HERE, one row per candidate's
            // definition. It does not jump on a single row where the gotos do:
            // a goto answers a question whose answer is a place, so landing
            // there IS the answer, whereas the question here is "what can I
            // write", and being teleported into the one candidate's
            // declaration instead of being shown it is not that.
            .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{
                .name = config.search_buffer,
                .steps = true,
            },
        },
    };
}

/// The same table asked of a file pane's `output` field, null (a real file)
/// included. This is what the kind-agnostic code in pardes.zig calls.
pub fn fileTraits(out: ?Output) Traits {
    return traits((out orelse return file_row).from);
}

/// How the dump spells an origin. A WORD, never an integer, for the reason the
/// dump already stores tag words: reordering builtins.zig stays free. Nothing
/// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is
/// neither.
pub fn word(o: Origin) []const u8 {
    return switch (o) {
        .cmd => |b| @tagName(b),
        .query => |k| @tagName(k),
        .search => "/",
    };
}

/// the inverse; null for "" (a real file) and for a word this build no longer
/// has, which is a dump from another version and not a reason to fail a load
pub fn fromWord(w: []const u8) ?Origin {
    if (w.len == 0) return null;
    if (std.mem.eql(u8, w, "/")) return .search;
    if (std.meta.stringToEnum(Builtin, w)) |b| return .{ .cmd = b };
    if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k };
    return null;
}

/// Is the results buffer `pane`'s n/N is armed on the one `from` filled?
/// `]d`/`[d` are the only keys that care WHICH search is showing — they step
/// the diagnostics list when it is up and ask for one when it is not — and
/// this is how they ask now that the buffer remembers: `search_pane` is a
/// SLOT, so this doubles as the check that the slot is still ours.
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);
}

/// Open one. `content` is gpa-owned and adopted; the NAME comes from the table
/// (the caller says what ran, not what to call it) and carries `dir` so looks
/// inside the buffer resolve like anywhere else.
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);
    const pane = try p.newDocPane(id);
    var out: Output = .{ .from = from };
    setArg(&out, arg);
    pane.file = .{ .path = path, .content = content, .output = out };
    pane.kind = .file;
    pane.cur_pinned = true;
    return pane;
}

/// Land a freshly produced list of rows in the buffer it belongs in — the one
/// rule every results buffer follows, whichever side of the core made them.
///
/// The SAME command asked again REFILLS the list it already opened rather than
/// stacking a byte-identical twin under the pane. That was runSearch's rule
/// from the start (right-clicking a word in four places is one +Search walked
/// four times) and language answers turned out to need it far more urgently:
/// Tab after a dot makes a query an ordinary typing keystroke, and without the
/// refill twenty of them fill every slot and the key is eaten for the rest of
/// the session — see docs/lsp.md.
///
/// What "the same command" means comes off the ORIGIN. A search is identified
/// by its PATTERN, so `foo`, `bar`, `foo` re-arms foo's own buffer and leaves
/// bar's open; a language query is asked about a different symbol every time
/// with the same (usually empty) arg, so the arg cannot tell two apart and the
/// KIND is the natural unit — a second `gr` replaces the first list. Same
/// directory only, because the rows are written relative to it, and never the
/// asking pane itself (a `/` inside a +Search writes its own rows).
///
/// `content` is gpa-owned: adopted by the buffer, or freed here when there is
/// nowhere to put it. `anchor` is the row n/N step from, null for the top.
pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) !void {
    errdefer p.gpa.free(content);
    const pane = p.panes[id] orelse return error.MissingPane;
    const by_arg = std.meta.activeTag(from) != .query;
    for (p.panes, 0..) |slot, i| {
        if (i == id) continue;
        const rp = slot orelse continue;
        const rf = if (rp.file) |*f| f else continue;
        const o = if (rf.output) |*x| x else continue;
        if (!std.meta.eql(o.from, from)) continue;
        if (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;
        setArg(o, arg);
        // a refill that changes NOTHING keeps its place: a right click on an
        // already-armed word is an `n`, and throwing the list back to the top
        // only to scroll down to the stepped row is a jump with no information
        // in it.
        const same = std.mem.eql(u8, rf.content, content);
        file_pane.setContent(p, rf, content);
        if (!same) rf.scroll = 0;
        p.active = id;
        if (traits(from).steps) {
            pane.search_pane = i;
            pane.search_row = anchor;
        }
        return;
    }
    const free = p.freeSlot() orelse return error.NoPaneSlots;
    const np = try open(p, free, dir, from, arg, content);
    p.placeDoc(id, free, np);
    p.active = id;
    // prose is not a list of locations: n/N over a hover blurb would step to
    // nowhere, so only stepping buffers arm the stepper — and WHICH command
    // filled it is the buffer's own record, not a field on the asking pane.
    if (traits(from).steps) {
        pane.search_pane = free;
        pane.search_row = anchor;
    }
}

fn putPrint(dst: []u8, at: *usize, comptime fmt: []const u8, args: anytype) void {
    const text = std.fmt.bufPrint(dst[at.*..], fmt, args) catch unreachable;
    at.* += text.len;
}

/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text,
/// one row per location, oldest first — the same `location text` shape every
/// results buffer here has, which is what buys n/N stepping and Look-on-a-row
/// for nothing: the leading word is an ordinary look target, and the ordinary
/// look path is what goes there.
///
/// A RENDERING, never a second list. The rows are spelled from the stack at
/// the moment you ask and go stale the moment you jump, exactly like a search
/// result — which is also why this opens a fresh buffer per press instead of
/// refreshing one the way Help does: Help is a document, this is a snapshot.
///
/// How a location is spelled is the rule runSearch already follows: a REAL
/// file names itself (its path is absolute, so the row resolves from any
/// pane's directory), and everything else — a terminal, an output buffer, an
/// image — has no file to point at and gets `@pN`. The trailing text is the
/// content line for anything holding text, else the pane's directory: enough
/// to recognise the place without opening it.
pub fn openJumps(p: *Pardes, id: usize) !void {
    const arena = p.scratch.allocator();
    var len: usize = 0;
    for (p.jumps[0..p.njumps]) |j| {
        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}", .{j.pane}) 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;
        len += if (j.line == 0)
            std.fmt.count("{s} {s}\n", .{ loc, what[0..cut] })
        else
            std.fmt.count("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
    }

    const out = try arena.alloc(u8, len);
    var at: usize = 0;
    for (p.jumps[0..p.njumps]) |j| {
        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}", .{j.pane}) 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)
            putPrint(out, &at, "{s} {s}\n", .{ loc, what[0..cut] })
        else
            putPrint(out, &at, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
    }
    std.debug.assert(at == out.len);
    try openStepped(p, id, .{ .cmd = .Jumplist }, out);
}

/// The ThemeSel builtin: the theme ring written out as one `Theme <name>` row
/// per theme — the ordinary builtin with its argument, exactly the line you
/// would type — into a buffer whose `executes` trait makes n/N RUN each row
/// rather than look it. So walking the list is trying the themes on, and
/// stopping on one is choosing it; there is no picker mode, no preview state
/// and nothing to commit or cancel, because every step already did the thing.
///
/// The command's own name comes from the builtin rather than a literal: the
/// word is derived from the struct in exactly one place (builtins.word), and a
/// rename there must not leave rows here that name something that is gone.
pub fn openThemes(p: *Pardes, id: usize) !void {
    const arena = p.scratch.allocator();
    var len: usize = 0;
    for (pardes.themes) |t|
        len += std.fmt.count(comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name});
    const out = try arena.alloc(u8, len);
    var at: usize = 0;
    for (pardes.themes) |t|
        putPrint(out, &at, comptime builtins.word(builtins.Theme) ++ " {s}\n", .{t.name});
    std.debug.assert(at == out.len);
    try openStepped(p, id, .{ .cmd = .ThemeSel }, out);
}

/// The FontSel builtin: openThemes over the fonts installed on the machine
/// instead of the themes compiled into the binary — one `Font <name>` row
/// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and
/// stopping picks one. Everything that makes that work is already above; this
/// is the same eight lines pointed at a different list.
///
/// Only where the shell draws its own text, and the body says so rather than
/// the signature: fonts.list and the FontSel origin both exist only there, and
/// a comptime-false `if` is what keeps the tty build from analysing either.
/// The dead parameters on that build are the honest shape of "this platform
/// cannot open one".
pub fn openFonts(p: *Pardes, id: usize) !void {
    if (pardes.font_picker) {
        const arena = p.scratch.allocator();
        const font_list = fonts.list(arena, null);
        var len: usize = 0;
        for (font_list) |f|
            len += std.fmt.count(comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name});
        const out = try arena.alloc(u8, len);
        var at: usize = 0;
        for (font_list) |f|
            putPrint(out, &at, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name});
        std.debug.assert(at == out.len);
        try openStepped(p, id, .{ .cmd = .FontSel }, out);
    }
}

/// Open a buffer n/N will walk, and arm them on it: the shared tail of every
/// builtin that answers with a list. `text` is borrowed (the callers build it
/// in the scratch arena) and copied into a gpa buffer the pane adopts.
///
/// Focus stays with the pane that ASKED, exactly as it does after a search:
/// n/N are read there, and they step the buffer they just armed.
fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void {
    const pane = p.panes[id] orelse return error.MissingPane;
    const content = try p.gpa.dupe(u8, text);
    errdefer 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 error.NoPaneSlots;
    const np = try open(p, free, dir, from, "", content);
    p.placeDoc(id, free, np);
    p.active = id;
    pane.search_pane = free;
    pane.search_row = null;
}

/// The Help builtin: THE INDEX of builtins — every one of them, and every way
/// to run it — filtered to what `prefix` can still reach, written into an
/// output buffer (acme's +Errors). Ordinary text, so the names in it are LIVE:
/// middle-click `Tutor` there and the tutor opens. Reuses the open +Help
/// buffer instead of piling panes up, and focus follows: you asked to read it.
///
/// ONE builtin and not two. The complete index and the mid-chord "what can
/// `SPC h` still reach" are the same array (pardes.builtin_rows) read with a
/// different prefix — the empty one matches every row, including the builtins
/// SPC cannot reach at all, so the reference page IS the filter's degenerate
/// case. A second builtin would have been a second renderer over a superset of
/// these rows, and the two would have drifted the first time a column moved.
pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
    const pane = p.panes[id] orelse return error.MissingPane;
    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";
    var len: usize = if (prefix.len == 0)
        full_header.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 p.gpa.alloc(u8, len);
    errdefer p.gpa.free(content);
    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;
    }
    std.debug.assert(at == content.len);
    // the buffer says what made it, so finding the open one is asking that and
    // not matching its name
    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, Origin{ .cmd = .Help })) continue;
        file_pane.setContent(p, hf, content);
        setArg(ho, prefix);
        hf.scroll = 0;
        hp.cur_row = 0;
        hp.msel.active = false;
        p.active = i;
        return;
    }
    const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
    const free = p.freeSlot() orelse return error.NoPaneSlots;
    const np = try open(p, free, dir, .{ .cmd = .Help }, prefix, content);
    p.placeDoc(id, free, np);
    p.active = free;
}