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