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