Skip to main content

fresh/app/
mod.rs

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