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