Skip to main content

fresh/app/
mod.rs

1mod action_events;
2mod active_focus;
3mod async_dispatch;
4mod async_messages;
5mod bookmark_actions;
6mod bookmarks;
7mod buffer_close;
8mod buffer_config_resolve;
9mod buffer_groups;
10mod buffer_management;
11mod calibration_actions;
12pub mod calibration_wizard;
13mod click_geometry;
14mod click_handlers;
15mod clipboard;
16mod composite_buffer_actions;
17mod dabbrev_actions;
18mod diagnostic_jumps;
19mod editor_accessors;
20mod editor_init;
21mod event_apply;
22pub mod event_debug;
23mod event_debug_actions;
24mod file_explorer;
25pub mod file_open;
26mod file_open_input;
27mod file_open_orchestrators;
28mod file_open_queue;
29mod file_operations;
30mod help;
31mod help_actions;
32mod hover;
33mod input;
34mod input_dispatch;
35mod input_helpers;
36pub mod keybinding_editor;
37mod keybinding_editor_actions;
38mod lifecycle;
39mod line_scan;
40mod lsp_actions;
41mod lsp_event_notify;
42mod lsp_requests;
43mod lsp_status;
44mod macro_actions;
45mod macro_codegen;
46mod macros;
47mod menu_actions;
48mod menu_context;
49mod mouse_input;
50mod navigation;
51mod on_save_actions;
52mod orchestrator_persistence;
53mod overlay;
54mod path_utils;
55#[cfg(feature = "plugins")]
56mod plugin_commands;
57#[cfg(feature = "plugins")]
58mod plugin_dispatch;
59mod popup_actions;
60mod popup_dialogs;
61mod popup_overlay_actions;
62mod prompt_actions;
63mod prompt_lifecycle;
64mod recovery_actions;
65mod regex_replace;
66mod render;
67mod scan_orchestrators;
68mod scroll_sync;
69mod scrollbar_input;
70mod scrollbar_math;
71mod search_ops;
72mod search_scan;
73mod settings_actions;
74mod settings_prompts;
75mod shell_command;
76mod smart_home;
77mod split_actions;
78mod stdin_stream;
79mod tab_drag;
80mod terminal;
81mod terminal_input;
82mod terminal_link;
83mod terminal_mouse;
84mod text_ops;
85mod theme_inspect;
86mod toggle_actions;
87pub mod types;
88mod undo_actions;
89mod view_actions;
90mod virtual_buffers;
91pub mod warning_domains;
92mod widget_runtime;
93pub mod window;
94mod window_actions;
95pub mod window_resources;
96pub mod workspace;
97
98use anyhow::Result as AnyhowResult;
99use rust_i18n::t;
100
101/// Shared per-tick housekeeping: process async messages, check timers, auto-save, etc.
102/// Returns true if a render is needed. The `clear_terminal` callback handles full-redraw
103/// requests (terminal clears the screen; GUI can ignore or handle differently).
104/// Used by both the terminal event loop and the GUI event loop.
105pub fn editor_tick(
106    editor: &mut Editor,
107    mut clear_terminal: impl FnMut() -> AnyhowResult<()>,
108) -> AnyhowResult<bool> {
109    let mut needs_render = false;
110
111    // Run the paste-deadline check *before* draining async messages
112    // so a deadline-fired fallback and a real result that came in
113    // the same tick are both handled in a single pass (the first to
114    // touch `paste_pending` wins; the other is silently dropped).
115    if editor.check_paste_deadline() {
116        needs_render = true;
117    }
118
119    let async_messages = {
120        let _s = tracing::info_span!("process_async_messages").entered();
121        editor.process_async_messages()
122    };
123    if async_messages {
124        needs_render = true;
125    }
126    let pending_file_opens = {
127        let _s = tracing::info_span!("process_pending_file_opens").entered();
128        editor.process_pending_file_opens()
129    };
130    if pending_file_opens {
131        needs_render = true;
132    }
133    if editor.process_line_scan() {
134        needs_render = true;
135    }
136    let search_scan = {
137        let _s = tracing::info_span!("process_search_scan").entered();
138        editor.process_search_scan()
139    };
140    if search_scan {
141        needs_render = true;
142    }
143    let search_overlay_refresh = {
144        let _s = tracing::info_span!("check_search_overlay_refresh").entered();
145        editor.check_search_overlay_refresh()
146    };
147    if search_overlay_refresh {
148        needs_render = true;
149    }
150    if editor.check_mouse_hover_timer() {
151        needs_render = true;
152    }
153    if editor.active_window().check_semantic_highlight_timer() {
154        needs_render = true;
155    }
156    if editor.check_completion_trigger_timer() {
157        needs_render = true;
158    }
159    editor.active_window_mut().check_diagnostic_pull_timer();
160    if editor.check_warning_log() {
161        needs_render = true;
162    }
163    if editor.poll_stdin_streaming() {
164        needs_render = true;
165    }
166
167    if let Err(e) = editor.auto_recovery_save_dirty_buffers() {
168        tracing::debug!("Auto-recovery-save error: {}", e);
169    }
170    if let Err(e) = editor.auto_save_persistent_buffers() {
171        tracing::debug!("Auto-save (disk) error: {}", e);
172    }
173
174    if editor.take_full_redraw_request() {
175        clear_terminal()?;
176        needs_render = true;
177    }
178
179    Ok(needs_render)
180}
181
182pub(crate) use path_utils::{
183    explorer_path_under_root, normalize_explorer_plugin_path, normalize_path,
184};
185
186use self::types::{
187    LspMenuItem, LspMessageEntry, LspProgressInfo, SearchState, TabContextMenu,
188    DEFAULT_BACKGROUND_FILE,
189};
190use crate::config::Config;
191use crate::config_io::DirectoryContext;
192use crate::input::buffer_mode::ModeRegistry;
193use crate::input::command_registry::CommandRegistry;
194use crate::input::keybindings::{Action, KeyContext, KeybindingResolver};
195use crate::input::quick_open::{
196    BufferProvider, CommandProvider, FileProvider, GotoLineProvider, QuickOpenRegistry,
197};
198use crate::model::cursor::Cursors;
199use crate::model::event::{Event, EventLog, LeafId, SplitDirection};
200use crate::model::filesystem::FileSystem;
201use crate::services::async_bridge::AsyncBridge;
202use crate::services::fs::FsManager;
203use crate::services::plugins::PluginManager;
204use crate::services::recovery::{RecoveryConfig, RecoveryService};
205use crate::services::time_source::{RealTimeSource, SharedTimeSource};
206use crate::state::EditorState;
207use crate::types::{LspLanguageConfig, LspServerConfig};
208use crate::view::file_tree::{FileTree, FileTreeView};
209use crate::view::prompt::PromptType;
210use crate::view::split::{SplitManager, SplitViewState};
211use crate::view::ui::{
212    ExplorerDecorations, FileExplorerRenderer, SplitRenderer, StatusBarRenderer,
213    SuggestionsRenderer,
214};
215use crossterm::event::{KeyCode, KeyModifiers};
216use ratatui::{
217    layout::{Constraint, Direction, Layout},
218    Frame,
219};
220use std::collections::HashMap;
221use std::ops::Range;
222use std::path::{Path, PathBuf};
223use std::sync::{Arc, Mutex, RwLock};
224use std::time::Instant;
225
226// Re-export BufferId from event module for backward compatibility
227pub use self::types::{BufferKind, BufferMetadata, HoverTarget};
228pub use self::warning_domains::{
229    GeneralWarningDomain, LspWarningDomain, WarningAction, WarningActionId, WarningDomain,
230    WarningDomainRegistry, WarningLevel, WarningPopupContent,
231};
232pub use crate::model::event::BufferId;
233
234/// Decode a wire-side LSP URI to a host path. Thin wrapper over
235/// [`LspUri::to_host_path`](crate::app::types::LspUri::to_host_path)
236/// that produces a `Result` for call sites that prefer the
237/// error-string form. Editor code that owns a raw `lsp_types::Uri`
238/// from a third-party type (e.g. `lsp_types::Location.uri`) wraps it
239/// via [`LspUri::from_wire`](crate::app::types::LspUri::from_wire)
240/// and then calls this — that's the only path from a wire URI to a
241/// host `PathBuf`, by construction.
242fn lsp_uri_to_host_path(
243    uri: &crate::app::types::LspUri,
244    translation: Option<&crate::services::authority::PathTranslation>,
245) -> Result<PathBuf, String> {
246    uri.to_host_path(translation)
247        .ok_or_else(|| "URI is not a file path".to_string())
248}
249
250/// A pending grammar registration waiting for reload_grammars() to apply
251#[derive(Clone, Debug)]
252pub struct PendingGrammar {
253    /// Language identifier (e.g., "elixir")
254    pub language: String,
255    /// Path to the grammar file (.sublime-syntax or .tmLanguage)
256    pub grammar_path: String,
257    /// File extensions to associate with this grammar
258    pub extensions: Vec<String>,
259}
260
261/// Track an in-flight semantic token range request.
262#[derive(Clone, Debug)]
263pub(crate) struct SemanticTokenRangeRequest {
264    pub(crate) buffer_id: BufferId,
265    pub(crate) version: u64,
266    pub(crate) range: Range<usize>,
267    pub(crate) start_line: usize,
268    pub(crate) end_line: usize,
269}
270
271#[derive(Clone, Copy, Debug)]
272pub(crate) enum SemanticTokensFullRequestKind {
273    Full,
274    FullDelta,
275}
276
277#[derive(Clone, Debug)]
278pub(crate) struct SemanticTokenFullRequest {
279    pub(crate) buffer_id: BufferId,
280    pub(crate) version: u64,
281    pub(crate) kind: SemanticTokensFullRequestKind,
282}
283
284#[derive(Clone, Debug)]
285pub(crate) struct FoldingRangeRequest {
286    pub(crate) buffer_id: BufferId,
287    pub(crate) version: u64,
288}
289
290#[derive(Clone, Debug)]
291pub(crate) struct InlayHintsRequest {
292    pub(crate) buffer_id: BufferId,
293    pub(crate) version: u64,
294}
295
296/// State for the dabbrev cycling session (Alt+/ style).
297///
298/// When the user presses Alt+/ repeatedly, we cycle through candidates
299/// in proximity order without showing a popup. The session is reset when
300/// any other action is taken (typing, moving, etc.).
301#[derive(Debug, Clone)]
302pub struct DabbrevCycleState {
303    /// The original prefix the user typed before the first expansion.
304    pub original_prefix: String,
305    /// Byte position where the prefix starts.
306    pub word_start: usize,
307    /// The list of candidates (ordered by proximity).
308    pub candidates: Vec<String>,
309    /// Current index into `candidates`.
310    pub index: usize,
311}
312
313/// Snapshot of cursor and viewport state used to restore the original position
314/// when a goto-line preview is abandoned (cancel, or the user edits the input
315/// so it no longer targets a line).
316///
317/// Shared between Quick Open's `:N` syntax and the standalone `Goto Line`
318/// prompt — both flows save a snapshot on the first preview jump and restore
319/// it if the user cancels or clears the target.
320///
321/// `last_jump_position` is the byte offset the most recent preview jump put the
322/// cursor at; the restore path only applies the snapshot when the cursor is
323/// still exactly there. If anything else moved the cursor (mouse click, an
324/// async buffer edit shifting positions via `adjust_for_edit`, …) the snapshot
325/// is considered stale and simply dropped. This is the single staleness check
326/// that replaces per-site invalidation across many call paths.
327#[derive(Debug, Clone)]
328pub(crate) struct GotoLinePreviewSnapshot {
329    pub buffer_id: BufferId,
330    pub split_id: LeafId,
331    pub cursor_id: crate::model::event::CursorId,
332    pub position: usize,
333    pub anchor: Option<usize>,
334    pub sticky_column: usize,
335    pub viewport_top_byte: usize,
336    pub viewport_top_view_line_offset: usize,
337    pub viewport_left_column: usize,
338    pub last_jump_position: usize,
339}
340
341/// The main editor struct - manages multiple buffers, clipboard, and rendering
342pub struct Editor {
343    // Buffers moved onto `Window` (Step 0c). Each window owns its
344    // own buffer storage; opening the same file in two windows
345    // produces two independent buffers. Access through
346    // `Editor::buffers()` / `buffers_mut()` (active window) or by
347    // direct `self.windows.get_mut(&id).unwrap().buffers` for
348    // cross-window iteration.
349
350    // NOTE: There is no `active_buffer` field. The active buffer is derived from
351    // `split_manager.active_buffer_id()` to maintain a single source of truth.
352    // Use `self.active_buffer()` to get the active buffer ID.
353    // event_logs moved onto `Window` (Step 0e). Undo logs follow the
354    // buffer storage, so they live alongside the buffer they describe.
355    /// Next buffer ID to assign.
356    ///
357    /// **Use [`Self::alloc_buffer_id`] instead of direct mutation.**
358    /// Direct `self.next_buffer_id += 1` works only on `impl Editor`
359    /// — methods that run on `impl Window` allocate via the shared
360    /// `Arc<BufferIdAllocator>` in `WindowResources`. Keeping this
361    /// counter as the canonical source means a handler on `Editor`
362    /// can still increment locally; the allocator's atomic stays in
363    /// sync because both go through `alloc_buffer_id`.
364    next_buffer_id: usize,
365
366    /// Globally-unique buffer-id allocator shared by `Arc` clone with
367    /// every `Window` (via `WindowResources`). Handlers on
368    /// `impl Window` call `Window::alloc_buffer_id()` which delegates
369    /// here; handlers on `impl Editor` call `Editor::alloc_buffer_id()`
370    /// which does the same plus advances the local `next_buffer_id`
371    /// snapshot. Both routes produce the same monotonic id sequence.
372    pub(crate) buffer_id_alloc: crate::app::window_resources::BufferIdAllocator,
373
374    /// Configuration.
375    ///
376    /// Stored as `Arc<Config>` so that mutations go through `Arc::make_mut`
377    /// (via `config_mut()`), which clone-on-writes when any other holder
378    /// references the same value. `Arc<T>` has no `DerefMut`, so direct
379    /// field assignment through `self.config` is a compile error — every
380    /// mutation must route through the CoW-aware accessor.
381    ///
382    /// Effective value is `base_config_json` + `runtime_overlay` (design
383    /// §3.4): init.ts and plugins may layer per-session writes via
384    /// `editor.setSetting(path, value)`. The overlay is merged into
385    /// `base_config_json`, the result is deserialised into this field,
386    /// and mutations go through `Arc::make_mut`.
387    ///
388    /// **Freshness invariant**: `config_snapshot_anchor` below is set to
389    /// `Arc::clone(&self.config)` on every plugin-snapshot refresh. That
390    /// guarantees the first `Arc::make_mut(&mut self.config)` after each
391    /// refresh *always* CoW-clones (strong count ≥ 2), so `self.config`
392    /// moves to a new pointer and stops being `ptr_eq` with the anchor.
393    config: Arc<Config>,
394
395    /// Clone of `config` captured at the last plugin-snapshot refresh.
396    config_snapshot_anchor: Arc<Config>,
397
398    /// Serialized JSON of `*self.config` as of the last time
399    /// `ptr_eq(&self.config, &self.config_snapshot_anchor)` was false.
400    config_cached_json: Arc<serde_json::Value>,
401
402    /// Cached raw user config (for plugins, avoids re-reading file on every frame).
403    user_config_raw: Arc<serde_json::Value>,
404
405    /// Directory context for editor state paths
406    dir_context: DirectoryContext,
407
408    /// Grammar registry for TextMate syntax highlighting
409    grammar_registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
410
411    /// Pending grammars registered by plugins, waiting for reload_grammars() to apply
412    pending_grammars: Vec<PendingGrammar>,
413
414    /// Whether a grammar reload has been requested but not yet flushed.
415    /// This allows batching multiple RegisterGrammar+ReloadGrammars sequences
416    /// into a single rebuild.
417    grammar_reload_pending: bool,
418
419    /// Whether a background grammar build is in progress.
420    /// When true, `flush_pending_grammars()` defers work until the build completes.
421    grammar_build_in_progress: bool,
422
423    /// Whether the initial full grammar build (user grammars + language packs)
424    /// still needs to happen. Deferred from construction so that plugin-registered
425    /// grammars from the first event-loop tick are included in a single build.
426    needs_full_grammar_build: bool,
427
428    /// Plugin callback IDs waiting for the grammar build to complete.
429    /// Multiple reloadGrammars() calls may accumulate here; all are resolved
430    /// when the background build finishes.
431    pending_grammar_callbacks: Vec<fresh_core::api::JsCallbackId>,
432
433    /// Active theme
434    /// Active resolved theme, shared with windows via WindowResources.
435    /// Wrapped in `Arc<RwLock<>>` so a theme reload propagates to every
436    /// window without copying.
437    pub(crate) theme: Arc<RwLock<crate::view::theme::Theme>>,
438
439    /// All loaded themes (embedded + user). Held as `Arc` so
440    /// `expanded_menus_cache` can detect a registry swap via `Arc::ptr_eq`.
441    theme_registry: Arc<crate::view::theme::ThemeRegistry>,
442
443    /// Memoised `MenuConfig` with `DynamicSubmenu` items expanded against
444    /// the current theme registry.
445    expanded_menus_cache: crate::view::ui::ExpandedMenusCache,
446
447    /// Shared theme data cache for plugin access (name → JSON value)
448    theme_cache: Arc<RwLock<HashMap<String, serde_json::Value>>>,
449
450    /// Optional ANSI background image
451    ansi_background: Option<crate::primitives::ansi_background::AnsiBackground>,
452
453    /// Source path for the currently loaded ANSI background
454    ansi_background_path: Option<PathBuf>,
455
456    /// Blend amount for the ANSI background (0..1)
457    background_fade: f32,
458
459    /// Keybinding resolver (shared with Quick Open CommandProvider)
460    keybindings: Arc<RwLock<KeybindingResolver>>,
461
462    /// Shared clipboard (handles both internal and system clipboard)
463    clipboard: crate::services::clipboard::Clipboard,
464
465    /// Should the editor quit?
466    should_quit: bool,
467
468    /// Whether the workspace-trust prompt currently on screen was opened
469    /// voluntarily (command palette) rather than as the mandatory open-time
470    /// gate. When `true` its secondary action is "Cancel" (just close); when
471    /// `false` it's "Quit" (exit the editor) and Escape is inert.
472    workspace_trust_prompt_cancellable: bool,
473
474    /// The marker files/dirs that triggered the workspace-trust prompt (e.g.
475    /// `.envrc`, `.venv`, `App.sln`), captured when the prompt is shown so the
476    /// dialog can tell the user why it appeared.
477    workspace_trust_markers: Vec<String>,
478
479    /// Scroll offset (in rows) for the workspace-trust dialog when it's too
480    /// tall for the terminal. Driven by the mouse wheel; clamped in render.
481    workspace_trust_scroll: u16,
482
483    /// Should the client detach (keep server running)?
484    should_detach: bool,
485
486    /// Running in session/server mode (use hardware cursor only, no REVERSED style)
487    session_mode: bool,
488
489    /// Backend does not render a hardware cursor — always use software cursor indicators.
490    software_cursor_only: bool,
491
492    /// Session name for display in status bar (session mode only)
493    session_name: Option<String>,
494
495    /// Pending escape sequences to send to client (session mode only)
496    /// These get prepended to the next render output
497    pending_escape_sequences: Vec<u8>,
498
499    /// If set, the editor should restart with this new working directory
500    /// This is used by Open Folder to do a clean context switch
501    restart_with_dir: Option<PathBuf>,
502
503    // status_message, plugin_status_message, prompt moved onto
504    // `Window` (Step 0k phase 3) — each window has its own chrome,
505    // and the active window's chrome is what renders.
506    /// Last terminal window title written via OSC 2. Used so we only write
507    /// the escape sequence when the title would actually change, rather
508    /// than on every frame.
509    last_window_title: Option<String>,
510
511    // `plugin_errors` moved onto `Window`.
512    /// Terminal dimensions (for creating new buffers)
513    terminal_width: u16,
514    terminal_height: u16,
515
516    /// Last layout signature the plugin `resize` hook fired for, used
517    /// by `Editor::relayout` to dedupe notifications. The tuple is
518    /// `(terminal_width, terminal_height, dock_cols, file_explorer_cols)`
519    /// — the content geometry plugins observe. Deduping is what keeps
520    /// the orchestrator's resize→`dock_width`→relayout reaction from
521    /// looping: once the dock width settles, the signature stops
522    /// changing and the hook stops re-firing. `None` until the first
523    /// relayout.
524    last_layout_signature: Option<(u16, u16, u16, u16)>,
525
526    // LSP manager moved onto `Window`. Access via
527    // `Editor::lsp()` / `lsp_mut()` — each window has its own
528    // LspManager rooted at its project root.
529    // buffer_metadata moved onto `Window` (Step 0l). Access via
530    // `self.active_window().buffer_metadata` (or
531    // `_mut()`) — each window owns the metadata for its own
532    // buffers, alongside the buffer storage itself.
533    /// Buffer mode registry (for buffer-local keybindings)
534    mode_registry: ModeRegistry,
535
536    /// Tokio runtime for async I/O tasks
537    tokio_runtime: Option<Arc<tokio::runtime::Runtime>>,
538
539    /// Bridge for async messages from tokio tasks to main loop
540    async_bridge: Option<AsyncBridge>,
541
542    /// Connection ids (`AgentChannel::id`) for which a reconnect-forwarder task
543    /// has already been spawned. The forwarder awaits each channel's
544    /// `reconnect_notify` and turns a hot-swap into an
545    /// `AsyncMessage::RemoteReconnected`, so reconnect handling is event-driven
546    /// rather than polled. `ensure_remote_reconnect_forwarders` registers one
547    /// lazily per remote window; this set makes that idempotent.
548    remote_reconnect_forwarders: std::collections::HashSet<u64>,
549
550    /// In-flight async system-clipboard reads, keyed by `request_id`.
551    /// Each entry owns an anchor (`VirtualText("▍")`) in some buffer
552    /// that floats with edits via the marker tree; when the matching
553    /// `ClipboardPasteResult` arrives, the pasted text is inserted at
554    /// the anchor's *current* position (which may have moved since the
555    /// user pressed Ctrl+V). The editor stays fully interactive while
556    /// pastes are pending — multiple Ctrl+V presses simply add more
557    /// entries, each capturing the OS clipboard contents at the moment
558    /// its own background thread starts.
559    pub(super) paste_pending: std::collections::HashMap<u64, crate::app::clipboard::PendingPaste>,
560
561    /// Set by `paste()` when it took the slow placeholder path. The
562    /// input dispatch reads and clears this to suppress the
563    /// otherwise-automatic render for the Ctrl+V keystroke, and the
564    /// main loop also reads `paste_render_suppress_until` (below)
565    /// to actively veto frame rendering until that deadline.
566    pub(super) paste_slow_path_just_armed: bool,
567
568    /// While `Some` and the instant is in the future, the main loop
569    /// holds off on rendering even when `needs_render` is set. Used
570    /// by the async paste path: when arboard doesn't return inside
571    /// the inline budget we plant a placeholder, but on a slow
572    /// renderer (LSP-active, remote terminal, dense diagnostics)
573    /// each `terminal.draw` is hundreds of ms, so rendering the
574    /// placeholder before the paste itself resolves *doubles* the
575    /// user-visible latency. Setting this suppresses renders until
576    /// the paste deadline; the paste resolve clears it and triggers
577    /// a single render with the final text.
578    pub(super) paste_render_suppress_until: Option<std::time::Instant>,
579
580    /// Test-only override for the system-clipboard reader used by the
581    /// async paste path. `None` in production (the real
582    /// `services::clipboard::read_system_clipboard` is used). Tests set
583    /// this to a deterministic stub — e.g. `|| None` to simulate a
584    /// host with no usable system clipboard (Termux, headless TTY) —
585    /// so the internal-clipboard fallback can be exercised in isolation
586    /// without touching the real host clipboard.
587    pub(super) system_clipboard_reader: Option<fn() -> Option<String>>,
588
589    // split_manager and split_view_states moved onto `Window`. Access
590    // via `Editor::split_manager()` / `split_manager_mut()` and
591    // `Editor::split_view_states()` / `split_view_states_mut()`.
592    // Each window owns its own split tree + per-leaf view state.
593    // `previous_viewports` moved onto `Window` (per-leaf state — each
594    // window has its own splits, so its own viewport-change tracker).
595    // `scroll_sync_manager` moved onto `Window` — pairs splits, which
596    // are per-window.
597
598    // file_explorer moved onto `Window`. Access via
599    // `Editor::file_explorer()` / `file_explorer_mut()` —
600    // each window has its own tree view.
601    // `preview` (per-window preview-tab tracker) moved onto `Window`.
602    // Each window has its own preview slot.
603
604    // suppress_position_history_once moved onto `Window` (Step 0f).
605    // The file explorer and Open File browser ride per-window fs_managers
606    // (`WindowResources::fs_manager`, derived from each window's authority), so
607    // the editor no longer holds a global one that could go stale against the
608    // active authority.
609    // The editor's active backend is *not* a field here — it lives on the
610    // active `Window` (owned, non-`Clone`), read via `Editor::authority()`.
611    // Keeping it single-owned per window is what makes a session's
612    // backend/trust/env impossible to share into another window by
613    // construction (issue #2280).
614    /// Authority queued by `install_authority`, picked up by `main.rs`
615    /// right before dropping this editor on restart. `None` in the
616    /// steady state. Not durable state — restarts from `main.rs`'s
617    /// restart-dir path leave this `None`, and the main loop carries
618    /// the authority over through its own channel.
619    pending_authority: Option<crate::services::authority::Authority>,
620
621    /// Keepalive bundle queued alongside `pending_authority` for a
622    /// connection-backed authority (remote agent / K8s), parked by the
623    /// restart loop so the live carrier + reconnect/heartbeat tasks
624    /// survive the rebuild. `None` for synchronously-constructible
625    /// authorities (local, docker). See
626    /// [`Editor::install_authority_with_keepalive`].
627    pending_keepalive: Option<Box<dyn std::any::Any + Send>>,
628
629    /// Plugin-supplied override for the Remote Indicator. Takes
630    /// precedence over the authority-derived state at render time.
631    /// Cleared on editor restart (plugins must reassert the state
632    /// after `setAuthority`). See
633    /// `PluginCommand::SetRemoteIndicatorState`.
634    pub remote_indicator_override: Option<crate::view::ui::status_bar::RemoteIndicatorOverride>,
635
636    /// Local filesystem for editor-internal files (log files, status
637    /// log). Stays separate from `authority` because these are the
638    /// editor's own private state — they live on the host disk
639    /// regardless of where the user is editing.
640    local_filesystem: Arc<dyn FileSystem + Send + Sync>,
641
642    // `file_explorer_visible`, `file_explorer_sync_in_progress`,
643    // `file_explorer_width`, `file_explorer_side`,
644    // `pending_file_explorer_show_*`, `file_explorer_decorations`,
645    // `file_explorer_decoration_cache` all moved onto `Window`. The
646    // file-explorer view itself was already per-window since Step 0b;
647    // these chrome flags follow.
648    /// File explorer clipboard for cut/copy/paste of files and directories
649    // `file_explorer_clipboard` moved onto `Window`.
650
651    // `menu_bar_visible`, `menu_bar_auto_shown`, `tab_bar_visible`,
652    // `status_bar_visible`, `prompt_line_visible`, `mouse_enabled`
653    // moved onto `Window` — per-window UI toggles.
654
655    // `same_buffer_scroll_sync` moved onto `Window` — per-window UX
656    // toggle, since the split tree it controls is per-window.
657    /// Mouse cursor position (for GPM software cursor rendering)
658    /// When GPM is active, we need to draw our own cursor since GPM can't
659    /// draw on the alternate screen buffer used by TUI applications.
660    // `mouse_cursor_position`, `gpm_active`, `key_context` moved onto `Window`.
661
662    /// Menu state (active menu, highlighted item)
663    menu_state: crate::view::ui::MenuState,
664
665    /// Menu configuration (built-in menus with i18n support)
666    menus: crate::config::MenuConfig,
667
668    /// All editor sessions, keyed by id. The active window's `root` is
669    /// the editor's working directory — see `working_dir()`, which
670    /// derives it. There is deliberately no separate `working_dir`
671    /// field: it cannot drift out of sync with the active window
672    /// (issue #2056). Holds exactly one session (`WindowId(1)`, the
673    /// "base") until the orchestrator adds more.
674    pub(crate) windows: HashMap<fresh_core::WindowId, crate::app::window::Window>,
675
676    /// Connection keepalives for born-attached remote windows, keyed by
677    /// `WindowId`. A remote (Kubernetes / SSH / …) window's carrier process +
678    /// reconnect/heartbeat tasks + dedicated runtime live in this opaque
679    /// bundle; it must outlive the `Editor` rebuilds that *don't* drop the
680    /// window and is torn down when the window is closed (`close_window`).
681    /// Local windows have no entry. This is the per-window analogue of the
682    /// process-level keepalive the restart-based attach parks.
683    pub(crate) session_keepalives: HashMap<fresh_core::WindowId, Box<dyn std::any::Any + Send>>,
684
685    /// Request ids of `attachRemoteAgent` connects currently in flight (added
686    /// when the connect is spawned, removed when it settles). Lets a plugin
687    /// cancel a pending connect (the New-Session dialog's Cancel).
688    pub(crate) remote_attach_inflight: std::collections::HashSet<u64>,
689    /// Request ids of in-flight attaches the plugin asked to cancel. When the
690    /// connect later resolves, the result (authority + carrier keepalive) is
691    /// dropped instead of installed — so no window is created and the carrier
692    /// is torn down — and a failure is ignored.
693    pub(crate) remote_attach_cancelled: std::collections::HashSet<u64>,
694    /// Cancellation senders for the background connect threads, keyed by
695    /// request id. The connect runs on a detached thread (so the carrier's
696    /// runtime outlives it); signalling here makes that thread's `select!` drop
697    /// the in-flight connect future, which drops the ssh child (spawned
698    /// kill-on-drop) — so even a hung handshake leaves no orphaned process. The
699    /// thread then finishes and its result is discarded. Cleared on settle.
700    pub(crate) remote_attach_cancels:
701        std::collections::HashMap<u64, tokio::sync::oneshot::Sender<()>>,
702
703    /// Id of the currently active session. Always `WindowId(1)` for
704    /// now; multi-session support arrives in a follow-up commit.
705    pub(crate) active_window: fresh_core::WindowId,
706
707    /// Windows whose persisted workspace (files/splits) has not yet
708    /// been restored from disk. Startup materializes only the
709    /// foreground (CLI-dir) window eagerly; every other discovered
710    /// session is seeded with an empty layout and listed here, then
711    /// restored lazily on first dive or preview via
712    /// `materialize_window`.
713    pub(crate) materialize_pending: std::collections::HashSet<fresh_core::WindowId>,
714
715    /// Persisted **remote** sessions (SSH / kube) discovered at boot but not
716    /// yet connected — there is deliberately **no `Window`** for them, because a
717    /// `Window` must always own its session's *real* authority and a remote
718    /// session's authority does not exist until it connects. Representing them
719    /// as `Window`s would force a dummy local-placeholder authority (the old
720    /// "shell"), which is exactly the "local before, remote later" pattern that
721    /// silently ran restored terminals on the local host. Instead they live
722    /// here as authority-less descriptors: listed in the dock via the
723    /// `WindowInfo` snapshot, and promoted to a real `Window` (born with the
724    /// connected SSH/kube authority, restoring their workspace through it) only
725    /// when the user dives in — see `bring_dormant_remote_online`. Keyed by the
726    /// id they will adopt as a `Window`.
727    pub(crate) dormant_remote: std::collections::HashMap<
728        fresh_core::WindowId,
729        crate::app::orchestrator_persistence::PersistedWindow,
730    >,
731
732    /// Monotonic counter for the next session id. The base session
733    /// uses 1; new sessions take 2, 3, …. Closing a session does
734    /// not free its id (per design, ids are stable within a process).
735    /// Unused until `createWindow` lands in the next migration step.
736    #[allow(dead_code)]
737    pub(crate) next_window_id: u64,
738
739    /// Optional plugin-provided order/subset for the Next/Prev Window
740    /// commands. When `Some`, those commands cycle through exactly these
741    /// window ids in this order (ids no longer open are skipped); when
742    /// `None`, the default (every window, by id) applies. Set by the
743    /// orchestrator dock so paging windows visits exactly the sessions
744    /// the dock currently shows. See `cycle_active_window`.
745    pub(crate) window_cycle_order: Option<Vec<fresh_core::WindowId>>,
746
747    // LSP request-tracking state (next_lsp_request_id,
748    // pending_*_requests, *_in_flight, completion_items,
749    // dabbrev_state, etc.) all moved onto `Window` in Step 0k.
750    // `completion_service` moved onto `Window` — orchestrates this
751    // window's per-window completion providers (dabbrev, buffer words,
752    // LSP, plugin providers).
753    // `lsp_diagnostic_namespace` moved onto `Window` — overlay
754    // namespace key for diagnostic overlays, which are buffer overlays
755    // and follow buffers onto the window.
756    // `interactive_replace_state` moved onto `Window` — per-window
757    // search-and-replace session state.
758    // `mouse_state` moved onto `Window` — drag targets reference per-window LeafIds.
759    // `tab_context_menu`, `file_explorer_context_menu`, `theme_info_popup`
760    // moved onto `Window`.
761    // `chrome_layout` moved onto `Window` — each window has its own
762    // status bar, menu, prompt overlay, and popups.
763    /// Command registry for dynamic commands
764    command_registry: Arc<RwLock<CommandRegistry>>,
765
766    /// Quick Open registry for unified prompt providers
767    quick_open_registry: QuickOpenRegistry,
768
769    /// Plugin manager (handles both enabled and disabled cases)
770    /// Plugin manager, wrapped in `Arc<RwLock<>>` so windows can fire
771    /// hooks (`run_hook`) via WindowResources without holding an
772    /// `&mut Editor` reference. `&self` methods (run_hook,
773    /// deliver_response, has_hook_handlers, …) take a read lock; the
774    /// few `&mut self` methods (process_commands, check_thread_health,
775    /// test_inject_command) take a write lock.
776    plugin_manager: Arc<RwLock<PluginManager>>,
777
778    // `plugin_dev_workspaces` moved onto `Window` — keyed by `BufferId`,
779    // and buffers are per-window, so the workspace map follows.
780    /// Registry of status-bar tokens contributed by plugins.
781    /// Key: "plugin_name:token_name" (e.g., "git_statusbar:branch"); value: display title.
782    /// Global and lifetime-checked. Per-buffer values live on each `Window`.
783    status_bar_token_registry: Mutex<HashMap<String, String>>,
784
785    /// Registry of plugin-provided config schemas, keyed by plugin name.
786    /// Each value is the validated JSON schema describing
787    /// `plugins.<name>.settings.*`. Populated at startup from
788    /// `<plugin_name>.schema.json` sidecar files discovered next to plugin
789    /// `.ts`/`.js` files; the Settings UI reads this to render a
790    /// per-plugin sub-category under "Plugin Settings".
791    pub(crate) plugin_schemas:
792        std::sync::Arc<std::sync::RwLock<HashMap<String, serde_json::Value>>>,
793
794    // `seen_byte_ranges` moved onto `Window` — keyed by `BufferId`
795    // which lives on `Window`, so the tracker follows the buffers.
796
797    // panel_ids moved onto `Window`. Access via
798    // `Editor::panel_ids()` / `panel_ids_mut()` — those resolve to
799    // the active window's dock occupancy. Each window owns its own
800    // utility-dock; switching windows doesn't share dock state.
801    // `buffer_groups`, `buffer_to_group`, `next_buffer_group_id`
802    // moved onto `Window` — each window has its own buffer groups.
803
804    // grouped_subtrees moved onto `Window` — each window owns its
805    // own buffer-group subtrees (a window with a Live Grep panel
806    // open doesn't share the panel state with sibling windows).
807    /// Background process abort handles for cancellation
808    /// Maps process_id to abort handle
809    background_process_handles: HashMap<u64, tokio::task::AbortHandle>,
810
811    /// Cancellation senders for host-side processes spawned via
812    /// `spawnHostProcess`. Firing the sender (or dropping it) triggers
813    /// an in-task `child.start_kill()` so the process is reaped, not
814    /// just orphaned. Entries are removed when the spawn task sends
815    /// its terminal `PluginProcessOutput`.
816    host_process_handles: HashMap<u64, tokio::sync::oneshot::Sender<()>>,
817    /// FIFO queue of plugin `editor.getNextKey()` callbacks awaiting a
818    /// keypress. While non-empty, the next key arriving in
819    /// `handle_key` is consumed by resolving the front-most callback
820    /// rather than dispatching to mode bindings or other handlers.
821    // `pending_next_key_callbacks`, `key_capture_active`,
822    // `pending_key_capture_buffer` moved onto `Window`.
823    // `lsp_progress`, `lsp_server_statuses`, `lsp_window_messages`,
824    // `lsp_log_messages` moved onto `Window` — each window has its own
825    // LspManager, so the progress/status/message streams describe that
826    // manager's servers, not the editor's.
827
828    // `diagnostic_result_ids` moved onto `Window` — each window has its
829    // own LspManager and therefore its own per-URI result_id stream.
830    // `stored_push_diagnostics`, `stored_pull_diagnostics`,
831    // `stored_diagnostics`, `stored_folding_ranges` moved onto
832    // `Window` — URI-keyed but each URI maps to a buffer in a specific
833    // window's LSP set, so the maps describe one window's diagnostics.
834    /// Event broadcaster for control events (observable by external systems)
835    event_broadcaster: crate::model::control_event::EventBroadcaster,
836
837    // bookmarks moved onto `Window` (Step 0f).
838    /// Macro record/playback subsystem (owns `macros`, `recording`,
839    /// `last_register`, and the `playing` guard flag).
840    // `macros` moved onto `Window`.
841
842    /// Pending plugin action receivers (for async action execution)
843    #[cfg(feature = "plugins")]
844    pending_plugin_actions: Vec<(
845        String,
846        crate::services::plugins::thread::oneshot::Receiver<anyhow::Result<()>>,
847    )>,
848
849    /// Flag set by plugin commands that need a render (e.g., RefreshLines)
850    #[cfg(feature = "plugins")]
851    plugin_render_requested: bool,
852
853    /// Pending chord sequence for multi-key bindings (e.g., C-x C-s in Emacs)
854    /// Stores the keys pressed so far in a chord sequence
855    // `chord_state` moved onto `Window`.
856
857    // (Historical `pending_lsp_confirmation` and `pending_lsp_status_popup`
858    // fields moved onto `Popup::resolver` — each popup carries its own
859    // "how do I confirm?" identity, so `handle_popup_confirm` dispatches
860    // by matching the focused popup's resolver instead of racing through
861    // a precedence cascade of side-channel `Option`s that a second
862    // simultaneously-open popup could steal.)
863    /// Languages the user has interactively dismissed from the LSP popup.
864    ///
865    /// Pending Save-As queue for the "save and quit" flow.
866    ///
867    // `last_auto_revert_poll`, `last_file_tree_poll`, `git_index_resolved`,
868    // `dir_mod_times`, `pending_file_poll_rx`, `pending_dir_poll_rx`
869    // all moved onto `Window` — auto-revert and file-tree change
870    // detection are per-window, paired with the already-per-window
871    // `file_mod_times`.
872    /// File open dialog state (when PromptType::OpenFile is active)
873    // `file_open_state` moved onto `Window`.
874
875    /// Cached layout for file browser (for mouse hit testing)
876    // `file_browser_layout` moved onto `Window`.
877
878    /// Recovery service for auto-recovery-save and crash recovery.
879    /// `Arc<Mutex>` because it is shared into every `Window` via
880    /// `WindowResources` so per-window restore / auto-save can reach it
881    /// without an active-window flip.
882    recovery_service: std::sync::Arc<std::sync::Mutex<RecoveryService>>,
883
884    /// Request a full terminal clear and redraw on the next frame
885    full_redraw_requested: bool,
886
887    /// When true, the render pipeline computes chrome *layout* (menu, dropdown,
888    /// command palette / suggestions) and records it on the layout caches as
889    /// usual, but SKIPS drawing those chrome layers into the cell buffer. Hosts
890    /// that render chrome from the semantic model instead of cells (the web /
891    /// Tauri frontends) set this so they get pane-only cells with no chrome to
892    /// hide. The TUI/GUI leave it `false` and draw chrome to cells as before.
893    /// See docs/internal/UNIFIED_SCENE_DESIGN.md (Phase 1).
894    pub(crate) suppress_chrome_cells: bool,
895
896    /// Request the event loop to suspend the process (SIGTSTP on Unix).
897    /// Consumed by the outer event loop after the current action returns.
898    suspend_requested: bool,
899
900    /// Time source for testable time operations
901    time_source: SharedTimeSource,
902
903    /// Last auto-recovery-save time for rate limiting
904    // `last_auto_recovery_save`, `last_persistent_auto_save` moved onto `Window`.
905
906    /// Active custom contexts for command visibility
907    /// Plugin-defined contexts like "config-editor" that control command availability
908    // `active_custom_contexts` moved onto `Window`.
909
910    /// Plugin-managed global state, isolated per plugin name.
911    /// Outer key is plugin name, inner key is the state key set by the plugin.
912    plugin_global_state: HashMap<String, HashMap<String, serde_json::Value>>,
913    /// Warning log receiver and path (for tracking warnings)
914    warning_log: Option<(std::sync::mpsc::Receiver<()>, PathBuf)>,
915
916    /// Status message log path (for viewing full status history)
917    status_log_path: Option<PathBuf>,
918
919    /// Warning domain registry for extensible warning indicators
920    /// Contains LSP warnings, general warnings, and can be extended by plugins
921    // `warning_domains` moved onto `Window`.
922
923    /// Periodic update checker (checks for new releases every hour)
924    update_checker: Option<crate::services::release_checker::PeriodicUpdateChecker>,
925
926    // Terminal subsystem moved onto `Window` (Step 0d). PTYs and
927    // their backing files belong to the window that spawned them, so
928    // closeWindow joins the threads. Access through methods on Window
929    // (called via `self.windows.get_mut(&id).unwrap().method(...)`),
930    // not via accessors on Editor.
931    /// Plugin-driven filesystem watchers (lazily constructed —
932    /// the underlying notify backend spawns a thread, so it's
933    /// nicer to defer until the first `watchPath` call). See
934    /// `services/file_watcher.rs`.
935    #[cfg(feature = "plugins")]
936    file_watcher_manager: crate::services::file_watcher::FileWatcherManager,
937
938    /// Test-only sink for `path_changed` plugin events. Captured
939    /// by `async_dispatch` whenever a PathChanged AsyncMessage
940    /// arrives, so e2e tests can assert filesystem events
941    /// reached the editor without standing up a JS plugin.
942    /// Production builds never read this.
943    pub(crate) last_path_change_for_test: Option<(u64, std::path::PathBuf, &'static str)>,
944
945    /// Test-only sink for the most-recent `WatchPathRegistered`
946    /// plugin response, keyed by request_id. Used by
947    /// `watch_path` e2e tests to read back the allocated handle.
948    pub(crate) last_watch_response_for_test: Option<(u64, Result<u64, String>)>,
949
950    /// Plugin-driven session preview override. When `Some(sid)`
951    /// and the floating-overlay prompt is open, the overlay's
952    /// preview pane renders the *entire* split tree of session
953    /// `sid` natively — Primitive #1 in
954    /// `docs/internal/orchestrator-sessions-design.md` §
955    /// "Rich Control Room rendering".
956    pub(crate) preview_window_id: Option<fresh_core::WindowId>,
957
958    // terminal_buffers / terminal_backing_files / terminal_log_files
959    // moved onto `Window` (Step 0d).
960    // `ephemeral_terminals` moved onto `Window` — TerminalManager and
961    // its terminals are per-window (Step 0d), so the ephemeral set
962    // follows.
963
964    // `terminal_mode` moved onto `Window`. Per-window because each
965    // window has its own active terminal buffer.
966    /// Whether keyboard capture is enabled in terminal mode.
967    /// When true, ALL keys go to the terminal (except Ctrl+` to toggle).
968    /// When false, UI keybindings (split nav, palette, etc.) are processed first.
969    // `keyboard_capture` moved onto `Window`.
970
971    // A terminal buffer's remembered live/scrollback interaction mode is
972    // part of its per-window `Window::terminal_buffers` record
973    // (`TerminalBuffer::mode`).
974    /// Timestamp of the previous mouse click (for multi-click detection)
975    // `previous_click_time`, `previous_click_position`, `click_count` moved onto `Window`.
976
977    /// Settings UI state (when settings modal is open)
978    pub(crate) settings_state: Option<crate::view::settings::SettingsState>,
979
980    /// Calibration wizard state (when calibration modal is open)
981    pub(crate) calibration_wizard: Option<calibration_wizard::CalibrationWizard>,
982
983    // `event_debug` modal state moved to `Window` — each window has its
984    // own debug overlay (the dialog records keystrokes destined for that
985    // window's input pipeline, so it's logically per-window).
986    /// Keybinding editor state (when keybinding editor modal is open)
987    pub(crate) keybinding_editor: Option<keybinding_editor::KeybindingEditor>,
988
989    /// Key translator for input calibration (loaded from config)
990    pub(crate) key_translator: crate::input::key_translator::KeyTranslator,
991
992    /// Terminal color capability (true color, 256, or 16 colors)
993    color_capability: crate::view::color_support::ColorCapability,
994
995    /// Hunks for the Review Diff tool
996    // `review_hunks` moved onto `Window`.
997
998    /// Editor-level popups that float above any buffer regardless of which
999    /// one is active. Plugin notifications (showActionPopup) live here so a
1000    /// switch to a virtual buffer (Dashboard, diagnostics panel, …) doesn't
1001    /// hide them mid-decision.
1002    ///
1003    /// Each plugin popup carries its `popup_id` inside its
1004    /// `PopupResolver::PluginAction` — no parallel side-channel stack.
1005    pub(crate) global_popups: crate::view::popup::PopupManager,
1006
1007    // composite_buffers + composite_view_states moved onto `Window` —
1008    // composite-buffer panels (Live Grep results, Diagnostics list,
1009    // References, etc.) belong to the window that opened the panel.
1010    /// Pending file opens from CLI arguments (processed after TUI starts)
1011    /// This allows CLI files to go through the same code path as interactive file opens,
1012    /// ensuring consistent error handling (e.g., encoding confirmation prompts).
1013    // `pending_file_opens` moved onto `Window`.
1014
1015    /// When true, apply hot exit recovery after the next batch of pending file opens
1016    // `pending_hot_exit_recovery` moved onto `Window`.
1017
1018    /// Tracks buffers opened with --wait: maps buffer_id → (wait_id, has_popup)
1019    // `wait_tracking` moved onto `Window`.
1020    /// Wait IDs that have completed (buffer closed or popup dismissed)
1021    // `completed_waits` moved onto `Window`.
1022
1023    /// Stdin streaming state (if reading from stdin)
1024    stdin_stream: stdin_stream::StdinStream,
1025
1026    /// Incremental line scan state (for non-blocking progress during Go to Line)
1027    // `line_scan` moved onto `Window`.
1028
1029    /// Incremental search scan state (for non-blocking search on large files)
1030    // `search_scan` moved onto `Window`.
1031
1032    /// Viewport top_byte when search overlays were last refreshed.
1033    /// Used to detect viewport scrolling so overlays can be updated.
1034    // `search_overlay_top_byte` moved onto `Window`.
1035
1036    /// Frame-buffer animation layer. Applied at the end of `render`; the
1037    /// main loop consults `is_active`/`next_deadline` to keep re-rendering
1038    /// while animations are running.
1039    // `animations` moved onto `Window`.
1040
1041    /// Hardware-cursor screen position from the previous render pass, paired
1042    /// with the active split that owned the cursor at that time. Used to
1043    /// detect "jumps" (search, goto-line, click, goto-definition, focus
1044    /// change between splits, tab/buffer switch, etc.) and animate the
1045    /// cursor moving from its old screen position to its new one. Cross-
1046    /// pane jumps animate unconditionally; same-pane jumps animate when
1047    /// the cursor moved more than two rows or at least ten columns.
1048    pub(crate) previous_cursor_screen_pos: Option<((u16, u16), LeafId)>,
1049    /// ID of the most recent cursor-jump animation, kept so successive jumps
1050    /// cancel the prior one instead of stacking trail effects.
1051    pub(crate) cursor_jump_animation: Option<crate::view::animation::AnimationId>,
1052
1053    /// Deferred plugin animations targeting a virtual buffer whose
1054    /// on-screen Rect wasn't in the cached split layout at command
1055    /// dispatch time. Drained at the top of each render pass once
1056    /// `split_areas` has been recomputed, so the animation starts on
1057    /// the very first frame the buffer actually occupies screen space.
1058    pub(crate) pending_vb_animations: Vec<(u64, BufferId, fresh_core::api::PluginAnimationKind)>,
1059
1060    /// Plugin widget panels mounted via `MountWidgetPanel`.
1061    ///
1062    /// One entry per active panel. The registry holds the most recent
1063    /// `WidgetSpec` per panel so future updates can reconcile against
1064    /// it and so a theme change can re-render every panel without
1065    /// the originating plugin needing to re-emit. See
1066    /// `docs/internal/plugin-widget-library-design.md`.
1067    pub(crate) widget_registry: crate::widgets::WidgetRegistry,
1068
1069    /// Currently-mounted floating widget panel, if any.
1070    ///
1071    /// At most one floating widget panel is shown at a time — the
1072    /// overlay is modal-ish (input is routed to it) and stacking
1073    /// floaters would obscure each other without a usable focus
1074    /// model. Mounting a second one replaces the first.
1075    ///
1076    /// This is the **centered modal** slot (`PanelSlot::Floating`): the
1077    /// orchestrator picker, the new-session form, and other plugin
1078    /// modals. The persistent left dock lives in a separate slot
1079    /// (`dock`) so the two can coexist (a modal opens *over* the editor
1080    /// while the dock stays visible). Routing is by `PanelSlot`.
1081    pub(crate) floating_widget_panel: Option<FloatingWidgetState>,
1082
1083    /// The editor-global left **dock** panel (`PanelSlot::Dock`), if
1084    /// shown. Independent of `floating_widget_panel` so the dock persists
1085    /// while a centered modal is open. Always rendered as a `LeftDock`.
1086    pub(crate) dock: Option<FloatingWidgetState>,
1087
1088    /// Persisted width (columns) of the orchestrator left dock after the
1089    /// user drags its right border. `None` until first resized; when set,
1090    /// `FloatingPanelControl{op:"dock"}` restores this instead of the
1091    /// plugin's default so the width survives toggling the dock off/on.
1092    pub(crate) dock_width: Option<u16>,
1093    /// True while the user is dragging the dock's right border to resize.
1094    pub(crate) dock_resizing: bool,
1095}
1096
1097/// Sentinel `BufferId` registered with the widget registry for the
1098/// floating panel — never appears in the editor's buffer table, so
1099/// `set_virtual_buffer_content` against it would fail. The mount /
1100/// rerender paths special-case this id and paint into the overlay
1101/// rect instead.
1102pub(crate) const FLOATING_PANEL_BUFFER_ID: BufferId = BufferId(usize::MAX);
1103
1104/// Sentinel `BufferId` for the editor-global **dock** panel — distinct
1105/// from `FLOATING_PANEL_BUFFER_ID` so the dock and a centered modal can
1106/// both live in the widget registry (and be hit-tested) at the same
1107/// time. See `Editor::dock` and `PanelSlot`.
1108pub(crate) const DOCK_PANEL_BUFFER_ID: BufferId = BufferId(usize::MAX - 1);
1109
1110/// Selects which of the two coexisting widget-panel slots an operation
1111/// targets: the centered modal overlay (`Floating`, the picker /
1112/// new-session form / plugin modals) or the persistent editor-global
1113/// left `Dock`. They occupy disjoint screen regions and render together.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub(crate) enum PanelSlot {
1116    Floating,
1117    Dock,
1118}
1119
1120impl PanelSlot {
1121    pub(crate) fn buffer_id(self) -> BufferId {
1122        match self {
1123            PanelSlot::Floating => FLOATING_PANEL_BUFFER_ID,
1124            PanelSlot::Dock => DOCK_PANEL_BUFFER_ID,
1125        }
1126    }
1127}
1128
1129/// A widget panel rendered as a centered floating overlay rather
1130/// than into a virtual buffer. The panel still lives in the shared
1131/// `widget_registry` (so the existing reconcile / widget-command /
1132/// mutate paths apply), but its rendered entries are painted into
1133/// the overlay rect at draw time instead of being written into a
1134/// virtual buffer.
1135/// Where a floating widget panel is anchored on screen.
1136//
1137// The variants are only *constructed* by the plugin-gated floating-panel
1138// handlers, but they are matched throughout the shared widget runtime and
1139// render/input code, so the enum itself stays un-gated. Suppress the
1140// "never constructed" lint in plugin-less builds.
1141#[cfg_attr(not(any(feature = "plugins", test)), allow(dead_code))]
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143pub(crate) enum PanelPlacement {
1144    /// Centered modal overlay sized by `width_pct`/`height_pct`
1145    /// (the historical default; dims the background, captures keys).
1146    Centered,
1147    /// Full-height column pinned to the left of the entire editor
1148    /// chrome (left of the menu bar, splits, and status bar). The
1149    /// chrome is laid out in the remaining width; no background
1150    /// dimming. Non-modal — see `FloatingWidgetState::focused`.
1151    LeftDock { width_cols: u16 },
1152    /// Content-sized popup anchored near a screen cell — a right-click
1153    /// context menu. Drawn at `(x, y)` (clamped to stay fully on
1154    /// screen), sized to its rendered content, with **no** background
1155    /// dimming, so it reads as an unobtrusive popup rather than a modal.
1156    /// Still input-modal via the `FloatingModal` layer: keys route to it
1157    /// and a click outside dismisses it.
1158    Anchored { x: u16, y: u16 },
1159}
1160
1161#[derive(Debug, Clone)]
1162pub(crate) struct FloatingWidgetState {
1163    /// Composite (plugin, id) identity of the mounted panel.
1164    pub panel_key: crate::widgets::PanelKey,
1165    pub width_pct: u8,
1166    pub height_pct: u8,
1167    /// On-screen anchor. Defaults to `Centered` on mount; a plugin
1168    /// re-anchors to a dock via `FloatingPanelControl{op:"dock"}`.
1169    pub placement: PanelPlacement,
1170    /// Whether keys route to this panel. Always true for `Centered`
1171    /// (modal). For `LeftDock` a plugin toggles this via
1172    /// `FloatingPanelControl{op:"focus"|"blur"}` so the editor
1173    /// underneath stays keyboard-usable while the dock is visible.
1174    pub focused: bool,
1175    /// Most-recently rendered entries. Refreshed on every spec /
1176    /// command / mutate; painted into the overlay rect at draw
1177    /// time.
1178    pub entries: Vec<fresh_core::text_property::TextPropertyEntry>,
1179    /// Hardware-cursor target when a `TextInput` is focused.
1180    pub focus_cursor: Option<crate::widgets::FocusCursor>,
1181    /// Window-embed rectangles reserved by `WindowEmbed`
1182    /// widgets in the panel's spec. After the entries paint
1183    /// down their (blank) cells, the floating panel render
1184    /// walks these and invokes the per-window paint path
1185    /// scoped to each rect — giving us a live render of the
1186    /// referenced editor window inside the floating overlay.
1187    pub embeds: Vec<crate::widgets::EmbedRect>,
1188    /// Rows produced by `WidgetSpec::Overlay` children. Painted
1189    /// AFTER `entries` and `embeds`, on top of whatever's at
1190    /// each `buffer_row`. Used for dropdown completions /
1191    /// transient popups that should appear next to a focused
1192    /// widget without reflowing the rest of the panel when
1193    /// they show or hide.
1194    pub overlays: Vec<crate::widgets::OverlayRow>,
1195    /// Scrollable `List` widgets that overflowed, with the geometry
1196    /// the draw pass uses to paint a scrollbar. Refreshed on every
1197    /// render alongside `entries`/`embeds`.
1198    pub scroll_regions: Vec<crate::widgets::ScrollRegion>,
1199    /// Screen-space scrollbar tracks computed at the last draw — used
1200    /// by the mouse hit-test to start/continue a scrollbar drag. One
1201    /// per overflowing list.
1202    pub scrollbar_tracks: Vec<WidgetScrollbarTrack>,
1203    /// Shared press/drag/release state for the panel's list
1204    /// scrollbars (the canonical `ScrollbarMouse`).
1205    pub scrollbar_mouse: crate::view::ui::scrollbar::ScrollbarMouse,
1206    /// `list_key` of the scrollbar currently being drag-scrolled.
1207    pub scrollbar_drag_key: Option<String>,
1208    /// Inner rect (frame interior) of the last draw — used by the
1209    /// click hit-test to map terminal coords back to buffer coords.
1210    pub last_inner_rect: Option<ratatui::layout::Rect>,
1211    /// Screen-space regions (one per overflowing list) over which the
1212    /// pointer reveals that list's overlay scrollbar. Refreshed every
1213    /// draw alongside `scrollbar_tracks`; empty when no list overflows.
1214    /// Only the dock populates this — its scrollbars are hover-revealed.
1215    pub scrollbar_hover_zones: Vec<ratatui::layout::Rect>,
1216    /// Whether the pointer was last seen inside a `scrollbar_hover_zone`.
1217    /// Memoised so the mouse-move handler only forces a re-render on the
1218    /// enter/leave transition rather than on every motion event.
1219    pub scrollbar_zone_hovered: bool,
1220    /// When true, a `Centered` panel renders over the *entire* frame
1221    /// (covering the dimmed left dock) instead of being confined to the
1222    /// chrome area beside the dock. Set via `FloatingPanelControl{op:
1223    /// "fullscreen"}`. Lets the orchestrator's global modals (the control
1224    /// room, the New-Session form) take the full screen over their own
1225    /// dock, while other plugins' floating panels keep the default
1226    /// coexist-beside-the-dock layout. Ignored for `LeftDock`.
1227    pub fullscreen: bool,
1228    /// When true, this panel renders through `render_spec_with_marker`:
1229    /// every focusable control reserves a two-column gutter for the
1230    /// `▸ ` focus marker so focus is legible from a plain capture and
1231    /// the layout stays constant as focus moves. Opt-in at mount
1232    /// (`MountFloatingWidget.focus_marker`); the Orchestrator New
1233    /// Session form uses it.
1234    pub focus_marker: bool,
1235}
1236
1237/// A list scrollbar's screen rect + scroll state, captured at draw
1238/// time so mouse press/drag can hit-test and drive `ScrollbarMouse`.
1239#[derive(Debug, Clone)]
1240pub(crate) struct WidgetScrollbarTrack {
1241    pub list_key: String,
1242    pub rect: ratatui::layout::Rect,
1243    pub total: usize,
1244    pub visible: usize,
1245    pub scroll: usize,
1246}
1247
1248/// A file that should be opened after the TUI starts
1249#[derive(Debug, Clone)]
1250pub struct PendingFileOpen {
1251    /// Path to the file
1252    pub path: PathBuf,
1253    /// Line number to navigate to (1-indexed, optional)
1254    pub line: Option<usize>,
1255    /// Column number to navigate to (1-indexed, optional)
1256    pub column: Option<usize>,
1257    /// End line for range selection (1-indexed, optional)
1258    pub end_line: Option<usize>,
1259    /// End column for range selection (1-indexed, optional)
1260    pub end_column: Option<usize>,
1261    /// Hover popup message to show after opening (optional)
1262    pub message: Option<String>,
1263    /// Wait ID for --wait tracking (if the CLI is blocking until done)
1264    pub wait_id: Option<u64>,
1265}
1266
1267impl Editor {
1268    /// Load an ANSI background image from a user-provided path
1269    fn load_ansi_background(&mut self, input: &str) -> AnyhowResult<()> {
1270        let trimmed = input.trim();
1271
1272        if trimmed.is_empty() {
1273            self.ansi_background = None;
1274            self.ansi_background_path = None;
1275            self.set_status_message(t!("status.background_cleared").to_string());
1276            return Ok(());
1277        }
1278
1279        let input_path = Path::new(trimmed);
1280        let resolved = if input_path.is_absolute() {
1281            input_path.to_path_buf()
1282        } else {
1283            self.working_dir().join(input_path)
1284        };
1285
1286        let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
1287
1288        let parsed = crate::primitives::ansi_background::AnsiBackground::from_file(&canonical)?;
1289
1290        self.ansi_background = Some(parsed);
1291        self.ansi_background_path = Some(canonical.clone());
1292        self.set_status_message(
1293            t!(
1294                "view.background_set",
1295                path = canonical.display().to_string()
1296            )
1297            .to_string(),
1298        );
1299
1300        Ok(())
1301    }
1302
1303    /// Total number of open buffers across the workspace. Test
1304    /// support for `EditorTestApi::buffer_count` (Phase 7 of the
1305    /// scenario migration).
1306    #[doc(hidden)]
1307    pub fn buffer_count_for_tests(&self) -> usize {
1308        self.windows
1309            .get(&self.active_window)
1310            .map(|w| &w.buffers)
1311            .expect("active window present")
1312            .len()
1313    }
1314
1315    /// Buffer IDs in stable order (sorted by inner value). Used by
1316    /// `EditorTestApi::buffer_paths` so workspace assertions don't
1317    /// depend on `HashMap` iteration order.
1318    #[doc(hidden)]
1319    pub fn all_buffer_ids_for_tests(&self) -> Vec<BufferId> {
1320        let mut ids: Vec<BufferId> = self
1321            .windows
1322            .get(&self.active_window)
1323            .map(|w| &w.buffers)
1324            .expect("active window present")
1325            .ids();
1326        ids.sort_by_key(|id| id.0);
1327        ids
1328    }
1329
1330    /// Get the currently active buffer state
1331    pub fn active_state(&self) -> &EditorState {
1332        self.windows
1333            .get(&self.active_window)
1334            .map(|w| &w.buffers)
1335            .expect("active window present")
1336            .get(&self.active_buffer())
1337            .unwrap()
1338    }
1339
1340    /// Get the currently active buffer state (mutable)
1341    pub fn active_state_mut(&mut self) -> &mut EditorState {
1342        let __buffer_id = self.active_buffer();
1343        self.windows
1344            .get_mut(&self.active_window)
1345            .map(|w| &mut w.buffers)
1346            .expect("active window present")
1347            .get_mut(&__buffer_id)
1348            .unwrap()
1349    }
1350
1351    /// Get the cursors for the active buffer in the active split.
1352    /// Uses `effective_active_split` so focused buffer-group panels return
1353    /// their own cursors (not the outer split's stale ones).
1354    pub fn active_cursors(&self) -> &Cursors {
1355        let split_id = self.effective_active_split();
1356        &self
1357            .windows
1358            .get(&self.active_window)
1359            .and_then(|w| w.buffers.splits())
1360            .map(|(_, vs)| vs)
1361            .expect("active window must have a populated split layout")
1362            .get(&split_id)
1363            .unwrap()
1364            .cursors
1365    }
1366
1367    /// Get the cursors for the active buffer in the active split (mutable)
1368    pub fn active_cursors_mut(&mut self) -> &mut Cursors {
1369        let split_id = self.effective_active_split();
1370        &mut self
1371            .windows
1372            .get_mut(&self.active_window)
1373            .and_then(|w| w.split_view_states_mut())
1374            .expect("active window must have a populated split layout")
1375            .get_mut(&split_id)
1376            .unwrap()
1377            .cursors
1378    }
1379
1380    /// Set completion items for type-to-filter (for testing)
1381    pub fn set_completion_items(&mut self, items: Vec<lsp_types::CompletionItem>) {
1382        self.active_window_mut().completion_items = Some(items);
1383    }
1384
1385    /// Get the viewport for the active split
1386    pub fn active_viewport(&self) -> &crate::view::viewport::Viewport {
1387        let active_split = self
1388            .windows
1389            .get(&self.active_window)
1390            .and_then(|w| w.buffers.splits())
1391            .map(|(mgr, _)| mgr)
1392            .expect("active window must have a populated split layout")
1393            .active_split();
1394        &self
1395            .windows
1396            .get(&self.active_window)
1397            .and_then(|w| w.buffers.splits())
1398            .map(|(_, vs)| vs)
1399            .expect("active window must have a populated split layout")
1400            .get(&active_split)
1401            .unwrap()
1402            .viewport
1403    }
1404
1405    /// Get the viewport for the active split (mutable)
1406    pub fn active_viewport_mut(&mut self) -> &mut crate::view::viewport::Viewport {
1407        let active_split = self
1408            .windows
1409            .get(&self.active_window)
1410            .and_then(|w| w.buffers.splits())
1411            .map(|(mgr, _)| mgr)
1412            .expect("active window must have a populated split layout")
1413            .active_split();
1414        &mut self
1415            .windows
1416            .get_mut(&self.active_window)
1417            .and_then(|w| w.split_view_states_mut())
1418            .expect("active window must have a populated split layout")
1419            .get_mut(&active_split)
1420            .unwrap()
1421            .viewport
1422    }
1423
1424    /// Width (in cells) of the line-number gutter for a given split leaf, or 0
1425    /// when that split hides line numbers. The same value the renderer uses, so
1426    /// a frontend can peel the gutter off the buffer text at the exact column.
1427    pub fn leaf_gutter_width(&self, leaf: fresh_core::LeafId, buffer_id: BufferId) -> u16 {
1428        let Some(w) = self.windows.get(&self.active_window) else {
1429            return 0;
1430        };
1431        let Some((_, view_states)) = w.buffers.splits() else {
1432            return 0;
1433        };
1434        let Some(vs) = view_states.get(&leaf) else {
1435            return 0;
1436        };
1437        if !vs.show_line_numbers {
1438            return 0;
1439        }
1440        match w.buffers.get(&buffer_id) {
1441            Some(state) => vs.viewport.gutter_width(&state.buffer) as u16,
1442            None => 0,
1443        }
1444    }
1445
1446    /// Get the display name for a buffer (filename or virtual buffer name)
1447    pub fn get_buffer_display_name(&self, buffer_id: BufferId) -> String {
1448        // Check composite buffers first
1449        if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1450            return composite.name.clone();
1451        }
1452
1453        self.active_window()
1454            .buffer_metadata
1455            .get(&buffer_id)
1456            .map(|m| m.display_name.clone())
1457            .or_else(|| {
1458                self.windows
1459                    .get(&self.active_window)
1460                    .map(|w| &w.buffers)
1461                    .expect("active window present")
1462                    .get(&buffer_id)
1463                    .and_then(|state| {
1464                        state
1465                            .buffer
1466                            .file_path()
1467                            .and_then(|p| p.file_name())
1468                            .and_then(|n| n.to_str())
1469                            .map(|s| s.to_string())
1470                    })
1471            })
1472            .unwrap_or_else(|| "[No Name]".to_string())
1473    }
1474
1475    /// Apply an event to the active buffer with all cross-cutting concerns.
1476    /// This is the centralized method that automatically handles:
1477    /// - Event application to buffer
1478    /// - Plugin hooks (after-insert, after-delete, etc.)
1479    /// - LSP notifications
1480    /// - Any other cross-cutting concerns
1481    ///
1482
1483    /// Get the event log for the active buffer
1484    pub fn active_event_log(&self) -> &EventLog {
1485        self.active_window()
1486            .event_logs
1487            .get(&self.active_buffer())
1488            .unwrap()
1489    }
1490
1491    /// Get the event log for the active buffer (mutable)
1492    pub fn active_event_log_mut(&mut self) -> &mut EventLog {
1493        let buffer_id = self.active_buffer();
1494        self.active_window_mut()
1495            .event_logs
1496            .get_mut(&buffer_id)
1497            .unwrap()
1498    }
1499
1500    /// Register a status-bar token contributed by a plugin.
1501    /// Token key is "plugin_name:token_name".
1502    /// Returns Err if token already registered or inputs are invalid.
1503    pub fn register_status_bar_element(
1504        &self,
1505        plugin_name: &str,
1506        token_name: &str,
1507        title: &str,
1508    ) -> Result<(), String> {
1509        if plugin_name.is_empty() {
1510            return Err("Plugin name cannot be empty".to_string());
1511        }
1512        if token_name.is_empty() {
1513            return Err("Token name cannot be empty".to_string());
1514        }
1515
1516        let key = format!("{}:{}", plugin_name, token_name);
1517        let mut registry = self.status_bar_token_registry.lock().unwrap();
1518
1519        if registry.contains_key(&key) {
1520            return Err(format!("Token '{}' already registered", key));
1521        }
1522
1523        registry.insert(key, title.to_string());
1524        Ok(())
1525    }
1526
1527    /// Set the value for a status-bar token on a specific buffer.
1528    /// Key format: "plugin_name:token_name". The buffer is located across
1529    /// every window — buffers are uniquely owned, so at most one window
1530    /// matches.
1531    pub fn set_status_bar_value(
1532        &mut self,
1533        buffer_id: BufferId,
1534        key: &str,
1535        value: String,
1536    ) -> Result<(), String> {
1537        for window in self.windows.values_mut() {
1538            if window.buffers.contains_key(&buffer_id) {
1539                window
1540                    .status_bar_values
1541                    .entry(buffer_id)
1542                    .or_default()
1543                    .insert(key.to_string(), value);
1544                return Ok(());
1545            }
1546        }
1547        Err(format!("Buffer {:?} not found", buffer_id))
1548    }
1549
1550    /// Get all registered status bar tokens for Settings UI.
1551    /// Returns Vec of (token_key_with_braces, title).
1552    pub fn get_status_bar_elements(&self) -> Vec<(String, String)> {
1553        self.status_bar_token_registry
1554            .lock()
1555            .unwrap()
1556            .iter()
1557            .map(|(k, title)| (format!("{{{}}}", k), title.clone()))
1558            .collect()
1559    }
1560
1561    /// Snapshot of token values for a specific buffer (render path).
1562    pub fn get_status_bar_element_values(&self, buffer_id: BufferId) -> HashMap<String, String> {
1563        for window in self.windows.values() {
1564            if let Some(values) = window.status_bar_values.get(&buffer_id) {
1565                return values.clone();
1566            }
1567        }
1568        HashMap::new()
1569    }
1570
1571    /// Current value of a single status-bar token for the given buffer, if any.
1572    /// Used by the plugin command dispatcher to detect no-op `SetStatusBarValue`
1573    /// commands (i.e. plugins re-publishing an unchanged value on every render).
1574    pub fn current_status_bar_value(&self, buffer_id: BufferId, key: &str) -> Option<&str> {
1575        for window in self.windows.values() {
1576            if let Some(values) = window.status_bar_values.get(&buffer_id) {
1577                if let Some(v) = values.get(key) {
1578                    return Some(v.as_str());
1579                }
1580                return None;
1581            }
1582        }
1583        None
1584    }
1585
1586    /// Remove every registry entry and per-buffer value belonging to a plugin.
1587    /// Called when a plugin is unloaded.
1588    fn remove_plugin_status_bar_elements(&mut self, plugin_name: &str) {
1589        let prefix = format!("{}:", plugin_name);
1590        self.status_bar_token_registry
1591            .lock()
1592            .unwrap()
1593            .retain(|k, _| !k.starts_with(&prefix));
1594        for window in self.windows.values_mut() {
1595            for values in window.status_bar_values.values_mut() {
1596                values.retain(|k, _| !k.starts_with(&prefix));
1597            }
1598        }
1599    }
1600}
1601
1602/// Parse a key string like "RET", "C-n", "M-x", "q" into KeyCode and KeyModifiers
1603///
1604/// Supports:
1605/// - Single characters: "a", "q", etc.
1606/// - Function keys: "F1", "F2", etc.
1607/// - Special keys: "RET", "TAB", "ESC", "SPC", "DEL", "BS"
1608/// - Modifiers: "C-" (Control), "M-" (Alt/Meta), "S-" (Shift)
1609/// - Combinations: "C-n", "M-x", "C-M-s", etc.
1610#[cfg(any(feature = "plugins", test))]
1611fn parse_key_string(key_str: &str) -> Option<(KeyCode, KeyModifiers)> {
1612    use crossterm::event::{KeyCode, KeyModifiers};
1613
1614    let mut modifiers = KeyModifiers::NONE;
1615    let mut remaining = key_str;
1616
1617    // Parse modifiers
1618    loop {
1619        if remaining.starts_with("C-") {
1620            modifiers |= KeyModifiers::CONTROL;
1621            remaining = &remaining[2..];
1622        } else if remaining.starts_with("M-") {
1623            modifiers |= KeyModifiers::ALT;
1624            remaining = &remaining[2..];
1625        } else if remaining.starts_with("S-") {
1626            modifiers |= KeyModifiers::SHIFT;
1627            remaining = &remaining[2..];
1628        } else {
1629            break;
1630        }
1631    }
1632
1633    // Parse the key
1634    // Use uppercase for matching special keys, but preserve original for single chars
1635    let upper = remaining.to_uppercase();
1636    let code = match upper.as_str() {
1637        "RET" | "RETURN" | "ENTER" => KeyCode::Enter,
1638        "TAB" => KeyCode::Tab,
1639        "BACKTAB" => KeyCode::BackTab,
1640        "ESC" | "ESCAPE" => KeyCode::Esc,
1641        "SPC" | "SPACE" => KeyCode::Char(' '),
1642        "DEL" | "DELETE" => KeyCode::Delete,
1643        "BS" | "BACKSPACE" => KeyCode::Backspace,
1644        "UP" => KeyCode::Up,
1645        "DOWN" => KeyCode::Down,
1646        "LEFT" => KeyCode::Left,
1647        "RIGHT" => KeyCode::Right,
1648        "HOME" => KeyCode::Home,
1649        "END" => KeyCode::End,
1650        "PAGEUP" | "PGUP" => KeyCode::PageUp,
1651        "PAGEDOWN" | "PGDN" => KeyCode::PageDown,
1652        s if s.starts_with('F') && s.len() > 1 => {
1653            // Function key (F1-F12)
1654            if let Ok(n) = s[1..].parse::<u8>() {
1655                KeyCode::F(n)
1656            } else {
1657                return None;
1658            }
1659        }
1660        _ if remaining.len() == 1 => {
1661            // Single character - use ORIGINAL remaining, not uppercased
1662            // For uppercase letters, add SHIFT modifier so 'J' != 'j'
1663            let c = remaining.chars().next()?;
1664            if c.is_ascii_uppercase() {
1665                modifiers |= KeyModifiers::SHIFT;
1666            }
1667            KeyCode::Char(c.to_ascii_lowercase())
1668        }
1669        _ => return None,
1670    };
1671
1672    // Plugins commonly spell Shift+Tab as "S-Tab"; terminals deliver
1673    // BackTab and the lookup-side `normalize_key` strips the redundant
1674    // SHIFT. Normalize on the binding side too so "S-Tab" and "BackTab"
1675    // both register as `(BackTab, NONE)` and match.
1676    if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
1677        return Some((KeyCode::BackTab, modifiers.difference(KeyModifiers::SHIFT)));
1678    }
1679
1680    Some((code, modifiers))
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685    use super::*;
1686    use lsp_types::{Position, Range as LspRange, TextDocumentContentChangeEvent};
1687    use tempfile::TempDir;
1688
1689    /// Create a test DirectoryContext with temp directories
1690    fn test_dir_context() -> (DirectoryContext, TempDir) {
1691        let temp_dir = TempDir::new().unwrap();
1692        let dir_context = DirectoryContext::for_testing(temp_dir.path());
1693        (dir_context, temp_dir)
1694    }
1695
1696    /// Create a test filesystem
1697    fn test_filesystem() -> Arc<dyn FileSystem + Send + Sync> {
1698        Arc::new(crate::model::filesystem::StdFileSystem)
1699    }
1700
1701    #[test]
1702    fn parse_key_string_shift_tab_normalizes_to_backtab() {
1703        use crossterm::event::{KeyCode, KeyModifiers};
1704        // Plugins write "S-Tab" in their defineMode binding tables; the
1705        // terminal delivers BackTab (with SHIFT stripped by normalize_key
1706        // on lookup). Without this normalization, the binding never
1707        // matches.
1708        assert_eq!(
1709            parse_key_string("S-Tab"),
1710            Some((KeyCode::BackTab, KeyModifiers::NONE)),
1711        );
1712        assert_eq!(
1713            parse_key_string("BackTab"),
1714            Some((KeyCode::BackTab, KeyModifiers::NONE)),
1715        );
1716        // Plain Tab is unaffected.
1717        assert_eq!(
1718            parse_key_string("Tab"),
1719            Some((KeyCode::Tab, KeyModifiers::NONE)),
1720        );
1721    }
1722
1723    #[test]
1724    fn test_editor_new() {
1725        let config = Config::default();
1726        let (dir_context, _temp) = test_dir_context();
1727        let editor = Editor::new(
1728            config,
1729            80,
1730            24,
1731            dir_context,
1732            crate::view::color_support::ColorCapability::TrueColor,
1733            test_filesystem(),
1734        )
1735        .unwrap();
1736
1737        assert_eq!(editor.buffers().len(), 1);
1738        assert!(!editor.should_quit());
1739    }
1740
1741    fn default_test_editor() -> Editor {
1742        let (dir_context, temp) = test_dir_context();
1743        // Keep the temp dir alive for the editor's lifetime.
1744        std::mem::forget(temp);
1745        Editor::new(
1746            Config::default(),
1747            80,
1748            24,
1749            dir_context,
1750            crate::view::color_support::ColorCapability::TrueColor,
1751            test_filesystem(),
1752        )
1753        .unwrap()
1754    }
1755
1756    fn test_panel(placement: PanelPlacement, focused: bool) -> FloatingWidgetState {
1757        FloatingWidgetState {
1758            panel_key: crate::widgets::PanelKey::new("test-plugin", 1),
1759            width_pct: 50,
1760            height_pct: 50,
1761            placement,
1762            focused,
1763            entries: Vec::new(),
1764            focus_cursor: None,
1765            embeds: Vec::new(),
1766            overlays: Vec::new(),
1767            scroll_regions: Vec::new(),
1768            scrollbar_tracks: Vec::new(),
1769            scrollbar_mouse: Default::default(),
1770            scrollbar_drag_key: None,
1771            last_inner_rect: None,
1772            scrollbar_hover_zones: Vec::new(),
1773            scrollbar_zone_hovered: false,
1774            fullscreen: false,
1775            focus_marker: false,
1776        }
1777    }
1778
1779    /// The overlay layer stack always terminates in the editor base layer,
1780    /// which owns the keyboard, so a fresh editor resolves to its active
1781    /// window's context.
1782    #[test]
1783    fn overlay_stack_base_layer_owns_keyboard() {
1784        use crate::app::overlay::LayerKind;
1785        use crate::input::keybindings::KeyContext;
1786
1787        let editor = default_test_editor();
1788        let layers = editor.overlay_layers();
1789        let base = layers.last().expect("at least the base layer");
1790        assert_eq!(base.kind, LayerKind::Editor);
1791        assert!(base.owns_keyboard);
1792        assert!(!base.blocks_terminal_input);
1793        assert_eq!(editor.get_key_context(), KeyContext::Normal);
1794    }
1795
1796    /// P1 invariant preserved through the P2 layer walk: a *focused* dock
1797    /// owns the keyboard (`KeyContext::Dock`); once blurred it falls
1798    /// through to the editor underneath so the buffer stays usable.
1799    #[test]
1800    fn focused_dock_owns_keyboard_blurred_falls_through() {
1801        use crate::input::keybindings::KeyContext;
1802
1803        let mut editor = default_test_editor();
1804        editor.dock = Some(test_panel(
1805            PanelPlacement::LeftDock { width_cols: 30 },
1806            true,
1807        ));
1808        assert_eq!(editor.get_key_context(), KeyContext::Dock);
1809
1810        editor.dock.as_mut().unwrap().focused = false;
1811        assert_eq!(editor.get_key_context(), KeyContext::Normal);
1812    }
1813
1814    /// A focused centered modal outranks a focused dock — when the
1815    /// new-session form opens on top of the dock, the modal owns input.
1816    #[test]
1817    fn centered_modal_outranks_dock() {
1818        use crate::input::keybindings::KeyContext;
1819
1820        let mut editor = default_test_editor();
1821        editor.dock = Some(test_panel(
1822            PanelPlacement::LeftDock { width_cols: 30 },
1823            true,
1824        ));
1825        editor.floating_widget_panel = Some(test_panel(PanelPlacement::Centered, true));
1826        // The centered modal resolves as Normal (not Dock).
1827        assert_eq!(editor.get_key_context(), KeyContext::Normal);
1828    }
1829
1830    /// F3: hiding the left dock (Toggle Dock → unmount) must request a
1831    /// full clear+redraw so the freed column is repainted unconditionally,
1832    /// rather than risking stale glyphs lingering as a left "gutter" until
1833    /// an unrelated event. The on-screen reflow is covered by the
1834    /// `dock_close_reflows_buffer_to_full_width` e2e test; this asserts the
1835    /// redraw *request* the e2e harness can't observe (it drives renders
1836    /// unconditionally, so it can't see a missing redraw).
1837    ///
1838    /// Gated on `plugins`: it drives the unmount through the plugin
1839    /// command path (`handle_plugin_command`), which only exists when the
1840    /// plugin runtime is compiled in.
1841    #[cfg(feature = "plugins")]
1842    #[test]
1843    fn unmounting_left_dock_requests_full_redraw() {
1844        use fresh_core::api::PluginCommand;
1845
1846        let mut editor = default_test_editor();
1847        editor.dock = Some(test_panel(
1848            PanelPlacement::LeftDock { width_cols: 30 },
1849            true,
1850        ));
1851        // Drop any redraw request left over from construction.
1852        let _ = editor.take_full_redraw_request();
1853
1854        editor
1855            .handle_plugin_command(PluginCommand::UnmountFloatingWidget {
1856                plugin: "test-plugin".to_string(),
1857                panel_id: 1,
1858            })
1859            .unwrap();
1860
1861        assert!(editor.dock.is_none(), "dock should be unmounted");
1862        assert!(
1863            editor.take_full_redraw_request(),
1864            "hiding the dock must request a full redraw so the freed \
1865             column is repainted immediately"
1866        );
1867    }
1868
1869    /// The dock-only gate: closing a *centered* modal overlays the
1870    /// full-width chrome without carving it, so it must NOT force a
1871    /// full-screen clear (that would only flicker).
1872    #[cfg(feature = "plugins")]
1873    #[test]
1874    fn unmounting_centered_modal_does_not_request_full_redraw() {
1875        use fresh_core::api::PluginCommand;
1876
1877        let mut editor = default_test_editor();
1878        editor.floating_widget_panel = Some(test_panel(PanelPlacement::Centered, true));
1879        let _ = editor.take_full_redraw_request();
1880
1881        editor
1882            .handle_plugin_command(PluginCommand::UnmountFloatingWidget {
1883                plugin: "test-plugin".to_string(),
1884                panel_id: 1,
1885            })
1886            .unwrap();
1887
1888        assert!(
1889            editor.floating_widget_panel.is_none(),
1890            "centered modal should be unmounted"
1891        );
1892        assert!(
1893            !editor.take_full_redraw_request(),
1894            "closing a centered modal must not force a full-screen clear"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_new_buffer() {
1900        let config = Config::default();
1901        let (dir_context, _temp) = test_dir_context();
1902        let mut editor = Editor::new(
1903            config,
1904            80,
1905            24,
1906            dir_context,
1907            crate::view::color_support::ColorCapability::TrueColor,
1908            test_filesystem(),
1909        )
1910        .unwrap();
1911
1912        let id = editor.new_buffer();
1913        assert_eq!(editor.buffers().len(), 2);
1914        assert_eq!(editor.active_buffer(), id);
1915    }
1916
1917    #[test]
1918    #[ignore]
1919    fn test_clipboard() {
1920        let config = Config::default();
1921        let (dir_context, _temp) = test_dir_context();
1922        let mut editor = Editor::new(
1923            config,
1924            80,
1925            24,
1926            dir_context,
1927            crate::view::color_support::ColorCapability::TrueColor,
1928            test_filesystem(),
1929        )
1930        .unwrap();
1931
1932        // Manually set clipboard (using internal to avoid system clipboard in tests)
1933        editor.clipboard.set_internal("test".to_string());
1934
1935        // Paste should work
1936        editor.paste();
1937
1938        let content = editor.active_state().buffer.to_string().unwrap();
1939        assert_eq!(content, "test");
1940    }
1941
1942    #[test]
1943    fn test_action_to_events_insert_char() {
1944        let config = Config::default();
1945        let (dir_context, _temp) = test_dir_context();
1946        let mut editor = Editor::new(
1947            config,
1948            80,
1949            24,
1950            dir_context,
1951            crate::view::color_support::ColorCapability::TrueColor,
1952            test_filesystem(),
1953        )
1954        .unwrap();
1955
1956        let events = editor
1957            .active_window_mut()
1958            .action_to_events(Action::InsertChar('a'));
1959        assert!(events.is_some());
1960
1961        let events = events.unwrap();
1962        assert_eq!(events.len(), 1);
1963
1964        match &events[0] {
1965            Event::Insert { position, text, .. } => {
1966                assert_eq!(*position, 0);
1967                assert_eq!(text, "a");
1968            }
1969            _ => panic!("Expected Insert event"),
1970        }
1971    }
1972
1973    #[test]
1974    fn test_action_to_events_move_right() {
1975        let config = Config::default();
1976        let (dir_context, _temp) = test_dir_context();
1977        let mut editor = Editor::new(
1978            config,
1979            80,
1980            24,
1981            dir_context,
1982            crate::view::color_support::ColorCapability::TrueColor,
1983            test_filesystem(),
1984        )
1985        .unwrap();
1986
1987        // Insert some text first
1988        let cursor_id = editor.active_cursors().primary_id();
1989        editor.apply_event_to_active_buffer(&Event::Insert {
1990            position: 0,
1991            text: "hello".to_string(),
1992            cursor_id,
1993        });
1994
1995        let events = editor
1996            .active_window_mut()
1997            .action_to_events(Action::MoveRight);
1998        assert!(events.is_some());
1999
2000        let events = events.unwrap();
2001        assert_eq!(events.len(), 1);
2002
2003        match &events[0] {
2004            Event::MoveCursor {
2005                new_position,
2006                new_anchor,
2007                ..
2008            } => {
2009                // Cursor was at 5 (end of "hello"), stays at 5 (can't move beyond end)
2010                assert_eq!(*new_position, 5);
2011                assert_eq!(*new_anchor, None); // No selection
2012            }
2013            _ => panic!("Expected MoveCursor event"),
2014        }
2015    }
2016
2017    #[test]
2018    fn test_action_to_events_move_up_down() {
2019        let config = Config::default();
2020        let (dir_context, _temp) = test_dir_context();
2021        let mut editor = Editor::new(
2022            config,
2023            80,
2024            24,
2025            dir_context,
2026            crate::view::color_support::ColorCapability::TrueColor,
2027            test_filesystem(),
2028        )
2029        .unwrap();
2030
2031        // Insert multi-line text
2032        let cursor_id = editor.active_cursors().primary_id();
2033        editor.apply_event_to_active_buffer(&Event::Insert {
2034            position: 0,
2035            text: "line1\nline2\nline3".to_string(),
2036            cursor_id,
2037        });
2038
2039        // Move cursor to start of line 2
2040        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2041            cursor_id,
2042            old_position: 0, // TODO: Get actual old position
2043            new_position: 6,
2044            old_anchor: None, // TODO: Get actual old anchor
2045            new_anchor: None,
2046            old_sticky_column: 0,
2047            new_sticky_column: 0,
2048        });
2049
2050        // Test move up
2051        let events = editor.active_window_mut().action_to_events(Action::MoveUp);
2052        assert!(events.is_some());
2053        let events = events.unwrap();
2054        assert_eq!(events.len(), 1);
2055
2056        match &events[0] {
2057            Event::MoveCursor { new_position, .. } => {
2058                assert_eq!(*new_position, 0); // Should be at start of line 1
2059            }
2060            _ => panic!("Expected MoveCursor event"),
2061        }
2062    }
2063
2064    #[test]
2065    fn test_action_to_events_insert_newline() {
2066        let config = Config::default();
2067        let (dir_context, _temp) = test_dir_context();
2068        let mut editor = Editor::new(
2069            config,
2070            80,
2071            24,
2072            dir_context,
2073            crate::view::color_support::ColorCapability::TrueColor,
2074            test_filesystem(),
2075        )
2076        .unwrap();
2077
2078        let events = editor
2079            .active_window_mut()
2080            .action_to_events(Action::InsertNewline);
2081        assert!(events.is_some());
2082
2083        let events = events.unwrap();
2084        assert_eq!(events.len(), 1);
2085
2086        match &events[0] {
2087            Event::Insert { text, .. } => {
2088                assert_eq!(text, "\n");
2089            }
2090            _ => panic!("Expected Insert event"),
2091        }
2092    }
2093
2094    #[test]
2095    fn test_action_to_events_unimplemented() {
2096        let config = Config::default();
2097        let (dir_context, _temp) = test_dir_context();
2098        let mut editor = Editor::new(
2099            config,
2100            80,
2101            24,
2102            dir_context,
2103            crate::view::color_support::ColorCapability::TrueColor,
2104            test_filesystem(),
2105        )
2106        .unwrap();
2107
2108        // These actions should return None (not yet implemented)
2109        assert!(editor
2110            .active_window_mut()
2111            .action_to_events(Action::Save)
2112            .is_none());
2113        assert!(editor
2114            .active_window_mut()
2115            .action_to_events(Action::Quit)
2116            .is_none());
2117        assert!(editor
2118            .active_window_mut()
2119            .action_to_events(Action::Undo)
2120            .is_none());
2121    }
2122
2123    #[test]
2124    fn test_action_to_events_delete_backward() {
2125        let config = Config::default();
2126        let (dir_context, _temp) = test_dir_context();
2127        let mut editor = Editor::new(
2128            config,
2129            80,
2130            24,
2131            dir_context,
2132            crate::view::color_support::ColorCapability::TrueColor,
2133            test_filesystem(),
2134        )
2135        .unwrap();
2136
2137        // Insert some text first
2138        let cursor_id = editor.active_cursors().primary_id();
2139        editor.apply_event_to_active_buffer(&Event::Insert {
2140            position: 0,
2141            text: "hello".to_string(),
2142            cursor_id,
2143        });
2144
2145        let events = editor
2146            .active_window_mut()
2147            .action_to_events(Action::DeleteBackward);
2148        assert!(events.is_some());
2149
2150        let events = events.unwrap();
2151        assert_eq!(events.len(), 1);
2152
2153        match &events[0] {
2154            Event::Delete {
2155                range,
2156                deleted_text,
2157                ..
2158            } => {
2159                assert_eq!(range.clone(), 4..5); // Delete 'o'
2160                assert_eq!(deleted_text, "o");
2161            }
2162            _ => panic!("Expected Delete event"),
2163        }
2164    }
2165
2166    #[test]
2167    fn test_action_to_events_delete_forward() {
2168        let config = Config::default();
2169        let (dir_context, _temp) = test_dir_context();
2170        let mut editor = Editor::new(
2171            config,
2172            80,
2173            24,
2174            dir_context,
2175            crate::view::color_support::ColorCapability::TrueColor,
2176            test_filesystem(),
2177        )
2178        .unwrap();
2179
2180        // Insert some text first
2181        let cursor_id = editor.active_cursors().primary_id();
2182        editor.apply_event_to_active_buffer(&Event::Insert {
2183            position: 0,
2184            text: "hello".to_string(),
2185            cursor_id,
2186        });
2187
2188        // Move cursor to position 0
2189        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2190            cursor_id,
2191            old_position: 0, // TODO: Get actual old position
2192            new_position: 0,
2193            old_anchor: None, // TODO: Get actual old anchor
2194            new_anchor: None,
2195            old_sticky_column: 0,
2196            new_sticky_column: 0,
2197        });
2198
2199        let events = editor
2200            .active_window_mut()
2201            .action_to_events(Action::DeleteForward);
2202        assert!(events.is_some());
2203
2204        let events = events.unwrap();
2205        assert_eq!(events.len(), 1);
2206
2207        match &events[0] {
2208            Event::Delete {
2209                range,
2210                deleted_text,
2211                ..
2212            } => {
2213                assert_eq!(range.clone(), 0..1); // Delete 'h'
2214                assert_eq!(deleted_text, "h");
2215            }
2216            _ => panic!("Expected Delete event"),
2217        }
2218    }
2219
2220    #[test]
2221    fn test_action_to_events_select_right() {
2222        let config = Config::default();
2223        let (dir_context, _temp) = test_dir_context();
2224        let mut editor = Editor::new(
2225            config,
2226            80,
2227            24,
2228            dir_context,
2229            crate::view::color_support::ColorCapability::TrueColor,
2230            test_filesystem(),
2231        )
2232        .unwrap();
2233
2234        // Insert some text first
2235        let cursor_id = editor.active_cursors().primary_id();
2236        editor.apply_event_to_active_buffer(&Event::Insert {
2237            position: 0,
2238            text: "hello".to_string(),
2239            cursor_id,
2240        });
2241
2242        // Move cursor to position 0
2243        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2244            cursor_id,
2245            old_position: 0, // TODO: Get actual old position
2246            new_position: 0,
2247            old_anchor: None, // TODO: Get actual old anchor
2248            new_anchor: None,
2249            old_sticky_column: 0,
2250            new_sticky_column: 0,
2251        });
2252
2253        let events = editor
2254            .active_window_mut()
2255            .action_to_events(Action::SelectRight);
2256        assert!(events.is_some());
2257
2258        let events = events.unwrap();
2259        assert_eq!(events.len(), 1);
2260
2261        match &events[0] {
2262            Event::MoveCursor {
2263                new_position,
2264                new_anchor,
2265                ..
2266            } => {
2267                assert_eq!(*new_position, 1); // Moved to position 1
2268                assert_eq!(*new_anchor, Some(0)); // Anchor at start
2269            }
2270            _ => panic!("Expected MoveCursor event"),
2271        }
2272    }
2273
2274    #[test]
2275    fn test_action_to_events_select_all() {
2276        let config = Config::default();
2277        let (dir_context, _temp) = test_dir_context();
2278        let mut editor = Editor::new(
2279            config,
2280            80,
2281            24,
2282            dir_context,
2283            crate::view::color_support::ColorCapability::TrueColor,
2284            test_filesystem(),
2285        )
2286        .unwrap();
2287
2288        // Insert some text first
2289        let cursor_id = editor.active_cursors().primary_id();
2290        editor.apply_event_to_active_buffer(&Event::Insert {
2291            position: 0,
2292            text: "hello world".to_string(),
2293            cursor_id,
2294        });
2295
2296        let events = editor
2297            .active_window_mut()
2298            .action_to_events(Action::SelectAll);
2299        assert!(events.is_some());
2300
2301        let events = events.unwrap();
2302        assert_eq!(events.len(), 1);
2303
2304        match &events[0] {
2305            Event::MoveCursor {
2306                new_position,
2307                new_anchor,
2308                ..
2309            } => {
2310                assert_eq!(*new_position, 11); // At end of buffer
2311                assert_eq!(*new_anchor, Some(0)); // Anchor at start
2312            }
2313            _ => panic!("Expected MoveCursor event"),
2314        }
2315    }
2316
2317    #[test]
2318    fn test_action_to_events_document_nav() {
2319        let config = Config::default();
2320        let (dir_context, _temp) = test_dir_context();
2321        let mut editor = Editor::new(
2322            config,
2323            80,
2324            24,
2325            dir_context,
2326            crate::view::color_support::ColorCapability::TrueColor,
2327            test_filesystem(),
2328        )
2329        .unwrap();
2330
2331        // Insert multi-line text
2332        let cursor_id = editor.active_cursors().primary_id();
2333        editor.apply_event_to_active_buffer(&Event::Insert {
2334            position: 0,
2335            text: "line1\nline2\nline3".to_string(),
2336            cursor_id,
2337        });
2338
2339        // Test MoveDocumentStart
2340        let events = editor
2341            .active_window_mut()
2342            .action_to_events(Action::MoveDocumentStart);
2343        assert!(events.is_some());
2344        let events = events.unwrap();
2345        match &events[0] {
2346            Event::MoveCursor { new_position, .. } => {
2347                assert_eq!(*new_position, 0);
2348            }
2349            _ => panic!("Expected MoveCursor event"),
2350        }
2351
2352        // Test MoveDocumentEnd
2353        let events = editor
2354            .active_window_mut()
2355            .action_to_events(Action::MoveDocumentEnd);
2356        assert!(events.is_some());
2357        let events = events.unwrap();
2358        match &events[0] {
2359            Event::MoveCursor { new_position, .. } => {
2360                assert_eq!(*new_position, 17); // End of buffer
2361            }
2362            _ => panic!("Expected MoveCursor event"),
2363        }
2364    }
2365
2366    #[test]
2367    fn test_action_to_events_remove_secondary_cursors() {
2368        use crate::model::event::CursorId;
2369
2370        let config = Config::default();
2371        let (dir_context, _temp) = test_dir_context();
2372        let mut editor = Editor::new(
2373            config,
2374            80,
2375            24,
2376            dir_context,
2377            crate::view::color_support::ColorCapability::TrueColor,
2378            test_filesystem(),
2379        )
2380        .unwrap();
2381
2382        // Insert some text first to have positions to place cursors
2383        let cursor_id = editor.active_cursors().primary_id();
2384        editor.apply_event_to_active_buffer(&Event::Insert {
2385            position: 0,
2386            text: "hello world test".to_string(),
2387            cursor_id,
2388        });
2389
2390        // Add secondary cursors at different positions to avoid normalization merging
2391        editor.apply_event_to_active_buffer(&Event::AddCursor {
2392            cursor_id: CursorId(1),
2393            position: 5,
2394            anchor: None,
2395        });
2396        editor.apply_event_to_active_buffer(&Event::AddCursor {
2397            cursor_id: CursorId(2),
2398            position: 10,
2399            anchor: None,
2400        });
2401
2402        assert_eq!(editor.active_cursors().count(), 3);
2403
2404        // Find the first cursor ID (the one that will be kept)
2405        let first_id = editor
2406            .active_cursors()
2407            .iter()
2408            .map(|(id, _)| id)
2409            .min_by_key(|id| id.0)
2410            .expect("Should have at least one cursor");
2411
2412        // RemoveSecondaryCursors should generate RemoveCursor events
2413        let events = editor
2414            .active_window_mut()
2415            .action_to_events(Action::RemoveSecondaryCursors);
2416        assert!(events.is_some());
2417
2418        let events = events.unwrap();
2419        // Should have RemoveCursor events for the two secondary cursors
2420        // Plus ClearAnchor events for all cursors (to clear Emacs mark mode)
2421        let remove_cursor_events: Vec<_> = events
2422            .iter()
2423            .filter_map(|e| match e {
2424                Event::RemoveCursor { cursor_id, .. } => Some(*cursor_id),
2425                _ => None,
2426            })
2427            .collect();
2428
2429        // Should have 2 RemoveCursor events (one for each secondary cursor)
2430        assert_eq!(remove_cursor_events.len(), 2);
2431
2432        for cursor_id in &remove_cursor_events {
2433            // Should not be the first cursor (the one we're keeping)
2434            assert_ne!(*cursor_id, first_id);
2435        }
2436    }
2437
2438    #[test]
2439    fn test_action_to_events_scroll() {
2440        let config = Config::default();
2441        let (dir_context, _temp) = test_dir_context();
2442        let mut editor = Editor::new(
2443            config,
2444            80,
2445            24,
2446            dir_context,
2447            crate::view::color_support::ColorCapability::TrueColor,
2448            test_filesystem(),
2449        )
2450        .unwrap();
2451
2452        // Test ScrollUp
2453        let events = editor
2454            .active_window_mut()
2455            .action_to_events(Action::ScrollUp);
2456        assert!(events.is_some());
2457        let events = events.unwrap();
2458        assert_eq!(events.len(), 1);
2459        match &events[0] {
2460            Event::Scroll { line_offset } => {
2461                assert_eq!(*line_offset, -1);
2462            }
2463            _ => panic!("Expected Scroll event"),
2464        }
2465
2466        // Test ScrollDown
2467        let events = editor
2468            .active_window_mut()
2469            .action_to_events(Action::ScrollDown);
2470        assert!(events.is_some());
2471        let events = events.unwrap();
2472        assert_eq!(events.len(), 1);
2473        match &events[0] {
2474            Event::Scroll { line_offset } => {
2475                assert_eq!(*line_offset, 1);
2476            }
2477            _ => panic!("Expected Scroll event"),
2478        }
2479    }
2480
2481    #[test]
2482    fn test_action_to_events_none() {
2483        let config = Config::default();
2484        let (dir_context, _temp) = test_dir_context();
2485        let mut editor = Editor::new(
2486            config,
2487            80,
2488            24,
2489            dir_context,
2490            crate::view::color_support::ColorCapability::TrueColor,
2491            test_filesystem(),
2492        )
2493        .unwrap();
2494
2495        // None action should return None
2496        let events = editor.active_window_mut().action_to_events(Action::None);
2497        assert!(events.is_none());
2498    }
2499
2500    #[test]
2501    fn test_lsp_incremental_insert_generates_correct_range() {
2502        // Test that insert events generate correct incremental LSP changes
2503        // with zero-width ranges at the insertion point
2504        use crate::model::buffer::Buffer;
2505
2506        let buffer = Buffer::from_str_test("hello\nworld");
2507
2508        // Insert "NEW" at position 0 (before "hello")
2509        // Expected LSP range: line 0, char 0 to line 0, char 0 (zero-width)
2510        let position = 0;
2511        let (line, character) = buffer.position_to_lsp_position(position);
2512
2513        assert_eq!(line, 0, "Insertion at start should be line 0");
2514        assert_eq!(character, 0, "Insertion at start should be char 0");
2515
2516        // Create the range as we do in notify_lsp_change
2517        let lsp_pos = Position::new(line as u32, character as u32);
2518        let lsp_range = LspRange::new(lsp_pos, lsp_pos);
2519
2520        assert_eq!(lsp_range.start.line, 0);
2521        assert_eq!(lsp_range.start.character, 0);
2522        assert_eq!(lsp_range.end.line, 0);
2523        assert_eq!(lsp_range.end.character, 0);
2524        assert_eq!(
2525            lsp_range.start, lsp_range.end,
2526            "Insert should have zero-width range"
2527        );
2528
2529        // Test insertion at middle of first line (position 3, after "hel")
2530        let position = 3;
2531        let (line, character) = buffer.position_to_lsp_position(position);
2532
2533        assert_eq!(line, 0);
2534        assert_eq!(character, 3);
2535
2536        // Test insertion at start of second line (position 6, after "hello\n")
2537        let position = 6;
2538        let (line, character) = buffer.position_to_lsp_position(position);
2539
2540        assert_eq!(line, 1, "Position after newline should be line 1");
2541        assert_eq!(character, 0, "Position at start of line 2 should be char 0");
2542    }
2543
2544    #[test]
2545    fn test_lsp_incremental_delete_generates_correct_range() {
2546        // Test that delete events generate correct incremental LSP changes
2547        // with proper start/end ranges
2548        use crate::model::buffer::Buffer;
2549
2550        let buffer = Buffer::from_str_test("hello\nworld");
2551
2552        // Delete "ello" (positions 1-5 on line 0)
2553        let range_start = 1;
2554        let range_end = 5;
2555
2556        let (start_line, start_char) = buffer.position_to_lsp_position(range_start);
2557        let (end_line, end_char) = buffer.position_to_lsp_position(range_end);
2558
2559        assert_eq!(start_line, 0);
2560        assert_eq!(start_char, 1);
2561        assert_eq!(end_line, 0);
2562        assert_eq!(end_char, 5);
2563
2564        let lsp_range = LspRange::new(
2565            Position::new(start_line as u32, start_char as u32),
2566            Position::new(end_line as u32, end_char as u32),
2567        );
2568
2569        assert_eq!(lsp_range.start.line, 0);
2570        assert_eq!(lsp_range.start.character, 1);
2571        assert_eq!(lsp_range.end.line, 0);
2572        assert_eq!(lsp_range.end.character, 5);
2573        assert_ne!(
2574            lsp_range.start, lsp_range.end,
2575            "Delete should have non-zero range"
2576        );
2577
2578        // Test deletion across lines (delete "o\nw" - positions 4-8)
2579        let range_start = 4;
2580        let range_end = 8;
2581
2582        let (start_line, start_char) = buffer.position_to_lsp_position(range_start);
2583        let (end_line, end_char) = buffer.position_to_lsp_position(range_end);
2584
2585        assert_eq!(start_line, 0, "Delete start on line 0");
2586        assert_eq!(start_char, 4, "Delete start at char 4");
2587        assert_eq!(end_line, 1, "Delete end on line 1");
2588        assert_eq!(end_char, 2, "Delete end at char 2 of line 1");
2589    }
2590
2591    #[test]
2592    fn test_lsp_incremental_utf16_encoding() {
2593        // Test that position_to_lsp_position correctly handles UTF-16 encoding
2594        // LSP uses UTF-16 code units, not byte positions
2595        use crate::model::buffer::Buffer;
2596
2597        // Test with emoji (4 bytes in UTF-8, 2 code units in UTF-16)
2598        let buffer = Buffer::from_str_test("😀hello");
2599
2600        // Position 4 is after the emoji (4 bytes)
2601        let (line, character) = buffer.position_to_lsp_position(4);
2602
2603        assert_eq!(line, 0);
2604        assert_eq!(character, 2, "Emoji should count as 2 UTF-16 code units");
2605
2606        // Position 9 is after "😀hell" (4 bytes emoji + 5 bytes text)
2607        let (line, character) = buffer.position_to_lsp_position(9);
2608
2609        assert_eq!(line, 0);
2610        assert_eq!(
2611            character, 7,
2612            "Should be 2 (emoji) + 5 (text) = 7 UTF-16 code units"
2613        );
2614
2615        // Test with multi-byte character (é is 2 bytes in UTF-8, 1 code unit in UTF-16)
2616        let buffer = Buffer::from_str_test("café");
2617
2618        // Position 3 is after "caf" (3 bytes)
2619        let (line, character) = buffer.position_to_lsp_position(3);
2620
2621        assert_eq!(line, 0);
2622        assert_eq!(character, 3);
2623
2624        // Position 5 is after "café" (3 + 2 bytes)
2625        let (line, character) = buffer.position_to_lsp_position(5);
2626
2627        assert_eq!(line, 0);
2628        assert_eq!(character, 4, "é should count as 1 UTF-16 code unit");
2629    }
2630
2631    #[test]
2632    fn test_lsp_content_change_event_structure() {
2633        // Test that we can create TextDocumentContentChangeEvent for incremental updates
2634
2635        // Incremental insert
2636        let insert_change = TextDocumentContentChangeEvent {
2637            range: Some(LspRange::new(Position::new(0, 5), Position::new(0, 5))),
2638            range_length: None,
2639            text: "NEW".to_string(),
2640        };
2641
2642        assert!(insert_change.range.is_some());
2643        assert_eq!(insert_change.text, "NEW");
2644        let range = insert_change.range.unwrap();
2645        assert_eq!(
2646            range.start, range.end,
2647            "Insert should have zero-width range"
2648        );
2649
2650        // Incremental delete
2651        let delete_change = TextDocumentContentChangeEvent {
2652            range: Some(LspRange::new(Position::new(0, 2), Position::new(0, 7))),
2653            range_length: None,
2654            text: String::new(),
2655        };
2656
2657        assert!(delete_change.range.is_some());
2658        assert_eq!(delete_change.text, "");
2659        let range = delete_change.range.unwrap();
2660        assert_ne!(range.start, range.end, "Delete should have non-zero range");
2661        assert_eq!(range.start.line, 0);
2662        assert_eq!(range.start.character, 2);
2663        assert_eq!(range.end.line, 0);
2664        assert_eq!(range.end.character, 7);
2665    }
2666
2667    #[test]
2668    fn test_goto_matching_bracket_forward() {
2669        let config = Config::default();
2670        let (dir_context, _temp) = test_dir_context();
2671        let mut editor = Editor::new(
2672            config,
2673            80,
2674            24,
2675            dir_context,
2676            crate::view::color_support::ColorCapability::TrueColor,
2677            test_filesystem(),
2678        )
2679        .unwrap();
2680
2681        // Insert text with brackets
2682        let cursor_id = editor.active_cursors().primary_id();
2683        editor.apply_event_to_active_buffer(&Event::Insert {
2684            position: 0,
2685            text: "fn main() { let x = (1 + 2); }".to_string(),
2686            cursor_id,
2687        });
2688
2689        // Move cursor to opening brace '{'
2690        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2691            cursor_id,
2692            old_position: 31,
2693            new_position: 10,
2694            old_anchor: None,
2695            new_anchor: None,
2696            old_sticky_column: 0,
2697            new_sticky_column: 0,
2698        });
2699
2700        assert_eq!(editor.active_cursors().primary().position, 10);
2701
2702        // Call goto_matching_bracket
2703        editor.goto_matching_bracket();
2704
2705        // Should move to closing brace '}' at position 29
2706        // "fn main() { let x = (1 + 2); }"
2707        //            ^                   ^
2708        //           10                  29
2709        assert_eq!(editor.active_cursors().primary().position, 29);
2710    }
2711
2712    #[test]
2713    fn test_goto_matching_bracket_backward() {
2714        let config = Config::default();
2715        let (dir_context, _temp) = test_dir_context();
2716        let mut editor = Editor::new(
2717            config,
2718            80,
2719            24,
2720            dir_context,
2721            crate::view::color_support::ColorCapability::TrueColor,
2722            test_filesystem(),
2723        )
2724        .unwrap();
2725
2726        // Insert text with brackets
2727        let cursor_id = editor.active_cursors().primary_id();
2728        editor.apply_event_to_active_buffer(&Event::Insert {
2729            position: 0,
2730            text: "fn main() { let x = (1 + 2); }".to_string(),
2731            cursor_id,
2732        });
2733
2734        // Move cursor to closing paren ')'
2735        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2736            cursor_id,
2737            old_position: 31,
2738            new_position: 26,
2739            old_anchor: None,
2740            new_anchor: None,
2741            old_sticky_column: 0,
2742            new_sticky_column: 0,
2743        });
2744
2745        // Call goto_matching_bracket
2746        editor.goto_matching_bracket();
2747
2748        // Should move to opening paren '('
2749        assert_eq!(editor.active_cursors().primary().position, 20);
2750    }
2751
2752    #[test]
2753    fn test_goto_matching_bracket_nested() {
2754        let config = Config::default();
2755        let (dir_context, _temp) = test_dir_context();
2756        let mut editor = Editor::new(
2757            config,
2758            80,
2759            24,
2760            dir_context,
2761            crate::view::color_support::ColorCapability::TrueColor,
2762            test_filesystem(),
2763        )
2764        .unwrap();
2765
2766        // Insert text with nested brackets
2767        let cursor_id = editor.active_cursors().primary_id();
2768        editor.apply_event_to_active_buffer(&Event::Insert {
2769            position: 0,
2770            text: "{a{b{c}d}e}".to_string(),
2771            cursor_id,
2772        });
2773
2774        // Move cursor to first '{'
2775        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2776            cursor_id,
2777            old_position: 11,
2778            new_position: 0,
2779            old_anchor: None,
2780            new_anchor: None,
2781            old_sticky_column: 0,
2782            new_sticky_column: 0,
2783        });
2784
2785        // Call goto_matching_bracket
2786        editor.goto_matching_bracket();
2787
2788        // Should jump to last '}'
2789        assert_eq!(editor.active_cursors().primary().position, 10);
2790    }
2791
2792    #[test]
2793    fn test_search_case_sensitive() {
2794        let config = Config::default();
2795        let (dir_context, _temp) = test_dir_context();
2796        let mut editor = Editor::new(
2797            config,
2798            80,
2799            24,
2800            dir_context,
2801            crate::view::color_support::ColorCapability::TrueColor,
2802            test_filesystem(),
2803        )
2804        .unwrap();
2805
2806        // Insert text
2807        let cursor_id = editor.active_cursors().primary_id();
2808        editor.apply_event_to_active_buffer(&Event::Insert {
2809            position: 0,
2810            text: "Hello hello HELLO".to_string(),
2811            cursor_id,
2812        });
2813
2814        // Test case-insensitive search (default)
2815        editor.active_window_mut().search_case_sensitive = false;
2816        editor.perform_search("hello");
2817
2818        let search_state = editor.active_window().search_state.as_ref().unwrap();
2819        assert_eq!(
2820            search_state.matches.len(),
2821            3,
2822            "Should find all 3 matches case-insensitively"
2823        );
2824
2825        // Test case-sensitive search
2826        editor.active_window_mut().search_case_sensitive = true;
2827        editor.perform_search("hello");
2828
2829        let search_state = editor.active_window().search_state.as_ref().unwrap();
2830        assert_eq!(
2831            search_state.matches.len(),
2832            1,
2833            "Should find only 1 exact match"
2834        );
2835        assert_eq!(
2836            search_state.matches[0], 6,
2837            "Should find 'hello' at position 6"
2838        );
2839    }
2840
2841    #[test]
2842    fn test_search_whole_word() {
2843        let config = Config::default();
2844        let (dir_context, _temp) = test_dir_context();
2845        let mut editor = Editor::new(
2846            config,
2847            80,
2848            24,
2849            dir_context,
2850            crate::view::color_support::ColorCapability::TrueColor,
2851            test_filesystem(),
2852        )
2853        .unwrap();
2854
2855        // Insert text
2856        let cursor_id = editor.active_cursors().primary_id();
2857        editor.apply_event_to_active_buffer(&Event::Insert {
2858            position: 0,
2859            text: "test testing tested attest test".to_string(),
2860            cursor_id,
2861        });
2862
2863        // Test partial word match (default)
2864        editor.active_window_mut().search_whole_word = false;
2865        editor.active_window_mut().search_case_sensitive = true;
2866        editor.perform_search("test");
2867
2868        let search_state = editor.active_window().search_state.as_ref().unwrap();
2869        assert_eq!(
2870            search_state.matches.len(),
2871            5,
2872            "Should find 'test' in all occurrences"
2873        );
2874
2875        // Test whole word match
2876        editor.active_window_mut().search_whole_word = true;
2877        editor.perform_search("test");
2878
2879        let search_state = editor.active_window().search_state.as_ref().unwrap();
2880        assert_eq!(
2881            search_state.matches.len(),
2882            2,
2883            "Should find only whole word 'test'"
2884        );
2885        assert_eq!(search_state.matches[0], 0, "First match at position 0");
2886        assert_eq!(search_state.matches[1], 27, "Second match at position 27");
2887    }
2888
2889    #[test]
2890    #[cfg(feature = "plugins")]
2891    fn repro_2414_plugin_edit_same_line_next_match() {
2892        // Issue #2414: after a plugin replaces the first of two matches on the
2893        // SAME line (delete range + insert, net length change), navigating to
2894        // the next match lands a few bytes short of the real position.
2895        let config = Config::default();
2896        let (dir_context, _temp) = test_dir_context();
2897        let mut editor = Editor::new(
2898            config,
2899            80,
2900            24,
2901            dir_context,
2902            crate::view::color_support::ColorCapability::TrueColor,
2903            test_filesystem(),
2904        )
2905        .unwrap();
2906
2907        // Line 0 has TWO occurrences of "<i>"; line 1 has one.
2908        // "<i>a</i> mid <i>b</i>\n<i>c</i>"
2909        let cursor_id = editor.active_cursors().primary_id();
2910        editor.apply_event_to_active_buffer(&Event::Insert {
2911            position: 0,
2912            text: "<i>a</i> mid <i>b</i>\n<i>c</i>".to_string(),
2913            cursor_id,
2914        });
2915
2916        editor.active_window_mut().search_case_sensitive = true;
2917        editor.perform_search("<i>");
2918
2919        // Sanity: three matches at 0, 13, 22.
2920        let m = editor
2921            .active_window()
2922            .search_state
2923            .as_ref()
2924            .unwrap()
2925            .matches
2926            .clone();
2927        assert_eq!(m, vec![0, 13, 22], "initial match offsets");
2928
2929        // Plugin replaces the first element "<i>a</i>" (bytes 0..8) with the
2930        // longer "<em>a</em>" (10 bytes, net +2) via the plugin edit path.
2931        let buf = editor.active_buffer();
2932        editor.handle_delete_range(buf, 0..8);
2933        editor.handle_insert_text(buf, 0, "<em>a</em>".to_string());
2934
2935        // The second "<i>" on line 0 is now at byte 15 (13 + 2).
2936        // Move cursor to the start of line 0 and ask for the next match.
2937        // Buffer is now "<em>a</em> mid <i>b</i>\n<i>c</i>"; the surviving
2938        // "<i>" occurrences sit at bytes 15 (same line) and 24 (next line).
2939        assert_eq!(
2940            editor.active_state().buffer.to_string().unwrap(),
2941            "<em>a</em> mid <i>b</i>\n<i>c</i>"
2942        );
2943
2944        // From the start of line 0, the next match must be the real second
2945        // "<i>" at byte 15 — not the phantom overlay (byte 10) left behind when
2946        // the plugin's delete collapsed the first match's highlight and the
2947        // following insert pushed it forward.
2948        editor.active_cursors_mut().primary_mut().move_to(0, false);
2949        editor.find_next();
2950        assert_eq!(
2951            editor.active_cursors().primary().position,
2952            15,
2953            "next match should land on the shifted same-line '<i>' (byte 15)"
2954        );
2955
2956        // And the following match is the next-line occurrence at byte 24.
2957        editor.find_next();
2958        assert_eq!(
2959            editor.active_cursors().primary().position,
2960            24,
2961            "subsequent match should land on the next-line '<i>' (byte 24)"
2962        );
2963    }
2964
2965    #[test]
2966    fn test_search_scan_completes_when_capped() {
2967        // Regression test: when the incremental search scan hits MAX_MATCHES
2968        // early (e.g. at 15% of the file), the scan's `capped` flag is set to
2969        // true and the batch loop breaks.  The completion check in
2970        // process_search_scan() must also consider `capped` — otherwise the
2971        // scan gets stuck in an infinite loop showing "Searching... 15%".
2972        let config = Config::default();
2973        let (dir_context, _temp) = test_dir_context();
2974        let mut editor = Editor::new(
2975            config,
2976            80,
2977            24,
2978            dir_context,
2979            crate::view::color_support::ColorCapability::TrueColor,
2980            test_filesystem(),
2981        )
2982        .unwrap();
2983
2984        // Manually create a search scan state that is already capped but not
2985        // at the last chunk (simulating early cap at ~15%).
2986        let buffer_id = editor.active_buffer();
2987        let regex = regex::bytes::Regex::new("test").unwrap();
2988        let fake_chunks = vec![
2989            crate::model::buffer::LineScanChunk {
2990                leaf_index: 0,
2991                byte_len: 100,
2992                already_known: true,
2993            },
2994            crate::model::buffer::LineScanChunk {
2995                leaf_index: 1,
2996                byte_len: 100,
2997                already_known: true,
2998            },
2999        ];
3000
3001        let chunked = crate::model::buffer::ChunkedSearchState {
3002            chunks: fake_chunks,
3003            next_chunk: 1, // Only processed 1 of 2 chunks
3004            next_doc_offset: 100,
3005            total_bytes: 200,
3006            scanned_bytes: 100,
3007            regex,
3008            matches: vec![
3009                crate::model::buffer::SearchMatch {
3010                    byte_offset: 10,
3011                    length: 4,
3012                    line: 1,
3013                    column: 11,
3014                    context: String::new(),
3015                },
3016                crate::model::buffer::SearchMatch {
3017                    byte_offset: 50,
3018                    length: 4,
3019                    line: 1,
3020                    column: 51,
3021                    context: String::new(),
3022                },
3023            ],
3024            overlap_tail: Vec::new(),
3025            overlap_doc_offset: 0,
3026            max_matches: 10_000,
3027            capped: true, // Capped early — this is the key condition
3028            query_len: 4,
3029            running_line: 1,
3030        };
3031
3032        editor.active_window_mut().search_scan.start(
3033            buffer_id,
3034            Vec::new(),
3035            chunked,
3036            "test".to_string(),
3037            None,
3038            false,
3039            false,
3040            false,
3041        );
3042
3043        // process_search_scan should finalize the search (not loop forever)
3044        let result = editor.process_search_scan();
3045        assert!(
3046            result,
3047            "process_search_scan should return true (needs render)"
3048        );
3049
3050        // The scan state should be consumed (drained)
3051        assert_eq!(
3052            editor.active_window().search_scan.buffer_id(),
3053            None,
3054            "search_scan should be drained after capped scan completes"
3055        );
3056
3057        // Search state should be set with the accumulated matches
3058        let search_state = editor
3059            .active_window()
3060            .search_state
3061            .as_ref()
3062            .expect("search_state should be set after scan finishes");
3063        assert_eq!(search_state.matches.len(), 2, "Should have 2 matches");
3064        assert_eq!(search_state.query, "test");
3065        assert!(
3066            search_state.capped,
3067            "search_state should be marked as capped"
3068        );
3069    }
3070
3071    #[test]
3072    fn test_bookmarks() {
3073        let config = Config::default();
3074        let (dir_context, _temp) = test_dir_context();
3075        let mut editor = Editor::new(
3076            config,
3077            80,
3078            24,
3079            dir_context,
3080            crate::view::color_support::ColorCapability::TrueColor,
3081            test_filesystem(),
3082        )
3083        .unwrap();
3084
3085        // Insert text
3086        let cursor_id = editor.active_cursors().primary_id();
3087        editor.apply_event_to_active_buffer(&Event::Insert {
3088            position: 0,
3089            text: "Line 1\nLine 2\nLine 3".to_string(),
3090            cursor_id,
3091        });
3092
3093        // Move cursor to line 2 start (position 7)
3094        editor.apply_event_to_active_buffer(&Event::MoveCursor {
3095            cursor_id,
3096            old_position: 21,
3097            new_position: 7,
3098            old_anchor: None,
3099            new_anchor: None,
3100            old_sticky_column: 0,
3101            new_sticky_column: 0,
3102        });
3103
3104        // Set bookmark '1'
3105        editor.active_window_mut().set_bookmark('1');
3106        assert_eq!(
3107            editor
3108                .active_window()
3109                .bookmarks
3110                .get('1')
3111                .map(|b| b.position),
3112            Some(7)
3113        );
3114
3115        // Move cursor elsewhere
3116        editor.apply_event_to_active_buffer(&Event::MoveCursor {
3117            cursor_id,
3118            old_position: 7,
3119            new_position: 14,
3120            old_anchor: None,
3121            new_anchor: None,
3122            old_sticky_column: 0,
3123            new_sticky_column: 0,
3124        });
3125
3126        // Jump back to bookmark
3127        editor.jump_to_bookmark('1');
3128        assert_eq!(editor.active_cursors().primary().position, 7);
3129
3130        // Clear bookmark
3131        editor.active_window_mut().clear_bookmark('1');
3132        assert_eq!(editor.active_window().bookmarks.get('1'), None);
3133    }
3134
3135    #[test]
3136    fn test_action_enum_new_variants() {
3137        // Test that new actions can be parsed from strings
3138        use serde_json::json;
3139
3140        let args = HashMap::new();
3141        assert_eq!(
3142            Action::from_str("smart_home", &args),
3143            Some(Action::SmartHome)
3144        );
3145        assert_eq!(
3146            Action::from_str("dedent_selection", &args),
3147            Some(Action::DedentSelection)
3148        );
3149        assert_eq!(
3150            Action::from_str("toggle_comment", &args),
3151            Some(Action::ToggleComment)
3152        );
3153        assert_eq!(
3154            Action::from_str("goto_matching_bracket", &args),
3155            Some(Action::GoToMatchingBracket)
3156        );
3157        assert_eq!(
3158            Action::from_str("list_bookmarks", &args),
3159            Some(Action::ListBookmarks)
3160        );
3161        assert_eq!(
3162            Action::from_str("toggle_search_case_sensitive", &args),
3163            Some(Action::ToggleSearchCaseSensitive)
3164        );
3165        assert_eq!(
3166            Action::from_str("toggle_search_whole_word", &args),
3167            Some(Action::ToggleSearchWholeWord)
3168        );
3169
3170        // Test bookmark actions with arguments
3171        let mut args_with_char = HashMap::new();
3172        args_with_char.insert("char".to_string(), json!("5"));
3173        assert_eq!(
3174            Action::from_str("set_bookmark", &args_with_char),
3175            Some(Action::SetBookmark('5'))
3176        );
3177        assert_eq!(
3178            Action::from_str("jump_to_bookmark", &args_with_char),
3179            Some(Action::JumpToBookmark('5'))
3180        );
3181        assert_eq!(
3182            Action::from_str("clear_bookmark", &args_with_char),
3183            Some(Action::ClearBookmark('5'))
3184        );
3185    }
3186
3187    #[test]
3188    fn test_keybinding_new_defaults() {
3189        use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
3190
3191        // Test that new keybindings are properly registered in the "default" keymap
3192        // Note: We explicitly use "default" keymap, not Config::default() which uses
3193        // platform-specific keymaps (e.g., "macos" on macOS has different bindings)
3194        let mut config = Config::default();
3195        config.active_keybinding_map = crate::config::KeybindingMapName("default".to_string());
3196        let resolver = KeybindingResolver::new(&config);
3197
3198        // Test Ctrl+/ is ToggleComment (not CommandPalette)
3199        let event = KeyEvent {
3200            code: KeyCode::Char('/'),
3201            modifiers: KeyModifiers::CONTROL,
3202            kind: KeyEventKind::Press,
3203            state: KeyEventState::NONE,
3204        };
3205        let action = resolver.resolve(&event, KeyContext::Normal);
3206        assert_eq!(action, Action::ToggleComment);
3207
3208        // Test Ctrl+] is GoToMatchingBracket
3209        let event = KeyEvent {
3210            code: KeyCode::Char(']'),
3211            modifiers: KeyModifiers::CONTROL,
3212            kind: KeyEventKind::Press,
3213            state: KeyEventState::NONE,
3214        };
3215        let action = resolver.resolve(&event, KeyContext::Normal);
3216        assert_eq!(action, Action::GoToMatchingBracket);
3217
3218        // Test Shift+Tab is DedentSelection
3219        let event = KeyEvent {
3220            code: KeyCode::Tab,
3221            modifiers: KeyModifiers::SHIFT,
3222            kind: KeyEventKind::Press,
3223            state: KeyEventState::NONE,
3224        };
3225        let action = resolver.resolve(&event, KeyContext::Normal);
3226        assert_eq!(action, Action::DedentSelection);
3227
3228        // Test Ctrl+G is GotoLine
3229        let event = KeyEvent {
3230            code: KeyCode::Char('g'),
3231            modifiers: KeyModifiers::CONTROL,
3232            kind: KeyEventKind::Press,
3233            state: KeyEventState::NONE,
3234        };
3235        let action = resolver.resolve(&event, KeyContext::Normal);
3236        assert_eq!(action, Action::GotoLine);
3237
3238        // Test bookmark keybindings
3239        let event = KeyEvent {
3240            code: KeyCode::Char('5'),
3241            modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
3242            kind: KeyEventKind::Press,
3243            state: KeyEventState::NONE,
3244        };
3245        let action = resolver.resolve(&event, KeyContext::Normal);
3246        assert_eq!(action, Action::SetBookmark('5'));
3247
3248        let event = KeyEvent {
3249            code: KeyCode::Char('5'),
3250            modifiers: KeyModifiers::ALT,
3251            kind: KeyEventKind::Press,
3252            state: KeyEventState::NONE,
3253        };
3254        let action = resolver.resolve(&event, KeyContext::Normal);
3255        assert_eq!(action, Action::JumpToBookmark('5'));
3256    }
3257
3258    /// This test demonstrates the bug where LSP didChange notifications contain
3259    /// incorrect positions because they're calculated from the already-modified buffer.
3260    ///
3261    /// When applying LSP rename edits:
3262    /// 1. apply_events_to_buffer_as_bulk_edit() applies the edits to the buffer
3263    /// 2. Then calls notify_lsp_change() which calls collect_lsp_changes()
3264    /// 3. collect_lsp_changes() converts byte positions to LSP positions using
3265    ///    the CURRENT buffer state
3266    ///
3267    /// But the byte positions in the events are relative to the ORIGINAL buffer,
3268    /// not the modified one! This causes LSP to receive wrong positions.
3269    #[test]
3270    fn test_lsp_rename_didchange_positions_bug() {
3271        use crate::model::buffer::Buffer;
3272
3273        let config = Config::default();
3274        let (dir_context, _temp) = test_dir_context();
3275        let mut editor = Editor::new(
3276            config,
3277            80,
3278            24,
3279            dir_context,
3280            crate::view::color_support::ColorCapability::TrueColor,
3281            test_filesystem(),
3282        )
3283        .unwrap();
3284
3285        // Set buffer content: "fn foo(val: i32) {\n    val + 1\n}\n"
3286        // Line 0: positions 0-19 (includes newline)
3287        // Line 1: positions 19-31 (includes newline)
3288        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
3289        editor.active_state_mut().buffer =
3290            Buffer::from_str(initial, 1024 * 1024, test_filesystem());
3291
3292        // Simulate LSP rename batch: rename "val" to "value" in two places
3293        // This is applied in reverse order to preserve positions:
3294        // 1. Delete "val" at position 23 (line 1, char 4), insert "value"
3295        // 2. Delete "val" at position 7 (line 0, char 7), insert "value"
3296        let cursor_id = editor.active_cursors().primary_id();
3297
3298        let batch = Event::Batch {
3299            events: vec![
3300                // Second occurrence first (reverse order for position preservation)
3301                Event::Delete {
3302                    range: 23..26, // "val" on line 1
3303                    deleted_text: "val".to_string(),
3304                    cursor_id,
3305                },
3306                Event::Insert {
3307                    position: 23,
3308                    text: "value".to_string(),
3309                    cursor_id,
3310                },
3311                // First occurrence second
3312                Event::Delete {
3313                    range: 7..10, // "val" on line 0
3314                    deleted_text: "val".to_string(),
3315                    cursor_id,
3316                },
3317                Event::Insert {
3318                    position: 7,
3319                    text: "value".to_string(),
3320                    cursor_id,
3321                },
3322            ],
3323            description: "LSP Rename".to_string(),
3324        };
3325
3326        // CORRECT: Calculate LSP positions BEFORE applying batch
3327        let lsp_changes_before = editor.active_window().collect_lsp_changes(&batch);
3328
3329        // Now apply the batch (this is what apply_events_to_buffer_as_bulk_edit does)
3330        editor.apply_event_to_active_buffer(&batch);
3331
3332        // BUG DEMONSTRATION: Calculate LSP positions AFTER applying batch
3333        // This is what happens when notify_lsp_change is called after state.apply()
3334        let lsp_changes_after = editor.active_window().collect_lsp_changes(&batch);
3335
3336        // Verify buffer was correctly modified
3337        let final_content = editor.active_state().buffer.to_string().unwrap();
3338        assert_eq!(
3339            final_content, "fn foo(value: i32) {\n    value + 1\n}\n",
3340            "Buffer should have 'value' in both places"
3341        );
3342
3343        // The CORRECT positions (before applying batch):
3344        // - Delete at 23..26 should be line 1, char 4-7 (in original buffer)
3345        // - Insert at 23 should be line 1, char 4 (in original buffer)
3346        // - Delete at 7..10 should be line 0, char 7-10 (in original buffer)
3347        // - Insert at 7 should be line 0, char 7 (in original buffer)
3348        assert_eq!(lsp_changes_before.len(), 4, "Should have 4 changes");
3349
3350        let first_delete = &lsp_changes_before[0];
3351        let first_del_range = first_delete.range.unwrap();
3352        assert_eq!(
3353            first_del_range.start.line, 1,
3354            "First delete should be on line 1 (BEFORE)"
3355        );
3356        assert_eq!(
3357            first_del_range.start.character, 4,
3358            "First delete start should be at char 4 (BEFORE)"
3359        );
3360
3361        // The INCORRECT positions (after applying batch):
3362        // Since the buffer has changed, position 23 now points to different text!
3363        // Original buffer position 23 was start of "val" on line 1
3364        // But after rename, the buffer is "fn foo(value: i32) {\n    value + 1\n}\n"
3365        // Position 23 in new buffer is 'l' in "value" (line 1, offset into "value")
3366        assert_eq!(lsp_changes_after.len(), 4, "Should have 4 changes");
3367
3368        let first_delete_after = &lsp_changes_after[0];
3369        let first_del_range_after = first_delete_after.range.unwrap();
3370
3371        // THIS IS THE BUG: The positions are WRONG when calculated from modified buffer
3372        // The first delete's range.end position will be wrong because the buffer changed
3373        eprintln!("BEFORE modification:");
3374        eprintln!(
3375            "  Delete at line {}, char {}-{}",
3376            first_del_range.start.line,
3377            first_del_range.start.character,
3378            first_del_range.end.character
3379        );
3380        eprintln!("AFTER modification:");
3381        eprintln!(
3382            "  Delete at line {}, char {}-{}",
3383            first_del_range_after.start.line,
3384            first_del_range_after.start.character,
3385            first_del_range_after.end.character
3386        );
3387
3388        // The bug causes the position calculation to be wrong.
3389        // After applying the batch, position 23..26 in the modified buffer
3390        // is different from what it was in the original buffer.
3391        //
3392        // Modified buffer: "fn foo(value: i32) {\n    value + 1\n}\n"
3393        // Position 23 = 'l' in second "value"
3394        // Position 26 = 'e' in second "value"
3395        // This maps to line 1, char 2-5 (wrong!)
3396        //
3397        // Original buffer: "fn foo(val: i32) {\n    val + 1\n}\n"
3398        // Position 23 = 'v' in "val"
3399        // Position 26 = ' ' after "val"
3400        // This maps to line 1, char 4-7 (correct!)
3401
3402        // The positions are different! This demonstrates the bug.
3403        // Note: Due to how the batch is applied (all operations at once),
3404        // the exact positions may vary, but they will definitely be wrong.
3405        assert_ne!(
3406            first_del_range_after.end.character, first_del_range.end.character,
3407            "BUG CONFIRMED: LSP positions are different when calculated after buffer modification!"
3408        );
3409
3410        eprintln!("\n=== BUG DEMONSTRATED ===");
3411        eprintln!("When collect_lsp_changes() is called AFTER buffer modification,");
3412        eprintln!("the positions are WRONG because they're calculated from the");
3413        eprintln!("modified buffer, not the original buffer.");
3414        eprintln!("This causes the second rename to fail with 'content modified' error.");
3415        eprintln!("========================\n");
3416    }
3417
3418    #[test]
3419    fn test_lsp_rename_preserves_cursor_position() {
3420        use crate::model::buffer::Buffer;
3421
3422        let config = Config::default();
3423        let (dir_context, _temp) = test_dir_context();
3424        let mut editor = Editor::new(
3425            config,
3426            80,
3427            24,
3428            dir_context,
3429            crate::view::color_support::ColorCapability::TrueColor,
3430            test_filesystem(),
3431        )
3432        .unwrap();
3433
3434        // Set buffer content: "fn foo(val: i32) {\n    val + 1\n}\n"
3435        // Line 0: positions 0-19 (includes newline)
3436        // Line 1: positions 19-31 (includes newline)
3437        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
3438        editor.active_state_mut().buffer =
3439            Buffer::from_str(initial, 1024 * 1024, test_filesystem());
3440
3441        // Position cursor at the second "val" (position 23 = 'v' of "val" on line 1)
3442        let original_cursor_pos = 23;
3443        editor.active_cursors_mut().primary_mut().position = original_cursor_pos;
3444
3445        // Verify cursor is at the right position
3446        let buffer_text = editor.active_state().buffer.to_string().unwrap();
3447        let text_at_cursor = buffer_text[original_cursor_pos..original_cursor_pos + 3].to_string();
3448        assert_eq!(text_at_cursor, "val", "Cursor should be at 'val'");
3449
3450        // Simulate LSP rename batch: rename "val" to "value" in two places
3451        // Applied in reverse order (from end of file to start)
3452        let cursor_id = editor.active_cursors().primary_id();
3453        let buffer_id = editor.active_buffer();
3454
3455        let events = vec![
3456            // Second occurrence first (at position 23, line 1)
3457            Event::Delete {
3458                range: 23..26, // "val" on line 1
3459                deleted_text: "val".to_string(),
3460                cursor_id,
3461            },
3462            Event::Insert {
3463                position: 23,
3464                text: "value".to_string(),
3465                cursor_id,
3466            },
3467            // First occurrence second (at position 7, line 0)
3468            Event::Delete {
3469                range: 7..10, // "val" on line 0
3470                deleted_text: "val".to_string(),
3471                cursor_id,
3472            },
3473            Event::Insert {
3474                position: 7,
3475                text: "value".to_string(),
3476                cursor_id,
3477            },
3478        ];
3479
3480        // Apply the rename using bulk edit (this should preserve cursor position)
3481        editor
3482            .apply_events_to_buffer_as_bulk_edit(buffer_id, events, "LSP Rename".to_string())
3483            .unwrap();
3484
3485        // Verify buffer was correctly modified
3486        let final_content = editor.active_state().buffer.to_string().unwrap();
3487        assert_eq!(
3488            final_content, "fn foo(value: i32) {\n    value + 1\n}\n",
3489            "Buffer should have 'value' in both places"
3490        );
3491
3492        // The cursor was originally at position 23 (start of "val" on line 1).
3493        // After renaming:
3494        // - The first "val" (at pos 7-10) was replaced with "value" (5 chars instead of 3)
3495        //   This adds 2 bytes before the cursor.
3496        // - The second "val" at the cursor position was replaced.
3497        //
3498        // Expected cursor position: 23 + 2 = 25 (start of "value" on line 1)
3499        let final_cursor_pos = editor.active_cursors().primary().position;
3500        let expected_cursor_pos = 25; // original 23 + 2 (delta from first rename)
3501
3502        assert_eq!(
3503            final_cursor_pos, expected_cursor_pos,
3504            "Cursor should be at position {} (start of 'value' on line 1), but was at {}. \
3505             Original pos: {}, expected adjustment: +2 for first rename",
3506            expected_cursor_pos, final_cursor_pos, original_cursor_pos
3507        );
3508
3509        // Verify cursor is at start of the renamed symbol
3510        let text_at_new_cursor = &final_content[final_cursor_pos..final_cursor_pos + 5];
3511        assert_eq!(
3512            text_at_new_cursor, "value",
3513            "Cursor should be at the start of 'value' after rename"
3514        );
3515    }
3516
3517    #[test]
3518    fn test_lsp_rename_twice_consecutive() {
3519        // This test reproduces the bug where the second rename fails because
3520        // LSP positions are calculated incorrectly after the first rename.
3521        use crate::model::buffer::Buffer;
3522
3523        let config = Config::default();
3524        let (dir_context, _temp) = test_dir_context();
3525        let mut editor = Editor::new(
3526            config,
3527            80,
3528            24,
3529            dir_context,
3530            crate::view::color_support::ColorCapability::TrueColor,
3531            test_filesystem(),
3532        )
3533        .unwrap();
3534
3535        // Initial content: "fn foo(val: i32) {\n    val + 1\n}\n"
3536        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
3537        editor.active_state_mut().buffer =
3538            Buffer::from_str(initial, 1024 * 1024, test_filesystem());
3539
3540        let cursor_id = editor.active_cursors().primary_id();
3541        let buffer_id = editor.active_buffer();
3542
3543        // === FIRST RENAME: "val" -> "value" ===
3544        // Create events for first rename (applied in reverse order)
3545        let events1 = vec![
3546            // Second occurrence first (at position 23, line 1, char 4)
3547            Event::Delete {
3548                range: 23..26,
3549                deleted_text: "val".to_string(),
3550                cursor_id,
3551            },
3552            Event::Insert {
3553                position: 23,
3554                text: "value".to_string(),
3555                cursor_id,
3556            },
3557            // First occurrence (at position 7, line 0, char 7)
3558            Event::Delete {
3559                range: 7..10,
3560                deleted_text: "val".to_string(),
3561                cursor_id,
3562            },
3563            Event::Insert {
3564                position: 7,
3565                text: "value".to_string(),
3566                cursor_id,
3567            },
3568        ];
3569
3570        // Create batch for LSP change verification
3571        let batch1 = Event::Batch {
3572            events: events1.clone(),
3573            description: "LSP Rename 1".to_string(),
3574        };
3575
3576        // Collect LSP changes BEFORE applying (this is the fix)
3577        let lsp_changes1 = editor.active_window().collect_lsp_changes(&batch1);
3578
3579        // Verify first rename LSP positions are correct
3580        assert_eq!(
3581            lsp_changes1.len(),
3582            4,
3583            "First rename should have 4 LSP changes"
3584        );
3585
3586        // First delete should be at line 1, char 4-7 (second "val")
3587        let first_del = &lsp_changes1[0];
3588        let first_del_range = first_del.range.unwrap();
3589        assert_eq!(first_del_range.start.line, 1, "First delete line");
3590        assert_eq!(
3591            first_del_range.start.character, 4,
3592            "First delete start char"
3593        );
3594        assert_eq!(first_del_range.end.character, 7, "First delete end char");
3595
3596        // Apply first rename using bulk edit
3597        editor
3598            .apply_events_to_buffer_as_bulk_edit(buffer_id, events1, "LSP Rename 1".to_string())
3599            .unwrap();
3600
3601        // Verify buffer after first rename
3602        let after_first = editor.active_state().buffer.to_string().unwrap();
3603        assert_eq!(
3604            after_first, "fn foo(value: i32) {\n    value + 1\n}\n",
3605            "After first rename"
3606        );
3607
3608        // === SECOND RENAME: "value" -> "x" ===
3609        // Now "value" is at:
3610        // - Line 0, char 7-12 (positions 7-12 in buffer)
3611        // - Line 1, char 4-9 (positions 25-30 in buffer, because line 0 grew by 2)
3612        //
3613        // Buffer: "fn foo(value: i32) {\n    value + 1\n}\n"
3614        //          0123456789...
3615
3616        // Create events for second rename
3617        let events2 = vec![
3618            // Second occurrence first (at position 25, line 1, char 4)
3619            Event::Delete {
3620                range: 25..30,
3621                deleted_text: "value".to_string(),
3622                cursor_id,
3623            },
3624            Event::Insert {
3625                position: 25,
3626                text: "x".to_string(),
3627                cursor_id,
3628            },
3629            // First occurrence (at position 7, line 0, char 7)
3630            Event::Delete {
3631                range: 7..12,
3632                deleted_text: "value".to_string(),
3633                cursor_id,
3634            },
3635            Event::Insert {
3636                position: 7,
3637                text: "x".to_string(),
3638                cursor_id,
3639            },
3640        ];
3641
3642        // Create batch for LSP change verification
3643        let batch2 = Event::Batch {
3644            events: events2.clone(),
3645            description: "LSP Rename 2".to_string(),
3646        };
3647
3648        // Collect LSP changes BEFORE applying (this is the fix)
3649        let lsp_changes2 = editor.active_window().collect_lsp_changes(&batch2);
3650
3651        // Verify second rename LSP positions are correct
3652        // THIS IS WHERE THE BUG WOULD MANIFEST - if positions are wrong,
3653        // the LSP server would report "No references found at position"
3654        assert_eq!(
3655            lsp_changes2.len(),
3656            4,
3657            "Second rename should have 4 LSP changes"
3658        );
3659
3660        // First delete should be at line 1, char 4-9 (second "value")
3661        let second_first_del = &lsp_changes2[0];
3662        let second_first_del_range = second_first_del.range.unwrap();
3663        assert_eq!(
3664            second_first_del_range.start.line, 1,
3665            "Second rename first delete should be on line 1"
3666        );
3667        assert_eq!(
3668            second_first_del_range.start.character, 4,
3669            "Second rename first delete start should be at char 4"
3670        );
3671        assert_eq!(
3672            second_first_del_range.end.character, 9,
3673            "Second rename first delete end should be at char 9 (4 + 5 for 'value')"
3674        );
3675
3676        // Third delete should be at line 0, char 7-12 (first "value")
3677        let second_third_del = &lsp_changes2[2];
3678        let second_third_del_range = second_third_del.range.unwrap();
3679        assert_eq!(
3680            second_third_del_range.start.line, 0,
3681            "Second rename third delete should be on line 0"
3682        );
3683        assert_eq!(
3684            second_third_del_range.start.character, 7,
3685            "Second rename third delete start should be at char 7"
3686        );
3687        assert_eq!(
3688            second_third_del_range.end.character, 12,
3689            "Second rename third delete end should be at char 12 (7 + 5 for 'value')"
3690        );
3691
3692        // Apply second rename using bulk edit
3693        editor
3694            .apply_events_to_buffer_as_bulk_edit(buffer_id, events2, "LSP Rename 2".to_string())
3695            .unwrap();
3696
3697        // Verify buffer after second rename
3698        let after_second = editor.active_state().buffer.to_string().unwrap();
3699        assert_eq!(
3700            after_second, "fn foo(x: i32) {\n    x + 1\n}\n",
3701            "After second rename"
3702        );
3703    }
3704
3705    #[test]
3706    fn test_ensure_active_tab_visible_static_offset() {
3707        let config = Config::default();
3708        let (dir_context, _temp) = test_dir_context();
3709        let mut editor = Editor::new(
3710            config,
3711            80,
3712            24,
3713            dir_context,
3714            crate::view::color_support::ColorCapability::TrueColor,
3715            test_filesystem(),
3716        )
3717        .unwrap();
3718        let split_id = editor.split_manager().active_split();
3719
3720        // Create three buffers with long names to force scrolling.
3721        let buf1 = editor.new_buffer();
3722        editor
3723            .buffers_mut()
3724            .get_mut(&buf1)
3725            .unwrap()
3726            .buffer
3727            .rename_file_path(std::path::PathBuf::from("aaa_long_name_01.txt"));
3728        let buf2 = editor.new_buffer();
3729        editor
3730            .buffers_mut()
3731            .get_mut(&buf2)
3732            .unwrap()
3733            .buffer
3734            .rename_file_path(std::path::PathBuf::from("bbb_long_name_02.txt"));
3735        let buf3 = editor.new_buffer();
3736        editor
3737            .buffers_mut()
3738            .get_mut(&buf3)
3739            .unwrap()
3740            .buffer
3741            .rename_file_path(std::path::PathBuf::from("ccc_long_name_03.txt"));
3742
3743        {
3744            use crate::view::split::TabTarget;
3745            let view_state = editor.split_view_states_mut().get_mut(&split_id).unwrap();
3746            view_state.open_buffers = vec![
3747                TabTarget::Buffer(buf1),
3748                TabTarget::Buffer(buf2),
3749                TabTarget::Buffer(buf3),
3750            ];
3751            view_state.tab_scroll_offset = 50;
3752        }
3753
3754        // Force active buffer to first tab and ensure helper brings it into view.
3755        // Note: available_width must be >= tab width (2 + name_len) for offset to be 0
3756        // Tab width = 2 + 20 (name length) = 22, so we need at least 22
3757        editor
3758            .active_window_mut()
3759            .ensure_active_tab_visible(split_id, buf1, 25);
3760        assert_eq!(
3761            editor
3762                .split_view_states()
3763                .get(&split_id)
3764                .unwrap()
3765                .tab_scroll_offset,
3766            0
3767        );
3768
3769        // Now make the last tab active and ensure offset moves forward but stays bounded.
3770        editor
3771            .active_window_mut()
3772            .ensure_active_tab_visible(split_id, buf3, 25);
3773        let view_state = editor.split_view_states().get(&split_id).unwrap();
3774        assert!(view_state.tab_scroll_offset > 0);
3775        let buffer_ids: Vec<_> = view_state.buffer_tab_ids_vec();
3776        let total_width: usize = buffer_ids
3777            .iter()
3778            .enumerate()
3779            .map(|(idx, id)| {
3780                let state = editor.buffers().get(id).unwrap();
3781                let name_len = state
3782                    .buffer
3783                    .file_path()
3784                    .and_then(|p| p.file_name())
3785                    .and_then(|n| n.to_str())
3786                    .map(|s| s.chars().count())
3787                    .unwrap_or(0);
3788                let tab_width = 2 + name_len;
3789                if idx < buffer_ids.len() - 1 {
3790                    tab_width + 1 // separator
3791                } else {
3792                    tab_width
3793                }
3794            })
3795            .sum();
3796        assert!(view_state.tab_scroll_offset <= total_width);
3797    }
3798
3799    /// Regression for sinelaw/fresh#2229.
3800    ///
3801    /// When the tab strip is scrolled (many tabs open) and the user invokes
3802    /// "Close Others" / "Close to Left" / "Close to Right" / "Close All",
3803    /// the surviving active tab must stay on-screen. Before the fix the
3804    /// scroll offset was carried over from the pre-close state, so the only
3805    /// remaining tab sat to the left of the viewport and the tab bar
3806    /// looked empty.
3807    #[test]
3808    fn close_others_re_anchors_tab_scroll_to_surviving_tab() {
3809        let config = Config::default();
3810        let (dir_context, _temp) = test_dir_context();
3811        let mut editor = Editor::new(
3812            config,
3813            80,
3814            24,
3815            dir_context,
3816            crate::view::color_support::ColorCapability::TrueColor,
3817            test_filesystem(),
3818        )
3819        .unwrap();
3820        let split_id = editor.split_manager().active_split();
3821
3822        // Open enough long-named buffers that the strip would scroll on a
3823        // realistic terminal width.
3824        use crate::view::split::TabTarget;
3825        let mut buffers = Vec::new();
3826        for i in 0..6 {
3827            let id = editor.new_buffer();
3828            editor
3829                .buffers_mut()
3830                .get_mut(&id)
3831                .unwrap()
3832                .buffer
3833                .rename_file_path(std::path::PathBuf::from(format!(
3834                    "long_filename_for_tab_{:02}.txt",
3835                    i
3836                )));
3837            buffers.push(id);
3838        }
3839
3840        // Make the last buffer the active one and seed a scrolled offset
3841        // — what the renderer would have computed mid-session — then mark
3842        // it as the surviving "keep" tab.
3843        let keep = buffers[5];
3844        editor
3845            .active_window_mut()
3846            .split_manager_mut()
3847            .unwrap()
3848            .set_split_buffer(split_id, keep);
3849        {
3850            let view_state = editor.split_view_states_mut().get_mut(&split_id).unwrap();
3851            view_state.open_buffers = buffers
3852                .iter()
3853                .map(|b| TabTarget::Buffer(*b))
3854                .collect::<Vec<_>>();
3855            view_state.tab_scroll_offset = 80;
3856        }
3857
3858        editor.close_other_tabs_in_split(keep, split_id);
3859
3860        // Only the kept tab remains. Its visual range is [0, tab_width)
3861        // and it must be inside the viewport — i.e. the offset must fit
3862        // within the total tab strip width.
3863        let view_state = editor.split_view_states().get(&split_id).unwrap();
3864        let remaining_targets = view_state.buffer_tab_ids_vec();
3865        assert_eq!(
3866            remaining_targets,
3867            vec![keep],
3868            "Close Others should leave only the kept buffer"
3869        );
3870        let name_len = editor
3871            .buffers()
3872            .get(&keep)
3873            .unwrap()
3874            .buffer
3875            .file_path()
3876            .and_then(|p| p.file_name())
3877            .and_then(|n| n.to_str())
3878            .map(|s| s.chars().count())
3879            .unwrap_or(0);
3880        let kept_tab_width = 2 + name_len;
3881        assert!(
3882            view_state.tab_scroll_offset < kept_tab_width,
3883            "scroll offset {} must keep the {}-wide kept tab in view (without the fix it stays at 80)",
3884            view_state.tab_scroll_offset,
3885            kept_tab_width,
3886        );
3887    }
3888}