Skip to main content

fresh/app/
mod.rs

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