Skip to main content

fresh/app/
mod.rs

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