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