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