Skip to main content

lattice_ui_tui/app/
lifecycle.rs

1//! Buffer-creation, activation transitions, and shutdown.
2//! R.1.16 lands a focused subset (pane navigation); the
3//! bulk migrates with follow-up slices.
4//!
5//! Methods that live here:
6//! - `do_navigate_pane` (`<C-w>h/j/k/l` -- step cardinally
7//!   to the spatial neighbour of the active pane; geometry
8//!   from `PaneTree::compute_rects`).
9//! - `activate_pane` (swap the App's hot-path cursor /
10//!   scroll with the target pane's stash).
11//! - `load_active_pane` (inverse of `snapshot_active_pane`:
12//!   pull stashed cursor / scroll back into App; restore
13//!   help-buffer mirror when the pane points at a different
14//!   help buffer than the one currently in the hot-path
15//!   slot).
16//!
17//! Stays in app.rs (deferred to follow-up lifecycle slices):
18//! - `App::new` (the big constructor).
19//! - The activate_* family (activate_document,
20//!   activate_buffer, activate_file_tree, activate_oil,
21//!   activate_help_in_pane).
22//! - `snapshot_active_pane` / `snapshot_active_document`
23//!   (already pub(super); used widely).
24//! - `do_split_pane` / `do_close_pane` /
25//!   `gc_unreferenced_panel_buffers`.
26//! - `do_edit` / `do_write` / `do_quit` /
27//!   `do_buffer_*` (ex-command bodies; lifecycle-shaped).
28//! - `set_viewport_height`, `pending_redraw` handling,
29//!   per-loop-iteration state hooks.
30
31use lattice_runtime::RuntimeError;
32// 5.8.AA.k: `do_edit` body moved host-side; the `Lang` / `Syntax`
33// imports here are referenced only by `#[cfg(test)]` fixtures
34// below. `spawn_document` follows do_edit and isn't needed
35// elsewhere in this module.
36#[allow(unused_imports)]
37use lattice_syntax::{Lang, Syntax};
38
39use super::{App, BufferId};
40// 5.8.AA.k: BufferEntry / BufferData / DocumentEntry consumed by
41// `do_edit` (now host); BufferFlags ditto. Kept here under
42// `#[allow]` for `#[cfg(test)]` fixtures further down the file.
43#[allow(unused_imports)]
44use crate::buffer_registry::{BufferData, BufferEntry, DocumentEntry};
45#[allow(unused_imports)]
46use crate::buffers::{BufferFlags, BufferKind};
47use crate::help::HelpContent;
48// 5.5.H: PaneDirection / SplitOrientation imports retired
49// alongside the `do_navigate_pane` / `do_split_pane` App-side
50// delegates. Test-only `SplitOrientation` reference at line 1401+
51// uses the full path.
52
53impl App {
54    /// Switch the active document to `id`. Snapshots the current
55    /// active state into its entry, then loads from the
56    /// destination's entry. No-op if `id` is already active or
57    /// not registered.
58    ///
59    /// 5.5.F.4.2: the body relocated to
60    /// [`lattice_host::dispatch::Editor::activate_document`]; the
61    /// returned bool indicates whether the full-activation path
62    /// was taken (caller runs `activate_buffer_state` on `true`).
63    /// `activate_buffer_state` itself stays on App until F.5 lands
64    /// mode lifecycle host-side.
65    pub fn activate_document(&mut self, id: BufferId) {
66        if self.mutate_editor_with(move |e| e.activate_document(id)) {
67            self.activate_buffer_state();
68        }
69    }
70
71    /// Switch the active pane to whatever buffer `id` references,
72    /// regardless of kind. Document buffers route through
73    /// `activate_document`; tree buffers update the active pane +
74    /// load the tree's stash; help buffers go through
75    /// `activate_help_in_pane`.
76    ///
77    /// 5.5.F.4.2: the dispatch body relocated to
78    /// [`lattice_host::dispatch::Editor::activate_buffer`]. The
79    /// returned bool indicates whether the dispatch went through
80    /// the full `activate_document` path; on `true` the App-side
81    /// wrapper runs `activate_buffer_state` (mode/syntax/option
82    /// re-init still on App until F.5).
83    pub fn activate_buffer(&mut self, id: BufferId) {
84        if self.mutate_editor_with(move |e| e.activate_buffer(id)) {
85            self.activate_buffer_state();
86        }
87        // 3c.atomic.B: `activate_buffer` mutates active buffer
88        // state outside the dispatch publish path, so reads
89        // through `app.ad().document_buffer_id` would otherwise
90        // observe the pre-activation buffer until the next
91        // dispatch tail. Publish here to keep render-state and
92        // editor in sync.
93        // S2.4.b (2026-05-26): `publish_render_state` became
94        // `&mut self`. Switch to `mutate_editor`.
95        self.mutate_editor(move |e| {
96            e.publish_render_state();
97        });
98    }
99
100    /// 5.5.F.4.2: see [`lattice_host::dispatch::Editor::activate_file_tree`].
101    /// No `activate_buffer_state` tail — tree buffers don't have
102    /// document/syntax/options state to re-resolve.
103    pub fn activate_file_tree(&mut self, id: BufferId) {
104        // Slice 3c.final.E.3: route through `mutate_editor`.
105        self.mutate_editor(move |e| e.activate_file_tree(id));
106    }
107
108    /// 5.5.F.4.2: see [`lattice_host::dispatch::Editor::activate_oil`].
109    pub fn activate_oil(&mut self, id: BufferId) {
110        // Slice 3c.final.E.3: route through `mutate_editor`.
111        self.mutate_editor(move |e| e.activate_oil(id));
112    }
113
114    /// 5.5.F.4.2: see [`lattice_host::dispatch::Editor::activate_help_in_pane`].
115    pub(super) fn activate_help_in_pane(&mut self, id: BufferId) {
116        // Slice 3c.final.E.3: route through `mutate_editor`.
117        self.mutate_editor(move |e| e.activate_help_in_pane(id));
118    }
119
120    // 5.5.F.4.3: `do_buffer_next` / `do_buffer_prev` relocated to
121    // [`lattice_host::dispatch::Editor::do_buffer_next`] /
122    // [`lattice_host::dispatch::Editor::do_buffer_prev`]; the
123    // corresponding `Effect::BufferNext` / `Effect::BufferPrev`
124    // arms now run inside `Editor::handle_effect` and emit
125    // `RendererSignal::BufferActivated` for the App-side
126    // `activate_buffer_state` tail.
127    //
128    // `next_listed_buffer_id` / `prev_listed_buffer_id` co-moved
129    // (the Editor helpers they relied on are all host-side); the
130    // App-side definitions delete entirely (Effect-only path; no
131    // direct callers).
132
133    // 5.5.F.4.4: `do_buffer_delete` relocated to
134    // [`lattice_host::dispatch::Editor::do_buffer_delete`]; the
135    // corresponding `Effect::BufferDelete` arm now runs inside
136    // `Editor::handle_effect` and emits `RendererSignal::BufferActivated`
137    // for the App-side `activate_buffer_state` tail. The App-side
138    // wrapper deletes entirely (Effect-only path; no direct callers).
139
140    // 5.5.F.4.3: `listed_buffer_ids_sorted` / `next_listed_buffer_id` /
141    // `prev_listed_buffer_id` relocated to
142    // [`lattice_host::dispatch::Editor`]; the only App-side callers
143    // were `do_buffer_next` / `do_buffer_prev`, which co-migrated.
144
145    /// `:e[dit] FILE` (DESIGN.md §5.9 multi-buffer). If a buffer
146    /// for `path` is already open, switch to it; otherwise spawn
147    /// a fresh document actor, register it, and switch the active
148    /// pane to the new buffer. With no path, re-edit the current
149    /// buffer's path (force-reload from disk; `!` required when
150    /// dirty).
151    pub(super) fn do_edit(&mut self, path: Option<std::path::PathBuf>, force: bool) {
152        // 5.8.AA.k: body migrated to
153        // `lattice_host::dispatch::Editor::do_edit`. This wrapper
154        // routes the host's `DoEditOutcome` through App-side
155        // helpers that aren't host-resident yet:
156        //   - `Directory(path)` → `do_open_oil(Some(path))` (oil
157        //     view is App-only)
158        //   - `Activated`/`Reloaded`/`Opened(signals)` → fan
159        //     signals through `handle_renderer_signal`
160        //   - `Failed`/`NoFileName` → host already echoed
161        let outcome = self.mutate_editor_with(move |e| e.do_edit(path, force));
162        self.handle_do_edit_outcome(outcome);
163    }
164
165    /// Route a host `DoEditOutcome` through the App-side follow-ups — shared
166    /// by `:e` and `Effect::OpenBufferAt`.
167    pub(super) fn handle_do_edit_outcome(
168        &mut self,
169        outcome: lattice_host::dispatch::DoEditOutcome,
170    ) {
171        use lattice_host::dispatch::DoEditOutcome;
172        match outcome {
173            DoEditOutcome::NoFileName | DoEditOutcome::Failed => {}
174            DoEditOutcome::Directory(dir) => self.do_open_oil(Some(dir)),
175            DoEditOutcome::Reloaded(signals)
176            | DoEditOutcome::Activated(signals)
177            | DoEditOutcome::Opened(signals) => {
178                for s in signals {
179                    self.handle_renderer_signal(s);
180                }
181            }
182        }
183    }
184
185    /// P.2: push `path` onto the MRU `recent_files` list. Slice
186    /// 3c.final.E.5d: stale duplicate body removed -- the host
187    /// already owns the canonical impl
188    /// ([`lattice_host::dispatch::Editor::push_recent_file`]). This
189    /// renderer-side wrapper just routes through `mutate_editor` so
190    /// post-swap callers cross the actor boundary cleanly.
191    pub(super) fn push_recent_file(&mut self, path: &std::path::Path) {
192        let path = path.to_path_buf();
193        self.mutate_editor(move |e| e.push_recent_file(&path));
194    }
195
196    /// `:w[rite] [path]` -- save the active buffer to disk. Oil
197    /// buffers route through OilBuffer::apply (diff-and-apply
198    /// filesystem ops); document buffers route through
199    /// save_blocking / save_as_blocking against the document
200    /// actor.
201    pub(super) fn do_write(&mut self, path: Option<std::path::PathBuf>) {
202        // Slice 3c.final.E.3: route through `mutate_editor`.
203        self.mutate_editor(move |e| e.do_write(path));
204    }
205
206    /// `:q[uit]` (`scope = Pane`) / `:qa[ll]` (`scope = All`) -- quit.
207    /// `Pane` is vim-style window close: with multiple panes open,
208    /// close the active pane (no dirty check; the buffer lives on in
209    /// the registry / other panes); with one pane left, run the dirty
210    /// guard and shut down the editor. `All` ignores pane count and
211    /// shuts down regardless. `force` (`!`) bypasses the dirty guard.
212    /// Publishes `Event::BeforeQuit` when the editor actually quits.
213    pub(super) fn do_quit(&mut self, force: bool, scope: lattice_grammar::QuitScope) {
214        // Phase 5.8.AC.1: body migrated to
215        // `lattice_host::dispatch::Editor::do_quit`. The
216        // pane-close path still routes through the App-side
217        // wrapper because `do_close_pane` calls App's
218        // `gc_unreferenced_panel_buffers` after the host close.
219        // `:qa` (scope = All) skips this short-circuit and always
220        // delegates to the host's quit (dirty guard + shutdown).
221        if scope == lattice_grammar::QuitScope::Pane && self.panes().tree.len() > 1 {
222            self.do_close_pane();
223            return;
224        }
225        self.mutate_editor_with(move |e| e.do_quit(force, scope));
226    }
227
228    // 5.5.H: `do_split_pane` App-side delegate retired (zero
229    // callers; host copy at
230    // [`lattice_host::dispatch::Editor::do_split_pane`]).
231
232    /// 5.5.G.5: body migrated to
233    /// [`lattice_host::dispatch::Editor::do_close_pane`]. Kept as
234    /// a delegate because `App::do_quit` still calls it when the
235    /// editor has >1 panes (quit-just-closes-pane semantics).
236    pub(super) fn do_close_pane(&mut self) {
237        self.mutate_editor_with(move |e| e.do_close_pane());
238        self.gc_unreferenced_panel_buffers();
239    }
240
241    /// Drop singleton non-document buffers (currently: file tree)
242    /// when no pane still references them. Document buffers are
243    /// no-op stub left in for backwards compatibility with the
244    /// pre-registry refactor. Trees now live in the unified buffer
245    /// registry alongside documents (DESIGN.md §5.9), so closing
246    /// the only pane that referenced a tree leaves the tree in the
247    /// registry where `:bn` / `:bp` can reach it. Use `:bd` to
248    /// actually drop a tree buffer.
249    pub(super) fn gc_unreferenced_panel_buffers(&mut self) {}
250
251    // 5.5.H: `do_navigate_pane` + `activate_pane` App-side
252    // delegates retired (zero callers; host copies at
253    // [`lattice_host::dispatch::Editor::do_navigate_pane`] /
254    // `Editor::activate_pane`).
255
256    /// Inverse of `snapshot_active_pane`: pull the freshly
257    /// activated pane's stashed cursor / scroll back into the App's
258    /// hot-path fields. `active_buffer` is denormalized from the
259    /// pane's `buffer` kind.
260    ///
261    /// **Unified hot-path**: `self.editor.cursor` and `self.editor.scroll` are
262    /// the active buffer's, regardless of kind. Help / file-tree
263    /// keep their own cursor / scroll fields as **save state** --
264    /// updated at the snapshot boundary so the registry record is
265    /// archival-correct, but the *live* cursor is `self.editor.cursor`
266    /// for every motion / scroll / search / render path.
267    pub(super) fn load_active_pane(&mut self) {
268        // Slice 3c.final.E.3: route through `mutate_editor`.
269        self.mutate_editor(|e| e.load_active_pane());
270    }
271
272    // 5.5.F.1: `:ls` / `:buffers` content builder relocated to
273    // [`lattice_host::dispatch::Editor::build_list_buffers_content`]
274    // and the `Effect::ListBuffers` arm now lives in
275    // `Editor::handle_effect`. The renderer-coupled tail
276    // (`display_buffer` dispatch) runs via
277    // `RendererSignal::DisplayBuffer` -> `App::handle_renderer_signal`.
278
279    // 5.5.E.2: `do_list_registers` / `do_list_marks` moved to
280    // [`lattice_host::dispatch::Editor::do_list_registers`] /
281    // [`lattice_host::dispatch::Editor::do_list_marks`] alongside
282    // the [`Effect::EchoRegisters`] / [`Effect::EchoMarks`] arms.
283
284    pub(super) fn find_document_by_path(&self, path: &std::path::Path) -> Option<BufferId> {
285        // 5.8.AA.j: migrated to host.
286        // Slice 3c.final.E.5e: clone `&Path` to owned `PathBuf` for
287        // the `Send + 'static` closure. `BufferId` is `Copy`.
288        let path = path.to_path_buf();
289        self.read_editor(move |e| e.find_document_by_path(&path))
290    }
291
292    /// Save the currently-active document's hot-path state
293    /// (`syntax`, `last_parsed_text_version`, `folds`) into its
294    /// [`DocumentEntry`]. Called before switching the active
295    /// buffer so the rotation is round-trippable.
296    ///
297    /// Guarded by `active_buffer == Document`: when the active
298    /// buffer is a file tree or help, `self.editor.syntax` was already
299    /// moved into the document entry on the *previous* transition
300    /// (when we left the document). Calling this again would
301    /// `take()` an already-None value and overwrite the entry's
302    /// stashed syntax, dropping the highlight state on the floor
303    /// (the visible symptom: opening `:Tree` and pressing `q`
304    /// returned to the document with no syntax colours).
305    pub(super) fn snapshot_active_document(&mut self) {
306        // Slice 3c.final.E.3: route through `mutate_editor`.
307        self.mutate_editor(|e| e.snapshot_active_document());
308    }
309
310    /// 5.5.F.5.5: see [`lattice_host::dispatch::Editor::activate_buffer_state`].
311    /// Wrapper fans host-returned `RendererSignal`s through
312    /// [`Self::handle_renderer_signal`].
313    pub(super) fn activate_buffer_state(&mut self) {
314        // Slice 3c.final.E.3: route through `mutate_editor_with`.
315        let signals = self.mutate_editor_with(|e| e.activate_buffer_state());
316        for sig in signals {
317            self.handle_renderer_signal(sig);
318        }
319    }
320
321    /// What `:bn` / `:bp` consider the "current" buffer for
322    /// stepping. The active pane's buffer_id is the source of
323    /// truth (the active pane is what the user sees).
324    pub(super) fn active_pane_buffer_id(&self) -> BufferId {
325        self.read_editor(move |e| e.active_pane_buffer_id())
326    }
327
328    /// Copy the App's hot-path cursor / scroll into the active
329    /// pane's stash. Called before any operation that flips which
330    /// pane is active.
331    ///
332    /// **Unified hot-path**: `self.editor.cursor` and `self.editor.scroll` are
333    /// the active buffer's regardless of kind, so the snapshot
334    /// reads from there uniformly. Help / file-tree records are
335    /// also synced into their kind-specific cursor / scroll fields
336    /// (and the registry copy for help) so the archival state stays
337    /// current; live state always lives on `self`.
338    pub(super) fn snapshot_active_pane(&mut self) {
339        // Slice 3c.final.E.3: route through `mutate_editor`.
340        self.mutate_editor(|e| e.snapshot_active_pane());
341    }
342
343    // 5.5.E.7.7: `publish_document_changed` wrapper retired -- all
344    // four prod call sites collapsed: `apply_edit_blocking` /
345    // `apply_edit_batch_blocking` / `undo_blocking` / `redo_blocking`
346    // now live on `Editor` (E.7.3), and the `handle_edits` chokepoint
347    // routes through `Editor::handle_edits` via the `Effect::Edits`
348    // arm. See [`lattice_host::dispatch::Editor::publish_document_changed`].
349
350    // 5.5.E.4: `publish_selections_changed` moved to
351    // [`lattice_host::dispatch::Editor::publish_selections_changed`]
352    // — it sat alongside `set_selections_blocking` (its only caller),
353    // which migrated host-side in the same slice.
354
355    // Total area available to pane content in screen-cell units.
356    // Currently the buffer area = full terminal minus the mode
357    // line (1 row) and the echo / cmdline area (1 row). Width is
358    // the terminal width; v1 doesn't track terminal width as
359    // state, so we estimate from `viewport_height` and a constant
360    // width that the renderer overrides with the real terminal
361    // width before navigation. Good enough until B.1.c has the
362    // per-frame terminal size cached on App.
363    // 5.5.H: `buffer_area_rect` App-side delegate retired (zero
364    // callers; host copy at
365    // [`lattice_host::dispatch::Editor::buffer_area_rect`]).
366
367    /// Pane buffer-label string — the path/dirty segment (or a pane
368    /// provider's custom label). Kept for its existing callers/tests
369    /// (synthetic-name fallback, log/messages labels); the modeline
370    /// renderer (`draw_pane_status_line`) lays out zones from the
371    /// registered elements instead.
372    ///
373    /// ML.3 retired the appended mode-items (LSP / diff badges) — those
374    /// are registered modeline elements now, not part of this label.
375    pub fn pane_status_label(&self, pane: &crate::pane::PaneState) -> String {
376        let provider = self
377            .pane_render_provider(pane.buffer_id)
378            .map(|p| (p.status)(self, pane));
379        let rs = self.render_state.load();
380        lattice_host::modeline::pane_path_segment(pane, &rs, provider.as_deref())
381    }
382
383    /// Jump to `path:line:col` (LSP 0-based line, utf-8 byte
384    /// column). Single entrypoint shared by the picker accept
385    /// path (`JumpToLspLocation`) and the `do_help_follow_link`
386    /// Source-link dispatch. Pushes the pre-jump cursor onto
387    /// position history with `PluginPush` so `<C-o>` walks back.
388    pub(super) fn jump_to_file_line_col(&mut self, path: &std::path::Path, line: u32, col: u32) {
389        // Slice 3c.final.E.3: clone path for the `Send + 'static`
390        // closure, then route through `mutate_editor_with`.
391        let path = path.to_path_buf();
392        let signals = self.mutate_editor_with(move |e| e.jump_to_file_line_col(&path, line, col));
393        for s in signals {
394            self.handle_renderer_signal(s);
395        }
396    }
397
398    // Adopt a freshly-built help buffer as the active view. Records
399    // the current document cursor on the position-history ring as
400    // an `AutoJump` (so `<C-o>` from inside the help buffer returns
401    // to the document spot the user opened from), then flips
402    // `active_buffer` to `Help`. Used by every `:describe-*` /
403    // `:apropos` / `:keymap` entry point.
404    //
405    // **Popup vs in-pane.** This is the *popup* path -- the help
406    // content sits on the App's transient `popup_buffer` slot and
407    // renders as a centred overlay. The complementary
408    // [`Self::open_help_in_pane`] path registers the buffer in
409    // [`BufferRegistry`] and swaps the active pane to it; that's
410    // what `:lsp-log` / `:lsp-server-log` / `:lsp-trace-log` (Phase
411    // 3) and future persistent help views route through.
412
413    /// Adopt a help buffer into the unified [`BufferRegistry`] and
414    /// swap the active pane to it -- the in-pane counterpart to
415    /// [`Self::open_help`]. Used by persistent help views (LSP logs,
416    /// `:diagnostics`, `:apropos` once migrated) that should live as
417    /// real buffers: split-able, switchable via `:bn` / `:b N`,
418    /// listed by `:ls`.
419    ///
420    /// De-duplicates by title -- re-running the command surfaces the
421    /// existing buffer rather than allocating a new one. Returns the
422    /// `BufferId` either way so callers can wire follow-up state
423    /// (Phase 4 live-tail subscriptions key off this id).
424    ///
425    /// **Hot-path model.** The registry entry is the durable record
426    /// (`:ls` / `:bn` / picker discovery); the App's `popup_buffer`
427    /// slot mirrors the active in-pane help so the keymap +
428    /// renderer stay single-path. Pane-switch hooks
429    /// ([`Self::snapshot_active_pane`] / [`Self::load_active_pane`])
430    /// sync the two at boundaries -- same pattern as Document's
431    /// `syntax`/`folds` snapshots.
432    pub(crate) fn open_help_in_pane(&mut self, content: HelpContent) -> BufferId {
433        // Slice 3c.final.E.3: route through `mutate_editor_with`.
434        let (id, signals) = self.mutate_editor_with(move |e| e.open_help_in_pane(content));
435        for s in signals {
436            self.handle_renderer_signal(s);
437        }
438        id
439    }
440
441    /// Thin wrapper around
442    /// [`lattice_host::editor::Editor::seed_empty_document_locals`]
443    /// (Phase 5.7.B.9 migration).
444    pub(super) fn seed_empty_document_locals(&mut self, buffer_id: BufferId) {
445        // Slice 3c.final.E.3: route through `mutate_editor`.
446        self.mutate_editor(move |e| e.seed_empty_document_locals(buffer_id));
447    }
448
449    // M.3.2.c.4 mirror for the active document: copy the App's
450    // hot-path fields (`syntax`, `last_parsed_text_version`,
451    // `last_synced_syntax_version`, `folds`) into the buffer-
452    // locals map for `self.editor.document_buffer_id`. Called from
453    // every site that mutates those fields so reader-side flips
454    // (M.3.2.c.4 follow-up + retirement) can resolve mode-owned
455    // state through `buffer_locals` uniformly across active /
456    // inactive buffers.
457    // 5.5.H: `seed_active_document_locals` retired (zero callers
458    // anywhere in the workspace). M.3.2.c.4's reader-side
459    // resolution through `buffer_locals` is now driven by the
460    // deactivation hook in the buffer-switch path, not an explicit
461    // seed call from App.
462
463    // ---- M.3.2.c.4 reader accessors ----
464    //
465    // These resolve mode-owned document state through
466    // `buffer_locals` so callers don't have to branch on
467    // active-vs-inactive. The active buffer's hot-path fields
468    // (`App.editor.syntax`, `App.folds`, etc.) remain canonical;
469    // locals mirror them at de-activation boundaries so reads
470    // for inactive buffers route through this path uniformly.
471
472    /// Mode-owned syntax handle for `id`. For the active
473    /// document this is `App.editor.syntax` (the live hot-path slot);
474    /// for inactive documents it routes through `buffer_locals`.
475    /// Returns `None` for `Lang::Plain` documents and for
476    /// non-document buffers.
477    pub(crate) fn document_syntax_for(&self, id: BufferId) -> Option<lattice_syntax::SyntaxHandle> {
478        // Slice 3c.final.E.5e: returns owned `SyntaxHandle` (Clone;
479        // cheap -- inner Arc<ArcSwap<_>> bump + mpsc sender bump) so
480        // the `Send + 'static` closure body is satisfied.
481        self.read_editor(move |e| e.document_syntax_for(id).cloned())
482    }
483
484    // Slice 3c.final.E.5h: `document_folds_for`,
485    // `document_last_parsed_text_version_for`, and
486    // `document_last_synced_syntax_version_for` App-side
487    // delegates moved to the `#[cfg(test)] impl App` block at the
488    // bottom of this file — production code reaches the host-side
489    // copies (`Editor::document_folds_for` etc.) directly from
490    // `pane_highlights.rs`; only the `document_locals_*` test in
491    // this file's `mod tests` block still pokes them through the
492    // App. Same pattern as the completion.rs E.5g cleanup.
493
494    pub(super) fn save_blocking(&mut self) -> Result<std::path::PathBuf, RuntimeError> {
495        // Slice 3c.final.E.3: route through `mutate_editor_with`.
496        self.mutate_editor_with(|e| e.save_blocking())
497    }
498
499    // Phase 5.8.AD.3: `fire_did_create_files_notifications` +
500    // `fire_will_save_notifications` migrated to
501    // `lattice_host::dispatch::Editor` (private helpers under
502    // `save_blocking`). No App-side callers remain.
503
504    // Run `textDocument/willSaveWaitUntil` against every
505    // server advertising the request; collect their TextEdits
506    // and apply them pre-save.
507    //
508    // Audit slice 5 / M4: the previous shape iterated servers
509    // sequentially with a 500ms timeout per server, so total
510    // UI-thread block was up to `500ms × N`. New shape runs
511    // every server's request concurrently under one shared
512    // 500ms budget -- worst-case UI block is bounded at 500ms
513    // regardless of how many servers are attached. The
514    // remaining sync `block_on` is queued for the eventual
515    // two-phase save (kick off → return → drain on completion);
516    // the bounded-parallel fix covers the audit's actual
517    // concern (1.5s+ stalls for multi-server saves) without
518    // the behavioural change of fully-async save.
519    // Phase 5.8.AD.3: `run_will_save_wait_until_blocking` +
520    // `fire_did_save_notifications` migrated to
521    // `lattice_host::dispatch::Editor` (private helpers under
522    // `save_blocking`).
523
524    pub(super) fn save_as_blocking(&self, path: std::path::PathBuf) -> Result<(), RuntimeError> {
525        // Phase 5.8.AD.3: body migrated to
526        // `lattice_host::dispatch::Editor::save_as_blocking`.
527        self.read_editor(move |e| e.save_as_blocking(path))
528    }
529
530    // 5.5.G.4: `do_redraw_screen` migrated to
531    // [`lattice_host::dispatch::Editor`].
532}
533
534// Slice 3c.final.E.5h — test-fixture surface for mode-owned
535// document state. Same shape as the `#[cfg(test)] impl App`
536// blocks in `completion.rs` (E.5g) and `picker.rs` (E.5h):
537// production code reaches host-side copies; the wrappers here
538// exist so this file's `mod tests` block can poke the App-level
539// resolution path against a fully-built `App`.
540#[cfg(test)]
541impl App {
542    pub(crate) fn document_folds_for(&self, id: BufferId) -> &[crate::app::Fold] {
543        let ad = self.ad();
544        if id == ad.document_buffer_id && matches!(ad.buffer_kind, BufferKind::Document) {
545            return &self.editor.folds;
546        }
547        self.editor
548            .buffer_locals
549            .get(&id)
550            .and_then(|l| l.get::<crate::modes::DocumentFolds>())
551            .map(|f| f.0.as_slice())
552            .unwrap_or(&[])
553    }
554
555    pub(crate) fn document_last_parsed_text_version_for(&self, id: BufferId) -> u64 {
556        let ad = self.ad();
557        if id == ad.document_buffer_id && matches!(ad.buffer_kind, BufferKind::Document) {
558            return self.editor.last_parsed_text_version;
559        }
560        self.editor
561            .buffer_locals
562            .get(&id)
563            .and_then(|l| l.get::<crate::modes::DocumentLastParsedTextVersion>())
564            .map(|v| v.0)
565            .unwrap_or(0)
566    }
567
568    pub(crate) fn document_last_synced_syntax_version_for(&self, id: BufferId) -> u64 {
569        let ad = self.ad();
570        if id == ad.document_buffer_id && matches!(ad.buffer_kind, BufferKind::Document) {
571            return self.editor.last_synced_syntax_version;
572        }
573        self.editor
574            .buffer_locals
575            .get(&id)
576            .and_then(|l| l.get::<crate::modes::DocumentLastSyncedSyntaxVersion>())
577            .map(|v| v.0)
578            .unwrap_or(0)
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    #![allow(clippy::unwrap_used, clippy::panic)]
585
586    use super::*;
587    use crate::app::test_helpers::{
588        app_with, attach_test_syntax, fresh_workspace, invoke_motion, press, set_rust_syntax,
589        settle_mode, submit_ex, unique_tempdir, write_temp_file, write_workspace_config,
590    };
591    use crate::app::*;
592    use lattice_protocol::edit::Edit;
593
594    /// LM.3 test driver: reproduce oil `<CR>` the way
595    /// `OilMode::action_handlers()` (`action:oil-follow`) does — resolve the
596    /// entry under the cursor from the oil buffer's locals and apply the
597    /// effect it emits (`OilNavigate` for a directory, `OpenBufferAt` for a
598    /// file). A faithful double of the mode handler, so the pre-LM.3
599    /// `do_oil_follow` behavioural tests keep asserting the same outcomes
600    /// through the new effect path. The keymap→handler wiring itself is
601    /// covered separately by the press-through-mode integration test.
602    fn oil_follow(a: &mut App) {
603        let id = a.editor.active_pane_buffer_id();
604        let line = a.editor.cursor.line as usize;
605        let dir = a.editor.oil_dir_for(id).expect("oil dir");
606        let entry = a
607            .editor
608            .oil_snapshot_for(id)
609            .and_then(|s| s.snapshot_entries().get(line).cloned())
610            .expect("entry under cursor");
611        let target = dir.join(&entry.name);
612        if entry.is_dir {
613            a.apply_effect(lattice_grammar::Effect::OilNavigate {
614                view: id,
615                dir: target,
616                focus: None,
617            });
618        } else {
619            a.apply_effect(lattice_grammar::Effect::OpenBufferAt {
620                path: Some(target),
621                position: lattice_protocol::Position::ZERO,
622                force: false,
623                content: None,
624                activate_minor: None,
625            });
626        }
627    }
628
629    #[test]
630    fn maybe_reparse_syntax_drains_pending_edits_and_updates_version() {
631        let mut a = app_with("hello", 5);
632        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
633        let initial_synced = a.editor.last_synced_syntax_version;
634        a.apply_edit_blocking(Edit::insert(Position::new(0, 5), " world"))
635            .unwrap();
636        assert_eq!(a.editor.pending_syntax_edits.len(), 1);
637        // Drive the reparse-request seam directly (mirrors what
638        // the runtime loop does at the end of each Action).
639        a.maybe_reparse_syntax();
640        // Edits drained.
641        assert_eq!(a.editor.pending_syntax_edits.len(), 0);
642        // Version baseline advanced -- next request will use
643        // this as `from_version`.
644        assert!(a.editor.last_synced_syntax_version > initial_synced);
645        assert_eq!(
646            a.editor.last_synced_syntax_version,
647            a.editor.document.text_version()
648        );
649    }
650
651    #[test]
652    fn edit_loads_named_file() {
653        let dir = unique_tempdir();
654        let path = dir.join("hello.txt");
655        std::fs::write(&path, "loaded contents\nsecond line").unwrap();
656        let mut a = app_with("original", 10);
657        let cmd = format!("e {}", path.display());
658        submit_ex(&mut a, &cmd);
659        assert_eq!(a.editor.document.text(), "loaded contents\nsecond line");
660        assert_eq!(a.editor.cursor, Position::ZERO);
661        std::fs::remove_dir_all(&dir).ok();
662    }
663
664    #[test]
665    fn autoread_stamps_fingerprint_on_load() {
666        // AR.0: loading a file-backed buffer stamps its on-disk
667        // fingerprint, and that fingerprint matches the file content.
668        use lattice_host::autoread::OnDiskFingerprint;
669        let dir = unique_tempdir();
670        let path = dir.join("hello.txt");
671        std::fs::write(&path, "loaded contents\n").unwrap();
672        let mut a = app_with("original", 10);
673        submit_ex(&mut a, &format!("e {}", path.display()));
674
675        let id = a.editor.document_buffer_id;
676        let fp = a
677            .editor
678            .on_disk_fingerprints
679            .get(&id)
680            .expect("fingerprint stamped on load");
681        let expected = OnDiskFingerprint::from_path_and_text(&path, "loaded contents\n");
682        assert!(
683            fp.same_content(&expected),
684            "load fingerprint matches file content"
685        );
686        std::fs::remove_dir_all(&dir).ok();
687    }
688
689    #[test]
690    fn autoread_restamps_fingerprint_on_save_to_match_disk() {
691        // AR.0 self-write suppression: after editing the buffer and
692        // saving, the stored fingerprint reflects the NEW content — i.e.
693        // it equals what a re-read of disk would produce, so the watcher
694        // (AR.2) will recognise this write as its own.
695        use lattice_host::autoread::OnDiskFingerprint;
696        let dir = unique_tempdir();
697        let path = dir.join("edit.txt");
698        std::fs::write(&path, "old\n").unwrap();
699        let mut a = app_with("original", 10);
700        submit_ex(&mut a, &format!("e {}", path.display()));
701        let id = a.editor.document_buffer_id;
702        let before = a.editor.on_disk_fingerprints.get(&id).cloned().unwrap();
703
704        // Dirty the buffer, then save.
705        a.apply_edit_blocking(Edit::insert(Position::new(0, 0), "new-"))
706            .unwrap();
707        assert!(a.editor.document.dirty());
708        a.save_blocking().unwrap();
709
710        let after = a.editor.on_disk_fingerprints.get(&id).cloned().unwrap();
711        assert!(
712            !before.same_content(&after),
713            "content changed ⇒ fingerprint changed"
714        );
715        // The stored fingerprint equals a fresh read of disk: self-write
716        // is suppressible.
717        let disk = std::fs::read_to_string(&path).unwrap();
718        let disk_fp = OnDiskFingerprint::from_path_and_text(&path, &disk);
719        assert!(
720            after.same_content(&disk_fp),
721            "post-save fingerprint matches on-disk content"
722        );
723        std::fs::remove_dir_all(&dir).ok();
724    }
725
726    #[test]
727    fn autoread_removes_fingerprint_on_buffer_delete() {
728        // AR.0: closing a file-backed buffer drops its fingerprint so the
729        // map tracks only live buffers.
730        let dir = unique_tempdir();
731        let a_path = dir.join("a.txt");
732        let b_path = dir.join("b.txt");
733        std::fs::write(&a_path, "aaa\n").unwrap();
734        std::fs::write(&b_path, "bbb\n").unwrap();
735        let mut app = app_with("original", 10);
736        submit_ex(&mut app, &format!("e {}", a_path.display()));
737        let a_id = app.editor.document_buffer_id;
738        submit_ex(&mut app, &format!("e {}", b_path.display()));
739        assert!(app.editor.on_disk_fingerprints.contains_key(&a_id));
740
741        // Switch back to A and delete it.
742        submit_ex(&mut app, &format!("e {}", a_path.display()));
743        assert_eq!(app.editor.document_buffer_id, a_id);
744        submit_ex(&mut app, "bd");
745        assert!(
746            !app.editor.on_disk_fingerprints.contains_key(&a_id),
747            "fingerprint dropped on :bd"
748        );
749        std::fs::remove_dir_all(&dir).ok();
750    }
751
752    /// The dirty guard is on RELOAD: `:e` of the file already showing would
753    /// throw the edits away. (This used to `:e /nonexistent`, and passed only
754    /// because opening a missing path failed — CD.2 made that open a new
755    /// buffer, as vim does, and switching away keeps the edits in a hidden
756    /// buffer rather than losing them.)
757    #[test]
758    fn edit_refuses_when_dirty() {
759        let dir = unique_tempdir();
760        let path = dir.join("guarded.txt");
761        std::fs::write(&path, "on disk").unwrap();
762        let mut a = app_with("scratch", 10);
763        submit_ex(&mut a, &format!("e {}", path.display()));
764        a.apply(Action::EnterMode(ModalState::Insert));
765        a.apply(Action::Insert("X".into()));
766        a.apply(Action::EnterMode(ModalState::Normal));
767        assert!(a.editor.document.dirty());
768        submit_ex(&mut a, "e");
769        let msg = a.editor.last_message.as_ref().unwrap();
770        assert_eq!(msg.level, EchoLevel::Error);
771        // Document unchanged.
772        assert_eq!(a.editor.document.text(), "Xon disk");
773        std::fs::remove_dir_all(&dir).ok();
774    }
775
776    #[test]
777    fn edit_force_overrides_dirty_guard() {
778        let dir = unique_tempdir();
779        let path = dir.join("forced.txt");
780        std::fs::write(&path, "loaded").unwrap();
781        let mut a = app_with("dirty content", 10);
782        a.apply(Action::EnterMode(ModalState::Insert));
783        a.apply(Action::Insert("Z".into()));
784        a.apply(Action::EnterMode(ModalState::Normal));
785        let cmd = format!("e! {}", path.display());
786        submit_ex(&mut a, &cmd);
787        assert_eq!(a.editor.document.text(), "loaded");
788        std::fs::remove_dir_all(&dir).ok();
789    }
790
791    #[test]
792    fn edit_preserves_registers_across_swap() {
793        let dir = unique_tempdir();
794        let path = dir.join("preserve.txt");
795        std::fs::write(&path, "new content").unwrap();
796        let mut a = app_with("hello world", 10);
797        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
798            lattice_grammar::Target::Motion(
799                a.editor.builtins.word_forward,
800                lattice_grammar::Args::None,
801            ),
802        );
803        a.apply(Action::Invoke(inv));
804        assert!(a.editor.unnamed_register.is_some());
805        let cmd = format!("e {}", path.display());
806        submit_ex(&mut a, &cmd);
807        // Register survives.
808        assert!(a.editor.unnamed_register.is_some());
809        std::fs::remove_dir_all(&dir).ok();
810    }
811
812    #[test]
813    fn edit_resets_per_document_state() {
814        let dir = unique_tempdir();
815        let path = dir.join("reset.txt");
816        std::fs::write(&path, "fresh").unwrap();
817        let mut a = app_with("aaa\nbbb\nccc", 10);
818        a.editor.cursor = Position::new(2, 1);
819        a.apply(invoke_motion(a.editor.builtins.goto_first_line));
820        // The goto_first_line motion pushed a jump entry.
821        let history_pre = a.editor.position_history.len();
822        assert!(history_pre > 0);
823        let cmd = format!("e {}", path.display());
824        submit_ex(&mut a, &cmd);
825        // M.10.3.fix1 (2026-06-03): position_history is
826        // session-wide, NOT per-document. `:e <new-file>` does
827        // NOT clear the jump list — vim semantics require
828        // `<C-o>` to walk back into the previous buffer after
829        // a fresh-file open. The entry pushed in the previous
830        // buffer survives; activate_buffer additionally pushes
831        // an entry for the cross-buffer hop (dedups against
832        // the pre-existing one if identical fields).
833        assert!(
834            a.editor.position_history.len() >= history_pre,
835            "position_history must survive fresh-file open (jump list is session-wide)"
836        );
837        // The other per-document state (cursor) DOES reset —
838        // fresh file starts at (0, 0).
839        assert_eq!(a.editor.cursor, Position::ZERO);
840        std::fs::remove_dir_all(&dir).ok();
841    }
842
843    /// vim 9.2: `:e` of a path with nothing there — even under a directory
844    /// that does not exist — opens an empty buffer; only `:w` would fail
845    /// (E212). CD.2; this used to assert an error.
846    #[test]
847    fn edit_unknown_path_opens_a_new_buffer() {
848        let mut a = app_with("hello", 10);
849        submit_ex(&mut a, "e /absolutely/does/not/exist/anywhere.txt");
850        let msg = a.editor.last_message.as_ref().unwrap();
851        assert_eq!(msg.level, EchoLevel::Info);
852        assert!(msg.text.ends_with("[New]"), "{}", msg.text);
853        assert_eq!(a.editor.document.text(), "");
854        assert!(!a.editor.document.dirty());
855        assert!(!std::path::Path::new("/absolutely/does/not/exist/anywhere.txt").exists());
856    }
857
858    #[test]
859    fn split_pane_horizontal_creates_second_pane() {
860        let mut a = app_with("xx", 10);
861        a.apply(Action::SplitPaneHorizontal);
862        assert_eq!(a.editor.pane_tree.len(), 2);
863        // Active stays on original.
864        assert_eq!(a.editor.pane_tree.active_index(), 0);
865    }
866
867    #[test]
868    fn split_pane_vertical_creates_second_pane() {
869        let mut a = app_with("xx", 10);
870        a.apply(Action::SplitPaneVertical);
871        assert_eq!(a.editor.pane_tree.len(), 2);
872    }
873
874    #[test]
875    fn close_pane_collapses_split() {
876        let mut a = app_with("xx", 10);
877        a.apply(Action::SplitPaneVertical);
878        a.apply(Action::ClosePane);
879        assert_eq!(a.editor.pane_tree.len(), 1);
880    }
881
882    #[test]
883    fn only_pane_collapses_all_other_panes() {
884        // `:only` / `<C-x>1`: from three panes, collapse to one.
885        let mut a = app_with("xx", 10);
886        a.apply(Action::SplitPaneVertical);
887        a.apply(Action::SplitPaneHorizontal);
888        assert_eq!(a.editor.pane_tree.len(), 3);
889        a.apply(Action::OnlyPane);
890        assert_eq!(a.editor.pane_tree.len(), 1);
891    }
892
893    #[test]
894    fn only_pane_single_pane_is_a_noop() {
895        // No panic, no quit, stays at one pane (failure-mode guard).
896        let mut a = app_with("xx", 10);
897        a.apply(Action::OnlyPane);
898        assert_eq!(a.editor.pane_tree.len(), 1);
899        assert!(!a.editor.should_quit);
900    }
901
902    #[test]
903    fn quit_with_multiple_panes_closes_active_pane() {
904        let mut a = app_with("xx", 10);
905        a.apply(Action::SplitPaneVertical);
906        assert_eq!(a.editor.pane_tree.len(), 2);
907        a.do_quit(false, lattice_grammar::QuitScope::Pane);
908        assert!(
909            !a.editor.should_quit,
910            "extra pane: :q must not exit the editor"
911        );
912        assert_eq!(a.editor.pane_tree.len(), 1);
913    }
914
915    #[test]
916    fn quit_with_multiple_panes_skips_dirty_check() {
917        let mut a = app_with("xx", 10);
918        a.apply(Action::SplitPaneVertical);
919        a.apply(Action::EnterMode(ModalState::Insert));
920        a.apply(Action::Insert("z".into()));
921        a.apply(Action::EnterMode(ModalState::Normal));
922        assert!(a.editor.document.dirty());
923        a.do_quit(false, lattice_grammar::QuitScope::Pane);
924        assert!(!a.editor.should_quit);
925        assert_eq!(a.editor.pane_tree.len(), 1);
926    }
927
928    #[test]
929    fn quit_with_last_pane_clean_quits_editor() {
930        let mut a = app_with("xx", 10);
931        a.do_quit(false, lattice_grammar::QuitScope::Pane);
932        assert!(a.editor.should_quit);
933    }
934
935    #[test]
936    fn quit_last_pane_with_other_tabs_closes_tab_not_editor() {
937        // `:q` on the last pane of a tab, when other tabs exist, closes
938        // the TAB (vim's tab-page close), it does NOT quit the editor.
939        let mut a = app_with("xx", 10);
940        a.apply(Action::NewTab);
941        assert_eq!(a.editor.tabs.len(), 2);
942        assert_eq!(a.editor.pane_tree.len(), 1, "new tab starts with one pane");
943        a.do_quit(false, lattice_grammar::QuitScope::Pane);
944        assert!(
945            !a.editor.should_quit,
946            ":q with other tabs open must not quit the editor"
947        );
948        assert_eq!(a.editor.tabs.len(), 1, ":q closed the tab, leaving one");
949    }
950
951    #[test]
952    fn quit_all_with_other_tabs_quits_editor() {
953        // `:qa` ignores tab count just like pane count.
954        let mut a = app_with("xx", 10);
955        a.apply(Action::NewTab);
956        assert_eq!(a.editor.tabs.len(), 2);
957        a.do_quit(false, lattice_grammar::QuitScope::All);
958        assert!(
959            a.editor.should_quit,
960            ":qa must quit regardless of how many tabs are open"
961        );
962    }
963
964    #[test]
965    fn quit_all_with_multiple_panes_quits_editor() {
966        // `:qa` ignores pane count: unlike `:q`, an extra pane must
967        // NOT turn the quit into a pane-close. Clean buffers → quit.
968        let mut a = app_with("xx", 10);
969        a.apply(Action::SplitPaneVertical);
970        assert_eq!(a.editor.pane_tree.len(), 2);
971        a.do_quit(false, lattice_grammar::QuitScope::All);
972        assert!(
973            a.editor.should_quit,
974            ":qa must exit the editor regardless of pane count"
975        );
976    }
977
978    #[test]
979    fn quit_all_dirty_refuses_then_force_quits() {
980        // `:qa` shares `:q`'s dirty guard: a dirty buffer blocks the
981        // quit (no `should_quit`, warning set), and `:qa!` forces past.
982        let mut a = app_with("xx", 10);
983        a.apply(Action::SplitPaneVertical);
984        a.apply(Action::EnterMode(ModalState::Insert));
985        a.apply(Action::Insert("z".into()));
986        a.apply(Action::EnterMode(ModalState::Normal));
987        assert!(a.editor.document.dirty());
988        a.do_quit(false, lattice_grammar::QuitScope::All);
989        assert!(
990            !a.editor.should_quit,
991            ":qa must honor the dirty guard (no force)"
992        );
993        // The extra pane is untouched — :qa did not degrade to a pane-close.
994        assert_eq!(a.editor.pane_tree.len(), 2);
995        a.do_quit(true, lattice_grammar::QuitScope::All);
996        assert!(a.editor.should_quit, ":qa! forces past the dirty guard");
997    }
998
999    #[test]
1000    fn quit_with_last_pane_dirty_refuses() {
1001        let mut a = app_with("xx", 10);
1002        a.apply(Action::EnterMode(ModalState::Insert));
1003        a.apply(Action::Insert("z".into()));
1004        a.apply(Action::EnterMode(ModalState::Normal));
1005        a.do_quit(false, lattice_grammar::QuitScope::Pane);
1006        assert!(!a.editor.should_quit);
1007        assert!(
1008            a.editor
1009                .last_message
1010                .as_ref()
1011                .map(|m| m.text.contains("no write since last change"))
1012                .unwrap_or(false)
1013        );
1014    }
1015
1016    #[test]
1017    fn quit_force_with_last_pane_dirty_quits() {
1018        let mut a = app_with("xx", 10);
1019        a.apply(Action::EnterMode(ModalState::Insert));
1020        a.apply(Action::Insert("z".into()));
1021        a.apply(Action::EnterMode(ModalState::Normal));
1022        a.do_quit(true, lattice_grammar::QuitScope::Pane);
1023        assert!(a.editor.should_quit);
1024    }
1025
1026    /// `:q` must skip the dirty guard for `*messages*` and other
1027    /// subsystem-owned synthetic buffers. `messages-mode`
1028    /// contributes `NoFile = true` so the resolved-option filter
1029    /// in `do_quit` excludes the buffer; without the filter, the
1030    /// transcript's append flow leaves it permanently "dirty"
1031    /// and `:q` refuses to exit.
1032    #[test]
1033    fn quit_with_dirty_messages_buffer_still_quits() {
1034        let mut a = app_with("xx", 10);
1035        let msgs = a.ensure_messages_buffer();
1036        // Force a content write so the buffer's clean position
1037        // diverges from its current depth (mirrors what the
1038        // tracing subscriber's append flow produces).
1039        a.append_to_owned_buffer(msgs, "boot record line\n");
1040        assert!(
1041            a.editor.buffers.document_dirty(msgs),
1042            "test pre-condition: *messages* buffer reports dirty after append",
1043        );
1044        a.do_quit(false, lattice_grammar::QuitScope::Pane);
1045        assert!(
1046            a.editor.should_quit,
1047            ":q must skip the dirty guard for NoFile = true buffers (e.g. *messages*)",
1048        );
1049    }
1050
1051    #[test]
1052    fn open_help_popup_preserves_doc_pane_cursor_for_render() {
1053        // Bug: invoking a popup-mode help command (`:lsp-status`,
1054        // `:describe-key`, etc.) flipped `active_buffer` to Help
1055        // without first syncing the doc's `app.editor.cursor` /
1056        // `app.editor.scroll` into the active pane's stash. The renderer
1057        // reads `pane.cursor` for any pane whose buffer kind
1058        // doesn't match `active_buffer` (popup mode = mismatch),
1059        // so the doc visibly jumped to wherever pane.cursor was
1060        // last (often (0,0)).
1061        let mut a = app_with("line0\nline1\nline2\nline3\nline4\n", 5);
1062        a.editor.cursor = Position::new(3, 2);
1063        a.editor.scroll = 1;
1064        a.do_lsp_status();
1065        // After open_help, active is Help but the active pane
1066        // still shows the doc -- pane.cursor must reflect where
1067        // the doc was, not the help buffer's (0,0).
1068        let pane = a.editor.pane_tree.active();
1069        assert_eq!(
1070            pane.cursor,
1071            Position::new(3, 2),
1072            "doc's pre-help cursor must be stashed onto pane.cursor"
1073        );
1074        assert_eq!(pane.scroll, 1);
1075    }
1076
1077    #[test]
1078    fn split_inherits_cursor_and_scroll_from_active() {
1079        let mut a = app_with("a\nb\nc\nd", 10);
1080        a.editor.cursor = Position::new(2, 0);
1081        a.editor.scroll = 1;
1082        a.apply(Action::SplitPaneVertical);
1083        // Both panes should have (line=2, scroll=1) initially.
1084        let panes = a.editor.pane_tree.leaves();
1085        assert_eq!(panes[0].cursor.line, 2);
1086        assert_eq!(panes[0].scroll, 1);
1087        assert_eq!(panes[1].cursor.line, 2);
1088        assert_eq!(panes[1].scroll, 1);
1089    }
1090
1091    #[test]
1092    fn edit_new_file_registers_a_second_buffer() {
1093        let path = write_temp_file("a", "alpha\n");
1094        let mut a = app_with("xx", 10);
1095        let initial_id = a.editor.document_buffer_id;
1096        a.editor
1097            .set_command_line_text(&format!("e {}", path.display()));
1098        a.editor.modal = ModalState::Command;
1099        a.apply(Action::CommandLineSubmit);
1100        // Two listed buffers (initial + opened); the synthetic
1101        // `*lsp*` buffer is unlisted and filtered out here.
1102        assert_eq!(a.editor.buffers.listed_ids_sorted().len(), 2);
1103        assert_ne!(a.editor.document_buffer_id, initial_id);
1104        assert_eq!(a.editor.document.text(), "alpha\n");
1105        let _ = std::fs::remove_file(path);
1106    }
1107
1108    #[test]
1109    fn ls_renders_help_with_every_open_buffer() {
1110        let path = write_temp_file("c", "x\n");
1111        let mut a = app_with("xx", 10);
1112        a.editor
1113            .set_command_line_text(&format!("e {}", path.display()));
1114        a.editor.modal = ModalState::Command;
1115        a.apply(Action::CommandLineSubmit);
1116        a.editor.set_command_line_text("ls");
1117        a.editor.modal = ModalState::Command;
1118        a.apply(Action::CommandLineSubmit);
1119        let h = a.popup_help().expect("buffers help");
1120        let body = h.content.as_string();
1121        // Four buffers total: the initial document, the file we
1122        // opened, the synthetic `*lsp*` Document, and the
1123        // `*messages*` Messages transcript. `:ls` lists every
1124        // entry regardless of `listed`; the unlisted marker `u`
1125        // signals the user-toggleable cycle filter without
1126        // suppressing the row. The per-kind summary partitions
1127        // documents (3) and messages (1).
1128        assert!(body.contains("4 open buffer"));
1129        assert!(body.contains("3 document"));
1130        assert!(body.contains("1 message"));
1131        assert!(body.contains("*lsp*"));
1132        assert!(body.contains("*messages*"));
1133        let _ = std::fs::remove_file(path);
1134    }
1135
1136    #[test]
1137    fn tree_open_makes_filetree_active() {
1138        let dir = std::env::temp_dir().join(format!("lattice-tree-test-{}", std::process::id()));
1139        std::fs::create_dir_all(&dir).ok();
1140        std::fs::write(dir.join("a.txt"), "alpha").ok();
1141        let mut a = app_with("xx", 10);
1142        a.editor
1143            .set_command_line_text(&format!("Filetree {}", dir.display()));
1144        a.editor.modal = ModalState::Command;
1145        a.apply(Action::CommandLineSubmit);
1146        assert_eq!(a.editor.active_buffer, BufferKind::FileTree);
1147        assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 1);
1148        std::fs::remove_dir_all(&dir).ok();
1149    }
1150
1151    #[test]
1152    fn tree_close_returns_to_document() {
1153        let dir = std::env::temp_dir().join(format!("lattice-tree-close-{}", std::process::id()));
1154        std::fs::create_dir_all(&dir).ok();
1155        let mut a = app_with("xx", 10);
1156        a.editor
1157            .set_command_line_text(&format!("Filetree {}", dir.display()));
1158        a.editor.modal = ModalState::Command;
1159        a.apply(Action::CommandLineSubmit);
1160        a.apply(Action::HelpDismiss);
1161        assert_eq!(a.editor.active_buffer, BufferKind::Document);
1162        assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 0);
1163        std::fs::remove_dir_all(&dir).ok();
1164    }
1165
1166    #[test]
1167    fn tree_motion_routes_through_active_buffer() {
1168        let dir = std::env::temp_dir().join(format!("lattice-tree-motion-{}", std::process::id()));
1169        std::fs::create_dir_all(&dir).ok();
1170        std::fs::write(dir.join("a.txt"), "x").ok();
1171        std::fs::write(dir.join("b.txt"), "y").ok();
1172        let mut a = app_with("xx", 10);
1173        a.editor
1174            .set_command_line_text(&format!("Filetree {}", dir.display()));
1175        a.editor.modal = ModalState::Command;
1176        a.apply(Action::CommandLineSubmit);
1177        let line_down = a.editor.builtins.line_down;
1178        a.apply(Action::Invoke(CommandInvocation::of(line_down.0)));
1179        // After unification, `self.editor.cursor` is the active buffer's
1180        // cursor. The tree's own `cursor` field is archival save-
1181        // state synced at activation transitions.
1182        assert_eq!(a.editor.cursor.line, 1);
1183        std::fs::remove_dir_all(&dir).ok();
1184    }
1185
1186    /// LM.4: `<CR>` on a tree row is owned by `file-tree-mode` (chord →
1187    /// mode keymap → `Effect::OpenBufferAt`), not `Action::FollowLink`, so
1188    /// the test presses the key rather than dispatching the action.
1189    #[tokio::test(flavor = "multi_thread")]
1190    async fn tree_follow_on_file_opens_document_buffer() {
1191        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1192        let dir = std::env::temp_dir().join(format!("lattice-tree-follow-{}", std::process::id()));
1193        let _ = std::fs::remove_dir_all(&dir);
1194        std::fs::create_dir_all(&dir).ok();
1195        std::fs::write(dir.join("alpha.txt"), "hello").ok();
1196        let mut a = app_with("xx", 10);
1197        a.do_open_file_tree(Some(dir.clone()));
1198        assert!(
1199            settle_mode(&mut a, "file-tree-mode").await,
1200            "file-tree-mode must activate on the tree buffer",
1201        );
1202        let id = a.editor.active_pane_buffer_id();
1203        let line = a
1204            .editor
1205            .file_tree_entries_for(id)
1206            .and_then(|es| {
1207                es.iter().position(|e| {
1208                    e.path
1209                        .file_name()
1210                        .map(|n| n == "alpha.txt")
1211                        .unwrap_or(false)
1212                })
1213            })
1214            .expect("alpha.txt row present");
1215        a.editor.cursor.line = line as u32;
1216        // Follow.
1217        press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1218        // Active pane now shows the file's Document buffer; the
1219        // tree stays in the registry (reachable via :bn / :b).
1220        assert_eq!(a.editor.active_buffer, BufferKind::Document);
1221        assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 1);
1222        assert_eq!(a.editor.document.text(), "hello");
1223        std::fs::remove_dir_all(&dir).ok();
1224    }
1225
1226    #[test]
1227    fn open_help_in_pane_registers_buffer_and_activates_pane() {
1228        let mut app = app_with("hi\n", 5);
1229        let buf = HelpContent::from_lines("test-help", vec!["# heading".into(), "body".into()]);
1230        let id = app.open_help_in_pane(buf);
1231        // Lives in the registry as a Help variant.
1232        assert!(app.editor.buffers.contains_help(id));
1233        // Active pane points at it.
1234        assert_eq!(app.active_pane_buffer_id(), id);
1235        assert!(matches!(app.editor.active_buffer, BufferKind::Help));
1236        // Hot-path popup slot mirrors the registry copy.
1237        assert_eq!(app.popup_help().unwrap().title, "test-help");
1238        // :ls walks the registry; help variants count.
1239        assert!(app.editor.buffers.help_ids_sorted().contains(&id));
1240    }
1241
1242    #[test]
1243    fn open_help_in_pane_dedups_by_title() {
1244        let mut app = app_with("hi\n", 5);
1245        let id1 = app.open_help_in_pane(HelpContent::from_lines("lsp:rust", vec!["v1".into()]));
1246        let id2 = app.open_help_in_pane(HelpContent::from_lines(
1247            "lsp:rust",
1248            vec!["v2 (refreshed)".into()],
1249        ));
1250        assert_eq!(id1, id2, "same title returns same BufferId");
1251        // Refresh path overwrote the body.
1252        let body = app.popup_help().unwrap().content.as_string();
1253        assert!(body.contains("refreshed"));
1254        // Single help entry in the registry.
1255        assert_eq!(app.editor.buffers.help_ids_sorted().len(), 1);
1256    }
1257
1258    #[test]
1259    fn active_pane_content_height_subtracts_status_row_always() {
1260        // Option A: every pane (including single) reserves a status row.
1261        let mut app = app_with("hi\n", 5);
1262        assert_eq!(app.active_pane_content_height(20), 19);
1263        // Horizontal split -> two panes, each ~half the buffer
1264        // height; minus the per-pane status row.
1265        app.editor
1266            .pane_tree
1267            .split_active(crate::pane::SplitOrientation::Horizontal);
1268        // Slice 3c.final.E.5i: `active_pane_content_height` reads
1269        // the pane tree through `panes()` (RS-backed mirror), so
1270        // direct `pane_tree` mutation needs an explicit publish.
1271        app.editor.publish_render_state();
1272        let content = app.active_pane_content_height(20);
1273        // 20 / 2 = 10; minus status row = 9.
1274        assert_eq!(content, 9);
1275    }
1276
1277    #[test]
1278    fn persistent_lsp_log_level_applies_from_toml_tree() {
1279        // M.6.5: canonical `lsp-mode.log-level` key.
1280        let mut app = app_with("hi\n", 5);
1281        let toml_text = "[lsp-mode]\nlog-level = \"debug\"\n";
1282        // BC.8b: `lsp_config_tree` is shared (`Arc<ArcSwap>`); `store` the tree.
1283        app.editor.lsp_config_tree.store(std::sync::Arc::new(
1284            toml_text.parse::<toml::Table>().expect("toml parse"),
1285        ));
1286        app.apply_persistent_lsp_editor_options();
1287        // Effect: a Debug-level record on an unattached server lands
1288        // in the ring. Default min-level is Info; without the TOML
1289        // override the record would be filtered before it reached
1290        // the ring.
1291        let instance = lattice_lsp::InstanceKey::new(
1292            std::sync::Arc::<str>::from("rust"),
1293            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
1294        );
1295        app.editor.lsp_logger.log(
1296            Some(&instance),
1297            lattice_lsp::LogLevel::Debug,
1298            lattice_lsp::LogSource::Client,
1299            "after-toml",
1300        );
1301        let recs = app.editor.lsp_logger.snapshot_instance(&instance);
1302        assert!(
1303            recs.iter().any(|r| r.message == "after-toml"),
1304            "Debug record should pass through after TOML log-level=debug",
1305        );
1306    }
1307
1308    #[test]
1309    fn persistent_lsp_log_level_legacy_key_warns_then_applies() {
1310        // M.6.5: legacy `[lsp] log-level` key still works for one
1311        // minor version; emits a deprecation warn before applying.
1312        let mut app = app_with("hi\n", 5);
1313        let toml_text = "[lsp]\nlog-level = \"debug\"\n";
1314        // BC.8b: `lsp_config_tree` is shared (`Arc<ArcSwap>`); `store` the tree.
1315        app.editor.lsp_config_tree.store(std::sync::Arc::new(
1316            toml_text.parse::<toml::Table>().expect("toml parse"),
1317        ));
1318        app.apply_persistent_lsp_editor_options();
1319        let msg = app.editor.last_message.as_ref().expect("deprecation warn");
1320        assert_eq!(msg.level, crate::app::EchoLevel::Warn);
1321        assert!(
1322            msg.text.contains("`lsp.log-level` is deprecated")
1323                && msg.text.contains("`lsp-mode.log-level`"),
1324            "echo should name old + new keys, got {}",
1325            msg.text,
1326        );
1327        // And the value still applied (one-version compatibility).
1328        let instance = lattice_lsp::InstanceKey::new(
1329            std::sync::Arc::<str>::from("rust"),
1330            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
1331        );
1332        app.editor.lsp_logger.log(
1333            Some(&instance),
1334            lattice_lsp::LogLevel::Debug,
1335            lattice_lsp::LogSource::Client,
1336            "after-legacy-toml",
1337        );
1338        let recs = app.editor.lsp_logger.snapshot_instance(&instance);
1339        assert!(
1340            recs.iter().any(|r| r.message == "after-legacy-toml"),
1341            "legacy key should still apply for one minor version",
1342        );
1343    }
1344
1345    #[test]
1346    fn persistent_lsp_log_level_canonical_wins_over_legacy() {
1347        // If both are set, canonical wins (silently -- no deprecation
1348        // echo since the user has already migrated; the legacy key
1349        // is a leftover).
1350        let mut app = app_with("hi\n", 5);
1351        let toml_text = "[lsp]\nlog-level = \"trace\"\n[lsp-mode]\nlog-level = \"debug\"\n";
1352        // BC.8b: `lsp_config_tree` is shared (`Arc<ArcSwap>`); `store` the tree.
1353        app.editor.lsp_config_tree.store(std::sync::Arc::new(
1354            toml_text.parse::<toml::Table>().expect("toml parse"),
1355        ));
1356        app.apply_persistent_lsp_editor_options();
1357        // No deprecation echo when canonical is present.
1358        assert!(
1359            app.editor.last_message.is_none(),
1360            "canonical present ⇒ silent; got {:?}",
1361            app.editor.last_message,
1362        );
1363    }
1364
1365    #[test]
1366    fn persistent_lsp_log_level_warns_on_unknown_value() {
1367        let mut app = app_with("hi\n", 5);
1368        let toml_text = "[lsp-mode]\nlog-level = \"babble\"\n";
1369        // BC.8b: `lsp_config_tree` is shared (`Arc<ArcSwap>`); `store` the tree.
1370        app.editor.lsp_config_tree.store(std::sync::Arc::new(
1371            toml_text.parse::<toml::Table>().expect("toml parse"),
1372        ));
1373        app.apply_persistent_lsp_editor_options();
1374        let msg = app.editor.last_message.as_ref().expect("warn echo");
1375        assert!(
1376            msg.text.contains("lsp-mode.log-level") && msg.text.contains("babble"),
1377            "echo should name the key + value, got {}",
1378            msg.text
1379        );
1380    }
1381
1382    #[test]
1383    fn persistent_lsp_log_level_silent_when_missing() {
1384        let mut app = app_with("hi\n", 5);
1385        app.editor.last_message = None;
1386        // Empty tree: nothing under [lsp].
1387        app.editor
1388            .lsp_config_tree
1389            .store(std::sync::Arc::new(toml::Table::new()));
1390        app.apply_persistent_lsp_editor_options();
1391        assert!(
1392            app.editor.last_message.is_none(),
1393            "no echo when key is absent (default applies)",
1394        );
1395    }
1396
1397    #[test]
1398    fn load_persistent_config_applies_scalar_override_from_project_toml() {
1399        let ws = fresh_workspace("scalar-override");
1400        write_workspace_config(&ws, "tabstop = 2\n");
1401        let mut a = app_with("", 5);
1402        // tabstop default is 4; a non-default override (2) should
1403        // land before the first frame.
1404        assert_eq!(
1405            *a.editor
1406                .config
1407                .get_typed::<lattice_config::Tabstop>()
1408                .unwrap(),
1409            4
1410        );
1411        a.load_persistent_config(Some(&ws));
1412        assert_eq!(
1413            *a.editor
1414                .config
1415                .get_typed::<lattice_config::Tabstop>()
1416                .unwrap(),
1417            2
1418        );
1419    }
1420
1421    #[test]
1422    fn load_persistent_config_buckets_per_language_section() {
1423        let ws = fresh_workspace("per-lang-bucket");
1424        write_workspace_config(
1425            &ws,
1426            "[completion.per-language.markdown]\n\
1427             auto_trigger = false\n\
1428             [completion.per-language.rust]\n\
1429             auto_trigger = true\n",
1430        );
1431        let mut a = app_with("", 5);
1432        a.load_persistent_config(Some(&ws));
1433        // Both per-language entries land in the structural
1434        // bucket, keyed by full dotted path.
1435        let paths = a.pending_structural_section_paths("completion.per-language");
1436        assert_eq!(paths.len(), 2);
1437        assert!(paths.contains(&"completion.per-language.markdown".to_string()));
1438        assert!(paths.contains(&"completion.per-language.rust".to_string()));
1439        // Drain markdown -> sub-table accessible.
1440        let md = a
1441            .take_pending_structural_section("completion.per-language.markdown")
1442            .expect("markdown section drained");
1443        assert_eq!(
1444            md.get("auto_trigger").and_then(|v| v.as_bool()),
1445            Some(false),
1446        );
1447        // After drain, only rust remains.
1448        let after = a.pending_structural_section_paths("completion.per-language");
1449        assert_eq!(after, vec!["completion.per-language.rust".to_string()]);
1450    }
1451
1452    #[test]
1453    fn load_persistent_config_collects_unknown_plugin_section_for_later_drain() {
1454        // Extensibility: a user writes `[plugin.X]` before the
1455        // plugin host exists. Loader buckets it; nothing warns;
1456        // the host (Phase 7) drains it when it registers.
1457        let ws = fresh_workspace("plugin-deferred");
1458        write_workspace_config(&ws, "[plugin.rust-analyzer]\nclippy = true\n");
1459        let mut a = app_with("", 5);
1460        a.load_persistent_config(Some(&ws));
1461        let paths = a.pending_structural_section_paths("plugin");
1462        assert_eq!(paths, vec!["plugin.rust-analyzer".to_string()]);
1463        let body = a
1464            .take_pending_structural_section("plugin.rust-analyzer")
1465            .expect("plugin section drained");
1466        assert_eq!(body.get("clippy").and_then(|v| v.as_bool()), Some(true));
1467    }
1468
1469    #[test]
1470    fn load_persistent_config_warning_surfaces_on_unknown_key() {
1471        let ws = fresh_workspace("unknown-key");
1472        write_workspace_config(&ws, "no_such_option = 42\n");
1473        let mut a = app_with("", 5);
1474        a.load_persistent_config(Some(&ws));
1475        // The echo carries the loader's warning.
1476        let msg = a.editor.last_message.as_ref().expect("warning echoed");
1477        assert_eq!(msg.level, EchoLevel::Warn);
1478        assert!(msg.text.contains("config:"), "got `{}`", msg.text);
1479        assert!(msg.text.contains("no_such_option"), "got `{}`", msg.text,);
1480    }
1481
1482    #[test]
1483    fn tree_sitter_source_emits_definition_position_symbols_for_rust() {
1484        let source = "fn outer(arg: i32) {\n    let local = arg;\n}\n";
1485        let mut a = app_with(source, 10);
1486        set_rust_syntax(&mut a, source);
1487        a.editor.modal = ModalState::Insert;
1488        // Cursor at end-of-buffer with empty query so every
1489        // candidate matches uniformly; the matcher won't drop
1490        // anything for prefix mismatch.
1491        a.editor.cursor = Position::new(2, 1);
1492        a.do_completion_trigger();
1493        let state = a.editor.insert_completion.as_ref().expect("popup");
1494        let tree_sitter_id = lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID;
1495        let ts_texts: Vec<&str> = state
1496            .raw
1497            .iter()
1498            .filter(|c| c.source.as_ref().map(|s| s.as_str()) == Some(tree_sitter_id))
1499            .map(|c| c.text.as_str())
1500            .collect();
1501        for expected in &["outer", "arg", "local"] {
1502            assert!(
1503                ts_texts.contains(expected),
1504                "expected `{expected}` in tree-sitter candidates: {ts_texts:?}",
1505            );
1506        }
1507    }
1508
1509    #[test]
1510    fn tree_sitter_source_skipped_by_per_language_override() {
1511        let source = "fn outer() {\n    let local = 1;\n}\n";
1512        let mut a = app_with(source, 10);
1513        set_rust_syntax(&mut a, source);
1514        a.editor.modal = ModalState::Insert;
1515        a.editor.cursor = Position::new(2, 1);
1516        // Override the active language (test buffer has no
1517        // path -> language id is "") to exclude tree-sitter.
1518        a.editor.per_language_completion.insert(
1519            String::new(),
1520            lattice_completion::PerLanguageOverrides {
1521                sources: Some(vec![lattice_completion::SourceId::new(
1522                    lattice_completion::BufferWordsSource::ID,
1523                )]),
1524                ..Default::default()
1525            },
1526        );
1527        a.do_completion_trigger();
1528        let state = a.editor.insert_completion.as_ref().expect("popup");
1529        let tree_sitter_id = lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID;
1530        for cand in &state.raw {
1531            let src = cand.source.as_ref().map(|s| s.as_str()).unwrap_or("");
1532            assert_ne!(
1533                src, tree_sitter_id,
1534                "tree-sitter source filtered out for this language",
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn tree_sitter_and_buffer_words_emit_independently_for_same_name() {
1541        // `outer` appears as a function definition (captured
1542        // by tree-sitter) AND as a referenced word (captured
1543        // by buffer-words). Both sources contribute their
1544        // tagged copy in `state.raw` -- the producers run
1545        // independently. Visual dedup at the renderer (4.2.g.7
1546        // polish) collapses them to a single popup row, so
1547        // `state.rendered` has exactly one entry for `outer`,
1548        // tagged with the higher-priority source (buffer-words
1549        // at 100 > tree-sitter at 80).
1550        let source = "fn outer() {\n    outer();\n}\n";
1551        let mut a = app_with(source, 10);
1552        set_rust_syntax(&mut a, source);
1553        a.editor.modal = ModalState::Insert;
1554        a.editor.cursor = Position::new(2, 1);
1555        a.do_completion_trigger();
1556        let state = a.editor.insert_completion.as_ref().expect("popup");
1557        let raw_sources: Vec<&str> = state
1558            .raw
1559            .iter()
1560            .filter(|c| c.text == "outer")
1561            .map(|c| c.source.as_ref().map(|s| s.as_str()).unwrap_or(""))
1562            .collect();
1563        assert!(
1564            raw_sources.contains(&lattice_completion::BufferWordsSource::ID),
1565            "buffer-words copy present in raw set: {raw_sources:?}",
1566        );
1567        assert!(
1568            raw_sources.contains(&lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID),
1569            "tree-sitter copy present in raw set: {raw_sources:?}",
1570        );
1571        let rendered_outer: Vec<&str> = state
1572            .rendered
1573            .iter()
1574            .filter(|c| c.raw.text == "outer")
1575            .map(|c| c.raw.source.as_ref().map(|s| s.as_str()).unwrap_or(""))
1576            .collect();
1577        assert_eq!(rendered_outer.len(), 1, "popup deduped to one row");
1578        assert_eq!(
1579            rendered_outer[0],
1580            lattice_completion::BufferWordsSource::ID,
1581            "higher-priority source's row survives the dedup",
1582        );
1583    }
1584
1585    #[test]
1586    fn tree_sitter_source_silent_without_syntax_attached() {
1587        // No `set_rust_syntax` -> `app_with` leaves
1588        // `self.editor.syntax = None`; tree-sitter source emits
1589        // nothing.
1590        let mut a = app_with("alpha bravo charlie", 5);
1591        a.editor.modal = ModalState::Insert;
1592        a.editor.cursor = Position::new(0, 19);
1593        a.do_completion_trigger();
1594        if let Some(state) = a.editor.insert_completion.as_ref() {
1595            let tree_sitter_id = lattice_completion::TREE_SITTER_SYMBOL_SOURCE_ID;
1596            for cand in &state.raw {
1597                assert_ne!(
1598                    cand.source.as_ref().map(|s| s.as_str()),
1599                    Some(tree_sitter_id),
1600                );
1601            }
1602        }
1603    }
1604
1605    #[test]
1606    fn load_persistent_config_silent_when_no_files_present() {
1607        let ws = fresh_workspace("no-files");
1608        // Empty workspace -- no .lattice/config.toml. Loader
1609        // produces no messages; the modeline stays clean.
1610        let mut a = app_with("", 5);
1611        let prior = a.editor.last_message.clone();
1612        a.load_persistent_config(Some(&ws));
1613        // No new echo (modeline message is whatever the test
1614        // setup left, which for app_with is None).
1615        assert_eq!(a.editor.last_message, prior);
1616    }
1617
1618    #[test]
1619    fn open_help_in_pane_seeds_help_locals() {
1620        let mut a = app_with("hi", 5);
1621        let help = crate::help::HelpContent::from_lines(
1622            "test-locals",
1623            vec![
1624                "# Heading One".to_string(),
1625                "see [ex:write](command:ex:write)".to_string(),
1626            ],
1627        );
1628        let help_id = a.open_help_in_pane(help);
1629        let locals = a
1630            .editor
1631            .buffer_locals
1632            .get(&help_id)
1633            .expect("buffer_locals should be populated for help buffer");
1634        // Links parsed from `[ex:write](command:ex:write)`.
1635        let links = locals
1636            .get::<crate::modes::HelpLinks>()
1637            .expect("HelpLinks local seeded");
1638        assert_eq!(links.0.len(), 1);
1639        // Anchors come from heading slug generation. `from_lines`
1640        // doesn't auto-anchor headings (only
1641        // `from_lines_and_anchors` plumbs anchors); the seed
1642        // should still be present, just empty.
1643        let anchors = locals
1644            .get::<crate::modes::HelpAnchors>()
1645            .expect("HelpAnchors local seeded (possibly empty)");
1646        assert_eq!(anchors.0.len(), 0);
1647    }
1648
1649    #[test]
1650    fn file_tree_locals_carry_owner_metadata() {
1651        let tmp =
1652            std::env::temp_dir().join(format!("lattice-m3-2-c-2-meta-{}", std::process::id()));
1653        let _ = std::fs::create_dir_all(&tmp);
1654        let mut a = app_with("hi", 5);
1655        a.do_open_file_tree(Some(tmp.clone()));
1656        let tree_id = a.active_pane_buffer_id();
1657        let locals = a.editor.buffer_locals.get(&tree_id).unwrap();
1658        // file-tree-mode-owned locals (root / entries /
1659        // nerd-fonts). Other locals (e.g.
1660        // `ActiveCompletionSources` from CSM.3) may coexist on
1661        // the same buffer; the file-tree subset must still be
1662        // namespaced correctly.
1663        let tree_descriptors: Vec<_> = locals
1664            .iter_descriptors()
1665            .filter(|d| d.owner_mode == "file-tree-mode")
1666            .collect();
1667        assert!(
1668            tree_descriptors.len() >= 3,
1669            "file-tree-mode should seed root/entries/nerd-fonts, got {tree_descriptors:?}",
1670        );
1671        for d in &tree_descriptors {
1672            assert!(
1673                d.name.starts_with("file-tree-mode."),
1674                "file-tree-mode local name {:?} should be namespaced under file-tree-mode",
1675                d.name,
1676            );
1677        }
1678        let _ = std::fs::remove_dir_all(&tmp);
1679    }
1680
1681    #[test]
1682    fn boot_seeds_initial_document_locals() {
1683        // M.3.2.c.4: the initial document buffer should have its
1684        // four mode-owned locals (DocumentSyntax, last_parsed,
1685        // last_synced, folds) seeded with empty defaults. Reader-
1686        // side flips later in this slice route through this map.
1687        let a = app_with("hello", 10);
1688        let id = a.editor.document_buffer_id;
1689        let locals = a
1690            .editor
1691            .buffer_locals
1692            .get(&id)
1693            .expect("initial document has buffer_locals");
1694        assert!(
1695            locals.get::<crate::modes::DocumentSyntax>().is_some(),
1696            "DocumentSyntax local seeded"
1697        );
1698        assert!(
1699            locals
1700                .get::<crate::modes::DocumentLastParsedTextVersion>()
1701                .is_some(),
1702            "DocumentLastParsedTextVersion local seeded"
1703        );
1704        assert!(
1705            locals
1706                .get::<crate::modes::DocumentLastSyncedSyntaxVersion>()
1707                .is_some(),
1708            "DocumentLastSyncedSyntaxVersion local seeded"
1709        );
1710        assert!(
1711            locals.get::<crate::modes::DocumentFolds>().is_some(),
1712            "DocumentFolds local seeded"
1713        );
1714    }
1715
1716    #[test]
1717    fn snapshot_active_document_mirrors_into_locals() {
1718        // After de-activating a document (which moves App.editor.syntax
1719        // / App.folds into entry.editor.syntax / entry.folds), the
1720        // buffer-locals for that document should reflect the
1721        // entry's new contents.
1722        let mut a = app_with("hello\nworld", 10);
1723        let active_id = a.editor.document_buffer_id;
1724        // Force a non-default fold so the mirror has something
1725        // to observe.
1726        a.editor.folds.push(crate::app::Fold {
1727            start_line: 0,
1728            end_line: 1,
1729            closed: false,
1730            identity: None,
1731        });
1732        a.editor.last_parsed_text_version = 42;
1733        a.editor.last_synced_syntax_version = 41;
1734        a.snapshot_active_document();
1735        let locals = a
1736            .editor
1737            .buffer_locals
1738            .get(&active_id)
1739            .expect("locals exist for active id");
1740        let parsed = locals
1741            .get::<crate::modes::DocumentLastParsedTextVersion>()
1742            .expect("last_parsed mirrored");
1743        let synced = locals
1744            .get::<crate::modes::DocumentLastSyncedSyntaxVersion>()
1745            .expect("last_synced mirrored");
1746        let folds = locals
1747            .get::<crate::modes::DocumentFolds>()
1748            .expect("folds mirrored");
1749        assert_eq!(parsed.0, 42);
1750        assert_eq!(synced.0, 41);
1751        assert_eq!(folds.0.len(), 1);
1752        assert_eq!(folds.0[0].start_line, 0);
1753    }
1754
1755    #[test]
1756    fn popup_dismiss_does_not_jolt_backdrop_scroll() {
1757        // Open a centred popup over a long doc so the doc's scroll
1758        // sits at a non-zero baseline; dismiss; assert the doc's
1759        // scroll round-trips. The bug: `ensure_cursor_visible`
1760        // fires after dispatch with `viewport_height` still set to
1761        // the popup's small inner height, recomputing scroll
1762        // against the wrong viewport and shifting the backdrop.
1763        let many_lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
1764        let mut a = app_with(&many_lines, 30);
1765        // Park the cursor mid-document so scroll is non-zero.
1766        a.editor.cursor = lattice_protocol::position::Position::new(40, 0);
1767        a.editor.scroll = 30;
1768        let pre_cursor = a.editor.cursor;
1769        let pre_scroll = a.editor.scroll;
1770        // :lsp-status opens a centred popup (focuses Help mode).
1771        a.do_lsp_status();
1772        // viewport_height is now the popup's inner height (small).
1773        // Set it explicitly to mimic what runtime would do.
1774        a.set_viewport_height(
1775            a.help_popup_inner_height(30)
1776                .unwrap_or(a.editor.viewport_height),
1777        );
1778        // Dismiss the popup (the dispatch path calls this on Esc).
1779        a.dismiss_popup();
1780        // Now simulate what `apply` does post-dispatch: the fix is
1781        // that ensure_cursor_visible gets skipped on this transition,
1782        // so we don't even need to call it. Verify cursor + scroll
1783        // are restored to pre-popup values.
1784        assert_eq!(a.editor.cursor, pre_cursor, "cursor restored");
1785        assert_eq!(a.editor.scroll, pre_scroll, "scroll restored without jolt");
1786    }
1787
1788    #[test]
1789    fn popup_with_long_content_scrolls_when_cursor_descends() {
1790        // Popup with 50 lines of content; focused (active_buffer =
1791        // Help). Step the cursor past the popup viewport's bottom
1792        // row and assert popup scroll advances to keep the cursor
1793        // visible. Goes through the input + keymap layer (`press`)
1794        // so the path matches what real `j` keystrokes hit, not
1795        // the App's direct-Invoke shortcut.
1796        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1797        let lines: Vec<String> = (0..50).map(|i| format!("popup line {i}")).collect();
1798        let mut a = app_with("backdrop\n", 30);
1799        let buf = crate::help::HelpContent::from_lines("status", lines);
1800        a.open_popup(buf, crate::popup::PopupPlacement::Centered);
1801        // Mimic the runtime's per-frame viewport set: in popup
1802        // mode it's the popup's inner height, not the doc area's.
1803        let inner = a
1804            .help_popup_inner_height(30)
1805            .expect("popup inner height available");
1806        a.set_viewport_height(inner);
1807        // Press `j` enough times to step past the visible
1808        // viewport. Each iteration mirrors the runtime: refresh
1809        // viewport_height, then process the keystroke.
1810        for _ in 0..(inner + 5) {
1811            a.set_viewport_height(
1812                a.help_popup_inner_height(30)
1813                    .unwrap_or(a.editor.viewport_height),
1814            );
1815            crate::app::test_helpers::press(
1816                &mut a,
1817                KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
1818            );
1819        }
1820        assert!(
1821            a.editor.cursor.line >= inner,
1822            "cursor should descend past the visible viewport, got line {} (inner {})",
1823            a.editor.cursor.line,
1824            inner
1825        );
1826        assert!(
1827            a.editor.scroll > 0,
1828            "scroll should advance once cursor leaves the visible window, got scroll {}",
1829            a.editor.scroll
1830        );
1831        assert!(
1832            a.editor.cursor.line < a.editor.scroll + inner,
1833            "cursor must still be inside the scrolled viewport (cursor {}, scroll {}, inner {})",
1834            a.editor.cursor.line,
1835            a.editor.scroll,
1836            inner
1837        );
1838    }
1839
1840    #[test]
1841    fn document_syntax_for_inactive_resolves_through_locals() {
1842        // For an inactive document buffer the accessor must
1843        // resolve through buffer_locals (since `App.editor.syntax` only
1844        // holds the active document's handle).
1845        use crate::buffer_registry::{BufferData, BufferEntry, DocumentEntry};
1846        use crate::buffers::{BufferFlags, BufferId};
1847        let mut a = app_with("active", 5);
1848        // Manufacture a second document buffer + seed empty
1849        // locals to validate the accessor path. Real `:e <new>`
1850        // does this through `do_edit`.
1851        let inactive_id = BufferId::next();
1852        let doc_handle = a.editor.document.as_arc();
1853        a.editor.buffers.insert(BufferEntry {
1854            id: inactive_id,
1855            flags: BufferFlags::default(),
1856            data: BufferData::Document(DocumentEntry {
1857                id: inactive_id,
1858                handle: doc_handle,
1859            }),
1860            name: None,
1861        });
1862        a.seed_empty_document_locals(inactive_id);
1863        // Read for the inactive buffer flows through locals; syntax
1864        // is None which the accessor returns as None.
1865        assert!(
1866            a.document_syntax_for(inactive_id).is_none(),
1867            "accessor returns None for empty locals"
1868        );
1869        // last_parsed / last_synced come back as 0 on the inactive
1870        // buffer's empty locals.
1871        assert_eq!(a.document_last_parsed_text_version_for(inactive_id), 0);
1872        assert_eq!(a.document_last_synced_syntax_version_for(inactive_id), 0);
1873        // folds slice is empty.
1874        assert!(a.document_folds_for(inactive_id).is_empty());
1875    }
1876
1877    #[test]
1878    fn document_locals_carry_owner_metadata() {
1879        let a = app_with("hi", 10);
1880        let id = a.editor.document_buffer_id;
1881        let locals = a.editor.buffer_locals.get(&id).unwrap();
1882        let descriptors: Vec<_> = locals.iter_descriptors().collect();
1883        for d in descriptors
1884            .iter()
1885            .filter(|d| d.name.starts_with("text-mode."))
1886        {
1887            assert_eq!(d.owner_mode, "text-mode");
1888        }
1889        // At minimum the four document locals.
1890        let names: Vec<_> = descriptors.iter().map(|d| d.name).collect();
1891        assert!(names.contains(&"text-mode.syntax"));
1892        assert!(names.contains(&"text-mode.last-parsed-text-version"));
1893        assert!(names.contains(&"text-mode.last-synced-syntax-version"));
1894        assert!(names.contains(&"text-mode.folds"));
1895    }
1896
1897    #[test]
1898    fn oil_open_then_write_creates_a_new_file_on_disk() {
1899        // End-to-end: open an oil buffer for an empty dir,
1900        // directly seed a new filename in the rope, run :w,
1901        // verify the file exists on disk. Tests the diff-and-
1902        // apply pipeline.
1903        let tmp =
1904            std::env::temp_dir().join(format!("lattice-oil-create-test-{}", std::process::id()));
1905        let _ = std::fs::remove_dir_all(&tmp);
1906        std::fs::create_dir_all(&tmp).expect("create tmp");
1907
1908        let mut a = app_with("hi", 5);
1909        a.do_open_oil(Some(tmp.clone()));
1910        assert_eq!(a.editor.active_buffer, BufferKind::Oil);
1911
1912        // DL.5: oil is a writable Document — edit it like one.
1913        a.editor
1914            .apply_edit_batch_blocking(vec![lattice_protocol::edit::Edit::insert(
1915                lattice_protocol::position::Position::ZERO,
1916                "newfile.txt\n".to_string(),
1917            )])
1918            .expect("insert edit");
1919
1920        // Save. Should run OilBuffer::apply and create the file.
1921        a.do_write(None);
1922        let msg = a.editor.last_message.as_ref().expect("write echo");
1923        assert!(
1924            msg.text.contains("oil: applied"),
1925            "expected oil-apply success echo, got: {}",
1926            msg.text,
1927        );
1928        let new_path = tmp.join("newfile.txt");
1929        assert!(
1930            new_path.exists(),
1931            "expected {} to be created",
1932            new_path.display(),
1933        );
1934
1935        let _ = std::fs::remove_dir_all(&tmp);
1936    }
1937
1938    #[test]
1939    fn oil_navigate_up_from_oil_buffer_goes_to_parent() {
1940        // `-` key from an oil buffer navigates to the parent
1941        // dir.
1942        let tmp = std::env::temp_dir().join(format!("lattice-oil-up-test-{}", std::process::id()));
1943        let _ = std::fs::remove_dir_all(&tmp);
1944        std::fs::create_dir_all(tmp.join("nested")).expect("nested");
1945
1946        let mut a = app_with("hi", 5);
1947        a.do_open_oil(Some(tmp.join("nested")));
1948        let oil_id = a.active_pane_buffer_id();
1949        assert_eq!(
1950            a.editor
1951                .buffer_locals
1952                .get(&oil_id)
1953                .and_then(|l| l.get::<crate::modes::OilDir>())
1954                .map(|d| d.0.clone())
1955                .unwrap_or_default(),
1956            tmp.join("nested"),
1957        );
1958        // Trigger `-`.
1959        a.apply(crate::app::Action::OilNavigateUp);
1960        // Dir lives in the OilDir buffer-local (canonical).
1961        let dir_after = a.oil_dir_for(oil_id).unwrap_or_default();
1962        assert_eq!(dir_after, tmp);
1963
1964        let _ = std::fs::remove_dir_all(&tmp);
1965    }
1966
1967    #[test]
1968    fn oil_navigate_up_from_document_opens_oil_at_parent() {
1969        // `-` from a document buffer should open oil for the
1970        // parent of the document's path. Use a document with no
1971        // path -- this falls back to cwd.
1972        let mut a = app_with("hi", 5);
1973        // Start in a Document buffer (default).
1974        assert_eq!(a.editor.active_buffer, BufferKind::Document);
1975        a.apply(crate::app::Action::OilNavigateUp);
1976        // Active buffer should now be Oil.
1977        assert_eq!(a.editor.active_buffer, BufferKind::Oil);
1978    }
1979
1980    #[test]
1981    fn oil_navigate_up_from_document_lands_on_the_edited_file() {
1982        // `-` from a file buffer opens oil for the file's parent
1983        // with the cursor on the file you were editing (oil.nvim
1984        // behaviour), not at the origin row.
1985        let tmp =
1986            std::env::temp_dir().join(format!("lattice-oil-focus-doc-test-{}", std::process::id()));
1987        let _ = std::fs::remove_dir_all(&tmp);
1988        std::fs::create_dir_all(tmp.join("adir")).expect("adir");
1989        std::fs::write(tmp.join("target.txt"), "x").expect("target");
1990
1991        let mut a = app_with("hi", 5);
1992        // Open the file as a document buffer so its path is set.
1993        a.do_edit(Some(tmp.join("target.txt")), false);
1994        assert_eq!(a.editor.active_buffer, BufferKind::Document);
1995
1996        a.apply(crate::app::Action::OilNavigateUp);
1997        assert_eq!(a.editor.active_buffer, BufferKind::Oil);
1998        // Listing is dirs-first alpha: ["adir", "target.txt"], so
1999        // the edited file is row 1 -- the cursor must land there.
2000        assert_eq!(a.editor.cursor.line, 1);
2001
2002        let _ = std::fs::remove_dir_all(&tmp);
2003    }
2004
2005    #[test]
2006    fn oil_navigate_up_lands_on_the_directory_left() {
2007        // `-` inside an oil buffer steps up to the parent listing
2008        // with the cursor on the child directory you stepped out
2009        // of, so `-` then `<CR>` round-trips to the same place.
2010        let tmp =
2011            std::env::temp_dir().join(format!("lattice-oil-focus-up-test-{}", std::process::id()));
2012        let _ = std::fs::remove_dir_all(&tmp);
2013        // Two dirs so "nested" is not row 0 after sorting
2014        // (dirs-first alpha: ["adir", "nested"]).
2015        std::fs::create_dir_all(tmp.join("adir")).expect("adir");
2016        std::fs::create_dir_all(tmp.join("nested")).expect("nested");
2017
2018        let mut a = app_with("hi", 5);
2019        a.do_open_oil(Some(tmp.join("nested")));
2020        assert_eq!(a.editor.active_buffer, BufferKind::Oil);
2021
2022        a.apply(crate::app::Action::OilNavigateUp);
2023        assert_eq!(
2024            a.oil_dir_for(a.active_pane_buffer_id()).unwrap_or_default(),
2025            tmp
2026        );
2027        // "nested" is row 1 in the parent listing.
2028        assert_eq!(a.editor.cursor.line, 1);
2029
2030        let _ = std::fs::remove_dir_all(&tmp);
2031    }
2032
2033    #[test]
2034    fn oil_normal_mode_o_then_insert_then_write_creates_file() {
2035        // Full keystroke pipeline test: in an oil buffer,
2036        // press `o` (Normal: open line below + Insert), type
2037        // a filename, press <Esc>, run :w. Verify the file is
2038        // created on disk.
2039        let tmp =
2040            std::env::temp_dir().join(format!("lattice-oil-o-write-test-{}", std::process::id()));
2041        let _ = std::fs::remove_dir_all(&tmp);
2042        std::fs::create_dir_all(&tmp).expect("create tmp");
2043        // Seed a file so there's an existing row to position
2044        // after.
2045        std::fs::write(tmp.join("seed.txt"), "x").expect("seed");
2046
2047        let mut a = app_with("hi", 5);
2048        a.do_open_oil(Some(tmp.clone()));
2049        a.apply(crate::app::Action::OpenLineBelow);
2050        assert_eq!(a.editor.modal, lattice_grammar::ModalState::Insert);
2051        a.apply(crate::app::Action::Insert("new.rs".into()));
2052        a.apply(crate::app::Action::EnterMode(
2053            lattice_grammar::ModalState::Normal,
2054        ));
2055        a.do_write(None);
2056        let new_path = tmp.join("new.rs");
2057        assert!(
2058            new_path.exists(),
2059            "expected {} to be created via the keystroke path; \
2060             write echo: {:?}",
2061            new_path.display(),
2062            a.editor.last_message,
2063        );
2064
2065        let _ = std::fs::remove_dir_all(&tmp);
2066    }
2067
2068    /// DL.2: `directory-listing-mode` activates on **both** listing
2069    /// majors, not just the one that happened to be tested.
2070    ///
2071    /// Registering a mode and having it activate are different things,
2072    /// and the gap between them is silent — an `ActivationPolicy` that
2073    /// names the wrong major id compiles, registers, and simply never
2074    /// fires. Asserting on the buffer's active-mode set is the only
2075    /// thing that catches it.
2076    ///
2077    /// Both kinds in one test on purpose: CV.5 was exactly the failure
2078    /// where oil was checked and the file tree, carrying the identical
2079    /// defect, was not.
2080    #[tokio::test]
2081    async fn directory_listing_mode_activates_on_oil_and_the_file_tree() {
2082        let mode = lattice_listing::listing_mode::DirectoryListingMode::mode_id();
2083
2084        for kind in ["oil", "file-tree"] {
2085            let tmp = unique_tempdir();
2086            std::fs::write(tmp.join("existing.txt"), "x").expect("seed");
2087
2088            let mut a = app_with("hi", 5);
2089            match kind {
2090                "oil" => a.do_open_oil(Some(tmp.clone())),
2091                _ => a.do_open_file_tree(Some(tmp.clone())),
2092            }
2093            let buffer_id = a.active_pane_buffer_id();
2094
2095            let active = crate::app::test_helpers::settle(&mut a, |app: &App| {
2096                app.editor
2097                    .active_modes
2098                    .get(&buffer_id)
2099                    .map(|m| m.is_active(mode))
2100                    .unwrap_or(false)
2101            })
2102            .await;
2103            assert!(
2104                active,
2105                "{kind}: directory-listing-mode must activate — it owns the \
2106                 icons and themed row colours for this buffer"
2107            );
2108
2109            let _ = std::fs::remove_dir_all(&tmp);
2110        }
2111    }
2112
2113    /// DL.5: `:w` still derives the right filesystem operations after
2114    /// oil became a Document and its icons became virtual text.
2115    ///
2116    /// This is the one thing in the listing convergence that can lose
2117    /// user data, so it is asserted against the **filesystem**, not
2118    /// against the rope. The specific hazard: `:w` diffs the buffer's
2119    /// text against the snapshot to decide what to rename, create and
2120    /// delete — so anything decorative leaking into the rope would be
2121    /// read as a filename. Icons are virtual text precisely to keep
2122    /// that from being possible, and this is the test that would
2123    /// notice if they stopped being.
2124    #[tokio::test]
2125    async fn oil_write_round_trips_rename_create_and_delete() {
2126        let tmp = unique_tempdir();
2127        std::fs::write(tmp.join("old.txt"), "keep").unwrap();
2128        std::fs::write(tmp.join("doomed.txt"), "bye").unwrap();
2129
2130        let mut a = app_with("hi", 10);
2131        a.do_open_oil(Some(tmp.clone()));
2132        let oil_id = a.active_pane_buffer_id();
2133
2134        // Let the listing mode (and its icons) come up, so the write
2135        // happens in the state a user's would.
2136        let _ = crate::app::test_helpers::settle_mode(&mut a, "directory-listing-mode").await;
2137
2138        let listing = a
2139            .editor
2140            .buffers
2141            .document_handle(oil_id)
2142            .map(|h| h.snapshot().buffer.as_string())
2143            .expect("oil document");
2144        assert!(
2145            listing.contains("old.txt") && listing.contains("doomed.txt"),
2146            "precondition: both files listed, got {listing:?}"
2147        );
2148        // The rope is BARE names — no glyph, no marker. If this ever
2149        // fails, `:w` is about to treat a decoration as a filename.
2150        for line in listing.lines() {
2151            assert!(
2152                line == "old.txt" || line == "doomed.txt",
2153                "oil's rope must hold bare filenames only, got {line:?}"
2154            );
2155        }
2156
2157        // Rename, on its own. Oil's rename heuristic is documented as
2158        // *exactly* one delete plus one create — bundling other changes
2159        // into the same write is a delete-and-create by contract, so
2160        // the two are exercised as separate writes rather than one.
2161        a.editor.replace_owned_buffer(oil_id, "new.txt\ndoomed.txt");
2162        a.mutate_editor(|e| {
2163            e.do_write(None);
2164        });
2165        assert!(tmp.join("new.txt").exists(), "rename: new.txt must exist");
2166        assert!(
2167            !tmp.join("old.txt").exists(),
2168            "rename: old.txt must be gone"
2169        );
2170        assert_eq!(
2171            std::fs::read_to_string(tmp.join("new.txt")).unwrap(),
2172            "keep",
2173            "a rename must move the CONTENT, not create an empty file"
2174        );
2175
2176        // Delete + create, against the snapshot the write above
2177        // refreshed — a stale snapshot here would derive nonsense.
2178        a.editor.replace_owned_buffer(oil_id, "new.txt\nfresh.txt");
2179        a.mutate_editor(|e| {
2180            e.do_write(None);
2181        });
2182        assert!(
2183            !tmp.join("doomed.txt").exists(),
2184            "delete: doomed.txt must be gone"
2185        );
2186        assert!(
2187            tmp.join("fresh.txt").exists(),
2188            "create: fresh.txt must exist"
2189        );
2190        assert!(
2191            tmp.join("new.txt").exists(),
2192            "the untouched row must survive the second write"
2193        );
2194
2195        let _ = std::fs::remove_dir_all(&tmp);
2196    }
2197
2198    #[test]
2199    fn oil_keystroke_pipeline_inserts_into_oil_rope() {
2200        // Regression: before the run_oil_invocation rewrite,
2201        // typing a chord in an oil buffer dispatched through
2202        // the document actor (which doesn't own the oil rope),
2203        // so the rope stayed empty no matter what the user
2204        // typed. Now the dispatch routes through a temp
2205        // Document and copies the resulting buffer back onto
2206        // `oil.content`.
2207        let tmp =
2208            std::env::temp_dir().join(format!("lattice-oil-keystroke-test-{}", std::process::id()));
2209        let _ = std::fs::remove_dir_all(&tmp);
2210        std::fs::create_dir_all(&tmp).expect("create tmp");
2211        // Seed one file so the listing has a row.
2212        std::fs::write(tmp.join("existing.txt"), "x").expect("seed");
2213
2214        let mut a = app_with("hi", 5);
2215        a.do_open_oil(Some(tmp.clone()));
2216        let oil_id = a.active_pane_buffer_id();
2217        // Initial rope has one row: `existing.txt`.
2218        let initial = a
2219            .editor
2220            .buffers
2221            .document_handle(oil_id)
2222            .map(|h| h.snapshot().buffer.as_string());
2223        assert!(
2224            initial
2225                .as_ref()
2226                .map(|s| s.contains("existing.txt"))
2227                .unwrap_or(false),
2228            "expected `existing.txt` in initial listing: {:?}",
2229            initial,
2230        );
2231        // Dispatch an Insert action with text. In an oil buffer,
2232        // this should land in the oil rope, not the document.
2233        a.editor.modal = lattice_grammar::ModalState::Insert;
2234        a.apply(crate::app::Action::Insert("foo".into()));
2235        let after = a
2236            .editor
2237            .buffers
2238            .document_handle(oil_id)
2239            .map(|h| h.snapshot().buffer.as_string());
2240        assert!(
2241            after.as_ref().map(|s| s.contains("foo")).unwrap_or(false),
2242            "expected `foo` to land in oil rope: {:?}",
2243            after,
2244        );
2245
2246        let _ = std::fs::remove_dir_all(&tmp);
2247    }
2248
2249    #[test]
2250    fn oil_navigate_up_re_mirrors_dir_into_buffer_locals() {
2251        // Regression: pressing `-` inside an oil buffer
2252        // updated OilBuffer::dir but didn't re-mirror the
2253        // `OilDir` buffer-local. The next `<CR>` on a file
2254        // would read the stale `OilDir`, join with the new
2255        // entry's name, and produce a path that doesn't
2256        // exist.
2257        let tmp = std::env::temp_dir().join(format!(
2258            "lattice-oil-nav-up-mirror-test-{}",
2259            std::process::id()
2260        ));
2261        let _ = std::fs::remove_dir_all(&tmp);
2262        std::fs::create_dir_all(tmp.join("sub")).expect("sub");
2263        std::fs::write(tmp.join("a.txt"), "a").expect("a");
2264        std::fs::write(tmp.join("sub/inside.txt"), "i").expect("inside");
2265
2266        let mut a = app_with("hi", 5);
2267        // Open oil rooted at the subdir.
2268        a.do_open_oil(Some(tmp.join("sub")));
2269        let oil_id = a.active_pane_buffer_id();
2270        // Verify initial OilDir mirror.
2271        assert_eq!(
2272            a.editor
2273                .buffer_locals
2274                .get(&oil_id)
2275                .and_then(|l| l.get::<crate::modes::OilDir>())
2276                .map(|d| d.0.clone())
2277                .unwrap_or_default(),
2278            tmp.join("sub"),
2279        );
2280        // Navigate up.
2281        a.do_oil_navigate_up();
2282        // M.3.2.c.5: dir lives in the OilDir buffer-local
2283        // (single source of truth; no struct mirror to drift).
2284        let dir_after = a.oil_dir_for(oil_id).unwrap_or_default();
2285        assert_eq!(
2286            dir_after, tmp,
2287            "OilDir should reflect the post-navigate dir",
2288        );
2289
2290        // Sanity: open the file at row 0 (which should be
2291        // a.txt -- the first file alphabetically). The
2292        // resolved path uses the buffer-local OilDir; if
2293        // stale, the path is wrong and `do_edit` opens
2294        // something that doesn't exist (or worse, the wrong
2295        // file).
2296        a.editor.cursor.line = 0;
2297        a.editor.cursor.byte = 0;
2298        // Find a.txt's row (dirs come first; "sub" is dir, then "a.txt").
2299        let names: Vec<String> = a
2300            .editor
2301            .oil_snapshot_for(oil_id)
2302            .map(|s| {
2303                s.snapshot_entries()
2304                    .iter()
2305                    .map(|e| e.name.clone())
2306                    .collect::<Vec<String>>()
2307            })
2308            .unwrap_or_default();
2309        let a_txt_row = names
2310            .iter()
2311            .position(|n| n == "a.txt")
2312            .expect("a.txt in listing");
2313        a.editor.cursor.line = a_txt_row as u32;
2314        oil_follow(&mut a);
2315        let opened = a.editor.document.path().map(|p| p.to_path_buf());
2316        assert_eq!(opened, Some(tmp.join("a.txt")));
2317
2318        let _ = std::fs::remove_dir_all(&tmp);
2319    }
2320
2321    #[test]
2322    fn oil_open_with_relative_dir_stores_absolute_oil_dir() {
2323        // Regression for the navigate-up ENOENT: opening oil with
2324        // a relative dir used to store the relative path
2325        // verbatim. `Path::parent()` then returned `Some("")` for
2326        // single-component cases, and `read_dir("")` failed.
2327        // Post-fix `do_open_oil` normalises to absolute before
2328        // storing.
2329        let tmp =
2330            std::env::temp_dir().join(format!("lattice-oil-relative-dir-{}", std::process::id()));
2331        let _ = std::fs::remove_dir_all(&tmp);
2332        std::fs::create_dir_all(tmp.join("sub")).expect("sub");
2333        std::fs::write(tmp.join("sub/inside.txt"), "i").expect("inside");
2334
2335        // Construct a path that's relative to cwd but points
2336        // inside tmp. `tmp` itself is absolute (`/tmp/...`); we
2337        // simulate the "user typed a relative path" case by
2338        // building one explicitly. Skip the test if cwd lookup
2339        // fails (no realistic test environment).
2340        let Ok(cwd) = std::env::current_dir() else {
2341            return;
2342        };
2343        let Ok(rel) = tmp.join("sub").strip_prefix(&cwd).map(|p| p.to_path_buf()) else {
2344            // tmp isn't a descendant of cwd (likely the common
2345            // case: `/tmp/...` vs `/home/.../lattice`); craft
2346            // the relative path via `..` walks. We only need
2347            // the assertion to exercise the relative→absolute
2348            // pipeline; the underlying directory just has to
2349            // exist.
2350            let mut a = app_with("hi", 5);
2351            a.do_open_oil(Some(tmp.join("sub")));
2352            let oil_id = a.active_pane_buffer_id();
2353            let stored = a.oil_dir_for(oil_id).unwrap_or_default();
2354            assert!(
2355                stored.is_absolute(),
2356                "OilDir should always be absolute; got {stored:?}",
2357            );
2358            // Hit `-` once. Pre-fix this hit ENOENT for any
2359            // relative dir; post-fix it lands on the absolute
2360            // parent.
2361            a.do_oil_navigate_up();
2362            let after = a.oil_dir_for(oil_id).unwrap_or_default();
2363            assert_eq!(after, tmp, "navigate-up should land on tmp's absolute path");
2364            let _ = std::fs::remove_dir_all(&tmp);
2365            return;
2366        };
2367
2368        let mut a = app_with("hi", 5);
2369        // Pass the truly-relative form. `do_open_oil` should
2370        // resolve it against cwd before storing.
2371        a.do_open_oil(Some(rel.clone()));
2372        let oil_id = a.active_pane_buffer_id();
2373        let stored = a.oil_dir_for(oil_id).unwrap_or_default();
2374        assert!(
2375            stored.is_absolute(),
2376            "OilDir should always be absolute, even when opened relative; got {stored:?}",
2377        );
2378        // `-` walks up to tmp.
2379        a.do_oil_navigate_up();
2380        let after = a.oil_dir_for(oil_id).unwrap_or_default();
2381        assert_eq!(
2382            after, tmp,
2383            "navigate-up from relative-opened oil should reach the absolute parent",
2384        );
2385        let _ = std::fs::remove_dir_all(&tmp);
2386    }
2387
2388    #[test]
2389    fn oil_navigate_into_then_open_file_uses_correct_subdir_path() {
2390        // Companion regression: after `<CR>` on a subdir,
2391        // pressing `<CR>` on a file inside that subdir must
2392        // open `<parent>/<subdir>/<file>`, not
2393        // `<parent>/<file>` or similar.
2394        let tmp = std::env::temp_dir().join(format!(
2395            "lattice-oil-nav-into-then-open-test-{}",
2396            std::process::id()
2397        ));
2398        let _ = std::fs::remove_dir_all(&tmp);
2399        std::fs::create_dir_all(tmp.join("sub")).expect("sub");
2400        std::fs::write(tmp.join("sub/inside.txt"), "i").expect("inside");
2401
2402        let mut a = app_with("hi", 5);
2403        a.do_open_oil(Some(tmp.clone()));
2404        let oil_id = a.active_pane_buffer_id();
2405        // Find `sub` (dirs first; should be row 0).
2406        let names: Vec<String> = a
2407            .editor
2408            .oil_snapshot_for(oil_id)
2409            .map(|s| {
2410                s.snapshot_entries()
2411                    .iter()
2412                    .map(|e| e.name.clone())
2413                    .collect::<Vec<String>>()
2414            })
2415            .unwrap_or_default();
2416        let sub_row = names
2417            .iter()
2418            .position(|n| n == "sub")
2419            .expect("sub in listing");
2420        a.editor.cursor.line = sub_row as u32;
2421        a.editor.cursor.byte = 0;
2422        oil_follow(&mut a);
2423        // Now in oil rooted at tmp/sub (read via OilDir, the
2424        // canonical buffer-local).
2425        let dir_after = a.oil_dir_for(oil_id).unwrap_or_default();
2426        assert_eq!(dir_after, tmp.join("sub"));
2427        // Listing should show `inside.txt` at row 0.
2428        let names_after: Vec<String> = a
2429            .editor
2430            .oil_snapshot_for(oil_id)
2431            .map(|s| {
2432                s.snapshot_entries()
2433                    .iter()
2434                    .map(|e| e.name.clone())
2435                    .collect()
2436            })
2437            .unwrap_or_default();
2438        let inside_row = names_after
2439            .iter()
2440            .position(|n| n == "inside.txt")
2441            .expect("inside.txt in listing");
2442        a.editor.cursor.line = inside_row as u32;
2443        oil_follow(&mut a);
2444        let opened = a.editor.document.path().map(|p| p.to_path_buf());
2445        assert_eq!(opened, Some(tmp.join("sub/inside.txt")));
2446
2447        let _ = std::fs::remove_dir_all(&tmp);
2448    }
2449
2450    #[test]
2451    fn oil_follow_uses_app_cursor_not_oil_internal_cursor() {
2452        // Regression: `<CR>` in an oil buffer used to always
2453        // open the first row's item regardless of where the
2454        // user had moved with `j` / `k`. Root cause: the
2455        // OilBuffer's own `cursor` field is never synced to
2456        // `app.editor.cursor`; `entry_at_cursor()` read the stale
2457        // internal field and always indexed snapshot[0]. The
2458        // fix routes through `self.editor.cursor.line` (the App's
2459        // hot-path cursor, same surface the user moves).
2460        let tmp = std::env::temp_dir().join(format!(
2461            "lattice-oil-follow-cursor-test-{}",
2462            std::process::id()
2463        ));
2464        let _ = std::fs::remove_dir_all(&tmp);
2465        std::fs::create_dir_all(&tmp).expect("tmp");
2466        // Three files: alpha.txt, beta.txt, gamma.txt. Listed
2467        // alphabetically.
2468        std::fs::write(tmp.join("alpha.txt"), "a").expect("a");
2469        std::fs::write(tmp.join("beta.txt"), "b").expect("b");
2470        std::fs::write(tmp.join("gamma.txt"), "g").expect("g");
2471
2472        let mut a = app_with("hi", 5);
2473        a.do_open_oil(Some(tmp.clone()));
2474        let oil_id = a.active_pane_buffer_id();
2475        // Snapshot order: alpha, beta, gamma.
2476        let names: Vec<String> = a
2477            .editor
2478            .oil_snapshot_for(oil_id)
2479            .map(|s| {
2480                s.snapshot_entries()
2481                    .iter()
2482                    .map(|e| e.name.clone())
2483                    .collect::<Vec<String>>()
2484            })
2485            .unwrap_or_default();
2486        assert_eq!(
2487            names,
2488            vec![
2489                "alpha.txt".to_string(),
2490                "beta.txt".into(),
2491                "gamma.txt".into()
2492            ],
2493        );
2494
2495        // Move the cursor to row 2 (gamma.txt). Pre-fix: follow
2496        // would open alpha.txt regardless.
2497        a.editor.cursor.line = 2;
2498        a.editor.cursor.byte = 0;
2499        oil_follow(&mut a);
2500
2501        // Follow on a file routes through `do_edit`, which
2502        // opens the file as a Document. The active buffer's
2503        // path should be gamma.txt, not alpha.txt.
2504        assert_eq!(a.editor.active_buffer, BufferKind::Document);
2505        let active_path = a.editor.document.path().map(|p| p.to_path_buf());
2506        assert_eq!(
2507            active_path,
2508            Some(tmp.join("gamma.txt")),
2509            "follow should have opened the file under the App's cursor",
2510        );
2511
2512        let _ = std::fs::remove_dir_all(&tmp);
2513    }
2514
2515    #[test]
2516    fn oil_navigate_into_subdir_replaces_listing() {
2517        let tmp = std::env::temp_dir().join(format!("lattice-oil-nav-test-{}", std::process::id()));
2518        let _ = std::fs::remove_dir_all(&tmp);
2519        std::fs::create_dir_all(tmp.join("subdir")).expect("create subdir");
2520        std::fs::write(tmp.join("subdir/inner.txt"), "hi").expect("write inner");
2521
2522        let mut a = app_with("hi", 5);
2523        a.do_open_oil(Some(tmp.clone()));
2524        let oil_id = a.active_pane_buffer_id();
2525
2526        // The rope content lists `subdir` (and `..`).
2527        let listing_before = a
2528            .editor
2529            .buffers
2530            .document_handle(oil_id)
2531            .map(|h| h.snapshot().buffer.as_string());
2532        assert!(
2533            listing_before
2534                .as_ref()
2535                .map(|s| s.contains("subdir"))
2536                .unwrap_or(false),
2537            "expected `subdir` in initial listing: {:?}",
2538            listing_before,
2539        );
2540
2541        // Move cursor to the subdir entry. The exact line index
2542        // depends on sort: dirs first, so subdir is at 0 (or 1
2543        // if `..` is included). Let's find it.
2544        let snap: Vec<String> = a
2545            .editor
2546            .oil_snapshot_for(oil_id)
2547            .map(|s| {
2548                s.snapshot_entries()
2549                    .iter()
2550                    .map(|e| e.name.clone())
2551                    .collect()
2552            })
2553            .expect("oil");
2554        let subdir_line = snap
2555            .iter()
2556            .position(|n| n == "subdir")
2557            .expect("subdir in snapshot");
2558        a.editor.cursor.line = subdir_line as u32;
2559        a.editor.cursor.byte = 0;
2560        oil_follow(&mut a);
2561
2562        // Listing now shows subdir's contents (`inner.txt`).
2563        let listing_after = a
2564            .editor
2565            .buffers
2566            .document_handle(oil_id)
2567            .map(|h| h.snapshot().buffer.as_string());
2568        assert!(
2569            listing_after
2570                .as_ref()
2571                .map(|s| s.contains("inner.txt"))
2572                .unwrap_or(false),
2573            "after navigate-into, expected `inner.txt`: {:?}",
2574            listing_after,
2575        );
2576
2577        // The buffer-locals dir reflects the new location.
2578        let dir = a
2579            .editor
2580            .buffer_locals
2581            .get(&oil_id)
2582            .and_then(|l| l.get::<crate::modes::OilDir>())
2583            .map(|d| d.0.clone())
2584            .expect("OilDir present");
2585        assert_eq!(dir, tmp.join("subdir"));
2586
2587        let _ = std::fs::remove_dir_all(&tmp);
2588    }
2589
2590    /// LM.2: `Effect::OilNavigate` re-lists the named oil buffer in place
2591    /// (the peer path the LM.3 handler emits), and a SECOND oil buffer is
2592    /// byte-for-byte untouched — the everything-is-a-buffer independence
2593    /// contract (design §3.2). The navigated view is the ACTIVE one, as it
2594    /// is when a chord fires; `other` stays inactive and must not move.
2595    #[test]
2596    fn oil_navigate_effect_relists_only_the_named_view() {
2597        let tmp = std::env::temp_dir().join(format!("lattice-lm2-oilnav-{}", std::process::id()));
2598        let _ = std::fs::remove_dir_all(&tmp);
2599        // Distinct roots so `do_open_oil` does not dedup the two buffers.
2600        let dir_view = tmp.join("view");
2601        let dir_other = tmp.join("other");
2602        std::fs::create_dir_all(dir_view.join("subdir")).unwrap();
2603        std::fs::write(dir_view.join("subdir/inner.txt"), "hi").unwrap();
2604        std::fs::create_dir_all(&dir_other).unwrap();
2605        std::fs::write(dir_other.join("othermark.txt"), "o").unwrap();
2606
2607        let mut a = app_with("hi", 5);
2608        // `other` first (tab 0), then `view` in a new tab so `view` is active.
2609        a.do_open_oil(Some(dir_other.clone()));
2610        let other = a.active_pane_buffer_id();
2611        let other_before = a
2612            .editor
2613            .buffers
2614            .document_handle(other)
2615            .map(|h| h.snapshot().buffer.as_string());
2616
2617        a.editor.do_new_tab();
2618        a.do_open_oil(Some(dir_view.clone()));
2619        let view = a.active_pane_buffer_id();
2620        assert_ne!(view, other, "two distinct oil buffers");
2621
2622        let subdir = dir_view.join("subdir");
2623        a.apply_effect(lattice_grammar::Effect::OilNavigate {
2624            view,
2625            dir: subdir.clone(),
2626            focus: None,
2627        });
2628
2629        let listing = a
2630            .editor
2631            .buffers
2632            .document_handle(view)
2633            .map(|h| h.snapshot().buffer.as_string())
2634            .unwrap_or_default();
2635        assert!(
2636            listing.contains("inner.txt"),
2637            "the navigated view re-listed to subdir: {listing:?}",
2638        );
2639        assert_eq!(
2640            a.editor.oil_dir_for(view).as_deref(),
2641            Some(subdir.as_path())
2642        );
2643
2644        let other_after = a
2645            .editor
2646            .buffers
2647            .document_handle(other)
2648            .map(|h| h.snapshot().buffer.as_string());
2649        assert_eq!(
2650            other_before, other_after,
2651            "the second oil buffer must be unchanged by navigating the first",
2652        );
2653
2654        let _ = std::fs::remove_dir_all(&tmp);
2655    }
2656
2657    /// LM.2: `Effect::FileTreeToggle` expands the named tree's directory
2658    /// row in place, and a SECOND tree is untouched (independence §3.2).
2659    #[test]
2660    fn file_tree_toggle_effect_expands_only_the_named_view() {
2661        let tmp = std::env::temp_dir().join(format!("lattice-lm2-fttoggle-{}", std::process::id()));
2662        let _ = std::fs::remove_dir_all(&tmp);
2663        // Distinct roots so `do_open_file_tree` does not dedup the two trees.
2664        let dir_view = tmp.join("view");
2665        let dir_other = tmp.join("other");
2666        std::fs::create_dir_all(dir_view.join("subdir")).unwrap();
2667        std::fs::write(dir_view.join("subdir/inner.txt"), "hi").unwrap();
2668        std::fs::create_dir_all(&dir_other).unwrap();
2669        std::fs::write(dir_other.join("othermark.txt"), "o").unwrap();
2670
2671        let mut a = app_with("hi", 5);
2672        // `other` tree first (inactive), then `view` tree active.
2673        a.do_open_file_tree(Some(dir_other.clone()));
2674        let other = a.active_pane_buffer_id();
2675        let other_before = a
2676            .editor
2677            .buffers
2678            .document_handle(other)
2679            .map(|h| h.snapshot().buffer.as_string());
2680
2681        a.editor.do_new_tab();
2682        a.do_open_file_tree(Some(dir_view.clone()));
2683        let view = a.active_pane_buffer_id();
2684        assert_ne!(view, other, "two distinct file-tree buffers");
2685
2686        // Find the `subdir` directory row.
2687        let entries = a.editor.file_tree_entries_for(view).expect("tree entries");
2688        let subdir_idx = entries
2689            .iter()
2690            .position(|e| {
2691                e.path.file_name().map(|n| n == "subdir").unwrap_or(false)
2692                    && matches!(
2693                        e.kind,
2694                        lattice_listing::file_tree::FileTreeEntryKind::Directory { .. }
2695                    )
2696            })
2697            .expect("subdir row present");
2698
2699        a.apply_effect(lattice_grammar::Effect::FileTreeToggle {
2700            view,
2701            entry_index: subdir_idx as u32,
2702        });
2703
2704        let listing = a
2705            .editor
2706            .buffers
2707            .document_handle(view)
2708            .map(|h| h.snapshot().buffer.as_string())
2709            .unwrap_or_default();
2710        assert!(
2711            listing.contains("inner.txt"),
2712            "toggling subdir expands it to show inner.txt: {listing:?}",
2713        );
2714
2715        let other_after = a
2716            .editor
2717            .buffers
2718            .document_handle(other)
2719            .map(|h| h.snapshot().buffer.as_string());
2720        assert_eq!(
2721            other_before, other_after,
2722            "the second tree must be unchanged by toggling the first",
2723        );
2724
2725        let _ = std::fs::remove_dir_all(&tmp);
2726    }
2727
2728    /// LM.3 (integration): pressing `<C-s>` on a file row in an oil buffer
2729    /// opens that file in a NEW split — the full chord → oil-mode keymap →
2730    /// `action:oil-follow-split` handler → `Effect::OpenInTarget` → pane
2731    /// split path, proven end to end (not just the effect). This is the
2732    /// keybinding the whole LM series is for.
2733    #[tokio::test(flavor = "multi_thread")]
2734    async fn oil_ctrl_s_opens_the_file_under_cursor_in_a_split() {
2735        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2736        let tmp = std::env::temp_dir().join(format!("lattice-lm3-ctrls-{}", std::process::id()));
2737        let _ = std::fs::remove_dir_all(&tmp);
2738        std::fs::create_dir_all(&tmp).unwrap();
2739        std::fs::write(tmp.join("file.txt"), "hi").unwrap();
2740
2741        let mut a = app_with("scratch\n", 20);
2742        a.do_open_oil(Some(tmp.clone()));
2743        // oil-mode activates through the async cascade; wait so its keymap
2744        // layer is live before the press.
2745        assert!(
2746            settle_mode(&mut a, "oil-mode").await,
2747            "oil-mode must activate on the oil buffer",
2748        );
2749        let before_panes = a.editor.pane_tree.len();
2750        let id = a.editor.active_pane_buffer_id();
2751        let line = a
2752            .editor
2753            .oil_snapshot_for(id)
2754            .and_then(|s| {
2755                s.snapshot_entries()
2756                    .iter()
2757                    .position(|e| e.name == "file.txt")
2758            })
2759            .expect("file.txt row present");
2760        a.editor.cursor.line = line as u32;
2761
2762        press(
2763            &mut a,
2764            KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
2765        );
2766
2767        assert_eq!(
2768            a.editor.pane_tree.len(),
2769            before_panes + 1,
2770            "<C-s> opened the file in a new split",
2771        );
2772        assert_eq!(
2773            a.editor.document.path().map(|p| p.to_path_buf()),
2774            Some(tmp.join("file.txt")),
2775            "the new split shows the file under the cursor",
2776        );
2777        let _ = std::fs::remove_dir_all(&tmp);
2778    }
2779
2780    /// LM.4 (integration): pressing `<C-s>` on a file row in a file-tree
2781    /// buffer opens that file in a NEW split — chord → file-tree-mode keymap
2782    /// → `action:file-tree-follow-split` → `Effect::OpenInTarget` → pane
2783    /// split, end to end.
2784    #[tokio::test(flavor = "multi_thread")]
2785    async fn file_tree_ctrl_s_opens_the_file_under_cursor_in_a_split() {
2786        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2787        let tmp = std::env::temp_dir().join(format!("lattice-lm4-ctrls-{}", std::process::id()));
2788        let _ = std::fs::remove_dir_all(&tmp);
2789        std::fs::create_dir_all(&tmp).unwrap();
2790        std::fs::write(tmp.join("file.txt"), "hi").unwrap();
2791
2792        let mut a = app_with("scratch\n", 20);
2793        a.do_open_file_tree(Some(tmp.clone()));
2794        assert!(
2795            settle_mode(&mut a, "file-tree-mode").await,
2796            "file-tree-mode must activate on the tree buffer",
2797        );
2798        let before_panes = a.editor.pane_tree.len();
2799        let id = a.editor.active_pane_buffer_id();
2800        let line = a
2801            .editor
2802            .file_tree_entries_for(id)
2803            .and_then(|es| {
2804                es.iter()
2805                    .position(|e| e.path.file_name().map(|n| n == "file.txt").unwrap_or(false))
2806            })
2807            .expect("file.txt row present");
2808        a.editor.cursor.line = line as u32;
2809
2810        press(
2811            &mut a,
2812            KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
2813        );
2814
2815        assert_eq!(
2816            a.editor.pane_tree.len(),
2817            before_panes + 1,
2818            "<C-s> opened the file in a new split",
2819        );
2820        assert_eq!(
2821            a.editor.document.path().map(|p| p.to_path_buf()),
2822            Some(tmp.join("file.txt")),
2823            "the new split shows the file under the cursor",
2824        );
2825        let _ = std::fs::remove_dir_all(&tmp);
2826    }
2827
2828    #[test]
2829    fn open_oil_seeds_oil_locals() {
2830        let tmp = std::env::temp_dir().join(format!("lattice-m3-2-c-3-{}", std::process::id()));
2831        let _ = std::fs::create_dir_all(&tmp);
2832
2833        let mut a = app_with("hi", 5);
2834        a.do_open_oil(Some(tmp.clone()));
2835        let oil_id = a.active_pane_buffer_id();
2836
2837        let locals = a
2838            .editor
2839            .buffer_locals
2840            .get(&oil_id)
2841            .expect("oil locals seeded");
2842        let dir = locals
2843            .get::<crate::modes::OilDir>()
2844            .expect("OilDir local present");
2845        assert_eq!(dir.0, tmp);
2846
2847        // Owner-mode metadata.
2848        let descriptors: Vec<_> = locals.iter_descriptors().collect();
2849        let oil_descriptors: Vec<_> = descriptors
2850            .iter()
2851            .filter(|d| d.owner_mode == "oil-mode")
2852            .collect();
2853        // DL.5: oil owns TWO locals now — the directory it points at,
2854        // and the snapshot `:w` diffs the buffer's text against (which
2855        // moved off the deleted `OilBuffer` struct). Asserted by name
2856        // rather than by count, so a future local has to be named here
2857        // rather than just bumping a number.
2858        let mut oil_names: Vec<&str> = oil_descriptors.iter().map(|d| d.name).collect();
2859        oil_names.sort_unstable();
2860        assert_eq!(oil_names, vec!["oil-mode.dir", "oil-mode.snapshot"]);
2861
2862        let _ = std::fs::remove_dir_all(&tmp);
2863    }
2864
2865    #[test]
2866    fn pane_status_label_falls_back_to_registry_name_for_synthetic_document() {
2867        // Slice A: a Document with no path but a synthetic name
2868        // (the shape `*lsp*`/`*messages*` will use once they migrate
2869        // out of HelpContent) shows the name in the modeline.
2870        let a = app_with("hi", 5);
2871        let active = a.active_pane_buffer_id();
2872        // Drop the existing entry; replace it with a no-path Document
2873        // carrying a synthetic name. Reuse the same RopeDocumentHandle
2874        // because the test fixture's `app_with` already produces an
2875        // unsaved buffer (`handle.path()` is None).
2876        let handle = a.editor.document.as_arc();
2877        a.editor.buffers.remove(active);
2878        a.editor.buffers.insert(BufferEntry {
2879            id: active,
2880            flags: BufferFlags::default(),
2881            data: BufferData::Document(DocumentEntry { id: active, handle }),
2882            name: Some("*lsp*".to_string()),
2883        });
2884        let pane = *a.editor.pane_tree.active();
2885        let label = a.pane_status_label(&pane);
2886        assert!(
2887            label.contains("*lsp*"),
2888            "expected modeline to surface synthetic name, got `{label}`"
2889        );
2890    }
2891
2892    #[test]
2893    fn buffer_picker_accept_then_ctrl_o_walks_back_to_origin() {
2894        // Bug: the `:b` picker preview-activates the candidate
2895        // (with `previewing=true`, so activate_buffer skips the
2896        // history push), and the accept path short-circuits when
2897        // the preview already moved us there. Without an
2898        // explicit push at picker-open, the position history
2899        // never captured the origin -- `<C-o>` echoed "no jumps".
2900        // Fix pushes the origin at `open_buffer_picker`.
2901        let mut a = app_with("alpha\nbeta\ngamma\n", 10);
2902        let origin = a.active_pane_buffer_id();
2903        a.editor.cursor = Position::new(1, 2);
2904        a.open_buffer_picker();
2905        // Position history should already have the origin entry
2906        // from the picker-open push, before any preview activates.
2907        let last = a.editor.position_history.last().expect("origin entry");
2908        assert_eq!(last.position, Position::new(1, 2));
2909        assert_eq!(last.buffer_id, origin);
2910        // Dismiss the picker so we're not stuck on a preview.
2911        a.apply(Action::PickerDismiss);
2912        // From the origin, `<C-o>` finds the pushed entry and
2913        // remains on the same buffer + position (idempotent).
2914        // The real value is on the accept path -- but the
2915        // entry must exist for the walker to find it.
2916        let history_len_before = a.editor.position_history.len();
2917        assert!(history_len_before >= 1);
2918    }
2919
2920    #[test]
2921    fn activate_buffer_pushes_position_history_so_ctrl_o_walks_back() {
2922        // Switching to any buffer should push the pre-jump cursor
2923        // onto position history automatically -- the user shouldn't
2924        // have to remember to do this at every call site.
2925        // `<C-o>` from the new buffer must walk back to the
2926        // previous one's cursor.
2927        let mut a = app_with("alpha\nbeta\ngamma\n", 10);
2928        let initial = a.active_pane_buffer_id();
2929        // Position cursor somewhere distinctive.
2930        a.editor.cursor = Position::new(1, 2);
2931        // Boot creates *lsp* and *messages*; activate *lsp*.
2932        let lsp_id = a.editor.buffers.by_name("*lsp*").unwrap();
2933        assert_ne!(initial, lsp_id);
2934        a.activate_buffer(lsp_id);
2935        // Position history should contain the pre-jump entry.
2936        let last = a.editor.position_history.last().expect("history entry");
2937        assert_eq!(last.position, Position::new(1, 2));
2938        assert_eq!(last.buffer_id, initial);
2939        // `<C-o>` walks back to the original buffer.
2940        a.apply(Action::JumpHistoryBack);
2941        assert_eq!(a.active_pane_buffer_id(), initial);
2942        assert_eq!(a.editor.cursor, Position::new(1, 2));
2943    }
2944
2945    #[test]
2946    fn pane_status_label_suppresses_dirty_marker_for_synthetic_documents() {
2947        // Synthetic buffers (`*lsp*`, `*messages*`, ...) are
2948        // owner-streamed; their content arrives via subsystem
2949        // appends, not user edits. The Document's underlying dirty
2950        // flag fires on every append, which is meaningful for
2951        // path-backed Documents but misleading for synthetic ones
2952        // -- the user can't "save" the streaming state. The
2953        // modeline must suppress the `[+]` marker for these.
2954        let a = app_with("hi", 5);
2955        let active = a.active_pane_buffer_id();
2956        let handle = a.editor.document.as_arc();
2957        a.editor.buffers.remove(active);
2958        a.editor.buffers.insert(BufferEntry {
2959            id: active,
2960            flags: BufferFlags::default(),
2961            data: BufferData::Document(DocumentEntry {
2962                id: active,
2963                handle: handle.clone(),
2964            }),
2965            name: Some("*lsp*".to_string()),
2966        });
2967        // Force the underlying document dirty: an apply_edit
2968        // advances undo depth past the clean position. The fresh
2969        // test fixture starts clean; an append makes it dirty.
2970        let snap = handle.snapshot();
2971        let last_line = crate::app::last_addressable_line(&snap.buffer);
2972        let line_len = crate::app::line_byte_len(&snap.buffer, last_line);
2973        let pos = Position::new(last_line, line_len);
2974        let _ = lattice_runtime::block_on(
2975            handle.apply_edit_batch(vec![lattice_protocol::edit::Edit::insert(pos, "x")]),
2976        );
2977        assert!(handle.dirty(), "fixture must produce a dirty Document");
2978        let pane = *a.editor.pane_tree.active();
2979        let label = a.pane_status_label(&pane);
2980        assert!(
2981            label.contains("*lsp*"),
2982            "modeline must surface synthetic name; got `{label}`"
2983        );
2984        assert!(
2985            !label.contains("[+]"),
2986            "modeline must NOT surface [+] for synthetic buffers; got `{label}`"
2987        );
2988    }
2989}