fresh/app/
mod.rs

1mod async_messages;
2mod buffer_management;
3mod calibration_actions;
4pub mod calibration_wizard;
5mod clipboard;
6mod composite_buffer_actions;
7mod file_explorer;
8pub mod file_open;
9mod file_open_input;
10mod file_operations;
11mod help;
12mod input;
13mod input_dispatch;
14mod lsp_actions;
15mod lsp_requests;
16mod menu_actions;
17mod menu_context;
18mod mouse_input;
19mod on_save_actions;
20mod plugin_commands;
21mod popup_actions;
22mod prompt_actions;
23mod recovery_actions;
24mod render;
25pub mod session;
26mod settings_actions;
27mod shell_command;
28mod split_actions;
29mod tab_drag;
30mod terminal;
31mod terminal_input;
32mod toggle_actions;
33pub mod types;
34mod undo_actions;
35mod view_actions;
36pub mod warning_domains;
37
38use anyhow::Result as AnyhowResult;
39use rust_i18n::t;
40use std::path::Component;
41
42/// Normalize a path by resolving `.` and `..` components without requiring the path to exist.
43/// This is similar to canonicalize but works on paths that don't exist yet.
44pub(crate) fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
45    let mut components = Vec::new();
46
47    for component in path.components() {
48        match component {
49            Component::CurDir => {
50                // Skip "." components
51            }
52            Component::ParentDir => {
53                // Pop the last component if it's a normal component
54                if let Some(Component::Normal(_)) = components.last() {
55                    components.pop();
56                } else {
57                    // Keep ".." if we can't go up further (for relative paths)
58                    components.push(component);
59                }
60            }
61            _ => {
62                components.push(component);
63            }
64        }
65    }
66
67    if components.is_empty() {
68        std::path::PathBuf::from(".")
69    } else {
70        components.iter().collect()
71    }
72}
73
74use self::types::{
75    Bookmark, CachedLayout, EventLineInfo, InteractiveReplaceState, LspMessageEntry,
76    LspProgressInfo, MacroRecordingState, MouseState, SearchState, TabContextMenu,
77    DEFAULT_BACKGROUND_FILE,
78};
79use crate::config::Config;
80use crate::config_io::{ConfigLayer, ConfigResolver, DirectoryContext};
81use crate::input::actions::action_to_events as convert_action_to_events;
82use crate::input::buffer_mode::ModeRegistry;
83use crate::input::command_registry::CommandRegistry;
84use crate::input::commands::Suggestion;
85use crate::input::keybindings::{Action, KeyContext, KeybindingResolver};
86use crate::input::position_history::PositionHistory;
87use crate::model::event::{Event, EventLog, SplitDirection, SplitId};
88use crate::services::async_bridge::{AsyncBridge, AsyncMessage};
89use crate::services::fs::{FsBackend, FsManager, LocalFsBackend};
90use crate::services::lsp::manager::{detect_language, LspManager};
91use crate::services::plugins::PluginManager;
92use crate::services::recovery::{RecoveryConfig, RecoveryService};
93use crate::services::time_source::{RealTimeSource, SharedTimeSource};
94use crate::state::EditorState;
95use crate::types::LspServerConfig;
96use crate::view::file_tree::{FileTree, FileTreeView};
97use crate::view::prompt::{Prompt, PromptType};
98use crate::view::scroll_sync::ScrollSyncManager;
99use crate::view::split::{SplitManager, SplitViewState};
100use crate::view::ui::{
101    FileExplorerRenderer, SplitRenderer, StatusBarRenderer, SuggestionsRenderer,
102};
103use crossterm::event::{KeyCode, KeyModifiers};
104#[cfg(feature = "plugins")]
105use fresh_core::api::BufferSavedDiff;
106use fresh_core::api::PluginCommand;
107use lsp_types::{Position, Range as LspRange, TextDocumentContentChangeEvent};
108use ratatui::{
109    layout::{Constraint, Direction, Layout},
110    Frame,
111};
112use std::collections::{HashMap, HashSet};
113use std::ops::Range;
114use std::path::{Path, PathBuf};
115use std::sync::{Arc, RwLock};
116use std::time::Instant;
117
118// Re-export BufferId from event module for backward compatibility
119pub use self::types::{BufferKind, BufferMetadata, HoverTarget};
120pub use self::warning_domains::{
121    GeneralWarningDomain, LspWarningDomain, WarningAction, WarningActionId, WarningDomain,
122    WarningDomainRegistry, WarningLevel, WarningPopupContent,
123};
124pub use crate::model::event::BufferId;
125
126/// Helper function to convert lsp_types::Uri to PathBuf
127fn uri_to_path(uri: &lsp_types::Uri) -> Result<PathBuf, String> {
128    // Convert to url::Url for path conversion
129    url::Url::parse(uri.as_str())
130        .map_err(|e| format!("Failed to parse URI: {}", e))?
131        .to_file_path()
132        .map_err(|_| "URI is not a file path".to_string())
133}
134
135/// Track an in-flight semantic token range request.
136#[derive(Clone, Debug)]
137struct SemanticTokenRangeRequest {
138    buffer_id: BufferId,
139    version: u64,
140    range: Range<usize>,
141    start_line: usize,
142    end_line: usize,
143}
144
145#[derive(Clone, Copy, Debug)]
146enum SemanticTokensFullRequestKind {
147    Full,
148    FullDelta,
149}
150
151#[derive(Clone, Debug)]
152struct SemanticTokenFullRequest {
153    buffer_id: BufferId,
154    version: u64,
155    kind: SemanticTokensFullRequestKind,
156}
157
158/// The main editor struct - manages multiple buffers, clipboard, and rendering
159pub struct Editor {
160    /// All open buffers
161    buffers: HashMap<BufferId, EditorState>,
162
163    // NOTE: There is no `active_buffer` field. The active buffer is derived from
164    // `split_manager.active_buffer_id()` to maintain a single source of truth.
165    // Use `self.active_buffer()` to get the active buffer ID.
166    /// Event log per buffer (for undo/redo)
167    event_logs: HashMap<BufferId, EventLog>,
168
169    /// Next buffer ID to assign
170    next_buffer_id: usize,
171
172    /// Configuration
173    config: Config,
174
175    /// Directory context for editor state paths
176    dir_context: DirectoryContext,
177
178    /// Grammar registry for TextMate syntax highlighting
179    grammar_registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
180
181    /// Active theme
182    theme: crate::view::theme::Theme,
183
184    /// Optional ANSI background image
185    ansi_background: Option<crate::primitives::ansi_background::AnsiBackground>,
186
187    /// Source path for the currently loaded ANSI background
188    ansi_background_path: Option<PathBuf>,
189
190    /// Blend amount for the ANSI background (0..1)
191    background_fade: f32,
192
193    /// Keybinding resolver
194    keybindings: KeybindingResolver,
195
196    /// Shared clipboard (handles both internal and system clipboard)
197    clipboard: crate::services::clipboard::Clipboard,
198
199    /// Should the editor quit?
200    should_quit: bool,
201
202    /// If set, the editor should restart with this new working directory
203    /// This is used by Open Folder to do a clean context switch
204    restart_with_dir: Option<PathBuf>,
205
206    /// Status message (shown in status bar)
207    status_message: Option<String>,
208
209    /// Plugin-provided status message (displayed alongside the core status)
210    plugin_status_message: Option<String>,
211
212    /// Accumulated plugin errors (for test assertions)
213    /// These are collected when plugin error messages are received
214    plugin_errors: Vec<String>,
215
216    /// Active prompt (minibuffer)
217    prompt: Option<Prompt>,
218
219    /// Terminal dimensions (for creating new buffers)
220    terminal_width: u16,
221    terminal_height: u16,
222
223    /// LSP manager
224    lsp: Option<LspManager>,
225
226    /// Metadata for each buffer (file paths, LSP status, etc.)
227    buffer_metadata: HashMap<BufferId, BufferMetadata>,
228
229    /// Buffer mode registry (for buffer-local keybindings)
230    mode_registry: ModeRegistry,
231
232    /// Tokio runtime for async I/O tasks
233    tokio_runtime: Option<tokio::runtime::Runtime>,
234
235    /// Bridge for async messages from tokio tasks to main loop
236    async_bridge: Option<AsyncBridge>,
237
238    /// Split view manager
239    split_manager: SplitManager,
240
241    /// Per-split view state (cursors and viewport for each split)
242    /// This allows multiple splits showing the same buffer to have independent
243    /// cursor positions and scroll positions
244    split_view_states: HashMap<SplitId, SplitViewState>,
245
246    /// Previous viewport states for viewport_changed hook detection
247    /// Stores (top_byte, width, height) from the end of the last render frame
248    /// Used to detect viewport changes that occur between renders (e.g., scroll events)
249    previous_viewports: HashMap<SplitId, (usize, u16, u16)>,
250
251    /// Scroll sync manager for anchor-based synchronized scrolling
252    /// Used for side-by-side diff views where two panes need to scroll together
253    scroll_sync_manager: ScrollSyncManager,
254
255    /// File explorer view (optional, only when open)
256    file_explorer: Option<FileTreeView>,
257
258    /// Filesystem manager for file explorer
259    fs_manager: Arc<FsManager>,
260
261    /// Whether file explorer is visible
262    file_explorer_visible: bool,
263
264    /// Whether file explorer is being synced to active file (async operation in progress)
265    /// When true, we still render the file explorer area even if file_explorer is temporarily None
266    file_explorer_sync_in_progress: bool,
267
268    /// File explorer width as percentage (0.0 to 1.0)
269    /// This is the runtime value that can be modified by dragging the border
270    file_explorer_width_percent: f32,
271
272    /// Pending show_hidden setting to apply when file explorer is initialized (from session restore)
273    pending_file_explorer_show_hidden: Option<bool>,
274
275    /// Pending show_gitignored setting to apply when file explorer is initialized (from session restore)
276    pending_file_explorer_show_gitignored: Option<bool>,
277
278    /// File explorer decorations by namespace
279    file_explorer_decorations: HashMap<String, Vec<crate::view::file_tree::FileExplorerDecoration>>,
280
281    /// Cached file explorer decorations (resolved + bubbled)
282    file_explorer_decoration_cache: crate::view::file_tree::FileExplorerDecorationCache,
283
284    /// Whether menu bar is visible
285    menu_bar_visible: bool,
286
287    /// Whether menu bar was auto-shown (temporarily visible due to menu activation)
288    /// When true, the menu bar will be hidden again when the menu is closed
289    menu_bar_auto_shown: bool,
290
291    /// Whether tab bar is visible
292    tab_bar_visible: bool,
293
294    /// Whether mouse capture is enabled
295    mouse_enabled: bool,
296
297    /// Mouse cursor position (for GPM software cursor rendering)
298    /// When GPM is active, we need to draw our own cursor since GPM can't
299    /// draw on the alternate screen buffer used by TUI applications.
300    mouse_cursor_position: Option<(u16, u16)>,
301
302    /// Whether GPM is being used for mouse input (requires software cursor)
303    gpm_active: bool,
304
305    /// Current keybinding context
306    key_context: KeyContext,
307
308    /// Menu state (active menu, highlighted item)
309    menu_state: crate::view::ui::MenuState,
310
311    /// Menu configuration (built-in menus with i18n support)
312    menus: crate::config::MenuConfig,
313
314    /// Working directory for file explorer (set at initialization)
315    working_dir: PathBuf,
316
317    /// Position history for back/forward navigation
318    pub position_history: PositionHistory,
319
320    /// Flag to prevent recording movements during navigation
321    in_navigation: bool,
322
323    /// Next LSP request ID
324    next_lsp_request_id: u64,
325
326    /// Pending LSP completion request ID (if any)
327    pending_completion_request: Option<u64>,
328
329    /// Original LSP completion items (for type-to-filter)
330    /// Stored when completion popup is shown, used for re-filtering as user types
331    completion_items: Option<Vec<lsp_types::CompletionItem>>,
332
333    /// Pending LSP go-to-definition request ID (if any)
334    pending_goto_definition_request: Option<u64>,
335
336    /// Pending LSP hover request ID (if any)
337    pending_hover_request: Option<u64>,
338
339    /// Pending LSP find references request ID (if any)
340    pending_references_request: Option<u64>,
341
342    /// Symbol name for pending references request
343    pending_references_symbol: String,
344
345    /// Pending LSP signature help request ID (if any)
346    pending_signature_help_request: Option<u64>,
347
348    /// Pending LSP code actions request ID (if any)
349    pending_code_actions_request: Option<u64>,
350
351    /// Pending LSP inlay hints request ID (if any)
352    pending_inlay_hints_request: Option<u64>,
353
354    /// Pending semantic token requests keyed by LSP request ID
355    pending_semantic_token_requests: HashMap<u64, SemanticTokenFullRequest>,
356
357    /// Track semantic token requests per buffer to prevent duplicate inflight requests
358    semantic_tokens_in_flight: HashMap<BufferId, (u64, u64, SemanticTokensFullRequestKind)>,
359
360    /// Pending semantic token range requests keyed by LSP request ID
361    pending_semantic_token_range_requests: HashMap<u64, SemanticTokenRangeRequest>,
362
363    /// Track semantic token range requests per buffer (request_id, start_line, end_line, version)
364    semantic_tokens_range_in_flight: HashMap<BufferId, (u64, usize, usize, u64)>,
365
366    /// Track last semantic token range request per buffer (start_line, end_line, version, time)
367    semantic_tokens_range_last_request: HashMap<BufferId, (usize, usize, u64, Instant)>,
368
369    /// Track last applied semantic token range per buffer (start_line, end_line, version)
370    semantic_tokens_range_applied: HashMap<BufferId, (usize, usize, u64)>,
371
372    /// Next time a full semantic token refresh is allowed for a buffer
373    semantic_tokens_full_debounce: HashMap<BufferId, Instant>,
374
375    /// Hover symbol range (byte offsets) - for highlighting the symbol under hover
376    /// Format: (start_byte_offset, end_byte_offset)
377    hover_symbol_range: Option<(usize, usize)>,
378
379    /// Hover symbol overlay handle (for removal)
380    hover_symbol_overlay: Option<crate::view::overlay::OverlayHandle>,
381
382    /// Mouse hover screen position for popup placement
383    /// Set when a mouse-triggered hover request is sent
384    mouse_hover_screen_position: Option<(u16, u16)>,
385
386    /// Search state (if search is active)
387    search_state: Option<SearchState>,
388
389    /// Search highlight namespace (for efficient bulk removal)
390    search_namespace: crate::view::overlay::OverlayNamespace,
391
392    /// LSP diagnostic namespace (for filtering and bulk removal)
393    lsp_diagnostic_namespace: crate::view::overlay::OverlayNamespace,
394
395    /// Pending search range that should be reused when the next search is confirmed
396    pending_search_range: Option<Range<usize>>,
397
398    /// Interactive replace state (if interactive replace is active)
399    interactive_replace_state: Option<InteractiveReplaceState>,
400
401    /// LSP status indicator for status bar
402    lsp_status: String,
403
404    /// Mouse state for scrollbar dragging
405    mouse_state: MouseState,
406
407    /// Tab context menu state (right-click on tabs)
408    tab_context_menu: Option<TabContextMenu>,
409
410    /// Cached layout areas from last render (for mouse hit testing)
411    pub(crate) cached_layout: CachedLayout,
412
413    /// Command registry for dynamic commands
414    command_registry: Arc<RwLock<CommandRegistry>>,
415
416    /// Plugin manager (handles both enabled and disabled cases)
417    plugin_manager: PluginManager,
418
419    /// Track which byte ranges have been seen per buffer (for lines_changed optimization)
420    /// Maps buffer_id -> set of (byte_start, byte_end) ranges that have been processed
421    /// Using byte ranges instead of line numbers makes this agnostic to line number shifts
422    seen_byte_ranges: HashMap<BufferId, std::collections::HashSet<(usize, usize)>>,
423
424    /// Named panel IDs mapping (for idempotent panel operations)
425    /// Maps panel ID (e.g., "diagnostics") to buffer ID
426    panel_ids: HashMap<String, BufferId>,
427
428    /// Background process abort handles for cancellation
429    /// Maps process_id to abort handle
430    background_process_handles: HashMap<u64, tokio::task::AbortHandle>,
431
432    /// Prompt histories keyed by prompt type name (e.g., "search", "replace", "goto_line", "plugin:custom_name")
433    /// This provides a generic history system that works for all prompt types including plugin prompts.
434    prompt_histories: HashMap<String, crate::input::input_history::InputHistory>,
435
436    /// Pending async prompt callback ID (for editor.prompt() API)
437    /// When the prompt is confirmed, the callback is resolved with the input text.
438    /// When cancelled, the callback is resolved with null.
439    pending_async_prompt_callback: Option<fresh_core::api::JsCallbackId>,
440
441    /// LSP progress tracking (token -> progress info)
442    lsp_progress: std::collections::HashMap<String, LspProgressInfo>,
443
444    /// LSP server statuses (language -> status)
445    lsp_server_statuses:
446        std::collections::HashMap<String, crate::services::async_bridge::LspServerStatus>,
447
448    /// LSP window messages (recent messages from window/showMessage)
449    lsp_window_messages: Vec<LspMessageEntry>,
450
451    /// LSP log messages (recent messages from window/logMessage)
452    lsp_log_messages: Vec<LspMessageEntry>,
453
454    /// Diagnostic result IDs per URI (for incremental pull diagnostics)
455    /// Maps URI string to last result_id received from server
456    diagnostic_result_ids: HashMap<String, String>,
457
458    /// Stored LSP diagnostics per URI
459    /// Maps file URI string to Vec of diagnostics for that file
460    stored_diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
461
462    /// Event broadcaster for control events (observable by external systems)
463    event_broadcaster: crate::model::control_event::EventBroadcaster,
464
465    /// Bookmarks (character key -> bookmark)
466    bookmarks: HashMap<char, Bookmark>,
467
468    /// Global search options (persist across searches)
469    search_case_sensitive: bool,
470    search_whole_word: bool,
471    search_use_regex: bool,
472    /// Whether to confirm each replacement (interactive/query-replace mode)
473    search_confirm_each: bool,
474
475    /// Macro storage (key -> list of recorded actions)
476    macros: HashMap<char, Vec<Action>>,
477
478    /// Macro recording state (Some(key) if recording, None otherwise)
479    macro_recording: Option<MacroRecordingState>,
480
481    /// Last recorded macro register (for F12 to replay)
482    last_macro_register: Option<char>,
483
484    /// Flag to prevent recursive macro playback
485    macro_playing: bool,
486
487    /// Pending plugin action receivers (for async action execution)
488    #[cfg(feature = "plugins")]
489    pending_plugin_actions: Vec<(
490        String,
491        crate::services::plugins::thread::oneshot::Receiver<anyhow::Result<()>>,
492    )>,
493
494    /// Flag set by plugin commands that need a render (e.g., RefreshLines)
495    #[cfg(feature = "plugins")]
496    plugin_render_requested: bool,
497
498    /// Pending chord sequence for multi-key bindings (e.g., C-x C-s in Emacs)
499    /// Stores the keys pressed so far in a chord sequence
500    chord_state: Vec<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
501
502    /// Pending LSP confirmation - language name awaiting user confirmation
503    /// When Some, a confirmation popup is shown asking user to approve LSP spawn
504    pending_lsp_confirmation: Option<String>,
505
506    /// Pending close buffer - buffer to close after SaveFileAs completes
507    /// Used when closing a modified buffer that needs to be saved first
508    pending_close_buffer: Option<BufferId>,
509
510    /// Whether auto-revert mode is enabled (automatically reload files when changed on disk)
511    auto_revert_enabled: bool,
512
513    /// Last time we polled for file changes (for auto-revert)
514    last_auto_revert_poll: std::time::Instant,
515
516    /// Last time we polled for directory changes (for file tree refresh)
517    last_file_tree_poll: std::time::Instant,
518
519    /// Last known modification times for open files (for auto-revert)
520    /// Maps file path to last known modification time
521    file_mod_times: HashMap<PathBuf, std::time::SystemTime>,
522
523    /// Last known modification times for expanded directories (for file tree refresh)
524    /// Maps directory path to last known modification time
525    dir_mod_times: HashMap<PathBuf, std::time::SystemTime>,
526
527    /// Tracks rapid file change events for debouncing
528    /// Maps file path to (last event time, event count)
529    file_rapid_change_counts: HashMap<PathBuf, (std::time::Instant, u32)>,
530
531    /// File open dialog state (when PromptType::OpenFile is active)
532    file_open_state: Option<file_open::FileOpenState>,
533
534    /// Cached layout for file browser (for mouse hit testing)
535    file_browser_layout: Option<crate::view::ui::FileBrowserLayout>,
536
537    /// Recovery service for auto-save and crash recovery
538    recovery_service: RecoveryService,
539
540    /// Request a full terminal clear and redraw on the next frame
541    full_redraw_requested: bool,
542
543    /// Time source for testable time operations
544    time_source: SharedTimeSource,
545
546    /// Last auto-save time for rate limiting
547    last_auto_save: std::time::Instant,
548
549    /// Active custom contexts for command visibility
550    /// Plugin-defined contexts like "config-editor" that control command availability
551    active_custom_contexts: HashSet<String>,
552
553    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
554    /// When set, this mode's keybindings take precedence over normal key handling
555    editor_mode: Option<String>,
556
557    /// Warning log receiver and path (for tracking warnings)
558    warning_log: Option<(std::sync::mpsc::Receiver<()>, PathBuf)>,
559
560    /// Warning domain registry for extensible warning indicators
561    /// Contains LSP warnings, general warnings, and can be extended by plugins
562    warning_domains: WarningDomainRegistry,
563
564    /// Periodic update checker (checks for new releases every hour)
565    update_checker: Option<crate::services::release_checker::PeriodicUpdateChecker>,
566
567    /// Terminal manager for built-in terminal support
568    terminal_manager: crate::services::terminal::TerminalManager,
569
570    /// Maps buffer ID to terminal ID (for terminal buffers)
571    terminal_buffers: HashMap<BufferId, crate::services::terminal::TerminalId>,
572
573    /// Maps terminal ID to backing file path (for terminal content storage)
574    terminal_backing_files: HashMap<crate::services::terminal::TerminalId, std::path::PathBuf>,
575
576    /// Maps terminal ID to raw log file path (full PTY capture)
577    terminal_log_files: HashMap<crate::services::terminal::TerminalId, std::path::PathBuf>,
578
579    /// Whether terminal mode is active (input goes to terminal)
580    terminal_mode: bool,
581
582    /// Whether keyboard capture is enabled in terminal mode.
583    /// When true, ALL keys go to the terminal (except Ctrl+` to toggle).
584    /// When false, UI keybindings (split nav, palette, etc.) are processed first.
585    keyboard_capture: bool,
586
587    /// Set of terminal buffer IDs that should auto-resume terminal mode when switched back to.
588    /// When leaving a terminal while in terminal mode, its ID is added here.
589    /// When switching to a terminal in this set, terminal mode is automatically re-entered.
590    terminal_mode_resume: std::collections::HashSet<BufferId>,
591
592    /// Timestamp of the previous mouse click (for double-click detection)
593    previous_click_time: Option<std::time::Instant>,
594
595    /// Position of the previous mouse click (for double-click detection)
596    /// Double-click is only detected if both clicks are at the same position
597    previous_click_position: Option<(u16, u16)>,
598
599    /// Settings UI state (when settings modal is open)
600    pub(crate) settings_state: Option<crate::view::settings::SettingsState>,
601
602    /// Calibration wizard state (when calibration modal is open)
603    pub(crate) calibration_wizard: Option<calibration_wizard::CalibrationWizard>,
604
605    /// Key translator for input calibration (loaded from config)
606    pub(crate) key_translator: crate::input::key_translator::KeyTranslator,
607
608    /// Terminal color capability (true color, 256, or 16 colors)
609    color_capability: crate::view::color_support::ColorCapability,
610
611    /// Hunks for the Review Diff tool
612    review_hunks: Vec<fresh_core::api::ReviewHunk>,
613
614    /// Active action popup (for plugin showActionPopup API)
615    /// Stores (popup_id, Vec<(action_id, action_label)>)
616    active_action_popup: Option<(String, Vec<(String, String)>)>,
617
618    /// Composite buffers (separate from regular buffers)
619    /// These display multiple source buffers in a single tab
620    composite_buffers: HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
621
622    /// View state for composite buffers (per split)
623    /// Maps (split_id, buffer_id) to composite view state
624    composite_view_states:
625        HashMap<(SplitId, BufferId), crate::view::composite_view::CompositeViewState>,
626
627    /// Stdin streaming state (if reading from stdin)
628    stdin_streaming: Option<StdinStreamingState>,
629}
630
631/// State for tracking stdin streaming in background
632pub struct StdinStreamingState {
633    /// Path to temp file where stdin is being written
634    pub temp_path: PathBuf,
635    /// Buffer ID for the stdin buffer
636    pub buffer_id: BufferId,
637    /// Last known file size (for detecting growth)
638    pub last_known_size: usize,
639    /// Whether streaming is complete (background thread finished)
640    pub complete: bool,
641    /// Background thread handle (for checking completion)
642    pub thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
643}
644
645impl Editor {
646    /// Create a new editor with the given configuration and terminal dimensions
647    /// Uses system directories for state (recovery, sessions, etc.)
648    pub fn new(
649        config: Config,
650        width: u16,
651        height: u16,
652        dir_context: DirectoryContext,
653        color_capability: crate::view::color_support::ColorCapability,
654    ) -> AnyhowResult<Self> {
655        Self::with_working_dir(
656            config,
657            width,
658            height,
659            None,
660            dir_context,
661            true,
662            color_capability,
663        )
664    }
665
666    /// Create a new editor with an explicit working directory
667    /// This is useful for testing with isolated temporary directories
668    pub fn with_working_dir(
669        config: Config,
670        width: u16,
671        height: u16,
672        working_dir: Option<PathBuf>,
673        dir_context: DirectoryContext,
674        plugins_enabled: bool,
675        color_capability: crate::view::color_support::ColorCapability,
676    ) -> AnyhowResult<Self> {
677        Self::with_options(
678            config,
679            width,
680            height,
681            working_dir,
682            None,
683            plugins_enabled,
684            dir_context,
685            None,
686            color_capability,
687            crate::primitives::grammar::GrammarRegistry::for_editor(),
688        )
689    }
690
691    /// Create a new editor for testing with optional custom backends
692    /// Uses empty grammar registry for fast initialization
693    #[allow(clippy::too_many_arguments)]
694    pub fn for_test(
695        config: Config,
696        width: u16,
697        height: u16,
698        working_dir: Option<PathBuf>,
699        dir_context: DirectoryContext,
700        color_capability: crate::view::color_support::ColorCapability,
701        fs_backend: Option<Arc<dyn FsBackend>>,
702        time_source: Option<SharedTimeSource>,
703    ) -> AnyhowResult<Self> {
704        Self::with_options(
705            config,
706            width,
707            height,
708            working_dir,
709            fs_backend,
710            true,
711            dir_context,
712            time_source,
713            color_capability,
714            crate::primitives::grammar::GrammarRegistry::empty(),
715        )
716    }
717
718    /// Create a new editor with custom options
719    /// This is primarily used for testing with slow or mock backends
720    /// to verify editor behavior under various I/O conditions
721    #[allow(clippy::too_many_arguments)]
722    fn with_options(
723        mut config: Config,
724        width: u16,
725        height: u16,
726        working_dir: Option<PathBuf>,
727        fs_backend: Option<Arc<dyn FsBackend>>,
728        enable_plugins: bool,
729        dir_context: DirectoryContext,
730        time_source: Option<SharedTimeSource>,
731        color_capability: crate::view::color_support::ColorCapability,
732        grammar_registry: Arc<crate::primitives::grammar::GrammarRegistry>,
733    ) -> AnyhowResult<Self> {
734        // Use provided time_source or default to RealTimeSource
735        let time_source = time_source.unwrap_or_else(RealTimeSource::shared);
736        tracing::info!("Editor::new called with width={}, height={}", width, height);
737
738        // Use provided working_dir or capture from environment
739        let working_dir = working_dir
740            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
741
742        // Canonicalize working_dir to resolve symlinks and normalize path components
743        // This ensures consistent path comparisons throughout the editor
744        let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
745
746        // Load theme from config
747        let theme_loader = crate::view::theme::LocalThemeLoader::new();
748        let theme = crate::view::theme::Theme::load(&config.theme, &theme_loader)
749            .ok_or_else(|| anyhow::anyhow!("Theme '{:?}' not found", config.theme))?;
750
751        // Set terminal cursor color to match theme
752        theme.set_terminal_cursor_color();
753
754        tracing::info!(
755            "Grammar registry has {} syntaxes",
756            grammar_registry.available_syntaxes().len()
757        );
758
759        let keybindings = KeybindingResolver::new(&config);
760
761        // Create an empty initial buffer
762        let mut buffers = HashMap::new();
763        let mut event_logs = HashMap::new();
764
765        let buffer_id = BufferId(0);
766        let mut state = EditorState::new(
767            width,
768            height,
769            config.editor.large_file_threshold_bytes as usize,
770        );
771        // Apply line_numbers default from config (fixes #539)
772        state.margins.set_line_numbers(config.editor.line_numbers);
773        // Note: line_wrap_enabled is now stored in SplitViewState.viewport
774        tracing::info!("EditorState created for buffer {:?}", buffer_id);
775        buffers.insert(buffer_id, state);
776        event_logs.insert(buffer_id, EventLog::new());
777
778        // Create metadata for the initial empty buffer
779        let mut buffer_metadata = HashMap::new();
780        buffer_metadata.insert(buffer_id, BufferMetadata::new());
781
782        // Initialize LSP manager with current working directory as root
783        let root_uri = url::Url::from_file_path(&working_dir)
784            .ok()
785            .and_then(|u| u.as_str().parse::<lsp_types::Uri>().ok());
786
787        // Create Tokio runtime for async I/O (LSP, file watching, git, etc.)
788        let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
789            .worker_threads(2) // Small pool for I/O tasks
790            .thread_name("editor-async")
791            .enable_all()
792            .build()
793            .ok();
794
795        // Create async bridge for communication
796        let async_bridge = AsyncBridge::new();
797
798        if tokio_runtime.is_none() {
799            tracing::warn!("Failed to create Tokio runtime - async features disabled");
800        }
801
802        // Create LSP manager with async support
803        let mut lsp = LspManager::new(root_uri);
804
805        // Configure runtime and bridge if available
806        if let Some(ref runtime) = tokio_runtime {
807            lsp.set_runtime(runtime.handle().clone(), async_bridge.clone());
808        }
809
810        // Configure LSP servers from config
811        for (language, lsp_config) in &config.lsp {
812            lsp.set_language_config(language.clone(), lsp_config.clone());
813        }
814
815        // Initialize split manager with the initial buffer
816        let split_manager = SplitManager::new(buffer_id);
817
818        // Initialize per-split view state for the initial split
819        let mut split_view_states = HashMap::new();
820        let initial_split_id = split_manager.active_split();
821        let mut initial_view_state = SplitViewState::with_buffer(width, height, buffer_id);
822        initial_view_state.viewport.line_wrap_enabled = config.editor.line_wrap;
823        split_view_states.insert(initial_split_id, initial_view_state);
824
825        // Initialize filesystem manager for file explorer
826        // Use provided backend or create default LocalFsBackend
827        let fs_backend = fs_backend.unwrap_or_else(|| Arc::new(LocalFsBackend::new()));
828        let fs_manager = Arc::new(FsManager::new(fs_backend));
829
830        // Initialize command registry (always available, used by both plugins and core)
831        let command_registry = Arc::new(RwLock::new(CommandRegistry::new()));
832
833        // Initialize plugin manager (handles both enabled and disabled cases internally)
834        let plugin_manager = PluginManager::new(
835            enable_plugins,
836            Arc::clone(&command_registry),
837            dir_context.clone(),
838        );
839
840        // Update the plugin state snapshot with working_dir BEFORE loading plugins
841        // This ensures plugins can call getCwd() correctly during initialization
842        #[cfg(feature = "plugins")]
843        if let Some(snapshot_handle) = plugin_manager.state_snapshot_handle() {
844            let mut snapshot = snapshot_handle.write().unwrap();
845            snapshot.working_dir = working_dir.clone();
846        }
847
848        // Load TypeScript plugins from multiple directories:
849        // 1. Next to the executable (for cargo-dist installations)
850        // 2. In the working directory (for development/local usage)
851        // 3. From embedded plugins (for cargo-binstall, when embed-plugins feature is enabled)
852        if plugin_manager.is_active() {
853            let mut plugin_dirs: Vec<std::path::PathBuf> = vec![];
854
855            // Check next to executable first (for cargo-dist installations)
856            if let Ok(exe_path) = std::env::current_exe() {
857                if let Some(exe_dir) = exe_path.parent() {
858                    let exe_plugin_dir = exe_dir.join("plugins");
859                    if exe_plugin_dir.exists() {
860                        plugin_dirs.push(exe_plugin_dir);
861                    }
862                }
863            }
864
865            // Then check working directory (for development)
866            let working_plugin_dir = working_dir.join("plugins");
867            if working_plugin_dir.exists() && !plugin_dirs.contains(&working_plugin_dir) {
868                plugin_dirs.push(working_plugin_dir);
869            }
870
871            // If no disk plugins found, try embedded plugins (cargo-binstall builds)
872            #[cfg(feature = "embed-plugins")]
873            if plugin_dirs.is_empty() {
874                if let Some(embedded_dir) =
875                    crate::services::plugins::embedded::get_embedded_plugins_dir()
876                {
877                    tracing::info!("Using embedded plugins from: {:?}", embedded_dir);
878                    plugin_dirs.push(embedded_dir.clone());
879                }
880            }
881
882            if plugin_dirs.is_empty() {
883                tracing::debug!(
884                    "No plugins directory found next to executable or in working dir: {:?}",
885                    working_dir
886                );
887            }
888
889            // Load from all found plugin directories, respecting config
890            for plugin_dir in plugin_dirs {
891                tracing::info!("Loading TypeScript plugins from: {:?}", plugin_dir);
892                let (errors, discovered_plugins) =
893                    plugin_manager.load_plugins_from_dir_with_config(&plugin_dir, &config.plugins);
894
895                // Merge discovered plugins into config
896                // discovered_plugins already contains the merged config (saved enabled state + discovered path)
897                for (name, plugin_config) in discovered_plugins {
898                    config.plugins.insert(name, plugin_config);
899                }
900
901                if !errors.is_empty() {
902                    for err in &errors {
903                        tracing::error!("TypeScript plugin load error: {}", err);
904                    }
905                    // In debug/test builds, panic to surface plugin loading errors
906                    #[cfg(debug_assertions)]
907                    panic!(
908                        "TypeScript plugin loading failed with {} error(s): {}",
909                        errors.len(),
910                        errors.join("; ")
911                    );
912                }
913            }
914        }
915
916        // Extract config values before moving config into the struct
917        let file_explorer_width = config.file_explorer.width;
918        let recovery_enabled = config.editor.recovery_enabled;
919        let auto_save_interval_secs = config.editor.auto_save_interval_secs;
920        let check_for_updates = config.check_for_updates;
921        let show_menu_bar = config.editor.show_menu_bar;
922        let show_tab_bar = config.editor.show_tab_bar;
923
924        // Start periodic update checker if enabled (also sends daily telemetry)
925        let update_checker = if check_for_updates {
926            tracing::debug!("Update checking enabled, starting periodic checker");
927            Some(
928                crate::services::release_checker::start_periodic_update_check(
929                    crate::services::release_checker::DEFAULT_RELEASES_URL,
930                    time_source.clone(),
931                    dir_context.data_dir.clone(),
932                ),
933            )
934        } else {
935            tracing::debug!("Update checking disabled by config");
936            None
937        };
938
939        let mut editor = Editor {
940            buffers,
941            event_logs,
942            next_buffer_id: 1,
943            config,
944            dir_context: dir_context.clone(),
945            grammar_registry,
946            theme,
947            ansi_background: None,
948            ansi_background_path: None,
949            background_fade: crate::primitives::ansi_background::DEFAULT_BACKGROUND_FADE,
950            keybindings,
951            clipboard: crate::services::clipboard::Clipboard::new(),
952            should_quit: false,
953            restart_with_dir: None,
954            status_message: None,
955            plugin_status_message: None,
956            plugin_errors: Vec::new(),
957            prompt: None,
958            terminal_width: width,
959            terminal_height: height,
960            lsp: Some(lsp),
961            buffer_metadata,
962            mode_registry: ModeRegistry::new(),
963            tokio_runtime,
964            async_bridge: Some(async_bridge),
965            split_manager,
966            split_view_states,
967            previous_viewports: HashMap::new(),
968            scroll_sync_manager: ScrollSyncManager::new(),
969            file_explorer: None,
970            fs_manager,
971            file_explorer_visible: false,
972            file_explorer_sync_in_progress: false,
973            file_explorer_width_percent: file_explorer_width,
974            pending_file_explorer_show_hidden: None,
975            pending_file_explorer_show_gitignored: None,
976            menu_bar_visible: show_menu_bar,
977            file_explorer_decorations: HashMap::new(),
978            file_explorer_decoration_cache:
979                crate::view::file_tree::FileExplorerDecorationCache::default(),
980            menu_bar_auto_shown: false,
981            tab_bar_visible: show_tab_bar,
982            mouse_enabled: true,
983            mouse_cursor_position: None,
984            gpm_active: false,
985            key_context: KeyContext::Normal,
986            menu_state: crate::view::ui::MenuState::new(),
987            menus: crate::config::MenuConfig::translated(),
988            working_dir,
989            position_history: PositionHistory::new(),
990            in_navigation: false,
991            next_lsp_request_id: 0,
992            pending_completion_request: None,
993            completion_items: None,
994            pending_goto_definition_request: None,
995            pending_hover_request: None,
996            pending_references_request: None,
997            pending_references_symbol: String::new(),
998            pending_signature_help_request: None,
999            pending_code_actions_request: None,
1000            pending_inlay_hints_request: None,
1001            pending_semantic_token_requests: HashMap::new(),
1002            semantic_tokens_in_flight: HashMap::new(),
1003            pending_semantic_token_range_requests: HashMap::new(),
1004            semantic_tokens_range_in_flight: HashMap::new(),
1005            semantic_tokens_range_last_request: HashMap::new(),
1006            semantic_tokens_range_applied: HashMap::new(),
1007            semantic_tokens_full_debounce: HashMap::new(),
1008            hover_symbol_range: None,
1009            hover_symbol_overlay: None,
1010            mouse_hover_screen_position: None,
1011            search_state: None,
1012            search_namespace: crate::view::overlay::OverlayNamespace::from_string(
1013                "search".to_string(),
1014            ),
1015            lsp_diagnostic_namespace: crate::view::overlay::OverlayNamespace::from_string(
1016                "lsp-diagnostic".to_string(),
1017            ),
1018            pending_search_range: None,
1019            interactive_replace_state: None,
1020            lsp_status: String::new(),
1021            mouse_state: MouseState::default(),
1022            tab_context_menu: None,
1023            cached_layout: CachedLayout::default(),
1024            command_registry,
1025            plugin_manager,
1026            seen_byte_ranges: HashMap::new(),
1027            panel_ids: HashMap::new(),
1028            background_process_handles: HashMap::new(),
1029            prompt_histories: {
1030                // Load prompt histories from disk if available
1031                let mut histories = HashMap::new();
1032                for history_name in ["search", "replace", "goto_line"] {
1033                    let path = dir_context.prompt_history_path(history_name);
1034                    let history = crate::input::input_history::InputHistory::load_from_file(&path)
1035                        .unwrap_or_else(|e| {
1036                            tracing::warn!("Failed to load {} history: {}", history_name, e);
1037                            crate::input::input_history::InputHistory::new()
1038                        });
1039                    histories.insert(history_name.to_string(), history);
1040                }
1041                histories
1042            },
1043            pending_async_prompt_callback: None,
1044            lsp_progress: std::collections::HashMap::new(),
1045            lsp_server_statuses: std::collections::HashMap::new(),
1046            lsp_window_messages: Vec::new(),
1047            lsp_log_messages: Vec::new(),
1048            diagnostic_result_ids: HashMap::new(),
1049            stored_diagnostics: HashMap::new(),
1050            event_broadcaster: crate::model::control_event::EventBroadcaster::default(),
1051            bookmarks: HashMap::new(),
1052            search_case_sensitive: true,
1053            search_whole_word: false,
1054            search_use_regex: false,
1055            search_confirm_each: false,
1056            macros: HashMap::new(),
1057            macro_recording: None,
1058            last_macro_register: None,
1059            macro_playing: false,
1060            #[cfg(feature = "plugins")]
1061            pending_plugin_actions: Vec::new(),
1062            #[cfg(feature = "plugins")]
1063            plugin_render_requested: false,
1064            chord_state: Vec::new(),
1065            pending_lsp_confirmation: None,
1066            pending_close_buffer: None,
1067            auto_revert_enabled: true,
1068            last_auto_revert_poll: time_source.now(),
1069            last_file_tree_poll: time_source.now(),
1070            file_mod_times: HashMap::new(),
1071            dir_mod_times: HashMap::new(),
1072            file_rapid_change_counts: HashMap::new(),
1073            file_open_state: None,
1074            file_browser_layout: None,
1075            recovery_service: {
1076                let recovery_config = RecoveryConfig {
1077                    enabled: recovery_enabled,
1078                    auto_save_interval_secs,
1079                    ..RecoveryConfig::default()
1080                };
1081                RecoveryService::with_config_and_dir(recovery_config, dir_context.recovery_dir())
1082            },
1083            full_redraw_requested: false,
1084            time_source: time_source.clone(),
1085            last_auto_save: time_source.now(),
1086            active_custom_contexts: HashSet::new(),
1087            editor_mode: None,
1088            warning_log: None,
1089            warning_domains: WarningDomainRegistry::new(),
1090            update_checker,
1091            terminal_manager: crate::services::terminal::TerminalManager::new(),
1092            terminal_buffers: HashMap::new(),
1093            terminal_backing_files: HashMap::new(),
1094            terminal_log_files: HashMap::new(),
1095            terminal_mode: false,
1096            keyboard_capture: false,
1097            terminal_mode_resume: std::collections::HashSet::new(),
1098            previous_click_time: None,
1099            previous_click_position: None,
1100            settings_state: None,
1101            calibration_wizard: None,
1102            key_translator: crate::input::key_translator::KeyTranslator::load_default()
1103                .unwrap_or_default(),
1104            color_capability,
1105            stdin_streaming: None,
1106            review_hunks: Vec::new(),
1107            active_action_popup: None,
1108            composite_buffers: HashMap::new(),
1109            composite_view_states: HashMap::new(),
1110        };
1111
1112        #[cfg(feature = "plugins")]
1113        {
1114            editor.update_plugin_state_snapshot();
1115            if editor.plugin_manager.is_active() {
1116                editor.plugin_manager.run_hook(
1117                    "editor_initialized",
1118                    crate::services::plugins::hooks::HookArgs::EditorInitialized,
1119                );
1120            }
1121        }
1122
1123        Ok(editor)
1124    }
1125
1126    /// Get a reference to the event broadcaster
1127    pub fn event_broadcaster(&self) -> &crate::model::control_event::EventBroadcaster {
1128        &self.event_broadcaster
1129    }
1130
1131    /// Get a reference to the async bridge (if available)
1132    pub fn async_bridge(&self) -> Option<&AsyncBridge> {
1133        self.async_bridge.as_ref()
1134    }
1135
1136    /// Get a reference to the config
1137    pub fn config(&self) -> &Config {
1138        &self.config
1139    }
1140
1141    /// Get a reference to the key translator (for input calibration)
1142    pub fn key_translator(&self) -> &crate::input::key_translator::KeyTranslator {
1143        &self.key_translator
1144    }
1145
1146    /// Get a reference to the time source
1147    pub fn time_source(&self) -> &SharedTimeSource {
1148        &self.time_source
1149    }
1150
1151    /// Emit a control event
1152    pub fn emit_event(&self, name: impl Into<String>, data: serde_json::Value) {
1153        self.event_broadcaster.emit_named(name, data);
1154    }
1155
1156    /// Send a response to a plugin for an async operation
1157    fn send_plugin_response(&self, response: fresh_core::api::PluginResponse) {
1158        self.plugin_manager.deliver_response(response);
1159    }
1160
1161    /// Remove a pending semantic token request from tracking maps.
1162    fn take_pending_semantic_token_request(
1163        &mut self,
1164        request_id: u64,
1165    ) -> Option<SemanticTokenFullRequest> {
1166        if let Some(request) = self.pending_semantic_token_requests.remove(&request_id) {
1167            self.semantic_tokens_in_flight.remove(&request.buffer_id);
1168            Some(request)
1169        } else {
1170            None
1171        }
1172    }
1173
1174    /// Remove a pending semantic token range request from tracking maps.
1175    fn take_pending_semantic_token_range_request(
1176        &mut self,
1177        request_id: u64,
1178    ) -> Option<SemanticTokenRangeRequest> {
1179        if let Some(request) = self
1180            .pending_semantic_token_range_requests
1181            .remove(&request_id)
1182        {
1183            self.semantic_tokens_range_in_flight
1184                .remove(&request.buffer_id);
1185            Some(request)
1186        } else {
1187            None
1188        }
1189    }
1190
1191    /// Get all keybindings as (key, action) pairs
1192    pub fn get_all_keybindings(&self) -> Vec<(String, String)> {
1193        self.keybindings.get_all_bindings()
1194    }
1195
1196    /// Get the formatted keybinding for a specific action (for display in messages)
1197    /// Returns None if no keybinding is found for the action
1198    pub fn get_keybinding_for_action(&self, action_name: &str) -> Option<String> {
1199        self.keybindings
1200            .find_keybinding_for_action(action_name, self.key_context)
1201    }
1202
1203    /// Get mutable access to the mode registry
1204    pub fn mode_registry_mut(&mut self) -> &mut ModeRegistry {
1205        &mut self.mode_registry
1206    }
1207
1208    /// Get immutable access to the mode registry
1209    pub fn mode_registry(&self) -> &ModeRegistry {
1210        &self.mode_registry
1211    }
1212
1213    /// Get the currently active buffer ID.
1214    ///
1215    /// This is derived from the split manager (single source of truth).
1216    /// The editor always has at least one buffer, so this never fails.
1217    #[inline]
1218    pub fn active_buffer(&self) -> BufferId {
1219        self.split_manager
1220            .active_buffer_id()
1221            .expect("Editor always has at least one buffer")
1222    }
1223
1224    /// Get the mode name for the active buffer (if it's a virtual buffer)
1225    pub fn active_buffer_mode(&self) -> Option<&str> {
1226        self.buffer_metadata
1227            .get(&self.active_buffer())
1228            .and_then(|meta| meta.virtual_mode())
1229    }
1230
1231    /// Check if the active buffer is read-only
1232    pub fn is_active_buffer_read_only(&self) -> bool {
1233        if let Some(metadata) = self.buffer_metadata.get(&self.active_buffer()) {
1234            if metadata.read_only {
1235                return true;
1236            }
1237            // Also check if the mode is read-only
1238            if let Some(mode_name) = metadata.virtual_mode() {
1239                return self.mode_registry.is_read_only(mode_name);
1240            }
1241        }
1242        false
1243    }
1244
1245    /// Check if editing should be disabled for the active buffer
1246    /// This returns true when editing_disabled is true (e.g., for read-only virtual buffers)
1247    pub fn is_editing_disabled(&self) -> bool {
1248        self.active_state().editing_disabled
1249    }
1250
1251    /// Resolve a keybinding for the current mode
1252    ///
1253    /// First checks the global editor mode (for vi mode and other modal editing).
1254    /// If no global mode is set or no binding is found, falls back to the
1255    /// active buffer's mode (for virtual buffers with custom modes).
1256    /// Returns the command name if found.
1257    pub fn resolve_mode_keybinding(
1258        &self,
1259        code: KeyCode,
1260        modifiers: KeyModifiers,
1261    ) -> Option<String> {
1262        // First check global editor mode (e.g., "vi-normal", "vi-operator-pending")
1263        if let Some(ref global_mode) = self.editor_mode {
1264            if let Some(binding) =
1265                self.mode_registry
1266                    .resolve_keybinding(global_mode, code, modifiers)
1267            {
1268                return Some(binding);
1269            }
1270        }
1271
1272        // Fall back to buffer-local mode (for virtual buffers)
1273        let mode_name = self.active_buffer_mode()?;
1274        self.mode_registry
1275            .resolve_keybinding(mode_name, code, modifiers)
1276    }
1277
1278    /// Check if LSP has any active progress tasks (e.g., indexing)
1279    pub fn has_active_lsp_progress(&self) -> bool {
1280        !self.lsp_progress.is_empty()
1281    }
1282
1283    /// Get the current LSP progress info (if any)
1284    pub fn get_lsp_progress(&self) -> Vec<(String, String, Option<String>)> {
1285        self.lsp_progress
1286            .iter()
1287            .map(|(token, info)| (token.clone(), info.title.clone(), info.message.clone()))
1288            .collect()
1289    }
1290
1291    /// Check if LSP server for a given language is running (ready)
1292    pub fn is_lsp_server_ready(&self, language: &str) -> bool {
1293        use crate::services::async_bridge::LspServerStatus;
1294        self.lsp_server_statuses
1295            .get(language)
1296            .map(|status| matches!(status, LspServerStatus::Running))
1297            .unwrap_or(false)
1298    }
1299
1300    /// Get the LSP status string (displayed in status bar)
1301    pub fn get_lsp_status(&self) -> &str {
1302        &self.lsp_status
1303    }
1304
1305    /// Get stored LSP diagnostics (for testing and external access)
1306    /// Returns a reference to the diagnostics map keyed by file URI
1307    pub fn get_stored_diagnostics(&self) -> &HashMap<String, Vec<lsp_types::Diagnostic>> {
1308        &self.stored_diagnostics
1309    }
1310
1311    /// Check if an update is available
1312    pub fn is_update_available(&self) -> bool {
1313        self.update_checker
1314            .as_ref()
1315            .map(|c| c.is_update_available())
1316            .unwrap_or(false)
1317    }
1318
1319    /// Get the latest version string if an update is available
1320    pub fn latest_version(&self) -> Option<&str> {
1321        self.update_checker
1322            .as_ref()
1323            .and_then(|c| c.latest_version())
1324    }
1325
1326    /// Get the cached release check result (for shutdown notification)
1327    pub fn get_update_result(
1328        &self,
1329    ) -> Option<&crate::services::release_checker::ReleaseCheckResult> {
1330        self.update_checker
1331            .as_ref()
1332            .and_then(|c| c.get_cached_result())
1333    }
1334
1335    /// Set a custom update checker (for testing)
1336    ///
1337    /// This allows injecting a custom PeriodicUpdateChecker that points to a mock server,
1338    /// enabling E2E tests for the update notification UI.
1339    #[doc(hidden)]
1340    pub fn set_update_checker(
1341        &mut self,
1342        checker: crate::services::release_checker::PeriodicUpdateChecker,
1343    ) {
1344        self.update_checker = Some(checker);
1345    }
1346
1347    /// Configure LSP server for a specific language
1348    pub fn set_lsp_config(&mut self, language: String, config: LspServerConfig) {
1349        if let Some(ref mut lsp) = self.lsp {
1350            lsp.set_language_config(language, config);
1351        }
1352    }
1353
1354    /// Get a list of currently running LSP server languages
1355    pub fn running_lsp_servers(&self) -> Vec<String> {
1356        self.lsp
1357            .as_ref()
1358            .map(|lsp| lsp.running_servers())
1359            .unwrap_or_default()
1360    }
1361
1362    /// Shutdown an LSP server by language (marks it as disabled until manual restart)
1363    ///
1364    /// Returns true if the server was found and shutdown, false otherwise
1365    pub fn shutdown_lsp_server(&mut self, language: &str) -> bool {
1366        if let Some(ref mut lsp) = self.lsp {
1367            lsp.shutdown_server(language)
1368        } else {
1369            false
1370        }
1371    }
1372
1373    /// Enable event log streaming to a file
1374    pub fn enable_event_streaming<P: AsRef<Path>>(&mut self, path: P) -> AnyhowResult<()> {
1375        // Enable streaming for all existing event logs
1376        for event_log in self.event_logs.values_mut() {
1377            event_log.enable_streaming(&path)?;
1378        }
1379        Ok(())
1380    }
1381
1382    /// Log keystroke for debugging
1383    pub fn log_keystroke(&mut self, key_code: &str, modifiers: &str) {
1384        if let Some(event_log) = self.event_logs.get_mut(&self.active_buffer()) {
1385            event_log.log_keystroke(key_code, modifiers);
1386        }
1387    }
1388
1389    /// Set up warning log monitoring
1390    ///
1391    /// When warnings/errors are logged, they will be written to the specified path
1392    /// and the editor will be notified via the receiver.
1393    pub fn set_warning_log(&mut self, receiver: std::sync::mpsc::Receiver<()>, path: PathBuf) {
1394        self.warning_log = Some((receiver, path));
1395    }
1396
1397    /// Check for and handle any new warnings in the warning log
1398    ///
1399    /// Updates the general warning domain for the status bar.
1400    /// Returns true if new warnings were found.
1401    pub fn check_warning_log(&mut self) -> bool {
1402        let Some((receiver, path)) = &self.warning_log else {
1403            return false;
1404        };
1405
1406        // Non-blocking check for any warnings
1407        let mut new_warning_count = 0usize;
1408        while receiver.try_recv().is_ok() {
1409            new_warning_count += 1;
1410        }
1411
1412        if new_warning_count > 0 {
1413            // Update general warning domain (don't auto-open file)
1414            self.warning_domains.general.add_warnings(new_warning_count);
1415            self.warning_domains.general.set_log_path(path.clone());
1416        }
1417
1418        new_warning_count > 0
1419    }
1420
1421    /// Get the warning domain registry
1422    pub fn get_warning_domains(&self) -> &WarningDomainRegistry {
1423        &self.warning_domains
1424    }
1425
1426    /// Get the warning log path (for opening when user clicks indicator)
1427    pub fn get_warning_log_path(&self) -> Option<&PathBuf> {
1428        self.warning_domains.general.log_path.as_ref()
1429    }
1430
1431    /// Open the warning log file (user-initiated action)
1432    pub fn open_warning_log(&mut self) {
1433        if let Some(path) = self.warning_domains.general.log_path.clone() {
1434            if let Err(e) = self.open_file(&path) {
1435                tracing::error!("Failed to open warning log: {}", e);
1436            }
1437        }
1438    }
1439
1440    /// Clear the general warning indicator (user dismissed)
1441    pub fn clear_warning_indicator(&mut self) {
1442        self.warning_domains.general.clear();
1443    }
1444
1445    /// Clear all warning indicators (user dismissed via command)
1446    pub fn clear_warnings(&mut self) {
1447        self.warning_domains.general.clear();
1448        self.warning_domains.lsp.clear();
1449        self.status_message = Some("Warnings cleared".to_string());
1450    }
1451
1452    /// Check if any LSP server is in error state
1453    pub fn has_lsp_error(&self) -> bool {
1454        self.warning_domains.lsp.level() == WarningLevel::Error
1455    }
1456
1457    /// Get the effective warning level for the status bar (LSP indicator)
1458    /// Returns Error if LSP has errors, Warning if there are warnings, None otherwise
1459    pub fn get_effective_warning_level(&self) -> WarningLevel {
1460        self.warning_domains.lsp.level()
1461    }
1462
1463    /// Get the general warning level (for the general warning badge)
1464    pub fn get_general_warning_level(&self) -> WarningLevel {
1465        self.warning_domains.general.level()
1466    }
1467
1468    /// Get the general warning count
1469    pub fn get_general_warning_count(&self) -> usize {
1470        self.warning_domains.general.count
1471    }
1472
1473    /// Update LSP warning domain from server statuses
1474    pub fn update_lsp_warning_domain(&mut self) {
1475        self.warning_domains
1476            .lsp
1477            .update_from_statuses(&self.lsp_server_statuses);
1478    }
1479
1480    /// Check if mouse hover timer has expired and trigger LSP hover request
1481    ///
1482    /// This implements debounced hover - we wait for the configured delay before
1483    /// sending the request to avoid spamming the LSP server on every mouse move.
1484    /// Returns true if a hover request was triggered.
1485    pub fn check_mouse_hover_timer(&mut self) -> bool {
1486        // Check if mouse hover is enabled
1487        if !self.config.editor.mouse_hover_enabled {
1488            return false;
1489        }
1490
1491        let hover_delay = std::time::Duration::from_millis(self.config.editor.mouse_hover_delay_ms);
1492
1493        // Get hover state without borrowing self
1494        let hover_info = match self.mouse_state.lsp_hover_state {
1495            Some((byte_pos, start_time, screen_x, screen_y)) => {
1496                if self.mouse_state.lsp_hover_request_sent {
1497                    return false; // Already sent request for this position
1498                }
1499                if start_time.elapsed() < hover_delay {
1500                    return false; // Timer hasn't expired yet
1501                }
1502                Some((byte_pos, screen_x, screen_y))
1503            }
1504            None => return false,
1505        };
1506
1507        let Some((byte_pos, screen_x, screen_y)) = hover_info else {
1508            return false;
1509        };
1510
1511        // Mark as sent before requesting (to prevent double-sending)
1512        self.mouse_state.lsp_hover_request_sent = true;
1513
1514        // Store mouse position for popup positioning
1515        self.mouse_hover_screen_position = Some((screen_x, screen_y));
1516
1517        // Request hover at the byte position
1518        if let Err(e) = self.request_hover_at_position(byte_pos) {
1519            tracing::debug!("Failed to request hover: {}", e);
1520            return false;
1521        }
1522
1523        true
1524    }
1525
1526    /// Check if semantic highlight debounce timer has expired
1527    ///
1528    /// Returns true if a redraw is needed because the debounce period has elapsed
1529    /// and semantic highlights need to be recomputed.
1530    pub fn check_semantic_highlight_timer(&self) -> bool {
1531        // Check all buffers for pending semantic highlight redraws
1532        for state in self.buffers.values() {
1533            if let Some(remaining) = state.reference_highlight_overlay.needs_redraw() {
1534                if remaining.is_zero() {
1535                    return true;
1536                }
1537            }
1538        }
1539        false
1540    }
1541
1542    /// Load an ANSI background image from a user-provided path
1543    fn load_ansi_background(&mut self, input: &str) -> AnyhowResult<()> {
1544        let trimmed = input.trim();
1545
1546        if trimmed.is_empty() {
1547            self.ansi_background = None;
1548            self.ansi_background_path = None;
1549            self.set_status_message(t!("status.background_cleared").to_string());
1550            return Ok(());
1551        }
1552
1553        let input_path = Path::new(trimmed);
1554        let resolved = if input_path.is_absolute() {
1555            input_path.to_path_buf()
1556        } else {
1557            self.working_dir.join(input_path)
1558        };
1559
1560        let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
1561
1562        let parsed = crate::primitives::ansi_background::AnsiBackground::from_file(&canonical)?;
1563
1564        self.ansi_background = Some(parsed);
1565        self.ansi_background_path = Some(canonical.clone());
1566        self.set_status_message(
1567            t!(
1568                "view.background_set",
1569                path = canonical.display().to_string()
1570            )
1571            .to_string(),
1572        );
1573
1574        Ok(())
1575    }
1576
1577    /// Calculate the effective width available for tabs.
1578    ///
1579    /// When the file explorer is visible, tabs only get a portion of the terminal width
1580    /// based on `file_explorer_width_percent`. This matches the layout calculation in render.rs.
1581    fn effective_tabs_width(&self) -> u16 {
1582        if self.file_explorer_visible && self.file_explorer.is_some() {
1583            // When file explorer is visible, tabs get (1 - explorer_width) of the terminal width
1584            let editor_percent = 1.0 - self.file_explorer_width_percent;
1585            (self.terminal_width as f32 * editor_percent) as u16
1586        } else {
1587            self.terminal_width
1588        }
1589    }
1590
1591    /// Set the active buffer and trigger all necessary side effects
1592    ///
1593    /// This is the centralized method for switching buffers. It:
1594    /// - Updates split manager (single source of truth for active buffer)
1595    /// - Adds buffer to active split's tabs (if not already there)
1596    /// - Syncs file explorer to the new active file (if visible)
1597    ///
1598    /// Use this instead of directly calling split_manager.set_active_buffer_id()
1599    /// to ensure all side effects happen consistently.
1600    fn set_active_buffer(&mut self, buffer_id: BufferId) {
1601        if self.active_buffer() == buffer_id {
1602            return; // No change
1603        }
1604
1605        // Dismiss transient popups and clear hover state when switching buffers
1606        self.on_editor_focus_lost();
1607
1608        // Cancel search/replace prompts when switching buffers
1609        // (they are buffer-specific and don't make sense across buffers)
1610        self.cancel_search_prompt_if_active();
1611
1612        // Track the previous buffer for "Switch to Previous Tab" command
1613        let previous = self.active_buffer();
1614
1615        // If leaving a terminal buffer while in terminal mode, remember it should resume
1616        if self.terminal_mode && self.is_terminal_buffer(previous) {
1617            self.terminal_mode_resume.insert(previous);
1618            self.terminal_mode = false;
1619            self.key_context = crate::input::keybindings::KeyContext::Normal;
1620        }
1621
1622        // Update split manager (single source of truth)
1623        self.split_manager.set_active_buffer_id(buffer_id);
1624
1625        // If switching to a terminal buffer that should resume terminal mode, re-enter it
1626        if self.terminal_mode_resume.contains(&buffer_id) && self.is_terminal_buffer(buffer_id) {
1627            self.terminal_mode = true;
1628            self.key_context = crate::input::keybindings::KeyContext::Terminal;
1629        } else if self.is_terminal_buffer(buffer_id) {
1630            // Switching to terminal in read-only mode - sync buffer to show current terminal content
1631            // This ensures the backing file content and cursor position are up to date
1632            self.sync_terminal_to_buffer(buffer_id);
1633        }
1634
1635        // Add buffer to the active split's open_buffers (tabs) if not already there
1636        let active_split = self.split_manager.active_split();
1637        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1638            view_state.add_buffer(buffer_id);
1639            // Update the previous buffer tracker
1640            view_state.previous_buffer = Some(previous);
1641        }
1642
1643        // Ensure the newly active tab is visible
1644        // Use effective_tabs_width() to account for file explorer taking 30% of width
1645        self.ensure_active_tab_visible(active_split, buffer_id, self.effective_tabs_width());
1646
1647        // Note: We don't sync file explorer here to avoid flicker during tab switches.
1648        // File explorer syncs when explicitly focused via focus_file_explorer().
1649
1650        // Emit buffer_activated hook for plugins
1651        self.plugin_manager.run_hook(
1652            "buffer_activated",
1653            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
1654        );
1655    }
1656
1657    /// Focus a split and its buffer, handling all side effects including terminal mode.
1658    ///
1659    /// This is the primary method for switching focus between splits via mouse clicks.
1660    /// It handles:
1661    /// - Exiting terminal mode when leaving a terminal buffer
1662    /// - Updating split manager state
1663    /// - Managing tab state and previous buffer tracking
1664    /// - Syncing file explorer
1665    ///
1666    /// Use this instead of calling set_active_split directly when switching focus.
1667    pub(super) fn focus_split(
1668        &mut self,
1669        split_id: crate::model::event::SplitId,
1670        buffer_id: BufferId,
1671    ) {
1672        let previous_split = self.split_manager.active_split();
1673        let previous_buffer = self.active_buffer(); // Get BEFORE changing split
1674        let split_changed = previous_split != split_id;
1675
1676        if split_changed {
1677            // Switching to a different split - exit terminal mode if active
1678            if self.terminal_mode && self.is_terminal_buffer(previous_buffer) {
1679                self.terminal_mode = false;
1680                self.key_context = crate::input::keybindings::KeyContext::Normal;
1681            }
1682
1683            // Update split manager to focus this split
1684            self.split_manager.set_active_split(split_id);
1685
1686            // Update the buffer in the new split
1687            self.split_manager.set_active_buffer_id(buffer_id);
1688
1689            // Set key context based on target buffer type
1690            if self.is_terminal_buffer(buffer_id) {
1691                self.terminal_mode = true;
1692                self.key_context = crate::input::keybindings::KeyContext::Terminal;
1693            } else {
1694                // Ensure key context is Normal when focusing a non-terminal buffer
1695                // This handles the case of clicking on editor from FileExplorer context
1696                self.key_context = crate::input::keybindings::KeyContext::Normal;
1697            }
1698
1699            // Handle buffer change side effects
1700            if previous_buffer != buffer_id {
1701                self.position_history.commit_pending_movement();
1702                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1703                    view_state.add_buffer(buffer_id);
1704                    view_state.previous_buffer = Some(previous_buffer);
1705                }
1706                // Note: We don't sync file explorer here to avoid flicker during split focus changes.
1707                // File explorer syncs when explicitly focused via focus_file_explorer().
1708            }
1709        } else {
1710            // Same split, different buffer (tab switch) - use set_active_buffer for terminal resume
1711            self.set_active_buffer(buffer_id);
1712        }
1713    }
1714
1715    /// Get the currently active buffer state
1716    pub fn active_state(&self) -> &EditorState {
1717        self.buffers.get(&self.active_buffer()).unwrap()
1718    }
1719
1720    /// Get the currently active buffer state (mutable)
1721    pub fn active_state_mut(&mut self) -> &mut EditorState {
1722        self.buffers.get_mut(&self.active_buffer()).unwrap()
1723    }
1724
1725    /// Set completion items for type-to-filter (for testing)
1726    pub fn set_completion_items(&mut self, items: Vec<lsp_types::CompletionItem>) {
1727        self.completion_items = Some(items);
1728    }
1729
1730    /// Get the viewport for the active split
1731    pub fn active_viewport(&self) -> &crate::view::viewport::Viewport {
1732        let active_split = self.split_manager.active_split();
1733        &self.split_view_states.get(&active_split).unwrap().viewport
1734    }
1735
1736    /// Get the viewport for the active split (mutable)
1737    pub fn active_viewport_mut(&mut self) -> &mut crate::view::viewport::Viewport {
1738        let active_split = self.split_manager.active_split();
1739        &mut self
1740            .split_view_states
1741            .get_mut(&active_split)
1742            .unwrap()
1743            .viewport
1744    }
1745
1746    /// Get the display name for a buffer (filename or virtual buffer name)
1747    pub fn get_buffer_display_name(&self, buffer_id: BufferId) -> String {
1748        // Check composite buffers first
1749        if let Some(composite) = self.composite_buffers.get(&buffer_id) {
1750            return composite.name.clone();
1751        }
1752
1753        self.buffer_metadata
1754            .get(&buffer_id)
1755            .map(|m| m.display_name.clone())
1756            .or_else(|| {
1757                self.buffers.get(&buffer_id).and_then(|state| {
1758                    state
1759                        .buffer
1760                        .file_path()
1761                        .and_then(|p| p.file_name())
1762                        .and_then(|n| n.to_str())
1763                        .map(|s| s.to_string())
1764                })
1765            })
1766            .unwrap_or_else(|| "[No Name]".to_string())
1767    }
1768
1769    /// Apply an event to the active buffer with all cross-cutting concerns.
1770    /// This is the centralized method that automatically handles:
1771    /// - Event application to buffer
1772    /// - Plugin hooks (after-insert, after-delete, etc.)
1773    /// - LSP notifications
1774    /// - Any other cross-cutting concerns
1775    ///
1776    /// All event applications MUST go through this method to ensure consistency.
1777    pub fn apply_event_to_active_buffer(&mut self, event: &Event) {
1778        // Handle View events at Editor level - View events go to SplitViewState, not EditorState
1779        // This properly separates Buffer state from View state
1780        match event {
1781            Event::Scroll { line_offset } => {
1782                self.handle_scroll_event(*line_offset);
1783                return;
1784            }
1785            Event::SetViewport { top_line } => {
1786                self.handle_set_viewport_event(*top_line);
1787                return;
1788            }
1789            Event::Recenter => {
1790                self.handle_recenter_event();
1791                return;
1792            }
1793            _ => {}
1794        }
1795
1796        // IMPORTANT: Calculate LSP changes and line info BEFORE applying to buffer!
1797        // The byte positions in the events are relative to the ORIGINAL buffer,
1798        // so we must convert them to LSP positions before modifying the buffer.
1799        let lsp_changes = self.collect_lsp_changes(event);
1800
1801        // Calculate line info for plugin hooks (using same pre-modification buffer state)
1802        let line_info = self.calculate_event_line_info(event);
1803
1804        // 1. Apply the event to the buffer
1805        self.active_state_mut().apply(event);
1806
1807        // 1b. Sync cursors and viewport from EditorState to SplitViewState
1808        // This keeps the authoritative View state in SplitViewState up to date
1809        self.sync_editor_state_to_split_view_state();
1810
1811        // 1c. Invalidate layouts for all views of this buffer after content changes
1812        // Note: recovery_pending is set automatically by the buffer on edits
1813        match event {
1814            Event::Insert { .. } | Event::Delete { .. } | Event::BulkEdit { .. } => {
1815                self.invalidate_layouts_for_buffer(self.active_buffer());
1816                self.schedule_semantic_tokens_full_refresh(self.active_buffer());
1817            }
1818            Event::Batch { events, .. } => {
1819                let has_edits = events
1820                    .iter()
1821                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
1822                if has_edits {
1823                    self.invalidate_layouts_for_buffer(self.active_buffer());
1824                    self.schedule_semantic_tokens_full_refresh(self.active_buffer());
1825                }
1826            }
1827            _ => {}
1828        }
1829
1830        // 2. Adjust cursors in other splits that share the same buffer
1831        self.adjust_other_split_cursors_for_event(event);
1832
1833        // 3. Clear search highlights on edit (Insert/Delete events)
1834        // This preserves highlights while navigating but clears them when modifying text
1835        // EXCEPT during interactive replace where we want to keep highlights visible
1836        let in_interactive_replace = self.interactive_replace_state.is_some();
1837
1838        // Note: We intentionally do NOT clear search overlays on buffer modification.
1839        // Overlays have markers that automatically track position changes through edits,
1840        // which allows F3/Shift+F3 to find matches at their updated positions.
1841        // The visual highlights may be on text that no longer matches the query,
1842        // but that's acceptable - user can see where original matches were.
1843        let _ = in_interactive_replace; // silence unused warning
1844
1845        // 3. Trigger plugin hooks for this event (with pre-calculated line info)
1846        self.trigger_plugin_hooks_for_event(event, line_info);
1847
1848        // 4. Notify LSP of the change using pre-calculated positions
1849        self.send_lsp_changes_for_buffer(self.active_buffer(), lsp_changes);
1850    }
1851
1852    /// Apply multiple Insert/Delete events efficiently using bulk edit optimization.
1853    ///
1854    /// This avoids O(n²) complexity by:
1855    /// 1. Converting events to (position, delete_len, insert_text) tuples
1856    /// 2. Applying all edits in a single tree pass via apply_bulk_edits
1857    /// 3. Creating a BulkEdit event for undo (stores tree snapshot via Arc clone = O(1))
1858    ///
1859    /// # Arguments
1860    /// * `events` - Vec of Insert/Delete events (sorted by position descending for correct application)
1861    /// * `description` - Description for the undo log
1862    ///
1863    /// # Returns
1864    /// The BulkEdit event that was applied, for tracking purposes
1865    pub fn apply_events_as_bulk_edit(
1866        &mut self,
1867        events: Vec<Event>,
1868        description: String,
1869    ) -> Option<Event> {
1870        use crate::model::event::CursorId;
1871
1872        // Check if any events modify the buffer
1873        let has_buffer_mods = events
1874            .iter()
1875            .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
1876
1877        if !has_buffer_mods {
1878            // No buffer modifications - use regular Batch
1879            return None;
1880        }
1881
1882        let state = self.active_state_mut();
1883
1884        // Capture old cursor states
1885        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = state
1886            .cursors
1887            .iter()
1888            .map(|(id, c)| (id, c.position, c.anchor))
1889            .collect();
1890
1891        // Snapshot the tree for undo (O(1) - Arc clone)
1892        let old_tree = state.buffer.snapshot_piece_tree();
1893
1894        // Convert events to edit tuples: (position, delete_len, insert_text)
1895        // Events must be sorted by position descending (later positions first)
1896        // This ensures earlier edits don't shift positions of later edits
1897        let mut edits: Vec<(usize, usize, String)> = Vec::new();
1898
1899        for event in &events {
1900            match event {
1901                Event::Insert { position, text, .. } => {
1902                    edits.push((*position, 0, text.clone()));
1903                }
1904                Event::Delete { range, .. } => {
1905                    edits.push((range.start, range.len(), String::new()));
1906                }
1907                _ => {}
1908            }
1909        }
1910
1911        // Sort edits by position descending (required by apply_bulk_edits)
1912        edits.sort_by(|a, b| b.0.cmp(&a.0));
1913
1914        // Convert to references for apply_bulk_edits
1915        let edit_refs: Vec<(usize, usize, &str)> = edits
1916            .iter()
1917            .map(|(pos, del, text)| (*pos, *del, text.as_str()))
1918            .collect();
1919
1920        // Apply bulk edits
1921        let _delta = state.buffer.apply_bulk_edits(&edit_refs);
1922
1923        // Snapshot the tree after edits (for redo) - O(1) Arc clone
1924        let new_tree = state.buffer.snapshot_piece_tree();
1925
1926        // Calculate new cursor positions based on events
1927        // Process cursor movements from the original events
1928        let mut new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors.clone();
1929
1930        // Calculate position adjustments from edits (sorted ascending by position)
1931        // Each entry is (edit_position, delta) where delta = insert_len - delete_len
1932        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
1933        for (pos, del_len, text) in &edits {
1934            let delta = text.len() as isize - *del_len as isize;
1935            position_deltas.push((*pos, delta));
1936        }
1937        position_deltas.sort_by_key(|(pos, _)| *pos);
1938
1939        // Helper: calculate cumulative shift for a position based on edits at lower positions
1940        let calc_shift = |original_pos: usize| -> isize {
1941            let mut shift: isize = 0;
1942            for (edit_pos, delta) in &position_deltas {
1943                if *edit_pos < original_pos {
1944                    shift += delta;
1945                }
1946            }
1947            shift
1948        };
1949
1950        // Apply adjustments to cursor positions
1951        // First check for explicit MoveCursor events (e.g., from indent operations)
1952        // These take precedence over implicit cursor updates from Insert/Delete
1953        for (cursor_id, ref mut pos, ref mut anchor) in &mut new_cursors {
1954            let mut found_move_cursor = false;
1955            // Save original position before any modifications - needed for shift calculation
1956            let original_pos = *pos;
1957
1958            // Check if this cursor has an Insert at its original position (auto-close pattern).
1959            // For auto-close, Insert is at cursor position and MoveCursor is relative to original state.
1960            // For other operations (like indent), Insert is elsewhere and MoveCursor already accounts for shifts.
1961            let insert_at_cursor_pos = events.iter().any(|e| {
1962                matches!(e, Event::Insert { position, cursor_id: c, .. }
1963                    if *c == *cursor_id && *position == original_pos)
1964            });
1965
1966            // First pass: look for explicit MoveCursor events for this cursor
1967            for event in &events {
1968                if let Event::MoveCursor {
1969                    cursor_id: event_cursor,
1970                    new_position,
1971                    new_anchor,
1972                    ..
1973                } = event
1974                {
1975                    if event_cursor == cursor_id {
1976                        // Only adjust for shifts if the Insert was at the cursor's original position
1977                        // (like auto-close). For other operations (like indent where Insert is at
1978                        // line start), the MoveCursor already accounts for the shift.
1979                        let shift = if insert_at_cursor_pos {
1980                            calc_shift(original_pos)
1981                        } else {
1982                            0
1983                        };
1984                        *pos = (*new_position as isize + shift) as usize;
1985                        *anchor = *new_anchor;
1986                        found_move_cursor = true;
1987                    }
1988                }
1989            }
1990
1991            // If no explicit MoveCursor, derive position from Insert/Delete
1992            if !found_move_cursor {
1993                for event in &events {
1994                    match event {
1995                        Event::Insert {
1996                            position,
1997                            text,
1998                            cursor_id: event_cursor,
1999                        } if event_cursor == cursor_id => {
2000                            // For insert, cursor moves to end of inserted text
2001                            // Account for shifts from edits at lower positions
2002                            let shift = calc_shift(*position);
2003                            let adjusted_pos = (*position as isize + shift) as usize;
2004                            *pos = adjusted_pos + text.len();
2005                            *anchor = None;
2006                        }
2007                        Event::Delete {
2008                            range,
2009                            cursor_id: event_cursor,
2010                            ..
2011                        } if event_cursor == cursor_id => {
2012                            // For delete, cursor moves to start of deleted range
2013                            // Account for shifts from edits at lower positions
2014                            let shift = calc_shift(range.start);
2015                            *pos = (range.start as isize + shift) as usize;
2016                            *anchor = None;
2017                        }
2018                        _ => {}
2019                    }
2020                }
2021            }
2022        }
2023
2024        // Update cursors in state
2025        for (cursor_id, position, anchor) in &new_cursors {
2026            if let Some(cursor) = state.cursors.get_mut(*cursor_id) {
2027                cursor.position = *position;
2028                cursor.anchor = *anchor;
2029            }
2030        }
2031
2032        // Invalidate highlighter
2033        state.highlighter.invalidate_all();
2034
2035        // Create BulkEdit event with both tree snapshots
2036        let bulk_edit = Event::BulkEdit {
2037            old_tree: Some(old_tree),
2038            new_tree: Some(new_tree),
2039            old_cursors,
2040            new_cursors,
2041            description,
2042        };
2043
2044        // Post-processing (layout invalidation, split cursor sync, etc.)
2045        self.sync_editor_state_to_split_view_state();
2046        self.invalidate_layouts_for_buffer(self.active_buffer());
2047        self.adjust_other_split_cursors_for_event(&bulk_edit);
2048        // Note: Do NOT clear search overlays - markers track through edits for F3/Shift+F3
2049
2050        Some(bulk_edit)
2051    }
2052
2053    /// Trigger plugin hooks for an event (if any)
2054    /// line_info contains pre-calculated line numbers from BEFORE buffer modification
2055    fn trigger_plugin_hooks_for_event(&mut self, event: &Event, line_info: EventLineInfo) {
2056        let buffer_id = self.active_buffer();
2057
2058        // Convert event to hook args and fire the appropriate hook
2059        let hook_args = match event {
2060            Event::Insert { position, text, .. } => {
2061                let insert_position = *position;
2062                let insert_len = text.len();
2063
2064                // Adjust byte ranges for the insertion
2065                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
2066                    // Collect adjusted ranges:
2067                    // - Ranges ending before insert: keep unchanged
2068                    // - Ranges containing insert point: remove (content changed)
2069                    // - Ranges starting after insert: shift by insert_len
2070                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
2071                        .iter()
2072                        .filter_map(|&(start, end)| {
2073                            if end <= insert_position {
2074                                // Range ends before insert - unchanged
2075                                Some((start, end))
2076                            } else if start >= insert_position {
2077                                // Range starts at or after insert - shift forward
2078                                Some((start + insert_len, end + insert_len))
2079                            } else {
2080                                // Range contains insert point - invalidate
2081                                None
2082                            }
2083                        })
2084                        .collect();
2085                    *seen = adjusted;
2086                }
2087
2088                Some((
2089                    "after_insert",
2090                    crate::services::plugins::hooks::HookArgs::AfterInsert {
2091                        buffer_id,
2092                        position: *position,
2093                        text: text.clone(),
2094                        // Byte range of the affected area
2095                        affected_start: insert_position,
2096                        affected_end: insert_position + insert_len,
2097                        // Line info from pre-modification buffer
2098                        start_line: line_info.start_line,
2099                        end_line: line_info.end_line,
2100                        lines_added: line_info.line_delta.max(0) as usize,
2101                    },
2102                ))
2103            }
2104            Event::Delete {
2105                range,
2106                deleted_text,
2107                ..
2108            } => {
2109                let delete_start = range.start;
2110
2111                // Adjust byte ranges for the deletion
2112                let delete_end = range.end;
2113                let delete_len = delete_end - delete_start;
2114                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
2115                    // Collect adjusted ranges:
2116                    // - Ranges ending before delete start: keep unchanged
2117                    // - Ranges overlapping deletion: remove (content changed)
2118                    // - Ranges starting after delete end: shift backward by delete_len
2119                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
2120                        .iter()
2121                        .filter_map(|&(start, end)| {
2122                            if end <= delete_start {
2123                                // Range ends before delete - unchanged
2124                                Some((start, end))
2125                            } else if start >= delete_end {
2126                                // Range starts after delete - shift backward
2127                                Some((start - delete_len, end - delete_len))
2128                            } else {
2129                                // Range overlaps deletion - invalidate
2130                                None
2131                            }
2132                        })
2133                        .collect();
2134                    *seen = adjusted;
2135                }
2136
2137                Some((
2138                    "after_delete",
2139                    crate::services::plugins::hooks::HookArgs::AfterDelete {
2140                        buffer_id,
2141                        range: range.clone(),
2142                        deleted_text: deleted_text.clone(),
2143                        // Byte position and length of deleted content
2144                        affected_start: delete_start,
2145                        deleted_len: deleted_text.len(),
2146                        // Line info from pre-modification buffer
2147                        start_line: line_info.start_line,
2148                        end_line: line_info.end_line,
2149                        lines_removed: (-line_info.line_delta).max(0) as usize,
2150                    },
2151                ))
2152            }
2153            Event::Batch { events, .. } => {
2154                // Fire hooks for each event in the batch
2155                // Note: For batches, line info is approximate since buffer already modified
2156                // Individual events will use the passed line_info which covers the whole batch
2157                for e in events {
2158                    // Use default line info for sub-events - they share the batch's line_info
2159                    // This is a simplification; proper tracking would need per-event pre-calculation
2160                    let sub_line_info = self.calculate_event_line_info(e);
2161                    self.trigger_plugin_hooks_for_event(e, sub_line_info);
2162                }
2163                None
2164            }
2165            Event::MoveCursor {
2166                cursor_id,
2167                old_position,
2168                new_position,
2169                ..
2170            } => {
2171                // Get the line number for the new position (1-indexed for plugins)
2172                let line = self.active_state().buffer.get_line_number(*new_position) + 1;
2173                Some((
2174                    "cursor_moved",
2175                    crate::services::plugins::hooks::HookArgs::CursorMoved {
2176                        buffer_id,
2177                        cursor_id: *cursor_id,
2178                        old_position: *old_position,
2179                        new_position: *new_position,
2180                        line,
2181                    },
2182                ))
2183            }
2184            _ => None,
2185        };
2186
2187        // Fire the hook to TypeScript plugins
2188        if let Some((hook_name, args)) = hook_args {
2189            // Update the full plugin state snapshot BEFORE firing the hook
2190            // This ensures the plugin can read up-to-date state (diff, cursors, viewport, etc.)
2191            // Without this, there's a race condition where the async hook might read stale data
2192            #[cfg(feature = "plugins")]
2193            self.update_plugin_state_snapshot();
2194
2195            self.plugin_manager.run_hook(hook_name, args);
2196        }
2197    }
2198
2199    /// Handle scroll events using the SplitViewState's viewport
2200    ///
2201    /// View events (like Scroll) go to SplitViewState, not EditorState.
2202    /// This correctly handles scroll limits when view transforms inject headers.
2203    /// Also syncs to EditorState.viewport for the active split (used in rendering).
2204    fn handle_scroll_event(&mut self, line_offset: isize) {
2205        use crate::view::ui::view_pipeline::ViewLineIterator;
2206
2207        let active_split = self.split_manager.active_split();
2208
2209        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
2210        // Mark both splits to skip ensure_visible so cursor doesn't override scroll
2211        // The sync_scroll_groups() at render time will sync the other split
2212        if let Some(group) = self.scroll_sync_manager.find_group_for_split(active_split) {
2213            let left = group.left_split;
2214            let right = group.right_split;
2215            if let Some(vs) = self.split_view_states.get_mut(&left) {
2216                vs.viewport.set_skip_ensure_visible();
2217            }
2218            if let Some(vs) = self.split_view_states.get_mut(&right) {
2219                vs.viewport.set_skip_ensure_visible();
2220            }
2221            // Continue to scroll the active split normally below
2222        }
2223
2224        // Fall back to simple sync_group (same delta to all splits)
2225        let sync_group = self
2226            .split_view_states
2227            .get(&active_split)
2228            .and_then(|vs| vs.sync_group);
2229        let splits_to_scroll = if let Some(group_id) = sync_group {
2230            self.split_manager
2231                .get_splits_in_group(group_id, &self.split_view_states)
2232        } else {
2233            vec![active_split]
2234        };
2235
2236        for split_id in splits_to_scroll {
2237            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
2238                id
2239            } else {
2240                continue;
2241            };
2242            let tab_size = self.config.editor.tab_size;
2243
2244            // Get view_transform tokens from SplitViewState (if any)
2245            let view_transform_tokens = self
2246                .split_view_states
2247                .get(&split_id)
2248                .and_then(|vs| vs.view_transform.as_ref())
2249                .map(|vt| vt.tokens.clone());
2250
2251            // Get mutable references to both buffer and view state
2252            if let Some(state) = self.buffers.get_mut(&buffer_id) {
2253                let buffer = &mut state.buffer;
2254                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2255                    if let Some(tokens) = view_transform_tokens {
2256                        // Use view-aware scrolling with the transform's tokens
2257                        let view_lines: Vec<_> =
2258                            ViewLineIterator::new(&tokens, false, false, tab_size).collect();
2259                        view_state
2260                            .viewport
2261                            .scroll_view_lines(&view_lines, line_offset);
2262                    } else {
2263                        // No view transform - use traditional buffer-based scrolling
2264                        if line_offset > 0 {
2265                            view_state
2266                                .viewport
2267                                .scroll_down(buffer, line_offset as usize);
2268                        } else {
2269                            view_state
2270                                .viewport
2271                                .scroll_up(buffer, line_offset.unsigned_abs());
2272                        }
2273                    }
2274                    // Mark to skip ensure_visible on next render so the scroll isn't undone
2275                    view_state.viewport.set_skip_ensure_visible();
2276                }
2277            }
2278        }
2279    }
2280
2281    /// Handle SetViewport event using SplitViewState's viewport
2282    fn handle_set_viewport_event(&mut self, top_line: usize) {
2283        let active_split = self.split_manager.active_split();
2284
2285        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
2286        // If so, set the group's scroll_line and let render sync the viewports
2287        if self.scroll_sync_manager.is_split_synced(active_split) {
2288            if let Some(group) = self
2289                .scroll_sync_manager
2290                .find_group_for_split_mut(active_split)
2291            {
2292                // Convert line to left buffer space if coming from right split
2293                let scroll_line = if group.is_left_split(active_split) {
2294                    top_line
2295                } else {
2296                    group.right_to_left_line(top_line)
2297                };
2298                group.set_scroll_line(scroll_line);
2299            }
2300
2301            // Mark both splits to skip ensure_visible
2302            if let Some(group) = self.scroll_sync_manager.find_group_for_split(active_split) {
2303                let left = group.left_split;
2304                let right = group.right_split;
2305                if let Some(vs) = self.split_view_states.get_mut(&left) {
2306                    vs.viewport.set_skip_ensure_visible();
2307                }
2308                if let Some(vs) = self.split_view_states.get_mut(&right) {
2309                    vs.viewport.set_skip_ensure_visible();
2310                }
2311            }
2312            return;
2313        }
2314
2315        // Fall back to simple sync_group (same line to all splits)
2316        let sync_group = self
2317            .split_view_states
2318            .get(&active_split)
2319            .and_then(|vs| vs.sync_group);
2320        let splits_to_scroll = if let Some(group_id) = sync_group {
2321            self.split_manager
2322                .get_splits_in_group(group_id, &self.split_view_states)
2323        } else {
2324            vec![active_split]
2325        };
2326
2327        for split_id in splits_to_scroll {
2328            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
2329                id
2330            } else {
2331                continue;
2332            };
2333
2334            if let Some(state) = self.buffers.get_mut(&buffer_id) {
2335                let buffer = &mut state.buffer;
2336                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2337                    view_state.viewport.scroll_to(buffer, top_line);
2338                    // Mark to skip ensure_visible on next render so the scroll isn't undone
2339                    view_state.viewport.set_skip_ensure_visible();
2340                }
2341            }
2342        }
2343    }
2344
2345    /// Handle Recenter event using SplitViewState's viewport
2346    fn handle_recenter_event(&mut self) {
2347        let active_split = self.split_manager.active_split();
2348
2349        // Find other splits in the same sync group if any
2350        let sync_group = self
2351            .split_view_states
2352            .get(&active_split)
2353            .and_then(|vs| vs.sync_group);
2354        let splits_to_recenter = if let Some(group_id) = sync_group {
2355            self.split_manager
2356                .get_splits_in_group(group_id, &self.split_view_states)
2357        } else {
2358            vec![active_split]
2359        };
2360
2361        for split_id in splits_to_recenter {
2362            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
2363                id
2364            } else {
2365                continue;
2366            };
2367
2368            if let Some(state) = self.buffers.get_mut(&buffer_id) {
2369                let buffer = &mut state.buffer;
2370                let view_state = self.split_view_states.get_mut(&split_id);
2371
2372                if let Some(view_state) = view_state {
2373                    // Recenter viewport on cursor
2374                    let cursor = *view_state.cursors.primary();
2375                    let viewport_height = view_state.viewport.visible_line_count();
2376                    let target_rows_from_top = viewport_height / 2;
2377
2378                    // Move backwards from cursor position target_rows_from_top lines
2379                    let mut iter = buffer.line_iterator(cursor.position, 80);
2380                    for _ in 0..target_rows_from_top {
2381                        if iter.prev().is_none() {
2382                            break;
2383                        }
2384                    }
2385                    let new_top_byte = iter.current_position();
2386                    view_state.viewport.top_byte = new_top_byte;
2387                    // Mark to skip ensure_visible on next render so the scroll isn't undone
2388                    view_state.viewport.set_skip_ensure_visible();
2389                }
2390            }
2391        }
2392    }
2393
2394    /// Invalidate layouts for all splits viewing a specific buffer
2395    ///
2396    /// Called after buffer content changes (Insert/Delete) to mark
2397    /// layouts as dirty, forcing rebuild on next access.
2398    fn invalidate_layouts_for_buffer(&mut self, buffer_id: BufferId) {
2399        // Find all splits that display this buffer
2400        let splits_for_buffer = self.split_manager.splits_for_buffer(buffer_id);
2401
2402        // Invalidate layout for each split
2403        for split_id in splits_for_buffer {
2404            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2405                view_state.invalidate_layout();
2406            }
2407        }
2408    }
2409
2410    /// Get the event log for the active buffer
2411    pub fn active_event_log(&self) -> &EventLog {
2412        self.event_logs.get(&self.active_buffer()).unwrap()
2413    }
2414
2415    /// Get the event log for the active buffer (mutable)
2416    pub fn active_event_log_mut(&mut self) -> &mut EventLog {
2417        self.event_logs.get_mut(&self.active_buffer()).unwrap()
2418    }
2419
2420    /// Update the buffer's modified flag based on event log position
2421    /// Call this after undo/redo to correctly track whether the buffer
2422    /// has returned to its saved state
2423    pub(super) fn update_modified_from_event_log(&mut self) {
2424        let is_at_saved = self
2425            .event_logs
2426            .get(&self.active_buffer())
2427            .map(|log| log.is_at_saved_position())
2428            .unwrap_or(false);
2429
2430        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2431            state.buffer.set_modified(!is_at_saved);
2432        }
2433    }
2434
2435    /// Check if the editor should quit
2436    pub fn should_quit(&self) -> bool {
2437        self.should_quit
2438    }
2439
2440    /// Check if the editor should restart with a new working directory
2441    pub fn should_restart(&self) -> bool {
2442        self.restart_with_dir.is_some()
2443    }
2444
2445    /// Take the restart directory, clearing the restart request
2446    /// Returns the new working directory if a restart was requested
2447    pub fn take_restart_dir(&mut self) -> Option<PathBuf> {
2448        self.restart_with_dir.take()
2449    }
2450
2451    /// Request the editor to restart with a new working directory
2452    /// This triggers a clean shutdown and restart with the new project root
2453    /// Request a full hardware terminal clear and redraw on the next frame.
2454    /// Used after external commands have messed up the terminal state.
2455    pub fn request_full_redraw(&mut self) {
2456        self.full_redraw_requested = true;
2457    }
2458
2459    /// Check if a full redraw was requested, and clear the flag.
2460    pub fn take_full_redraw_request(&mut self) -> bool {
2461        let requested = self.full_redraw_requested;
2462        self.full_redraw_requested = false;
2463        requested
2464    }
2465
2466    pub fn request_restart(&mut self, new_working_dir: PathBuf) {
2467        tracing::info!(
2468            "Restart requested with new working directory: {}",
2469            new_working_dir.display()
2470        );
2471        self.restart_with_dir = Some(new_working_dir);
2472        // Also signal quit so the event loop exits
2473        self.should_quit = true;
2474    }
2475
2476    /// Get the active theme
2477    pub fn theme(&self) -> &crate::view::theme::Theme {
2478        &self.theme
2479    }
2480
2481    /// Check if the settings dialog is open and visible
2482    pub fn is_settings_open(&self) -> bool {
2483        self.settings_state.as_ref().is_some_and(|s| s.visible)
2484    }
2485
2486    /// Request the editor to quit
2487    pub fn quit(&mut self) {
2488        // Check for unsaved buffers
2489        let modified_count = self.count_modified_buffers();
2490        if modified_count > 0 {
2491            // Prompt user for confirmation with translated keys
2492            let discard_key = t!("prompt.key.discard").to_string();
2493            let cancel_key = t!("prompt.key.cancel").to_string();
2494            let msg = if modified_count == 1 {
2495                t!(
2496                    "prompt.quit_modified_one",
2497                    discard_key = discard_key,
2498                    cancel_key = cancel_key
2499                )
2500                .to_string()
2501            } else {
2502                t!(
2503                    "prompt.quit_modified_many",
2504                    count = modified_count,
2505                    discard_key = discard_key,
2506                    cancel_key = cancel_key
2507                )
2508                .to_string()
2509            };
2510            self.start_prompt(msg, PromptType::ConfirmQuitWithModified);
2511        } else {
2512            self.should_quit = true;
2513        }
2514    }
2515
2516    /// Count the number of modified buffers
2517    fn count_modified_buffers(&self) -> usize {
2518        self.buffers
2519            .values()
2520            .filter(|state| state.buffer.is_modified())
2521            .count()
2522    }
2523
2524    /// Resize all buffers to match new terminal size
2525    pub fn resize(&mut self, width: u16, height: u16) {
2526        // Update terminal dimensions for future buffer creation
2527        self.terminal_width = width;
2528        self.terminal_height = height;
2529
2530        // Resize all SplitViewState viewports (viewport is now owned by SplitViewState)
2531        for view_state in self.split_view_states.values_mut() {
2532            view_state.viewport.resize(width, height);
2533        }
2534
2535        // Resize visible terminal PTYs to match new dimensions
2536        self.resize_visible_terminals();
2537    }
2538
2539    // Prompt/Minibuffer control methods
2540
2541    /// Start a new prompt (enter minibuffer mode)
2542    pub fn start_prompt(&mut self, message: String, prompt_type: PromptType) {
2543        self.start_prompt_with_suggestions(message, prompt_type, Vec::new());
2544    }
2545
2546    /// Start a search prompt with an optional selection scope
2547    ///
2548    /// When `use_selection_range` is true and a single-line selection is present,
2549    /// the search will be restricted to that range once confirmed.
2550    fn start_search_prompt(
2551        &mut self,
2552        message: String,
2553        prompt_type: PromptType,
2554        use_selection_range: bool,
2555    ) {
2556        // Reset any previously stored selection range
2557        self.pending_search_range = None;
2558
2559        let selection_range = {
2560            let state = self.active_state();
2561            state.cursors.primary().selection_range()
2562        };
2563
2564        let selected_text = if let Some(range) = selection_range.clone() {
2565            let state = self.active_state_mut();
2566            let text = state.get_text_range(range.start, range.end);
2567            if !text.contains('\n') && !text.is_empty() {
2568                Some(text)
2569            } else {
2570                None
2571            }
2572        } else {
2573            None
2574        };
2575
2576        if use_selection_range {
2577            self.pending_search_range = selection_range;
2578        }
2579
2580        // Determine the default text: selection > last history > empty
2581        let from_history = selected_text.is_none();
2582        let default_text = selected_text.or_else(|| {
2583            self.get_prompt_history("search")
2584                .and_then(|h| h.last().map(|s| s.to_string()))
2585        });
2586
2587        // Start the prompt
2588        self.start_prompt(message, prompt_type);
2589
2590        // Pre-fill with default text if available
2591        if let Some(text) = default_text {
2592            if let Some(ref mut prompt) = self.prompt {
2593                prompt.set_input(text.clone());
2594                prompt.selection_anchor = Some(0);
2595                prompt.cursor_pos = text.len();
2596            }
2597            if from_history {
2598                self.get_or_create_prompt_history("search").init_at_last();
2599            }
2600            self.update_search_highlights(&text);
2601        }
2602    }
2603
2604    /// Start a new prompt with autocomplete suggestions
2605    pub fn start_prompt_with_suggestions(
2606        &mut self,
2607        message: String,
2608        prompt_type: PromptType,
2609        suggestions: Vec<Suggestion>,
2610    ) {
2611        // Dismiss transient popups and clear hover state when opening a prompt
2612        self.on_editor_focus_lost();
2613
2614        // Clear search highlights when starting a new search prompt
2615        // This ensures old highlights from previous searches don't persist
2616        match prompt_type {
2617            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
2618                self.clear_search_highlights();
2619            }
2620            _ => {}
2621        }
2622
2623        // Check if we need to update suggestions after creating the prompt
2624        let needs_suggestions = matches!(
2625            prompt_type,
2626            PromptType::OpenFile
2627                | PromptType::SwitchProject
2628                | PromptType::SaveFileAs
2629                | PromptType::Command
2630        );
2631
2632        self.prompt = Some(Prompt::with_suggestions(message, prompt_type, suggestions));
2633
2634        // For file and command prompts, populate initial suggestions
2635        if needs_suggestions {
2636            self.update_prompt_suggestions();
2637        }
2638    }
2639
2640    /// Start a new prompt with initial text
2641    pub fn start_prompt_with_initial_text(
2642        &mut self,
2643        message: String,
2644        prompt_type: PromptType,
2645        initial_text: String,
2646    ) {
2647        // Dismiss transient popups and clear hover state when opening a prompt
2648        self.on_editor_focus_lost();
2649
2650        self.prompt = Some(Prompt::with_initial_text(
2651            message,
2652            prompt_type,
2653            initial_text,
2654        ));
2655    }
2656
2657    /// Cancel search/replace prompts if one is active.
2658    /// Called when focus leaves the editor (e.g., switching buffers, focusing file explorer).
2659    fn cancel_search_prompt_if_active(&mut self) {
2660        if let Some(ref prompt) = self.prompt {
2661            if matches!(
2662                prompt.prompt_type,
2663                PromptType::Search
2664                    | PromptType::ReplaceSearch
2665                    | PromptType::Replace { .. }
2666                    | PromptType::QueryReplaceSearch
2667                    | PromptType::QueryReplace { .. }
2668                    | PromptType::QueryReplaceConfirm
2669            ) {
2670                self.prompt = None;
2671                // Also cancel interactive replace if active
2672                self.interactive_replace_state = None;
2673                // Clear search highlights from current buffer
2674                let ns = self.search_namespace.clone();
2675                let state = self.active_state_mut();
2676                state.overlays.clear_namespace(&ns, &mut state.marker_list);
2677            }
2678        }
2679    }
2680
2681    /// Pre-fill the Open File prompt input with the current buffer directory
2682    fn prefill_open_file_prompt(&mut self) {
2683        // With the native file browser, the directory is shown from file_open_state.current_dir
2684        // in the prompt rendering. The prompt.input is just the filter/filename, so we
2685        // start with an empty input.
2686        if let Some(prompt) = self.prompt.as_mut() {
2687            if prompt.prompt_type == PromptType::OpenFile {
2688                prompt.input.clear();
2689                prompt.cursor_pos = 0;
2690                prompt.selection_anchor = None;
2691            }
2692        }
2693    }
2694
2695    /// Initialize the file open dialog state
2696    ///
2697    /// Called when the Open File prompt is started. Determines the initial directory
2698    /// (from current buffer's directory or working directory) and triggers async
2699    /// directory loading.
2700    fn init_file_open_state(&mut self) {
2701        // Determine initial directory
2702        let buffer_id = self.active_buffer();
2703
2704        // For terminal buffers, use the terminal's initial CWD or fall back to project root
2705        // This avoids showing the terminal backing file directory which is confusing for users
2706        let initial_dir = if self.is_terminal_buffer(buffer_id) {
2707            self.get_terminal_id(buffer_id)
2708                .and_then(|tid| self.terminal_manager.get(tid))
2709                .and_then(|handle| handle.cwd())
2710                .unwrap_or_else(|| self.working_dir.clone())
2711        } else {
2712            self.active_state()
2713                .buffer
2714                .file_path()
2715                .and_then(|path| path.parent())
2716                .map(|p| p.to_path_buf())
2717                .unwrap_or_else(|| self.working_dir.clone())
2718        };
2719
2720        // Create the file open state with config-based show_hidden setting
2721        let show_hidden = self.config.file_browser.show_hidden;
2722        self.file_open_state = Some(file_open::FileOpenState::new(
2723            initial_dir.clone(),
2724            show_hidden,
2725        ));
2726
2727        // Start async directory loading
2728        self.load_file_open_directory(initial_dir);
2729    }
2730
2731    /// Initialize the folder open dialog state
2732    ///
2733    /// Called when the Switch Project prompt is started. Starts from the current working
2734    /// directory and triggers async directory loading.
2735    fn init_folder_open_state(&mut self) {
2736        // Start from the current working directory
2737        let initial_dir = self.working_dir.clone();
2738
2739        // Create the file open state with config-based show_hidden setting
2740        let show_hidden = self.config.file_browser.show_hidden;
2741        self.file_open_state = Some(file_open::FileOpenState::new(
2742            initial_dir.clone(),
2743            show_hidden,
2744        ));
2745
2746        // Start async directory loading
2747        self.load_file_open_directory(initial_dir);
2748    }
2749
2750    /// Change the working directory to a new path
2751    ///
2752    /// This requests a full editor restart with the new working directory.
2753    /// The main loop will drop the current editor instance and create a fresh
2754    /// one pointing to the new directory. This ensures:
2755    /// - All buffers are cleanly closed
2756    /// - LSP servers are properly shut down and restarted with new root
2757    /// - Plugins are cleanly restarted
2758    /// - No state leaks between projects
2759    pub fn change_working_dir(&mut self, new_path: PathBuf) {
2760        // Canonicalize the path to resolve symlinks and normalize
2761        let new_path = new_path.canonicalize().unwrap_or(new_path);
2762
2763        // Request a restart with the new working directory
2764        // The main loop will handle creating a fresh editor instance
2765        self.request_restart(new_path);
2766    }
2767
2768    /// Load directory contents for the file open dialog
2769    fn load_file_open_directory(&mut self, path: PathBuf) {
2770        // Update state to loading
2771        if let Some(state) = &mut self.file_open_state {
2772            state.current_dir = path.clone();
2773            state.loading = true;
2774            state.error = None;
2775            state.update_shortcuts();
2776        }
2777
2778        // Use tokio runtime to load directory
2779        if let Some(ref runtime) = self.tokio_runtime {
2780            let fs_manager = self.fs_manager.clone();
2781            let sender = self.async_bridge.as_ref().map(|b| b.sender());
2782
2783            runtime.spawn(async move {
2784                let result = fs_manager.list_dir_with_metadata(path).await;
2785                if let Some(sender) = sender {
2786                    let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
2787                }
2788            });
2789        } else {
2790            // No runtime, set error
2791            if let Some(state) = &mut self.file_open_state {
2792                state.set_error("Async runtime not available".to_string());
2793            }
2794        }
2795    }
2796
2797    /// Handle file open directory load result
2798    pub(super) fn handle_file_open_directory_loaded(
2799        &mut self,
2800        result: std::io::Result<Vec<crate::services::fs::FsEntry>>,
2801    ) {
2802        match result {
2803            Ok(entries) => {
2804                if let Some(state) = &mut self.file_open_state {
2805                    state.set_entries(entries);
2806                }
2807                // Re-apply filter from prompt (entries were just loaded, filter needs to select matching entry)
2808                let filter = self
2809                    .prompt
2810                    .as_ref()
2811                    .map(|p| p.input.clone())
2812                    .unwrap_or_default();
2813                if !filter.is_empty() {
2814                    if let Some(state) = &mut self.file_open_state {
2815                        state.apply_filter(&filter);
2816                    }
2817                }
2818            }
2819            Err(e) => {
2820                if let Some(state) = &mut self.file_open_state {
2821                    state.set_error(e.to_string());
2822                }
2823            }
2824        }
2825    }
2826
2827    /// Cancel the current prompt and return to normal mode
2828    pub fn cancel_prompt(&mut self) {
2829        // Extract theme to restore if this is a SelectTheme prompt
2830        let theme_to_restore = if let Some(ref prompt) = self.prompt {
2831            if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
2832                Some(original_theme.clone())
2833            } else {
2834                None
2835            }
2836        } else {
2837            None
2838        };
2839
2840        // Determine prompt type and reset appropriate history navigation
2841        if let Some(ref prompt) = self.prompt {
2842            // Reset history navigation for this prompt type
2843            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
2844                if let Some(history) = self.prompt_histories.get_mut(&key) {
2845                    history.reset_navigation();
2846                }
2847            }
2848            match &prompt.prompt_type {
2849                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
2850                    self.clear_search_highlights();
2851                }
2852                PromptType::Plugin { custom_type } => {
2853                    // Fire plugin hook for prompt cancellation
2854                    use crate::services::plugins::hooks::HookArgs;
2855                    self.plugin_manager.run_hook(
2856                        "prompt_cancelled",
2857                        HookArgs::PromptCancelled {
2858                            prompt_type: custom_type.clone(),
2859                            input: prompt.input.clone(),
2860                        },
2861                    );
2862                }
2863                PromptType::LspRename { overlay_handle, .. } => {
2864                    // Remove the rename overlay when cancelling
2865                    let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
2866                        handle: overlay_handle.clone(),
2867                    };
2868                    self.apply_event_to_active_buffer(&remove_overlay_event);
2869                }
2870                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
2871                    // Clear file browser state
2872                    self.file_open_state = None;
2873                    self.file_browser_layout = None;
2874                }
2875                PromptType::AsyncPrompt => {
2876                    // Resolve the pending async prompt callback with null (cancelled)
2877                    if let Some(callback_id) = self.pending_async_prompt_callback.take() {
2878                        self.plugin_manager
2879                            .resolve_callback(callback_id, "null".to_string());
2880                    }
2881                }
2882                _ => {}
2883            }
2884        }
2885
2886        self.prompt = None;
2887        self.pending_search_range = None;
2888        self.status_message = Some(t!("search.cancelled").to_string());
2889
2890        // Restore original theme if we were in SelectTheme prompt
2891        if let Some(original_theme) = theme_to_restore {
2892            self.preview_theme(&original_theme);
2893        }
2894    }
2895
2896    /// Get the confirmed input and prompt type, consuming the prompt
2897    /// For command palette, returns the selected suggestion if available, otherwise the raw input
2898    /// Returns (input, prompt_type, selected_index)
2899    /// Returns None if trying to confirm a disabled command
2900    pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
2901        if let Some(prompt) = self.prompt.take() {
2902            let selected_index = prompt.selected_suggestion;
2903            // For command, file, theme, plugin, and LSP stop prompts, prefer the selected suggestion over raw input
2904            let final_input = if matches!(
2905                prompt.prompt_type,
2906                PromptType::Command
2907                    | PromptType::OpenFile
2908                    | PromptType::SwitchProject
2909                    | PromptType::SaveFileAs
2910                    | PromptType::StopLspServer
2911                    | PromptType::SelectTheme { .. }
2912                    | PromptType::SelectLocale
2913                    | PromptType::SwitchToTab
2914                    | PromptType::Plugin { .. }
2915            ) {
2916                // Use the selected suggestion if any
2917                if let Some(selected_idx) = prompt.selected_suggestion {
2918                    if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
2919                        // Don't confirm disabled commands, but still record usage for history
2920                        if suggestion.disabled {
2921                            // Record usage even for disabled commands so they appear in history
2922                            if matches!(prompt.prompt_type, PromptType::Command) {
2923                                self.command_registry
2924                                    .write()
2925                                    .unwrap()
2926                                    .record_usage(&suggestion.text);
2927                            }
2928                            self.set_status_message(
2929                                t!(
2930                                    "error.command_not_available",
2931                                    command = suggestion.text.clone()
2932                                )
2933                                .to_string(),
2934                            );
2935                            return None;
2936                        }
2937                        // Use the selected suggestion value
2938                        suggestion.get_value().to_string()
2939                    } else {
2940                        prompt.input.clone()
2941                    }
2942                } else {
2943                    prompt.input.clone()
2944                }
2945            } else {
2946                prompt.input.clone()
2947            };
2948
2949            // For StopLspServer, validate that the input matches a running server
2950            if matches!(prompt.prompt_type, PromptType::StopLspServer) {
2951                let is_valid = prompt
2952                    .suggestions
2953                    .iter()
2954                    .any(|s| s.text == final_input || s.get_value() == final_input);
2955                if !is_valid {
2956                    // Restore the prompt and don't confirm
2957                    self.prompt = Some(prompt);
2958                    self.set_status_message(
2959                        t!("error.no_lsp_match", input = final_input.clone()).to_string(),
2960                    );
2961                    return None;
2962                }
2963            }
2964
2965            // Add to appropriate history based on prompt type
2966            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
2967                let history = self.get_or_create_prompt_history(&key);
2968                history.push(final_input.clone());
2969                history.reset_navigation();
2970            }
2971
2972            Some((final_input, prompt.prompt_type, selected_index))
2973        } else {
2974            None
2975        }
2976    }
2977
2978    /// Check if currently in prompt mode
2979    pub fn is_prompting(&self) -> bool {
2980        self.prompt.is_some()
2981    }
2982
2983    /// Get or create a prompt history for the given key
2984    fn get_or_create_prompt_history(
2985        &mut self,
2986        key: &str,
2987    ) -> &mut crate::input::input_history::InputHistory {
2988        self.prompt_histories.entry(key.to_string()).or_default()
2989    }
2990
2991    /// Get a prompt history for the given key (immutable)
2992    fn get_prompt_history(&self, key: &str) -> Option<&crate::input::input_history::InputHistory> {
2993        self.prompt_histories.get(key)
2994    }
2995
2996    /// Get the history key for a prompt type
2997    fn prompt_type_to_history_key(prompt_type: &crate::view::prompt::PromptType) -> Option<String> {
2998        use crate::view::prompt::PromptType;
2999        match prompt_type {
3000            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
3001                Some("search".to_string())
3002            }
3003            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
3004                Some("replace".to_string())
3005            }
3006            PromptType::GotoLine => Some("goto_line".to_string()),
3007            PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
3008            _ => None,
3009        }
3010    }
3011
3012    /// Get the current global editor mode (e.g., "vi-normal", "vi-insert")
3013    /// Returns None if no special mode is active
3014    pub fn editor_mode(&self) -> Option<String> {
3015        self.editor_mode.clone()
3016    }
3017
3018    /// Get access to the command registry
3019    pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
3020        &self.command_registry
3021    }
3022
3023    /// Get access to the plugin manager
3024    pub fn plugin_manager(&self) -> &PluginManager {
3025        &self.plugin_manager
3026    }
3027
3028    /// Get mutable access to the plugin manager
3029    pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
3030        &mut self.plugin_manager
3031    }
3032
3033    /// Check if file explorer has focus
3034    pub fn file_explorer_is_focused(&self) -> bool {
3035        self.key_context == KeyContext::FileExplorer
3036    }
3037
3038    /// Get current prompt input (for display)
3039    pub fn prompt_input(&self) -> Option<&str> {
3040        self.prompt.as_ref().map(|p| p.input.as_str())
3041    }
3042
3043    /// Check if the active cursor currently has a selection
3044    pub fn has_active_selection(&self) -> bool {
3045        self.active_state()
3046            .cursors
3047            .primary()
3048            .selection_range()
3049            .is_some()
3050    }
3051
3052    /// Get mutable reference to prompt (for input handling)
3053    pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
3054        self.prompt.as_mut()
3055    }
3056
3057    /// Set a status message to display in the status bar
3058    pub fn set_status_message(&mut self, message: String) {
3059        self.plugin_status_message = None;
3060        self.status_message = Some(message);
3061    }
3062
3063    /// Get the current status message
3064    pub fn get_status_message(&self) -> Option<&String> {
3065        self.plugin_status_message
3066            .as_ref()
3067            .or(self.status_message.as_ref())
3068    }
3069
3070    /// Get accumulated plugin errors (for test assertions)
3071    /// Returns all error messages that were detected in plugin status messages
3072    pub fn get_plugin_errors(&self) -> &[String] {
3073        &self.plugin_errors
3074    }
3075
3076    /// Clear accumulated plugin errors
3077    pub fn clear_plugin_errors(&mut self) {
3078        self.plugin_errors.clear();
3079    }
3080
3081    /// Update prompt suggestions based on current input
3082    pub fn update_prompt_suggestions(&mut self) {
3083        // Extract prompt type and input to avoid borrow checker issues
3084        let (prompt_type, input) = if let Some(prompt) = &self.prompt {
3085            (prompt.prompt_type.clone(), prompt.input.clone())
3086        } else {
3087            return;
3088        };
3089
3090        match prompt_type {
3091            PromptType::Command => {
3092                let selection_active = self.has_active_selection();
3093                let active_buffer_mode = self
3094                    .buffer_metadata
3095                    .get(&self.active_buffer())
3096                    .and_then(|m| m.virtual_mode());
3097                if let Some(prompt) = &mut self.prompt {
3098                    // Use the underlying context (not Prompt context) for filtering
3099                    prompt.suggestions = self.command_registry.read().unwrap().filter(
3100                        &input,
3101                        self.key_context,
3102                        &self.keybindings,
3103                        selection_active,
3104                        &self.active_custom_contexts,
3105                        active_buffer_mode,
3106                    );
3107                    prompt.selected_suggestion = if prompt.suggestions.is_empty() {
3108                        None
3109                    } else {
3110                        Some(0)
3111                    };
3112                }
3113            }
3114            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
3115                // Update incremental search highlights as user types
3116                self.update_search_highlights(&input);
3117                // Reset history navigation when user types - allows Up to navigate history
3118                if let Some(history) = self.prompt_histories.get_mut("search") {
3119                    history.reset_navigation();
3120                }
3121            }
3122            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
3123                // Reset history navigation when user types - allows Up to navigate history
3124                if let Some(history) = self.prompt_histories.get_mut("replace") {
3125                    history.reset_navigation();
3126                }
3127            }
3128            PromptType::GotoLine => {
3129                // Reset history navigation when user types - allows Up to navigate history
3130                if let Some(history) = self.prompt_histories.get_mut("goto_line") {
3131                    history.reset_navigation();
3132                }
3133            }
3134            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
3135                // For OpenFile/SwitchProject/SaveFileAs, update the file browser filter (native implementation)
3136                self.update_file_open_filter();
3137            }
3138            PromptType::Plugin { custom_type } => {
3139                // Reset history navigation when user types - allows Up to navigate history
3140                let key = format!("plugin:{}", custom_type);
3141                if let Some(history) = self.prompt_histories.get_mut(&key) {
3142                    history.reset_navigation();
3143                }
3144                // Fire plugin hook for prompt input change
3145                use crate::services::plugins::hooks::HookArgs;
3146                self.plugin_manager.run_hook(
3147                    "prompt_changed",
3148                    HookArgs::PromptChanged {
3149                        prompt_type: custom_type,
3150                        input,
3151                    },
3152                );
3153                // Apply fuzzy filtering if original_suggestions is set.
3154                // Note: filter_suggestions checks suggestions_set_for_input to skip
3155                // filtering if the plugin has already provided filtered results for
3156                // this input (handles the async race condition with run_hook).
3157                if let Some(prompt) = &mut self.prompt {
3158                    prompt.filter_suggestions(false);
3159                }
3160            }
3161            PromptType::SwitchToTab
3162            | PromptType::SelectTheme { .. }
3163            | PromptType::StopLspServer => {
3164                if let Some(prompt) = &mut self.prompt {
3165                    prompt.filter_suggestions(false);
3166                }
3167            }
3168            PromptType::SelectLocale => {
3169                // Locale selection also matches on description (language names)
3170                if let Some(prompt) = &mut self.prompt {
3171                    prompt.filter_suggestions(true);
3172                }
3173            }
3174            _ => {}
3175        }
3176    }
3177
3178    /// Process pending async messages from the async bridge
3179    ///
3180    /// This should be called each frame in the main loop to handle:
3181    /// - LSP diagnostics
3182    /// - LSP initialization/errors
3183    /// - File system changes (future)
3184    /// - Git status updates
3185    pub fn process_async_messages(&mut self) -> bool {
3186        // Check plugin thread health - will panic if thread died due to error
3187        // This ensures plugin errors surface quickly instead of causing silent hangs
3188        self.plugin_manager.check_thread_health();
3189
3190        let Some(bridge) = &self.async_bridge else {
3191            return false;
3192        };
3193
3194        let messages = bridge.try_recv_all();
3195        let needs_render = !messages.is_empty();
3196
3197        for message in messages {
3198            match message {
3199                AsyncMessage::LspDiagnostics { uri, diagnostics } => {
3200                    self.handle_lsp_diagnostics(uri, diagnostics);
3201                }
3202                AsyncMessage::LspInitialized {
3203                    language,
3204                    completion_trigger_characters,
3205                    semantic_tokens_legend,
3206                    semantic_tokens_full,
3207                    semantic_tokens_full_delta,
3208                    semantic_tokens_range,
3209                } => {
3210                    tracing::info!("LSP server initialized for language: {}", language);
3211                    tracing::debug!(
3212                        "LSP completion trigger characters for {}: {:?}",
3213                        language,
3214                        completion_trigger_characters
3215                    );
3216                    self.status_message = Some(format!("LSP ({}) ready", language));
3217
3218                    // Store completion trigger characters
3219                    if let Some(lsp) = &mut self.lsp {
3220                        lsp.set_completion_trigger_characters(
3221                            &language,
3222                            completion_trigger_characters,
3223                        );
3224                        lsp.set_semantic_tokens_capabilities(
3225                            &language,
3226                            semantic_tokens_legend,
3227                            semantic_tokens_full,
3228                            semantic_tokens_full_delta,
3229                            semantic_tokens_range,
3230                        );
3231                    }
3232
3233                    // Send didOpen for all open buffers of this language
3234                    self.resend_did_open_for_language(&language);
3235                    self.request_semantic_tokens_for_language(&language);
3236                }
3237                AsyncMessage::LspError {
3238                    language,
3239                    error,
3240                    stderr_log_path,
3241                } => {
3242                    tracing::error!("LSP error for {}: {}", language, error);
3243                    self.status_message = Some(format!("LSP error ({}): {}", language, error));
3244
3245                    // Get server command from config for the hook
3246                    let server_command = self
3247                        .config
3248                        .lsp
3249                        .get(&language)
3250                        .map(|c| c.command.clone())
3251                        .unwrap_or_else(|| "unknown".to_string());
3252
3253                    // Determine error type from error message
3254                    let error_type = if error.contains("not found") || error.contains("NotFound") {
3255                        "not_found"
3256                    } else if error.contains("permission") || error.contains("PermissionDenied") {
3257                        "spawn_failed"
3258                    } else if error.contains("timeout") {
3259                        "timeout"
3260                    } else {
3261                        "spawn_failed"
3262                    }
3263                    .to_string();
3264
3265                    // Fire the LspServerError hook for plugins
3266                    self.plugin_manager.run_hook(
3267                        "lsp_server_error",
3268                        crate::services::plugins::hooks::HookArgs::LspServerError {
3269                            language: language.clone(),
3270                            server_command,
3271                            error_type,
3272                            message: error.clone(),
3273                        },
3274                    );
3275
3276                    // Open stderr log as read-only buffer if it exists and has content
3277                    // Opens in background (new tab) without stealing focus
3278                    if let Some(log_path) = stderr_log_path {
3279                        let has_content = log_path.metadata().map(|m| m.len() > 0).unwrap_or(false);
3280                        if has_content {
3281                            tracing::info!("Opening LSP stderr log in background: {:?}", log_path);
3282                            match self.open_file_no_focus(&log_path) {
3283                                Ok(buffer_id) => {
3284                                    // Make the buffer read-only
3285                                    if let Some(state) = self.buffers.get_mut(&buffer_id) {
3286                                        state.editing_disabled = true;
3287                                    }
3288                                    if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id)
3289                                    {
3290                                        metadata.read_only = true;
3291                                    }
3292                                    self.status_message = Some(format!(
3293                                        "LSP error ({}): {} - See stderr log",
3294                                        language, error
3295                                    ));
3296                                }
3297                                Err(e) => {
3298                                    tracing::error!("Failed to open LSP stderr log: {}", e);
3299                                }
3300                            }
3301                        }
3302                    }
3303                }
3304                AsyncMessage::LspCompletion { request_id, items } => {
3305                    if let Err(e) = self.handle_completion_response(request_id, items) {
3306                        tracing::error!("Error handling completion response: {}", e);
3307                    }
3308                }
3309                AsyncMessage::LspGotoDefinition {
3310                    request_id,
3311                    locations,
3312                } => {
3313                    if let Err(e) = self.handle_goto_definition_response(request_id, locations) {
3314                        tracing::error!("Error handling goto definition response: {}", e);
3315                    }
3316                }
3317                AsyncMessage::LspRename { request_id, result } => {
3318                    if let Err(e) = self.handle_rename_response(request_id, result) {
3319                        tracing::error!("Error handling rename response: {}", e);
3320                    }
3321                }
3322                AsyncMessage::LspHover {
3323                    request_id,
3324                    contents,
3325                    is_markdown,
3326                    range,
3327                } => {
3328                    self.handle_hover_response(request_id, contents, is_markdown, range);
3329                }
3330                AsyncMessage::LspReferences {
3331                    request_id,
3332                    locations,
3333                } => {
3334                    if let Err(e) = self.handle_references_response(request_id, locations) {
3335                        tracing::error!("Error handling references response: {}", e);
3336                    }
3337                }
3338                AsyncMessage::LspSignatureHelp {
3339                    request_id,
3340                    signature_help,
3341                } => {
3342                    self.handle_signature_help_response(request_id, signature_help);
3343                }
3344                AsyncMessage::LspCodeActions {
3345                    request_id,
3346                    actions,
3347                } => {
3348                    self.handle_code_actions_response(request_id, actions);
3349                }
3350                AsyncMessage::LspPulledDiagnostics {
3351                    request_id: _,
3352                    uri,
3353                    result_id,
3354                    diagnostics,
3355                    unchanged,
3356                } => {
3357                    self.handle_lsp_pulled_diagnostics(uri, result_id, diagnostics, unchanged);
3358                }
3359                AsyncMessage::LspInlayHints {
3360                    request_id,
3361                    uri,
3362                    hints,
3363                } => {
3364                    self.handle_lsp_inlay_hints(request_id, uri, hints);
3365                }
3366                AsyncMessage::LspSemanticTokens {
3367                    request_id,
3368                    uri,
3369                    response,
3370                } => {
3371                    self.handle_lsp_semantic_tokens(request_id, uri, response);
3372                }
3373                AsyncMessage::LspServerQuiescent { language } => {
3374                    self.handle_lsp_server_quiescent(language);
3375                }
3376                AsyncMessage::FileChanged { path } => {
3377                    self.handle_async_file_changed(path);
3378                }
3379                AsyncMessage::GitStatusChanged { status } => {
3380                    tracing::info!("Git status changed: {}", status);
3381                    // TODO: Handle git status changes
3382                }
3383                AsyncMessage::FileExplorerInitialized(view) => {
3384                    self.handle_file_explorer_initialized(view);
3385                }
3386                AsyncMessage::FileExplorerToggleNode(node_id) => {
3387                    self.handle_file_explorer_toggle_node(node_id);
3388                }
3389                AsyncMessage::FileExplorerRefreshNode(node_id) => {
3390                    self.handle_file_explorer_refresh_node(node_id);
3391                }
3392                AsyncMessage::FileExplorerExpandedToPath(view) => {
3393                    self.handle_file_explorer_expanded_to_path(view);
3394                }
3395                AsyncMessage::Plugin(plugin_msg) => {
3396                    use fresh_core::api::{JsCallbackId, PluginAsyncMessage};
3397                    match plugin_msg {
3398                        PluginAsyncMessage::ProcessOutput {
3399                            process_id,
3400                            stdout,
3401                            stderr,
3402                            exit_code,
3403                        } => {
3404                            self.handle_plugin_process_output(
3405                                JsCallbackId::from(process_id),
3406                                stdout,
3407                                stderr,
3408                                exit_code,
3409                            );
3410                        }
3411                        PluginAsyncMessage::DelayComplete { callback_id } => {
3412                            self.plugin_manager.resolve_callback(
3413                                JsCallbackId::from(callback_id),
3414                                "null".to_string(),
3415                            );
3416                        }
3417                        PluginAsyncMessage::ProcessStdout { process_id, data } => {
3418                            self.plugin_manager.run_hook(
3419                                "onProcessStdout",
3420                                crate::services::plugins::hooks::HookArgs::ProcessOutput {
3421                                    process_id,
3422                                    data,
3423                                },
3424                            );
3425                        }
3426                        PluginAsyncMessage::ProcessStderr { process_id, data } => {
3427                            self.plugin_manager.run_hook(
3428                                "onProcessStderr",
3429                                crate::services::plugins::hooks::HookArgs::ProcessOutput {
3430                                    process_id,
3431                                    data,
3432                                },
3433                            );
3434                        }
3435                        PluginAsyncMessage::ProcessExit {
3436                            process_id,
3437                            callback_id,
3438                            exit_code,
3439                        } => {
3440                            self.background_process_handles.remove(&process_id);
3441                            let result = fresh_core::api::BackgroundProcessResult {
3442                                process_id,
3443                                exit_code,
3444                            };
3445                            self.plugin_manager.resolve_callback(
3446                                JsCallbackId::from(callback_id),
3447                                serde_json::to_string(&result).unwrap(),
3448                            );
3449                        }
3450                        PluginAsyncMessage::LspResponse {
3451                            language: _,
3452                            request_id,
3453                            result,
3454                        } => {
3455                            self.handle_plugin_lsp_response(request_id, result);
3456                        }
3457                        PluginAsyncMessage::PluginResponse(response) => {
3458                            self.handle_plugin_response(response);
3459                        }
3460                    }
3461                }
3462                AsyncMessage::LspProgress {
3463                    language,
3464                    token,
3465                    value,
3466                } => {
3467                    self.handle_lsp_progress(language, token, value);
3468                }
3469                AsyncMessage::LspWindowMessage {
3470                    language,
3471                    message_type,
3472                    message,
3473                } => {
3474                    self.handle_lsp_window_message(language, message_type, message);
3475                }
3476                AsyncMessage::LspLogMessage {
3477                    language,
3478                    message_type,
3479                    message,
3480                } => {
3481                    self.handle_lsp_log_message(language, message_type, message);
3482                }
3483                AsyncMessage::LspStatusUpdate {
3484                    language,
3485                    status,
3486                    message: _,
3487                } => {
3488                    self.handle_lsp_status_update(language, status);
3489                }
3490                AsyncMessage::FileOpenDirectoryLoaded(result) => {
3491                    self.handle_file_open_directory_loaded(result);
3492                }
3493                AsyncMessage::TerminalOutput { terminal_id } => {
3494                    // Terminal output received - check if we should auto-jump back to terminal mode
3495                    tracing::trace!("Terminal output received for {:?}", terminal_id);
3496
3497                    // If viewing scrollback for this terminal and jump_to_end_on_output is enabled,
3498                    // automatically re-enter terminal mode
3499                    if self.config.terminal.jump_to_end_on_output && !self.terminal_mode {
3500                        // Check if active buffer is this terminal
3501                        if let Some(&active_terminal_id) =
3502                            self.terminal_buffers.get(&self.active_buffer())
3503                        {
3504                            if active_terminal_id == terminal_id {
3505                                self.enter_terminal_mode();
3506                            }
3507                        }
3508                    }
3509
3510                    // When in terminal mode, ensure display stays at bottom (follows new output)
3511                    if self.terminal_mode {
3512                        if let Some(handle) = self.terminal_manager.get(terminal_id) {
3513                            if let Ok(mut state) = handle.state.lock() {
3514                                state.scroll_to_bottom();
3515                            }
3516                        }
3517                    }
3518                }
3519                AsyncMessage::TerminalExited { terminal_id } => {
3520                    tracing::info!("Terminal {:?} exited", terminal_id);
3521                    // Find the buffer associated with this terminal
3522                    if let Some((&buffer_id, _)) = self
3523                        .terminal_buffers
3524                        .iter()
3525                        .find(|(_, &tid)| tid == terminal_id)
3526                    {
3527                        // Exit terminal mode if this is the active buffer
3528                        if self.active_buffer() == buffer_id && self.terminal_mode {
3529                            self.terminal_mode = false;
3530                            self.key_context = crate::input::keybindings::KeyContext::Normal;
3531                        }
3532
3533                        // Sync terminal content to buffer (final screen state)
3534                        self.sync_terminal_to_buffer(buffer_id);
3535
3536                        // Append exit message to the backing file and reload
3537                        let exit_msg = "\n[Terminal process exited]\n";
3538
3539                        if let Some(backing_path) =
3540                            self.terminal_backing_files.get(&terminal_id).cloned()
3541                        {
3542                            if let Ok(mut file) =
3543                                std::fs::OpenOptions::new().append(true).open(&backing_path)
3544                            {
3545                                use std::io::Write;
3546                                let _ = file.write_all(exit_msg.as_bytes());
3547                            }
3548
3549                            // Force reload buffer from file to pick up the exit message
3550                            let _ = self.revert_buffer_by_id(buffer_id, &backing_path);
3551                        }
3552
3553                        // Ensure buffer remains read-only with no line numbers
3554                        if let Some(state) = self.buffers.get_mut(&buffer_id) {
3555                            state.editing_disabled = true;
3556                            state.margins.set_line_numbers(false);
3557                            state.buffer.set_modified(false);
3558                        }
3559
3560                        // Remove from terminal_buffers so it's no longer treated as a terminal
3561                        self.terminal_buffers.remove(&buffer_id);
3562
3563                        self.set_status_message(
3564                            t!("terminal.exited", id = terminal_id.0).to_string(),
3565                        );
3566                    }
3567                    self.terminal_manager.close(terminal_id);
3568                }
3569
3570                AsyncMessage::LspServerRequest {
3571                    language,
3572                    server_command,
3573                    method,
3574                    params,
3575                } => {
3576                    self.handle_lsp_server_request(language, server_command, method, params);
3577                }
3578                AsyncMessage::PluginLspResponse {
3579                    language: _,
3580                    request_id,
3581                    result,
3582                } => {
3583                    self.handle_plugin_lsp_response(request_id, result);
3584                }
3585                AsyncMessage::PluginProcessOutput {
3586                    process_id,
3587                    stdout,
3588                    stderr,
3589                    exit_code,
3590                } => {
3591                    self.handle_plugin_process_output(
3592                        fresh_core::api::JsCallbackId::from(process_id),
3593                        stdout,
3594                        stderr,
3595                        exit_code,
3596                    );
3597                }
3598            }
3599        }
3600
3601        // Update plugin state snapshot BEFORE processing commands
3602        // This ensures plugins have access to current editor state (cursor positions, etc.)
3603        #[cfg(feature = "plugins")]
3604        self.update_plugin_state_snapshot();
3605
3606        // Process TypeScript plugin commands
3607        let processed_any_commands = self.process_plugin_commands();
3608
3609        // Process pending plugin action completions
3610        #[cfg(feature = "plugins")]
3611        self.process_pending_plugin_actions();
3612
3613        // Process pending LSP server restarts (with exponential backoff)
3614        self.process_pending_lsp_restarts();
3615
3616        // Check and clear the plugin render request flag
3617        #[cfg(feature = "plugins")]
3618        let plugin_render = {
3619            let render = self.plugin_render_requested;
3620            self.plugin_render_requested = false;
3621            render
3622        };
3623        #[cfg(not(feature = "plugins"))]
3624        let plugin_render = false;
3625
3626        // Poll periodic update checker for new results
3627        if let Some(ref mut checker) = self.update_checker {
3628            // Poll for results but don't act on them - just cache
3629            let _ = checker.poll_result();
3630        }
3631
3632        // Poll for file changes (auto-revert) and file tree changes
3633        let file_changes = self.poll_file_changes();
3634        let tree_changes = self.poll_file_tree_changes();
3635
3636        // Trigger render if any async messages, plugin commands were processed, or plugin requested render
3637        needs_render || processed_any_commands || plugin_render || file_changes || tree_changes
3638    }
3639
3640    /// Update LSP status bar string from active progress operations
3641    fn update_lsp_status_from_progress(&mut self) {
3642        if self.lsp_progress.is_empty() {
3643            // No active progress, update from server statuses
3644            self.update_lsp_status_from_server_statuses();
3645            return;
3646        }
3647
3648        // Show the first active progress operation
3649        if let Some((_, info)) = self.lsp_progress.iter().next() {
3650            let mut status = format!("LSP ({}): {}", info.language, info.title);
3651            if let Some(ref msg) = info.message {
3652                status.push_str(&format!(" - {}", msg));
3653            }
3654            if let Some(pct) = info.percentage {
3655                status.push_str(&format!(" ({}%)", pct));
3656            }
3657            self.lsp_status = status;
3658        }
3659    }
3660
3661    /// Update LSP status bar string from server statuses
3662    fn update_lsp_status_from_server_statuses(&mut self) {
3663        use crate::services::async_bridge::LspServerStatus;
3664
3665        // Collect all server statuses
3666        let mut statuses: Vec<(String, LspServerStatus)> = self
3667            .lsp_server_statuses
3668            .iter()
3669            .map(|(lang, status)| (lang.clone(), *status))
3670            .collect();
3671
3672        if statuses.is_empty() {
3673            self.lsp_status = String::new();
3674            return;
3675        }
3676
3677        // Sort by language name for consistent display
3678        statuses.sort_by(|a, b| a.0.cmp(&b.0));
3679
3680        // Build status string
3681        let status_parts: Vec<String> = statuses
3682            .iter()
3683            .map(|(lang, status)| {
3684                let status_str = match status {
3685                    LspServerStatus::Starting => "starting",
3686                    LspServerStatus::Initializing => "initializing",
3687                    LspServerStatus::Running => "ready",
3688                    LspServerStatus::Error => "error",
3689                    LspServerStatus::Shutdown => "shutdown",
3690                };
3691                format!("{}: {}", lang, status_str)
3692            })
3693            .collect();
3694
3695        self.lsp_status = format!("LSP [{}]", status_parts.join(", "));
3696    }
3697
3698    /// Update the plugin state snapshot with current editor state
3699    #[cfg(feature = "plugins")]
3700    fn update_plugin_state_snapshot(&mut self) {
3701        // Update TypeScript plugin manager state
3702        if let Some(snapshot_handle) = self.plugin_manager.state_snapshot_handle() {
3703            use fresh_core::api::{BufferInfo, CursorInfo, ViewportInfo};
3704            let mut snapshot = snapshot_handle.write().unwrap();
3705
3706            // Update active buffer ID
3707            snapshot.active_buffer_id = self.active_buffer();
3708
3709            // Update active split ID
3710            snapshot.active_split_id = self.split_manager.active_split().0;
3711
3712            // Clear and update buffer info
3713            snapshot.buffers.clear();
3714            snapshot.buffer_saved_diffs.clear();
3715            snapshot.buffer_cursor_positions.clear();
3716            snapshot.buffer_text_properties.clear();
3717
3718            for (buffer_id, state) in &self.buffers {
3719                let buffer_info = BufferInfo {
3720                    id: *buffer_id,
3721                    path: state.buffer.file_path().map(|p| p.to_path_buf()),
3722                    modified: state.buffer.is_modified(),
3723                    length: state.buffer.len(),
3724                };
3725                snapshot.buffers.insert(*buffer_id, buffer_info);
3726
3727                // Skip diffing in large file mode - too expensive
3728                // TODO: Enable when we have an efficient streaming diff algorithm
3729                let is_large_file = state.buffer.line_count().is_none();
3730                let diff = if is_large_file {
3731                    BufferSavedDiff {
3732                        equal: !state.buffer.is_modified(),
3733                        byte_ranges: vec![],
3734                        line_ranges: None,
3735                    }
3736                } else {
3737                    let diff = state.buffer.diff_since_saved();
3738                    BufferSavedDiff {
3739                        equal: diff.equal,
3740                        byte_ranges: diff.byte_ranges.clone(),
3741                        line_ranges: diff.line_ranges.clone(),
3742                    }
3743                };
3744                snapshot.buffer_saved_diffs.insert(*buffer_id, diff);
3745
3746                // Store cursor position for this buffer
3747                let cursor_pos = state.cursors.primary().position;
3748                snapshot
3749                    .buffer_cursor_positions
3750                    .insert(*buffer_id, cursor_pos);
3751
3752                // Store text properties if this buffer has any
3753                if !state.text_properties.is_empty() {
3754                    snapshot
3755                        .buffer_text_properties
3756                        .insert(*buffer_id, state.text_properties.all().to_vec());
3757                }
3758            }
3759
3760            // Update cursor information for active buffer
3761            if let Some(active_state) = self.buffers.get_mut(&self.active_buffer()) {
3762                // Primary cursor
3763                let primary = active_state.cursors.primary();
3764                let primary_position = primary.position;
3765                let primary_selection = primary.selection_range();
3766
3767                snapshot.primary_cursor = Some(CursorInfo {
3768                    position: primary_position,
3769                    selection: primary_selection.clone(),
3770                });
3771
3772                // Selected text from primary cursor (for clipboard plugin)
3773                snapshot.selected_text = primary_selection
3774                    .map(|range| active_state.get_text_range(range.start, range.end));
3775
3776                // All cursors
3777                snapshot.all_cursors = active_state
3778                    .cursors
3779                    .iter()
3780                    .map(|(_, cursor)| CursorInfo {
3781                        position: cursor.position,
3782                        selection: cursor.selection_range(),
3783                    })
3784                    .collect();
3785
3786                // Viewport - get from SplitViewState (the authoritative source)
3787                let active_split = self.split_manager.active_split();
3788                if let Some(view_state) = self.split_view_states.get(&active_split) {
3789                    snapshot.viewport = Some(ViewportInfo {
3790                        top_byte: view_state.viewport.top_byte,
3791                        left_column: view_state.viewport.left_column,
3792                        width: view_state.viewport.width,
3793                        height: view_state.viewport.height,
3794                    });
3795                } else {
3796                    snapshot.viewport = None;
3797                }
3798            } else {
3799                snapshot.primary_cursor = None;
3800                snapshot.all_cursors.clear();
3801                snapshot.viewport = None;
3802                snapshot.selected_text = None;
3803            }
3804
3805            // Update clipboard (provide internal clipboard content to plugins)
3806            snapshot.clipboard = self.clipboard.get_internal().to_string();
3807
3808            // Update working directory (for spawning processes in correct directory)
3809            snapshot.working_dir = self.working_dir.clone();
3810
3811            // Update LSP diagnostics
3812            snapshot.diagnostics = self.stored_diagnostics.clone();
3813
3814            // Update config (serialize the runtime config for plugins)
3815            snapshot.config = serde_json::to_value(&self.config).unwrap_or(serde_json::Value::Null);
3816
3817            // Update user config (raw file contents, not merged with defaults)
3818            // This allows plugins to distinguish between user-set and default values
3819            snapshot.user_config = Config::read_user_config_raw(&self.working_dir);
3820
3821            // Update editor mode (for vi mode and other modal editing)
3822            snapshot.editor_mode = self.editor_mode.clone();
3823        }
3824    }
3825
3826    /// Handle a plugin command - dispatches to specialized handlers in plugin_commands module
3827    pub fn handle_plugin_command(&mut self, command: PluginCommand) -> AnyhowResult<()> {
3828        match command {
3829            // ==================== Text Editing Commands ====================
3830            PluginCommand::InsertText {
3831                buffer_id,
3832                position,
3833                text,
3834            } => {
3835                self.handle_insert_text(buffer_id, position, text);
3836            }
3837            PluginCommand::DeleteRange { buffer_id, range } => {
3838                self.handle_delete_range(buffer_id, range);
3839            }
3840            PluginCommand::InsertAtCursor { text } => {
3841                self.handle_insert_at_cursor(text);
3842            }
3843            PluginCommand::DeleteSelection => {
3844                self.handle_delete_selection();
3845            }
3846
3847            // ==================== Overlay Commands ====================
3848            PluginCommand::AddOverlay {
3849                buffer_id,
3850                namespace,
3851                range,
3852                color,
3853                bg_color,
3854                underline,
3855                bold,
3856                italic,
3857                extend_to_line_end,
3858            } => {
3859                self.handle_add_overlay(
3860                    buffer_id,
3861                    namespace,
3862                    range,
3863                    color,
3864                    bg_color,
3865                    underline,
3866                    bold,
3867                    italic,
3868                    extend_to_line_end,
3869                );
3870            }
3871            PluginCommand::RemoveOverlay { buffer_id, handle } => {
3872                self.handle_remove_overlay(buffer_id, handle);
3873            }
3874            PluginCommand::ClearAllOverlays { buffer_id } => {
3875                self.handle_clear_all_overlays(buffer_id);
3876            }
3877            PluginCommand::ClearNamespace {
3878                buffer_id,
3879                namespace,
3880            } => {
3881                self.handle_clear_namespace(buffer_id, namespace);
3882            }
3883            PluginCommand::ClearOverlaysInRange {
3884                buffer_id,
3885                start,
3886                end,
3887            } => {
3888                self.handle_clear_overlays_in_range(buffer_id, start, end);
3889            }
3890
3891            // ==================== Virtual Text Commands ====================
3892            PluginCommand::AddVirtualText {
3893                buffer_id,
3894                virtual_text_id,
3895                position,
3896                text,
3897                color,
3898                use_bg,
3899                before,
3900            } => {
3901                self.handle_add_virtual_text(
3902                    buffer_id,
3903                    virtual_text_id,
3904                    position,
3905                    text,
3906                    color,
3907                    use_bg,
3908                    before,
3909                );
3910            }
3911            PluginCommand::RemoveVirtualText {
3912                buffer_id,
3913                virtual_text_id,
3914            } => {
3915                self.handle_remove_virtual_text(buffer_id, virtual_text_id);
3916            }
3917            PluginCommand::RemoveVirtualTextsByPrefix { buffer_id, prefix } => {
3918                self.handle_remove_virtual_texts_by_prefix(buffer_id, prefix);
3919            }
3920            PluginCommand::ClearVirtualTexts { buffer_id } => {
3921                self.handle_clear_virtual_texts(buffer_id);
3922            }
3923            PluginCommand::AddVirtualLine {
3924                buffer_id,
3925                position,
3926                text,
3927                fg_color,
3928                bg_color,
3929                above,
3930                namespace,
3931                priority,
3932            } => {
3933                self.handle_add_virtual_line(
3934                    buffer_id, position, text, fg_color, bg_color, above, namespace, priority,
3935                );
3936            }
3937            PluginCommand::ClearVirtualTextNamespace {
3938                buffer_id,
3939                namespace,
3940            } => {
3941                self.handle_clear_virtual_text_namespace(buffer_id, namespace);
3942            }
3943
3944            // ==================== Menu Commands ====================
3945            PluginCommand::AddMenuItem {
3946                menu_label,
3947                item,
3948                position,
3949            } => {
3950                self.handle_add_menu_item(menu_label, item, position);
3951            }
3952            PluginCommand::AddMenu { menu, position } => {
3953                self.handle_add_menu(menu, position);
3954            }
3955            PluginCommand::RemoveMenuItem {
3956                menu_label,
3957                item_label,
3958            } => {
3959                self.handle_remove_menu_item(menu_label, item_label);
3960            }
3961            PluginCommand::RemoveMenu { menu_label } => {
3962                self.handle_remove_menu(menu_label);
3963            }
3964
3965            // ==================== Split Commands ====================
3966            PluginCommand::FocusSplit { split_id } => {
3967                self.handle_focus_split(split_id);
3968            }
3969            PluginCommand::SetSplitBuffer {
3970                split_id,
3971                buffer_id,
3972            } => {
3973                self.handle_set_split_buffer(split_id, buffer_id);
3974            }
3975            PluginCommand::SetSplitScroll { split_id, top_byte } => {
3976                self.handle_set_split_scroll(split_id, top_byte);
3977            }
3978            PluginCommand::RequestHighlights {
3979                buffer_id,
3980                range,
3981                request_id,
3982            } => {
3983                self.handle_request_highlights(buffer_id, range, request_id);
3984            }
3985            PluginCommand::CloseSplit { split_id } => {
3986                self.handle_close_split(split_id);
3987            }
3988            PluginCommand::SetSplitRatio { split_id, ratio } => {
3989                self.handle_set_split_ratio(split_id, ratio);
3990            }
3991            PluginCommand::DistributeSplitsEvenly { split_ids: _ } => {
3992                self.handle_distribute_splits_evenly();
3993            }
3994            PluginCommand::SetBufferCursor {
3995                buffer_id,
3996                position,
3997            } => {
3998                self.handle_set_buffer_cursor(buffer_id, position);
3999            }
4000
4001            // ==================== View/Layout Commands ====================
4002            PluginCommand::SetLayoutHints {
4003                buffer_id,
4004                split_id,
4005                range: _,
4006                hints,
4007            } => {
4008                self.handle_set_layout_hints(buffer_id, split_id, hints);
4009            }
4010            PluginCommand::SetLineNumbers { buffer_id, enabled } => {
4011                self.handle_set_line_numbers(buffer_id, enabled);
4012            }
4013            PluginCommand::SubmitViewTransform {
4014                buffer_id,
4015                split_id,
4016                payload,
4017            } => {
4018                self.handle_submit_view_transform(buffer_id, split_id, payload);
4019            }
4020            PluginCommand::ClearViewTransform {
4021                buffer_id: _,
4022                split_id,
4023            } => {
4024                self.handle_clear_view_transform(split_id);
4025            }
4026            PluginCommand::RefreshLines { buffer_id } => {
4027                self.handle_refresh_lines(buffer_id);
4028            }
4029            PluginCommand::SetLineIndicator {
4030                buffer_id,
4031                line,
4032                namespace,
4033                symbol,
4034                color,
4035                priority,
4036            } => {
4037                self.handle_set_line_indicator(buffer_id, line, namespace, symbol, color, priority);
4038            }
4039            PluginCommand::ClearLineIndicators {
4040                buffer_id,
4041                namespace,
4042            } => {
4043                self.handle_clear_line_indicators(buffer_id, namespace);
4044            }
4045            PluginCommand::SetFileExplorerDecorations {
4046                namespace,
4047                decorations,
4048            } => {
4049                self.handle_set_file_explorer_decorations(namespace, decorations);
4050            }
4051            PluginCommand::ClearFileExplorerDecorations { namespace } => {
4052                self.handle_clear_file_explorer_decorations(&namespace);
4053            }
4054
4055            // ==================== Status/Prompt Commands ====================
4056            PluginCommand::SetStatus { message } => {
4057                self.handle_set_status(message);
4058            }
4059            PluginCommand::ApplyTheme { theme_name } => {
4060                self.apply_theme(&theme_name);
4061            }
4062            PluginCommand::ReloadConfig => {
4063                self.reload_config();
4064            }
4065            PluginCommand::StartPrompt { label, prompt_type } => {
4066                self.handle_start_prompt(label, prompt_type);
4067            }
4068            PluginCommand::StartPromptWithInitial {
4069                label,
4070                prompt_type,
4071                initial_value,
4072            } => {
4073                self.handle_start_prompt_with_initial(label, prompt_type, initial_value);
4074            }
4075            PluginCommand::StartPromptAsync {
4076                label,
4077                initial_value,
4078                callback_id,
4079            } => {
4080                self.handle_start_prompt_async(label, initial_value, callback_id);
4081            }
4082            PluginCommand::SetPromptSuggestions { suggestions } => {
4083                self.handle_set_prompt_suggestions(suggestions);
4084            }
4085
4086            // ==================== Command/Mode Registration ====================
4087            PluginCommand::RegisterCommand { command } => {
4088                self.handle_register_command(command);
4089            }
4090            PluginCommand::UnregisterCommand { name } => {
4091                self.handle_unregister_command(name);
4092            }
4093            PluginCommand::DefineMode {
4094                name,
4095                parent,
4096                bindings,
4097                read_only,
4098            } => {
4099                self.handle_define_mode(name, parent, bindings, read_only);
4100            }
4101
4102            // ==================== File/Navigation Commands ====================
4103            PluginCommand::OpenFileInBackground { path } => {
4104                self.handle_open_file_in_background(path);
4105            }
4106            PluginCommand::OpenFileAtLocation { path, line, column } => {
4107                return self.handle_open_file_at_location(path, line, column);
4108            }
4109            PluginCommand::OpenFileInSplit {
4110                split_id,
4111                path,
4112                line,
4113                column,
4114            } => {
4115                return self.handle_open_file_in_split(split_id, path, line, column);
4116            }
4117            PluginCommand::ShowBuffer { buffer_id } => {
4118                self.handle_show_buffer(buffer_id);
4119            }
4120            PluginCommand::CloseBuffer { buffer_id } => {
4121                self.handle_close_buffer(buffer_id);
4122            }
4123
4124            // ==================== LSP Commands ====================
4125            PluginCommand::SendLspRequest {
4126                language,
4127                method,
4128                params,
4129                request_id,
4130            } => {
4131                self.handle_send_lsp_request(language, method, params, request_id);
4132            }
4133
4134            // ==================== Clipboard Commands ====================
4135            PluginCommand::SetClipboard { text } => {
4136                self.handle_set_clipboard(text);
4137            }
4138
4139            // ==================== Async Plugin Commands ====================
4140            PluginCommand::SpawnProcess {
4141                command,
4142                args,
4143                cwd,
4144                callback_id,
4145            } => {
4146                // Spawn process asynchronously via tokio
4147                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
4148                    let effective_cwd = cwd.unwrap_or_else(|| {
4149                        std::env::current_dir()
4150                            .map(|p| p.to_string_lossy().to_string())
4151                            .unwrap_or_else(|_| ".".to_string())
4152                    });
4153                    let sender = bridge.sender();
4154                    runtime.spawn(async move {
4155                        let output = tokio::process::Command::new(&command)
4156                            .args(&args)
4157                            .current_dir(&effective_cwd)
4158                            .output()
4159                            .await;
4160
4161                        match output {
4162                            Ok(output) => {
4163                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
4164                                    process_id: callback_id.as_u64(),
4165                                    stdout: String::from_utf8_lossy(&output.stdout).to_string(),
4166                                    stderr: String::from_utf8_lossy(&output.stderr).to_string(),
4167                                    exit_code: output.status.code().unwrap_or(-1),
4168                                });
4169                            }
4170                            Err(e) => {
4171                                let _ = sender.send(AsyncMessage::PluginProcessOutput {
4172                                    process_id: callback_id.as_u64(),
4173                                    stdout: String::new(),
4174                                    stderr: e.to_string(),
4175                                    exit_code: -1,
4176                                });
4177                            }
4178                        }
4179                    });
4180                } else {
4181                    // Fallback to blocking if no runtime available
4182                    let effective_cwd = cwd.unwrap_or_else(|| ".".to_string());
4183                    match std::process::Command::new(&command)
4184                        .args(&args)
4185                        .current_dir(&effective_cwd)
4186                        .output()
4187                    {
4188                        Ok(output) => {
4189                            // Using SpawnResult struct ensures field names match TypeScript types
4190                            let result = fresh_core::api::SpawnResult {
4191                                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
4192                                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
4193                                exit_code: output.status.code().unwrap_or(-1),
4194                            };
4195                            self.plugin_manager.resolve_callback(
4196                                callback_id,
4197                                serde_json::to_string(&result).unwrap(),
4198                            );
4199                        }
4200                        Err(e) => {
4201                            self.plugin_manager
4202                                .reject_callback(callback_id, e.to_string());
4203                        }
4204                    }
4205                }
4206            }
4207
4208            PluginCommand::SpawnProcessWait {
4209                process_id,
4210                callback_id,
4211            } => {
4212                // TODO: Implement proper process wait tracking
4213                // For now, just reject with an error since there's no process tracking yet
4214                tracing::warn!(
4215                    "SpawnProcessWait not fully implemented - process_id={}",
4216                    process_id
4217                );
4218                self.plugin_manager.reject_callback(
4219                    callback_id,
4220                    format!(
4221                        "SpawnProcessWait not yet fully implemented for process_id={}",
4222                        process_id
4223                    ),
4224                );
4225            }
4226
4227            PluginCommand::Delay {
4228                callback_id,
4229                duration_ms,
4230            } => {
4231                // Spawn async delay via tokio
4232                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
4233                    let sender = bridge.sender();
4234                    let callback_id_u64 = callback_id.as_u64();
4235                    runtime.spawn(async move {
4236                        tokio::time::sleep(tokio::time::Duration::from_millis(duration_ms)).await;
4237                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
4238                            fresh_core::api::PluginAsyncMessage::DelayComplete {
4239                                callback_id: callback_id_u64,
4240                            },
4241                        ));
4242                    });
4243                } else {
4244                    // Fallback to blocking if no runtime available
4245                    std::thread::sleep(std::time::Duration::from_millis(duration_ms));
4246                    self.plugin_manager
4247                        .resolve_callback(callback_id, "null".to_string());
4248                }
4249            }
4250
4251            PluginCommand::SpawnBackgroundProcess {
4252                process_id,
4253                command,
4254                args,
4255                cwd,
4256                callback_id,
4257            } => {
4258                // Spawn background process with streaming output via tokio
4259                if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
4260                    use tokio::io::{AsyncBufReadExt, BufReader};
4261                    use tokio::process::Command as TokioCommand;
4262
4263                    let effective_cwd = cwd.unwrap_or_else(|| {
4264                        std::env::current_dir()
4265                            .map(|p| p.to_string_lossy().to_string())
4266                            .unwrap_or_else(|_| ".".to_string())
4267                    });
4268
4269                    let sender = bridge.sender();
4270                    let sender_stdout = sender.clone();
4271                    let sender_stderr = sender.clone();
4272                    let callback_id_u64 = callback_id.as_u64();
4273
4274                    let handle = runtime.spawn(async move {
4275                        let mut child = match TokioCommand::new(&command)
4276                            .args(&args)
4277                            .current_dir(&effective_cwd)
4278                            .stdout(std::process::Stdio::piped())
4279                            .stderr(std::process::Stdio::piped())
4280                            .spawn()
4281                        {
4282                            Ok(child) => child,
4283                            Err(e) => {
4284                                let _ = sender.send(
4285                                    crate::services::async_bridge::AsyncMessage::Plugin(
4286                                        fresh_core::api::PluginAsyncMessage::ProcessExit {
4287                                            process_id,
4288                                            callback_id: callback_id_u64,
4289                                            exit_code: -1,
4290                                        },
4291                                    ),
4292                                );
4293                                tracing::error!("Failed to spawn background process: {}", e);
4294                                return;
4295                            }
4296                        };
4297
4298                        // Stream stdout
4299                        let stdout = child.stdout.take();
4300                        let stderr = child.stderr.take();
4301                        let pid = process_id;
4302
4303                        // Spawn stdout reader
4304                        if let Some(stdout) = stdout {
4305                            let sender = sender_stdout;
4306                            tokio::spawn(async move {
4307                                let reader = BufReader::new(stdout);
4308                                let mut lines = reader.lines();
4309                                while let Ok(Some(line)) = lines.next_line().await {
4310                                    let _ = sender.send(
4311                                        crate::services::async_bridge::AsyncMessage::Plugin(
4312                                            fresh_core::api::PluginAsyncMessage::ProcessStdout {
4313                                                process_id: pid,
4314                                                data: line + "\n",
4315                                            },
4316                                        ),
4317                                    );
4318                                }
4319                            });
4320                        }
4321
4322                        // Spawn stderr reader
4323                        if let Some(stderr) = stderr {
4324                            let sender = sender_stderr;
4325                            tokio::spawn(async move {
4326                                let reader = BufReader::new(stderr);
4327                                let mut lines = reader.lines();
4328                                while let Ok(Some(line)) = lines.next_line().await {
4329                                    let _ = sender.send(
4330                                        crate::services::async_bridge::AsyncMessage::Plugin(
4331                                            fresh_core::api::PluginAsyncMessage::ProcessStderr {
4332                                                process_id: pid,
4333                                                data: line + "\n",
4334                                            },
4335                                        ),
4336                                    );
4337                                }
4338                            });
4339                        }
4340
4341                        // Wait for process to complete
4342                        let exit_code = match child.wait().await {
4343                            Ok(status) => status.code().unwrap_or(-1),
4344                            Err(_) => -1,
4345                        };
4346
4347                        let _ = sender.send(crate::services::async_bridge::AsyncMessage::Plugin(
4348                            fresh_core::api::PluginAsyncMessage::ProcessExit {
4349                                process_id,
4350                                callback_id: callback_id_u64,
4351                                exit_code,
4352                            },
4353                        ));
4354                    });
4355
4356                    // Store abort handle for potential kill
4357                    self.background_process_handles
4358                        .insert(process_id, handle.abort_handle());
4359                } else {
4360                    // No runtime - reject immediately
4361                    self.plugin_manager
4362                        .reject_callback(callback_id, "Async runtime not available".to_string());
4363                }
4364            }
4365
4366            PluginCommand::KillBackgroundProcess { process_id } => {
4367                if let Some(handle) = self.background_process_handles.remove(&process_id) {
4368                    handle.abort();
4369                    tracing::debug!("Killed background process {}", process_id);
4370                }
4371            }
4372
4373            // ==================== Virtual Buffer Commands (complex, kept inline) ====================
4374            PluginCommand::CreateVirtualBuffer {
4375                name,
4376                mode,
4377                read_only,
4378            } => {
4379                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
4380                tracing::info!(
4381                    "Created virtual buffer '{}' with mode '{}' (id={:?})",
4382                    name,
4383                    mode,
4384                    buffer_id
4385                );
4386                // TODO: Return buffer_id to plugin via callback or hook
4387            }
4388            PluginCommand::CreateVirtualBufferWithContent {
4389                name,
4390                mode,
4391                read_only,
4392                entries,
4393                show_line_numbers,
4394                show_cursors,
4395                editing_disabled,
4396                hidden_from_tabs,
4397                request_id,
4398            } => {
4399                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
4400                tracing::info!(
4401                    "Created virtual buffer '{}' with mode '{}' (id={:?})",
4402                    name,
4403                    mode,
4404                    buffer_id
4405                );
4406
4407                // Apply view options to the buffer
4408                if let Some(state) = self.buffers.get_mut(&buffer_id) {
4409                    state.margins.set_line_numbers(show_line_numbers);
4410                    state.show_cursors = show_cursors;
4411                    state.editing_disabled = editing_disabled;
4412                    tracing::debug!(
4413                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
4414                        buffer_id,
4415                        show_line_numbers,
4416                        show_cursors,
4417                        editing_disabled
4418                    );
4419                }
4420
4421                // Apply hidden_from_tabs to buffer metadata
4422                if hidden_from_tabs {
4423                    if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
4424                        meta.hidden_from_tabs = true;
4425                    }
4426                }
4427
4428                // Now set the content
4429                match self.set_virtual_buffer_content(buffer_id, entries) {
4430                    Ok(()) => {
4431                        tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
4432                        // Switch to the new buffer to display it
4433                        self.set_active_buffer(buffer_id);
4434                        tracing::debug!("Switched to virtual buffer {:?}", buffer_id);
4435
4436                        // Send response if request_id is present
4437                        if let Some(req_id) = request_id {
4438                            tracing::info!(
4439                                "CreateVirtualBufferWithContent: resolving callback for request_id={}, buffer_id={:?}",
4440                                req_id,
4441                                buffer_id
4442                            );
4443                            // createVirtualBuffer returns VirtualBufferResult: { bufferId, splitId }
4444                            let result = fresh_core::api::VirtualBufferResult {
4445                                buffer_id: buffer_id.0 as u64,
4446                                split_id: None,
4447                            };
4448                            self.plugin_manager.resolve_callback(
4449                                fresh_core::api::JsCallbackId::from(req_id),
4450                                serde_json::to_string(&result).unwrap_or_default(),
4451                            );
4452                            tracing::info!("CreateVirtualBufferWithContent: resolve_callback sent for request_id={}", req_id);
4453                        }
4454                    }
4455                    Err(e) => {
4456                        tracing::error!("Failed to set virtual buffer content: {}", e);
4457                    }
4458                }
4459            }
4460            PluginCommand::CreateVirtualBufferInSplit {
4461                name,
4462                mode,
4463                read_only,
4464                entries,
4465                ratio,
4466                direction,
4467                panel_id,
4468                show_line_numbers,
4469                show_cursors,
4470                editing_disabled,
4471                line_wrap,
4472                request_id,
4473            } => {
4474                // Check if this panel already exists (for idempotent operations)
4475                if let Some(pid) = &panel_id {
4476                    if let Some(&existing_buffer_id) = self.panel_ids.get(pid) {
4477                        // Verify the buffer actually exists (defensive check for stale entries)
4478                        if self.buffers.contains_key(&existing_buffer_id) {
4479                            // Panel exists, just update its content
4480                            if let Err(e) =
4481                                self.set_virtual_buffer_content(existing_buffer_id, entries)
4482                            {
4483                                tracing::error!("Failed to update panel content: {}", e);
4484                            } else {
4485                                tracing::info!("Updated existing panel '{}' content", pid);
4486                            }
4487
4488                            // Find and focus the split that contains this buffer
4489                            let splits = self.split_manager.splits_for_buffer(existing_buffer_id);
4490                            if let Some(&split_id) = splits.first() {
4491                                self.split_manager.set_active_split(split_id);
4492                                // NOTE: active_buffer is derived from split_manager,
4493                                // but we need to ensure the split shows the right buffer
4494                                self.split_manager.set_active_buffer_id(existing_buffer_id);
4495                                tracing::debug!(
4496                                    "Focused split {:?} containing panel buffer",
4497                                    split_id
4498                                );
4499                            }
4500
4501                            // Send response with existing buffer ID and split ID via callback resolution
4502                            if let Some(req_id) = request_id {
4503                                let result = fresh_core::api::VirtualBufferResult {
4504                                    buffer_id: existing_buffer_id.0 as u64,
4505                                    split_id: splits.first().map(|s| s.0 as u64),
4506                                };
4507                                self.plugin_manager.resolve_callback(
4508                                    fresh_core::api::JsCallbackId::from(req_id),
4509                                    serde_json::to_string(&result).unwrap_or_default(),
4510                                );
4511                            }
4512                            return Ok(());
4513                        } else {
4514                            // Buffer no longer exists, remove stale panel_id entry
4515                            tracing::warn!(
4516                                "Removing stale panel_id '{}' pointing to non-existent buffer {:?}",
4517                                pid,
4518                                existing_buffer_id
4519                            );
4520                            self.panel_ids.remove(pid);
4521                            // Fall through to create a new buffer
4522                        }
4523                    }
4524                }
4525
4526                // Create the virtual buffer first
4527                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
4528                tracing::info!(
4529                    "Created virtual buffer '{}' with mode '{}' in split (id={:?})",
4530                    name,
4531                    mode,
4532                    buffer_id
4533                );
4534
4535                // Apply view options to the buffer
4536                if let Some(state) = self.buffers.get_mut(&buffer_id) {
4537                    state.margins.set_line_numbers(show_line_numbers);
4538                    state.show_cursors = show_cursors;
4539                    state.editing_disabled = editing_disabled;
4540                    tracing::debug!(
4541                        "Set buffer {:?} view options: show_line_numbers={}, show_cursors={}, editing_disabled={}",
4542                        buffer_id,
4543                        show_line_numbers,
4544                        show_cursors,
4545                        editing_disabled
4546                    );
4547                }
4548
4549                // Store the panel ID mapping if provided
4550                if let Some(pid) = panel_id {
4551                    self.panel_ids.insert(pid, buffer_id);
4552                }
4553
4554                // Set the content
4555                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
4556                    tracing::error!("Failed to set virtual buffer content: {}", e);
4557                    return Ok(());
4558                }
4559
4560                // Save current split's view state
4561                self.save_current_split_view_state();
4562
4563                // Determine split direction
4564                let split_dir = match direction.as_deref() {
4565                    Some("vertical") => crate::model::event::SplitDirection::Vertical,
4566                    _ => crate::model::event::SplitDirection::Horizontal,
4567                };
4568
4569                // Create a split with the new buffer
4570                let created_split_id =
4571                    match self.split_manager.split_active(split_dir, buffer_id, ratio) {
4572                        Ok(new_split_id) => {
4573                            // Create independent view state for the new split with the buffer in tabs
4574                            let mut view_state = SplitViewState::with_buffer(
4575                                self.terminal_width,
4576                                self.terminal_height,
4577                                buffer_id,
4578                            );
4579                            view_state.viewport.line_wrap_enabled =
4580                                line_wrap.unwrap_or(self.config.editor.line_wrap);
4581                            self.split_view_states.insert(new_split_id, view_state);
4582
4583                            // Focus the new split (the diagnostics panel)
4584                            self.split_manager.set_active_split(new_split_id);
4585                            // NOTE: split tree was updated by split_active, active_buffer derives from it
4586
4587                            tracing::info!(
4588                                "Created {:?} split with virtual buffer {:?}",
4589                                split_dir,
4590                                buffer_id
4591                            );
4592                            Some(new_split_id)
4593                        }
4594                        Err(e) => {
4595                            tracing::error!("Failed to create split: {}", e);
4596                            // Fall back to just switching to the buffer
4597                            self.set_active_buffer(buffer_id);
4598                            None
4599                        }
4600                    };
4601
4602                // Send response with buffer ID and split ID via callback resolution
4603                // NOTE: Using VirtualBufferResult type for type-safe JSON serialization
4604                if let Some(req_id) = request_id {
4605                    tracing::trace!("CreateVirtualBufferInSplit: resolving callback for request_id={}, buffer_id={:?}, split_id={:?}", req_id, buffer_id, created_split_id);
4606                    let result = fresh_core::api::VirtualBufferResult {
4607                        buffer_id: buffer_id.0 as u64,
4608                        split_id: created_split_id.map(|s| s.0 as u64),
4609                    };
4610                    self.plugin_manager.resolve_callback(
4611                        fresh_core::api::JsCallbackId::from(req_id),
4612                        serde_json::to_string(&result).unwrap_or_default(),
4613                    );
4614                }
4615            }
4616            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4617                match self.set_virtual_buffer_content(buffer_id, entries) {
4618                    Ok(()) => {
4619                        tracing::debug!("Set virtual buffer content for {:?}", buffer_id);
4620                    }
4621                    Err(e) => {
4622                        tracing::error!("Failed to set virtual buffer content: {}", e);
4623                    }
4624                }
4625            }
4626            PluginCommand::GetTextPropertiesAtCursor { buffer_id } => {
4627                // Get text properties at cursor and fire a hook with the data
4628                if let Some(state) = self.buffers.get(&buffer_id) {
4629                    let cursor_pos = state.cursors.primary().position;
4630                    let properties = state.text_properties.get_at(cursor_pos);
4631                    tracing::debug!(
4632                        "Text properties at cursor in {:?}: {} properties found",
4633                        buffer_id,
4634                        properties.len()
4635                    );
4636                    // TODO: Fire hook with properties data for plugins to consume
4637                }
4638            }
4639            PluginCommand::CreateVirtualBufferInExistingSplit {
4640                name,
4641                mode,
4642                read_only,
4643                entries,
4644                split_id,
4645                show_line_numbers,
4646                show_cursors,
4647                editing_disabled,
4648                line_wrap,
4649                request_id,
4650            } => {
4651                // Create the virtual buffer
4652                let buffer_id = self.create_virtual_buffer(name.clone(), mode.clone(), read_only);
4653                tracing::info!(
4654                    "Created virtual buffer '{}' with mode '{}' for existing split {:?} (id={:?})",
4655                    name,
4656                    mode,
4657                    split_id,
4658                    buffer_id
4659                );
4660
4661                // Apply view options to the buffer
4662                if let Some(state) = self.buffers.get_mut(&buffer_id) {
4663                    state.margins.set_line_numbers(show_line_numbers);
4664                    state.show_cursors = show_cursors;
4665                    state.editing_disabled = editing_disabled;
4666                }
4667
4668                // Set the content
4669                if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
4670                    tracing::error!("Failed to set virtual buffer content: {}", e);
4671                    return Ok(());
4672                }
4673
4674                // Show the buffer in the target split
4675                if let Err(e) = self.split_manager.set_split_buffer(split_id, buffer_id) {
4676                    tracing::error!("Failed to set buffer in split {:?}: {}", split_id, e);
4677                    // Fall back to just switching to the buffer
4678                    self.set_active_buffer(buffer_id);
4679                } else {
4680                    // Focus the target split and set its buffer
4681                    self.split_manager.set_active_split(split_id);
4682                    self.split_manager.set_active_buffer_id(buffer_id);
4683
4684                    // Apply line_wrap setting if provided
4685                    if let Some(wrap) = line_wrap {
4686                        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
4687                            view_state.viewport.line_wrap_enabled = wrap;
4688                        }
4689                    }
4690
4691                    tracing::info!(
4692                        "Displayed virtual buffer {:?} in split {:?}",
4693                        buffer_id,
4694                        split_id
4695                    );
4696                }
4697
4698                // Send response with buffer ID and split ID via callback resolution
4699                if let Some(req_id) = request_id {
4700                    let result = fresh_core::api::VirtualBufferResult {
4701                        buffer_id: buffer_id.0 as u64,
4702                        split_id: Some(split_id.0 as u64),
4703                    };
4704                    self.plugin_manager.resolve_callback(
4705                        fresh_core::api::JsCallbackId::from(req_id),
4706                        serde_json::to_string(&result).unwrap_or_default(),
4707                    );
4708                }
4709            }
4710
4711            // ==================== Context Commands ====================
4712            PluginCommand::SetContext { name, active } => {
4713                if active {
4714                    self.active_custom_contexts.insert(name.clone());
4715                    tracing::debug!("Set custom context: {}", name);
4716                } else {
4717                    self.active_custom_contexts.remove(&name);
4718                    tracing::debug!("Unset custom context: {}", name);
4719                }
4720            }
4721
4722            // ==================== Review Diff Commands ====================
4723            PluginCommand::SetReviewDiffHunks { hunks } => {
4724                self.review_hunks = hunks;
4725                tracing::debug!("Set {} review hunks", self.review_hunks.len());
4726            }
4727
4728            // ==================== Vi Mode Commands ====================
4729            PluginCommand::ExecuteAction { action_name } => {
4730                self.handle_execute_action(action_name);
4731            }
4732            PluginCommand::ExecuteActions { actions } => {
4733                self.handle_execute_actions(actions);
4734            }
4735            PluginCommand::GetBufferText {
4736                buffer_id,
4737                start,
4738                end,
4739                request_id,
4740            } => {
4741                self.handle_get_buffer_text(buffer_id, start, end, request_id);
4742            }
4743            PluginCommand::GetLineStartPosition {
4744                buffer_id,
4745                line,
4746                request_id,
4747            } => {
4748                self.handle_get_line_start_position(buffer_id, line, request_id);
4749            }
4750            PluginCommand::SetEditorMode { mode } => {
4751                self.handle_set_editor_mode(mode);
4752            }
4753
4754            // ==================== LSP Helper Commands ====================
4755            PluginCommand::ShowActionPopup {
4756                popup_id,
4757                title,
4758                message,
4759                actions,
4760            } => {
4761                tracing::info!(
4762                    "Action popup requested: id={}, title={}, actions={}",
4763                    popup_id,
4764                    title,
4765                    actions.len()
4766                );
4767
4768                // Build popup list items from actions
4769                let items: Vec<crate::model::event::PopupListItemData> = actions
4770                    .iter()
4771                    .map(|action| crate::model::event::PopupListItemData {
4772                        text: action.label.clone(),
4773                        detail: None,
4774                        icon: None,
4775                        data: Some(action.id.clone()),
4776                    })
4777                    .collect();
4778
4779                // Store action info for when popup is confirmed/cancelled
4780                let action_ids: Vec<(String, String)> =
4781                    actions.into_iter().map(|a| (a.id, a.label)).collect();
4782                self.active_action_popup = Some((popup_id.clone(), action_ids));
4783
4784                // Create popup with message + action list
4785                let popup = crate::model::event::PopupData {
4786                    title: Some(title),
4787                    description: Some(message),
4788                    transient: false,
4789                    content: crate::model::event::PopupContentData::List { items, selected: 0 },
4790                    position: crate::model::event::PopupPositionData::BottomRight,
4791                    width: 60,
4792                    max_height: 15,
4793                    bordered: true,
4794                };
4795
4796                self.show_popup(popup);
4797                tracing::info!(
4798                    "Action popup shown: id={}, active_action_popup={:?}",
4799                    popup_id,
4800                    self.active_action_popup.as_ref().map(|(id, _)| id)
4801                );
4802            }
4803
4804            PluginCommand::DisableLspForLanguage { language } => {
4805                tracing::info!("Disabling LSP for language: {}", language);
4806
4807                // 1. Stop the LSP server for this language if running
4808                if let Some(ref mut lsp) = self.lsp {
4809                    lsp.shutdown_server(&language);
4810                    tracing::info!("Stopped LSP server for {}", language);
4811                }
4812
4813                // 2. Update the config to disable the language
4814                if let Some(lsp_config) = self.config.lsp.get_mut(&language) {
4815                    lsp_config.enabled = false;
4816                    lsp_config.auto_start = false;
4817                    tracing::info!("Disabled LSP config for {}", language);
4818                }
4819
4820                // 3. Persist the config change
4821                if let Err(e) = self.save_config() {
4822                    tracing::error!("Failed to save config: {}", e);
4823                    self.status_message = Some(format!(
4824                        "LSP disabled for {} (config save failed)",
4825                        language
4826                    ));
4827                } else {
4828                    self.status_message = Some(format!("LSP disabled for {}", language));
4829                }
4830
4831                // 4. Clear any LSP-related warnings for this language
4832                self.warning_domains.lsp.clear();
4833            }
4834
4835            // ==================== Scroll Sync Commands ====================
4836            PluginCommand::CreateScrollSyncGroup {
4837                group_id,
4838                left_split,
4839                right_split,
4840            } => {
4841                let success = self.scroll_sync_manager.create_group_with_id(
4842                    group_id,
4843                    left_split,
4844                    right_split,
4845                );
4846                if success {
4847                    tracing::debug!(
4848                        "Created scroll sync group {} for splits {:?} and {:?}",
4849                        group_id,
4850                        left_split,
4851                        right_split
4852                    );
4853                } else {
4854                    tracing::warn!(
4855                        "Failed to create scroll sync group {} (ID already exists)",
4856                        group_id
4857                    );
4858                }
4859            }
4860            PluginCommand::SetScrollSyncAnchors { group_id, anchors } => {
4861                use crate::view::scroll_sync::SyncAnchor;
4862                let anchor_count = anchors.len();
4863                let sync_anchors: Vec<SyncAnchor> = anchors
4864                    .into_iter()
4865                    .map(|(left_line, right_line)| SyncAnchor {
4866                        left_line,
4867                        right_line,
4868                    })
4869                    .collect();
4870                self.scroll_sync_manager.set_anchors(group_id, sync_anchors);
4871                tracing::debug!(
4872                    "Set {} anchors for scroll sync group {}",
4873                    anchor_count,
4874                    group_id
4875                );
4876            }
4877            PluginCommand::RemoveScrollSyncGroup { group_id } => {
4878                if self.scroll_sync_manager.remove_group(group_id) {
4879                    tracing::debug!("Removed scroll sync group {}", group_id);
4880                } else {
4881                    tracing::warn!("Scroll sync group {} not found", group_id);
4882                }
4883            }
4884
4885            // ==================== Composite Buffer Commands ====================
4886            PluginCommand::CreateCompositeBuffer {
4887                name,
4888                mode,
4889                layout,
4890                sources,
4891                hunks,
4892                request_id,
4893            } => {
4894                self.handle_create_composite_buffer(name, mode, layout, sources, hunks, request_id);
4895            }
4896            PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => {
4897                self.handle_update_composite_alignment(buffer_id, hunks);
4898            }
4899            PluginCommand::CloseCompositeBuffer { buffer_id } => {
4900                self.close_composite_buffer(buffer_id);
4901            }
4902
4903            // ==================== File Operations ====================
4904            PluginCommand::SaveBufferToPath { buffer_id, path } => {
4905                self.handle_save_buffer_to_path(buffer_id, path);
4906            }
4907        }
4908        Ok(())
4909    }
4910
4911    /// Save a buffer to a specific file path (for :w filename)
4912    fn handle_save_buffer_to_path(&mut self, buffer_id: BufferId, path: std::path::PathBuf) {
4913        if let Some(state) = self.buffers.get_mut(&buffer_id) {
4914            // Save to the specified path
4915            match state.buffer.save_to_file(&path) {
4916                Ok(()) => {
4917                    // Update the buffer's file path so future saves go to the same file
4918                    state.buffer.set_file_path(path.clone());
4919                    // Run on-save actions (formatting, etc.)
4920                    let _ = self.finalize_save(Some(path));
4921                    tracing::debug!("Saved buffer {:?} to path", buffer_id);
4922                }
4923                Err(e) => {
4924                    self.handle_set_status(format!("Error saving: {}", e));
4925                    tracing::error!("Failed to save buffer to path: {}", e);
4926                }
4927            }
4928        } else {
4929            self.handle_set_status(format!("Buffer {:?} not found", buffer_id));
4930            tracing::warn!("SaveBufferToPath: buffer {:?} not found", buffer_id);
4931        }
4932    }
4933
4934    /// Execute an editor action by name (for vi mode plugin)
4935    fn handle_execute_action(&mut self, action_name: String) {
4936        use crate::input::keybindings::Action;
4937        use std::collections::HashMap;
4938
4939        // Parse the action name into an Action enum
4940        if let Some(action) = Action::from_str(&action_name, &HashMap::new()) {
4941            // Execute the action
4942            if let Err(e) = self.handle_action(action) {
4943                tracing::warn!("Failed to execute action '{}': {}", action_name, e);
4944            } else {
4945                tracing::debug!("Executed action: {}", action_name);
4946            }
4947        } else {
4948            tracing::warn!("Unknown action: {}", action_name);
4949        }
4950    }
4951
4952    /// Execute multiple actions in sequence, each with an optional repeat count
4953    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
4954    fn handle_execute_actions(&mut self, actions: Vec<fresh_core::api::ActionSpec>) {
4955        use crate::input::keybindings::Action;
4956        use std::collections::HashMap;
4957
4958        for action_spec in actions {
4959            if let Some(action) = Action::from_str(&action_spec.action, &HashMap::new()) {
4960                // Execute the action `count` times
4961                for _ in 0..action_spec.count {
4962                    if let Err(e) = self.handle_action(action.clone()) {
4963                        tracing::warn!("Failed to execute action '{}': {}", action_spec.action, e);
4964                        return; // Stop on first error
4965                    }
4966                }
4967                tracing::debug!(
4968                    "Executed action '{}' {} time(s)",
4969                    action_spec.action,
4970                    action_spec.count
4971                );
4972            } else {
4973                tracing::warn!("Unknown action: {}", action_spec.action);
4974                return; // Stop on unknown action
4975            }
4976        }
4977    }
4978
4979    /// Get text from a buffer range (for vi mode yank operations)
4980    fn handle_get_buffer_text(
4981        &mut self,
4982        buffer_id: BufferId,
4983        start: usize,
4984        end: usize,
4985        request_id: u64,
4986    ) {
4987        let result = if let Some(state) = self.buffers.get_mut(&buffer_id) {
4988            // Get text from the buffer using the mutable get_text_range method
4989            let len = state.buffer.len();
4990            if start <= end && end <= len {
4991                Ok(state.get_text_range(start, end))
4992            } else {
4993                Err(format!(
4994                    "Invalid range {}..{} for buffer of length {}",
4995                    start, end, len
4996                ))
4997            }
4998        } else {
4999            Err(format!("Buffer {:?} not found", buffer_id))
5000        };
5001
5002        // Resolve the JavaScript Promise callback directly
5003        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
5004        match result {
5005            Ok(text) => {
5006                // Serialize text as JSON string
5007                let json = serde_json::to_string(&text).unwrap_or_else(|_| "null".to_string());
5008                self.plugin_manager.resolve_callback(callback_id, json);
5009            }
5010            Err(error) => {
5011                self.plugin_manager.reject_callback(callback_id, error);
5012            }
5013        }
5014    }
5015
5016    /// Set the global editor mode (for vi mode)
5017    fn handle_set_editor_mode(&mut self, mode: Option<String>) {
5018        self.editor_mode = mode.clone();
5019        tracing::debug!("Set editor mode: {:?}", mode);
5020    }
5021
5022    /// Get the byte offset of the start of a line in the active buffer
5023    fn handle_get_line_start_position(&mut self, buffer_id: BufferId, line: u32, request_id: u64) {
5024        // Use active buffer if buffer_id is 0
5025        let actual_buffer_id = if buffer_id.0 == 0 {
5026            self.active_buffer_id()
5027        } else {
5028            buffer_id
5029        };
5030
5031        let result = if let Some(state) = self.buffers.get_mut(&actual_buffer_id) {
5032            // Get line start position by iterating through the buffer content
5033            let line_number = line as usize;
5034            let buffer_len = state.buffer.len();
5035
5036            if line_number == 0 {
5037                // First line always starts at 0
5038                Some(0)
5039            } else {
5040                // Count newlines to find the start of the requested line
5041                let mut current_line = 0;
5042                let mut line_start = None;
5043
5044                // Read buffer content to find newlines using the BufferState's get_text_range
5045                let content = state.get_text_range(0, buffer_len);
5046                for (byte_idx, c) in content.char_indices() {
5047                    if c == '\n' {
5048                        current_line += 1;
5049                        if current_line == line_number {
5050                            // Found the start of the requested line (byte after newline)
5051                            line_start = Some(byte_idx + 1);
5052                            break;
5053                        }
5054                    }
5055                }
5056                line_start
5057            }
5058        } else {
5059            None
5060        };
5061
5062        // Resolve the JavaScript Promise callback directly
5063        let callback_id = fresh_core::api::JsCallbackId::from(request_id);
5064        // Serialize as JSON (null for None, number for Some)
5065        let json = serde_json::to_string(&result).unwrap_or_else(|_| "null".to_string());
5066        self.plugin_manager.resolve_callback(callback_id, json);
5067    }
5068}
5069
5070/// Parse a key string like "RET", "C-n", "M-x", "q" into KeyCode and KeyModifiers
5071///
5072/// Supports:
5073/// - Single characters: "a", "q", etc.
5074/// - Function keys: "F1", "F2", etc.
5075/// - Special keys: "RET", "TAB", "ESC", "SPC", "DEL", "BS"
5076/// - Modifiers: "C-" (Control), "M-" (Alt/Meta), "S-" (Shift)
5077/// - Combinations: "C-n", "M-x", "C-M-s", etc.
5078fn parse_key_string(key_str: &str) -> Option<(KeyCode, KeyModifiers)> {
5079    use crossterm::event::{KeyCode, KeyModifiers};
5080
5081    let mut modifiers = KeyModifiers::NONE;
5082    let mut remaining = key_str;
5083
5084    // Parse modifiers
5085    loop {
5086        if remaining.starts_with("C-") {
5087            modifiers |= KeyModifiers::CONTROL;
5088            remaining = &remaining[2..];
5089        } else if remaining.starts_with("M-") {
5090            modifiers |= KeyModifiers::ALT;
5091            remaining = &remaining[2..];
5092        } else if remaining.starts_with("S-") {
5093            modifiers |= KeyModifiers::SHIFT;
5094            remaining = &remaining[2..];
5095        } else {
5096            break;
5097        }
5098    }
5099
5100    // Parse the key
5101    // Use uppercase for matching special keys, but preserve original for single chars
5102    let upper = remaining.to_uppercase();
5103    let code = match upper.as_str() {
5104        "RET" | "RETURN" | "ENTER" => KeyCode::Enter,
5105        "TAB" => KeyCode::Tab,
5106        "ESC" | "ESCAPE" => KeyCode::Esc,
5107        "SPC" | "SPACE" => KeyCode::Char(' '),
5108        "DEL" | "DELETE" => KeyCode::Delete,
5109        "BS" | "BACKSPACE" => KeyCode::Backspace,
5110        "UP" => KeyCode::Up,
5111        "DOWN" => KeyCode::Down,
5112        "LEFT" => KeyCode::Left,
5113        "RIGHT" => KeyCode::Right,
5114        "HOME" => KeyCode::Home,
5115        "END" => KeyCode::End,
5116        "PAGEUP" | "PGUP" => KeyCode::PageUp,
5117        "PAGEDOWN" | "PGDN" => KeyCode::PageDown,
5118        s if s.starts_with('F') && s.len() > 1 => {
5119            // Function key (F1-F12)
5120            if let Ok(n) = s[1..].parse::<u8>() {
5121                KeyCode::F(n)
5122            } else {
5123                return None;
5124            }
5125        }
5126        _ if remaining.len() == 1 => {
5127            // Single character - use ORIGINAL remaining, not uppercased
5128            // For uppercase letters, add SHIFT modifier so 'J' != 'j'
5129            let c = remaining.chars().next()?;
5130            if c.is_ascii_uppercase() {
5131                modifiers |= KeyModifiers::SHIFT;
5132            }
5133            KeyCode::Char(c.to_ascii_lowercase())
5134        }
5135        _ => return None,
5136    };
5137
5138    Some((code, modifiers))
5139}
5140
5141#[cfg(test)]
5142mod tests {
5143    use super::*;
5144    use tempfile::TempDir;
5145
5146    /// Create a test DirectoryContext with temp directories
5147    fn test_dir_context() -> (DirectoryContext, TempDir) {
5148        let temp_dir = TempDir::new().unwrap();
5149        let dir_context = DirectoryContext::for_testing(temp_dir.path());
5150        (dir_context, temp_dir)
5151    }
5152
5153    #[test]
5154    fn test_editor_new() {
5155        let config = Config::default();
5156        let (dir_context, _temp) = test_dir_context();
5157        let editor = Editor::new(
5158            config,
5159            80,
5160            24,
5161            dir_context,
5162            crate::view::color_support::ColorCapability::TrueColor,
5163        )
5164        .unwrap();
5165
5166        assert_eq!(editor.buffers.len(), 1);
5167        assert!(!editor.should_quit());
5168    }
5169
5170    #[test]
5171    fn test_new_buffer() {
5172        let config = Config::default();
5173        let (dir_context, _temp) = test_dir_context();
5174        let mut editor = Editor::new(
5175            config,
5176            80,
5177            24,
5178            dir_context,
5179            crate::view::color_support::ColorCapability::TrueColor,
5180        )
5181        .unwrap();
5182
5183        let id = editor.new_buffer();
5184        assert_eq!(editor.buffers.len(), 2);
5185        assert_eq!(editor.active_buffer(), id);
5186    }
5187
5188    #[test]
5189    #[ignore]
5190    fn test_clipboard() {
5191        let config = Config::default();
5192        let (dir_context, _temp) = test_dir_context();
5193        let mut editor = Editor::new(
5194            config,
5195            80,
5196            24,
5197            dir_context,
5198            crate::view::color_support::ColorCapability::TrueColor,
5199        )
5200        .unwrap();
5201
5202        // Manually set clipboard (using internal to avoid system clipboard in tests)
5203        editor.clipboard.set_internal("test".to_string());
5204
5205        // Paste should work
5206        editor.paste();
5207
5208        let content = editor.active_state().buffer.to_string().unwrap();
5209        assert_eq!(content, "test");
5210    }
5211
5212    #[test]
5213    fn test_action_to_events_insert_char() {
5214        let config = Config::default();
5215        let (dir_context, _temp) = test_dir_context();
5216        let mut editor = Editor::new(
5217            config,
5218            80,
5219            24,
5220            dir_context,
5221            crate::view::color_support::ColorCapability::TrueColor,
5222        )
5223        .unwrap();
5224
5225        let events = editor.action_to_events(Action::InsertChar('a'));
5226        assert!(events.is_some());
5227
5228        let events = events.unwrap();
5229        assert_eq!(events.len(), 1);
5230
5231        match &events[0] {
5232            Event::Insert { position, text, .. } => {
5233                assert_eq!(*position, 0);
5234                assert_eq!(text, "a");
5235            }
5236            _ => panic!("Expected Insert event"),
5237        }
5238    }
5239
5240    #[test]
5241    fn test_action_to_events_move_right() {
5242        let config = Config::default();
5243        let (dir_context, _temp) = test_dir_context();
5244        let mut editor = Editor::new(
5245            config,
5246            80,
5247            24,
5248            dir_context,
5249            crate::view::color_support::ColorCapability::TrueColor,
5250        )
5251        .unwrap();
5252
5253        // Insert some text first
5254        let state = editor.active_state_mut();
5255        state.apply(&Event::Insert {
5256            position: 0,
5257            text: "hello".to_string(),
5258            cursor_id: state.cursors.primary_id(),
5259        });
5260
5261        let events = editor.action_to_events(Action::MoveRight);
5262        assert!(events.is_some());
5263
5264        let events = events.unwrap();
5265        assert_eq!(events.len(), 1);
5266
5267        match &events[0] {
5268            Event::MoveCursor {
5269                new_position,
5270                new_anchor,
5271                ..
5272            } => {
5273                // Cursor was at 5 (end of "hello"), stays at 5 (can't move beyond end)
5274                assert_eq!(*new_position, 5);
5275                assert_eq!(*new_anchor, None); // No selection
5276            }
5277            _ => panic!("Expected MoveCursor event"),
5278        }
5279    }
5280
5281    #[test]
5282    fn test_action_to_events_move_up_down() {
5283        let config = Config::default();
5284        let (dir_context, _temp) = test_dir_context();
5285        let mut editor = Editor::new(
5286            config,
5287            80,
5288            24,
5289            dir_context,
5290            crate::view::color_support::ColorCapability::TrueColor,
5291        )
5292        .unwrap();
5293
5294        // Insert multi-line text
5295        let state = editor.active_state_mut();
5296        state.apply(&Event::Insert {
5297            position: 0,
5298            text: "line1\nline2\nline3".to_string(),
5299            cursor_id: state.cursors.primary_id(),
5300        });
5301
5302        // Move cursor to start of line 2
5303        state.apply(&Event::MoveCursor {
5304            cursor_id: state.cursors.primary_id(),
5305            old_position: 0, // TODO: Get actual old position
5306            new_position: 6,
5307            old_anchor: None, // TODO: Get actual old anchor
5308            new_anchor: None,
5309            old_sticky_column: 0,
5310            new_sticky_column: 0,
5311        });
5312
5313        // Test move up
5314        let events = editor.action_to_events(Action::MoveUp);
5315        assert!(events.is_some());
5316        let events = events.unwrap();
5317        assert_eq!(events.len(), 1);
5318
5319        match &events[0] {
5320            Event::MoveCursor { new_position, .. } => {
5321                assert_eq!(*new_position, 0); // Should be at start of line 1
5322            }
5323            _ => panic!("Expected MoveCursor event"),
5324        }
5325    }
5326
5327    #[test]
5328    fn test_action_to_events_insert_newline() {
5329        let config = Config::default();
5330        let (dir_context, _temp) = test_dir_context();
5331        let mut editor = Editor::new(
5332            config,
5333            80,
5334            24,
5335            dir_context,
5336            crate::view::color_support::ColorCapability::TrueColor,
5337        )
5338        .unwrap();
5339
5340        let events = editor.action_to_events(Action::InsertNewline);
5341        assert!(events.is_some());
5342
5343        let events = events.unwrap();
5344        assert_eq!(events.len(), 1);
5345
5346        match &events[0] {
5347            Event::Insert { text, .. } => {
5348                assert_eq!(text, "\n");
5349            }
5350            _ => panic!("Expected Insert event"),
5351        }
5352    }
5353
5354    #[test]
5355    fn test_action_to_events_unimplemented() {
5356        let config = Config::default();
5357        let (dir_context, _temp) = test_dir_context();
5358        let mut editor = Editor::new(
5359            config,
5360            80,
5361            24,
5362            dir_context,
5363            crate::view::color_support::ColorCapability::TrueColor,
5364        )
5365        .unwrap();
5366
5367        // These actions should return None (not yet implemented)
5368        assert!(editor.action_to_events(Action::Save).is_none());
5369        assert!(editor.action_to_events(Action::Quit).is_none());
5370        assert!(editor.action_to_events(Action::Undo).is_none());
5371    }
5372
5373    #[test]
5374    fn test_action_to_events_delete_backward() {
5375        let config = Config::default();
5376        let (dir_context, _temp) = test_dir_context();
5377        let mut editor = Editor::new(
5378            config,
5379            80,
5380            24,
5381            dir_context,
5382            crate::view::color_support::ColorCapability::TrueColor,
5383        )
5384        .unwrap();
5385
5386        // Insert some text first
5387        let state = editor.active_state_mut();
5388        state.apply(&Event::Insert {
5389            position: 0,
5390            text: "hello".to_string(),
5391            cursor_id: state.cursors.primary_id(),
5392        });
5393
5394        let events = editor.action_to_events(Action::DeleteBackward);
5395        assert!(events.is_some());
5396
5397        let events = events.unwrap();
5398        assert_eq!(events.len(), 1);
5399
5400        match &events[0] {
5401            Event::Delete {
5402                range,
5403                deleted_text,
5404                ..
5405            } => {
5406                assert_eq!(range.clone(), 4..5); // Delete 'o'
5407                assert_eq!(deleted_text, "o");
5408            }
5409            _ => panic!("Expected Delete event"),
5410        }
5411    }
5412
5413    #[test]
5414    fn test_action_to_events_delete_forward() {
5415        let config = Config::default();
5416        let (dir_context, _temp) = test_dir_context();
5417        let mut editor = Editor::new(
5418            config,
5419            80,
5420            24,
5421            dir_context,
5422            crate::view::color_support::ColorCapability::TrueColor,
5423        )
5424        .unwrap();
5425
5426        // Insert some text first
5427        let state = editor.active_state_mut();
5428        state.apply(&Event::Insert {
5429            position: 0,
5430            text: "hello".to_string(),
5431            cursor_id: state.cursors.primary_id(),
5432        });
5433
5434        // Move cursor to position 0
5435        state.apply(&Event::MoveCursor {
5436            cursor_id: state.cursors.primary_id(),
5437            old_position: 0, // TODO: Get actual old position
5438            new_position: 0,
5439            old_anchor: None, // TODO: Get actual old anchor
5440            new_anchor: None,
5441            old_sticky_column: 0,
5442            new_sticky_column: 0,
5443        });
5444
5445        let events = editor.action_to_events(Action::DeleteForward);
5446        assert!(events.is_some());
5447
5448        let events = events.unwrap();
5449        assert_eq!(events.len(), 1);
5450
5451        match &events[0] {
5452            Event::Delete {
5453                range,
5454                deleted_text,
5455                ..
5456            } => {
5457                assert_eq!(range.clone(), 0..1); // Delete 'h'
5458                assert_eq!(deleted_text, "h");
5459            }
5460            _ => panic!("Expected Delete event"),
5461        }
5462    }
5463
5464    #[test]
5465    fn test_action_to_events_select_right() {
5466        let config = Config::default();
5467        let (dir_context, _temp) = test_dir_context();
5468        let mut editor = Editor::new(
5469            config,
5470            80,
5471            24,
5472            dir_context,
5473            crate::view::color_support::ColorCapability::TrueColor,
5474        )
5475        .unwrap();
5476
5477        // Insert some text first
5478        let state = editor.active_state_mut();
5479        state.apply(&Event::Insert {
5480            position: 0,
5481            text: "hello".to_string(),
5482            cursor_id: state.cursors.primary_id(),
5483        });
5484
5485        // Move cursor to position 0
5486        state.apply(&Event::MoveCursor {
5487            cursor_id: state.cursors.primary_id(),
5488            old_position: 0, // TODO: Get actual old position
5489            new_position: 0,
5490            old_anchor: None, // TODO: Get actual old anchor
5491            new_anchor: None,
5492            old_sticky_column: 0,
5493            new_sticky_column: 0,
5494        });
5495
5496        let events = editor.action_to_events(Action::SelectRight);
5497        assert!(events.is_some());
5498
5499        let events = events.unwrap();
5500        assert_eq!(events.len(), 1);
5501
5502        match &events[0] {
5503            Event::MoveCursor {
5504                new_position,
5505                new_anchor,
5506                ..
5507            } => {
5508                assert_eq!(*new_position, 1); // Moved to position 1
5509                assert_eq!(*new_anchor, Some(0)); // Anchor at start
5510            }
5511            _ => panic!("Expected MoveCursor event"),
5512        }
5513    }
5514
5515    #[test]
5516    fn test_action_to_events_select_all() {
5517        let config = Config::default();
5518        let (dir_context, _temp) = test_dir_context();
5519        let mut editor = Editor::new(
5520            config,
5521            80,
5522            24,
5523            dir_context,
5524            crate::view::color_support::ColorCapability::TrueColor,
5525        )
5526        .unwrap();
5527
5528        // Insert some text first
5529        let state = editor.active_state_mut();
5530        state.apply(&Event::Insert {
5531            position: 0,
5532            text: "hello world".to_string(),
5533            cursor_id: state.cursors.primary_id(),
5534        });
5535
5536        let events = editor.action_to_events(Action::SelectAll);
5537        assert!(events.is_some());
5538
5539        let events = events.unwrap();
5540        assert_eq!(events.len(), 1);
5541
5542        match &events[0] {
5543            Event::MoveCursor {
5544                new_position,
5545                new_anchor,
5546                ..
5547            } => {
5548                assert_eq!(*new_position, 11); // At end of buffer
5549                assert_eq!(*new_anchor, Some(0)); // Anchor at start
5550            }
5551            _ => panic!("Expected MoveCursor event"),
5552        }
5553    }
5554
5555    #[test]
5556    fn test_action_to_events_document_nav() {
5557        let config = Config::default();
5558        let (dir_context, _temp) = test_dir_context();
5559        let mut editor = Editor::new(
5560            config,
5561            80,
5562            24,
5563            dir_context,
5564            crate::view::color_support::ColorCapability::TrueColor,
5565        )
5566        .unwrap();
5567
5568        // Insert multi-line text
5569        let state = editor.active_state_mut();
5570        state.apply(&Event::Insert {
5571            position: 0,
5572            text: "line1\nline2\nline3".to_string(),
5573            cursor_id: state.cursors.primary_id(),
5574        });
5575
5576        // Test MoveDocumentStart
5577        let events = editor.action_to_events(Action::MoveDocumentStart);
5578        assert!(events.is_some());
5579        let events = events.unwrap();
5580        match &events[0] {
5581            Event::MoveCursor { new_position, .. } => {
5582                assert_eq!(*new_position, 0);
5583            }
5584            _ => panic!("Expected MoveCursor event"),
5585        }
5586
5587        // Test MoveDocumentEnd
5588        let events = editor.action_to_events(Action::MoveDocumentEnd);
5589        assert!(events.is_some());
5590        let events = events.unwrap();
5591        match &events[0] {
5592            Event::MoveCursor { new_position, .. } => {
5593                assert_eq!(*new_position, 17); // End of buffer
5594            }
5595            _ => panic!("Expected MoveCursor event"),
5596        }
5597    }
5598
5599    #[test]
5600    fn test_action_to_events_remove_secondary_cursors() {
5601        use crate::model::event::CursorId;
5602
5603        let config = Config::default();
5604        let (dir_context, _temp) = test_dir_context();
5605        let mut editor = Editor::new(
5606            config,
5607            80,
5608            24,
5609            dir_context,
5610            crate::view::color_support::ColorCapability::TrueColor,
5611        )
5612        .unwrap();
5613
5614        // Insert some text first to have positions to place cursors
5615        {
5616            let state = editor.active_state_mut();
5617            state.apply(&Event::Insert {
5618                position: 0,
5619                text: "hello world test".to_string(),
5620                cursor_id: state.cursors.primary_id(),
5621            });
5622        }
5623
5624        // Add secondary cursors at different positions to avoid normalization merging
5625        {
5626            let state = editor.active_state_mut();
5627            state.apply(&Event::AddCursor {
5628                cursor_id: CursorId(1),
5629                position: 5,
5630                anchor: None,
5631            });
5632            state.apply(&Event::AddCursor {
5633                cursor_id: CursorId(2),
5634                position: 10,
5635                anchor: None,
5636            });
5637
5638            assert_eq!(state.cursors.count(), 3);
5639        }
5640
5641        // Find the first cursor ID (the one that will be kept)
5642        let first_id = editor
5643            .active_state()
5644            .cursors
5645            .iter()
5646            .map(|(id, _)| id)
5647            .min_by_key(|id| id.0)
5648            .expect("Should have at least one cursor");
5649
5650        // RemoveSecondaryCursors should generate RemoveCursor events
5651        let events = editor.action_to_events(Action::RemoveSecondaryCursors);
5652        assert!(events.is_some());
5653
5654        let events = events.unwrap();
5655        // Should have RemoveCursor events for the two secondary cursors
5656        // Plus ClearAnchor events for all cursors (to clear Emacs mark mode)
5657        let remove_cursor_events: Vec<_> = events
5658            .iter()
5659            .filter_map(|e| match e {
5660                Event::RemoveCursor { cursor_id, .. } => Some(*cursor_id),
5661                _ => None,
5662            })
5663            .collect();
5664
5665        // Should have 2 RemoveCursor events (one for each secondary cursor)
5666        assert_eq!(remove_cursor_events.len(), 2);
5667
5668        for cursor_id in &remove_cursor_events {
5669            // Should not be the first cursor (the one we're keeping)
5670            assert_ne!(*cursor_id, first_id);
5671        }
5672    }
5673
5674    #[test]
5675    fn test_action_to_events_scroll() {
5676        let config = Config::default();
5677        let (dir_context, _temp) = test_dir_context();
5678        let mut editor = Editor::new(
5679            config,
5680            80,
5681            24,
5682            dir_context,
5683            crate::view::color_support::ColorCapability::TrueColor,
5684        )
5685        .unwrap();
5686
5687        // Test ScrollUp
5688        let events = editor.action_to_events(Action::ScrollUp);
5689        assert!(events.is_some());
5690        let events = events.unwrap();
5691        assert_eq!(events.len(), 1);
5692        match &events[0] {
5693            Event::Scroll { line_offset } => {
5694                assert_eq!(*line_offset, -1);
5695            }
5696            _ => panic!("Expected Scroll event"),
5697        }
5698
5699        // Test ScrollDown
5700        let events = editor.action_to_events(Action::ScrollDown);
5701        assert!(events.is_some());
5702        let events = events.unwrap();
5703        assert_eq!(events.len(), 1);
5704        match &events[0] {
5705            Event::Scroll { line_offset } => {
5706                assert_eq!(*line_offset, 1);
5707            }
5708            _ => panic!("Expected Scroll event"),
5709        }
5710    }
5711
5712    #[test]
5713    fn test_action_to_events_none() {
5714        let config = Config::default();
5715        let (dir_context, _temp) = test_dir_context();
5716        let mut editor = Editor::new(
5717            config,
5718            80,
5719            24,
5720            dir_context,
5721            crate::view::color_support::ColorCapability::TrueColor,
5722        )
5723        .unwrap();
5724
5725        // None action should return None
5726        let events = editor.action_to_events(Action::None);
5727        assert!(events.is_none());
5728    }
5729
5730    #[test]
5731    fn test_lsp_incremental_insert_generates_correct_range() {
5732        // Test that insert events generate correct incremental LSP changes
5733        // with zero-width ranges at the insertion point
5734        use crate::model::buffer::Buffer;
5735
5736        let buffer = Buffer::from_str_test("hello\nworld");
5737
5738        // Insert "NEW" at position 0 (before "hello")
5739        // Expected LSP range: line 0, char 0 to line 0, char 0 (zero-width)
5740        let position = 0;
5741        let (line, character) = buffer.position_to_lsp_position(position);
5742
5743        assert_eq!(line, 0, "Insertion at start should be line 0");
5744        assert_eq!(character, 0, "Insertion at start should be char 0");
5745
5746        // Create the range as we do in notify_lsp_change
5747        let lsp_pos = Position::new(line as u32, character as u32);
5748        let lsp_range = LspRange::new(lsp_pos, lsp_pos);
5749
5750        assert_eq!(lsp_range.start.line, 0);
5751        assert_eq!(lsp_range.start.character, 0);
5752        assert_eq!(lsp_range.end.line, 0);
5753        assert_eq!(lsp_range.end.character, 0);
5754        assert_eq!(
5755            lsp_range.start, lsp_range.end,
5756            "Insert should have zero-width range"
5757        );
5758
5759        // Test insertion at middle of first line (position 3, after "hel")
5760        let position = 3;
5761        let (line, character) = buffer.position_to_lsp_position(position);
5762
5763        assert_eq!(line, 0);
5764        assert_eq!(character, 3);
5765
5766        // Test insertion at start of second line (position 6, after "hello\n")
5767        let position = 6;
5768        let (line, character) = buffer.position_to_lsp_position(position);
5769
5770        assert_eq!(line, 1, "Position after newline should be line 1");
5771        assert_eq!(character, 0, "Position at start of line 2 should be char 0");
5772    }
5773
5774    #[test]
5775    fn test_lsp_incremental_delete_generates_correct_range() {
5776        // Test that delete events generate correct incremental LSP changes
5777        // with proper start/end ranges
5778        use crate::model::buffer::Buffer;
5779
5780        let buffer = Buffer::from_str_test("hello\nworld");
5781
5782        // Delete "ello" (positions 1-5 on line 0)
5783        let range_start = 1;
5784        let range_end = 5;
5785
5786        let (start_line, start_char) = buffer.position_to_lsp_position(range_start);
5787        let (end_line, end_char) = buffer.position_to_lsp_position(range_end);
5788
5789        assert_eq!(start_line, 0);
5790        assert_eq!(start_char, 1);
5791        assert_eq!(end_line, 0);
5792        assert_eq!(end_char, 5);
5793
5794        let lsp_range = LspRange::new(
5795            Position::new(start_line as u32, start_char as u32),
5796            Position::new(end_line as u32, end_char as u32),
5797        );
5798
5799        assert_eq!(lsp_range.start.line, 0);
5800        assert_eq!(lsp_range.start.character, 1);
5801        assert_eq!(lsp_range.end.line, 0);
5802        assert_eq!(lsp_range.end.character, 5);
5803        assert_ne!(
5804            lsp_range.start, lsp_range.end,
5805            "Delete should have non-zero range"
5806        );
5807
5808        // Test deletion across lines (delete "o\nw" - positions 4-8)
5809        let range_start = 4;
5810        let range_end = 8;
5811
5812        let (start_line, start_char) = buffer.position_to_lsp_position(range_start);
5813        let (end_line, end_char) = buffer.position_to_lsp_position(range_end);
5814
5815        assert_eq!(start_line, 0, "Delete start on line 0");
5816        assert_eq!(start_char, 4, "Delete start at char 4");
5817        assert_eq!(end_line, 1, "Delete end on line 1");
5818        assert_eq!(end_char, 2, "Delete end at char 2 of line 1");
5819    }
5820
5821    #[test]
5822    fn test_lsp_incremental_utf16_encoding() {
5823        // Test that position_to_lsp_position correctly handles UTF-16 encoding
5824        // LSP uses UTF-16 code units, not byte positions
5825        use crate::model::buffer::Buffer;
5826
5827        // Test with emoji (4 bytes in UTF-8, 2 code units in UTF-16)
5828        let buffer = Buffer::from_str_test("😀hello");
5829
5830        // Position 4 is after the emoji (4 bytes)
5831        let (line, character) = buffer.position_to_lsp_position(4);
5832
5833        assert_eq!(line, 0);
5834        assert_eq!(character, 2, "Emoji should count as 2 UTF-16 code units");
5835
5836        // Position 9 is after "😀hell" (4 bytes emoji + 5 bytes text)
5837        let (line, character) = buffer.position_to_lsp_position(9);
5838
5839        assert_eq!(line, 0);
5840        assert_eq!(
5841            character, 7,
5842            "Should be 2 (emoji) + 5 (text) = 7 UTF-16 code units"
5843        );
5844
5845        // Test with multi-byte character (é is 2 bytes in UTF-8, 1 code unit in UTF-16)
5846        let buffer = Buffer::from_str_test("café");
5847
5848        // Position 3 is after "caf" (3 bytes)
5849        let (line, character) = buffer.position_to_lsp_position(3);
5850
5851        assert_eq!(line, 0);
5852        assert_eq!(character, 3);
5853
5854        // Position 5 is after "café" (3 + 2 bytes)
5855        let (line, character) = buffer.position_to_lsp_position(5);
5856
5857        assert_eq!(line, 0);
5858        assert_eq!(character, 4, "é should count as 1 UTF-16 code unit");
5859    }
5860
5861    #[test]
5862    fn test_lsp_content_change_event_structure() {
5863        // Test that we can create TextDocumentContentChangeEvent for incremental updates
5864
5865        // Incremental insert
5866        let insert_change = TextDocumentContentChangeEvent {
5867            range: Some(LspRange::new(Position::new(0, 5), Position::new(0, 5))),
5868            range_length: None,
5869            text: "NEW".to_string(),
5870        };
5871
5872        assert!(insert_change.range.is_some());
5873        assert_eq!(insert_change.text, "NEW");
5874        let range = insert_change.range.unwrap();
5875        assert_eq!(
5876            range.start, range.end,
5877            "Insert should have zero-width range"
5878        );
5879
5880        // Incremental delete
5881        let delete_change = TextDocumentContentChangeEvent {
5882            range: Some(LspRange::new(Position::new(0, 2), Position::new(0, 7))),
5883            range_length: None,
5884            text: String::new(),
5885        };
5886
5887        assert!(delete_change.range.is_some());
5888        assert_eq!(delete_change.text, "");
5889        let range = delete_change.range.unwrap();
5890        assert_ne!(range.start, range.end, "Delete should have non-zero range");
5891        assert_eq!(range.start.line, 0);
5892        assert_eq!(range.start.character, 2);
5893        assert_eq!(range.end.line, 0);
5894        assert_eq!(range.end.character, 7);
5895    }
5896
5897    #[test]
5898    fn test_goto_matching_bracket_forward() {
5899        let config = Config::default();
5900        let (dir_context, _temp) = test_dir_context();
5901        let mut editor = Editor::new(
5902            config,
5903            80,
5904            24,
5905            dir_context,
5906            crate::view::color_support::ColorCapability::TrueColor,
5907        )
5908        .unwrap();
5909
5910        // Insert text with brackets
5911        let state = editor.active_state_mut();
5912        state.apply(&Event::Insert {
5913            position: 0,
5914            text: "fn main() { let x = (1 + 2); }".to_string(),
5915            cursor_id: state.cursors.primary_id(),
5916        });
5917
5918        // Move cursor to opening brace '{'
5919        state.apply(&Event::MoveCursor {
5920            cursor_id: state.cursors.primary_id(),
5921            old_position: 31,
5922            new_position: 10,
5923            old_anchor: None,
5924            new_anchor: None,
5925            old_sticky_column: 0,
5926            new_sticky_column: 0,
5927        });
5928
5929        assert_eq!(state.cursors.primary().position, 10);
5930
5931        // Call goto_matching_bracket
5932        editor.goto_matching_bracket();
5933
5934        // Should move to closing brace '}' at position 29
5935        // "fn main() { let x = (1 + 2); }"
5936        //            ^                   ^
5937        //           10                  29
5938        assert_eq!(editor.active_state().cursors.primary().position, 29);
5939    }
5940
5941    #[test]
5942    fn test_goto_matching_bracket_backward() {
5943        let config = Config::default();
5944        let (dir_context, _temp) = test_dir_context();
5945        let mut editor = Editor::new(
5946            config,
5947            80,
5948            24,
5949            dir_context,
5950            crate::view::color_support::ColorCapability::TrueColor,
5951        )
5952        .unwrap();
5953
5954        // Insert text with brackets
5955        let state = editor.active_state_mut();
5956        state.apply(&Event::Insert {
5957            position: 0,
5958            text: "fn main() { let x = (1 + 2); }".to_string(),
5959            cursor_id: state.cursors.primary_id(),
5960        });
5961
5962        // Move cursor to closing paren ')'
5963        state.apply(&Event::MoveCursor {
5964            cursor_id: state.cursors.primary_id(),
5965            old_position: 31,
5966            new_position: 26,
5967            old_anchor: None,
5968            new_anchor: None,
5969            old_sticky_column: 0,
5970            new_sticky_column: 0,
5971        });
5972
5973        // Call goto_matching_bracket
5974        editor.goto_matching_bracket();
5975
5976        // Should move to opening paren '('
5977        assert_eq!(editor.active_state().cursors.primary().position, 20);
5978    }
5979
5980    #[test]
5981    fn test_goto_matching_bracket_nested() {
5982        let config = Config::default();
5983        let (dir_context, _temp) = test_dir_context();
5984        let mut editor = Editor::new(
5985            config,
5986            80,
5987            24,
5988            dir_context,
5989            crate::view::color_support::ColorCapability::TrueColor,
5990        )
5991        .unwrap();
5992
5993        // Insert text with nested brackets
5994        let state = editor.active_state_mut();
5995        state.apply(&Event::Insert {
5996            position: 0,
5997            text: "{a{b{c}d}e}".to_string(),
5998            cursor_id: state.cursors.primary_id(),
5999        });
6000
6001        // Move cursor to first '{'
6002        state.apply(&Event::MoveCursor {
6003            cursor_id: state.cursors.primary_id(),
6004            old_position: 11,
6005            new_position: 0,
6006            old_anchor: None,
6007            new_anchor: None,
6008            old_sticky_column: 0,
6009            new_sticky_column: 0,
6010        });
6011
6012        // Call goto_matching_bracket
6013        editor.goto_matching_bracket();
6014
6015        // Should jump to last '}'
6016        assert_eq!(editor.active_state().cursors.primary().position, 10);
6017    }
6018
6019    #[test]
6020    fn test_search_case_sensitive() {
6021        let config = Config::default();
6022        let (dir_context, _temp) = test_dir_context();
6023        let mut editor = Editor::new(
6024            config,
6025            80,
6026            24,
6027            dir_context,
6028            crate::view::color_support::ColorCapability::TrueColor,
6029        )
6030        .unwrap();
6031
6032        // Insert text
6033        let state = editor.active_state_mut();
6034        state.apply(&Event::Insert {
6035            position: 0,
6036            text: "Hello hello HELLO".to_string(),
6037            cursor_id: state.cursors.primary_id(),
6038        });
6039
6040        // Test case-insensitive search (default)
6041        editor.search_case_sensitive = false;
6042        editor.perform_search("hello");
6043
6044        let search_state = editor.search_state.as_ref().unwrap();
6045        assert_eq!(
6046            search_state.matches.len(),
6047            3,
6048            "Should find all 3 matches case-insensitively"
6049        );
6050
6051        // Test case-sensitive search
6052        editor.search_case_sensitive = true;
6053        editor.perform_search("hello");
6054
6055        let search_state = editor.search_state.as_ref().unwrap();
6056        assert_eq!(
6057            search_state.matches.len(),
6058            1,
6059            "Should find only 1 exact match"
6060        );
6061        assert_eq!(
6062            search_state.matches[0], 6,
6063            "Should find 'hello' at position 6"
6064        );
6065    }
6066
6067    #[test]
6068    fn test_search_whole_word() {
6069        let config = Config::default();
6070        let (dir_context, _temp) = test_dir_context();
6071        let mut editor = Editor::new(
6072            config,
6073            80,
6074            24,
6075            dir_context,
6076            crate::view::color_support::ColorCapability::TrueColor,
6077        )
6078        .unwrap();
6079
6080        // Insert text
6081        let state = editor.active_state_mut();
6082        state.apply(&Event::Insert {
6083            position: 0,
6084            text: "test testing tested attest test".to_string(),
6085            cursor_id: state.cursors.primary_id(),
6086        });
6087
6088        // Test partial word match (default)
6089        editor.search_whole_word = false;
6090        editor.search_case_sensitive = true;
6091        editor.perform_search("test");
6092
6093        let search_state = editor.search_state.as_ref().unwrap();
6094        assert_eq!(
6095            search_state.matches.len(),
6096            5,
6097            "Should find 'test' in all occurrences"
6098        );
6099
6100        // Test whole word match
6101        editor.search_whole_word = true;
6102        editor.perform_search("test");
6103
6104        let search_state = editor.search_state.as_ref().unwrap();
6105        assert_eq!(
6106            search_state.matches.len(),
6107            2,
6108            "Should find only whole word 'test'"
6109        );
6110        assert_eq!(search_state.matches[0], 0, "First match at position 0");
6111        assert_eq!(search_state.matches[1], 27, "Second match at position 27");
6112    }
6113
6114    #[test]
6115    fn test_bookmarks() {
6116        let config = Config::default();
6117        let (dir_context, _temp) = test_dir_context();
6118        let mut editor = Editor::new(
6119            config,
6120            80,
6121            24,
6122            dir_context,
6123            crate::view::color_support::ColorCapability::TrueColor,
6124        )
6125        .unwrap();
6126
6127        // Insert text
6128        let state = editor.active_state_mut();
6129        state.apply(&Event::Insert {
6130            position: 0,
6131            text: "Line 1\nLine 2\nLine 3".to_string(),
6132            cursor_id: state.cursors.primary_id(),
6133        });
6134
6135        // Move cursor to line 2 start (position 7)
6136        state.apply(&Event::MoveCursor {
6137            cursor_id: state.cursors.primary_id(),
6138            old_position: 21,
6139            new_position: 7,
6140            old_anchor: None,
6141            new_anchor: None,
6142            old_sticky_column: 0,
6143            new_sticky_column: 0,
6144        });
6145
6146        // Set bookmark '1'
6147        editor.set_bookmark('1');
6148        assert!(editor.bookmarks.contains_key(&'1'));
6149        assert_eq!(editor.bookmarks.get(&'1').unwrap().position, 7);
6150
6151        // Move cursor elsewhere
6152        let state = editor.active_state_mut();
6153        state.apply(&Event::MoveCursor {
6154            cursor_id: state.cursors.primary_id(),
6155            old_position: 7,
6156            new_position: 14,
6157            old_anchor: None,
6158            new_anchor: None,
6159            old_sticky_column: 0,
6160            new_sticky_column: 0,
6161        });
6162
6163        // Jump back to bookmark
6164        editor.jump_to_bookmark('1');
6165        assert_eq!(editor.active_state().cursors.primary().position, 7);
6166
6167        // Clear bookmark
6168        editor.clear_bookmark('1');
6169        assert!(!editor.bookmarks.contains_key(&'1'));
6170    }
6171
6172    #[test]
6173    fn test_action_enum_new_variants() {
6174        // Test that new actions can be parsed from strings
6175        use serde_json::json;
6176
6177        let args = HashMap::new();
6178        assert_eq!(
6179            Action::from_str("smart_home", &args),
6180            Some(Action::SmartHome)
6181        );
6182        assert_eq!(
6183            Action::from_str("dedent_selection", &args),
6184            Some(Action::DedentSelection)
6185        );
6186        assert_eq!(
6187            Action::from_str("toggle_comment", &args),
6188            Some(Action::ToggleComment)
6189        );
6190        assert_eq!(
6191            Action::from_str("goto_matching_bracket", &args),
6192            Some(Action::GoToMatchingBracket)
6193        );
6194        assert_eq!(
6195            Action::from_str("list_bookmarks", &args),
6196            Some(Action::ListBookmarks)
6197        );
6198        assert_eq!(
6199            Action::from_str("toggle_search_case_sensitive", &args),
6200            Some(Action::ToggleSearchCaseSensitive)
6201        );
6202        assert_eq!(
6203            Action::from_str("toggle_search_whole_word", &args),
6204            Some(Action::ToggleSearchWholeWord)
6205        );
6206
6207        // Test bookmark actions with arguments
6208        let mut args_with_char = HashMap::new();
6209        args_with_char.insert("char".to_string(), json!("5"));
6210        assert_eq!(
6211            Action::from_str("set_bookmark", &args_with_char),
6212            Some(Action::SetBookmark('5'))
6213        );
6214        assert_eq!(
6215            Action::from_str("jump_to_bookmark", &args_with_char),
6216            Some(Action::JumpToBookmark('5'))
6217        );
6218        assert_eq!(
6219            Action::from_str("clear_bookmark", &args_with_char),
6220            Some(Action::ClearBookmark('5'))
6221        );
6222    }
6223
6224    #[test]
6225    fn test_keybinding_new_defaults() {
6226        use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
6227
6228        // Test that new keybindings are properly registered in the "default" keymap
6229        // Note: We explicitly use "default" keymap, not Config::default() which uses
6230        // platform-specific keymaps (e.g., "macos" on macOS has different bindings)
6231        let mut config = Config::default();
6232        config.active_keybinding_map = crate::config::KeybindingMapName("default".to_string());
6233        let resolver = KeybindingResolver::new(&config);
6234
6235        // Test Ctrl+/ is ToggleComment (not CommandPalette)
6236        let event = KeyEvent {
6237            code: KeyCode::Char('/'),
6238            modifiers: KeyModifiers::CONTROL,
6239            kind: KeyEventKind::Press,
6240            state: KeyEventState::NONE,
6241        };
6242        let action = resolver.resolve(&event, KeyContext::Normal);
6243        assert_eq!(action, Action::ToggleComment);
6244
6245        // Test Ctrl+] is GoToMatchingBracket
6246        let event = KeyEvent {
6247            code: KeyCode::Char(']'),
6248            modifiers: KeyModifiers::CONTROL,
6249            kind: KeyEventKind::Press,
6250            state: KeyEventState::NONE,
6251        };
6252        let action = resolver.resolve(&event, KeyContext::Normal);
6253        assert_eq!(action, Action::GoToMatchingBracket);
6254
6255        // Test Shift+Tab is DedentSelection
6256        let event = KeyEvent {
6257            code: KeyCode::Tab,
6258            modifiers: KeyModifiers::SHIFT,
6259            kind: KeyEventKind::Press,
6260            state: KeyEventState::NONE,
6261        };
6262        let action = resolver.resolve(&event, KeyContext::Normal);
6263        assert_eq!(action, Action::DedentSelection);
6264
6265        // Test Ctrl+G is GotoLine
6266        let event = KeyEvent {
6267            code: KeyCode::Char('g'),
6268            modifiers: KeyModifiers::CONTROL,
6269            kind: KeyEventKind::Press,
6270            state: KeyEventState::NONE,
6271        };
6272        let action = resolver.resolve(&event, KeyContext::Normal);
6273        assert_eq!(action, Action::GotoLine);
6274
6275        // Test bookmark keybindings
6276        let event = KeyEvent {
6277            code: KeyCode::Char('5'),
6278            modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
6279            kind: KeyEventKind::Press,
6280            state: KeyEventState::NONE,
6281        };
6282        let action = resolver.resolve(&event, KeyContext::Normal);
6283        assert_eq!(action, Action::SetBookmark('5'));
6284
6285        let event = KeyEvent {
6286            code: KeyCode::Char('5'),
6287            modifiers: KeyModifiers::ALT,
6288            kind: KeyEventKind::Press,
6289            state: KeyEventState::NONE,
6290        };
6291        let action = resolver.resolve(&event, KeyContext::Normal);
6292        assert_eq!(action, Action::JumpToBookmark('5'));
6293    }
6294
6295    /// This test demonstrates the bug where LSP didChange notifications contain
6296    /// incorrect positions because they're calculated from the already-modified buffer.
6297    ///
6298    /// When applying LSP rename edits:
6299    /// 1. apply_events_to_buffer_as_bulk_edit() applies the edits to the buffer
6300    /// 2. Then calls notify_lsp_change() which calls collect_lsp_changes()
6301    /// 3. collect_lsp_changes() converts byte positions to LSP positions using
6302    ///    the CURRENT buffer state
6303    ///
6304    /// But the byte positions in the events are relative to the ORIGINAL buffer,
6305    /// not the modified one! This causes LSP to receive wrong positions.
6306    #[test]
6307    fn test_lsp_rename_didchange_positions_bug() {
6308        use crate::model::buffer::Buffer;
6309
6310        let config = Config::default();
6311        let (dir_context, _temp) = test_dir_context();
6312        let mut editor = Editor::new(
6313            config,
6314            80,
6315            24,
6316            dir_context,
6317            crate::view::color_support::ColorCapability::TrueColor,
6318        )
6319        .unwrap();
6320
6321        // Set buffer content: "fn foo(val: i32) {\n    val + 1\n}\n"
6322        // Line 0: positions 0-19 (includes newline)
6323        // Line 1: positions 19-31 (includes newline)
6324        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
6325        editor.active_state_mut().buffer = Buffer::from_str(initial, 1024 * 1024);
6326
6327        // Simulate LSP rename batch: rename "val" to "value" in two places
6328        // This is applied in reverse order to preserve positions:
6329        // 1. Delete "val" at position 23 (line 1, char 4), insert "value"
6330        // 2. Delete "val" at position 7 (line 0, char 7), insert "value"
6331        let cursor_id = editor.active_state().cursors.primary_id();
6332
6333        let batch = Event::Batch {
6334            events: vec![
6335                // Second occurrence first (reverse order for position preservation)
6336                Event::Delete {
6337                    range: 23..26, // "val" on line 1
6338                    deleted_text: "val".to_string(),
6339                    cursor_id,
6340                },
6341                Event::Insert {
6342                    position: 23,
6343                    text: "value".to_string(),
6344                    cursor_id,
6345                },
6346                // First occurrence second
6347                Event::Delete {
6348                    range: 7..10, // "val" on line 0
6349                    deleted_text: "val".to_string(),
6350                    cursor_id,
6351                },
6352                Event::Insert {
6353                    position: 7,
6354                    text: "value".to_string(),
6355                    cursor_id,
6356                },
6357            ],
6358            description: "LSP Rename".to_string(),
6359        };
6360
6361        // CORRECT: Calculate LSP positions BEFORE applying batch
6362        let lsp_changes_before = editor.collect_lsp_changes(&batch);
6363
6364        // Now apply the batch (this is what apply_events_to_buffer_as_bulk_edit does)
6365        editor.active_state_mut().apply(&batch);
6366
6367        // BUG DEMONSTRATION: Calculate LSP positions AFTER applying batch
6368        // This is what happens when notify_lsp_change is called after state.apply()
6369        let lsp_changes_after = editor.collect_lsp_changes(&batch);
6370
6371        // Verify buffer was correctly modified
6372        let final_content = editor.active_state().buffer.to_string().unwrap();
6373        assert_eq!(
6374            final_content, "fn foo(value: i32) {\n    value + 1\n}\n",
6375            "Buffer should have 'value' in both places"
6376        );
6377
6378        // The CORRECT positions (before applying batch):
6379        // - Delete at 23..26 should be line 1, char 4-7 (in original buffer)
6380        // - Insert at 23 should be line 1, char 4 (in original buffer)
6381        // - Delete at 7..10 should be line 0, char 7-10 (in original buffer)
6382        // - Insert at 7 should be line 0, char 7 (in original buffer)
6383        assert_eq!(lsp_changes_before.len(), 4, "Should have 4 changes");
6384
6385        let first_delete = &lsp_changes_before[0];
6386        let first_del_range = first_delete.range.unwrap();
6387        assert_eq!(
6388            first_del_range.start.line, 1,
6389            "First delete should be on line 1 (BEFORE)"
6390        );
6391        assert_eq!(
6392            first_del_range.start.character, 4,
6393            "First delete start should be at char 4 (BEFORE)"
6394        );
6395
6396        // The INCORRECT positions (after applying batch):
6397        // Since the buffer has changed, position 23 now points to different text!
6398        // Original buffer position 23 was start of "val" on line 1
6399        // But after rename, the buffer is "fn foo(value: i32) {\n    value + 1\n}\n"
6400        // Position 23 in new buffer is 'l' in "value" (line 1, offset into "value")
6401        assert_eq!(lsp_changes_after.len(), 4, "Should have 4 changes");
6402
6403        let first_delete_after = &lsp_changes_after[0];
6404        let first_del_range_after = first_delete_after.range.unwrap();
6405
6406        // THIS IS THE BUG: The positions are WRONG when calculated from modified buffer
6407        // The first delete's range.end position will be wrong because the buffer changed
6408        eprintln!("BEFORE modification:");
6409        eprintln!(
6410            "  Delete at line {}, char {}-{}",
6411            first_del_range.start.line,
6412            first_del_range.start.character,
6413            first_del_range.end.character
6414        );
6415        eprintln!("AFTER modification:");
6416        eprintln!(
6417            "  Delete at line {}, char {}-{}",
6418            first_del_range_after.start.line,
6419            first_del_range_after.start.character,
6420            first_del_range_after.end.character
6421        );
6422
6423        // The bug causes the position calculation to be wrong.
6424        // After applying the batch, position 23..26 in the modified buffer
6425        // is different from what it was in the original buffer.
6426        //
6427        // Modified buffer: "fn foo(value: i32) {\n    value + 1\n}\n"
6428        // Position 23 = 'l' in second "value"
6429        // Position 26 = 'e' in second "value"
6430        // This maps to line 1, char 2-5 (wrong!)
6431        //
6432        // Original buffer: "fn foo(val: i32) {\n    val + 1\n}\n"
6433        // Position 23 = 'v' in "val"
6434        // Position 26 = ' ' after "val"
6435        // This maps to line 1, char 4-7 (correct!)
6436
6437        // The positions are different! This demonstrates the bug.
6438        // Note: Due to how the batch is applied (all operations at once),
6439        // the exact positions may vary, but they will definitely be wrong.
6440        assert_ne!(
6441            first_del_range_after.end.character, first_del_range.end.character,
6442            "BUG CONFIRMED: LSP positions are different when calculated after buffer modification!"
6443        );
6444
6445        eprintln!("\n=== BUG DEMONSTRATED ===");
6446        eprintln!("When collect_lsp_changes() is called AFTER buffer modification,");
6447        eprintln!("the positions are WRONG because they're calculated from the");
6448        eprintln!("modified buffer, not the original buffer.");
6449        eprintln!("This causes the second rename to fail with 'content modified' error.");
6450        eprintln!("========================\n");
6451    }
6452
6453    #[test]
6454    fn test_lsp_rename_preserves_cursor_position() {
6455        use crate::model::buffer::Buffer;
6456
6457        let config = Config::default();
6458        let (dir_context, _temp) = test_dir_context();
6459        let mut editor = Editor::new(
6460            config,
6461            80,
6462            24,
6463            dir_context,
6464            crate::view::color_support::ColorCapability::TrueColor,
6465        )
6466        .unwrap();
6467
6468        // Set buffer content: "fn foo(val: i32) {\n    val + 1\n}\n"
6469        // Line 0: positions 0-19 (includes newline)
6470        // Line 1: positions 19-31 (includes newline)
6471        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
6472        editor.active_state_mut().buffer = Buffer::from_str(initial, 1024 * 1024);
6473
6474        // Position cursor at the second "val" (position 23 = 'v' of "val" on line 1)
6475        let original_cursor_pos = 23;
6476        editor.active_state_mut().cursors.primary_mut().position = original_cursor_pos;
6477
6478        // Verify cursor is at the right position
6479        let buffer_text = editor.active_state().buffer.to_string().unwrap();
6480        let text_at_cursor = buffer_text[original_cursor_pos..original_cursor_pos + 3].to_string();
6481        assert_eq!(text_at_cursor, "val", "Cursor should be at 'val'");
6482
6483        // Simulate LSP rename batch: rename "val" to "value" in two places
6484        // Applied in reverse order (from end of file to start)
6485        let cursor_id = editor.active_state().cursors.primary_id();
6486        let buffer_id = editor.active_buffer();
6487
6488        let events = vec![
6489            // Second occurrence first (at position 23, line 1)
6490            Event::Delete {
6491                range: 23..26, // "val" on line 1
6492                deleted_text: "val".to_string(),
6493                cursor_id,
6494            },
6495            Event::Insert {
6496                position: 23,
6497                text: "value".to_string(),
6498                cursor_id,
6499            },
6500            // First occurrence second (at position 7, line 0)
6501            Event::Delete {
6502                range: 7..10, // "val" on line 0
6503                deleted_text: "val".to_string(),
6504                cursor_id,
6505            },
6506            Event::Insert {
6507                position: 7,
6508                text: "value".to_string(),
6509                cursor_id,
6510            },
6511        ];
6512
6513        // Apply the rename using bulk edit (this should preserve cursor position)
6514        editor
6515            .apply_events_to_buffer_as_bulk_edit(buffer_id, events, "LSP Rename".to_string())
6516            .unwrap();
6517
6518        // Verify buffer was correctly modified
6519        let final_content = editor.active_state().buffer.to_string().unwrap();
6520        assert_eq!(
6521            final_content, "fn foo(value: i32) {\n    value + 1\n}\n",
6522            "Buffer should have 'value' in both places"
6523        );
6524
6525        // The cursor was originally at position 23 (start of "val" on line 1).
6526        // After renaming:
6527        // - The first "val" (at pos 7-10) was replaced with "value" (5 chars instead of 3)
6528        //   This adds 2 bytes before the cursor.
6529        // - The second "val" at the cursor position was replaced.
6530        //
6531        // Expected cursor position: 23 + 2 = 25 (start of "value" on line 1)
6532        let final_cursor_pos = editor.active_state().cursors.primary().position;
6533        let expected_cursor_pos = 25; // original 23 + 2 (delta from first rename)
6534
6535        assert_eq!(
6536            final_cursor_pos, expected_cursor_pos,
6537            "Cursor should be at position {} (start of 'value' on line 1), but was at {}. \
6538             Original pos: {}, expected adjustment: +2 for first rename",
6539            expected_cursor_pos, final_cursor_pos, original_cursor_pos
6540        );
6541
6542        // Verify cursor is at start of the renamed symbol
6543        let text_at_new_cursor = &final_content[final_cursor_pos..final_cursor_pos + 5];
6544        assert_eq!(
6545            text_at_new_cursor, "value",
6546            "Cursor should be at the start of 'value' after rename"
6547        );
6548    }
6549
6550    #[test]
6551    fn test_lsp_rename_twice_consecutive() {
6552        // This test reproduces the bug where the second rename fails because
6553        // LSP positions are calculated incorrectly after the first rename.
6554        use crate::model::buffer::Buffer;
6555
6556        let config = Config::default();
6557        let (dir_context, _temp) = test_dir_context();
6558        let mut editor = Editor::new(
6559            config,
6560            80,
6561            24,
6562            dir_context,
6563            crate::view::color_support::ColorCapability::TrueColor,
6564        )
6565        .unwrap();
6566
6567        // Initial content: "fn foo(val: i32) {\n    val + 1\n}\n"
6568        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
6569        editor.active_state_mut().buffer = Buffer::from_str(initial, 1024 * 1024);
6570
6571        let cursor_id = editor.active_state().cursors.primary_id();
6572        let buffer_id = editor.active_buffer();
6573
6574        // === FIRST RENAME: "val" -> "value" ===
6575        // Create events for first rename (applied in reverse order)
6576        let events1 = vec![
6577            // Second occurrence first (at position 23, line 1, char 4)
6578            Event::Delete {
6579                range: 23..26,
6580                deleted_text: "val".to_string(),
6581                cursor_id,
6582            },
6583            Event::Insert {
6584                position: 23,
6585                text: "value".to_string(),
6586                cursor_id,
6587            },
6588            // First occurrence (at position 7, line 0, char 7)
6589            Event::Delete {
6590                range: 7..10,
6591                deleted_text: "val".to_string(),
6592                cursor_id,
6593            },
6594            Event::Insert {
6595                position: 7,
6596                text: "value".to_string(),
6597                cursor_id,
6598            },
6599        ];
6600
6601        // Create batch for LSP change verification
6602        let batch1 = Event::Batch {
6603            events: events1.clone(),
6604            description: "LSP Rename 1".to_string(),
6605        };
6606
6607        // Collect LSP changes BEFORE applying (this is the fix)
6608        let lsp_changes1 = editor.collect_lsp_changes(&batch1);
6609
6610        // Verify first rename LSP positions are correct
6611        assert_eq!(
6612            lsp_changes1.len(),
6613            4,
6614            "First rename should have 4 LSP changes"
6615        );
6616
6617        // First delete should be at line 1, char 4-7 (second "val")
6618        let first_del = &lsp_changes1[0];
6619        let first_del_range = first_del.range.unwrap();
6620        assert_eq!(first_del_range.start.line, 1, "First delete line");
6621        assert_eq!(
6622            first_del_range.start.character, 4,
6623            "First delete start char"
6624        );
6625        assert_eq!(first_del_range.end.character, 7, "First delete end char");
6626
6627        // Apply first rename using bulk edit
6628        editor
6629            .apply_events_to_buffer_as_bulk_edit(buffer_id, events1, "LSP Rename 1".to_string())
6630            .unwrap();
6631
6632        // Verify buffer after first rename
6633        let after_first = editor.active_state().buffer.to_string().unwrap();
6634        assert_eq!(
6635            after_first, "fn foo(value: i32) {\n    value + 1\n}\n",
6636            "After first rename"
6637        );
6638
6639        // === SECOND RENAME: "value" -> "x" ===
6640        // Now "value" is at:
6641        // - Line 0, char 7-12 (positions 7-12 in buffer)
6642        // - Line 1, char 4-9 (positions 25-30 in buffer, because line 0 grew by 2)
6643        //
6644        // Buffer: "fn foo(value: i32) {\n    value + 1\n}\n"
6645        //          0123456789...
6646
6647        // Create events for second rename
6648        let events2 = vec![
6649            // Second occurrence first (at position 25, line 1, char 4)
6650            Event::Delete {
6651                range: 25..30,
6652                deleted_text: "value".to_string(),
6653                cursor_id,
6654            },
6655            Event::Insert {
6656                position: 25,
6657                text: "x".to_string(),
6658                cursor_id,
6659            },
6660            // First occurrence (at position 7, line 0, char 7)
6661            Event::Delete {
6662                range: 7..12,
6663                deleted_text: "value".to_string(),
6664                cursor_id,
6665            },
6666            Event::Insert {
6667                position: 7,
6668                text: "x".to_string(),
6669                cursor_id,
6670            },
6671        ];
6672
6673        // Create batch for LSP change verification
6674        let batch2 = Event::Batch {
6675            events: events2.clone(),
6676            description: "LSP Rename 2".to_string(),
6677        };
6678
6679        // Collect LSP changes BEFORE applying (this is the fix)
6680        let lsp_changes2 = editor.collect_lsp_changes(&batch2);
6681
6682        // Verify second rename LSP positions are correct
6683        // THIS IS WHERE THE BUG WOULD MANIFEST - if positions are wrong,
6684        // the LSP server would report "No references found at position"
6685        assert_eq!(
6686            lsp_changes2.len(),
6687            4,
6688            "Second rename should have 4 LSP changes"
6689        );
6690
6691        // First delete should be at line 1, char 4-9 (second "value")
6692        let second_first_del = &lsp_changes2[0];
6693        let second_first_del_range = second_first_del.range.unwrap();
6694        assert_eq!(
6695            second_first_del_range.start.line, 1,
6696            "Second rename first delete should be on line 1"
6697        );
6698        assert_eq!(
6699            second_first_del_range.start.character, 4,
6700            "Second rename first delete start should be at char 4"
6701        );
6702        assert_eq!(
6703            second_first_del_range.end.character, 9,
6704            "Second rename first delete end should be at char 9 (4 + 5 for 'value')"
6705        );
6706
6707        // Third delete should be at line 0, char 7-12 (first "value")
6708        let second_third_del = &lsp_changes2[2];
6709        let second_third_del_range = second_third_del.range.unwrap();
6710        assert_eq!(
6711            second_third_del_range.start.line, 0,
6712            "Second rename third delete should be on line 0"
6713        );
6714        assert_eq!(
6715            second_third_del_range.start.character, 7,
6716            "Second rename third delete start should be at char 7"
6717        );
6718        assert_eq!(
6719            second_third_del_range.end.character, 12,
6720            "Second rename third delete end should be at char 12 (7 + 5 for 'value')"
6721        );
6722
6723        // Apply second rename using bulk edit
6724        editor
6725            .apply_events_to_buffer_as_bulk_edit(buffer_id, events2, "LSP Rename 2".to_string())
6726            .unwrap();
6727
6728        // Verify buffer after second rename
6729        let after_second = editor.active_state().buffer.to_string().unwrap();
6730        assert_eq!(
6731            after_second, "fn foo(x: i32) {\n    x + 1\n}\n",
6732            "After second rename"
6733        );
6734    }
6735
6736    #[test]
6737    fn test_ensure_active_tab_visible_static_offset() {
6738        let config = Config::default();
6739        let (dir_context, _temp) = test_dir_context();
6740        let mut editor = Editor::new(
6741            config,
6742            80,
6743            24,
6744            dir_context,
6745            crate::view::color_support::ColorCapability::TrueColor,
6746        )
6747        .unwrap();
6748        let split_id = editor.split_manager.active_split();
6749
6750        // Create three buffers with long names to force scrolling.
6751        let buf1 = editor.new_buffer();
6752        editor
6753            .buffers
6754            .get_mut(&buf1)
6755            .unwrap()
6756            .buffer
6757            .set_file_path(std::path::PathBuf::from("aaa_long_name_01.txt"));
6758        let buf2 = editor.new_buffer();
6759        editor
6760            .buffers
6761            .get_mut(&buf2)
6762            .unwrap()
6763            .buffer
6764            .set_file_path(std::path::PathBuf::from("bbb_long_name_02.txt"));
6765        let buf3 = editor.new_buffer();
6766        editor
6767            .buffers
6768            .get_mut(&buf3)
6769            .unwrap()
6770            .buffer
6771            .set_file_path(std::path::PathBuf::from("ccc_long_name_03.txt"));
6772
6773        {
6774            let view_state = editor.split_view_states.get_mut(&split_id).unwrap();
6775            view_state.open_buffers = vec![buf1, buf2, buf3];
6776            view_state.tab_scroll_offset = 50;
6777        }
6778
6779        // Force active buffer to first tab and ensure helper brings it into view.
6780        // Note: available_width must be >= tab width (2 + name_len) for offset to be 0
6781        // Tab width = 2 + 20 (name length) = 22, so we need at least 22
6782        editor.ensure_active_tab_visible(split_id, buf1, 25);
6783        assert_eq!(
6784            editor
6785                .split_view_states
6786                .get(&split_id)
6787                .unwrap()
6788                .tab_scroll_offset,
6789            0
6790        );
6791
6792        // Now make the last tab active and ensure offset moves forward but stays bounded.
6793        editor.ensure_active_tab_visible(split_id, buf3, 25);
6794        let view_state = editor.split_view_states.get(&split_id).unwrap();
6795        assert!(view_state.tab_scroll_offset > 0);
6796        let total_width: usize = view_state
6797            .open_buffers
6798            .iter()
6799            .enumerate()
6800            .map(|(idx, id)| {
6801                let state = editor.buffers.get(id).unwrap();
6802                let name_len = state
6803                    .buffer
6804                    .file_path()
6805                    .and_then(|p| p.file_name())
6806                    .and_then(|n| n.to_str())
6807                    .map(|s| s.chars().count())
6808                    .unwrap_or(0);
6809                let tab_width = 2 + name_len;
6810                if idx < view_state.open_buffers.len() - 1 {
6811                    tab_width + 1 // separator
6812                } else {
6813                    tab_width
6814                }
6815            })
6816            .sum();
6817        assert!(view_state.tab_scroll_offset <= total_width);
6818    }
6819}