summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig11
-rw-r--r--src/config.zig5
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/limits.zig10
-rw-r--r--src/macos.zig2
-rw-r--r--src/message.zig19
-rw-r--r--src/output_pane.zig17
-rw-r--r--src/pardes.zig136
-rw-r--r--src/tty/tty.zig2
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| {