Skip to main content

fresh/app/
mod.rs

1mod action_events;
2mod active_focus;
3mod async_dispatch;
4mod async_messages;
5mod bookmark_actions;
6mod bookmarks;
7mod buffer_close;
8mod buffer_config_resolve;
9mod buffer_groups;
10mod buffer_management;
11mod calibration_actions;
12pub mod calibration_wizard;
13mod click_geometry;
14mod click_handlers;
15mod clipboard;
16mod composite_buffer_actions;
17mod dabbrev_actions;
18mod diagnostic_jumps;
19mod editor_accessors;
20mod editor_init;
21mod event_apply;
22pub mod event_debug;
23mod event_debug_actions;
24mod file_explorer;
25pub mod file_open;
26mod file_open_input;
27mod file_open_orchestrators;
28mod file_open_queue;
29mod file_operations;
30mod help;
31mod help_actions;
32mod hover;
33mod input;
34mod input_dispatch;
35mod input_helpers;
36pub mod keybinding_editor;
37mod keybinding_editor_actions;
38mod lifecycle;
39mod line_scan;
40mod lsp_actions;
41mod lsp_event_notify;
42mod lsp_requests;
43mod lsp_status;
44mod macro_actions;
45mod macros;
46mod menu_actions;
47mod menu_context;
48mod mouse_input;
49mod navigation;
50mod on_save_actions;
51mod orchestrator_persistence;
52mod path_utils;
53mod plugin_commands;
54mod plugin_dispatch;
55mod popup_actions;
56mod popup_dialogs;
57mod popup_overlay_actions;
58mod prompt_actions;
59mod prompt_lifecycle;
60mod recovery_actions;
61mod regex_replace;
62mod render;
63mod scan_orchestrators;
64mod scroll_sync;
65mod scrollbar_input;
66mod scrollbar_math;
67mod search_ops;
68mod search_scan;
69mod settings_actions;
70mod settings_prompts;
71mod shell_command;
72mod smart_home;
73mod split_actions;
74mod stdin_stream;
75mod tab_drag;
76mod terminal;
77mod terminal_input;
78mod terminal_mouse;
79mod text_ops;
80mod theme_inspect;
81mod toggle_actions;
82pub mod types;
83mod undo_actions;
84mod view_actions;
85mod virtual_buffers;
86pub mod warning_domains;
87pub mod window;
88mod window_actions;
89pub mod window_resources;
90pub mod workspace;
91
92use anyhow::Result as AnyhowResult;
93use rust_i18n::t;
94
95/// Shared per-tick housekeeping: process async messages, check timers, auto-save, etc.
96/// Returns true if a render is needed. The `clear_terminal` callback handles full-redraw
97/// requests (terminal clears the screen; GUI can ignore or handle differently).
98/// Used by both the terminal event loop and the GUI event loop.
99pub fn editor_tick(
100    editor: &mut Editor,
101    mut clear_terminal: impl FnMut() -> AnyhowResult<()>,
102) -> AnyhowResult<bool> {
103    let mut needs_render = false;
104
105    let async_messages = {
106        let _s = tracing::info_span!("process_async_messages").entered();
107        editor.process_async_messages()
108    };
109    if async_messages {
110        needs_render = true;
111    }
112    let pending_file_opens = {
113        let _s = tracing::info_span!("process_pending_file_opens").entered();
114        editor.process_pending_file_opens()
115    };
116    if pending_file_opens {
117        needs_render = true;
118    }
119    if editor.process_line_scan() {
120        needs_render = true;
121    }
122    let search_scan = {
123        let _s = tracing::info_span!("process_search_scan").entered();
124        editor.process_search_scan()
125    };
126    if search_scan {
127        needs_render = true;
128    }
129    let search_overlay_refresh = {
130        let _s = tracing::info_span!("check_search_overlay_refresh").entered();
131        editor.check_search_overlay_refresh()
132    };
133    if search_overlay_refresh {
134        needs_render = true;
135    }
136    if editor.check_mouse_hover_timer() {
137        needs_render = true;
138    }
139    if editor.active_window().check_semantic_highlight_timer() {
140        needs_render = true;
141    }
142    if editor.check_completion_trigger_timer() {
143        needs_render = true;
144    }
145    editor.active_window_mut().check_diagnostic_pull_timer();
146    if editor.check_warning_log() {
147        needs_render = true;
148    }
149    if editor.poll_stdin_streaming() {
150        needs_render = true;
151    }
152
153    if let Err(e) = editor.auto_recovery_save_dirty_buffers() {
154        tracing::debug!("Auto-recovery-save error: {}", e);
155    }
156    if let Err(e) = editor.auto_save_persistent_buffers() {
157        tracing::debug!("Auto-save (disk) error: {}", e);
158    }
159
160    if editor.take_full_redraw_request() {
161        clear_terminal()?;
162        needs_render = true;
163    }
164
165    Ok(needs_render)
166}
167
168pub(crate) use path_utils::normalize_path;
169
170use self::types::{
171    LspMenuItem, LspMessageEntry, LspProgressInfo, SearchState, TabContextMenu,
172    DEFAULT_BACKGROUND_FILE,
173};
174use crate::config::Config;
175use crate::config_io::DirectoryContext;
176use crate::input::buffer_mode::ModeRegistry;
177use crate::input::command_registry::CommandRegistry;
178use crate::input::keybindings::{Action, KeyContext, KeybindingResolver};
179use crate::input::quick_open::{
180    BufferProvider, CommandProvider, FileProvider, GotoLineProvider, QuickOpenRegistry,
181};
182use crate::model::cursor::Cursors;
183use crate::model::event::{Event, EventLog, LeafId, SplitDirection};
184use crate::model::filesystem::FileSystem;
185use crate::services::async_bridge::{AsyncBridge, AsyncMessage};
186use crate::services::fs::FsManager;
187use crate::services::lsp::manager::LspManager;
188use crate::services::plugins::PluginManager;
189use crate::services::recovery::{RecoveryConfig, RecoveryService};
190use crate::services::time_source::{RealTimeSource, SharedTimeSource};
191use crate::state::EditorState;
192use crate::types::{LspLanguageConfig, LspServerConfig, ProcessLimits};
193use crate::view::file_tree::{FileTree, FileTreeView};
194use crate::view::prompt::PromptType;
195use crate::view::split::{SplitManager, SplitViewState};
196use crate::view::ui::{
197    FileExplorerRenderer, SplitRenderer, StatusBarRenderer, SuggestionsRenderer,
198};
199use crossterm::event::{KeyCode, KeyModifiers};
200use ratatui::{
201    layout::{Constraint, Direction, Layout},
202    Frame,
203};
204use std::collections::HashMap;
205use std::ops::Range;
206use std::path::{Path, PathBuf};
207use std::sync::{Arc, Mutex, RwLock};
208use std::time::Instant;
209
210// Re-export BufferId from event module for backward compatibility
211pub use self::types::{BufferKind, BufferMetadata, HoverTarget};
212pub use self::warning_domains::{
213    GeneralWarningDomain, LspWarningDomain, WarningAction, WarningActionId, WarningDomain,
214    WarningDomainRegistry, WarningLevel, WarningPopupContent,
215};
216pub use crate::model::event::BufferId;
217
218/// Decode a wire-side LSP URI to a host path. Thin wrapper over
219/// [`LspUri::to_host_path`](crate::app::types::LspUri::to_host_path)
220/// that produces a `Result` for call sites that prefer the
221/// error-string form. Editor code that owns a raw `lsp_types::Uri`
222/// from a third-party type (e.g. `lsp_types::Location.uri`) wraps it
223/// via [`LspUri::from_wire`](crate::app::types::LspUri::from_wire)
224/// and then calls this — that's the only path from a wire URI to a
225/// host `PathBuf`, by construction.
226fn lsp_uri_to_host_path(
227    uri: &crate::app::types::LspUri,
228    translation: Option<&crate::services::authority::PathTranslation>,
229) -> Result<PathBuf, String> {
230    uri.to_host_path(translation)
231        .ok_or_else(|| "URI is not a file path".to_string())
232}
233
234/// A pending grammar registration waiting for reload_grammars() to apply
235#[derive(Clone, Debug)]
236pub struct PendingGrammar {
237    /// Language identifier (e.g., "elixir")
238    pub language: String,
239    /// Path to the grammar file (.sublime-syntax or .tmLanguage)
240    pub grammar_path: String,
241    /// File extensions to associate with this grammar
242    pub extensions: Vec<String>,
243}
244
245/// Track an in-flight semantic token range request.
246#[derive(Clone, Debug)]
247pub(crate) struct SemanticTokenRangeRequest {
248    pub(crate) buffer_id: BufferId,
249    pub(crate) version: u64,
250    pub(crate) range: Range<usize>,
251    pub(crate) start_line: usize,
252    pub(crate) end_line: usize,
253}
254
255#[derive(Clone, Copy, Debug)]
256pub(crate) enum SemanticTokensFullRequestKind {
257    Full,
258    FullDelta,
259}
260
261#[derive(Clone, Debug)]
262pub(crate) struct SemanticTokenFullRequest {
263    pub(crate) buffer_id: BufferId,
264    pub(crate) version: u64,
265    pub(crate) kind: SemanticTokensFullRequestKind,
266}
267
268#[derive(Clone, Debug)]
269pub(crate) struct FoldingRangeRequest {
270    pub(crate) buffer_id: BufferId,
271    pub(crate) version: u64,
272}
273
274#[derive(Clone, Debug)]
275pub(crate) struct InlayHintsRequest {
276    pub(crate) buffer_id: BufferId,
277    pub(crate) version: u64,
278}
279
280/// State for the dabbrev cycling session (Alt+/ style).
281///
282/// When the user presses Alt+/ repeatedly, we cycle through candidates
283/// in proximity order without showing a popup. The session is reset when
284/// any other action is taken (typing, moving, etc.).
285#[derive(Debug, Clone)]
286pub struct DabbrevCycleState {
287    /// The original prefix the user typed before the first expansion.
288    pub original_prefix: String,
289    /// Byte position where the prefix starts.
290    pub word_start: usize,
291    /// The list of candidates (ordered by proximity).
292    pub candidates: Vec<String>,
293    /// Current index into `candidates`.
294    pub index: usize,
295}
296
297/// Snapshot of cursor and viewport state used to restore the original position
298/// when a goto-line preview is abandoned (cancel, or the user edits the input
299/// so it no longer targets a line).
300///
301/// Shared between Quick Open's `:N` syntax and the standalone `Goto Line`
302/// prompt — both flows save a snapshot on the first preview jump and restore
303/// it if the user cancels or clears the target.
304///
305/// `last_jump_position` is the byte offset the most recent preview jump put the
306/// cursor at; the restore path only applies the snapshot when the cursor is
307/// still exactly there. If anything else moved the cursor (mouse click, an
308/// async buffer edit shifting positions via `adjust_for_edit`, …) the snapshot
309/// is considered stale and simply dropped. This is the single staleness check
310/// that replaces per-site invalidation across many call paths.
311#[derive(Debug, Clone)]
312pub(crate) struct GotoLinePreviewSnapshot {
313    pub buffer_id: BufferId,
314    pub split_id: LeafId,
315    pub cursor_id: crate::model::event::CursorId,
316    pub position: usize,
317    pub anchor: Option<usize>,
318    pub sticky_column: usize,
319    pub viewport_top_byte: usize,
320    pub viewport_top_view_line_offset: usize,
321    pub viewport_left_column: usize,
322    pub last_jump_position: usize,
323}
324
325/// The main editor struct - manages multiple buffers, clipboard, and rendering
326pub struct Editor {
327    // Buffers moved onto `Window` (Step 0c). Each window owns its
328    // own buffer storage; opening the same file in two windows
329    // produces two independent buffers. Access through
330    // `Editor::buffers()` / `buffers_mut()` (active window) or by
331    // direct `self.windows.get_mut(&id).unwrap().buffers` for
332    // cross-window iteration.
333
334    // NOTE: There is no `active_buffer` field. The active buffer is derived from
335    // `split_manager.active_buffer_id()` to maintain a single source of truth.
336    // Use `self.active_buffer()` to get the active buffer ID.
337    // event_logs moved onto `Window` (Step 0e). Undo logs follow the
338    // buffer storage, so they live alongside the buffer they describe.
339    /// Next buffer ID to assign.
340    ///
341    /// **Use [`Self::alloc_buffer_id`] instead of direct mutation.**
342    /// Direct `self.next_buffer_id += 1` works only on `impl Editor`
343    /// — methods that run on `impl Window` allocate via the shared
344    /// `Arc<BufferIdAllocator>` in `WindowResources`. Keeping this
345    /// counter as the canonical source means a handler on `Editor`
346    /// can still increment locally; the allocator's atomic stays in
347    /// sync because both go through `alloc_buffer_id`.
348    next_buffer_id: usize,
349
350    /// Globally-unique buffer-id allocator shared by `Arc` clone with
351    /// every `Window` (via `WindowResources`). Handlers on
352    /// `impl Window` call `Window::alloc_buffer_id()` which delegates
353    /// here; handlers on `impl Editor` call `Editor::alloc_buffer_id()`
354    /// which does the same plus advances the local `next_buffer_id`
355    /// snapshot. Both routes produce the same monotonic id sequence.
356    pub(crate) buffer_id_alloc: crate::app::window_resources::BufferIdAllocator,
357
358    /// Configuration.
359    ///
360    /// Stored as `Arc<Config>` so that mutations go through `Arc::make_mut`
361    /// (via `config_mut()`), which clone-on-writes when any other holder
362    /// references the same value. `Arc<T>` has no `DerefMut`, so direct
363    /// field assignment through `self.config` is a compile error — every
364    /// mutation must route through the CoW-aware accessor.
365    ///
366    /// Effective value is `base_config_json` + `runtime_overlay` (design
367    /// §3.4): init.ts and plugins may layer per-session writes via
368    /// `editor.setSetting(path, value)`. The overlay is merged into
369    /// `base_config_json`, the result is deserialised into this field,
370    /// and mutations go through `Arc::make_mut`.
371    ///
372    /// **Freshness invariant**: `config_snapshot_anchor` below is set to
373    /// `Arc::clone(&self.config)` on every plugin-snapshot refresh. That
374    /// guarantees the first `Arc::make_mut(&mut self.config)` after each
375    /// refresh *always* CoW-clones (strong count ≥ 2), so `self.config`
376    /// moves to a new pointer and stops being `ptr_eq` with the anchor.
377    config: Arc<Config>,
378
379    /// Clone of `config` captured at the last plugin-snapshot refresh.
380    config_snapshot_anchor: Arc<Config>,
381
382    /// Serialized JSON of `*self.config` as of the last time
383    /// `ptr_eq(&self.config, &self.config_snapshot_anchor)` was false.
384    config_cached_json: Arc<serde_json::Value>,
385
386    /// Cached raw user config (for plugins, avoids re-reading file on every frame).
387    user_config_raw: Arc<serde_json::Value>,
388
389    /// Directory context for editor state paths
390    dir_context: DirectoryContext,
391
392    /// Grammar registry for TextMate syntax highlighting
393    grammar_registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
394
395    /// Pending grammars registered by plugins, waiting for reload_grammars() to apply
396    pending_grammars: Vec<PendingGrammar>,
397
398    /// Whether a grammar reload has been requested but not yet flushed.
399    /// This allows batching multiple RegisterGrammar+ReloadGrammars sequences
400    /// into a single rebuild.
401    grammar_reload_pending: bool,
402
403    /// Whether a background grammar build is in progress.
404    /// When true, `flush_pending_grammars()` defers work until the build completes.
405    grammar_build_in_progress: bool,
406
407    /// Whether the initial full grammar build (user grammars + language packs)
408    /// still needs to happen. Deferred from construction so that plugin-registered
409    /// grammars from the first event-loop tick are included in a single build.
410    needs_full_grammar_build: bool,
411
412    /// Plugin callback IDs waiting for the grammar build to complete.
413    /// Multiple reloadGrammars() calls may accumulate here; all are resolved
414    /// when the background build finishes.
415    pending_grammar_callbacks: Vec<fresh_core::api::JsCallbackId>,
416
417    /// Active theme
418    /// Active resolved theme, shared with windows via WindowResources.
419    /// Wrapped in `Arc<RwLock<>>` so a theme reload propagates to every
420    /// window without copying.
421    pub(crate) theme: Arc<RwLock<crate::view::theme::Theme>>,
422
423    /// All loaded themes (embedded + user). Held as `Arc` so
424    /// `expanded_menus_cache` can detect a registry swap via `Arc::ptr_eq`.
425    theme_registry: Arc<crate::view::theme::ThemeRegistry>,
426
427    /// Memoised `MenuConfig` with `DynamicSubmenu` items expanded against
428    /// the current theme registry.
429    expanded_menus_cache: crate::view::ui::ExpandedMenusCache,
430
431    /// Shared theme data cache for plugin access (name → JSON value)
432    theme_cache: Arc<RwLock<HashMap<String, serde_json::Value>>>,
433
434    /// Optional ANSI background image
435    ansi_background: Option<crate::primitives::ansi_background::AnsiBackground>,
436
437    /// Source path for the currently loaded ANSI background
438    ansi_background_path: Option<PathBuf>,
439
440    /// Blend amount for the ANSI background (0..1)
441    background_fade: f32,
442
443    /// Keybinding resolver (shared with Quick Open CommandProvider)
444    keybindings: Arc<RwLock<KeybindingResolver>>,
445
446    /// Shared clipboard (handles both internal and system clipboard)
447    clipboard: crate::services::clipboard::Clipboard,
448
449    /// Should the editor quit?
450    should_quit: bool,
451
452    /// Should the client detach (keep server running)?
453    should_detach: bool,
454
455    /// Running in session/server mode (use hardware cursor only, no REVERSED style)
456    session_mode: bool,
457
458    /// Backend does not render a hardware cursor — always use software cursor indicators.
459    software_cursor_only: bool,
460
461    /// Session name for display in status bar (session mode only)
462    session_name: Option<String>,
463
464    /// Pending escape sequences to send to client (session mode only)
465    /// These get prepended to the next render output
466    pending_escape_sequences: Vec<u8>,
467
468    /// If set, the editor should restart with this new working directory
469    /// This is used by Open Folder to do a clean context switch
470    restart_with_dir: Option<PathBuf>,
471
472    // status_message, plugin_status_message, prompt moved onto
473    // `Window` (Step 0k phase 3) — each window has its own chrome,
474    // and the active window's chrome is what renders.
475    /// Last terminal window title written via OSC 2. Used so we only write
476    /// the escape sequence when the title would actually change, rather
477    /// than on every frame.
478    last_window_title: Option<String>,
479
480    // `plugin_errors` moved onto `Window`.
481    /// Terminal dimensions (for creating new buffers)
482    terminal_width: u16,
483    terminal_height: u16,
484
485    // LSP manager moved onto `Window`. Access via
486    // `Editor::lsp()` / `lsp_mut()` — each window has its own
487    // LspManager rooted at its project root.
488    // buffer_metadata moved onto `Window` (Step 0l). Access via
489    // `self.active_window().buffer_metadata` (or
490    // `_mut()`) — each window owns the metadata for its own
491    // buffers, alongside the buffer storage itself.
492    /// Buffer mode registry (for buffer-local keybindings)
493    mode_registry: ModeRegistry,
494
495    /// Tokio runtime for async I/O tasks
496    tokio_runtime: Option<Arc<tokio::runtime::Runtime>>,
497
498    /// Bridge for async messages from tokio tasks to main loop
499    async_bridge: Option<AsyncBridge>,
500
501    // split_manager and split_view_states moved onto `Window`. Access
502    // via `Editor::split_manager()` / `split_manager_mut()` and
503    // `Editor::split_view_states()` / `split_view_states_mut()`.
504    // Each window owns its own split tree + per-leaf view state.
505    // `previous_viewports` moved onto `Window` (per-leaf state — each
506    // window has its own splits, so its own viewport-change tracker).
507    // `scroll_sync_manager` moved onto `Window` — pairs splits, which
508    // are per-window.
509
510    // file_explorer moved onto `Window`. Access via
511    // `Editor::file_explorer()` / `file_explorer_mut()` —
512    // each window has its own tree view.
513    // `preview` (per-window preview-tab tracker) moved onto `Window`.
514    // Each window has its own preview slot.
515
516    // suppress_position_history_once moved onto `Window` (Step 0f).
517    /// Filesystem manager for file explorer
518    fs_manager: Arc<FsManager>,
519
520    /// Single backend slot for "where does the editor act?".
521    ///
522    /// Bundles filesystem, process spawner, terminal wrapper, and
523    /// display label. Replaces the old quartet of `filesystem`,
524    /// `process_spawner`, `terminal_wrapper`, `authority_display_string`
525    /// fields. Always present; the editor boots with `Authority::local()`
526    /// and plugins (or the SSH startup flow) install a different one
527    /// later via `install_authority`. Pointer-equality on the inner
528    /// `Arc`s answers "still the same backend?".
529    authority: crate::services::authority::Authority,
530
531    /// Authority queued by `install_authority`, picked up by `main.rs`
532    /// right before dropping this editor on restart. `None` in the
533    /// steady state. Not durable state — restarts from `main.rs`'s
534    /// restart-dir path leave this `None`, and the main loop carries
535    /// the authority over through its own channel.
536    pending_authority: Option<crate::services::authority::Authority>,
537
538    /// Plugin-supplied override for the Remote Indicator. Takes
539    /// precedence over the authority-derived state at render time.
540    /// Cleared on editor restart (plugins must reassert the state
541    /// after `setAuthority`). See
542    /// `PluginCommand::SetRemoteIndicatorState`.
543    pub remote_indicator_override: Option<crate::view::ui::status_bar::RemoteIndicatorOverride>,
544
545    /// Local filesystem for editor-internal files (log files, status
546    /// log). Stays separate from `authority` because these are the
547    /// editor's own private state — they live on the host disk
548    /// regardless of where the user is editing.
549    local_filesystem: Arc<dyn FileSystem + Send + Sync>,
550
551    // `file_explorer_visible`, `file_explorer_sync_in_progress`,
552    // `file_explorer_width`, `file_explorer_side`,
553    // `pending_file_explorer_show_*`, `file_explorer_decorations`,
554    // `file_explorer_decoration_cache` all moved onto `Window`. The
555    // file-explorer view itself was already per-window since Step 0b;
556    // these chrome flags follow.
557    /// File explorer clipboard for cut/copy/paste of files and directories
558    // `file_explorer_clipboard` moved onto `Window`.
559
560    // `menu_bar_visible`, `menu_bar_auto_shown`, `tab_bar_visible`,
561    // `status_bar_visible`, `prompt_line_visible`, `mouse_enabled`
562    // moved onto `Window` — per-window UI toggles.
563
564    // `same_buffer_scroll_sync` moved onto `Window` — per-window UX
565    // toggle, since the split tree it controls is per-window.
566    /// Mouse cursor position (for GPM software cursor rendering)
567    /// When GPM is active, we need to draw our own cursor since GPM can't
568    /// draw on the alternate screen buffer used by TUI applications.
569    // `mouse_cursor_position`, `gpm_active`, `key_context` moved onto `Window`.
570
571    /// Menu state (active menu, highlighted item)
572    menu_state: crate::view::ui::MenuState,
573
574    /// Menu configuration (built-in menus with i18n support)
575    menus: crate::config::MenuConfig,
576
577    /// Working directory for file explorer (set at initialization).
578    ///
579    /// During the Session migration this field still backs every
580    /// existing read site. New code should prefer
581    /// `self.active_window().root` so the eventual swap to a real
582    /// active-session pointer is a no-op for the call site. See
583    /// `docs/internal/orchestrator-sessions-design.md` Step 1.
584    working_dir: PathBuf,
585
586    /// All editor sessions, keyed by id. Initially holds exactly one
587    /// session (`WindowId(1)`, the "base") rooted at `working_dir`.
588    /// Step 1 of the migration adds the abstraction without yet
589    /// allowing more than one entry.
590    pub(crate) windows: HashMap<fresh_core::WindowId, crate::app::window::Window>,
591
592    /// Id of the currently active session. Always `WindowId(1)` for
593    /// now; multi-session support arrives in a follow-up commit.
594    pub(crate) active_window: fresh_core::WindowId,
595
596    /// Monotonic counter for the next session id. The base session
597    /// uses 1; new sessions take 2, 3, …. Closing a session does
598    /// not free its id (per design, ids are stable within a process).
599    /// Unused until `createWindow` lands in the next migration step.
600    #[allow(dead_code)]
601    pub(crate) next_window_id: u64,
602
603    // LSP request-tracking state (next_lsp_request_id,
604    // pending_*_requests, *_in_flight, completion_items,
605    // dabbrev_state, etc.) all moved onto `Window` in Step 0k.
606    // `completion_service` moved onto `Window` — orchestrates this
607    // window's per-window completion providers (dabbrev, buffer words,
608    // LSP, plugin providers).
609    // `lsp_diagnostic_namespace` moved onto `Window` — overlay
610    // namespace key for diagnostic overlays, which are buffer overlays
611    // and follow buffers onto the window.
612    // `interactive_replace_state` moved onto `Window` — per-window
613    // search-and-replace session state.
614    // `mouse_state` moved onto `Window` — drag targets reference per-window LeafIds.
615    // `tab_context_menu`, `file_explorer_context_menu`, `theme_info_popup`
616    // moved onto `Window`.
617    // `chrome_layout` moved onto `Window` — each window has its own
618    // status bar, menu, prompt overlay, and popups.
619    /// Command registry for dynamic commands
620    command_registry: Arc<RwLock<CommandRegistry>>,
621
622    /// Quick Open registry for unified prompt providers
623    quick_open_registry: QuickOpenRegistry,
624
625    /// Plugin manager (handles both enabled and disabled cases)
626    /// Plugin manager, wrapped in `Arc<RwLock<>>` so windows can fire
627    /// hooks (`run_hook`) via WindowResources without holding an
628    /// `&mut Editor` reference. `&self` methods (run_hook,
629    /// deliver_response, has_hook_handlers, …) take a read lock; the
630    /// few `&mut self` methods (process_commands, check_thread_health,
631    /// test_inject_command) take a write lock.
632    plugin_manager: Arc<RwLock<PluginManager>>,
633
634    // `plugin_dev_workspaces` moved onto `Window` — keyed by `BufferId`,
635    // and buffers are per-window, so the workspace map follows.
636    /// Registry of status-bar tokens contributed by plugins.
637    /// Key: "plugin_name:token_name" (e.g., "git_statusbar:branch"); value: display title.
638    /// Global and lifetime-checked. Per-buffer values live on each `Window`.
639    status_bar_token_registry: Mutex<HashMap<String, String>>,
640
641    /// Registry of plugin-provided config schemas, keyed by plugin name.
642    /// Each value is the validated JSON schema describing
643    /// `plugins.<name>.settings.*`. Populated at startup from
644    /// `<plugin_name>.schema.json` sidecar files discovered next to plugin
645    /// `.ts`/`.js` files; the Settings UI reads this to render a
646    /// per-plugin sub-category under "Plugin Settings".
647    pub(crate) plugin_schemas:
648        std::sync::Arc<std::sync::RwLock<HashMap<String, serde_json::Value>>>,
649
650    // `seen_byte_ranges` moved onto `Window` — keyed by `BufferId`
651    // which lives on `Window`, so the tracker follows the buffers.
652
653    // panel_ids moved onto `Window`. Access via
654    // `Editor::panel_ids()` / `panel_ids_mut()` — those resolve to
655    // the active window's dock occupancy. Each window owns its own
656    // utility-dock; switching windows doesn't share dock state.
657    // `buffer_groups`, `buffer_to_group`, `next_buffer_group_id`
658    // moved onto `Window` — each window has its own buffer groups.
659
660    // grouped_subtrees moved onto `Window` — each window owns its
661    // own buffer-group subtrees (a window with a Live Grep panel
662    // open doesn't share the panel state with sibling windows).
663    /// Background process abort handles for cancellation
664    /// Maps process_id to abort handle
665    background_process_handles: HashMap<u64, tokio::task::AbortHandle>,
666
667    /// Cancellation senders for host-side processes spawned via
668    /// `spawnHostProcess`. Firing the sender (or dropping it) triggers
669    /// an in-task `child.start_kill()` so the process is reaped, not
670    /// just orphaned. Entries are removed when the spawn task sends
671    /// its terminal `PluginProcessOutput`.
672    host_process_handles: HashMap<u64, tokio::sync::oneshot::Sender<()>>,
673    /// FIFO queue of plugin `editor.getNextKey()` callbacks awaiting a
674    /// keypress. While non-empty, the next key arriving in
675    /// `handle_key` is consumed by resolving the front-most callback
676    /// rather than dispatching to mode bindings or other handlers.
677    // `pending_next_key_callbacks`, `key_capture_active`,
678    // `pending_key_capture_buffer` moved onto `Window`.
679    // `lsp_progress`, `lsp_server_statuses`, `lsp_window_messages`,
680    // `lsp_log_messages` moved onto `Window` — each window has its own
681    // LspManager, so the progress/status/message streams describe that
682    // manager's servers, not the editor's.
683
684    // `diagnostic_result_ids` moved onto `Window` — each window has its
685    // own LspManager and therefore its own per-URI result_id stream.
686    // `stored_push_diagnostics`, `stored_pull_diagnostics`,
687    // `stored_diagnostics`, `stored_folding_ranges` moved onto
688    // `Window` — URI-keyed but each URI maps to a buffer in a specific
689    // window's LSP set, so the maps describe one window's diagnostics.
690    /// Event broadcaster for control events (observable by external systems)
691    event_broadcaster: crate::model::control_event::EventBroadcaster,
692
693    // bookmarks moved onto `Window` (Step 0f).
694    /// Macro record/playback subsystem (owns `macros`, `recording`,
695    /// `last_register`, and the `playing` guard flag).
696    // `macros` moved onto `Window`.
697
698    /// Pending plugin action receivers (for async action execution)
699    #[cfg(feature = "plugins")]
700    pending_plugin_actions: Vec<(
701        String,
702        crate::services::plugins::thread::oneshot::Receiver<anyhow::Result<()>>,
703    )>,
704
705    /// Flag set by plugin commands that need a render (e.g., RefreshLines)
706    #[cfg(feature = "plugins")]
707    plugin_render_requested: bool,
708
709    /// Pending chord sequence for multi-key bindings (e.g., C-x C-s in Emacs)
710    /// Stores the keys pressed so far in a chord sequence
711    // `chord_state` moved onto `Window`.
712
713    // (Historical `pending_lsp_confirmation` and `pending_lsp_status_popup`
714    // fields moved onto `Popup::resolver` — each popup carries its own
715    // "how do I confirm?" identity, so `handle_popup_confirm` dispatches
716    // by matching the focused popup's resolver instead of racing through
717    // a precedence cascade of side-channel `Option`s that a second
718    // simultaneously-open popup could steal.)
719    /// Languages the user has interactively dismissed from the LSP popup.
720    ///
721    /// Pending Save-As queue for the "save and quit" flow.
722    ///
723    // `last_auto_revert_poll`, `last_file_tree_poll`, `git_index_resolved`,
724    // `dir_mod_times`, `pending_file_poll_rx`, `pending_dir_poll_rx`
725    // all moved onto `Window` — auto-revert and file-tree change
726    // detection are per-window, paired with the already-per-window
727    // `file_mod_times`.
728    /// File open dialog state (when PromptType::OpenFile is active)
729    // `file_open_state` moved onto `Window`.
730
731    /// Cached layout for file browser (for mouse hit testing)
732    // `file_browser_layout` moved onto `Window`.
733
734    /// Recovery service for auto-recovery-save and crash recovery
735    recovery_service: RecoveryService,
736
737    /// Request a full terminal clear and redraw on the next frame
738    full_redraw_requested: bool,
739
740    /// Request the event loop to suspend the process (SIGTSTP on Unix).
741    /// Consumed by the outer event loop after the current action returns.
742    suspend_requested: bool,
743
744    /// Time source for testable time operations
745    time_source: SharedTimeSource,
746
747    /// Last auto-recovery-save time for rate limiting
748    // `last_auto_recovery_save`, `last_persistent_auto_save` moved onto `Window`.
749
750    /// Active custom contexts for command visibility
751    /// Plugin-defined contexts like "config-editor" that control command availability
752    // `active_custom_contexts` moved onto `Window`.
753
754    /// Plugin-managed global state, isolated per plugin name.
755    /// Outer key is plugin name, inner key is the state key set by the plugin.
756    plugin_global_state: HashMap<String, HashMap<String, serde_json::Value>>,
757    /// Warning log receiver and path (for tracking warnings)
758    warning_log: Option<(std::sync::mpsc::Receiver<()>, PathBuf)>,
759
760    /// Status message log path (for viewing full status history)
761    status_log_path: Option<PathBuf>,
762
763    /// Warning domain registry for extensible warning indicators
764    /// Contains LSP warnings, general warnings, and can be extended by plugins
765    // `warning_domains` moved onto `Window`.
766
767    /// Periodic update checker (checks for new releases every hour)
768    update_checker: Option<crate::services::release_checker::PeriodicUpdateChecker>,
769
770    // Terminal subsystem moved onto `Window` (Step 0d). PTYs and
771    // their backing files belong to the window that spawned them, so
772    // closeWindow joins the threads. Access through methods on Window
773    // (called via `self.windows.get_mut(&id).unwrap().method(...)`),
774    // not via accessors on Editor.
775    /// Plugin-driven filesystem watchers (lazily constructed —
776    /// the underlying notify backend spawns a thread, so it's
777    /// nicer to defer until the first `watchPath` call). See
778    /// `services/file_watcher.rs`.
779    file_watcher_manager: crate::services::file_watcher::FileWatcherManager,
780
781    /// Test-only sink for `path_changed` plugin events. Captured
782    /// by `async_dispatch` whenever a PathChanged AsyncMessage
783    /// arrives, so e2e tests can assert filesystem events
784    /// reached the editor without standing up a JS plugin.
785    /// Production builds never read this.
786    pub(crate) last_path_change_for_test: Option<(u64, std::path::PathBuf, &'static str)>,
787
788    /// Test-only sink for the most-recent `WatchPathRegistered`
789    /// plugin response, keyed by request_id. Used by
790    /// `watch_path` e2e tests to read back the allocated handle.
791    pub(crate) last_watch_response_for_test: Option<(u64, Result<u64, String>)>,
792
793    /// Plugin-driven session preview override. When `Some(sid)`
794    /// and the floating-overlay prompt is open, the overlay's
795    /// preview pane renders the *entire* split tree of session
796    /// `sid` natively — Primitive #1 in
797    /// `docs/internal/orchestrator-sessions-design.md` §
798    /// "Rich Control Room rendering".
799    pub(crate) preview_window_id: Option<fresh_core::WindowId>,
800
801    // terminal_buffers / terminal_backing_files / terminal_log_files
802    // moved onto `Window` (Step 0d).
803    // `ephemeral_terminals` moved onto `Window` — TerminalManager and
804    // its terminals are per-window (Step 0d), so the ephemeral set
805    // follows.
806
807    // `terminal_mode` moved onto `Window`. Per-window because each
808    // window has its own active terminal buffer.
809    /// Whether keyboard capture is enabled in terminal mode.
810    /// When true, ALL keys go to the terminal (except Ctrl+` to toggle).
811    /// When false, UI keybindings (split nav, palette, etc.) are processed first.
812    // `keyboard_capture` moved onto `Window`.
813
814    // `terminal_mode_resume` moved onto `Window` — terminal buffers
815    // are per-window (Step 0d), so the auto-resume set follows.
816    /// Timestamp of the previous mouse click (for multi-click detection)
817    // `previous_click_time`, `previous_click_position`, `click_count` moved onto `Window`.
818
819    /// Settings UI state (when settings modal is open)
820    pub(crate) settings_state: Option<crate::view::settings::SettingsState>,
821
822    /// Calibration wizard state (when calibration modal is open)
823    pub(crate) calibration_wizard: Option<calibration_wizard::CalibrationWizard>,
824
825    // `event_debug` modal state moved to `Window` — each window has its
826    // own debug overlay (the dialog records keystrokes destined for that
827    // window's input pipeline, so it's logically per-window).
828    /// Keybinding editor state (when keybinding editor modal is open)
829    pub(crate) keybinding_editor: Option<keybinding_editor::KeybindingEditor>,
830
831    /// Key translator for input calibration (loaded from config)
832    pub(crate) key_translator: crate::input::key_translator::KeyTranslator,
833
834    /// Terminal color capability (true color, 256, or 16 colors)
835    color_capability: crate::view::color_support::ColorCapability,
836
837    /// Hunks for the Review Diff tool
838    // `review_hunks` moved onto `Window`.
839
840    /// Editor-level popups that float above any buffer regardless of which
841    /// one is active. Plugin notifications (showActionPopup) live here so a
842    /// switch to a virtual buffer (Dashboard, diagnostics panel, …) doesn't
843    /// hide them mid-decision.
844    ///
845    /// Each plugin popup carries its `popup_id` inside its
846    /// `PopupResolver::PluginAction` — no parallel side-channel stack.
847    pub(crate) global_popups: crate::view::popup::PopupManager,
848
849    // composite_buffers + composite_view_states moved onto `Window` —
850    // composite-buffer panels (Live Grep results, Diagnostics list,
851    // References, etc.) belong to the window that opened the panel.
852    /// Pending file opens from CLI arguments (processed after TUI starts)
853    /// This allows CLI files to go through the same code path as interactive file opens,
854    /// ensuring consistent error handling (e.g., encoding confirmation prompts).
855    // `pending_file_opens` moved onto `Window`.
856
857    /// When true, apply hot exit recovery after the next batch of pending file opens
858    // `pending_hot_exit_recovery` moved onto `Window`.
859
860    /// Tracks buffers opened with --wait: maps buffer_id → (wait_id, has_popup)
861    // `wait_tracking` moved onto `Window`.
862    /// Wait IDs that have completed (buffer closed or popup dismissed)
863    // `completed_waits` moved onto `Window`.
864
865    /// Stdin streaming state (if reading from stdin)
866    stdin_stream: stdin_stream::StdinStream,
867
868    /// Incremental line scan state (for non-blocking progress during Go to Line)
869    // `line_scan` moved onto `Window`.
870
871    /// Incremental search scan state (for non-blocking search on large files)
872    // `search_scan` moved onto `Window`.
873
874    /// Viewport top_byte when search overlays were last refreshed.
875    /// Used to detect viewport scrolling so overlays can be updated.
876    // `search_overlay_top_byte` moved onto `Window`.
877
878    /// Frame-buffer animation layer. Applied at the end of `render`; the
879    /// main loop consults `is_active`/`next_deadline` to keep re-rendering
880    /// while animations are running.
881    // `animations` moved onto `Window`.
882
883    /// Hardware-cursor screen position from the previous render pass, paired
884    /// with the active split that owned the cursor at that time. Used to
885    /// detect "jumps" (search, goto-line, click, goto-definition, focus
886    /// change between splits, tab/buffer switch, etc.) and animate the
887    /// cursor moving from its old screen position to its new one. Cross-
888    /// pane jumps animate unconditionally; same-pane jumps animate when
889    /// the cursor moved more than two rows or at least ten columns.
890    pub(crate) previous_cursor_screen_pos: Option<((u16, u16), LeafId)>,
891    /// ID of the most recent cursor-jump animation, kept so successive jumps
892    /// cancel the prior one instead of stacking trail effects.
893    pub(crate) cursor_jump_animation: Option<crate::view::animation::AnimationId>,
894
895    /// Deferred plugin animations targeting a virtual buffer whose
896    /// on-screen Rect wasn't in the cached split layout at command
897    /// dispatch time. Drained at the top of each render pass once
898    /// `split_areas` has been recomputed, so the animation starts on
899    /// the very first frame the buffer actually occupies screen space.
900    pub(crate) pending_vb_animations: Vec<(u64, BufferId, fresh_core::api::PluginAnimationKind)>,
901
902    /// Plugin widget panels mounted via `MountWidgetPanel`.
903    ///
904    /// One entry per active panel. The registry holds the most recent
905    /// `WidgetSpec` per panel so future updates can reconcile against
906    /// it and so a theme change can re-render every panel without
907    /// the originating plugin needing to re-emit. See
908    /// `docs/internal/plugin-widget-library-design.md`.
909    pub(crate) widget_registry: crate::widgets::WidgetRegistry,
910
911    /// Currently-mounted floating widget panel, if any.
912    ///
913    /// At most one floating widget panel is shown at a time — the
914    /// overlay is modal-ish (input is routed to it) and stacking
915    /// floaters would obscure each other without a usable focus
916    /// model. Mounting a second one replaces the first.
917    pub(crate) floating_widget_panel: Option<FloatingWidgetState>,
918}
919
920/// Sentinel `BufferId` registered with the widget registry for the
921/// floating panel — never appears in the editor's buffer table, so
922/// `set_virtual_buffer_content` against it would fail. The mount /
923/// rerender paths special-case this id and paint into the overlay
924/// rect instead.
925pub(crate) const FLOATING_PANEL_BUFFER_ID: BufferId = BufferId(usize::MAX);
926
927/// A widget panel rendered as a centered floating overlay rather
928/// than into a virtual buffer. The panel still lives in the shared
929/// `widget_registry` (so the existing reconcile / widget-command /
930/// mutate paths apply), but its rendered entries are painted into
931/// the overlay rect at draw time instead of being written into a
932/// virtual buffer.
933#[derive(Debug, Clone)]
934pub(crate) struct FloatingWidgetState {
935    pub panel_id: crate::widgets::PanelId,
936    pub width_pct: u8,
937    pub height_pct: u8,
938    /// Most-recently rendered entries. Refreshed on every spec /
939    /// command / mutate; painted into the overlay rect at draw
940    /// time.
941    pub entries: Vec<fresh_core::text_property::TextPropertyEntry>,
942    /// Hardware-cursor target when a `TextInput` is focused.
943    pub focus_cursor: Option<crate::widgets::FocusCursor>,
944    /// Window-embed rectangles reserved by `WindowEmbed`
945    /// widgets in the panel's spec. After the entries paint
946    /// down their (blank) cells, the floating panel render
947    /// walks these and invokes the per-window paint path
948    /// scoped to each rect — giving us a live render of the
949    /// referenced editor window inside the floating overlay.
950    pub embeds: Vec<crate::widgets::EmbedRect>,
951    /// Rows produced by `WidgetSpec::Overlay` children. Painted
952    /// AFTER `entries` and `embeds`, on top of whatever's at
953    /// each `buffer_row`. Used for dropdown completions /
954    /// transient popups that should appear next to a focused
955    /// widget without reflowing the rest of the panel when
956    /// they show or hide.
957    pub overlays: Vec<crate::widgets::OverlayRow>,
958    /// Inner rect (frame interior) of the last draw — used by the
959    /// click hit-test to map terminal coords back to buffer coords.
960    pub last_inner_rect: Option<ratatui::layout::Rect>,
961}
962
963/// A file that should be opened after the TUI starts
964#[derive(Debug, Clone)]
965pub struct PendingFileOpen {
966    /// Path to the file
967    pub path: PathBuf,
968    /// Line number to navigate to (1-indexed, optional)
969    pub line: Option<usize>,
970    /// Column number to navigate to (1-indexed, optional)
971    pub column: Option<usize>,
972    /// End line for range selection (1-indexed, optional)
973    pub end_line: Option<usize>,
974    /// End column for range selection (1-indexed, optional)
975    pub end_column: Option<usize>,
976    /// Hover popup message to show after opening (optional)
977    pub message: Option<String>,
978    /// Wait ID for --wait tracking (if the CLI is blocking until done)
979    pub wait_id: Option<u64>,
980}
981
982impl Editor {
983    /// Load an ANSI background image from a user-provided path
984    fn load_ansi_background(&mut self, input: &str) -> AnyhowResult<()> {
985        let trimmed = input.trim();
986
987        if trimmed.is_empty() {
988            self.ansi_background = None;
989            self.ansi_background_path = None;
990            self.set_status_message(t!("status.background_cleared").to_string());
991            return Ok(());
992        }
993
994        let input_path = Path::new(trimmed);
995        let resolved = if input_path.is_absolute() {
996            input_path.to_path_buf()
997        } else {
998            self.working_dir.join(input_path)
999        };
1000
1001        let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
1002
1003        let parsed = crate::primitives::ansi_background::AnsiBackground::from_file(&canonical)?;
1004
1005        self.ansi_background = Some(parsed);
1006        self.ansi_background_path = Some(canonical.clone());
1007        self.set_status_message(
1008            t!(
1009                "view.background_set",
1010                path = canonical.display().to_string()
1011            )
1012            .to_string(),
1013        );
1014
1015        Ok(())
1016    }
1017
1018    /// Total number of open buffers across the workspace. Test
1019    /// support for `EditorTestApi::buffer_count` (Phase 7 of the
1020    /// scenario migration).
1021    #[doc(hidden)]
1022    pub fn buffer_count_for_tests(&self) -> usize {
1023        self.windows
1024            .get(&self.active_window)
1025            .map(|w| &w.buffers)
1026            .expect("active window present")
1027            .len()
1028    }
1029
1030    /// Buffer IDs in stable order (sorted by inner value). Used by
1031    /// `EditorTestApi::buffer_paths` so workspace assertions don't
1032    /// depend on `HashMap` iteration order.
1033    #[doc(hidden)]
1034    pub fn all_buffer_ids_for_tests(&self) -> Vec<BufferId> {
1035        let mut ids: Vec<BufferId> = self
1036            .windows
1037            .get(&self.active_window)
1038            .map(|w| &w.buffers)
1039            .expect("active window present")
1040            .ids();
1041        ids.sort_by_key(|id| id.0);
1042        ids
1043    }
1044
1045    /// Get the currently active buffer state
1046    pub fn active_state(&self) -> &EditorState {
1047        self.windows
1048            .get(&self.active_window)
1049            .map(|w| &w.buffers)
1050            .expect("active window present")
1051            .get(&self.active_buffer())
1052            .unwrap()
1053    }
1054
1055    /// Get the currently active buffer state (mutable)
1056    pub fn active_state_mut(&mut self) -> &mut EditorState {
1057        let __buffer_id = self.active_buffer();
1058        self.windows
1059            .get_mut(&self.active_window)
1060            .map(|w| &mut w.buffers)
1061            .expect("active window present")
1062            .get_mut(&__buffer_id)
1063            .unwrap()
1064    }
1065
1066    /// Get the cursors for the active buffer in the active split.
1067    /// Uses `effective_active_split` so focused buffer-group panels return
1068    /// their own cursors (not the outer split's stale ones).
1069    pub fn active_cursors(&self) -> &Cursors {
1070        let split_id = self.effective_active_split();
1071        &self
1072            .windows
1073            .get(&self.active_window)
1074            .and_then(|w| w.buffers.splits())
1075            .map(|(_, vs)| vs)
1076            .expect("active window must have a populated split layout")
1077            .get(&split_id)
1078            .unwrap()
1079            .cursors
1080    }
1081
1082    /// Get the cursors for the active buffer in the active split (mutable)
1083    pub fn active_cursors_mut(&mut self) -> &mut Cursors {
1084        let split_id = self.effective_active_split();
1085        &mut self
1086            .windows
1087            .get_mut(&self.active_window)
1088            .and_then(|w| w.split_view_states_mut())
1089            .expect("active window must have a populated split layout")
1090            .get_mut(&split_id)
1091            .unwrap()
1092            .cursors
1093    }
1094
1095    /// Set completion items for type-to-filter (for testing)
1096    pub fn set_completion_items(&mut self, items: Vec<lsp_types::CompletionItem>) {
1097        self.active_window_mut().completion_items = Some(items);
1098    }
1099
1100    /// Get the viewport for the active split
1101    pub fn active_viewport(&self) -> &crate::view::viewport::Viewport {
1102        let active_split = self
1103            .windows
1104            .get(&self.active_window)
1105            .and_then(|w| w.buffers.splits())
1106            .map(|(mgr, _)| mgr)
1107            .expect("active window must have a populated split layout")
1108            .active_split();
1109        &self
1110            .windows
1111            .get(&self.active_window)
1112            .and_then(|w| w.buffers.splits())
1113            .map(|(_, vs)| vs)
1114            .expect("active window must have a populated split layout")
1115            .get(&active_split)
1116            .unwrap()
1117            .viewport
1118    }
1119
1120    /// Get the viewport for the active split (mutable)
1121    pub fn active_viewport_mut(&mut self) -> &mut crate::view::viewport::Viewport {
1122        let active_split = self
1123            .windows
1124            .get(&self.active_window)
1125            .and_then(|w| w.buffers.splits())
1126            .map(|(mgr, _)| mgr)
1127            .expect("active window must have a populated split layout")
1128            .active_split();
1129        &mut self
1130            .windows
1131            .get_mut(&self.active_window)
1132            .and_then(|w| w.split_view_states_mut())
1133            .expect("active window must have a populated split layout")
1134            .get_mut(&active_split)
1135            .unwrap()
1136            .viewport
1137    }
1138
1139    /// Get the display name for a buffer (filename or virtual buffer name)
1140    pub fn get_buffer_display_name(&self, buffer_id: BufferId) -> String {
1141        // Check composite buffers first
1142        if let Some(composite) = self.active_window().composite_buffers.get(&buffer_id) {
1143            return composite.name.clone();
1144        }
1145
1146        self.active_window()
1147            .buffer_metadata
1148            .get(&buffer_id)
1149            .map(|m| m.display_name.clone())
1150            .or_else(|| {
1151                self.windows
1152                    .get(&self.active_window)
1153                    .map(|w| &w.buffers)
1154                    .expect("active window present")
1155                    .get(&buffer_id)
1156                    .and_then(|state| {
1157                        state
1158                            .buffer
1159                            .file_path()
1160                            .and_then(|p| p.file_name())
1161                            .and_then(|n| n.to_str())
1162                            .map(|s| s.to_string())
1163                    })
1164            })
1165            .unwrap_or_else(|| "[No Name]".to_string())
1166    }
1167
1168    /// Apply an event to the active buffer with all cross-cutting concerns.
1169    /// This is the centralized method that automatically handles:
1170    /// - Event application to buffer
1171    /// - Plugin hooks (after-insert, after-delete, etc.)
1172    /// - LSP notifications
1173    /// - Any other cross-cutting concerns
1174    ///
1175
1176    /// Get the event log for the active buffer
1177    pub fn active_event_log(&self) -> &EventLog {
1178        self.active_window()
1179            .event_logs
1180            .get(&self.active_buffer())
1181            .unwrap()
1182    }
1183
1184    /// Get the event log for the active buffer (mutable)
1185    pub fn active_event_log_mut(&mut self) -> &mut EventLog {
1186        let buffer_id = self.active_buffer();
1187        self.active_window_mut()
1188            .event_logs
1189            .get_mut(&buffer_id)
1190            .unwrap()
1191    }
1192
1193    /// Register a status-bar token contributed by a plugin.
1194    /// Token key is "plugin_name:token_name".
1195    /// Returns Err if token already registered or inputs are invalid.
1196    pub fn register_status_bar_element(
1197        &self,
1198        plugin_name: &str,
1199        token_name: &str,
1200        title: &str,
1201    ) -> Result<(), String> {
1202        if plugin_name.is_empty() {
1203            return Err("Plugin name cannot be empty".to_string());
1204        }
1205        if token_name.is_empty() {
1206            return Err("Token name cannot be empty".to_string());
1207        }
1208
1209        let key = format!("{}:{}", plugin_name, token_name);
1210        let mut registry = self.status_bar_token_registry.lock().unwrap();
1211
1212        if registry.contains_key(&key) {
1213            return Err(format!("Token '{}' already registered", key));
1214        }
1215
1216        registry.insert(key, title.to_string());
1217        Ok(())
1218    }
1219
1220    /// Set the value for a status-bar token on a specific buffer.
1221    /// Key format: "plugin_name:token_name". The buffer is located across
1222    /// every window — buffers are uniquely owned, so at most one window
1223    /// matches.
1224    pub fn set_status_bar_value(
1225        &mut self,
1226        buffer_id: BufferId,
1227        key: &str,
1228        value: String,
1229    ) -> Result<(), String> {
1230        for window in self.windows.values_mut() {
1231            if window.buffers.contains_key(&buffer_id) {
1232                window
1233                    .status_bar_values
1234                    .entry(buffer_id)
1235                    .or_default()
1236                    .insert(key.to_string(), value);
1237                return Ok(());
1238            }
1239        }
1240        Err(format!("Buffer {:?} not found", buffer_id))
1241    }
1242
1243    /// Get all registered status bar tokens for Settings UI.
1244    /// Returns Vec of (token_key_with_braces, title).
1245    pub fn get_status_bar_elements(&self) -> Vec<(String, String)> {
1246        self.status_bar_token_registry
1247            .lock()
1248            .unwrap()
1249            .iter()
1250            .map(|(k, title)| (format!("{{{}}}", k), title.clone()))
1251            .collect()
1252    }
1253
1254    /// Snapshot of token values for a specific buffer (render path).
1255    pub fn get_status_bar_element_values(&self, buffer_id: BufferId) -> HashMap<String, String> {
1256        for window in self.windows.values() {
1257            if let Some(values) = window.status_bar_values.get(&buffer_id) {
1258                return values.clone();
1259            }
1260        }
1261        HashMap::new()
1262    }
1263
1264    /// Current value of a single status-bar token for the given buffer, if any.
1265    /// Used by the plugin command dispatcher to detect no-op `SetStatusBarValue`
1266    /// commands (i.e. plugins re-publishing an unchanged value on every render).
1267    pub fn current_status_bar_value(&self, buffer_id: BufferId, key: &str) -> Option<&str> {
1268        for window in self.windows.values() {
1269            if let Some(values) = window.status_bar_values.get(&buffer_id) {
1270                if let Some(v) = values.get(key) {
1271                    return Some(v.as_str());
1272                }
1273                return None;
1274            }
1275        }
1276        None
1277    }
1278
1279    /// Remove every registry entry and per-buffer value belonging to a plugin.
1280    /// Called when a plugin is unloaded.
1281    fn remove_plugin_status_bar_elements(&mut self, plugin_name: &str) {
1282        let prefix = format!("{}:", plugin_name);
1283        self.status_bar_token_registry
1284            .lock()
1285            .unwrap()
1286            .retain(|k, _| !k.starts_with(&prefix));
1287        for window in self.windows.values_mut() {
1288            for values in window.status_bar_values.values_mut() {
1289                values.retain(|k, _| !k.starts_with(&prefix));
1290            }
1291        }
1292    }
1293}
1294
1295/// Parse a key string like "RET", "C-n", "M-x", "q" into KeyCode and KeyModifiers
1296///
1297/// Supports:
1298/// - Single characters: "a", "q", etc.
1299/// - Function keys: "F1", "F2", etc.
1300/// - Special keys: "RET", "TAB", "ESC", "SPC", "DEL", "BS"
1301/// - Modifiers: "C-" (Control), "M-" (Alt/Meta), "S-" (Shift)
1302/// - Combinations: "C-n", "M-x", "C-M-s", etc.
1303fn parse_key_string(key_str: &str) -> Option<(KeyCode, KeyModifiers)> {
1304    use crossterm::event::{KeyCode, KeyModifiers};
1305
1306    let mut modifiers = KeyModifiers::NONE;
1307    let mut remaining = key_str;
1308
1309    // Parse modifiers
1310    loop {
1311        if remaining.starts_with("C-") {
1312            modifiers |= KeyModifiers::CONTROL;
1313            remaining = &remaining[2..];
1314        } else if remaining.starts_with("M-") {
1315            modifiers |= KeyModifiers::ALT;
1316            remaining = &remaining[2..];
1317        } else if remaining.starts_with("S-") {
1318            modifiers |= KeyModifiers::SHIFT;
1319            remaining = &remaining[2..];
1320        } else {
1321            break;
1322        }
1323    }
1324
1325    // Parse the key
1326    // Use uppercase for matching special keys, but preserve original for single chars
1327    let upper = remaining.to_uppercase();
1328    let code = match upper.as_str() {
1329        "RET" | "RETURN" | "ENTER" => KeyCode::Enter,
1330        "TAB" => KeyCode::Tab,
1331        "BACKTAB" => KeyCode::BackTab,
1332        "ESC" | "ESCAPE" => KeyCode::Esc,
1333        "SPC" | "SPACE" => KeyCode::Char(' '),
1334        "DEL" | "DELETE" => KeyCode::Delete,
1335        "BS" | "BACKSPACE" => KeyCode::Backspace,
1336        "UP" => KeyCode::Up,
1337        "DOWN" => KeyCode::Down,
1338        "LEFT" => KeyCode::Left,
1339        "RIGHT" => KeyCode::Right,
1340        "HOME" => KeyCode::Home,
1341        "END" => KeyCode::End,
1342        "PAGEUP" | "PGUP" => KeyCode::PageUp,
1343        "PAGEDOWN" | "PGDN" => KeyCode::PageDown,
1344        s if s.starts_with('F') && s.len() > 1 => {
1345            // Function key (F1-F12)
1346            if let Ok(n) = s[1..].parse::<u8>() {
1347                KeyCode::F(n)
1348            } else {
1349                return None;
1350            }
1351        }
1352        _ if remaining.len() == 1 => {
1353            // Single character - use ORIGINAL remaining, not uppercased
1354            // For uppercase letters, add SHIFT modifier so 'J' != 'j'
1355            let c = remaining.chars().next()?;
1356            if c.is_ascii_uppercase() {
1357                modifiers |= KeyModifiers::SHIFT;
1358            }
1359            KeyCode::Char(c.to_ascii_lowercase())
1360        }
1361        _ => return None,
1362    };
1363
1364    // Plugins commonly spell Shift+Tab as "S-Tab"; terminals deliver
1365    // BackTab and the lookup-side `normalize_key` strips the redundant
1366    // SHIFT. Normalize on the binding side too so "S-Tab" and "BackTab"
1367    // both register as `(BackTab, NONE)` and match.
1368    if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
1369        return Some((KeyCode::BackTab, modifiers.difference(KeyModifiers::SHIFT)));
1370    }
1371
1372    Some((code, modifiers))
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377    use super::*;
1378    use lsp_types::{Position, Range as LspRange, TextDocumentContentChangeEvent};
1379    use tempfile::TempDir;
1380
1381    /// Create a test DirectoryContext with temp directories
1382    fn test_dir_context() -> (DirectoryContext, TempDir) {
1383        let temp_dir = TempDir::new().unwrap();
1384        let dir_context = DirectoryContext::for_testing(temp_dir.path());
1385        (dir_context, temp_dir)
1386    }
1387
1388    /// Create a test filesystem
1389    fn test_filesystem() -> Arc<dyn FileSystem + Send + Sync> {
1390        Arc::new(crate::model::filesystem::StdFileSystem)
1391    }
1392
1393    #[test]
1394    fn parse_key_string_shift_tab_normalizes_to_backtab() {
1395        use crossterm::event::{KeyCode, KeyModifiers};
1396        // Plugins write "S-Tab" in their defineMode binding tables; the
1397        // terminal delivers BackTab (with SHIFT stripped by normalize_key
1398        // on lookup). Without this normalization, the binding never
1399        // matches.
1400        assert_eq!(
1401            parse_key_string("S-Tab"),
1402            Some((KeyCode::BackTab, KeyModifiers::NONE)),
1403        );
1404        assert_eq!(
1405            parse_key_string("BackTab"),
1406            Some((KeyCode::BackTab, KeyModifiers::NONE)),
1407        );
1408        // Plain Tab is unaffected.
1409        assert_eq!(
1410            parse_key_string("Tab"),
1411            Some((KeyCode::Tab, KeyModifiers::NONE)),
1412        );
1413    }
1414
1415    #[test]
1416    fn test_editor_new() {
1417        let config = Config::default();
1418        let (dir_context, _temp) = test_dir_context();
1419        let editor = Editor::new(
1420            config,
1421            80,
1422            24,
1423            dir_context,
1424            crate::view::color_support::ColorCapability::TrueColor,
1425            test_filesystem(),
1426        )
1427        .unwrap();
1428
1429        assert_eq!(editor.buffers().len(), 1);
1430        assert!(!editor.should_quit());
1431    }
1432
1433    #[test]
1434    fn test_new_buffer() {
1435        let config = Config::default();
1436        let (dir_context, _temp) = test_dir_context();
1437        let mut editor = Editor::new(
1438            config,
1439            80,
1440            24,
1441            dir_context,
1442            crate::view::color_support::ColorCapability::TrueColor,
1443            test_filesystem(),
1444        )
1445        .unwrap();
1446
1447        let id = editor.new_buffer();
1448        assert_eq!(editor.buffers().len(), 2);
1449        assert_eq!(editor.active_buffer(), id);
1450    }
1451
1452    #[test]
1453    #[ignore]
1454    fn test_clipboard() {
1455        let config = Config::default();
1456        let (dir_context, _temp) = test_dir_context();
1457        let mut editor = Editor::new(
1458            config,
1459            80,
1460            24,
1461            dir_context,
1462            crate::view::color_support::ColorCapability::TrueColor,
1463            test_filesystem(),
1464        )
1465        .unwrap();
1466
1467        // Manually set clipboard (using internal to avoid system clipboard in tests)
1468        editor.clipboard.set_internal("test".to_string());
1469
1470        // Paste should work
1471        editor.paste();
1472
1473        let content = editor.active_state().buffer.to_string().unwrap();
1474        assert_eq!(content, "test");
1475    }
1476
1477    #[test]
1478    fn test_action_to_events_insert_char() {
1479        let config = Config::default();
1480        let (dir_context, _temp) = test_dir_context();
1481        let mut editor = Editor::new(
1482            config,
1483            80,
1484            24,
1485            dir_context,
1486            crate::view::color_support::ColorCapability::TrueColor,
1487            test_filesystem(),
1488        )
1489        .unwrap();
1490
1491        let events = editor
1492            .active_window_mut()
1493            .action_to_events(Action::InsertChar('a'));
1494        assert!(events.is_some());
1495
1496        let events = events.unwrap();
1497        assert_eq!(events.len(), 1);
1498
1499        match &events[0] {
1500            Event::Insert { position, text, .. } => {
1501                assert_eq!(*position, 0);
1502                assert_eq!(text, "a");
1503            }
1504            _ => panic!("Expected Insert event"),
1505        }
1506    }
1507
1508    #[test]
1509    fn test_action_to_events_move_right() {
1510        let config = Config::default();
1511        let (dir_context, _temp) = test_dir_context();
1512        let mut editor = Editor::new(
1513            config,
1514            80,
1515            24,
1516            dir_context,
1517            crate::view::color_support::ColorCapability::TrueColor,
1518            test_filesystem(),
1519        )
1520        .unwrap();
1521
1522        // Insert some text first
1523        let cursor_id = editor.active_cursors().primary_id();
1524        editor.apply_event_to_active_buffer(&Event::Insert {
1525            position: 0,
1526            text: "hello".to_string(),
1527            cursor_id,
1528        });
1529
1530        let events = editor
1531            .active_window_mut()
1532            .action_to_events(Action::MoveRight);
1533        assert!(events.is_some());
1534
1535        let events = events.unwrap();
1536        assert_eq!(events.len(), 1);
1537
1538        match &events[0] {
1539            Event::MoveCursor {
1540                new_position,
1541                new_anchor,
1542                ..
1543            } => {
1544                // Cursor was at 5 (end of "hello"), stays at 5 (can't move beyond end)
1545                assert_eq!(*new_position, 5);
1546                assert_eq!(*new_anchor, None); // No selection
1547            }
1548            _ => panic!("Expected MoveCursor event"),
1549        }
1550    }
1551
1552    #[test]
1553    fn test_action_to_events_move_up_down() {
1554        let config = Config::default();
1555        let (dir_context, _temp) = test_dir_context();
1556        let mut editor = Editor::new(
1557            config,
1558            80,
1559            24,
1560            dir_context,
1561            crate::view::color_support::ColorCapability::TrueColor,
1562            test_filesystem(),
1563        )
1564        .unwrap();
1565
1566        // Insert multi-line text
1567        let cursor_id = editor.active_cursors().primary_id();
1568        editor.apply_event_to_active_buffer(&Event::Insert {
1569            position: 0,
1570            text: "line1\nline2\nline3".to_string(),
1571            cursor_id,
1572        });
1573
1574        // Move cursor to start of line 2
1575        editor.apply_event_to_active_buffer(&Event::MoveCursor {
1576            cursor_id,
1577            old_position: 0, // TODO: Get actual old position
1578            new_position: 6,
1579            old_anchor: None, // TODO: Get actual old anchor
1580            new_anchor: None,
1581            old_sticky_column: 0,
1582            new_sticky_column: 0,
1583        });
1584
1585        // Test move up
1586        let events = editor.active_window_mut().action_to_events(Action::MoveUp);
1587        assert!(events.is_some());
1588        let events = events.unwrap();
1589        assert_eq!(events.len(), 1);
1590
1591        match &events[0] {
1592            Event::MoveCursor { new_position, .. } => {
1593                assert_eq!(*new_position, 0); // Should be at start of line 1
1594            }
1595            _ => panic!("Expected MoveCursor event"),
1596        }
1597    }
1598
1599    #[test]
1600    fn test_action_to_events_insert_newline() {
1601        let config = Config::default();
1602        let (dir_context, _temp) = test_dir_context();
1603        let mut editor = Editor::new(
1604            config,
1605            80,
1606            24,
1607            dir_context,
1608            crate::view::color_support::ColorCapability::TrueColor,
1609            test_filesystem(),
1610        )
1611        .unwrap();
1612
1613        let events = editor
1614            .active_window_mut()
1615            .action_to_events(Action::InsertNewline);
1616        assert!(events.is_some());
1617
1618        let events = events.unwrap();
1619        assert_eq!(events.len(), 1);
1620
1621        match &events[0] {
1622            Event::Insert { text, .. } => {
1623                assert_eq!(text, "\n");
1624            }
1625            _ => panic!("Expected Insert event"),
1626        }
1627    }
1628
1629    #[test]
1630    fn test_action_to_events_unimplemented() {
1631        let config = Config::default();
1632        let (dir_context, _temp) = test_dir_context();
1633        let mut editor = Editor::new(
1634            config,
1635            80,
1636            24,
1637            dir_context,
1638            crate::view::color_support::ColorCapability::TrueColor,
1639            test_filesystem(),
1640        )
1641        .unwrap();
1642
1643        // These actions should return None (not yet implemented)
1644        assert!(editor
1645            .active_window_mut()
1646            .action_to_events(Action::Save)
1647            .is_none());
1648        assert!(editor
1649            .active_window_mut()
1650            .action_to_events(Action::Quit)
1651            .is_none());
1652        assert!(editor
1653            .active_window_mut()
1654            .action_to_events(Action::Undo)
1655            .is_none());
1656    }
1657
1658    #[test]
1659    fn test_action_to_events_delete_backward() {
1660        let config = Config::default();
1661        let (dir_context, _temp) = test_dir_context();
1662        let mut editor = Editor::new(
1663            config,
1664            80,
1665            24,
1666            dir_context,
1667            crate::view::color_support::ColorCapability::TrueColor,
1668            test_filesystem(),
1669        )
1670        .unwrap();
1671
1672        // Insert some text first
1673        let cursor_id = editor.active_cursors().primary_id();
1674        editor.apply_event_to_active_buffer(&Event::Insert {
1675            position: 0,
1676            text: "hello".to_string(),
1677            cursor_id,
1678        });
1679
1680        let events = editor
1681            .active_window_mut()
1682            .action_to_events(Action::DeleteBackward);
1683        assert!(events.is_some());
1684
1685        let events = events.unwrap();
1686        assert_eq!(events.len(), 1);
1687
1688        match &events[0] {
1689            Event::Delete {
1690                range,
1691                deleted_text,
1692                ..
1693            } => {
1694                assert_eq!(range.clone(), 4..5); // Delete 'o'
1695                assert_eq!(deleted_text, "o");
1696            }
1697            _ => panic!("Expected Delete event"),
1698        }
1699    }
1700
1701    #[test]
1702    fn test_action_to_events_delete_forward() {
1703        let config = Config::default();
1704        let (dir_context, _temp) = test_dir_context();
1705        let mut editor = Editor::new(
1706            config,
1707            80,
1708            24,
1709            dir_context,
1710            crate::view::color_support::ColorCapability::TrueColor,
1711            test_filesystem(),
1712        )
1713        .unwrap();
1714
1715        // Insert some text first
1716        let cursor_id = editor.active_cursors().primary_id();
1717        editor.apply_event_to_active_buffer(&Event::Insert {
1718            position: 0,
1719            text: "hello".to_string(),
1720            cursor_id,
1721        });
1722
1723        // Move cursor to position 0
1724        editor.apply_event_to_active_buffer(&Event::MoveCursor {
1725            cursor_id,
1726            old_position: 0, // TODO: Get actual old position
1727            new_position: 0,
1728            old_anchor: None, // TODO: Get actual old anchor
1729            new_anchor: None,
1730            old_sticky_column: 0,
1731            new_sticky_column: 0,
1732        });
1733
1734        let events = editor
1735            .active_window_mut()
1736            .action_to_events(Action::DeleteForward);
1737        assert!(events.is_some());
1738
1739        let events = events.unwrap();
1740        assert_eq!(events.len(), 1);
1741
1742        match &events[0] {
1743            Event::Delete {
1744                range,
1745                deleted_text,
1746                ..
1747            } => {
1748                assert_eq!(range.clone(), 0..1); // Delete 'h'
1749                assert_eq!(deleted_text, "h");
1750            }
1751            _ => panic!("Expected Delete event"),
1752        }
1753    }
1754
1755    #[test]
1756    fn test_action_to_events_select_right() {
1757        let config = Config::default();
1758        let (dir_context, _temp) = test_dir_context();
1759        let mut editor = Editor::new(
1760            config,
1761            80,
1762            24,
1763            dir_context,
1764            crate::view::color_support::ColorCapability::TrueColor,
1765            test_filesystem(),
1766        )
1767        .unwrap();
1768
1769        // Insert some text first
1770        let cursor_id = editor.active_cursors().primary_id();
1771        editor.apply_event_to_active_buffer(&Event::Insert {
1772            position: 0,
1773            text: "hello".to_string(),
1774            cursor_id,
1775        });
1776
1777        // Move cursor to position 0
1778        editor.apply_event_to_active_buffer(&Event::MoveCursor {
1779            cursor_id,
1780            old_position: 0, // TODO: Get actual old position
1781            new_position: 0,
1782            old_anchor: None, // TODO: Get actual old anchor
1783            new_anchor: None,
1784            old_sticky_column: 0,
1785            new_sticky_column: 0,
1786        });
1787
1788        let events = editor
1789            .active_window_mut()
1790            .action_to_events(Action::SelectRight);
1791        assert!(events.is_some());
1792
1793        let events = events.unwrap();
1794        assert_eq!(events.len(), 1);
1795
1796        match &events[0] {
1797            Event::MoveCursor {
1798                new_position,
1799                new_anchor,
1800                ..
1801            } => {
1802                assert_eq!(*new_position, 1); // Moved to position 1
1803                assert_eq!(*new_anchor, Some(0)); // Anchor at start
1804            }
1805            _ => panic!("Expected MoveCursor event"),
1806        }
1807    }
1808
1809    #[test]
1810    fn test_action_to_events_select_all() {
1811        let config = Config::default();
1812        let (dir_context, _temp) = test_dir_context();
1813        let mut editor = Editor::new(
1814            config,
1815            80,
1816            24,
1817            dir_context,
1818            crate::view::color_support::ColorCapability::TrueColor,
1819            test_filesystem(),
1820        )
1821        .unwrap();
1822
1823        // Insert some text first
1824        let cursor_id = editor.active_cursors().primary_id();
1825        editor.apply_event_to_active_buffer(&Event::Insert {
1826            position: 0,
1827            text: "hello world".to_string(),
1828            cursor_id,
1829        });
1830
1831        let events = editor
1832            .active_window_mut()
1833            .action_to_events(Action::SelectAll);
1834        assert!(events.is_some());
1835
1836        let events = events.unwrap();
1837        assert_eq!(events.len(), 1);
1838
1839        match &events[0] {
1840            Event::MoveCursor {
1841                new_position,
1842                new_anchor,
1843                ..
1844            } => {
1845                assert_eq!(*new_position, 11); // At end of buffer
1846                assert_eq!(*new_anchor, Some(0)); // Anchor at start
1847            }
1848            _ => panic!("Expected MoveCursor event"),
1849        }
1850    }
1851
1852    #[test]
1853    fn test_action_to_events_document_nav() {
1854        let config = Config::default();
1855        let (dir_context, _temp) = test_dir_context();
1856        let mut editor = Editor::new(
1857            config,
1858            80,
1859            24,
1860            dir_context,
1861            crate::view::color_support::ColorCapability::TrueColor,
1862            test_filesystem(),
1863        )
1864        .unwrap();
1865
1866        // Insert multi-line text
1867        let cursor_id = editor.active_cursors().primary_id();
1868        editor.apply_event_to_active_buffer(&Event::Insert {
1869            position: 0,
1870            text: "line1\nline2\nline3".to_string(),
1871            cursor_id,
1872        });
1873
1874        // Test MoveDocumentStart
1875        let events = editor
1876            .active_window_mut()
1877            .action_to_events(Action::MoveDocumentStart);
1878        assert!(events.is_some());
1879        let events = events.unwrap();
1880        match &events[0] {
1881            Event::MoveCursor { new_position, .. } => {
1882                assert_eq!(*new_position, 0);
1883            }
1884            _ => panic!("Expected MoveCursor event"),
1885        }
1886
1887        // Test MoveDocumentEnd
1888        let events = editor
1889            .active_window_mut()
1890            .action_to_events(Action::MoveDocumentEnd);
1891        assert!(events.is_some());
1892        let events = events.unwrap();
1893        match &events[0] {
1894            Event::MoveCursor { new_position, .. } => {
1895                assert_eq!(*new_position, 17); // End of buffer
1896            }
1897            _ => panic!("Expected MoveCursor event"),
1898        }
1899    }
1900
1901    #[test]
1902    fn test_action_to_events_remove_secondary_cursors() {
1903        use crate::model::event::CursorId;
1904
1905        let config = Config::default();
1906        let (dir_context, _temp) = test_dir_context();
1907        let mut editor = Editor::new(
1908            config,
1909            80,
1910            24,
1911            dir_context,
1912            crate::view::color_support::ColorCapability::TrueColor,
1913            test_filesystem(),
1914        )
1915        .unwrap();
1916
1917        // Insert some text first to have positions to place cursors
1918        let cursor_id = editor.active_cursors().primary_id();
1919        editor.apply_event_to_active_buffer(&Event::Insert {
1920            position: 0,
1921            text: "hello world test".to_string(),
1922            cursor_id,
1923        });
1924
1925        // Add secondary cursors at different positions to avoid normalization merging
1926        editor.apply_event_to_active_buffer(&Event::AddCursor {
1927            cursor_id: CursorId(1),
1928            position: 5,
1929            anchor: None,
1930        });
1931        editor.apply_event_to_active_buffer(&Event::AddCursor {
1932            cursor_id: CursorId(2),
1933            position: 10,
1934            anchor: None,
1935        });
1936
1937        assert_eq!(editor.active_cursors().count(), 3);
1938
1939        // Find the first cursor ID (the one that will be kept)
1940        let first_id = editor
1941            .active_cursors()
1942            .iter()
1943            .map(|(id, _)| id)
1944            .min_by_key(|id| id.0)
1945            .expect("Should have at least one cursor");
1946
1947        // RemoveSecondaryCursors should generate RemoveCursor events
1948        let events = editor
1949            .active_window_mut()
1950            .action_to_events(Action::RemoveSecondaryCursors);
1951        assert!(events.is_some());
1952
1953        let events = events.unwrap();
1954        // Should have RemoveCursor events for the two secondary cursors
1955        // Plus ClearAnchor events for all cursors (to clear Emacs mark mode)
1956        let remove_cursor_events: Vec<_> = events
1957            .iter()
1958            .filter_map(|e| match e {
1959                Event::RemoveCursor { cursor_id, .. } => Some(*cursor_id),
1960                _ => None,
1961            })
1962            .collect();
1963
1964        // Should have 2 RemoveCursor events (one for each secondary cursor)
1965        assert_eq!(remove_cursor_events.len(), 2);
1966
1967        for cursor_id in &remove_cursor_events {
1968            // Should not be the first cursor (the one we're keeping)
1969            assert_ne!(*cursor_id, first_id);
1970        }
1971    }
1972
1973    #[test]
1974    fn test_action_to_events_scroll() {
1975        let config = Config::default();
1976        let (dir_context, _temp) = test_dir_context();
1977        let mut editor = Editor::new(
1978            config,
1979            80,
1980            24,
1981            dir_context,
1982            crate::view::color_support::ColorCapability::TrueColor,
1983            test_filesystem(),
1984        )
1985        .unwrap();
1986
1987        // Test ScrollUp
1988        let events = editor
1989            .active_window_mut()
1990            .action_to_events(Action::ScrollUp);
1991        assert!(events.is_some());
1992        let events = events.unwrap();
1993        assert_eq!(events.len(), 1);
1994        match &events[0] {
1995            Event::Scroll { line_offset } => {
1996                assert_eq!(*line_offset, -1);
1997            }
1998            _ => panic!("Expected Scroll event"),
1999        }
2000
2001        // Test ScrollDown
2002        let events = editor
2003            .active_window_mut()
2004            .action_to_events(Action::ScrollDown);
2005        assert!(events.is_some());
2006        let events = events.unwrap();
2007        assert_eq!(events.len(), 1);
2008        match &events[0] {
2009            Event::Scroll { line_offset } => {
2010                assert_eq!(*line_offset, 1);
2011            }
2012            _ => panic!("Expected Scroll event"),
2013        }
2014    }
2015
2016    #[test]
2017    fn test_action_to_events_none() {
2018        let config = Config::default();
2019        let (dir_context, _temp) = test_dir_context();
2020        let mut editor = Editor::new(
2021            config,
2022            80,
2023            24,
2024            dir_context,
2025            crate::view::color_support::ColorCapability::TrueColor,
2026            test_filesystem(),
2027        )
2028        .unwrap();
2029
2030        // None action should return None
2031        let events = editor.active_window_mut().action_to_events(Action::None);
2032        assert!(events.is_none());
2033    }
2034
2035    #[test]
2036    fn test_lsp_incremental_insert_generates_correct_range() {
2037        // Test that insert events generate correct incremental LSP changes
2038        // with zero-width ranges at the insertion point
2039        use crate::model::buffer::Buffer;
2040
2041        let buffer = Buffer::from_str_test("hello\nworld");
2042
2043        // Insert "NEW" at position 0 (before "hello")
2044        // Expected LSP range: line 0, char 0 to line 0, char 0 (zero-width)
2045        let position = 0;
2046        let (line, character) = buffer.position_to_lsp_position(position);
2047
2048        assert_eq!(line, 0, "Insertion at start should be line 0");
2049        assert_eq!(character, 0, "Insertion at start should be char 0");
2050
2051        // Create the range as we do in notify_lsp_change
2052        let lsp_pos = Position::new(line as u32, character as u32);
2053        let lsp_range = LspRange::new(lsp_pos, lsp_pos);
2054
2055        assert_eq!(lsp_range.start.line, 0);
2056        assert_eq!(lsp_range.start.character, 0);
2057        assert_eq!(lsp_range.end.line, 0);
2058        assert_eq!(lsp_range.end.character, 0);
2059        assert_eq!(
2060            lsp_range.start, lsp_range.end,
2061            "Insert should have zero-width range"
2062        );
2063
2064        // Test insertion at middle of first line (position 3, after "hel")
2065        let position = 3;
2066        let (line, character) = buffer.position_to_lsp_position(position);
2067
2068        assert_eq!(line, 0);
2069        assert_eq!(character, 3);
2070
2071        // Test insertion at start of second line (position 6, after "hello\n")
2072        let position = 6;
2073        let (line, character) = buffer.position_to_lsp_position(position);
2074
2075        assert_eq!(line, 1, "Position after newline should be line 1");
2076        assert_eq!(character, 0, "Position at start of line 2 should be char 0");
2077    }
2078
2079    #[test]
2080    fn test_lsp_incremental_delete_generates_correct_range() {
2081        // Test that delete events generate correct incremental LSP changes
2082        // with proper start/end ranges
2083        use crate::model::buffer::Buffer;
2084
2085        let buffer = Buffer::from_str_test("hello\nworld");
2086
2087        // Delete "ello" (positions 1-5 on line 0)
2088        let range_start = 1;
2089        let range_end = 5;
2090
2091        let (start_line, start_char) = buffer.position_to_lsp_position(range_start);
2092        let (end_line, end_char) = buffer.position_to_lsp_position(range_end);
2093
2094        assert_eq!(start_line, 0);
2095        assert_eq!(start_char, 1);
2096        assert_eq!(end_line, 0);
2097        assert_eq!(end_char, 5);
2098
2099        let lsp_range = LspRange::new(
2100            Position::new(start_line as u32, start_char as u32),
2101            Position::new(end_line as u32, end_char as u32),
2102        );
2103
2104        assert_eq!(lsp_range.start.line, 0);
2105        assert_eq!(lsp_range.start.character, 1);
2106        assert_eq!(lsp_range.end.line, 0);
2107        assert_eq!(lsp_range.end.character, 5);
2108        assert_ne!(
2109            lsp_range.start, lsp_range.end,
2110            "Delete should have non-zero range"
2111        );
2112
2113        // Test deletion across lines (delete "o\nw" - positions 4-8)
2114        let range_start = 4;
2115        let range_end = 8;
2116
2117        let (start_line, start_char) = buffer.position_to_lsp_position(range_start);
2118        let (end_line, end_char) = buffer.position_to_lsp_position(range_end);
2119
2120        assert_eq!(start_line, 0, "Delete start on line 0");
2121        assert_eq!(start_char, 4, "Delete start at char 4");
2122        assert_eq!(end_line, 1, "Delete end on line 1");
2123        assert_eq!(end_char, 2, "Delete end at char 2 of line 1");
2124    }
2125
2126    #[test]
2127    fn test_lsp_incremental_utf16_encoding() {
2128        // Test that position_to_lsp_position correctly handles UTF-16 encoding
2129        // LSP uses UTF-16 code units, not byte positions
2130        use crate::model::buffer::Buffer;
2131
2132        // Test with emoji (4 bytes in UTF-8, 2 code units in UTF-16)
2133        let buffer = Buffer::from_str_test("😀hello");
2134
2135        // Position 4 is after the emoji (4 bytes)
2136        let (line, character) = buffer.position_to_lsp_position(4);
2137
2138        assert_eq!(line, 0);
2139        assert_eq!(character, 2, "Emoji should count as 2 UTF-16 code units");
2140
2141        // Position 9 is after "😀hell" (4 bytes emoji + 5 bytes text)
2142        let (line, character) = buffer.position_to_lsp_position(9);
2143
2144        assert_eq!(line, 0);
2145        assert_eq!(
2146            character, 7,
2147            "Should be 2 (emoji) + 5 (text) = 7 UTF-16 code units"
2148        );
2149
2150        // Test with multi-byte character (é is 2 bytes in UTF-8, 1 code unit in UTF-16)
2151        let buffer = Buffer::from_str_test("café");
2152
2153        // Position 3 is after "caf" (3 bytes)
2154        let (line, character) = buffer.position_to_lsp_position(3);
2155
2156        assert_eq!(line, 0);
2157        assert_eq!(character, 3);
2158
2159        // Position 5 is after "café" (3 + 2 bytes)
2160        let (line, character) = buffer.position_to_lsp_position(5);
2161
2162        assert_eq!(line, 0);
2163        assert_eq!(character, 4, "é should count as 1 UTF-16 code unit");
2164    }
2165
2166    #[test]
2167    fn test_lsp_content_change_event_structure() {
2168        // Test that we can create TextDocumentContentChangeEvent for incremental updates
2169
2170        // Incremental insert
2171        let insert_change = TextDocumentContentChangeEvent {
2172            range: Some(LspRange::new(Position::new(0, 5), Position::new(0, 5))),
2173            range_length: None,
2174            text: "NEW".to_string(),
2175        };
2176
2177        assert!(insert_change.range.is_some());
2178        assert_eq!(insert_change.text, "NEW");
2179        let range = insert_change.range.unwrap();
2180        assert_eq!(
2181            range.start, range.end,
2182            "Insert should have zero-width range"
2183        );
2184
2185        // Incremental delete
2186        let delete_change = TextDocumentContentChangeEvent {
2187            range: Some(LspRange::new(Position::new(0, 2), Position::new(0, 7))),
2188            range_length: None,
2189            text: String::new(),
2190        };
2191
2192        assert!(delete_change.range.is_some());
2193        assert_eq!(delete_change.text, "");
2194        let range = delete_change.range.unwrap();
2195        assert_ne!(range.start, range.end, "Delete should have non-zero range");
2196        assert_eq!(range.start.line, 0);
2197        assert_eq!(range.start.character, 2);
2198        assert_eq!(range.end.line, 0);
2199        assert_eq!(range.end.character, 7);
2200    }
2201
2202    #[test]
2203    fn test_goto_matching_bracket_forward() {
2204        let config = Config::default();
2205        let (dir_context, _temp) = test_dir_context();
2206        let mut editor = Editor::new(
2207            config,
2208            80,
2209            24,
2210            dir_context,
2211            crate::view::color_support::ColorCapability::TrueColor,
2212            test_filesystem(),
2213        )
2214        .unwrap();
2215
2216        // Insert text with brackets
2217        let cursor_id = editor.active_cursors().primary_id();
2218        editor.apply_event_to_active_buffer(&Event::Insert {
2219            position: 0,
2220            text: "fn main() { let x = (1 + 2); }".to_string(),
2221            cursor_id,
2222        });
2223
2224        // Move cursor to opening brace '{'
2225        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2226            cursor_id,
2227            old_position: 31,
2228            new_position: 10,
2229            old_anchor: None,
2230            new_anchor: None,
2231            old_sticky_column: 0,
2232            new_sticky_column: 0,
2233        });
2234
2235        assert_eq!(editor.active_cursors().primary().position, 10);
2236
2237        // Call goto_matching_bracket
2238        editor.goto_matching_bracket();
2239
2240        // Should move to closing brace '}' at position 29
2241        // "fn main() { let x = (1 + 2); }"
2242        //            ^                   ^
2243        //           10                  29
2244        assert_eq!(editor.active_cursors().primary().position, 29);
2245    }
2246
2247    #[test]
2248    fn test_goto_matching_bracket_backward() {
2249        let config = Config::default();
2250        let (dir_context, _temp) = test_dir_context();
2251        let mut editor = Editor::new(
2252            config,
2253            80,
2254            24,
2255            dir_context,
2256            crate::view::color_support::ColorCapability::TrueColor,
2257            test_filesystem(),
2258        )
2259        .unwrap();
2260
2261        // Insert text with brackets
2262        let cursor_id = editor.active_cursors().primary_id();
2263        editor.apply_event_to_active_buffer(&Event::Insert {
2264            position: 0,
2265            text: "fn main() { let x = (1 + 2); }".to_string(),
2266            cursor_id,
2267        });
2268
2269        // Move cursor to closing paren ')'
2270        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2271            cursor_id,
2272            old_position: 31,
2273            new_position: 26,
2274            old_anchor: None,
2275            new_anchor: None,
2276            old_sticky_column: 0,
2277            new_sticky_column: 0,
2278        });
2279
2280        // Call goto_matching_bracket
2281        editor.goto_matching_bracket();
2282
2283        // Should move to opening paren '('
2284        assert_eq!(editor.active_cursors().primary().position, 20);
2285    }
2286
2287    #[test]
2288    fn test_goto_matching_bracket_nested() {
2289        let config = Config::default();
2290        let (dir_context, _temp) = test_dir_context();
2291        let mut editor = Editor::new(
2292            config,
2293            80,
2294            24,
2295            dir_context,
2296            crate::view::color_support::ColorCapability::TrueColor,
2297            test_filesystem(),
2298        )
2299        .unwrap();
2300
2301        // Insert text with nested brackets
2302        let cursor_id = editor.active_cursors().primary_id();
2303        editor.apply_event_to_active_buffer(&Event::Insert {
2304            position: 0,
2305            text: "{a{b{c}d}e}".to_string(),
2306            cursor_id,
2307        });
2308
2309        // Move cursor to first '{'
2310        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2311            cursor_id,
2312            old_position: 11,
2313            new_position: 0,
2314            old_anchor: None,
2315            new_anchor: None,
2316            old_sticky_column: 0,
2317            new_sticky_column: 0,
2318        });
2319
2320        // Call goto_matching_bracket
2321        editor.goto_matching_bracket();
2322
2323        // Should jump to last '}'
2324        assert_eq!(editor.active_cursors().primary().position, 10);
2325    }
2326
2327    #[test]
2328    fn test_search_case_sensitive() {
2329        let config = Config::default();
2330        let (dir_context, _temp) = test_dir_context();
2331        let mut editor = Editor::new(
2332            config,
2333            80,
2334            24,
2335            dir_context,
2336            crate::view::color_support::ColorCapability::TrueColor,
2337            test_filesystem(),
2338        )
2339        .unwrap();
2340
2341        // Insert text
2342        let cursor_id = editor.active_cursors().primary_id();
2343        editor.apply_event_to_active_buffer(&Event::Insert {
2344            position: 0,
2345            text: "Hello hello HELLO".to_string(),
2346            cursor_id,
2347        });
2348
2349        // Test case-insensitive search (default)
2350        editor.active_window_mut().search_case_sensitive = false;
2351        editor.perform_search("hello");
2352
2353        let search_state = editor.active_window().search_state.as_ref().unwrap();
2354        assert_eq!(
2355            search_state.matches.len(),
2356            3,
2357            "Should find all 3 matches case-insensitively"
2358        );
2359
2360        // Test case-sensitive search
2361        editor.active_window_mut().search_case_sensitive = true;
2362        editor.perform_search("hello");
2363
2364        let search_state = editor.active_window().search_state.as_ref().unwrap();
2365        assert_eq!(
2366            search_state.matches.len(),
2367            1,
2368            "Should find only 1 exact match"
2369        );
2370        assert_eq!(
2371            search_state.matches[0], 6,
2372            "Should find 'hello' at position 6"
2373        );
2374    }
2375
2376    #[test]
2377    fn test_search_whole_word() {
2378        let config = Config::default();
2379        let (dir_context, _temp) = test_dir_context();
2380        let mut editor = Editor::new(
2381            config,
2382            80,
2383            24,
2384            dir_context,
2385            crate::view::color_support::ColorCapability::TrueColor,
2386            test_filesystem(),
2387        )
2388        .unwrap();
2389
2390        // Insert text
2391        let cursor_id = editor.active_cursors().primary_id();
2392        editor.apply_event_to_active_buffer(&Event::Insert {
2393            position: 0,
2394            text: "test testing tested attest test".to_string(),
2395            cursor_id,
2396        });
2397
2398        // Test partial word match (default)
2399        editor.active_window_mut().search_whole_word = false;
2400        editor.active_window_mut().search_case_sensitive = true;
2401        editor.perform_search("test");
2402
2403        let search_state = editor.active_window().search_state.as_ref().unwrap();
2404        assert_eq!(
2405            search_state.matches.len(),
2406            5,
2407            "Should find 'test' in all occurrences"
2408        );
2409
2410        // Test whole word match
2411        editor.active_window_mut().search_whole_word = true;
2412        editor.perform_search("test");
2413
2414        let search_state = editor.active_window().search_state.as_ref().unwrap();
2415        assert_eq!(
2416            search_state.matches.len(),
2417            2,
2418            "Should find only whole word 'test'"
2419        );
2420        assert_eq!(search_state.matches[0], 0, "First match at position 0");
2421        assert_eq!(search_state.matches[1], 27, "Second match at position 27");
2422    }
2423
2424    #[test]
2425    fn test_search_scan_completes_when_capped() {
2426        // Regression test: when the incremental search scan hits MAX_MATCHES
2427        // early (e.g. at 15% of the file), the scan's `capped` flag is set to
2428        // true and the batch loop breaks.  The completion check in
2429        // process_search_scan() must also consider `capped` — otherwise the
2430        // scan gets stuck in an infinite loop showing "Searching... 15%".
2431        let config = Config::default();
2432        let (dir_context, _temp) = test_dir_context();
2433        let mut editor = Editor::new(
2434            config,
2435            80,
2436            24,
2437            dir_context,
2438            crate::view::color_support::ColorCapability::TrueColor,
2439            test_filesystem(),
2440        )
2441        .unwrap();
2442
2443        // Manually create a search scan state that is already capped but not
2444        // at the last chunk (simulating early cap at ~15%).
2445        let buffer_id = editor.active_buffer();
2446        let regex = regex::bytes::Regex::new("test").unwrap();
2447        let fake_chunks = vec![
2448            crate::model::buffer::LineScanChunk {
2449                leaf_index: 0,
2450                byte_len: 100,
2451                already_known: true,
2452            },
2453            crate::model::buffer::LineScanChunk {
2454                leaf_index: 1,
2455                byte_len: 100,
2456                already_known: true,
2457            },
2458        ];
2459
2460        let chunked = crate::model::buffer::ChunkedSearchState {
2461            chunks: fake_chunks,
2462            next_chunk: 1, // Only processed 1 of 2 chunks
2463            next_doc_offset: 100,
2464            total_bytes: 200,
2465            scanned_bytes: 100,
2466            regex,
2467            matches: vec![
2468                crate::model::buffer::SearchMatch {
2469                    byte_offset: 10,
2470                    length: 4,
2471                    line: 1,
2472                    column: 11,
2473                    context: String::new(),
2474                },
2475                crate::model::buffer::SearchMatch {
2476                    byte_offset: 50,
2477                    length: 4,
2478                    line: 1,
2479                    column: 51,
2480                    context: String::new(),
2481                },
2482            ],
2483            overlap_tail: Vec::new(),
2484            overlap_doc_offset: 0,
2485            max_matches: 10_000,
2486            capped: true, // Capped early — this is the key condition
2487            query_len: 4,
2488            running_line: 1,
2489        };
2490
2491        editor.active_window_mut().search_scan.start(
2492            buffer_id,
2493            Vec::new(),
2494            chunked,
2495            "test".to_string(),
2496            None,
2497            false,
2498            false,
2499            false,
2500        );
2501
2502        // process_search_scan should finalize the search (not loop forever)
2503        let result = editor.process_search_scan();
2504        assert!(
2505            result,
2506            "process_search_scan should return true (needs render)"
2507        );
2508
2509        // The scan state should be consumed (drained)
2510        assert_eq!(
2511            editor.active_window().search_scan.buffer_id(),
2512            None,
2513            "search_scan should be drained after capped scan completes"
2514        );
2515
2516        // Search state should be set with the accumulated matches
2517        let search_state = editor
2518            .active_window()
2519            .search_state
2520            .as_ref()
2521            .expect("search_state should be set after scan finishes");
2522        assert_eq!(search_state.matches.len(), 2, "Should have 2 matches");
2523        assert_eq!(search_state.query, "test");
2524        assert!(
2525            search_state.capped,
2526            "search_state should be marked as capped"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_bookmarks() {
2532        let config = Config::default();
2533        let (dir_context, _temp) = test_dir_context();
2534        let mut editor = Editor::new(
2535            config,
2536            80,
2537            24,
2538            dir_context,
2539            crate::view::color_support::ColorCapability::TrueColor,
2540            test_filesystem(),
2541        )
2542        .unwrap();
2543
2544        // Insert text
2545        let cursor_id = editor.active_cursors().primary_id();
2546        editor.apply_event_to_active_buffer(&Event::Insert {
2547            position: 0,
2548            text: "Line 1\nLine 2\nLine 3".to_string(),
2549            cursor_id,
2550        });
2551
2552        // Move cursor to line 2 start (position 7)
2553        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2554            cursor_id,
2555            old_position: 21,
2556            new_position: 7,
2557            old_anchor: None,
2558            new_anchor: None,
2559            old_sticky_column: 0,
2560            new_sticky_column: 0,
2561        });
2562
2563        // Set bookmark '1'
2564        editor.active_window_mut().set_bookmark('1');
2565        assert_eq!(
2566            editor
2567                .active_window()
2568                .bookmarks
2569                .get('1')
2570                .map(|b| b.position),
2571            Some(7)
2572        );
2573
2574        // Move cursor elsewhere
2575        editor.apply_event_to_active_buffer(&Event::MoveCursor {
2576            cursor_id,
2577            old_position: 7,
2578            new_position: 14,
2579            old_anchor: None,
2580            new_anchor: None,
2581            old_sticky_column: 0,
2582            new_sticky_column: 0,
2583        });
2584
2585        // Jump back to bookmark
2586        editor.jump_to_bookmark('1');
2587        assert_eq!(editor.active_cursors().primary().position, 7);
2588
2589        // Clear bookmark
2590        editor.active_window_mut().clear_bookmark('1');
2591        assert_eq!(editor.active_window().bookmarks.get('1'), None);
2592    }
2593
2594    #[test]
2595    fn test_action_enum_new_variants() {
2596        // Test that new actions can be parsed from strings
2597        use serde_json::json;
2598
2599        let args = HashMap::new();
2600        assert_eq!(
2601            Action::from_str("smart_home", &args),
2602            Some(Action::SmartHome)
2603        );
2604        assert_eq!(
2605            Action::from_str("dedent_selection", &args),
2606            Some(Action::DedentSelection)
2607        );
2608        assert_eq!(
2609            Action::from_str("toggle_comment", &args),
2610            Some(Action::ToggleComment)
2611        );
2612        assert_eq!(
2613            Action::from_str("goto_matching_bracket", &args),
2614            Some(Action::GoToMatchingBracket)
2615        );
2616        assert_eq!(
2617            Action::from_str("list_bookmarks", &args),
2618            Some(Action::ListBookmarks)
2619        );
2620        assert_eq!(
2621            Action::from_str("toggle_search_case_sensitive", &args),
2622            Some(Action::ToggleSearchCaseSensitive)
2623        );
2624        assert_eq!(
2625            Action::from_str("toggle_search_whole_word", &args),
2626            Some(Action::ToggleSearchWholeWord)
2627        );
2628
2629        // Test bookmark actions with arguments
2630        let mut args_with_char = HashMap::new();
2631        args_with_char.insert("char".to_string(), json!("5"));
2632        assert_eq!(
2633            Action::from_str("set_bookmark", &args_with_char),
2634            Some(Action::SetBookmark('5'))
2635        );
2636        assert_eq!(
2637            Action::from_str("jump_to_bookmark", &args_with_char),
2638            Some(Action::JumpToBookmark('5'))
2639        );
2640        assert_eq!(
2641            Action::from_str("clear_bookmark", &args_with_char),
2642            Some(Action::ClearBookmark('5'))
2643        );
2644    }
2645
2646    #[test]
2647    fn test_keybinding_new_defaults() {
2648        use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
2649
2650        // Test that new keybindings are properly registered in the "default" keymap
2651        // Note: We explicitly use "default" keymap, not Config::default() which uses
2652        // platform-specific keymaps (e.g., "macos" on macOS has different bindings)
2653        let mut config = Config::default();
2654        config.active_keybinding_map = crate::config::KeybindingMapName("default".to_string());
2655        let resolver = KeybindingResolver::new(&config);
2656
2657        // Test Ctrl+/ is ToggleComment (not CommandPalette)
2658        let event = KeyEvent {
2659            code: KeyCode::Char('/'),
2660            modifiers: KeyModifiers::CONTROL,
2661            kind: KeyEventKind::Press,
2662            state: KeyEventState::NONE,
2663        };
2664        let action = resolver.resolve(&event, KeyContext::Normal);
2665        assert_eq!(action, Action::ToggleComment);
2666
2667        // Test Ctrl+] is GoToMatchingBracket
2668        let event = KeyEvent {
2669            code: KeyCode::Char(']'),
2670            modifiers: KeyModifiers::CONTROL,
2671            kind: KeyEventKind::Press,
2672            state: KeyEventState::NONE,
2673        };
2674        let action = resolver.resolve(&event, KeyContext::Normal);
2675        assert_eq!(action, Action::GoToMatchingBracket);
2676
2677        // Test Shift+Tab is DedentSelection
2678        let event = KeyEvent {
2679            code: KeyCode::Tab,
2680            modifiers: KeyModifiers::SHIFT,
2681            kind: KeyEventKind::Press,
2682            state: KeyEventState::NONE,
2683        };
2684        let action = resolver.resolve(&event, KeyContext::Normal);
2685        assert_eq!(action, Action::DedentSelection);
2686
2687        // Test Ctrl+G is GotoLine
2688        let event = KeyEvent {
2689            code: KeyCode::Char('g'),
2690            modifiers: KeyModifiers::CONTROL,
2691            kind: KeyEventKind::Press,
2692            state: KeyEventState::NONE,
2693        };
2694        let action = resolver.resolve(&event, KeyContext::Normal);
2695        assert_eq!(action, Action::GotoLine);
2696
2697        // Test bookmark keybindings
2698        let event = KeyEvent {
2699            code: KeyCode::Char('5'),
2700            modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
2701            kind: KeyEventKind::Press,
2702            state: KeyEventState::NONE,
2703        };
2704        let action = resolver.resolve(&event, KeyContext::Normal);
2705        assert_eq!(action, Action::SetBookmark('5'));
2706
2707        let event = KeyEvent {
2708            code: KeyCode::Char('5'),
2709            modifiers: KeyModifiers::ALT,
2710            kind: KeyEventKind::Press,
2711            state: KeyEventState::NONE,
2712        };
2713        let action = resolver.resolve(&event, KeyContext::Normal);
2714        assert_eq!(action, Action::JumpToBookmark('5'));
2715    }
2716
2717    /// This test demonstrates the bug where LSP didChange notifications contain
2718    /// incorrect positions because they're calculated from the already-modified buffer.
2719    ///
2720    /// When applying LSP rename edits:
2721    /// 1. apply_events_to_buffer_as_bulk_edit() applies the edits to the buffer
2722    /// 2. Then calls notify_lsp_change() which calls collect_lsp_changes()
2723    /// 3. collect_lsp_changes() converts byte positions to LSP positions using
2724    ///    the CURRENT buffer state
2725    ///
2726    /// But the byte positions in the events are relative to the ORIGINAL buffer,
2727    /// not the modified one! This causes LSP to receive wrong positions.
2728    #[test]
2729    fn test_lsp_rename_didchange_positions_bug() {
2730        use crate::model::buffer::Buffer;
2731
2732        let config = Config::default();
2733        let (dir_context, _temp) = test_dir_context();
2734        let mut editor = Editor::new(
2735            config,
2736            80,
2737            24,
2738            dir_context,
2739            crate::view::color_support::ColorCapability::TrueColor,
2740            test_filesystem(),
2741        )
2742        .unwrap();
2743
2744        // Set buffer content: "fn foo(val: i32) {\n    val + 1\n}\n"
2745        // Line 0: positions 0-19 (includes newline)
2746        // Line 1: positions 19-31 (includes newline)
2747        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
2748        editor.active_state_mut().buffer =
2749            Buffer::from_str(initial, 1024 * 1024, test_filesystem());
2750
2751        // Simulate LSP rename batch: rename "val" to "value" in two places
2752        // This is applied in reverse order to preserve positions:
2753        // 1. Delete "val" at position 23 (line 1, char 4), insert "value"
2754        // 2. Delete "val" at position 7 (line 0, char 7), insert "value"
2755        let cursor_id = editor.active_cursors().primary_id();
2756
2757        let batch = Event::Batch {
2758            events: vec![
2759                // Second occurrence first (reverse order for position preservation)
2760                Event::Delete {
2761                    range: 23..26, // "val" on line 1
2762                    deleted_text: "val".to_string(),
2763                    cursor_id,
2764                },
2765                Event::Insert {
2766                    position: 23,
2767                    text: "value".to_string(),
2768                    cursor_id,
2769                },
2770                // First occurrence second
2771                Event::Delete {
2772                    range: 7..10, // "val" on line 0
2773                    deleted_text: "val".to_string(),
2774                    cursor_id,
2775                },
2776                Event::Insert {
2777                    position: 7,
2778                    text: "value".to_string(),
2779                    cursor_id,
2780                },
2781            ],
2782            description: "LSP Rename".to_string(),
2783        };
2784
2785        // CORRECT: Calculate LSP positions BEFORE applying batch
2786        let lsp_changes_before = editor.active_window().collect_lsp_changes(&batch);
2787
2788        // Now apply the batch (this is what apply_events_to_buffer_as_bulk_edit does)
2789        editor.apply_event_to_active_buffer(&batch);
2790
2791        // BUG DEMONSTRATION: Calculate LSP positions AFTER applying batch
2792        // This is what happens when notify_lsp_change is called after state.apply()
2793        let lsp_changes_after = editor.active_window().collect_lsp_changes(&batch);
2794
2795        // Verify buffer was correctly modified
2796        let final_content = editor.active_state().buffer.to_string().unwrap();
2797        assert_eq!(
2798            final_content, "fn foo(value: i32) {\n    value + 1\n}\n",
2799            "Buffer should have 'value' in both places"
2800        );
2801
2802        // The CORRECT positions (before applying batch):
2803        // - Delete at 23..26 should be line 1, char 4-7 (in original buffer)
2804        // - Insert at 23 should be line 1, char 4 (in original buffer)
2805        // - Delete at 7..10 should be line 0, char 7-10 (in original buffer)
2806        // - Insert at 7 should be line 0, char 7 (in original buffer)
2807        assert_eq!(lsp_changes_before.len(), 4, "Should have 4 changes");
2808
2809        let first_delete = &lsp_changes_before[0];
2810        let first_del_range = first_delete.range.unwrap();
2811        assert_eq!(
2812            first_del_range.start.line, 1,
2813            "First delete should be on line 1 (BEFORE)"
2814        );
2815        assert_eq!(
2816            first_del_range.start.character, 4,
2817            "First delete start should be at char 4 (BEFORE)"
2818        );
2819
2820        // The INCORRECT positions (after applying batch):
2821        // Since the buffer has changed, position 23 now points to different text!
2822        // Original buffer position 23 was start of "val" on line 1
2823        // But after rename, the buffer is "fn foo(value: i32) {\n    value + 1\n}\n"
2824        // Position 23 in new buffer is 'l' in "value" (line 1, offset into "value")
2825        assert_eq!(lsp_changes_after.len(), 4, "Should have 4 changes");
2826
2827        let first_delete_after = &lsp_changes_after[0];
2828        let first_del_range_after = first_delete_after.range.unwrap();
2829
2830        // THIS IS THE BUG: The positions are WRONG when calculated from modified buffer
2831        // The first delete's range.end position will be wrong because the buffer changed
2832        eprintln!("BEFORE modification:");
2833        eprintln!(
2834            "  Delete at line {}, char {}-{}",
2835            first_del_range.start.line,
2836            first_del_range.start.character,
2837            first_del_range.end.character
2838        );
2839        eprintln!("AFTER modification:");
2840        eprintln!(
2841            "  Delete at line {}, char {}-{}",
2842            first_del_range_after.start.line,
2843            first_del_range_after.start.character,
2844            first_del_range_after.end.character
2845        );
2846
2847        // The bug causes the position calculation to be wrong.
2848        // After applying the batch, position 23..26 in the modified buffer
2849        // is different from what it was in the original buffer.
2850        //
2851        // Modified buffer: "fn foo(value: i32) {\n    value + 1\n}\n"
2852        // Position 23 = 'l' in second "value"
2853        // Position 26 = 'e' in second "value"
2854        // This maps to line 1, char 2-5 (wrong!)
2855        //
2856        // Original buffer: "fn foo(val: i32) {\n    val + 1\n}\n"
2857        // Position 23 = 'v' in "val"
2858        // Position 26 = ' ' after "val"
2859        // This maps to line 1, char 4-7 (correct!)
2860
2861        // The positions are different! This demonstrates the bug.
2862        // Note: Due to how the batch is applied (all operations at once),
2863        // the exact positions may vary, but they will definitely be wrong.
2864        assert_ne!(
2865            first_del_range_after.end.character, first_del_range.end.character,
2866            "BUG CONFIRMED: LSP positions are different when calculated after buffer modification!"
2867        );
2868
2869        eprintln!("\n=== BUG DEMONSTRATED ===");
2870        eprintln!("When collect_lsp_changes() is called AFTER buffer modification,");
2871        eprintln!("the positions are WRONG because they're calculated from the");
2872        eprintln!("modified buffer, not the original buffer.");
2873        eprintln!("This causes the second rename to fail with 'content modified' error.");
2874        eprintln!("========================\n");
2875    }
2876
2877    #[test]
2878    fn test_lsp_rename_preserves_cursor_position() {
2879        use crate::model::buffer::Buffer;
2880
2881        let config = Config::default();
2882        let (dir_context, _temp) = test_dir_context();
2883        let mut editor = Editor::new(
2884            config,
2885            80,
2886            24,
2887            dir_context,
2888            crate::view::color_support::ColorCapability::TrueColor,
2889            test_filesystem(),
2890        )
2891        .unwrap();
2892
2893        // Set buffer content: "fn foo(val: i32) {\n    val + 1\n}\n"
2894        // Line 0: positions 0-19 (includes newline)
2895        // Line 1: positions 19-31 (includes newline)
2896        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
2897        editor.active_state_mut().buffer =
2898            Buffer::from_str(initial, 1024 * 1024, test_filesystem());
2899
2900        // Position cursor at the second "val" (position 23 = 'v' of "val" on line 1)
2901        let original_cursor_pos = 23;
2902        editor.active_cursors_mut().primary_mut().position = original_cursor_pos;
2903
2904        // Verify cursor is at the right position
2905        let buffer_text = editor.active_state().buffer.to_string().unwrap();
2906        let text_at_cursor = buffer_text[original_cursor_pos..original_cursor_pos + 3].to_string();
2907        assert_eq!(text_at_cursor, "val", "Cursor should be at 'val'");
2908
2909        // Simulate LSP rename batch: rename "val" to "value" in two places
2910        // Applied in reverse order (from end of file to start)
2911        let cursor_id = editor.active_cursors().primary_id();
2912        let buffer_id = editor.active_buffer();
2913
2914        let events = vec![
2915            // Second occurrence first (at position 23, line 1)
2916            Event::Delete {
2917                range: 23..26, // "val" on line 1
2918                deleted_text: "val".to_string(),
2919                cursor_id,
2920            },
2921            Event::Insert {
2922                position: 23,
2923                text: "value".to_string(),
2924                cursor_id,
2925            },
2926            // First occurrence second (at position 7, line 0)
2927            Event::Delete {
2928                range: 7..10, // "val" on line 0
2929                deleted_text: "val".to_string(),
2930                cursor_id,
2931            },
2932            Event::Insert {
2933                position: 7,
2934                text: "value".to_string(),
2935                cursor_id,
2936            },
2937        ];
2938
2939        // Apply the rename using bulk edit (this should preserve cursor position)
2940        editor
2941            .apply_events_to_buffer_as_bulk_edit(buffer_id, events, "LSP Rename".to_string())
2942            .unwrap();
2943
2944        // Verify buffer was correctly modified
2945        let final_content = editor.active_state().buffer.to_string().unwrap();
2946        assert_eq!(
2947            final_content, "fn foo(value: i32) {\n    value + 1\n}\n",
2948            "Buffer should have 'value' in both places"
2949        );
2950
2951        // The cursor was originally at position 23 (start of "val" on line 1).
2952        // After renaming:
2953        // - The first "val" (at pos 7-10) was replaced with "value" (5 chars instead of 3)
2954        //   This adds 2 bytes before the cursor.
2955        // - The second "val" at the cursor position was replaced.
2956        //
2957        // Expected cursor position: 23 + 2 = 25 (start of "value" on line 1)
2958        let final_cursor_pos = editor.active_cursors().primary().position;
2959        let expected_cursor_pos = 25; // original 23 + 2 (delta from first rename)
2960
2961        assert_eq!(
2962            final_cursor_pos, expected_cursor_pos,
2963            "Cursor should be at position {} (start of 'value' on line 1), but was at {}. \
2964             Original pos: {}, expected adjustment: +2 for first rename",
2965            expected_cursor_pos, final_cursor_pos, original_cursor_pos
2966        );
2967
2968        // Verify cursor is at start of the renamed symbol
2969        let text_at_new_cursor = &final_content[final_cursor_pos..final_cursor_pos + 5];
2970        assert_eq!(
2971            text_at_new_cursor, "value",
2972            "Cursor should be at the start of 'value' after rename"
2973        );
2974    }
2975
2976    #[test]
2977    fn test_lsp_rename_twice_consecutive() {
2978        // This test reproduces the bug where the second rename fails because
2979        // LSP positions are calculated incorrectly after the first rename.
2980        use crate::model::buffer::Buffer;
2981
2982        let config = Config::default();
2983        let (dir_context, _temp) = test_dir_context();
2984        let mut editor = Editor::new(
2985            config,
2986            80,
2987            24,
2988            dir_context,
2989            crate::view::color_support::ColorCapability::TrueColor,
2990            test_filesystem(),
2991        )
2992        .unwrap();
2993
2994        // Initial content: "fn foo(val: i32) {\n    val + 1\n}\n"
2995        let initial = "fn foo(val: i32) {\n    val + 1\n}\n";
2996        editor.active_state_mut().buffer =
2997            Buffer::from_str(initial, 1024 * 1024, test_filesystem());
2998
2999        let cursor_id = editor.active_cursors().primary_id();
3000        let buffer_id = editor.active_buffer();
3001
3002        // === FIRST RENAME: "val" -> "value" ===
3003        // Create events for first rename (applied in reverse order)
3004        let events1 = vec![
3005            // Second occurrence first (at position 23, line 1, char 4)
3006            Event::Delete {
3007                range: 23..26,
3008                deleted_text: "val".to_string(),
3009                cursor_id,
3010            },
3011            Event::Insert {
3012                position: 23,
3013                text: "value".to_string(),
3014                cursor_id,
3015            },
3016            // First occurrence (at position 7, line 0, char 7)
3017            Event::Delete {
3018                range: 7..10,
3019                deleted_text: "val".to_string(),
3020                cursor_id,
3021            },
3022            Event::Insert {
3023                position: 7,
3024                text: "value".to_string(),
3025                cursor_id,
3026            },
3027        ];
3028
3029        // Create batch for LSP change verification
3030        let batch1 = Event::Batch {
3031            events: events1.clone(),
3032            description: "LSP Rename 1".to_string(),
3033        };
3034
3035        // Collect LSP changes BEFORE applying (this is the fix)
3036        let lsp_changes1 = editor.active_window().collect_lsp_changes(&batch1);
3037
3038        // Verify first rename LSP positions are correct
3039        assert_eq!(
3040            lsp_changes1.len(),
3041            4,
3042            "First rename should have 4 LSP changes"
3043        );
3044
3045        // First delete should be at line 1, char 4-7 (second "val")
3046        let first_del = &lsp_changes1[0];
3047        let first_del_range = first_del.range.unwrap();
3048        assert_eq!(first_del_range.start.line, 1, "First delete line");
3049        assert_eq!(
3050            first_del_range.start.character, 4,
3051            "First delete start char"
3052        );
3053        assert_eq!(first_del_range.end.character, 7, "First delete end char");
3054
3055        // Apply first rename using bulk edit
3056        editor
3057            .apply_events_to_buffer_as_bulk_edit(buffer_id, events1, "LSP Rename 1".to_string())
3058            .unwrap();
3059
3060        // Verify buffer after first rename
3061        let after_first = editor.active_state().buffer.to_string().unwrap();
3062        assert_eq!(
3063            after_first, "fn foo(value: i32) {\n    value + 1\n}\n",
3064            "After first rename"
3065        );
3066
3067        // === SECOND RENAME: "value" -> "x" ===
3068        // Now "value" is at:
3069        // - Line 0, char 7-12 (positions 7-12 in buffer)
3070        // - Line 1, char 4-9 (positions 25-30 in buffer, because line 0 grew by 2)
3071        //
3072        // Buffer: "fn foo(value: i32) {\n    value + 1\n}\n"
3073        //          0123456789...
3074
3075        // Create events for second rename
3076        let events2 = vec![
3077            // Second occurrence first (at position 25, line 1, char 4)
3078            Event::Delete {
3079                range: 25..30,
3080                deleted_text: "value".to_string(),
3081                cursor_id,
3082            },
3083            Event::Insert {
3084                position: 25,
3085                text: "x".to_string(),
3086                cursor_id,
3087            },
3088            // First occurrence (at position 7, line 0, char 7)
3089            Event::Delete {
3090                range: 7..12,
3091                deleted_text: "value".to_string(),
3092                cursor_id,
3093            },
3094            Event::Insert {
3095                position: 7,
3096                text: "x".to_string(),
3097                cursor_id,
3098            },
3099        ];
3100
3101        // Create batch for LSP change verification
3102        let batch2 = Event::Batch {
3103            events: events2.clone(),
3104            description: "LSP Rename 2".to_string(),
3105        };
3106
3107        // Collect LSP changes BEFORE applying (this is the fix)
3108        let lsp_changes2 = editor.active_window().collect_lsp_changes(&batch2);
3109
3110        // Verify second rename LSP positions are correct
3111        // THIS IS WHERE THE BUG WOULD MANIFEST - if positions are wrong,
3112        // the LSP server would report "No references found at position"
3113        assert_eq!(
3114            lsp_changes2.len(),
3115            4,
3116            "Second rename should have 4 LSP changes"
3117        );
3118
3119        // First delete should be at line 1, char 4-9 (second "value")
3120        let second_first_del = &lsp_changes2[0];
3121        let second_first_del_range = second_first_del.range.unwrap();
3122        assert_eq!(
3123            second_first_del_range.start.line, 1,
3124            "Second rename first delete should be on line 1"
3125        );
3126        assert_eq!(
3127            second_first_del_range.start.character, 4,
3128            "Second rename first delete start should be at char 4"
3129        );
3130        assert_eq!(
3131            second_first_del_range.end.character, 9,
3132            "Second rename first delete end should be at char 9 (4 + 5 for 'value')"
3133        );
3134
3135        // Third delete should be at line 0, char 7-12 (first "value")
3136        let second_third_del = &lsp_changes2[2];
3137        let second_third_del_range = second_third_del.range.unwrap();
3138        assert_eq!(
3139            second_third_del_range.start.line, 0,
3140            "Second rename third delete should be on line 0"
3141        );
3142        assert_eq!(
3143            second_third_del_range.start.character, 7,
3144            "Second rename third delete start should be at char 7"
3145        );
3146        assert_eq!(
3147            second_third_del_range.end.character, 12,
3148            "Second rename third delete end should be at char 12 (7 + 5 for 'value')"
3149        );
3150
3151        // Apply second rename using bulk edit
3152        editor
3153            .apply_events_to_buffer_as_bulk_edit(buffer_id, events2, "LSP Rename 2".to_string())
3154            .unwrap();
3155
3156        // Verify buffer after second rename
3157        let after_second = editor.active_state().buffer.to_string().unwrap();
3158        assert_eq!(
3159            after_second, "fn foo(x: i32) {\n    x + 1\n}\n",
3160            "After second rename"
3161        );
3162    }
3163
3164    #[test]
3165    fn test_ensure_active_tab_visible_static_offset() {
3166        let config = Config::default();
3167        let (dir_context, _temp) = test_dir_context();
3168        let mut editor = Editor::new(
3169            config,
3170            80,
3171            24,
3172            dir_context,
3173            crate::view::color_support::ColorCapability::TrueColor,
3174            test_filesystem(),
3175        )
3176        .unwrap();
3177        let split_id = editor.split_manager().active_split();
3178
3179        // Create three buffers with long names to force scrolling.
3180        let buf1 = editor.new_buffer();
3181        editor
3182            .buffers_mut()
3183            .get_mut(&buf1)
3184            .unwrap()
3185            .buffer
3186            .rename_file_path(std::path::PathBuf::from("aaa_long_name_01.txt"));
3187        let buf2 = editor.new_buffer();
3188        editor
3189            .buffers_mut()
3190            .get_mut(&buf2)
3191            .unwrap()
3192            .buffer
3193            .rename_file_path(std::path::PathBuf::from("bbb_long_name_02.txt"));
3194        let buf3 = editor.new_buffer();
3195        editor
3196            .buffers_mut()
3197            .get_mut(&buf3)
3198            .unwrap()
3199            .buffer
3200            .rename_file_path(std::path::PathBuf::from("ccc_long_name_03.txt"));
3201
3202        {
3203            use crate::view::split::TabTarget;
3204            let view_state = editor.split_view_states_mut().get_mut(&split_id).unwrap();
3205            view_state.open_buffers = vec![
3206                TabTarget::Buffer(buf1),
3207                TabTarget::Buffer(buf2),
3208                TabTarget::Buffer(buf3),
3209            ];
3210            view_state.tab_scroll_offset = 50;
3211        }
3212
3213        // Force active buffer to first tab and ensure helper brings it into view.
3214        // Note: available_width must be >= tab width (2 + name_len) for offset to be 0
3215        // Tab width = 2 + 20 (name length) = 22, so we need at least 22
3216        editor
3217            .active_window_mut()
3218            .ensure_active_tab_visible(split_id, buf1, 25);
3219        assert_eq!(
3220            editor
3221                .split_view_states()
3222                .get(&split_id)
3223                .unwrap()
3224                .tab_scroll_offset,
3225            0
3226        );
3227
3228        // Now make the last tab active and ensure offset moves forward but stays bounded.
3229        editor
3230            .active_window_mut()
3231            .ensure_active_tab_visible(split_id, buf3, 25);
3232        let view_state = editor.split_view_states().get(&split_id).unwrap();
3233        assert!(view_state.tab_scroll_offset > 0);
3234        let buffer_ids: Vec<_> = view_state.buffer_tab_ids_vec();
3235        let total_width: usize = buffer_ids
3236            .iter()
3237            .enumerate()
3238            .map(|(idx, id)| {
3239                let state = editor.buffers().get(id).unwrap();
3240                let name_len = state
3241                    .buffer
3242                    .file_path()
3243                    .and_then(|p| p.file_name())
3244                    .and_then(|n| n.to_str())
3245                    .map(|s| s.chars().count())
3246                    .unwrap_or(0);
3247                let tab_width = 2 + name_len;
3248                if idx < buffer_ids.len() - 1 {
3249                    tab_width + 1 // separator
3250                } else {
3251                    tab_width
3252                }
3253            })
3254            .sum();
3255        assert!(view_state.tab_scroll_offset <= total_width);
3256    }
3257}