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