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