Skip to main content

strop_engine/editor/
mod.rs

1//! The editor state: modes, pending keys, named registers, buffers.
2//! One input path for TUI and headless — both call `feed`.
3//!
4//! Mode handlers live beside this file: `normal`, `visual`, `insert`.
5
6mod blame;
7pub mod block;
8#[cfg(test)]
9pub mod conformance;
10#[cfg(test)]
11pub mod contract_probes;
12pub mod trace;
13
14pub(crate) mod analysis;
15mod changes;
16pub use changes::review::ReviewRow;
17pub use dispatch::InputOwner;
18pub mod collections;
19mod containers;
20mod cursor;
21mod diagnostics;
22mod dispatch;
23pub mod resolution;
24pub use diagnostics::DocumentDiagnostics;
25mod dive;
26mod document;
27pub mod events;
28mod explain;
29mod git;
30mod git_memory;
31mod help;
32mod indent;
33mod input;
34mod insert;
35pub mod io;
36mod jumps;
37pub mod keys;
38mod lsp;
39pub mod macros;
40pub(crate) mod matching;
41#[cfg(test)]
42mod multicursor_tests;
43pub(crate) mod normal;
44mod occurrence;
45#[cfg(test)]
46mod occurrence_tests;
47mod panes;
48pub mod pending;
49mod permalink;
50mod picker;
51mod registers;
52pub mod remote;
53mod remote_completion;
54mod shell;
55pub mod transact;
56mod undo;
57pub mod view;
58mod visual;
59mod workspaces;
60
61pub use collections::{CollectionRow, CollectionRowInfo};
62pub use document::Document;
63pub use document::{DiffRow, DocumentSource, RemoteDirectory, RemoteDocument, Surface};
64pub use git_memory::{git_channel, BlameGutter, GitJob};
65pub use git_memory::{CommitFiles, PreparedDiff, PreparedFiles, Sidebar, SidebarRow};
66pub use panes::{LayoutDir, Pane};
67pub use picker::{
68    checked_hit_range, PickerGlue, PreviewKey, PreviewResult, PreviewSource, Previews,
69    ReplacementHit, SearchScope,
70};
71pub use registers::{ClipboardKey, ClipboardResult, Register};
72pub use shell::{ShellIntent, ShellKey, ShellResult};
73
74pub use containers::{ContainerKey, ContainerResult};
75pub use lsp::attach::AttachRecord;
76pub use lsp::LspServer;
77pub use permalink::PendingPermalink;
78pub use picker::ranking::Event as RankingEvent;
79pub use remote::{RemoteEvent, RemoteView};
80pub use remote_completion::{RemoteCompletionKey, RemoteCompletionResult};
81pub use resolution::{ResolutionEvent, ResolutionState};
82use std::collections::HashMap;
83use std::path::PathBuf;
84use std::time::Duration;
85pub use workspaces::WorkspaceRegistry;
86
87use strop_core::{Buffer, Range};
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Mode {
91    Normal,
92    Insert,
93    Visual,
94    VisualLine,
95    /// ctrl-v: the rectangle selection (0017).
96    VisualBlock,
97}
98
99impl Mode {
100    pub fn chip(self) -> &'static str {
101        match self {
102            Mode::Normal => "NORMAL",
103            Mode::Insert => "INSERT",
104            Mode::Visual => "VISUAL",
105            Mode::VisualLine => "V-LINE",
106            Mode::VisualBlock => "V-BLOCK",
107        }
108    }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112pub enum Key {
113    Char(char),
114    Esc,
115    Enter,
116    Backspace,
117    Up,
118    Down,
119    Left,
120    Right,
121    Tab,
122    Backtab,
123    /// Replace picker: exclude the row's whole file (vscode's toggle).
124    CtrlD,
125    CtrlR,
126    /// vim's jump-back (ctrl-i forward is Tab in a terminal).
127    CtrlO,
128    /// ctrl-space: query suggestions in a query field (0051 R02).
129    CtrlSpace,
130    CtrlW,
131    /// Replace picker: exclude/include the selected match (0007 §2).
132    CtrlX,
133    /// vim ctrl-u/ctrl-f/ctrl-b: half/full page up.
134    CtrlU,
135    CtrlF,
136    CtrlB,
137    /// vim ctrl-^: alternate buffer.
138    CtrlCaret,
139    /// vim ctrl-v: visual block mode.
140    CtrlV,
141    /// vim ctrl-l: force a full terminal repaint (desync recovery).
142    CtrlL,
143}
144
145pub const FLASH_FOR: Duration = Duration::from_millis(280);
146
147/// The injected frame renderer's shape (0046): editor, columns, rows,
148/// record-action flag — the binary's implementation renders via TestBackend.
149pub type FrameDraw = fn(&mut Editor, u16, u16, bool) -> std::io::Result<()>;
150
151pub struct Editor {
152    pub docs: strop_core::id::Arena<strop_core::id::DocumentKind, Document>,
153    pub(crate) trace_documents:
154        HashMap<strop_core::id::DocumentId, strop_core::diagnostics::BufferTraceId>,
155    pub io: io::IoState,
156    pub(crate) remote: remote::RemoteState,
157    pub(crate) remote_completion: remote_completion::RemoteCompletionState,
158    pub(crate) worker_ids: strop_core::worker::WorkerIds,
159    pub(crate) worker_handles:
160        HashMap<strop_core::worker::WorkerId, strop_core::worker::CancelHandle>,
161    pub(crate) focus_epoch: u64,
162    pub(crate) finishing: bool,
163    pub tape: std::rc::Rc<strop_trace::replay::Tape>,
164    pub git_view: strop_core::worker::WorkerId,
165    pub git_discovery: strop_core::worker::Load<git_memory::ContextKey>,
166    pub hunk_load: strop_core::worker::Load<git_memory::HunkKey>,
167    pub hunks_untracked: bool,
168    pub log_requests:
169        HashMap<strop_core::id::DocumentId, strop_core::worker::Ticket<git_memory::LogKey>>,
170    pub card_request: Option<strop_core::worker::Ticket<git_memory::CardKey>>,
171    pub dive_requests:
172        HashMap<strop_core::id::DocumentId, strop_core::worker::Ticket<git_memory::DiveKey>>,
173    pub git_mutations: std::collections::VecDeque<git_memory::GitMutation>,
174    pub git_mutation: Option<strop_core::worker::Ticket<git_memory::MutationKey>>,
175    /// vim's jumplist (ctrl-o/ctrl-i): past/future stacks of named
176    /// navigation/view records — caret, selection, viewport and
177    /// horizontal origin (0051 §7, jumps.rs).
178    pub jumplist_past: Vec<jumps::JumpRecord>,
179    pub jumplist_future: Vec<jumps::JumpRecord>,
180    pub mode: Mode,
181    pub pending: pending::PendingInput,
182    /// The input walker (0008 stage 2): typed parser state for
183    /// counts/registers/operators/prefixes — pending stays for the
184    /// free-text lines only.
185    pub walker: input::Walker,
186    /// `Space u` browser state (editor/undo.rs); None when closed.
187    pub undo_browser: Option<undo::UndoBrowser>,
188    /// Last `f/F/t/T` find: (char, backward, till). `;` and `,` replay it.
189    pub last_find: Option<(char, bool, bool)>,
190    /// Armed by `/`/`?`/`*`/`#` searches. `n`/`N` replay it; the render
191    /// highlights matches persistently (rootle: current match underlined).
192    pub last_search: Option<LastSearch>,
193    /// Live occurrence selection (0049 §7): needle + add-order ranges.
194    pub(crate) occurrence: Option<occurrence::OccurrenceState>,
195    pub registers: HashMap<char, Register>,
196    /// Marks: char → (document, byte offset). `m{a}` sets, `'{a}` jumps.
197    pub marks: HashMap<char, (strop_core::id::DocumentId, usize)>,
198    pub flash: Option<(Range, strop_trace::replay::Tick)>,
199    pub message: String,
200    pub should_quit: bool,
201    /// ctrl-c is armed after the first warn (0015 quit policy).
202    pub ctrl_c_armed: bool,
203    /// Last visual range for `gv` (recorded per visual-mode key).
204    pub last_visual: Option<(usize, usize)>,
205    /// Where the last insert session was for `gi`.
206    pub last_insert_pos: Option<usize>,
207    /// g;/g, walk: (index, history depth it was taken at) — a new
208    /// commit invalidates the walk.
209    pub change_idx: Option<(usize, usize)>,
210    /// Text area height in rows — the render loop feeds it via
211    /// scroll_to_cursor; viewport motions read it.
212    pub view_rows: usize,
213    /// Macro recording (0016): the register being recorded into.
214    pub recording: Option<char>,
215    /// The app event channel (0018): set by connect_events; late LSP
216    /// attaches forward through it.
217    pub app_tx: Option<events::EventSender>,
218    pub(crate) lsp_state: lsp::state::LspState,
219    /// Recorded macros: register → key events.
220    pub macros: std::collections::HashMap<char, Vec<Key>>,
221    /// The last replayed macro register (@@).
222    pub last_macro: Option<char>,
223    /// The rows and cell edge owned by an ongoing block insert/change.
224    pub(crate) block_insert_state: Option<block::BlockInsertState>,
225    /// Macro self-replay depth guard.
226    pub macro_depth: usize,
227    pub picker: Option<PickerGlue>,
228    pub(crate) retained_search: Option<PickerGlue>,
229    pub(crate) picker_ranking: picker::ranking::State,
230    pub(crate) analysis: analysis::AnalysisState,
231    pub resolution: resolution::ResolutionState,
232    pub cwd: PathBuf,
233    /// Bound workspace contexts (0042 slice 2): one per filesystem in use.
234    pub workspaces: workspaces::WorkspaceRegistry,
235    /// Applied change plans and their receipts (0043); grouped undo reads it.
236    pub(crate) changes: changes::ChangeState,
237    pub(crate) review: changes::review::ReviewState,
238    /// Open editable code collections by their buffer document (0044).
239    pub(crate) collections: HashMap<strop_core::id::DocumentId, collections::Collection>,
240    /// A build waiting on background source loads (0044 v2).
241    pub(crate) collection_build: Option<collections::CollectionBuild>,
242    /// Container attach/browse state (0037 DC1a).
243    pub(crate) containers: containers::ContainerState,
244    /// MRU document order (most recent first); drives `Space b`.
245    pub mru: Vec<strop_core::id::DocumentId>,
246    /// Picker preview file cache.
247    pub previews: Previews,
248    /// Git working surface state (M2).
249    pub git: Option<strop_git::GitContext>,
250    /// Preview file reads run on worker threads (0001 §3); results and
251    /// the in-flight set are drained in drain_picker.
252    pub preview_tx: std::sync::mpsc::Sender<PreviewResult>,
253    pub preview_rx: Option<std::sync::mpsc::Receiver<PreviewResult>>,
254    pub(crate) preview_loads: HashMap<PathBuf, strop_core::worker::Load<PreviewKey>>,
255    pub hunks: git_memory::HunkSet,
256    /// HEAD↔index — the staged set (0014 wave 4); rendered in the
257    /// gutter's committed-adjacent color.
258    pub staged_hunks: git_memory::HunkSet,
259    /// Git memory (M3): per-buffer surface kinds, blame card, job channel,
260    /// OSC52 clipboard payload drained by the TUI.
261    pub blame_card: Option<strop_git::memory::BlameCard>,
262    /// Each full/range/tail document owns its own revision-checked gutter.
263    pub blame_gutters: HashMap<strop_core::id::DocumentId, BlameGutter>,
264    /// Bumped on every buffer-list mutation; git jobs carry the
265    /// generation they were spawned under so results for dead
266    /// surfaces are dropped (0011 §2).
267    pub generation: u64,
268    pub git_tx: std::sync::mpsc::Sender<GitJob>,
269    pub git_rx: Option<std::sync::mpsc::Receiver<GitJob>>,
270    pub osc52: Option<String>,
271    pub terminal_output: Vec<String>,
272    /// ctrl-l: the terminal desynced from the model — the draw loop
273    /// answers with a full repaint (vim's redraw).
274    pub needs_repaint: bool,
275    /// System-clipboard reads (paste from `+`) run on a worker thread;
276    /// `clip_paste_pending` remembers before/after AND the initiating
277    /// document until the read lands (0023 §4).
278    pub clip_tx: std::sync::mpsc::Sender<ClipboardResult>,
279    pub clip_rx: Option<std::sync::mpsc::Receiver<ClipboardResult>>,
280    pub clip_paste_pending: Option<(bool, strop_core::worker::Ticket<ClipboardKey>)>,
281    /// LSP server pool (0014 wave 2): one client per (workspace root,
282    /// server) — a rust file and a python file in one session get their
283    /// own servers. Diagnostics by path, hover card, open bookkeeping.
284    pub lsp_servers: Vec<crate::editor::lsp::LspServer>,
285    pub diags: HashMap<strop_core::id::DocumentId, DocumentDiagnostics>,
286    pub hover_card: Option<String>,
287    /// Shell jobs (`:!cmd` output buffers, `|cmd` pipes): results land
288    /// in drain_shell — never a subprocess on the input path (0001 §3).
289    pub shell_tx: std::sync::mpsc::Sender<ShellResult>,
290    pub shell_rx: Option<std::sync::mpsc::Receiver<ShellResult>>,
291    pub(crate) shell_requests: HashMap<strop_core::worker::WorkerId, ShellIntent>,
292    pub(crate) shell_focus: Option<strop_core::worker::WorkerId>,
293    /// Splits: flat row/column of panes (v1; tree layout later).
294    pub panes: Vec<Pane>,
295    pub active_pane: usize,
296    pub layout: LayoutDir,
297    /// User config (0005-lite: TOML, embedded defaults, never bricks).
298    pub config: crate::config::Config,
299    /// Shared state root for explicit trust and optional session persistence.
300    pub state_dir: Option<PathBuf>,
301    pub session_policy: crate::session::SessionPolicy,
302    /// The last grammar-level change (dot-repeat's semantic form).
303    pub(crate) last_change: Option<strop_grammar::Command>,
304    /// Direct non-grammar commands (x, p, J…) replay their key string.
305    pub(crate) last_cmd_keys: String,
306    pub(crate) last_insert: Option<String>,
307    pub(crate) recording_insert: Option<String>,
308    /// vim insert counts: `3i…`/`2o` repeat the session's text (o/O
309    /// repeat the opened line too — `insert_open` carries it).
310    pub(crate) insert_count: usize,
311    pub(crate) insert_open: Option<String>,
312    /// Injected frame renderer (0046): cell-grid production belongs to the
313    /// binary; replay of a recorded `Frame` action renders through this
314    /// hook. The engine never renders on its own.
315    pub frame_draw: Option<FrameDraw>,
316}
317
318/// The compiled query owns matching semantics for repeat, preview and highlighting.
319#[derive(Debug, Clone)]
320pub struct LastSearch {
321    pub query: strop_grammar::CompiledQuery,
322    pub backward: bool,
323}
324
325/// A pending `f/F/t/T` awaiting its target char — the leap-style
326/// candidate overlay's input. Named fields, not a naked `(u8, bool)`.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct FindPending {
329    pub ch: char,
330    pub backward: bool,
331}
332
333/// The ctrl-v rectangle (0017): line span by buffer index, cell span
334/// by LineLayout columns — named fields, not a naked mixed-unit tuple.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub struct BlockRect {
337    pub first_line: usize,
338    pub last_line: usize,
339    pub left_cell: strop_core::id::DisplayColumn,
340    pub right_cell: strop_core::id::DisplayColumn,
341}
342
343impl Editor {
344    pub fn new(buf: Buffer) -> Self {
345        // cwd is the process directory (project-wide): pickers walk it,
346        // LSP/git resolve against it; a file's own dir is not the project.
347        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
348        Self::new_in(buf, cwd)
349    }
350
351    /// Pure construction. Native services start explicitly after forensic seeding.
352    pub fn new_in(buf: Buffer, cwd: PathBuf) -> Self {
353        let (preview_tx, preview_rx) = std::sync::mpsc::channel();
354        let (shell_tx, shell_rx) = std::sync::mpsc::channel();
355        let (clip_tx, clip_rx) = std::sync::mpsc::channel();
356        let (git_tx, git_rx) = git_channel();
357        let mut docs = strop_core::id::Arena::default();
358        // the source identity is set at construction, not by convention
359        let doc = if buf.path.is_some() {
360            Document::new(buf)
361        } else {
362            Document::scratch(buf)
363        };
364        let current = docs.insert(doc);
365        Self {
366            docs,
367            trace_documents: HashMap::new(),
368            io: io::IoState::default(),
369            remote: remote::RemoteState::default(),
370            remote_completion: remote_completion::RemoteCompletionState::default(),
371            picker_ranking: picker::ranking::State::default(),
372            analysis: analysis::AnalysisState::default(),
373            resolution: resolution::ResolutionState::default(),
374            worker_ids: strop_core::worker::WorkerIds::default(),
375            worker_handles: HashMap::new(),
376            focus_epoch: 0,
377            finishing: false,
378            tape: std::rc::Rc::new(strop_trace::replay::Tape::new()),
379            git_view: strop_core::worker::WorkerId::new(0),
380            git_discovery: strop_core::worker::Load::Idle,
381            hunk_load: strop_core::worker::Load::Idle,
382            hunks_untracked: false,
383            log_requests: HashMap::new(),
384            card_request: None,
385            dive_requests: HashMap::new(),
386            git_mutations: std::collections::VecDeque::new(),
387            git_mutation: None,
388            shell_requests: HashMap::new(),
389            shell_focus: None,
390            containers: containers::ContainerState::default(),
391            mru: vec![current],
392            changes: changes::ChangeState::default(),
393            review: changes::review::ReviewState::default(),
394            collections: HashMap::new(),
395            collection_build: None,
396            mode: Mode::Normal,
397            occurrence: None,
398            pending: pending::PendingInput::default(),
399            walker: input::Walker::new(),
400            last_search: None,
401            undo_browser: None,
402            registers: HashMap::new(),
403            marks: HashMap::new(),
404            last_find: None,
405            flash: None,
406            message: String::new(),
407            should_quit: false,
408            ctrl_c_armed: false,
409            last_visual: None,
410            last_insert_pos: None,
411            change_idx: None,
412            view_rows: 24,
413            recording: None,
414            app_tx: None,
415            lsp_state: lsp::state::LspState::default(),
416            macros: std::collections::HashMap::new(),
417            last_macro: None,
418            block_insert_state: None,
419            macro_depth: 0,
420            last_change: None,
421            last_cmd_keys: String::new(),
422            last_insert: None,
423            recording_insert: None,
424            insert_count: 1,
425            insert_open: None,
426            picker: None,
427            retained_search: None,
428            workspaces: {
429                let mut registry = workspaces::WorkspaceRegistry::default();
430                registry.bind(strop_workspace::Filesystem::Local, Some(cwd.clone()));
431                registry
432            },
433            cwd,
434            blame_gutters: HashMap::new(),
435            generation: 0,
436            previews: HashMap::new(),
437            shell_tx,
438            shell_rx: Some(shell_rx),
439            git: None,
440            hunks: git_memory::HunkSet::default(),
441            staged_hunks: git_memory::HunkSet::default(),
442            blame_card: None,
443            git_tx,
444            git_rx: Some(git_rx),
445            needs_repaint: false,
446            osc52: None,
447            terminal_output: Vec::new(),
448            preview_tx,
449            preview_rx: Some(preview_rx),
450            preview_loads: HashMap::new(),
451            jumplist_past: Vec::new(),
452            jumplist_future: Vec::new(),
453            lsp_servers: Vec::new(),
454            clip_tx,
455            clip_rx: Some(clip_rx),
456            clip_paste_pending: None,
457            diags: HashMap::new(),
458            hover_card: None,
459            panes: vec![Pane {
460                doc: current,
461                sels: strop_core::selection::SelectionSet::default(),
462                view_top: 0,
463                hscroll: strop_core::id::DisplayColumn::new(0),
464                desired_column: None,
465            }],
466            active_pane: 0,
467            layout: LayoutDir::Row,
468            config: crate::config::Config::default(),
469            state_dir: None,
470            session_policy: crate::session::SessionPolicy::Automatic,
471            frame_draw: None,
472        }
473    }
474
475    pub fn feed_text(&mut self, text: &str) {
476        for key in keys::parse(text) {
477            self.feed(key);
478        }
479    }
480
481    pub fn feed(&mut self, key: Key) {
482        let _trace_scope = trace::InputScope::enter(self, key);
483        self.trace_state();
484        let generated = self.resolution.in_action;
485        if self.resolution.blocked()
486            || (!generated && !self.resolution.queue.is_empty())
487            || (generated && !self.resolution.staged.is_empty())
488        {
489            if generated {
490                self.resolution
491                    .staged
492                    .push_back(resolution::DeferredInput::GeneratedKey {
493                        key,
494                        depth: self.macro_depth,
495                    });
496            } else {
497                self.resolution
498                    .queue
499                    .push_back(resolution::DeferredInput::Key(key));
500            }
501            return;
502        }
503        self.run_input_action(|editor| {
504            editor.feed_inner(key);
505            editor.prepare_resolution_preview();
506        });
507        self.trace_state();
508    }
509
510    fn feed_inner(&mut self, key: Key) {
511        self.lsp_state.hover = None;
512        if let Some(build) = self.collection_build.as_mut() {
513            build.focus_on_ready = false;
514        }
515        if let Some(preparing) = self.review.preparing.as_mut() {
516            preparing.focus_ready = false;
517        }
518        self.revoke_shell_focus();
519        self.message.clear();
520        if key == Key::Esc
521            && self.mode == Mode::Normal
522            && !self.pending.is_active()
523            && self.review.preparing.is_some()
524        {
525            self.review_cancel_pub();
526            return;
527        }
528        if key == Key::Esc
529            && !self.pending.is_active()
530            && self.cancel_open(strop_core::worker::CancelReason::Dismissed)
531        {
532            self.message = "open cancelled".into();
533        }
534        // macro recording (0016): q at ground stops and never reaches
535        // the machine; everything else records BEFORE it runs, so
536        // replay is exactly the live stream
537        if let Some(reg) = self.recording {
538            let at_ground = self.walker.is_ground() && !self.pending.is_active();
539            if at_ground && key == Key::Char('q') {
540                self.recording = None;
541                self.message = format!("recorded @{}", reg);
542                return;
543            }
544            if let Some(buf) = self.macros.get_mut(&reg) {
545                buf.push(key);
546            }
547        }
548        self.dispatch_owned(key);
549    }
550
551    /// True when a modal input field sits in normal mode (picker field
552    /// or pending line) — the TUI draws the block cursor for it.
553    pub fn input_normal(&self) -> bool {
554        self.pending.normal()
555            || self
556                .picker
557                .as_ref()
558                .is_some_and(|g| g.picker.input_normal())
559    }
560
561    /// The modal line's sigil when a free-text line is open (`: / ? |`)
562    /// — the ONE authority; the render card, the terminal's bar-cursor
563    /// shape, and pending dispatch all ask here (a `|sed s/a/b/` body
564    /// is a pipe, not a search).
565    pub fn pending_sigil(&self) -> Option<char> {
566        self.pending.sigil()
567    }
568
569    /// The current document's indent (config default or detected at
570    /// open — resolved eagerly, so reads never rescan).
571    pub(crate) fn cur_indent(&self) -> document::Indent {
572        self.indentation_at(self.current(), self.head())
573    }
574
575    // ---- shared helpers -------------------------------------------------
576
577    /// `m{a}`: set mark a at the cursor.
578    pub(crate) fn set_mark(&mut self, mark: char) {
579        self.marks.insert(mark, (self.current(), self.head()));
580        self.message = format!("mark {mark} set");
581    }
582
583    /// (name, 1-based line, trimmed line text) per set mark, name-sorted —
584    /// the which-key mark cards' live rows (0047 §3).
585    pub fn mark_rows(&self) -> Vec<(char, usize, String)> {
586        let mut rows: Vec<_> = self
587            .marks
588            .iter()
589            .filter_map(|(name, (document, offset))| {
590                let doc = self.docs.get(*document)?;
591                let line = doc.buf.line_of(*offset);
592                let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
593                Some((*name, line + 1, text))
594            })
595            .collect();
596        rows.sort_by_key(|row| row.0);
597        rows
598    }
599
600    /// `'{a}`: jump to mark a (switches buffer if the mark lives there).
601    pub(crate) fn jump_mark(&mut self, mark: char) {
602        self.push_jump(); // mark jumps are jumplist entries
603        match self.marks.get(&mark).copied() {
604            Some((buf, offset)) => {
605                if self.docs.get(buf).is_some() {
606                    if buf != self.current() {
607                        self.switch_to(buf);
608                        self.discover_git();
609                    }
610                    self.set_head(
611                        self.buf()
612                            .clamp_boundary(offset.min(self.buf().len_bytes())),
613                    );
614                    self.clamp_cursor();
615                    // mark jumps use the 0051 §7 landing placement:
616                    // center unless comfortably visible
617                    self.place_jump_target();
618                }
619            }
620            None => self.message = format!("mark {mark} not set"),
621        }
622    }
623}
624
625/// Local-channel delivery and fixture helpers for engine-consumer tests;
626/// live drivers use the shared AppEvent channel.
627#[cfg(any(test, feature = "test-support"))]
628pub mod test_support;
629#[cfg(test)]
630mod tests;
631#[cfg(test)]
632mod transaction_conformance;
633
634impl Drop for Editor {
635    fn drop(&mut self) {
636        // One shutdown boundary, including headless errors and terminal failures.
637        for handle in std::mem::take(&mut self.worker_handles).into_values() {
638            handle.cancel(strop_core::worker::CancelReason::Shutdown);
639        }
640        for server in std::mem::take(&mut self.lsp_servers) {
641            if let Some(client) = server.client {
642                client.shutdown();
643                client.wait(Duration::from_millis(500));
644            }
645        }
646    }
647}
648
649/// The headless `state` directive's logical observation (0006): mode,
650/// cursor, pending input, picker and register state — no cells. The
651/// binary's headless driver prints it; the forensic observation embeds it.
652pub fn state_json(editor: &Editor) -> String {
653    if editor.docs.is_empty() {
654        return serde_json::json!({"should_quit":editor.should_quit,"documents":0,"message":editor.message}).to_string();
655    }
656    serde_json::json!({
657        "mode": editor.mode.chip(),
658        "cursor": editor.head(),
659        "line": editor.buf().line_of(editor.head()) + 1,
660        "col": editor.buf().col_of(editor.head()) + 1,
661        "pending": editor.pending.text(),
662        "message": editor.message,
663        "extra_cursors": editor.extra_selections().iter().map(|s| s.head).collect::<Vec<_>>(),
664        "panes": editor.panes.len(),
665        "active_pane": editor.active_pane,
666        "picker": editor.picker_open(),
667        "picker_input": editor.picker.as_ref().map(|g| g.picker.input.text.clone()),
668        "picker_items": editor.picker.as_ref().map(|g| g.picker.items.len()),
669        "picker_streaming": editor.picker.as_ref().map(|g| g.picker.streaming),
670        "register": editor.register(None).text,
671        "dirty": editor.buf().dirty,
672    })
673    .to_string()
674}