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