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