Skip to main content

fresh/app/
mod.rs

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