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}