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