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