Skip to main content

lattice_host/
synthetic_buffers.rs

1//! Synthetic-buffer management on `Editor`.
2//!
3//! Phase 5.7.B.9: migrates the synthetic-buffer plumbing
4//! (`SYNTHETIC_BUFFER_FLAGS`, `seed_empty_document_locals`,
5//! `activate_major_by_id`, `append_to_owned_buffer`,
6//! `ensure_named_synthetic_document`) from `impl App` (TUI,
7//! `lattice-ui-tui::app::lsp_log_buffers` +
8//! `lattice-ui-tui::app::lifecycle`) to `impl Editor` (host).
9//!
10//! The TUI peer keeps thin wrappers so existing call sites
11//! (`App::new` boot, ex-command handlers like
12//! `do_open_messages`, `:b *lsp*` ensure paths) stay compiling
13//! while both renderer peers reach the same canonical bodies.
14//!
15//! ## What synthetic buffers are
16//!
17//! "Synthetic" = subsystem-owned Document buffers that don't
18//! correspond to an on-disk file. Examples:
19//!
20//! - `*lsp*` -- the global LSP subsystem log (one per editor).
21//! - `*lsp:<server>:<workspace>*` -- per-LSP-instance log.
22//! - `*messages*` -- the editor's echo / `tracing::*` transcript.
23//! - `*scratch*` (future) -- a user-visible scratch buffer.
24//!
25//! All of them register in the same [`crate::buffer_registry::BufferRegistry`]
26//! keyed by `BufferId`, marked with [`SYNTHETIC_BUFFER_FLAGS`]
27//! (`listed: false, hidden: false` -- skip from `:bn`/`:bp`
28//! cycles, show in `:ls` with a marker, reach via `:b <name>`).
29//!
30//! Each synthetic buffer's content + lifecycle is driven by its
31//! major mode (e.g. `messages-mode`, `lsp-log-mode`); the host
32//! plumbing here is renderer-neutral.
33
34use lattice_core::Document;
35use lattice_mode::ModeId;
36use lattice_protocol::edit::Edit;
37use lattice_protocol::position::Position;
38use lattice_runtime::spawn_document;
39
40use crate::buffer_registry::{BufferData, BufferEntry, DocumentEntry};
41use crate::buffers::{BufferFlags, BufferId};
42use crate::dispatch::last_addressable_line;
43use crate::editor::Editor;
44
45/// Unlisted, non-hidden flags -- the canonical shape every
46/// mode-owned synthetic buffer wants. `:bn` / `:bp` cycles skip,
47/// `:ls` shows with a `u` marker, `:b <name>` still reaches.
48///
49/// Phase 5.7.B.9: promoted from `pub(crate) const` on the TUI
50/// `App` to a public `const` on the host substrate so both
51/// renderer peers (and any synthetic-buffer creators in
52/// subsystem crates) reach the same flag value.
53pub const SYNTHETIC_BUFFER_FLAGS: BufferFlags = BufferFlags {
54    listed: false,
55    hidden: false,
56    ephemeral: false,
57};
58
59impl Editor {
60    /// Convenience accessor for [`SYNTHETIC_BUFFER_FLAGS`] from
61    /// callers that already have `&Editor` in scope -- saves a
62    /// `use` import for the const at every call site (boot,
63    /// `ensure_messages_buffer`, the LSP log-buffer creators,
64    /// the GPUI peer's `finalize_boot`, ...).
65    pub const SYNTHETIC_BUFFER_FLAGS: BufferFlags = SYNTHETIC_BUFFER_FLAGS;
66
67    /// M.3.2.c.5: seed an empty set of document mode-locals for a
68    /// freshly-registered document buffer. Subsequent activation
69    /// transitions read through these slots; if the slot is
70    /// missing the accessor returns the type's natural default.
71    /// Idempotent (replace-on-collision).
72    ///
73    /// Phase 5.7.B.9: migrated from
74    /// `lattice-ui-tui::app::lifecycle::App::seed_empty_document_locals`.
75    /// The body touches only renderer-neutral editor state
76    /// (`buffer_locals`) + host-owned local types
77    /// (`crate::modes::Document*`).
78    pub fn seed_empty_document_locals(&mut self, buffer_id: BufferId) {
79        let locals = self.buffer_locals.entry(buffer_id).or_default();
80        locals.insert(crate::modes::DocumentSyntax(None));
81        locals.insert(crate::modes::DocumentLastParsedTextVersion(0));
82        locals.insert(crate::modes::DocumentLastSyncedSyntaxVersion(0));
83        locals.insert(crate::modes::DocumentFolds(Vec::new()));
84    }
85
86    /// Activate `major_id` on `buffer_id` directly, bypassing the
87    /// language-detection path. Used by synthetic-buffer creators
88    /// that already know which major mode they want (LSP log
89    /// buffers want `lsp-log-mode` / `lsp-trace-log-mode`;
90    /// `*messages*` wants `messages-mode`).
91    ///
92    /// Errors from `mode_registry.activate_major` surface as an
93    /// `EchoLevel::Warn` set_message; activation never panics on
94    /// per-mode hook failures.
95    ///
96    /// Phase 5.7.B.9: migrated from
97    /// `lattice-ui-tui::app::lsp_log_buffers::App::activate_major_by_id`.
98    /// All host-callable; the `set_message` + `recompute_options_for_buffer`
99    /// deps already live on `Editor`.
100    pub fn activate_major_by_id(&mut self, buffer_id: BufferId, major_id: ModeId) {
101        let proto_id = lattice_protocol::ids::BufferId::new(buffer_id.0 as u64);
102        let mut active = self.active_modes.remove(&buffer_id).unwrap_or_default();
103        if let Err(e) = self.mode_registry.load_full().activate_major(
104            &mut active,
105            &self.mode_guards,
106            &self.config,
107            &self.event_bus,
108            &self.services,
109            proto_id,
110            major_id,
111            self.capabilities_for_proto(proto_id),
112        ) {
113            // Through the shared handler, so a capability refusal here defers
114            // like every other activation site rather than being lost.
115            self.note_activation_failure(buffer_id, major_id, &e);
116        }
117        self.active_modes.insert(buffer_id, active);
118        // Recompute the resolved-options cache so the mode's
119        // contributions (e.g. `ReadOnly = true` from
120        // `lsp-log-mode`) are visible at the next `resolved_option`
121        // read. The full kind-driven `activate_major_for_buffer_kind`
122        // calls this too; we mirror the contract here.
123        self.recompute_options_for_buffer(buffer_id);
124    }
125
126    /// Append `text` to the end of the Document at `buffer_id`.
127    /// Used by subsystems that own synthetic buffers to feed
128    /// streamed records without going through the modal-dispatch
129    /// insert path (which would block on the buffer's read-only
130    /// contribution).
131    ///
132    /// Blocking: this calls into the document actor's
133    /// `apply_edit_batch` mailbox via `block_on`. Cheap when text
134    /// is small; the actor's reparse path is a no-op for buffers
135    /// whose major mode (`lsp-log-mode` / `messages-mode` / ...)
136    /// does not attach a syntax handle.
137    ///
138    /// No-op when `buffer_id` does not resolve to a Document in
139    /// the registry, or when `text` is empty.
140    ///
141    /// Phase 5.7.B.9: migrated from
142    /// `lattice-ui-tui::app::lsp_log_buffers::App::append_to_owned_buffer`.
143    /// Uses host's [`last_addressable_line`] + `Buffer::line_byte_len`
144    /// to compute the EOL insertion point.
145    pub fn append_to_owned_buffer(&mut self, buffer_id: BufferId, text: &str) {
146        if text.is_empty() {
147            return;
148        }
149        let Some(handle) = self.buffers.document_handle(buffer_id) else {
150            return;
151        };
152        let snap = handle.snapshot();
153        // Append at the TRUE end of the rope, past any trailing newline —
154        // ROPE space, phantom trailing line included, exactly as
155        // `replace_owned_buffer` does and for the same reason.
156        //
157        // `last_addressable_line` deliberately backs off a trailing-empty
158        // line (right for motions/ranges), but here it puts the insertion
159        // point BEFORE the buffer's terminating `\n`, so the first appended
160        // line fuses onto the previous last line. The synthetic-highlight
161        // pipeline then publishes one span row per appended record while the
162        // append created one fewer buffer line, so every span after the seam
163        // paints one row low and the tail span drops off the end — `*messages*`
164        // rendered its last lines unhighlighted and mis-coloured INFO as
165        // ERROR/WARN. Spanning to the rope end keeps record N on buffer line N.
166        let last_line = snap.buffer.rope_line_count().saturating_sub(1);
167        let line_len = snap.buffer.line_byte_len(last_line);
168        let pos = Position::new(last_line, line_len);
169        let edit = Edit::insert(pos, text);
170        let _ = lattice_runtime::block_on(handle.apply_edit_batch(vec![edit]));
171    }
172
173    /// Replace the ENTIRE content of an owner-written buffer with `text` (one
174    /// full-range edit). The idempotent counterpart to
175    /// [`Self::append_to_owned_buffer`]: a synthetic buffer opened by name is
176    /// reused across calls, so a re-render (e.g. `:export-plugin-api` a second
177    /// time) must overwrite rather than append. Bypasses the read-only
178    /// dispatcher, exactly like the append path (owner writes).
179    pub fn replace_owned_buffer(&mut self, buffer_id: BufferId, text: &str) {
180        let Some(handle) = self.buffers.document_handle(buffer_id) else {
181            return;
182        };
183        let snap = handle.snapshot();
184        // The TRUE end of the buffer -- `last_addressable_line` deliberately
185        // backs up past a trailing-newline line, which would leave that line
186        // uncovered here (and on a reused buffer it collapses to line 0). A
187        // full replace must span every line, phantom trailing line included.
188        // CV.3: ROPE space, and the comment above says why — a full replace
189        // must span the phantom trailing line too, or it leaves it behind.
190        let lc = snap.buffer.rope_line_count();
191        let last_line = lc.saturating_sub(1);
192        let line_len = snap.buffer.line_byte_len(last_line);
193        let whole = lattice_protocol::position::Range {
194            start: Position::new(0, 0),
195            end: Position::new(last_line, line_len),
196        };
197        let edit = Edit::replace(whole, text);
198        let _ = lattice_runtime::block_on(handle.apply_edit_batch(vec![edit]));
199    }
200
201    /// Find-or-create a Document buffer named `name`, register it
202    /// in the registry with `flags`, seed empty document locals,
203    /// and activate `major_id` directly (skipping the
204    /// `activate_major_for_buffer_kind` language-detection path
205    /// because synthetic buffers have no on-disk path).
206    ///
207    /// Returns the resolved [`BufferId`]; idempotent on subsequent
208    /// calls (re-uses the existing entry).
209    ///
210    /// Activation runs the major's `on_activate` synchronously,
211    /// so any subscription / spawn the mode does is in place by
212    /// the time this function returns. The major mode is what
213    /// derives the buffer's identity (instance key for LSP log
214    /// variants); the host does not stash any subsystem-shaped
215    /// buffer-local before activation.
216    ///
217    /// Phase 5.7.B.9: migrated from
218    /// `lattice-ui-tui::app::lsp_log_buffers::App::ensure_named_synthetic_document`.
219    pub fn ensure_named_synthetic_document(
220        &mut self,
221        name: &str,
222        major_id: ModeId,
223        flags: BufferFlags,
224    ) -> BufferId {
225        self.ensure_named_synthetic_doc_with_variant(
226            name,
227            major_id,
228            flags,
229            SyntheticDocVariant::Document,
230        )
231    }
232
233    /// Same as [`Self::ensure_named_synthetic_document`] but
234    /// inserts as [`BufferData::Messages`] so the kind tag is
235    /// `BufferKind::Messages`. Used by `ensure_messages_buffer`
236    /// so `:ls` / modeline / introspection can distinguish the
237    /// transcript from user-edited documents.
238    pub fn ensure_named_messages_document(
239        &mut self,
240        name: &str,
241        major_id: ModeId,
242        flags: BufferFlags,
243    ) -> BufferId {
244        self.ensure_named_synthetic_doc_with_variant(
245            name,
246            major_id,
247            flags,
248            SyntheticDocVariant::Messages,
249        )
250    }
251
252    /// PU-B.2: idempotently ensure a named popup buffer under `major_id`,
253    /// stored as [`BufferData::Help`] so the popup renderer draws it (the
254    /// renderer's popup path is `BufferData::Help`-gated) while `major_id`
255    /// (e.g. `ai-permission-mode`) owns the buffer's behaviour + keymap. The
256    /// content is empty on creation — the owning mode's `on_activate`
257    /// owner-writes the projection. Returns the existing id when a buffer of
258    /// this `name` is already registered (re-open reuses it).
259    pub fn ensure_named_popup_buffer(
260        &mut self,
261        name: &str,
262        major_id: ModeId,
263        flags: BufferFlags,
264    ) -> BufferId {
265        self.ensure_named_synthetic_doc_with_variant(
266            name,
267            major_id,
268            flags,
269            SyntheticDocVariant::Help,
270        )
271    }
272
273    /// DL.4/DL.5: mint an empty actor-backed Document filed under a
274    /// listing kind.
275    ///
276    /// The listing peer of [`Self::register_help_document`] — same
277    /// PU.1a shape (a `DocumentEntry` behind a kind discriminator),
278    /// minus the help metadata. The caller seeds the text through the
279    /// entries chokepoint, which also publishes the icons, so the rope
280    /// and the virtual text cannot drift apart.
281    ///
282    /// DL.5 generalised it to oil once that kind converged too.
283    pub fn register_listing_document(
284        &mut self,
285        flags: BufferFlags,
286        kind: crate::buffer_registry::ListingKind,
287    ) -> BufferId {
288        let id = BufferId::next();
289        let document = Document::empty();
290        let handle = spawn_document(id, document, self.registry.clone());
291        let handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(handle);
292        let entry = DocumentEntry { id, handle };
293        self.buffers.insert(BufferEntry {
294            id,
295            flags,
296            data: match kind {
297                crate::buffer_registry::ListingKind::FileTree => BufferData::FileTree(entry),
298                crate::buffer_registry::ListingKind::Oil => BufferData::Oil(entry),
299            },
300            name: None,
301        });
302        self.seed_empty_document_locals(id);
303        id
304    }
305
306    /// PU.1a: register a freshly-built [`lattice_help::HelpContent`]
307    /// as an actor-backed synthetic Document ([`BufferData::Help`]),
308    /// seeded with the content's text + parsed metadata. Returns the
309    /// new [`BufferId`]. This is the single creation path help shares
310    /// with `*messages*` and the LSP logs — content lives once in the
311    /// Document; the title goes to the registry `name` slot; links /
312    /// anchors / highlights go to `buffer_locals`. The caller owns the
313    /// popup/pane focus wiring + mode activation.
314    pub fn register_help_document(
315        &mut self,
316        content: lattice_help::HelpContent,
317        flags: BufferFlags,
318    ) -> BufferId {
319        let lattice_help::HelpContent { buffer, metadata } = content;
320        let id = BufferId::next();
321        let document = Document::empty();
322        let handle = spawn_document(id, document, self.registry.clone());
323        let handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(handle);
324        self.buffers.insert(BufferEntry {
325            id,
326            flags,
327            data: BufferData::Help(DocumentEntry { id, handle }),
328            name: Some(buffer.title),
329        });
330        self.seed_empty_document_locals(id);
331        // Seed the help text into the actor. A fresh document is
332        // empty, so an end-of-buffer append (which bypasses the
333        // read-only dispatcher, exactly like the messages backlog
334        // seed) lands the text at the top.
335        let text = buffer.content.as_string();
336        if !text.is_empty() {
337            self.append_to_owned_buffer(id, &text);
338        }
339        // PU.1b-2b: the markdown `SyntaxHandle` (path `help.md` ⇒
340        // `Lang::Markdown`) + the link `ExtraHighlights` are both
341        // attached/seeded by `seed_help_metadata_locals` below, the
342        // single point that ALSO fires on every swap — so the matrix's
343        // grammar colour and link styling stay fresh across back-stack /
344        // link-follow / in-pane re-seed without a bespoke re-attach.
345        self.seed_help_metadata_locals(id, metadata);
346        id
347    }
348
349    /// DB.2: create the `*dashboard*` buffer. Identical to
350    /// [`Self::register_help_document`] except the buffer data is
351    /// [`BufferData::Dashboard`] (so `:ls` / introspection tell it apart and
352    /// the follow gates group it with help), and `dashboard-mode` is the
353    /// major (assigned by the caller). Reuses the help metadata seed for the
354    /// markdown `SyntaxHandle` + link `ExtraHighlights`, so `<CR>`-follow
355    /// works through the shared help mechanism.
356    pub fn register_dashboard_document(
357        &mut self,
358        content: lattice_help::HelpContent,
359        flags: BufferFlags,
360    ) -> BufferId {
361        let lattice_help::HelpContent { buffer, metadata } = content;
362        let id = BufferId::next();
363        let document = Document::empty();
364        let handle = spawn_document(id, document, self.registry.clone());
365        let handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(handle);
366        self.buffers.insert(BufferEntry {
367            id,
368            flags,
369            data: BufferData::Dashboard(DocumentEntry { id, handle }),
370            name: Some(buffer.title),
371        });
372        self.seed_empty_document_locals(id);
373        let text = buffer.content.as_string();
374        if !text.is_empty() {
375            self.append_to_owned_buffer(id, &text);
376        }
377        self.seed_help_metadata_locals(id, metadata);
378        id
379    }
380
381    /// PU.1a: replace the entire content of an owned synthetic
382    /// Document at `id` with `text` (bypasses the read-only
383    /// dispatcher, like [`Self::append_to_owned_buffer`]). Used by
384    /// the popup back-stack / link-follow swap paths to re-seed a
385    /// help Document in place without changing its `BufferId`.
386    /// No-op when `id` is not a Document.
387    pub fn replace_owned_document_text(&mut self, id: BufferId, text: &str) {
388        let Some(handle) = self.buffers.document_handle(id) else {
389            return;
390        };
391        let snap = handle.snapshot();
392        let last_line = last_addressable_line(&snap.buffer);
393        let line_len = snap.buffer.line_byte_len(last_line);
394        let range = lattice_protocol::position::Range::new(
395            Position::ZERO,
396            Position::new(last_line, line_len),
397        );
398        let _ = lattice_runtime::block_on(
399            handle.apply_edit_batch(vec![Edit::replace(range, text.to_string())]),
400        );
401    }
402
403    fn ensure_named_synthetic_doc_with_variant(
404        &mut self,
405        name: &str,
406        major_id: ModeId,
407        flags: BufferFlags,
408        variant: SyntheticDocVariant,
409    ) -> BufferId {
410        if let Some(id) = self.buffers.by_name(name) {
411            return id;
412        }
413        let id = BufferId::next();
414        let document = Document::empty();
415        let handle = spawn_document(id, document, self.registry.clone());
416        // M.0: BufferRegistry stores `Arc<dyn Document>` so the
417        // entry slot accepts either a regular handle (here) or
418        // (M.1+) a multibuffer handle.
419        let handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(handle);
420        let data = match variant {
421            SyntheticDocVariant::Document => BufferData::Document(DocumentEntry { id, handle }),
422            SyntheticDocVariant::Messages => BufferData::Messages(DocumentEntry { id, handle }),
423            SyntheticDocVariant::Help => BufferData::Help(DocumentEntry { id, handle }),
424        };
425        self.buffers.insert(BufferEntry {
426            id,
427            flags,
428            data,
429            name: Some(name.to_string()),
430        });
431        // Seed empty mode-owned document locals so downstream
432        // accessors (`document_syntax_for` etc.) resolve cleanly
433        // through `buffer_locals` for this id.
434        self.seed_empty_document_locals(id);
435        // Does any provider know which directory a buffer of this NAME is
436        // about? A synthetic buffer has no path, so without this the
437        // editor's project resolution has only the process working directory
438        // left to answer with — `:files` in a magit buffer for one checkout
439        // listed whichever tree the editor happened to be launched in.
440        //
441        // Asked by name because a provider that opens through
442        // `Effect::OpenSyntheticBuffer` never sees this `BufferId`: it
443        // returned a name and the host did the rest. The name is the one
444        // thing both sides hold, which is also why magit keys `RepoScopes`
445        // that way. Generic: the host learns a directory, never whose it is.
446        if let Some(sources) = self
447            .services
448            .get::<lattice_mode::BufferScopeSourceRegistryHandle>()
449            && let Some(dir) = sources.load().scope_dir_for_name(name)
450        {
451            self.set_buffer_scope_dir(id, dir);
452        }
453        // Activate `major_id` directly. We can't use
454        // `activate_major_for_buffer_kind` because it auto-detects
455        // the language from the buffer's path (which is None here)
456        // and would pick `text-mode` instead of the caller's
457        // intended major.
458        self.activate_major_by_id(id, major_id);
459        id
460    }
461}
462
463/// Discriminator for `ensure_named_synthetic_doc_with_variant`:
464/// which `BufferData` variant to use. Storage is identical
465/// (`DocumentEntry`); only the kind tag differs.
466enum SyntheticDocVariant {
467    Document,
468    Messages,
469    /// [`BufferData::Help`] — the popup-renderable variant (PU-B.2 popup menus).
470    Help,
471}