diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 11 | ||||
| -rw-r--r-- | src/config.zig | 5 | ||||
| -rw-r--r-- | src/gui/gui.zig | 2 | ||||
| -rw-r--r-- | src/limits.zig | 10 | ||||
| -rw-r--r-- | src/macos.zig | 2 | ||||
| -rw-r--r-- | src/message.zig | 19 | ||||
| -rw-r--r-- | src/output_pane.zig | 17 | ||||
| -rw-r--r-- | src/pardes.zig | 136 | ||||
| -rw-r--r-- | src/tty/tty.zig | 2 |
9 files changed, 201 insertions, 3 deletions
diff --git a/src/builtins.zig b/src/builtins.zig index 40a5083c..f7822caf 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -738,6 +738,17 @@ pub const Config = struct { } }; +/// Read back what the message rows said. A message row is cleared by the next +/// keystroke, so anything reported while you were looking at another pane was +/// gone before you could read it — a failed save, a watcher's reload, a +/// builtin's complaint. +pub const Messages = struct { + pub const output: OutputTraits = .{ .name = config.messages_buffer }; + pub fn run(c: Ctx) void { + output_pane.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); + } +}; + /// This build's version and what changed to reach it, printed into an output /// buffer the same way Config prints the live settings. pub const Changelog = struct { diff --git a/src/config.zig b/src/config.zig index 522ae76f..331cb4ab 100644 --- a/src/config.zig +++ b/src/config.zig @@ -147,6 +147,10 @@ pub const leader_path = paths: { .Config = "fc", .Tutor = "ht", .Changelog = "hc", + // `Messages` joins the help group because it answers the same kind of + // question they do — "what did that say?" — about lines that have + // already left the screen. + .Messages = "hm", .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", @@ -901,6 +905,7 @@ pub const pdf_sections_buffer = "+PdfSections"; pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; +pub const messages_buffer = "+Messages"; /// What `9p <dial> <path>` opens a remote file into. NOT the remote path: an /// output buffer's name comes off the command that filled it, and the path is /// the command's ARGUMENT, which is what makes two remote files two panes. diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b31fb5f6..3876a222 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3614,7 +3614,7 @@ const Shell = struct { // session news, same row and same stamp a completed save uses. .lsp_status => |text| { var mbuf: [256]u8 = undefined; - s.core.setMessage(s.core.active, message.stamp(&mbuf, "lsp", text)); + s.core.setStatus(s.core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_allocator.free(text); s.saw_event = true; }, diff --git a/src/limits.zig b/src/limits.zig index 75bf5262..ee94a4f6 100644 --- a/src/limits.zig +++ b/src/limits.zig @@ -116,6 +116,16 @@ pub const cwd_buf_cap = if (board) 0 else 1024; /// deepest undo steps and nothing else — no truncation, no dropped edit. pub const undo_max = if (board) 16 else 256; +/// How many message-row lines the session keeps for `Messages`, and one of the +/// bigger fixed costs on `Pardes`: an entry is 262 bytes, so 128 of them is +/// 32.75 KiB that is allocated whether or not anybody ever reads it. That is +/// 8.5% of the board's whole 384 KiB heap and about the size of its effect +/// ring, so the board takes sixteen — enough that a failure you looked away +/// from is still there, which is the whole point, and not enough to matter +/// beside the panes. This belongs here rather than in config.zig for exactly +/// the reason the file's header gives: it is a board-shaped capacity. +pub const message_log = if (board) 16 else 128; + /// Bounds the only user-editable, schema-owned tag fragment. It IS the storage /// bound: `Pane.tag_tail` is `[max_tag_tail]u8`, and every writer (appendTag, /// tagInsert, restoreDumpTail, the acmefs `tag` file) refuses input that does diff --git a/src/macos.zig b/src/macos.zig index c50d64cf..ddab78e9 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1404,7 +1404,7 @@ fn drainInbox(st: *State) bool { // about whichever pane happened to ask. .lsp_status => |text| { var mbuf: [256]u8 = undefined; - st.core.setMessage(st.core.active, message.stamp(&mbuf, "lsp", text)); + st.core.setStatus(st.core.active, message.stamp(&mbuf, "lsp", text)); }, // The filter's answer, then join the worker that produced it. // diff --git a/src/message.zig b/src/message.zig index 0935ce96..0855c448 100644 --- a/src/message.zig +++ b/src/message.zig @@ -43,6 +43,25 @@ extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; /// clock cannot live in a golden — but a message that vanished under the /// harness could not be pinned at all, so the row still renders, still says /// what happened, and still occupies the same columns. +/// The stamped text WITHOUT its clock — what two of these rows have in common +/// when they say the same thing at different times. +/// +/// The message log de-duplicates on this rather than on the whole row, because +/// the clock makes every host message unique by construction: `saved x` at +/// 14:32:07 and at 14:32:09 are different strings, so a watched file rebuilt +/// in a loop filled the ring with identical-looking rows, each counted once. +/// That is exactly the case the de-duplication exists for. +pub fn body(text: []const u8) []const u8 { + // `HH:MM:SS ` — ten bytes, digits or the `--:--:--` the harness blanks + // them to. Anything else is a message that was never stamped. + if (text.len < 10) return text; + if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; + for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { + if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; + } + return text[10..]; +} + pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { var clock: [8]u8 = "--:--:--".*; const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; diff --git a/src/output_pane.zig b/src/output_pane.zig index 288c3e43..f4721136 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -519,6 +519,23 @@ pub fn openConfig(p: *Pardes, id: usize) !void { return openRead(p, id, .{ .cmd = .Config }, "", 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.messageLog(i)) |m| : (i += 1) { + if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); + 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 { diff --git a/src/pardes.zig b/src/pardes.zig index b87a5d6a..357a6b13 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -40,6 +40,7 @@ const runtime_cfg = @import("runtime_config.zig"); /// Every board-shaped capacity, in one table keyed on a profile rather than on /// the platform. See src/limits.zig. const limits = @import("limits.zig"); +const message = @import("message.zig"); const selection_pipe = @import("selection_pipe.zig"); /// acme's control filesystem, as a pure transaction over this core: the FILES /// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The @@ -700,6 +701,50 @@ test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" try std.testing.expectEqual(@as(i32, 1), pane.cur_row); } +test "the message log keeps what the row forgets, and collapses repeats" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + + p.setMessage(0, "14:32:07 saved /x.zig"); + p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock + p.setMessage(0, "save: AccessDenied"); + + // Two entries, not three: the clock does not make a message new. This is + // the case the de-duplication exists for and the one it used to miss, + // because `message.stamp` makes every host message unique by construction. + try std.testing.expectEqual(@as(usize, 2), p.messages_len); + const first = p.messageLog(0).?; + try std.testing.expectEqual(@as(u16, 2), first.repeats); + // ...and the NEWEST wording is what survives, so the row carries the last + // time it happened rather than the first. + try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice()); + try std.testing.expectEqualStrings("save: AccessDenied", p.messageLog(1).?.slice()); + try std.testing.expect(p.messageLog(2) == null); + + // The same text from a DIFFERENT pane is a different event: one pane's + // failure must not be recorded as another's. + p.setMessage(1, "save: AccessDenied"); + try std.testing.expectEqual(@as(usize, 3), p.messages_len); + try std.testing.expectEqual(@as(u8, 1), p.messageLog(2).?.pane); + + // Progress is NOT logged: it arrives several times a second for a whole + // index and would push everything else out (`setStatus`). + p.setStatus(0, "14:32:10 lsp rust-analyzer: Indexing 47%"); + try std.testing.expectEqual(@as(usize, 3), p.messages_len); + + // The ring wraps rather than grows, and still reads oldest-first. + for (0..limits.message_log + 5) |i| { + var buf: [32]u8 = undefined; + p.setMessage(0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable); + } + try std.testing.expectEqual(@as(usize, limits.message_log), p.messages_len); + try std.testing.expectEqualStrings("line 5", p.messageLog(0).?.slice()); + var last_buf: [32]u8 = undefined; + const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable; + try std.testing.expectEqualStrings(want_last, p.messageLog(limits.message_log - 1).?.slice()); +} + test "the acme chords act once per selection, not once on the primary" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -6132,6 +6177,24 @@ fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { }; } +/// One line that was said on a message row. Fixed storage so the log cannot +/// fail: `setMessage` is reached from `reportError`, which is reached from +/// paths that are reporting an allocation failure. +pub const LoggedMessage = struct { + pub const cap = 256; + text: [cap]u8 = undefined, + len: u16 = 0, + /// Which pane said it, or 0xff for one said to no pane in particular. + pane: u8 = 0xff, + /// How many times in a row. Saturating: a key held down against the same + /// refusal is one event, not four hundred. + repeats: u16 = 0, + + pub fn slice(m: *const LoggedMessage) []const u8 { + return m.text[0..m.len]; + } +}; + const PendingPipe = struct { id: u32, pane: usize, @@ -6476,6 +6539,12 @@ pub const Pardes = struct { /// The in-program answers behind every unimplemented host method. Per /// instance, so several cores behind one fan-out host stay independent. fallback: Fallback, + /// The message-row log: a fixed ring, never allocated, never grown. See + /// `logMessage` and the `Messages` builtin. + messages: [limits.message_log]LoggedMessage = @splat(.{}), + /// Next slot to write. `messages_len` saturates at the ring's size. + messages_head: usize = 0, + messages_len: usize = 0, /// 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. @@ -7214,12 +7283,79 @@ pub const Pardes = struct { /// mouse event — and nothing here knows whether the row is free: an armed /// prompt simply outranks a message at render time, so a message posted /// under one is stored and invisible rather than refused. + /// A message row that is NOT worth remembering: unsolicited progress from a + /// language server, which arrives several times a second for the whole of a + /// large index. + /// + /// `rust-analyzer: Indexing 47% core` is a different string every tick by + /// construction, so no de-duplication can collapse it, and at the client's + /// throttle of one per 150ms per server it takes about NINETEEN SECONDS to + /// push every save, error and reload out of a 128-entry ring. A log that + /// one indexing run empties is not a log. Progress belongs on the row, + /// where it is read as it happens and then replaced; the log is for things + /// that were said once. + pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { + p.showMessage(id, text); + } + pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { + // LOGGED FIRST, and logged even when the pane is gone. A message row + // is cleared by the next keystroke (see `clearMessages`), so anything + // said while the user was looking elsewhere — a save that failed, a + // watcher's reload, a builtin's complaint — used to be unrecoverable + // the instant it appeared. `Messages` reads this back. + p.logMessage(id, text); + p.showMessage(id, text); + } + + fn showMessage(p: *Pardes, id: usize, text: []const u8) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; pane.msg_len = @intCast(@min(text.len, pane.msg.len)); @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } + + /// Append to the ring, oldest overwritten. No allocation and no failure: + /// this sits under every `reportError` in the program, including the ones + /// raised because an allocation just failed. + fn logMessage(p: *Pardes, id: usize, text: []const u8) void { + if (text.len == 0) return; + const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff; + // What is STORED is truncated to the slot, so what is COMPARED must be + // too: comparing a 300-byte message against its own 256-byte record + // never matched, and two identical long messages each got their own + // row. And the comparison is on `message.body` — the row without its + // clock — because a stamp makes every host message unique by + // construction, which defeated this entirely for `saved`/`reloaded`. + const kept = text[0..@min(text.len, LoggedMessage.cap)]; + if (p.messages_len > 0) { + const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log]; + if (last.pane == pane and + std.mem.eql(u8, message.body(last.slice()), message.body(kept))) + { + // The NEWEST wording wins, so the row carries the latest clock + // rather than the moment the run started. + last.len = @intCast(kept.len); + @memcpy(last.text[0..last.len], kept); + last.repeats +|= 1; + return; + } + } + const slot = &p.messages[p.messages_head]; + slot.len = @intCast(kept.len); + @memcpy(slot.text[0..slot.len], kept); + slot.pane = pane; + slot.repeats = 1; + p.messages_head = (p.messages_head + 1) % limits.message_log; + p.messages_len = @min(p.messages_len + 1, limits.message_log); + } + + /// The log oldest-first, which is reading order. + pub fn messageLog(p: *const Pardes, i: usize) ?*const LoggedMessage { + if (i >= p.messages_len) return null; + const first = (p.messages_head + limits.message_log - p.messages_len) % limits.message_log; + return &p.messages[(first + i) % limits.message_log]; + } pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index ff86ef88..fb0a5b8e 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1151,7 +1151,7 @@ const Shell = struct { // server is session news, not a fact about the pane that asked. .lsp_status => |text| { var mbuf: [256]u8 = undefined; - core.setMessage(core.active, message.stamp(&mbuf, "lsp", text)); + core.setStatus(core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_gpa.free(text); }, .pipe_done => |response_value| { |
