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