lattice_host/messages.rs
1//! `*messages*` buffer management on `Editor`.
2//!
3//! Phase 5.7.B.9: migrates [`MESSAGES_BUFFER_NAME`],
4//! `format_message_record`, `one_line`, and
5//! `ensure_messages_buffer` from `impl App` (TUI,
6//! `lattice-ui-tui::app::messages`) to `impl Editor` (host) so
7//! the GPUI peer can eagerly seed the messages transcript
8//! buffer at boot through the same code path the TUI peer uses.
9//!
10//! The TUI peer keeps `do_open_messages` and
11//! `drain_message_events` (renderer-coupled — they pull the
12//! buffer into focus / drive per-tick draining); those are
13//! out of scope for this host migration.
14
15use crate::editor::Editor;
16use crate::synthetic_buffers::SYNTHETIC_BUFFER_FLAGS;
17
18/// Synthetic name for the `*messages*` transcript buffer.
19/// Matches emacs' `*Messages*` analogue; surfaced via
20/// `:messages` (ex-command) and `:b *messages*`.
21pub const MESSAGES_BUFFER_NAME: &str = "*messages*";
22
23/// Render one record as `HH:MM:SS.mmm <level> <text>`. The
24/// level prefix lets the reader scan for warns / errors at a
25/// glance; the timestamp anchors the entry to wall-clock so
26/// users can correlate with logs / external tools.
27///
28/// Phase 5.7.B.9: migrated from
29/// `lattice-ui-tui::app::messages::format_message_record`.
30/// `pub` so subsystem crates (drain loops, test fixtures) can
31/// reach the canonical format.
32pub fn format_message_record(r: &lattice_runtime::MessageRecord) -> String {
33 let elapsed = r
34 .timestamp
35 .duration_since(std::time::SystemTime::UNIX_EPOCH)
36 .ok();
37 let secs = elapsed.map(|d| d.as_secs()).unwrap_or(0);
38 let ms = elapsed.map(|d| d.subsec_millis()).unwrap_or(0);
39 let hh = (secs / 3600) % 24;
40 let mm = (secs / 60) % 60;
41 let ss = secs % 60;
42 let level = match r.level {
43 lattice_grammar::EchoLevel::Trace => "TRACE",
44 lattice_grammar::EchoLevel::Debug => "DEBUG",
45 lattice_grammar::EchoLevel::Info => " INFO",
46 lattice_grammar::EchoLevel::Warn => " WARN",
47 lattice_grammar::EchoLevel::Error => "ERROR",
48 };
49 let text = one_line(&r.text);
50 format!("{hh:02}:{mm:02}:{ss:02}.{ms:03} {level} {text}")
51}
52
53/// Collapse internal newlines so multi-line echo bodies (rare
54/// but possible -- e.g. a multi-line server error) still
55/// render as a single transcript row. Mirrors the formatting
56/// the LSP log buffer uses.
57fn one_line(s: &str) -> String {
58 let mut out = String::with_capacity(s.len());
59 let mut last_was_space = false;
60 for c in s.chars() {
61 if c == '\n' || c == '\r' || c == '\t' {
62 if !last_was_space {
63 out.push(' ');
64 last_was_space = true;
65 }
66 } else {
67 out.push(c);
68 last_was_space = c == ' ';
69 }
70 }
71 out
72}
73
74impl Editor {
75 /// Eagerly create the editor's standard subsystem buffers
76 /// (`*lsp*` + `*messages*`) so name-based lookups like
77 /// `:b *lsp*` and `:b *messages*` resolve from t=0 instead
78 /// of waiting for lazy creation on first use.
79 ///
80 /// Phase 5.7.B.9: aggregator added so both renderer peers
81 /// run identical eager-seeding without each having to name
82 /// the subsystem-specific constants / mode-ids. The TUI
83 /// peer historically inlined the two `ensure_*` calls in
84 /// `App::new`; both peers now share this entry.
85 pub fn ensure_subsystem_buffers(&mut self) {
86 self.ensure_named_synthetic_document(
87 lattice_lsp::LSP_SUBSYSTEM_LOG_NAME,
88 lattice_lsp::modes::LspLogMode::mode_id(),
89 SYNTHETIC_BUFFER_FLAGS,
90 );
91 self.ensure_messages_buffer();
92 }
93
94 /// Drain queued `MessagePushed` events; append each formatted
95 /// record to the `*messages*` buffer in one
96 /// `apply_edit_batch` so the actor sees a single edit per
97 /// drain. Phase 5.8.AA.f: hoisted from
98 /// `lattice-ui-tui::app::messages::App::drain_message_events`.
99 pub fn drain_message_events(&mut self) {
100 let Some(mut rx) = self.pending_message_event_rx.take() else {
101 return;
102 };
103 let mut text = String::new();
104 while let Ok(ev) = rx.try_recv() {
105 text.push_str(&format_message_record(&ev.record));
106 text.push('\n');
107 }
108 self.pending_message_event_rx = Some(rx);
109 if text.is_empty() {
110 return;
111 }
112 let id = self.ensure_messages_buffer();
113 let first_new_line = self.messages_line_count(id);
114 self.append_to_owned_buffer(id, &text);
115 self.publish_messages_highlights(id, first_new_line, &text);
116 }
117
118 /// Lines already in the `*messages*` buffer — the index the next appended
119 /// record lands on.
120 fn messages_line_count(&self, id: crate::buffers::BufferId) -> u32 {
121 self.buffers
122 .document_handle(id)
123 .map(|h| h.snapshot().buffer.content_line_count())
124 .unwrap_or(0)
125 }
126
127 /// Publish `messages-mode`'s spans for the lines just appended.
128 ///
129 /// **The mode owns the syntax; this only delivers it.** The TUI used to
130 /// compose `*messages*` bodies itself behind `if is_messages_buffer`, and
131 /// the GPUI peer had no equivalent — so the log was coloured in one
132 /// renderer and plain in the other. Going through
133 /// `PendingSyntheticHighlights` means neither renderer knows what a log
134 /// line is, which is what makes them agree.
135 ///
136 /// Incremental (`insert_at_and_wake`) rather than re-spanning the whole
137 /// buffer: this runs on every drain, and the ring is thousands of lines
138 /// long in a busy session.
139 fn publish_messages_highlights(
140 &self,
141 id: crate::buffers::BufferId,
142 start_line: u32,
143 appended: &str,
144 ) {
145 let Some(pending) = self
146 .services
147 .get::<lattice_mode::PendingSyntheticHighlights>()
148 else {
149 return;
150 };
151 let spans = lattice_mode::modes::messages::buffer_spans(appended);
152 if spans.is_empty() {
153 return;
154 }
155 pending.insert_at_and_wake(id, start_line, spans);
156 }
157
158 /// Find-or-create the `*messages*` Document buffer.
159 /// Idempotent; first creation seeds the buffer with the
160 /// in-memory ring contents (so `:messages` after some
161 /// records have already accumulated shows the backlog).
162 /// Activates `messages-mode` (which contributes
163 /// `ReadOnly = true` so user keystrokes can't mutate; the
164 /// streaming append path goes through
165 /// [`Self::append_to_owned_buffer`]).
166 ///
167 /// Phase 5.7.B.9: migrated from
168 /// `lattice-ui-tui::app::messages::App::ensure_messages_buffer`.
169 /// Both renderer peers now reach the canonical body; the
170 /// TUI peer's `App::ensure_messages_buffer` becomes a thin
171 /// wrapper.
172 pub fn ensure_messages_buffer(&mut self) -> crate::buffers::BufferId {
173 let already_present = self.buffers.by_name(MESSAGES_BUFFER_NAME).is_some();
174 // msg-mode.1: the buffer's major mode IS `messages-mode`
175 // (symmetric with `lsp-log-mode` for `*lsp*`). The mode
176 // contributes `ReadOnly = true` + `NoFile = true` so the
177 // dispatcher gates writes and `:q` skips the dirty
178 // guard. Stored as `BufferData::Messages` (kind tag
179 // `BufferKind::Messages`) so introspection paths (`:ls`,
180 // modeline) don't conflate the transcript with
181 // user-edited documents.
182 let id = self.ensure_named_messages_document(
183 MESSAGES_BUFFER_NAME,
184 lattice_mode::MessagesMode::mode_id(),
185 SYNTHETIC_BUFFER_FLAGS,
186 );
187 if already_present {
188 return id;
189 }
190 // First-time creation: seed the buffer with any ring
191 // backlog records that arrived before the buffer
192 // existed. Backlog seeding matters when the buffer is
193 // created lazily (e.g. tests); the production boot
194 // creates it eagerly so the ring is usually empty at
195 // creation time.
196 // msg-mode.1: ring is Arc<Mutex<>> so the
197 // boot-installed MessagesLayer can push from any
198 // thread. Lock briefly to snapshot the backlog into a
199 // local Vec, then release before formatting +
200 // appending (Drop semantics: keep the critical section
201 // short).
202 let backlog_records: Vec<lattice_runtime::MessageRecord> = match self.messages.lock() {
203 Ok(ring) => ring.records().iter().cloned().collect(),
204 Err(_) => Vec::new(),
205 };
206 let backlog: String = backlog_records
207 .iter()
208 .map(format_message_record)
209 .map(|line| {
210 let mut s = line;
211 s.push('\n');
212 s
213 })
214 .collect();
215 if !backlog.is_empty() {
216 let first_new_line = self.messages_line_count(id);
217 self.append_to_owned_buffer(id, &backlog);
218 self.publish_messages_highlights(id, first_new_line, &backlog);
219 }
220 // The ring backlog we just seeded and the event channel are fed by the
221 // SAME `MessagesLayer::emit` from boot: every record now in the buffer
222 // is ALSO still queued in `pending_message_event_rx`, waiting for the
223 // next `drain_message_events` — which would append the entire boot log a
224 // second time (the duplicated transcript). Discard whatever is queued
225 // now; it is exactly the backlog we just rendered. Records emitted after
226 // this point still flow through the channel and are appended once by the
227 // live drain. (Snapshot-then-discard order means a record emitted in the
228 // gap is deferred to a later re-seed rather than duplicated.)
229 if let Some(mut rx) = self.pending_message_event_rx.take() {
230 while rx.try_recv().is_ok() {}
231 self.pending_message_event_rx = Some(rx);
232 }
233 id
234 }
235
236 /// `:messages` -- activate the `*messages*` Document buffer.
237 /// Drains queued events first so the view is up to date.
238 /// Returns post-activation `RendererSignal`s the renderer
239 /// must fan through `handle_renderer_signal` (mode-mirror,
240 /// theme caches). Phase 5.8.AF.3.
241 pub fn do_open_messages(&mut self) -> Vec<crate::dispatch::RendererSignal> {
242 self.drain_message_events();
243 let id = self.ensure_messages_buffer();
244 if self.activate_buffer(id) {
245 self.activate_buffer_state()
246 } else {
247 Vec::new()
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn one_line_collapses_internal_newlines() {
258 assert_eq!(one_line("hello\nworld"), "hello world");
259 assert_eq!(one_line("a\n\nb"), "a b");
260 assert_eq!(one_line("tab\there"), "tab here");
261 assert_eq!(one_line("crlf\r\nline"), "crlf line");
262 }
263
264 #[test]
265 fn one_line_preserves_spaces() {
266 assert_eq!(one_line("hello world"), "hello world");
267 assert_eq!(one_line("a b"), "a b");
268 }
269
270 #[test]
271 fn one_line_no_special_chars_passes_through() {
272 assert_eq!(one_line("simple"), "simple");
273 assert_eq!(one_line(""), "");
274 }
275
276 #[test]
277 fn format_message_record_level_prefixes_align() {
278 // Width-padded level prefixes so the transcript columns
279 // line up regardless of severity. Visible to the user --
280 // worth guarding against regressions.
281 use std::time::SystemTime;
282 let rec = |level: lattice_grammar::EchoLevel| lattice_runtime::MessageRecord {
283 level,
284 text: "x".into(),
285 timestamp: SystemTime::UNIX_EPOCH,
286 };
287 let line_for = |level: lattice_grammar::EchoLevel| format_message_record(&rec(level));
288 // All five rendered lines share the same length.
289 let lengths: Vec<usize> = [
290 lattice_grammar::EchoLevel::Trace,
291 lattice_grammar::EchoLevel::Debug,
292 lattice_grammar::EchoLevel::Info,
293 lattice_grammar::EchoLevel::Warn,
294 lattice_grammar::EchoLevel::Error,
295 ]
296 .iter()
297 .map(|&l| line_for(l).len())
298 .collect();
299 assert!(
300 lengths.windows(2).all(|w| w[0] == w[1]),
301 "level prefixes should be width-padded so all rows align; got lengths {lengths:?}"
302 );
303 }
304}