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