Skip to main content

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}