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