Skip to main content

fresh/app/
mod.rs

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