Skip to main content

fresh/app/window/
mod.rs

1//! Editor `Window` — a project-rooted unit of editor state.
2//!
3//! A `Window` bundles the state that is logically scoped to one
4//! project root: the file tree, ignore matcher, LSP client set,
5//! file watchers, split layout, and buffer membership. Switching the
6//! active window re-targets the entire editor UI (file explorer,
7//! quick-open, LSP roots) without recreating buffers, terminals, or
8//! plugin state — those live on the `Editor` and survive switches.
9//!
10//! See `docs/internal/orchestrator-sessions-design.md` for the full
11//! design rationale.
12//!
13//! ## Naming
14//!
15//! Internally we call these "windows" (modelled on VS Code windows)
16//! to disambiguate from Fresh's pre-existing workspace-recovery and
17//! config-layer "session" concepts. Orchestrator presents windows as
18//! "agent sessions" in its UX, since the parallel-agents domain
19//! language is what users see — but the editor types are `Window`,
20//! `WindowId`, etc.
21//!
22//! ## Migration status
23//!
24//! Steps 0a–0f, 0j, 0k phases 1–3, and 0l shipped. Per-subsystem
25//! state that used to warm-swap on `setActiveWindow` —
26//! `panel_ids`, `file_mod_times`, `file_explorer`, `lsp`, the
27//! `splits` pair, `buffers`, `buffer_metadata`, the terminal
28//! subsystem (`terminal_manager` + `terminal_buffers` +
29//! `terminal_backing_files` + `terminal_log_files`),
30//! `event_logs`, `position_history` (with its `in_navigation` /
31//! `suppress_position_history_once` companion flags),
32//! `bookmarks`, `grouped_subtrees`, `composite_buffers`,
33//! `composite_view_states`, all 23 LSP-request-tracking maps
34//! (pending-/in-flight/applied, debounce timers,
35//! `next_lsp_request_id`, `completion_items`, `dabbrev_state`,
36//! code-action attribution), the per-window async `bridge`, and
37//! the chrome surfaces (`status_message`, `plugin_status_message`,
38//! `prompt`) — all live directly on `Window`. `set_active_window`
39//! is a pointer write (plus first-dive seed allocation for
40//! windows that have never been activated).
41
42pub mod buffers;
43pub mod process_group;
44
45pub use buffers::WindowBuffers;
46pub use process_group::{LocalSignaller, ProcessGroupEntry, ProcessGroups, Signaller};
47
48use crate::app::types::{ChromeLayout, WindowLayoutCache};
49use crate::app::window_resources::WindowResources;
50use crate::model::event::{Event, LeafId};
51use crate::services::lsp::manager::LspManager;
52use crate::types::LspFeature;
53use crate::view::file_tree::FileTreeView;
54use crate::view::split::{SplitManager, SplitViewState};
55use fresh_core::{BufferId, WindowId};
56use std::collections::HashMap;
57use std::path::PathBuf;
58use std::sync::Arc;
59
60/// A project-rooted unit of editor state.
61///
62/// After Step 0b every per-subsystem field listed below is owned
63/// outright by the window — there are no warm-swap stashes.
64/// `setActiveWindow` is a pointer write; reads of the active
65/// window's state route through Editor accessors
66/// (`active_layout()`, `split_manager()`, `file_explorer()`, `lsp()`,
67/// `panel_ids()`, `file_mod_times()`, …). Cross-window access goes
68/// through `Editor.windows.get(&id)` directly.
69/// A clickable path-link highlighted under a Ctrl+hover in the live terminal
70/// grid. Coordinates are relative to the terminal content area.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct TerminalLinkHover {
73    /// The terminal buffer the link is in.
74    pub buffer_id: BufferId,
75    /// Grid row (0-based, within the content area) containing the link.
76    pub row: u16,
77    /// Column range (0-based char columns) the link spans, for underlining.
78    pub cols: std::ops::Range<usize>,
79}
80
81/// A terminal buffer's remembered interaction mode, restored whenever it
82/// regains focus (see `Window::sync_terminal_mode_flags`).
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum TerminalInteractionMode {
85    /// Keystrokes forwarded to the PTY; the buffer mirrors the live screen.
86    Live,
87    /// Read-only scrollback. Entered via Ctrl+Space, scroll-up, or process exit.
88    Scrollback,
89}
90
91/// Per-terminal-buffer editor state, keyed by `BufferId` in
92/// [`Window::terminal_buffers`]. PTY I/O lives in the `TerminalManager`; the
93/// byte-stream backing files stay keyed by `TerminalId`.
94#[derive(Debug, Clone)]
95pub struct TerminalBuffer {
96    /// The PTY session feeding this buffer, scoped to this window.
97    pub terminal_id: crate::services::terminal::TerminalId,
98    pub mode: TerminalInteractionMode,
99}
100
101impl TerminalBuffer {
102    /// A freshly opened/attached terminal buffer, which starts live.
103    pub fn new_live(terminal_id: crate::services::terminal::TerminalId) -> Self {
104        Self {
105            terminal_id,
106            mode: TerminalInteractionMode::Live,
107        }
108    }
109
110    pub fn is_live(&self) -> bool {
111        matches!(self.mode, TerminalInteractionMode::Live)
112    }
113
114    /// Route writes through [`Window::set_terminal_interaction_mode`] to keep
115    /// the mode single-sited.
116    pub fn set_mode(&mut self, mode: TerminalInteractionMode) {
117        self.mode = mode;
118    }
119}
120
121pub struct Window {
122    /// Stable identifier. The base window is always `WindowId(1)`.
123    pub id: WindowId,
124
125    /// This workspace's backend — *where* it acts, *whether* it may
126    /// (`workspace_trust`), and *with what env*. **Owned outright by this
127    /// window**, never shared with another: it lives here (not in the
128    /// `Clone` `WindowResources`) so the type system prevents one workspace's
129    /// authority/trust/env from leaking into another (issue #2280). The
130    /// editor's active backend is just `active_window().authority` — there is
131    /// no separate clonable editor-wide copy.
132    pub(crate) authority: crate::services::authority::Authority,
133
134    /// User-visible label. Defaults to the basename of `root` (or
135    /// "main" when the root is the original process cwd). Not
136    /// required to be unique.
137    pub label: String,
138
139    /// Canonical absolute path of the project root. Read-only after
140    /// construction; closing a window and creating a new one is the
141    /// way to "rename" the root.
142    pub root: PathBuf,
143
144    /// File-explorer view (expansion, scroll, selection). `None`
145    /// means "never opened" — the caller rebuilds at `root` on first
146    /// toggle. Each window has its own view; switching windows shows
147    /// the new window's tree (or none, if it hasn't been opened yet).
148    pub file_explorer: Option<FileTreeView>,
149
150    /// Polling-based mtime cache for auto-revert. Auto-revert only
151    /// fires for the active window's files; inactive windows' mtimes
152    /// stay frozen at dive-out time and resync on dive-back —
153    /// matching the user's mental model that a dormant window "is
154    /// paused".
155    pub file_mod_times: HashMap<PathBuf, std::time::SystemTime>,
156
157    /// LSP manager (running language servers, configs, per-language
158    /// root URIs). Each window owns its own LSP set, rooted at its
159    /// project root; inactive windows' servers remain running in the
160    /// background — that's the warm-LSP property the design's
161    /// trade-off discussion calls out as a memory cost worth paying
162    /// so dive-back is instant.
163    ///
164    /// `None` means "this window has never spawned any LSP"; the
165    /// next LSP feature trigger will lazily create one.
166    /// This window's language-server manager. Every window owns one,
167    /// built in [`Window::new`] rooted at the window's project root —
168    /// there is no "window without a manager" state (that was the
169    /// "No LSP manager available" bug). Servers are still spawned
170    /// lazily on demand; an idle window's manager holds only config.
171    pub lsp: LspManager,
172
173    /// Utility-dock panel-id → buffer-id occupancy. Each window
174    /// gets its own dock — when one window has the search panel
175    /// claimed and the user dives elsewhere, the new window starts
176    /// with an empty dock and rebuilds on demand.
177    pub panel_ids: HashMap<String, BufferId>,
178
179    /// Buffers attached to this window. Each window owns its
180    /// `EditorState`s outright; closing the window drops them.
181    /// Opening the same file in two windows produces two independent
182    /// buffers.
183    pub buffers: WindowBuffers,
184
185    /// Per-buffer metadata (display name, file path / LSP URI,
186    /// virtual-buffer mode, read-only flag, LSP-opened set, preview
187    /// flag, etc.) for the buffers in `Window.buffers`. Lives next
188    /// to the buffer storage it describes; closing a window drops
189    /// every metadata entry along with the buffers themselves.
190    pub buffer_metadata: HashMap<BufferId, crate::app::types::BufferMetadata>,
191
192    /// Per-buffer undo/redo event log. Lives next to `buffers`
193    /// because undo history is buffer-scoped — closing a window
194    /// drops the buffer and its log together.
195    pub event_logs: HashMap<BufferId, crate::model::event::EventLog>,
196
197    /// Status message (shown in this window's status bar). Per-window
198    /// because each window has its own context — a save in window A
199    /// shouldn't flash a status message into window B's UI. Only the
200    /// active window's chrome renders, so background-window status
201    /// messages are naturally invisible.
202    pub status_message: Option<String>,
203
204    /// Plugin-provided status message (displayed alongside the core
205    /// status, also per-window).
206    pub plugin_status_message: Option<String>,
207
208    /// Active prompt (minibuffer) for this window. Each window can
209    /// have its own prompt mid-flight; switching windows preserves
210    /// each window's prompt state independently.
211    pub prompt: Option<crate::view::prompt::Prompt>,
212
213    /// Per-window async bridge — the (Sender, Receiver) pair the
214    /// LSP manager (and per-window terminal/file-explorer tasks
215    /// once they migrate) uses to deliver async responses back to
216    /// the main loop. Each window owns its own channel so cleanup
217    /// on `closeWindow` is automatic (the receiver drops, senders
218    /// error and stop). Editor-global async messages (plugin
219    /// runtime callbacks, file-open dialog) flow through
220    /// `Editor.async_bridge` instead.
221    pub bridge: crate::services::async_bridge::AsyncBridge,
222
223    // ---- LSP request-tracking state (moved from Editor in Step 0k) ----
224    /// Per-window LSP request-id allocator. Each window's LspManager
225    /// talks to its own server connections, and each connection only
226    /// requires per-connection request-id uniqueness — no global
227    /// namespace needed. Starts at 0 per window.
228    pub next_lsp_request_id: u64,
229
230    /// Pending LSP completion request ids (multi-server).
231    pub pending_completion_requests: std::collections::HashSet<u64>,
232
233    /// Original LSP completion items (for type-to-filter).
234    pub completion_items: Option<Vec<lsp_types::CompletionItem>>,
235
236    /// Scheduled completion-trigger time (debounced quick-suggestions).
237    pub scheduled_completion_trigger: Option<std::time::Instant>,
238
239    /// Dabbrev cycling state (Alt+/ session).
240    pub dabbrev_state: Option<crate::app::DabbrevCycleState>,
241
242    /// Pending LSP go-to-definition request id.
243    pub pending_goto_definition_request: Option<u64>,
244
245    /// Pending LSP find-references request id and the symbol name.
246    pub pending_references_request: Option<u64>,
247    pub pending_references_symbol: String,
248
249    /// Pending LSP go-to-implementation request id and the symbol name.
250    pub pending_implementation_request: Option<u64>,
251    pub pending_implementation_symbol: String,
252
253    /// Pending LSP signature-help request id.
254    pub pending_signature_help_request: Option<u64>,
255
256    /// Pending LSP code-actions request ids and per-request server-name
257    /// attribution + the selected-from list.
258    pub pending_code_actions_requests: std::collections::HashSet<u64>,
259    pub pending_code_actions_server_names: std::collections::HashMap<u64, String>,
260    pub pending_code_actions: Option<Vec<(String, lsp_types::CodeActionOrCommand)>>,
261
262    /// Pending inlay-hints requests keyed by request id.
263    pub(crate) pending_inlay_hints_requests:
264        std::collections::HashMap<u64, crate::app::InlayHintsRequest>,
265
266    /// Pending folding-range requests + per-buffer in-flight tracking + debounce.
267    pub(crate) pending_folding_range_requests:
268        std::collections::HashMap<u64, crate::app::FoldingRangeRequest>,
269    pub folding_ranges_in_flight: std::collections::HashMap<BufferId, (u64, u64)>,
270    pub folding_ranges_debounce: std::collections::HashMap<BufferId, std::time::Instant>,
271
272    /// Pending semantic-tokens-full requests + per-buffer in-flight tracking +
273    /// the next-allowed-refresh debounce.
274    pub(crate) pending_semantic_token_requests:
275        std::collections::HashMap<u64, crate::app::SemanticTokenFullRequest>,
276    pub(crate) semantic_tokens_in_flight:
277        std::collections::HashMap<BufferId, (u64, u64, crate::app::SemanticTokensFullRequestKind)>,
278    pub semantic_tokens_full_debounce: std::collections::HashMap<BufferId, std::time::Instant>,
279
280    /// Pending semantic-tokens-range requests + per-buffer in-flight,
281    /// last-request, and last-applied tracking.
282    pub(crate) pending_semantic_token_range_requests:
283        std::collections::HashMap<u64, crate::app::SemanticTokenRangeRequest>,
284    pub semantic_tokens_range_in_flight:
285        std::collections::HashMap<BufferId, (u64, usize, usize, u64)>,
286    pub semantic_tokens_range_last_request:
287        std::collections::HashMap<BufferId, (usize, usize, u64, std::time::Instant)>,
288    pub semantic_tokens_range_applied: std::collections::HashMap<BufferId, (usize, usize, u64)>,
289
290    /// Back/forward navigation stack (cursor jumps, file switches)
291    /// scoped to this window. Each window has its own history so
292    /// switching windows doesn't pollute the other window's
293    /// back-stack — diving back into a window resumes navigation
294    /// where you left it.
295    pub position_history: crate::input::position_history::PositionHistory,
296
297    /// `true` while a back/forward jump is in progress. Suppresses
298    /// `track_cursor_movement` from recording the jump itself as a
299    /// new entry. Per-window so windows don't fight over the flag
300    /// during cross-window orchestration.
301    pub in_navigation: bool,
302
303    /// One-shot suppression of position-history recording for the
304    /// next buffer-switch (used by file-open paths that don't want
305    /// to leave a trail entry for the about-to-be-loaded file).
306    pub suppress_position_history_once: bool,
307
308    /// Bookmarks (single-char register → buffer + byte position) for
309    /// this window. Bookmarks point at this window's buffers and
310    /// follow the window across `setActiveWindow` switches — every
311    /// window has its own register set.
312    pub(crate) bookmarks: crate::app::bookmarks::BookmarkState,
313
314    /// Composite buffers in this window (separate from regular
315    /// buffers). These display multiple source buffers in a single
316    /// tab — Live Grep results, References, Diagnostics list,
317    /// etc. Owned per-window so the panel state follows the window
318    /// that opened it.
319    pub composite_buffers: HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
320
321    /// Per-split view state for composite buffers in this window.
322    /// Keyed by (split_id, buffer_id) — each split that hosts a
323    /// composite buffer gets its own scroll-row tracking.
324    pub composite_view_states:
325        HashMap<(LeafId, BufferId), crate::view::composite_view::CompositeViewState>,
326
327    /// Grouped `SplitNode` subtrees for this window, keyed by their
328    /// `LeafId` (which is what `TabTarget::Group(leaf_id)`
329    /// references). Each entry is a `SplitNode::Grouped` node
330    /// holding the layout for one buffer group (Live Grep, References,
331    /// Diagnostics, etc.). These subtrees are NOT part of the main
332    /// split tree — they live here and are dispatched to at render
333    /// time when the current split's active target is a `Group`.
334    /// Per-window because a buffer-group panel belongs to the window
335    /// that opened it.
336    pub grouped_subtrees: HashMap<LeafId, crate::view::split::SplitNode>,
337
338    /// Terminal subsystem (PTY processes + render-state grids) for
339    /// this window. Owned per-window so closing a window joins its
340    /// PTY threads — no orphan agents survive a `closeWindow`.
341    pub terminal_manager: crate::services::terminal::TerminalManager,
342
343    /// Per-terminal-buffer editor state, keyed by buffer id and scoped to
344    /// this window: the PTY id feeding the buffer plus its remembered
345    /// live/scrollback interaction mode. See [`TerminalBuffer`].
346    pub terminal_buffers: HashMap<BufferId, TerminalBuffer>,
347
348    /// Backing files for terminal buffers (the rendered visible-screen
349    /// + scrollback content the buffer actually displays).
350    pub terminal_backing_files: HashMap<crate::services::terminal::TerminalId, std::path::PathBuf>,
351
352    /// Raw log files for terminal buffers (the unfiltered byte stream
353    /// from the PTY, used for replay / save-history).
354    pub terminal_log_files: HashMap<crate::services::terminal::TerminalId, std::path::PathBuf>,
355
356    /// Terminal buffers whose tab title was set explicitly (plugin- or
357    /// command-derived). These are excluded from foreground-process
358    /// auto-naming so a program running inside doesn't clobber the chosen
359    /// title; an OSC title emitted by the program still takes precedence.
360    pub terminal_explicit_titles: std::collections::HashSet<BufferId>,
361
362    /// Last time foreground-process names were polled for terminal tab
363    /// auto-naming. Throttles the `tcgetpgrp` + `/proc` reads to roughly
364    /// once a second rather than every frame. `None` until the first poll.
365    pub(crate) terminal_fg_poll_at: Option<std::time::Instant>,
366
367    /// Cached foreground-process name per terminal buffer, refreshed on the
368    /// [`FG_POLL_INTERVAL`] poll. Present means a name was read; absent
369    /// means none was available (so callers fall back to the OSC title or
370    /// default). Applied to the tab every frame so the title stays put
371    /// between polls without re-running the syscall.
372    pub(crate) terminal_fg_cache: HashMap<BufferId, String>,
373
374    /// Plugin-managed per-window state. Outer key is plugin name,
375    /// inner is the plugin-defined key. Read via
376    /// `editor.getWindowState(key)` and written via
377    /// `editor.setWindowState(key, value)`. Persisted to the
378    /// orchestrator's global `windows.json` under the platform
379    /// data dir so it survives editor restarts.
380    pub plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
381
382    /// Declarative spec for *how to rebuild this workspace's backend* — the
383    /// persisted source of truth behind the live `resources.authority`. Set
384    /// when an authority is installed for the workspace (`setAuthority` →
385    /// `Plugin`, born-attached remote → `RemoteAgent`, new local → `Local`)
386    /// and round-tripped through the workspace's workspace file so a restart /
387    /// relaunch can reconnect the backend rather than degrade it to local.
388    /// `Local` (the default) for an ordinary host-local workspace. A workspace
389    /// whose spec is remote but whose live `authority` is local is *dormant*
390    /// — disconnected, awaiting reconnect. See
391    /// `docs/internal/PER_SESSION_BACKENDS_DESIGN.md`.
392    pub authority_spec: crate::services::authority::SessionAuthoritySpec,
393
394    /// Error from the most recent failed *reconnect* of this dormant remote
395    /// workspace (the dive-triggered `reconnect_dormant_session_if_needed`
396    /// path). `Some` drives the status-bar remote indicator into `FailedAttach`
397    /// for this window — a persistent, per-window signal that survives until the
398    /// next successful reconnect (cleared on success / on a fresh reconnect
399    /// attempt) or the user dismisses it. Per-window rather than the editor-wide
400    /// `remote_indicator_override` (which the devcontainer plugin owns) so a
401    /// failed SSH/kube reconnect on one workspace can't bleed its error onto
402    /// another window's indicator.
403    pub remote_reconnect_error: Option<String>,
404
405    /// Window-scoped layout hit-test cache: split-leaf rects, tab
406    /// rects, the file-explorer rect, separators, scrollbars, and
407    /// per-leaf `view_line_mappings` that mouse positioning and
408    /// visual-line motion read. Repopulated by the renderer on every
409    /// frame; stale until the next render after a window switch (the
410    /// post-switch render fills it in before any input handling).
411    /// Editor-chrome rects (status bar, menu, popups, prompt overlay)
412    /// live on `Window::chrome_layout` (also per-window).
413    pub(crate) layout_cache: WindowLayoutCache,
414
415    /// Per-window editor-chrome layout cache: status bar, menu,
416    /// popups, prompt overlay, full-frame cell-theme map. Each
417    /// window has its own status bar / prompt / popup state, so the
418    /// cache is per-window. Repopulated by the renderer for the
419    /// active window every frame.
420    pub(crate) chrome_layout: ChromeLayout,
421
422    /// Last-known terminal screen dimensions, mirrored from
423    /// `Editor::terminal_width` / `Editor::terminal_height` whenever
424    /// `Editor::resize` loops over windows. Per-window because
425    /// `Window::resize_visible_terminals` and other per-window resize
426    /// logic need the screen size without reaching back to `Editor`.
427    pub(crate) terminal_width: u16,
428    pub(crate) terminal_height: u16,
429
430    /// Effective width (cols) of the editor-global left dock, pushed
431    /// down by `Editor::relayout` (the single layout funnel). Mirrored
432    /// here — like `terminal_width` — so per-window terminal sizing
433    /// (`resize_visible_terminals`) can subtract the dock without
434    /// reaching back to `Editor`. `0` when no dock is shown. This is a
435    /// derived cache, never a source of truth: `Editor::dock` owns the
436    /// real placement and `relayout` recomputes this from it.
437    pub(crate) dock_cols: u16,
438
439    /// Editor-global resources shared by `Arc` clone (config, theme
440    /// registry, keybindings, command registry, filesystem authority,
441    /// the buffer-id allocator, …). See [`WindowResources`] for the
442    /// full inventory and rationale.
443    pub(crate) resources: WindowResources,
444
445    /// Buffer currently opened in "preview" (ephemeral) mode, together
446    /// with the split (pane) it lives in. At most one preview exists
447    /// per window. Pre Step-0 this lived on `Editor`; moved here so
448    /// preview tracking follows the window's other view-state.
449    ///
450    /// This is the **single source of truth** for preview state: a buffer is
451    /// "the preview" iff this tuple is `Some` and points at it
452    /// ([`Window::is_buffer_preview`]). At most one preview exists editor-wide,
453    /// so the "two previews" / "orphan preview flag" states are unrepresentable.
454    ///
455    /// Invariants:
456    /// - The preview is anchored to the split it was opened in.
457    /// - Cleared when the buffer is closed or promoted. Promotion fires the
458    ///   deferred `after_file_open` hook (see `promote_buffer_from_preview`).
459    pub preview: Option<(LeafId, BufferId)>,
460
461    /// Whether terminal mode is active in this window (input goes to
462    /// the active terminal buffer). Per-window because each window
463    /// has its own terminal set + active buffer.
464    pub terminal_mode: bool,
465
466    /// Path-link currently highlighted under a Ctrl+hover over the live
467    /// terminal grid. `Some` means the renderer underlines the given grid row
468    /// columns to signal it's clickable. Cleared when Ctrl is released or the
469    /// pointer leaves a resolvable path. See [`TerminalLinkHover`].
470    pub terminal_link_hover: Option<TerminalLinkHover>,
471
472    /// Track which byte ranges have been seen per buffer (for the
473    /// `lines_changed` plugin-hook optimisation). Keyed by `BufferId`,
474    /// follows the buffers onto Window.
475    pub seen_byte_ranges: HashMap<BufferId, std::collections::HashSet<(usize, usize)>>,
476
477    /// Previous viewport states for `viewport_changed` hook detection.
478    /// Stores `(top_byte, width, height)` from the end of the last
479    /// render frame. Keyed by `LeafId`, per-window because the splits
480    /// it tracks are per-window.
481    pub previous_viewports: HashMap<LeafId, (usize, u16, u16)>,
482
483    /// Whether scroll syncing applies to splits showing the same
484    /// buffer. Per-window UX toggle.
485    pub same_buffer_scroll_sync: bool,
486
487    /// Per-window interactive search-and-replace session state.
488    /// Drives the F+y/n/!/q UX during `replace_in_buffer` /
489    /// `replace_all`. Per-window because the search target buffer
490    /// and the visible matches are window-scoped.
491    pub(crate) interactive_replace_state: Option<crate::app::types::InteractiveReplaceState>,
492
493    /// Cross-split scroll-sync manager for side-by-side diff views.
494    /// Per-window because the splits it pairs are per-window.
495    pub scroll_sync_manager: crate::view::scroll_sync::ScrollSyncManager,
496
497    /// Whether the file-explorer panel is visible in this window.
498    pub file_explorer_visible: bool,
499
500    /// Whether a file-explorer rebuild is in flight (debounce flag).
501    pub file_explorer_sync_in_progress: bool,
502
503    /// Width of the file-explorer panel.
504    pub file_explorer_width: crate::config::ExplorerWidth,
505
506    /// Side (left/right) the file-explorer panel docks on.
507    pub file_explorer_side: crate::config::FileExplorerSide,
508
509    /// Pending toggles for show-hidden/show-gitignored that apply on
510    /// the next file-explorer rebuild.
511    pub pending_file_explorer_show_hidden: Option<bool>,
512    pub pending_file_explorer_show_gitignored: Option<bool>,
513
514    /// Decorations supplied by plugins for the file explorer (badges,
515    /// status icons, etc.) keyed by absolute path.
516    pub file_explorer_decorations:
517        HashMap<String, Vec<crate::view::file_tree::FileExplorerDecoration>>,
518
519    /// Compiled decoration lookup cache invalidated when
520    /// `file_explorer_decorations` changes.
521    pub file_explorer_decoration_cache: crate::view::file_tree::FileExplorerDecorationCache,
522
523    /// Slot overrides supplied by plugins for the file explorer keyed by
524    /// namespace. These are additive overrides: unspecified fields continue to
525    /// fall back to compatibility providers.
526    pub file_explorer_slot_overrides:
527        HashMap<String, Vec<fresh_core::file_explorer::FileExplorerSlotEntry>>,
528
529    /// Compiled slot-override lookup cache invalidated when
530    /// `file_explorer_slot_overrides` changes.
531    pub file_explorer_slot_override_cache: crate::view::file_tree::FileExplorerSlotOverrideCache,
532
533    /// Hover-popup correlation state (which buffer / cursor a hover
534    /// request was issued from). Per-window because hover requests
535    /// route through the active window's LSP.
536    pub(crate) hover: crate::app::hover::HoverState,
537
538    /// Active find-in-buffer search session (if any).
539    pub(crate) search_state: Option<crate::app::types::SearchState>,
540
541    /// Overlay namespace used for search-result highlights. Per-window
542    /// because the overlays it scopes are per-buffer (per-window).
543    pub search_namespace: crate::view::overlay::OverlayNamespace,
544
545    /// Range that should be reused when the next search is confirmed
546    /// (e.g. after the user picks a hit in the search overlay).
547    pub pending_search_range: Option<std::ops::Range<usize>>,
548
549    /// Last live-grep panel state (cached so re-opening the panel
550    /// preserves the user's query / scroll / selection).
551    pub live_grep_last_state: Option<crate::services::live_grep_state::LiveGrepLastState>,
552
553    /// Overlay-preview state used by the floating-prompt preview pane
554    /// when it's showing a buffer view.
555    pub overlay_preview_state: Option<crate::app::types::OverlayPreviewState>,
556
557    /// Whether auto-revert (poll-based file-mtime watching) is enabled
558    /// for buffers in this window.
559    pub auto_revert_enabled: bool,
560
561    /// Tracks rapid file-change events for debouncing the auto-revert
562    /// reload trigger.
563    pub file_rapid_change_counts: HashMap<PathBuf, (std::time::Instant, u32)>,
564
565    /// Cursor-position snapshot captured when the user opens the
566    /// goto-line prompt, restored on Esc.
567    pub(crate) goto_line_preview: Option<crate::app::GotoLinePreviewSnapshot>,
568
569    /// Pending plugin-issued prompt callback id (used by
570    /// `editor.startPrompt` to deliver the prompt result back).
571    pub pending_async_prompt_callback: Option<fresh_core::api::JsCallbackId>,
572
573    /// Buffer ids the user picked "save before quit" for via the
574    /// modified-buffers prompt; consumed in order on quit.
575    pub pending_quit_unnamed_save: Vec<BufferId>,
576
577    /// Per-window search UX toggles. Each window has its own search
578    /// session, so these flags follow the search state.
579    pub search_case_sensitive: bool,
580    pub search_whole_word: bool,
581    pub search_use_regex: bool,
582    pub search_confirm_each: bool,
583
584    /// Scheduled (debounced) per-buffer LSP feature requests for the
585    /// active window's LSP. Per-window because the LSP they target is
586    /// per-window (Step 0k).
587    pub scheduled_diagnostic_pull: Option<(BufferId, std::time::Instant)>,
588    pub scheduled_inlay_hints_request: Option<(BufferId, std::time::Instant)>,
589
590    /// LSP languages the user dismissed the "do you want to enable
591    /// LSP for this language?" popup for. Per-window because LSP is
592    /// per-window — different windows can prompt independently.
593    pub user_dismissed_lsp_languages: std::collections::HashSet<String>,
594
595    /// Active editor mode (e.g. "search", "replace", "macro-record").
596    /// Per-window because the modes drive UI affordances that belong
597    /// to one window's UX flow.
598    pub editor_mode: Option<String>,
599
600    /// Per-window prompt histories (one ring per `PromptType`). Each
601    /// window has its own minibuffer, so each maintains its own
602    /// history.
603    pub prompt_histories: HashMap<String, crate::input::input_history::InputHistory>,
604
605    /// Buffer id pending close-confirmation prompt resolution.
606    /// Per-window because the prompt that produced this is per-window.
607    pub pending_close_buffer: Option<BufferId>,
608
609    /// Pluggable completion service that orchestrates this window's
610    /// completion providers (dabbrev, buffer words, LSP, plugin
611    /// providers). Per-window because the providers it orchestrates
612    /// (notably the LSP set) are per-window.
613    pub completion_service: crate::services::completion::CompletionService,
614
615    /// Overlay namespace for LSP diagnostic overlays in this window
616    /// (filter / bulk-remove key). The diagnostics it scopes are buffer
617    /// overlays, and buffers are per-window, so the namespace follows.
618    pub lsp_diagnostic_namespace: crate::view::overlay::OverlayNamespace,
619
620    /// Last `result_id` seen from the LSP server per URI for incremental
621    /// pull diagnostics. Per-window because each window has its own
622    /// LSP manager and therefore its own result-id stream.
623    pub diagnostic_result_ids: HashMap<String, String>,
624
625    /// `$/progress` token → progress info for this window's LSP servers.
626    /// Drives the spinner in the status bar's LSP pill. Per-window
627    /// because the LspManager that emits these tokens is per-window.
628    pub(crate) lsp_progress: HashMap<String, crate::app::LspProgressInfo>,
629
630    /// Status of each `(language, server_name)` pair attached to this
631    /// window's LspManager (running, errored, restarting, …).
632    pub lsp_server_statuses:
633        HashMap<(String, String), crate::services::async_bridge::LspServerStatus>,
634
635    /// Plugin-contributed menu items merged into the LSP-Servers popup
636    /// (the one opened by clicking the LSP indicator). Keyed by
637    /// `(language, plugin_id)` so each plugin owns its own slice and
638    /// can refresh it independently. The items render as an extra
639    /// section in `build_and_show_lsp_status_popup` between the
640    /// built-in actions and the trailing "View Log / Dismiss" rows.
641    /// Selecting one fires `action_popup_result` with `popup_id =
642    /// "lsp_status"` and `action_id = "{plugin_id}|{item_id}"` so the
643    /// contributing plugin can react.
644    ///
645    /// See #1941 follow-up "Option B": instead of plugins pushing
646    /// their own separate popup (which created the stacked-popup UX
647    /// problem), they contribute items into the single LSP-Servers
648    /// popup.
649    pub lsp_menu_contributions: HashMap<(String, String), Vec<crate::app::LspMenuItem>>,
650
651    /// Recent `window/showMessage` payloads from this window's LSP
652    /// servers. Bounded ring (newest entries kept, drops the oldest
653    /// when the soft cap is exceeded).
654    pub(crate) lsp_window_messages: Vec<crate::app::LspMessageEntry>,
655
656    /// Recent `window/logMessage` payloads from this window's LSP
657    /// servers, on the same bounded-ring pattern as `lsp_window_messages`.
658    pub(crate) lsp_log_messages: Vec<crate::app::LspMessageEntry>,
659
660    /// Push-model diagnostics keyed by URI, then by server name. Each
661    /// `publishDiagnostics` from a server replaces that server's slice
662    /// for the URI; the merged view is materialised in
663    /// `stored_diagnostics`.
664    pub stored_push_diagnostics: HashMap<String, HashMap<String, Vec<lsp_types::Diagnostic>>>,
665
666    /// Pull-model diagnostics (rust-analyzer-style native pull)
667    /// keyed by URI. Independent of `stored_push_diagnostics`; the
668    /// two are merged into `stored_diagnostics` for plugin / overlay
669    /// consumption.
670    pub stored_pull_diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
671
672    /// Merged view of push + pull diagnostics, exposed to plugins.
673    /// `Arc` wrapper so plugin snapshots can hold a refcount-bumped
674    /// reference; mutation goes through `Arc::make_mut` (CoW).
675    pub stored_diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
676
677    /// Per-URI folding ranges from `textDocument/foldingRange`. Same
678    /// `Arc` + CoW pattern as `stored_diagnostics` so plugin snapshots
679    /// don't pin the underlying map across mutations.
680    pub stored_folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
681
682    /// Per-directory mtime cache (paired with `file_mod_times`) for
683    /// detecting file-tree changes in this window. Per-window because
684    /// the file tree is per-window.
685    pub dir_mod_times: HashMap<PathBuf, std::time::SystemTime>,
686
687    /// Last time auto-revert polled this window's open buffers.
688    pub last_auto_revert_poll: std::time::Instant,
689
690    /// Last time the file-tree change-detection poll fired for this window.
691    pub last_file_tree_poll: std::time::Instant,
692
693    /// Whether this window has resolved and seeded the `.git/index`
694    /// path in `dir_mod_times`.
695    pub git_index_resolved: bool,
696
697    /// Receiver for background file change poll results for this window.
698    /// `Some` while a metadata poll is in flight.
699    #[allow(clippy::type_complexity)]
700    pub pending_file_poll_rx:
701        Option<std::sync::mpsc::Receiver<Vec<(PathBuf, Option<std::time::SystemTime>)>>>,
702
703    /// Receiver for background directory change poll results for this window.
704    #[allow(clippy::type_complexity)]
705    pub pending_dir_poll_rx: Option<
706        std::sync::mpsc::Receiver<(
707            Vec<(
708                crate::view::file_tree::NodeId,
709                PathBuf,
710                Option<std::time::SystemTime>,
711            )>,
712            Option<(PathBuf, std::time::SystemTime)>,
713        )>,
714    >,
715
716    /// Terminals in this window that should not persist to the
717    /// workspace file. Plugin-created terminals default to ephemeral;
718    /// user-opened terminals are absent and persist as before.
719    pub ephemeral_terminals: std::collections::HashSet<crate::services::terminal::TerminalId>,
720
721    /// Argv each terminal was spawned with, when it ran a command other
722    /// than the plain shell (e.g. an Orchestrator agent). Captured at spawn
723    /// and persisted into the workspace so a restored workspace re-runs the
724    /// same command rather than coming back as a bare shell. Terminals
725    /// spawned as a plain shell have no entry.
726    pub terminal_commands:
727        std::collections::HashMap<crate::services::terminal::TerminalId, Vec<String>>,
728
729    /// Argv to run on *restore* instead of re-running the launch command,
730    /// for terminals that carry an agent-resume spec (Orchestrator sets this
731    /// to e.g. `claude --resume <id>` / `claude --continue`). Persisted into
732    /// the workspace's `agent_resume`; absent for plain terminals, which
733    /// just re-run their launch command.
734    pub terminal_resume_commands:
735        std::collections::HashMap<crate::services::terminal::TerminalId, Vec<String>>,
736
737    /// Plugin-development workspace per buffer (temp dir + LSP
738    /// configuration for plugin buffers). Buffer-keyed and buffers
739    /// are per-window, so the workspace map follows.
740    pub plugin_dev_workspaces:
741        HashMap<BufferId, crate::services::plugins::plugin_dev_workspace::PluginDevWorkspace>,
742
743    /// Per-buffer plugin status-bar token values. Outer key: BufferId;
744    /// inner key: "plugin_name:token_name"; inner value: current text
745    /// to render. The registry of which tokens exist lives globally on
746    /// `Editor.status_bar_token_registry`; this map holds only the
747    /// values plugins have pushed for individual buffers.
748    pub status_bar_values: HashMap<BufferId, HashMap<String, String>>,
749
750    /// Mouse drag/selection/scrollbar state for this window. Drag
751    /// targets reference per-window LeafIds and BufferIds.
752    pub(crate) mouse_state: crate::app::types::MouseState,
753
754    /// Currently focused widget context (Normal / FileExplorer /
755    /// Terminal / Prompt …). Per-window because each window has its
756    /// own focus state — switching windows preserves each window's
757    /// focused widget.
758    pub key_context: crate::input::keybindings::KeyContext,
759
760    /// Pending chord sequence for multi-key bindings (e.g. C-x C-s).
761    /// Each window tracks its own in-progress chord.
762    pub chord_state: Vec<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
763
764    /// Multi-click detection state (per-window because clicks land
765    /// inside a window).
766    pub previous_click_time: Option<std::time::Instant>,
767    pub previous_click_position: Option<(u16, u16)>,
768    pub click_count: u8,
769
770    /// Whether mouse capture is enabled in this window.
771    pub mouse_enabled: bool,
772
773    /// GPM software-cursor position for this window (when GPM is
774    /// active and we draw our own cursor).
775    pub mouse_cursor_position: Option<(u16, u16)>,
776    pub gpm_active: bool,
777
778    /// Per-window chrome toggles. Each window can independently show
779    /// or hide its menu bar / tab bar / status bar / prompt line.
780    pub menu_bar_visible: bool,
781    pub menu_bar_auto_shown: bool,
782    pub tab_bar_visible: bool,
783    pub status_bar_visible: bool,
784    pub prompt_line_visible: bool,
785
786    /// Timing state for auto-recovery saves and persistent auto-saves
787    /// in this window.
788    pub last_auto_recovery_save: std::time::Instant,
789    pub last_persistent_auto_save: std::time::Instant,
790
791    /// Warning domain registry for this window's status indicator.
792    pub warning_domains: crate::app::warning_domains::WarningDomainRegistry,
793
794    /// Tab context menu state (right-click on a tab in this window).
795    pub tab_context_menu: Option<crate::app::types::TabContextMenu>,
796
797    /// "+" new-tab popup menu state (left-click on the tab bar's trailing
798    /// `+` button). Offers "New Terminal" / "New File".
799    pub new_tab_menu: Option<crate::app::types::NewTabMenu>,
800
801    /// File-explorer context menu state (right-click in the explorer).
802    pub file_explorer_context_menu: Option<crate::app::types::FileExplorerContextMenu>,
803
804    /// Theme inspector popup (Ctrl+Right-Click) anchored in this window.
805    pub theme_info_popup: Option<crate::app::types::ThemeInfoPopup>,
806
807    /// Event debug dialog state (when the event-debug modal is open in
808    /// this window). The dialog records keystrokes for the window's
809    /// input pipeline so it's logically per-window.
810    pub event_debug: Option<crate::app::event_debug::EventDebug>,
811
812    /// File-open dialog state (when PromptType::OpenFile is active in
813    /// this window's prompt).
814    pub file_open_state: Option<crate::app::file_open::FileOpenState>,
815
816    /// Cached layout for the file browser (mouse hit-testing).
817    pub file_browser_layout: Option<crate::view::ui::FileBrowserLayout>,
818
819    /// Buffer groups (multiple buffers shown as one tab) in this window.
820    pub buffer_groups: HashMap<crate::app::types::BufferGroupId, crate::app::types::BufferGroup>,
821    /// Reverse index: buffer ID → group ID.
822    pub buffer_to_group: HashMap<BufferId, crate::app::types::BufferGroupId>,
823    /// Next buffer group id within this window.
824    pub next_buffer_group_id: usize,
825
826    /// Plugin keystroke-callback queue (in-flight `getNextKey()` callbacks).
827    pub pending_next_key_callbacks: std::collections::VecDeque<fresh_core::api::JsCallbackId>,
828
829    /// Whether a plugin currently has key-capture active in this window.
830    pub key_capture_active: bool,
831
832    /// Keys queued while `key_capture_active` was set but no callback
833    /// was pending — drained on the next `AwaitNextKey`.
834    pub pending_key_capture_buffer: std::collections::VecDeque<fresh_core::api::KeyEventPayload>,
835
836    /// Macro state (record/playback/registers) — one window's macro
837    /// session at a time.
838    pub(crate) macros: crate::app::macros::MacroState,
839
840    /// Plugin-defined custom contexts active in this window (drives
841    /// command palette visibility, e.g. "config-editor").
842    pub active_custom_contexts: std::collections::HashSet<String>,
843
844    /// Whether keyboard capture is active for the terminal in this
845    /// window (terminal mode swallows non-toggle keys).
846    pub keyboard_capture: bool,
847
848    /// In-flight review session hunks for this window.
849    pub review_hunks: Vec<fresh_core::api::ReviewHunk>,
850
851    /// Pending file-open queue (PendingFileOpen) for this window.
852    pub pending_file_opens: Vec<crate::app::PendingFileOpen>,
853
854    /// Whether this window has a hot-exit recovery prompt pending.
855    pub pending_hot_exit_recovery: bool,
856
857    /// Plugin "wait until file opens" tracking (buffer_id → (wait_id, …)).
858    pub wait_tracking: HashMap<BufferId, (u64, bool)>,
859
860    /// Wait ids that have completed and need to be reported back to plugins.
861    pub completed_waits: Vec<u64>,
862
863    /// Background line-scan state for this window (line counts for
864    /// large files).
865    pub(crate) line_scan: crate::app::line_scan::LineScan,
866
867    /// Background search-scan state for this window.
868    pub(crate) search_scan: crate::app::search_scan::SearchScan,
869
870    /// Anchor for the search-result overlay in this window.
871    pub search_overlay_top_byte: Option<usize>,
872
873    /// Per-window UI animation runner.
874    pub animations: crate::view::animation::AnimationRunner,
875
876    /// Plugin error log (populated when plugin status messages match
877    /// error patterns; tests assert against this).
878    pub plugin_errors: Vec<String>,
879
880    /// Cut/copy clipboard for file-explorer ops in this window. Each
881    /// window has its own paste buffer; cross-window file ops would
882    /// require a separately-shared clipboard.
883    pub file_explorer_clipboard: Option<crate::app::file_explorer::FileExplorerClipboard>,
884
885    /// Process-group tracking for everything this window owns
886    /// (today: pty children from `terminal_manager.spawn`).
887    /// Exposed through `signal_all` so window-level lifecycle
888    /// operations can terminate every spawned process in one
889    /// call regardless of how many terminals the window owns —
890    /// see [`process_group`] module docs for the authority-
891    /// pluggable `Signaller` design.
892    pub process_groups: ProcessGroups,
893}
894
895/// Apply language-server configuration to a freshly-created
896/// [`LspManager`]: per-language configs, the universal (global)
897/// servers, and the Deno auto-detection override. Shared by every
898/// window's construction so the server set is identical regardless of
899/// how the window came to exist (boot, orchestrator new-session,
900/// disk-restored shell).
901pub(crate) fn configure_lsp_servers(
902    lsp: &mut LspManager,
903    root: &std::path::Path,
904    config: &crate::config::Config,
905) {
906    use crate::types::{LspServerConfig, ProcessLimits};
907
908    // Global master switch — gates auto-start of every server below.
909    lsp.set_globally_enabled(config.lsp_enabled);
910
911    // Per-language servers from config.
912    for (language, lsp_configs) in &config.lsp {
913        lsp.set_language_configs(language.clone(), lsp_configs.as_slice().to_vec());
914    }
915
916    // Universal (global) servers — spawned once, shared across languages.
917    let universal_servers: Vec<LspServerConfig> = config
918        .universal_lsp
919        .values()
920        .flat_map(|lc| lc.as_slice().to_vec())
921        .filter(|c| c.enabled)
922        .collect();
923    lsp.set_universal_configs(universal_servers);
924
925    // Auto-detect Deno projects: if deno.json or deno.jsonc exists in the
926    // window root, override JS/TS LSP to use `deno lsp` (#1191). Checked
927    // against the window's own root so each workspace gets the detection for
928    // its actual project rather than the process cwd.
929    if root.join("deno.json").exists() || root.join("deno.jsonc").exists() {
930        tracing::info!("Detected Deno project (deno.json found), using deno lsp for JS/TS");
931        let deno_config = LspServerConfig {
932            command: "deno".to_string(),
933            args: vec!["lsp".to_string()],
934            enabled: true,
935            auto_start: false,
936            process_limits: ProcessLimits::default(),
937            initialization_options: Some(serde_json::json!({"enable": true})),
938            ..Default::default()
939        };
940        lsp.set_language_config("javascript".to_string(), deno_config.clone());
941        lsp.set_language_config("typescript".to_string(), deno_config);
942    }
943}
944
945/// Build the [`LspManager`] every window owns: rooted at the window's
946/// own `root`, wired to its own `bridge` (which
947/// `process_async_messages` drains every frame) and the shared tokio
948/// runtime, and configured with the full server set. Called from
949/// [`Window::new`] so the manager is present *by construction* — there
950/// is no window without one, and no "No LSP manager available" state to
951/// represent.
952pub(crate) fn build_window_lsp(
953    id: WindowId,
954    root: &std::path::Path,
955    authority: &crate::services::authority::Authority,
956    resources: &crate::app::window_resources::WindowResources,
957    bridge: &crate::services::async_bridge::AsyncBridge,
958) -> LspManager {
959    let root_uri = crate::app::types::file_path_to_lsp_uri(root);
960    let mut lsp = LspManager::new(id, root_uri);
961
962    // No runtime means async features are disabled (matches the
963    // historical base-window path when the tokio runtime fails to build).
964    if let Some(runtime) = resources.tokio_runtime.as_ref() {
965        lsp.set_runtime(runtime.handle().clone(), bridge.clone());
966    }
967
968    // Wire the LSP backend from the window's authority at construction:
969    // `force_spawn` routes server processes through the long-running
970    // spawner, URIs are host↔container-translated via path translation, and
971    // trust gates spawning. Doing this here (rather than via a later
972    // `set_boot_authority`) means the manager is never left pointing at a
973    // backend that doesn't match the authority the window was built with.
974    lsp.set_long_running_spawner(authority.long_running_spawner.clone());
975    lsp.set_path_translation(authority.path_translation.clone());
976    lsp.set_workspace_trust(authority.workspace_trust.clone());
977
978    configure_lsp_servers(&mut lsp, root, &resources.config);
979    lsp
980}
981
982impl Window {
983    /// Apply LSP folding ranges to the named buffer's `folding_ranges`
984    /// store. Pure window mutation — no editor-global state touched.
985    /// Used by the LSP folding-ranges response dispatcher after the
986    /// editor-global URI-keyed map has been updated.
987    pub fn apply_folding_ranges_response(
988        &mut self,
989        buffer_id: BufferId,
990        lsp_ranges: Vec<lsp_types::FoldingRange>,
991    ) {
992        let Some(state) = self.buffers.get_mut(&buffer_id) else {
993            return;
994        };
995        state
996            .folding_ranges
997            .set_from_lsp(&state.buffer, &mut state.marker_list, lsp_ranges);
998    }
999
1000    /// Allocate a fresh per-window LSP request id and return it. The
1001    /// counter is per-window because each window's `LspManager` talks
1002    /// to its own server connections — no global namespace needed.
1003    pub fn alloc_lsp_request_id(&mut self) -> u64 {
1004        let id = self.next_lsp_request_id;
1005        self.next_lsp_request_id += 1;
1006        id
1007    }
1008
1009    /// True if this window has any in-flight LSP completion or
1010    /// goto-definition request whose response would still be relevant.
1011    pub fn has_pending_lsp_requests(&self) -> bool {
1012        !self.pending_completion_requests.is_empty()
1013            || self.pending_goto_definition_request.is_some()
1014    }
1015
1016    /// Cancel any in-flight LSP requests on this window. Called when
1017    /// the user does something that would make the response stale
1018    /// (cursor movement, text edit, scroll). Drains the pending
1019    /// completion id set, clears the goto-definition slot, and sends
1020    /// `$/cancelRequest` to the appropriate server for each.
1021    pub(crate) fn cancel_pending_lsp_requests(&mut self) {
1022        self.scheduled_completion_trigger = None;
1023        if !self.pending_completion_requests.is_empty() {
1024            let ids: Vec<u64> = self.pending_completion_requests.drain().collect();
1025            for request_id in ids {
1026                tracing::debug!("Canceling pending LSP completion request {}", request_id);
1027                self.send_lsp_cancel_request(request_id);
1028            }
1029        }
1030        if let Some(request_id) = self.pending_goto_definition_request.take() {
1031            tracing::debug!(
1032                "Canceling pending LSP goto-definition request {}",
1033                request_id
1034            );
1035            self.send_lsp_cancel_request(request_id);
1036        }
1037    }
1038
1039    /// Send `$/cancelRequest` to the LSP server backing the active
1040    /// buffer's language, if a server is already running. Called only
1041    /// from cancel paths — does not spawn a server just to cancel.
1042    pub(crate) fn send_lsp_cancel_request(&mut self, request_id: u64) {
1043        let buffer_id = self.active_buffer();
1044        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
1045            return;
1046        };
1047        {
1048            let lsp = &mut self.lsp;
1049            if let Some(handle) = lsp.get_handle_mut(&language) {
1050                if let Err(e) = handle.cancel_request(request_id) {
1051                    tracing::warn!("Failed to send LSP cancel request: {}", e);
1052                } else {
1053                    tracing::debug!("Sent $/cancelRequest for request_id={}", request_id);
1054                }
1055            }
1056        }
1057    }
1058
1059    /// Toggle this window's tab-bar visibility and post a status message.
1060    pub fn toggle_tab_bar(&mut self) {
1061        self.tab_bar_visible = !self.tab_bar_visible;
1062        let key = if self.tab_bar_visible {
1063            "toggle.tab_bar_shown"
1064        } else {
1065            "toggle.tab_bar_hidden"
1066        };
1067        self.set_status_message(rust_i18n::t!(key).to_string());
1068    }
1069
1070    /// Toggle this window's status-bar visibility and post a status message.
1071    pub fn toggle_status_bar(&mut self) {
1072        self.status_bar_visible = !self.status_bar_visible;
1073        let key = if self.status_bar_visible {
1074            "toggle.status_bar_shown"
1075        } else {
1076            "toggle.status_bar_hidden"
1077        };
1078        self.set_status_message(rust_i18n::t!(key).to_string());
1079    }
1080
1081    /// Toggle this window's prompt-line visibility and post a status message.
1082    pub fn toggle_prompt_line(&mut self) {
1083        self.prompt_line_visible = !self.prompt_line_visible;
1084        let key = if self.prompt_line_visible {
1085            "toggle.prompt_line_shown"
1086        } else {
1087            "toggle.prompt_line_hidden"
1088        };
1089        self.set_status_message(rust_i18n::t!(key).to_string());
1090    }
1091
1092    /// Toggle this window's same-buffer scroll-sync flag and post a
1093    /// status message announcing the new state.
1094    pub fn toggle_scroll_sync(&mut self) {
1095        self.same_buffer_scroll_sync = !self.same_buffer_scroll_sync;
1096        let key = if self.same_buffer_scroll_sync {
1097            "toggle.scroll_sync_enabled"
1098        } else {
1099            "toggle.scroll_sync_disabled"
1100        };
1101        self.set_status_message(rust_i18n::t!(key).to_string());
1102    }
1103
1104    /// Toggle the active buffer's `debug_highlight_mode` (shows byte
1105    /// positions and highlight-span info on screen). No-op if there is
1106    /// no active buffer.
1107    pub fn toggle_debug_highlights(&mut self) {
1108        let buffer_id = self.active_buffer();
1109        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1110            state.debug_highlight_mode = !state.debug_highlight_mode;
1111            let key = if state.debug_highlight_mode {
1112                "toggle.debug_mode_on"
1113            } else {
1114                "toggle.debug_mode_off"
1115            };
1116            self.set_status_message(rust_i18n::t!(key).to_string());
1117        }
1118    }
1119
1120    /// Build a compiled `regex::Regex` from this window's current
1121    /// search-flags (`use_regex`, `whole_word`, `case_sensitive`)
1122    /// applied to `query`. Returns the compiled regex or a
1123    /// human-readable error string.
1124    pub(crate) fn build_search_regex(&self, query: &str) -> Result<regex::Regex, String> {
1125        crate::app::regex_replace::build_search_regex(
1126            query,
1127            self.search_use_regex,
1128            self.search_whole_word,
1129            self.search_case_sensitive,
1130        )
1131    }
1132
1133    /// True iff editing should be disabled for the active buffer
1134    /// (e.g. read-only virtual buffers like the help manual).
1135    pub fn is_editing_disabled(&self) -> bool {
1136        self.active_state().editing_disabled
1137    }
1138
1139    /// Recompute the active buffer's `modified` flag from the event log's
1140    /// position relative to its last-saved point. Called after undo/redo
1141    /// to correctly report "buffer is dirty / clean" in the status bar.
1142    pub(super) fn update_modified_from_event_log(&mut self) {
1143        let buffer_id = self.active_buffer();
1144        let is_at_saved = self
1145            .event_logs
1146            .get(&buffer_id)
1147            .map(|log| log.is_at_saved_position())
1148            .unwrap_or(false);
1149        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1150            state.buffer.set_modified(!is_at_saved);
1151        }
1152    }
1153
1154    /// True iff `language` is currently user-dismissed in this window's
1155    /// LSP status pill.
1156    pub fn is_lsp_language_user_dismissed(&self, language: &str) -> bool {
1157        self.user_dismissed_lsp_languages.contains(language)
1158    }
1159
1160    /// Dismiss the LSP pill for `language` in this window until the user
1161    /// re-enables it (or the editor restarts).
1162    pub fn dismiss_lsp_language(&mut self, language: &str) {
1163        self.user_dismissed_lsp_languages
1164            .insert(language.to_string());
1165    }
1166
1167    /// Undo a previous dismissal — the pill for `language` returns to its
1168    /// normal style.
1169    pub fn undismiss_lsp_language(&mut self, language: &str) {
1170        self.user_dismissed_lsp_languages.remove(language);
1171    }
1172
1173    /// True iff at least one LSP server attached to the active buffer's
1174    /// language advertises `codeAction/resolve`.
1175    pub(crate) fn server_supports_code_action_resolve(&self) -> bool {
1176        let Some(language) = self
1177            .buffers
1178            .get(&self.active_buffer())
1179            .map(|s| s.language.clone())
1180        else {
1181            return false;
1182        };
1183        {
1184            let lsp = &self.lsp;
1185            for sh in lsp.get_handles(&language) {
1186                if sh.capabilities.code_action_resolve {
1187                    return true;
1188                }
1189            }
1190        }
1191        false
1192    }
1193
1194    /// True iff at least one LSP server attached to the active buffer's
1195    /// language advertises `completionItem/resolve`.
1196    pub(crate) fn server_supports_completion_resolve(&self) -> bool {
1197        let Some(language) = self
1198            .buffers
1199            .get(&self.active_buffer())
1200            .map(|s| s.language.clone())
1201        else {
1202            return false;
1203        };
1204        {
1205            let lsp = &self.lsp;
1206            for sh in lsp.get_handles(&language) {
1207                if sh.capabilities.completion_resolve {
1208                    return true;
1209                }
1210            }
1211        }
1212        false
1213    }
1214
1215    /// True iff at least one LSP server attached to the active buffer's
1216    /// language advertises `textDocument/rename` (and therefore the
1217    /// `prepareRename` request, which the editor surfaces only through
1218    /// the rename feature flag).
1219    pub(crate) fn server_supports_prepare_rename(&self) -> bool {
1220        let Some(language) = self
1221            .buffers
1222            .get(&self.active_buffer())
1223            .map(|s| s.language.clone())
1224        else {
1225            return false;
1226        };
1227        {
1228            let lsp = &self.lsp;
1229            for sh in lsp.get_handles(&language) {
1230                if sh.capabilities.rename {
1231                    return true;
1232                }
1233            }
1234        }
1235        false
1236    }
1237
1238    /// Send `textDocument/prepareRename` for the symbol at the active
1239    /// cursor. No-op if the buffer has no LSP metadata, no language, or
1240    /// no rename-capable handle. The response is dispatched to
1241    /// `handle_prepare_rename_response`.
1242    pub(crate) fn send_prepare_rename(&mut self) {
1243        let cursor_pos = self.active_cursors().primary().position;
1244        let (line, character) = self
1245            .active_state()
1246            .buffer
1247            .position_to_lsp_position(cursor_pos);
1248
1249        let buffer_id = self.active_buffer();
1250        let metadata = match self.buffer_metadata.get(&buffer_id) {
1251            Some(m) if m.lsp_enabled => m,
1252            _ => return,
1253        };
1254        let uri = match metadata.file_uri() {
1255            Some(u) => u.clone(),
1256            None => return,
1257        };
1258        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
1259            return;
1260        };
1261
1262        let request_id = self.alloc_lsp_request_id();
1263
1264        {
1265            let lsp = &mut self.lsp;
1266            if let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::Rename) {
1267                if let Err(e) = sh.handle.prepare_rename(
1268                    request_id,
1269                    uri.as_uri().clone(),
1270                    line as u32,
1271                    character as u32,
1272                ) {
1273                    tracing::warn!("Failed to send prepareRename: {}", e);
1274                }
1275            }
1276        }
1277    }
1278
1279    /// Send `completionItem/resolve` for `item` to the first LSP server
1280    /// (in language order) that advertises `completion_resolve` for the
1281    /// active buffer's language. No-op if no server is running or no
1282    /// server supports the resolve.
1283    pub(crate) fn send_completion_resolve(&mut self, item: lsp_types::CompletionItem) {
1284        let Some(language) = self
1285            .buffers
1286            .get(&self.active_buffer())
1287            .map(|s| s.language.clone())
1288        else {
1289            return;
1290        };
1291        let request_id = self.alloc_lsp_request_id();
1292        {
1293            let lsp = &mut self.lsp;
1294            for sh in lsp.get_handles_mut(&language) {
1295                if sh.capabilities.completion_resolve {
1296                    if let Err(e) = sh.handle.completion_resolve(request_id, item.clone()) {
1297                        tracing::warn!(
1298                            "Failed to send completionItem/resolve to '{}': {}",
1299                            sh.name,
1300                            e
1301                        );
1302                    }
1303                    return;
1304                }
1305            }
1306        }
1307    }
1308
1309    /// Apply an event to a buffer + the cursors of a split inside this
1310    /// window. Window-level method (not Editor-level) so the borrow
1311    /// checker can split-borrow `self.buffers` and `self.splits`
1312    /// cleanly without inline `self.windows.get_mut(...)` boilerplate
1313    /// at the call site. No-op if the buffer or split is missing.
1314    pub fn apply_event_to_buffer(
1315        &mut self,
1316        buffer_id: BufferId,
1317        split_id: LeafId,
1318        event: &crate::model::event::Event,
1319    ) {
1320        self.buffers
1321            .with_buffer_and_split(buffer_id, split_id, |state, vs| {
1322                state.apply(&mut vs.cursors, event);
1323            });
1324    }
1325
1326    /// Same as [`apply_event_to_buffer`] but operates on a buffer-group
1327    /// panel's keyed cursor (the `keyed_states[buffer_id].cursors`
1328    /// inside the host split's view state, not the host's own cursors).
1329    /// Used by event-apply paths that target a focused inner panel of
1330    /// a Grouped split rather than the outer split's leaf buffer.
1331    pub fn apply_event_to_keyed_buffer(
1332        &mut self,
1333        buffer_id: BufferId,
1334        split_id: LeafId,
1335        event: &crate::model::event::Event,
1336    ) {
1337        self.buffers
1338            .with_buffer_and_split(buffer_id, split_id, |state, vs| {
1339                if let Some(keyed) = vs.keyed_states.get_mut(&buffer_id) {
1340                    state.apply(&mut keyed.cursors, event);
1341                }
1342            });
1343    }
1344
1345    /// Scroll the named split's viewport so the buffer's primary cursor
1346    /// is visible. Calls into `SplitViewState::ensure_cursor_visible`
1347    /// with the buffer's text + marker list. No-op if buffer/split is
1348    /// missing.
1349    pub fn ensure_cursor_visible_for_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
1350        self.buffers
1351            .with_buffer_and_split(buffer_id, split_id, |state, vs| {
1352                vs.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
1353            });
1354    }
1355
1356    /// Scroll a split's viewport to the given line, given a buffer to
1357    /// resolve the line→byte offset. No-op if buffer/split is missing.
1358    /// `lock_against_ensure_visible`: when true, sets the
1359    /// skip-ensure-visible flag so the next render's cursor-visibility
1360    /// pass doesn't undo this scroll. Plugin-driven jumps want true;
1361    /// scroll-sync-from-active-to-other-splits wants false.
1362    pub fn scroll_split_viewport_to(
1363        &mut self,
1364        buffer_id: BufferId,
1365        split_id: LeafId,
1366        target_line: usize,
1367        lock_against_ensure_visible: bool,
1368    ) {
1369        self.buffers
1370            .with_buffer_and_split(buffer_id, split_id, |state, vs| {
1371                vs.viewport.scroll_to(&mut state.buffer, target_line);
1372                if lock_against_ensure_visible {
1373                    vs.viewport.set_skip_ensure_visible();
1374                }
1375            });
1376    }
1377
1378    /// Add a collapsed fold range on `buffer_id`'s marker list and on
1379    /// every view state hosting the buffer. Returns `true` when the
1380    /// buffer was found (so the caller knows to flag a render). No-op
1381    /// when the buffer is missing.
1382    pub fn add_fold(
1383        &mut self,
1384        buffer_id: BufferId,
1385        start: usize,
1386        end: usize,
1387        placeholder: Option<String>,
1388    ) -> bool {
1389        self.buffers
1390            .with_buffer_and_view_states(buffer_id, |state, vs_map| {
1391                for vs in vs_map.values_mut() {
1392                    if vs.keyed_states.contains_key(&buffer_id) {
1393                        let buf_state = vs.ensure_buffer_state(buffer_id);
1394                        buf_state.folds.add(
1395                            &mut state.marker_list,
1396                            start,
1397                            end,
1398                            placeholder.clone(),
1399                        );
1400                    }
1401                }
1402            })
1403            .is_some()
1404    }
1405
1406    /// Clear every fold range on `buffer_id` across the window's view
1407    /// states. Returns `true` when the buffer was found.
1408    pub fn clear_folds(&mut self, buffer_id: BufferId) -> bool {
1409        self.buffers
1410            .with_buffer_and_view_states(buffer_id, |state, vs_map| {
1411                for vs in vs_map.values_mut() {
1412                    if vs.keyed_states.contains_key(&buffer_id) {
1413                        let buf_state = vs.ensure_buffer_state(buffer_id);
1414                        buf_state.folds.clear(&mut state.marker_list);
1415                    }
1416                }
1417            })
1418            .is_some()
1419    }
1420
1421    /// Move every supplied split's primary cursor to `position` in
1422    /// `buffer_id` and re-anchor the viewport to keep it visible.
1423    /// Caller is responsible for computing `splits` (typically by
1424    /// walking the split tree plus any grouped subtrees on the
1425    /// editor — those live outside the window). No-op for missing
1426    /// buffer/splits.
1427    pub fn set_buffer_cursor_in_splits(
1428        &mut self,
1429        buffer_id: BufferId,
1430        position: usize,
1431        splits: &[LeafId],
1432    ) {
1433        self.buffers
1434            .with_buffer_and_view_states(buffer_id, |state, vs_map| {
1435                let mut moved_any = false;
1436                for leaf_id in splits {
1437                    let Some(view_state) = vs_map.get_mut(leaf_id) else {
1438                        continue;
1439                    };
1440                    view_state.cursors.primary_mut().move_to(position, false);
1441                    view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
1442                    moved_any = true;
1443                }
1444                // Refresh the cached primary cursor line number so the status
1445                // bar (and any other consumer of `primary_cursor_line_number`)
1446                // reflects the new position. Other cursor-move paths update
1447                // this cache themselves; without doing the same here, a
1448                // plugin-driven setBufferCursor would leave the cache pinned
1449                // to its initial Absolute(0) — the user-visible "off-by-one"
1450                // when opening blame from line 2 still shows "Ln 1". Guard
1451                // on `moved_any` so we don't desync the cache when no split
1452                // was actually carrying the buffer's cursor.
1453                if moved_any {
1454                    let line = state
1455                        .buffer
1456                        .offset_to_position(position)
1457                        .map(|p| p.line)
1458                        .unwrap_or(0);
1459                    state.primary_cursor_line_number =
1460                        crate::model::buffer::LineNumber::Absolute(line);
1461                }
1462            });
1463    }
1464
1465    /// Scroll `leaf_id`'s viewport so the byte position `top_byte` is
1466    /// the new top line, using `buffer_id` to resolve byte→line. Sets
1467    /// `skip_ensure_visible` so the next render's cursor-visibility
1468    /// pass doesn't undo the plugin-driven scroll. No-op for missing
1469    /// buffer/split.
1470    pub fn set_split_scroll_to_byte(
1471        &mut self,
1472        buffer_id: BufferId,
1473        leaf_id: LeafId,
1474        top_byte: usize,
1475    ) {
1476        self.buffers
1477            .with_buffer_and_split(buffer_id, leaf_id, |state, view_state| {
1478                let total_bytes = state.buffer.len();
1479                let clamped_byte = top_byte.min(total_bytes);
1480                let target_line = state
1481                    .buffer
1482                    .offset_to_position(clamped_byte)
1483                    .map(|p| p.line)
1484                    .unwrap_or(0);
1485                view_state
1486                    .viewport
1487                    .scroll_to(&mut state.buffer, target_line);
1488                view_state.viewport.top_byte = clamped_byte;
1489                view_state.viewport.top_view_line_offset = 0;
1490                view_state.viewport.set_skip_ensure_visible();
1491            });
1492    }
1493
1494    /// Scroll every supplied split so `line` is roughly a third
1495    /// from the top of the viewport, using `buffer_id` for line
1496    /// resolution. Used for plugin-driven "scroll buffer to line"
1497    /// where the caller has already collected target leaves
1498    /// (including those from grouped subtrees).
1499    pub fn scroll_buffer_to_line_in_splits(
1500        &mut self,
1501        buffer_id: BufferId,
1502        target_leaves: &[LeafId],
1503        line: usize,
1504    ) {
1505        self.buffers
1506            .with_buffer_and_view_states(buffer_id, |state, vs_map| {
1507                for leaf_id in target_leaves {
1508                    let Some(view_state) = vs_map.get_mut(leaf_id) else {
1509                        continue;
1510                    };
1511                    let viewport_height = view_state.viewport.height as usize;
1512                    let lines_above = viewport_height / 3;
1513                    let target = line.saturating_sub(lines_above);
1514                    view_state.viewport.scroll_to(&mut state.buffer, target);
1515                    view_state.viewport.set_skip_ensure_visible();
1516                }
1517            });
1518    }
1519
1520    /// Apply a previously-saved cursor + scroll position to a
1521    /// specific buffer's keyed view state inside a specific split.
1522    /// Restoration must NOT go through `view_state.viewport` /
1523    /// `view_state.cursors` — those Deref to the split's *active*
1524    /// buffer's view, which for `open_file_no_focus` is still the
1525    /// previously-active buffer; writing through the Deref would
1526    /// scroll the unrelated active buffer. After restoring the
1527    /// fields, reconciles cursor visibility against viewport
1528    /// (#1689 follow-up). No-op if buffer/split is missing.
1529    pub fn restore_buffer_state_in_split(
1530        &mut self,
1531        buffer_id: BufferId,
1532        split_id: LeafId,
1533        file_state: &crate::workspace::SerializedFileState,
1534    ) {
1535        self.buffers
1536            .with_buffer_and_split(buffer_id, split_id, |buffer_state, vs| {
1537                let Some(buf_state) = vs.keyed_states.get_mut(&buffer_id) else {
1538                    return;
1539                };
1540                let max_pos = buffer_state.buffer.len();
1541                let cursor_pos = file_state.cursor.position.min(max_pos);
1542                buf_state.cursors.primary_mut().position = cursor_pos;
1543                buf_state.cursors.primary_mut().anchor =
1544                    file_state.cursor.anchor.map(|a| a.min(max_pos));
1545                buf_state.viewport.top_byte = file_state.scroll.top_byte;
1546                buf_state.viewport.left_column = file_state.scroll.left_column;
1547                crate::app::navigation::reconcile_restored_buffer_view(
1548                    buf_state,
1549                    &mut buffer_state.buffer,
1550                );
1551            });
1552    }
1553
1554    /// Configure `leaf_id`'s viewport for a terminal-buffer
1555    /// scrollback view: disable line wrap, clear any pending
1556    /// skip-ensure-visible flag, then scroll so the buffer's primary
1557    /// cursor (positioned at end-of-buffer when entering scrollback)
1558    /// is visible. No-op if the buffer or split is missing.
1559    pub fn enter_terminal_scrollback_view(&mut self, buffer_id: BufferId, leaf_id: LeafId) {
1560        self.buffers
1561            .with_buffer_and_split(buffer_id, leaf_id, |state, view_state| {
1562                view_state.viewport.line_wrap_enabled = false;
1563                view_state.viewport.clear_skip_ensure_visible();
1564                view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
1565            });
1566    }
1567
1568    /// Install a freshly-loaded `EditorState` for a terminal buffer:
1569    /// replace the slot's state, push every per-split cursor showing
1570    /// the buffer to end-of-buffer (scrollback start), clear the
1571    /// modified flag (terminals are never user-modified), disable
1572    /// editing (scrollback mode), and turn off line-number margins.
1573    /// Used by workspace restore when re-loading the on-disk
1574    /// rendering of a previously-running terminal.
1575    pub fn install_terminal_buffer_state(
1576        &mut self,
1577        buffer_id: BufferId,
1578        new_state: crate::state::EditorState,
1579    ) {
1580        self.buffers
1581            .with_buffer_and_view_states(buffer_id, |state, vs_map| {
1582                *state = new_state;
1583                let total = state.buffer.total_bytes();
1584                for vs in vs_map.values_mut() {
1585                    if vs.has_buffer(buffer_id) {
1586                        vs.cursors.primary_mut().position = total;
1587                        // Disable gutter + current-line highlight for the
1588                        // terminal buffer's per-buffer view state so that
1589                        // exiting terminal mode on a restored terminal
1590                        // doesn't flash a line-number column. The render
1591                        // path overwrites the buffer's margin config from
1592                        // this flag every frame, so the buffer-level
1593                        // `configure_for_line_numbers(false)` below isn't
1594                        // enough on its own.
1595                        let buf_state = vs.ensure_buffer_state(buffer_id);
1596                        buf_state.show_line_numbers = false;
1597                        buf_state.highlight_current_line = false;
1598                        buf_state.viewport.line_wrap_enabled = false;
1599                    }
1600                }
1601                state.buffer.set_modified(false);
1602                state.editing_disabled = true;
1603                state.margins.configure_for_line_numbers(false);
1604            });
1605    }
1606
1607    /// Scroll `leaf_id`'s viewport by `delta` lines (negative = up,
1608    /// positive = down). Honours `view_transform_tokens` when present
1609    /// (uses view-aware scrolling) and falls back to buffer-based
1610    /// `scroll_up` / `scroll_down`. After scrolling, skips
1611    /// ensure_visible and snaps the viewport top to a fold boundary
1612    /// if the new top byte landed inside a collapsed fold.
1613    /// `tab_size` is needed for view-line tokenization.
1614    pub fn scroll_split_by_lines(
1615        &mut self,
1616        buffer_id: BufferId,
1617        leaf_id: LeafId,
1618        delta: i32,
1619        view_transform_tokens: Option<Vec<fresh_core::api::ViewTokenWire>>,
1620        tab_size: usize,
1621    ) {
1622        self.buffers
1623            .with_buffer_and_split(buffer_id, leaf_id, |state, view_state| {
1624                let soft_breaks = state.collect_soft_break_positions();
1625                let virtual_lines = state.collect_virtual_line_positions();
1626                let buffer = &mut state.buffer;
1627                let top_byte_before = view_state.viewport.top_byte;
1628                if let Some(tokens) = view_transform_tokens {
1629                    use crate::view::ui::view_pipeline::ViewLineIterator;
1630                    let view_lines: Vec<_> =
1631                        ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
1632                    view_state
1633                        .viewport
1634                        .scroll_view_lines(&view_lines, delta as isize);
1635                } else if delta < 0 {
1636                    let lines_to_scroll = delta.unsigned_abs() as usize;
1637                    view_state.viewport.scroll_up(
1638                        buffer,
1639                        &soft_breaks,
1640                        &virtual_lines,
1641                        lines_to_scroll,
1642                    );
1643                } else {
1644                    let lines_to_scroll = delta as usize;
1645                    view_state.viewport.scroll_down(
1646                        buffer,
1647                        &soft_breaks,
1648                        &virtual_lines,
1649                        lines_to_scroll,
1650                    );
1651                }
1652                view_state.viewport.set_skip_ensure_visible();
1653
1654                if let Some(folds) = view_state.keyed_states.get(&buffer_id).map(|bs| &bs.folds) {
1655                    if !folds.is_empty() {
1656                        let top_line = buffer.get_line_number(view_state.viewport.top_byte);
1657                        if let Some(range) = folds
1658                            .resolved_ranges(buffer, &state.marker_list)
1659                            .iter()
1660                            .find(|r| top_line >= r.start_line && top_line <= r.end_line)
1661                        {
1662                            let target_line = if delta >= 0 {
1663                                range.end_line.saturating_add(1)
1664                            } else {
1665                                range.header_line
1666                            };
1667                            let target_byte = buffer
1668                                .line_start_offset(target_line)
1669                                .unwrap_or_else(|| buffer.len());
1670                            view_state.viewport.top_byte = target_byte;
1671                            view_state.viewport.top_view_line_offset = 0;
1672                        }
1673                    }
1674                }
1675                tracing::trace!(
1676                    "scroll_split_by_lines: delta={}, top_byte {} -> {}",
1677                    delta,
1678                    top_byte_before,
1679                    view_state.viewport.top_byte
1680                );
1681            });
1682    }
1683
1684    /// Scroll the Live Grep overlay's preview pane by `delta` lines
1685    /// (issue #2119). The preview lives in `overlay_preview_state` (not in
1686    /// the split tree), so it needs its own scroll path rather than going
1687    /// through `scroll_split_by_lines`. Returns true if a preview was present
1688    /// and scrolled.
1689    pub fn scroll_overlay_preview_by_lines(&mut self, delta: i32) -> bool {
1690        let buffer_id = match self.overlay_preview_state.as_ref() {
1691            Some(ps) if !ps.blanked => ps.buffer_id,
1692            _ => return false,
1693        };
1694        // Gather buffer-derived inputs first so the mutable borrows below stay
1695        // disjoint (buffer store vs. overlay preview state).
1696        let (soft_breaks, virtual_lines) = match self.buffers.get(&buffer_id) {
1697            Some(s) => (
1698                s.collect_soft_break_positions(),
1699                s.collect_virtual_line_positions(),
1700            ),
1701            None => return false,
1702        };
1703        let Some(state) = self.buffers.get_mut(&buffer_id) else {
1704            return false;
1705        };
1706        let buffer = &mut state.buffer;
1707        let Some(ps) = self.overlay_preview_state.as_mut() else {
1708            return false;
1709        };
1710        let viewport = &mut ps.view_state.active_state_mut().viewport;
1711        if delta < 0 {
1712            viewport.scroll_up(
1713                buffer,
1714                &soft_breaks,
1715                &virtual_lines,
1716                delta.unsigned_abs() as usize,
1717            );
1718        } else {
1719            viewport.scroll_down(buffer, &soft_breaks, &virtual_lines, delta as usize);
1720        }
1721        viewport.set_skip_ensure_visible();
1722        true
1723    }
1724
1725    /// Clear LSP-related overlays (diagnostics, virtual texts,
1726    /// folding ranges, and folds) for `buffer_id`, used when LSP is
1727    /// being disabled for the buffer. Pure window-state mutation.
1728    pub fn clear_lsp_overlays_for_buffer(
1729        &mut self,
1730        buffer_id: BufferId,
1731        diagnostic_namespace: &crate::model::event::OverlayNamespace,
1732    ) {
1733        self.buffers
1734            .with_buffer_and_view_states(buffer_id, |state, vs_map| {
1735                state
1736                    .overlays
1737                    .clear_namespace(diagnostic_namespace, &mut state.marker_list);
1738                state.virtual_texts.clear(&mut state.marker_list);
1739                state.folding_ranges.clear(&mut state.marker_list);
1740                for view_state in vs_map.values_mut() {
1741                    if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
1742                        buf_state.folds.clear(&mut state.marker_list);
1743                    }
1744                }
1745            });
1746    }
1747
1748    /// Mutable handle to this window's split tree (or `None` when
1749    /// the layout hasn't been seeded yet). Useful at sites where
1750    /// the caller already has a `&mut Window` from a direct
1751    /// `self.windows.get_mut(&id)` and wants the split layout
1752    /// without going back through Editor's accessor.
1753    pub fn split_manager_mut(&mut self) -> Option<&mut SplitManager> {
1754        self.buffers.split_manager_mut()
1755    }
1756
1757    /// Mutable handle to this window's per-leaf view state map.
1758    pub fn split_view_states_mut(&mut self) -> Option<&mut HashMap<LeafId, SplitViewState>> {
1759        self.buffers.split_view_states_mut()
1760    }
1761
1762    /// Both halves of the split layout at once. Returns `None` if
1763    /// the layout hasn't been seeded yet.
1764    pub fn splits_mut(
1765        &mut self,
1766    ) -> Option<(&mut SplitManager, &mut HashMap<LeafId, SplitViewState>)> {
1767        self.buffers.splits_mut().map(|(m, vs)| (m, vs))
1768    }
1769
1770    /// Construct a window.
1771    ///
1772    /// `root` is taken as-is (the caller is responsible for
1773    /// canonicalisation). `label` defaults to the basename of
1774    /// `root` when empty. `resources` is the editor-global service
1775    /// bundle every window holds an `Arc`-cloned reference to — see
1776    /// [`WindowResources`] for the rationale.
1777    pub fn new(
1778        id: WindowId,
1779        label: impl Into<String>,
1780        root: PathBuf,
1781        authority: crate::services::authority::Authority,
1782        resources: WindowResources,
1783    ) -> Self {
1784        let mut label = label.into();
1785        if label.is_empty() {
1786            label = root
1787                .file_name()
1788                .and_then(|n| n.to_str())
1789                .map(str::to_owned)
1790                .unwrap_or_else(|| "main".to_owned());
1791        }
1792        // Seed every poll/throttle timestamp with the *editor's* time
1793        // source rather than real wall-clock — otherwise tests using
1794        // `TestTimeSource::advance` see a misaligned baseline and
1795        // `elapsed_since` returns less than the configured interval
1796        // (broke auto-save / auto-recovery tests after these fields
1797        // moved off `Editor`).
1798        let now = resources.time_source.now();
1799        // Build this window's bridge and LSP manager up front so the
1800        // manager is wired to the window's own channel and present by
1801        // construction (see `build_window_lsp`). `&root`/`&resources`
1802        // are borrowed here, then moved into the struct below.
1803        let bridge = crate::services::async_bridge::AsyncBridge::new();
1804        let lsp = build_window_lsp(id, &root, &authority, &resources, &bridge);
1805        Self {
1806            id,
1807            label,
1808            root,
1809            authority,
1810            file_explorer: None,
1811            file_mod_times: HashMap::new(),
1812            plugin_state: HashMap::new(),
1813            authority_spec: crate::services::authority::SessionAuthoritySpec::Local,
1814            remote_reconnect_error: None,
1815            lsp,
1816            panel_ids: HashMap::new(),
1817            buffers: WindowBuffers::new(),
1818            buffer_metadata: HashMap::new(),
1819            terminal_manager: crate::services::terminal::TerminalManager::new(id),
1820            terminal_buffers: HashMap::new(),
1821            terminal_backing_files: HashMap::new(),
1822            terminal_log_files: HashMap::new(),
1823            terminal_explicit_titles: std::collections::HashSet::new(),
1824            terminal_fg_poll_at: None,
1825            terminal_fg_cache: HashMap::new(),
1826            event_logs: HashMap::new(),
1827            status_message: None,
1828            plugin_status_message: None,
1829            prompt: None,
1830            bridge,
1831            next_lsp_request_id: 0,
1832            pending_completion_requests: std::collections::HashSet::new(),
1833            completion_items: None,
1834            scheduled_completion_trigger: None,
1835            dabbrev_state: None,
1836            pending_goto_definition_request: None,
1837            pending_references_request: None,
1838            pending_references_symbol: String::new(),
1839            pending_implementation_request: None,
1840            pending_implementation_symbol: String::new(),
1841            pending_signature_help_request: None,
1842            pending_code_actions_requests: std::collections::HashSet::new(),
1843            pending_code_actions_server_names: std::collections::HashMap::new(),
1844            pending_code_actions: None,
1845            pending_inlay_hints_requests: std::collections::HashMap::new(),
1846            pending_folding_range_requests: std::collections::HashMap::new(),
1847            folding_ranges_in_flight: std::collections::HashMap::new(),
1848            folding_ranges_debounce: std::collections::HashMap::new(),
1849            pending_semantic_token_requests: std::collections::HashMap::new(),
1850            semantic_tokens_in_flight: std::collections::HashMap::new(),
1851            semantic_tokens_full_debounce: std::collections::HashMap::new(),
1852            pending_semantic_token_range_requests: std::collections::HashMap::new(),
1853            semantic_tokens_range_in_flight: std::collections::HashMap::new(),
1854            semantic_tokens_range_last_request: std::collections::HashMap::new(),
1855            semantic_tokens_range_applied: std::collections::HashMap::new(),
1856            position_history: crate::input::position_history::PositionHistory::new(),
1857            in_navigation: false,
1858            suppress_position_history_once: false,
1859            bookmarks: crate::app::bookmarks::BookmarkState::default(),
1860            grouped_subtrees: HashMap::new(),
1861            composite_buffers: HashMap::new(),
1862            composite_view_states: HashMap::new(),
1863            layout_cache: WindowLayoutCache::default(),
1864            chrome_layout: ChromeLayout::default(),
1865            terminal_width: 80,
1866            terminal_height: 24,
1867            dock_cols: 0,
1868            preview: None,
1869            terminal_mode: false,
1870            terminal_link_hover: None,
1871            seen_byte_ranges: HashMap::new(),
1872            previous_viewports: HashMap::new(),
1873            same_buffer_scroll_sync: false,
1874            interactive_replace_state: None,
1875            scroll_sync_manager: crate::view::scroll_sync::ScrollSyncManager::new(),
1876            file_explorer_visible: false,
1877            file_explorer_sync_in_progress: false,
1878            file_explorer_width: resources.config.file_explorer.width,
1879            file_explorer_side: resources.config.file_explorer.side,
1880            pending_file_explorer_show_hidden: None,
1881            pending_file_explorer_show_gitignored: None,
1882            file_explorer_decorations: HashMap::new(),
1883            file_explorer_decoration_cache:
1884                crate::view::file_tree::FileExplorerDecorationCache::default(),
1885            file_explorer_slot_overrides: HashMap::new(),
1886            file_explorer_slot_override_cache:
1887                crate::view::file_tree::FileExplorerSlotOverrideCache::default(),
1888            hover: crate::app::hover::HoverState::default(),
1889            search_state: None,
1890            search_namespace: crate::view::overlay::OverlayNamespace::from_string(
1891                "search".to_string(),
1892            ),
1893            pending_search_range: None,
1894            live_grep_last_state: None,
1895            overlay_preview_state: None,
1896            auto_revert_enabled: true,
1897            file_rapid_change_counts: HashMap::new(),
1898            goto_line_preview: None,
1899            pending_async_prompt_callback: None,
1900            pending_quit_unnamed_save: Vec::new(),
1901            search_case_sensitive: true,
1902            search_whole_word: false,
1903            search_use_regex: false,
1904            search_confirm_each: false,
1905            scheduled_diagnostic_pull: None,
1906            scheduled_inlay_hints_request: None,
1907            user_dismissed_lsp_languages: std::collections::HashSet::new(),
1908            editor_mode: None,
1909            prompt_histories: HashMap::new(),
1910            pending_close_buffer: None,
1911            completion_service: crate::services::completion::CompletionService::new(),
1912            lsp_diagnostic_namespace: crate::view::overlay::OverlayNamespace::from_string(
1913                "lsp-diagnostic".to_string(),
1914            ),
1915            diagnostic_result_ids: HashMap::new(),
1916            lsp_progress: HashMap::new(),
1917            lsp_server_statuses: HashMap::new(),
1918            lsp_menu_contributions: HashMap::new(),
1919            lsp_window_messages: Vec::new(),
1920            lsp_log_messages: Vec::new(),
1921            stored_push_diagnostics: HashMap::new(),
1922            stored_pull_diagnostics: HashMap::new(),
1923            stored_diagnostics: Arc::new(HashMap::new()),
1924            stored_folding_ranges: Arc::new(HashMap::new()),
1925            dir_mod_times: HashMap::new(),
1926            last_auto_revert_poll: now,
1927            last_file_tree_poll: now,
1928            git_index_resolved: false,
1929            pending_file_poll_rx: None,
1930            pending_dir_poll_rx: None,
1931            ephemeral_terminals: std::collections::HashSet::new(),
1932            terminal_commands: std::collections::HashMap::new(),
1933            terminal_resume_commands: std::collections::HashMap::new(),
1934            plugin_dev_workspaces: HashMap::new(),
1935            status_bar_values: HashMap::new(),
1936            mouse_state: crate::app::types::MouseState::default(),
1937            key_context: crate::input::keybindings::KeyContext::Normal,
1938            chord_state: Vec::new(),
1939            previous_click_time: None,
1940            previous_click_position: None,
1941            click_count: 0,
1942            mouse_enabled: false,
1943            mouse_cursor_position: None,
1944            gpm_active: false,
1945            menu_bar_visible: resources.config.editor.show_menu_bar,
1946            menu_bar_auto_shown: false,
1947            tab_bar_visible: resources.config.editor.show_tab_bar,
1948            status_bar_visible: resources.config.editor.show_status_bar,
1949            prompt_line_visible: resources.config.editor.show_prompt_line,
1950            last_auto_recovery_save: now,
1951            last_persistent_auto_save: now,
1952            warning_domains: crate::app::warning_domains::WarningDomainRegistry::default(),
1953            tab_context_menu: None,
1954            new_tab_menu: None,
1955            file_explorer_context_menu: None,
1956            theme_info_popup: None,
1957            event_debug: None,
1958            file_open_state: None,
1959            file_browser_layout: None,
1960            buffer_groups: HashMap::new(),
1961            buffer_to_group: HashMap::new(),
1962            next_buffer_group_id: 0,
1963            pending_next_key_callbacks: std::collections::VecDeque::new(),
1964            key_capture_active: false,
1965            pending_key_capture_buffer: std::collections::VecDeque::new(),
1966            macros: crate::app::macros::MacroState::default(),
1967            active_custom_contexts: std::collections::HashSet::new(),
1968            keyboard_capture: false,
1969            review_hunks: Vec::new(),
1970            pending_file_opens: Vec::new(),
1971            pending_hot_exit_recovery: false,
1972            wait_tracking: HashMap::new(),
1973            completed_waits: Vec::new(),
1974            line_scan: crate::app::line_scan::LineScan::default(),
1975            search_scan: crate::app::search_scan::SearchScan::default(),
1976            search_overlay_top_byte: None,
1977            animations: crate::view::animation::AnimationRunner::default(),
1978            plugin_errors: Vec::new(),
1979            file_explorer_clipboard: None,
1980            process_groups: ProcessGroups::default(),
1981            resources,
1982        }
1983    }
1984
1985    // ---- Resource accessors (canonical reading API) ----
1986    //
1987    // These are thin wrappers around `self.resources.X` for the most
1988    // commonly-read resources. Use them at sites where the borrow
1989    // checker is happy with a method call; fall back to direct
1990    // `self.resources.X` field access at sites that need to split-borrow
1991    // alongside other Window sub-fields.
1992
1993    /// Read-only handle to editor configuration.
1994    pub fn config(&self) -> &crate::config::Config {
1995        &self.resources.config
1996    }
1997
1998    /// This window's backend (local / devcontainer / remote) — owned by the
1999    /// window, never shared with another.
2000    pub fn authority(&self) -> &crate::services::authority::Authority {
2001        &self.authority
2002    }
2003
2004    /// Allocate the next globally-unique `BufferId`.
2005    pub fn alloc_buffer_id(&self) -> BufferId {
2006        self.resources.buffer_id_alloc.next()
2007    }
2008
2009    /// Set this window's status-bar message. Mirrors
2010    /// `Editor::set_status_message` — moved here so handlers on
2011    /// `impl Window` can post status without an `Editor` reference.
2012    /// Clears any plugin-supplied status (matches Editor behaviour).
2013    pub fn set_status_message(&mut self, message: String) {
2014        tracing::info!(target: "status", "{}", message);
2015        self.plugin_status_message = None;
2016        self.status_message = Some(message);
2017    }
2018
2019    /// Clear this window's status-bar message.
2020    pub fn clear_status_message(&mut self) {
2021        self.status_message = None;
2022    }
2023
2024    /// Resolve the effective (split, buffer) pair for the currently-
2025    /// focused target inside this window. Returned invariant: the split
2026    /// id is in `splits.1` (view_states), its `active_buffer` equals
2027    /// the returned buffer id, `self.buffers` contains the buffer id,
2028    /// and the split's `keyed_states` contains an entry for the buffer.
2029    ///
2030    /// Falls back to the outer split when a buffer-group panel is
2031    /// focused but any of those invariants doesn't hold for the inner
2032    /// leaf. Mirrors `Editor::effective_active_pair`.
2033    pub fn effective_active_pair(&self) -> (LeafId, BufferId) {
2034        let (mgr, vs_map) = self
2035            .buffers
2036            .splits()
2037            .expect("active window must have a populated split layout");
2038        let active_split = mgr.active_split();
2039        if let Some(vs) = vs_map.get(&active_split) {
2040            if vs.active_group_tab.is_some() {
2041                if let Some(inner_leaf) = vs.focused_group_leaf {
2042                    if let Some(inner_vs) = vs_map.get(&inner_leaf) {
2043                        let inner_buf = inner_vs.active_buffer;
2044                        if self.buffers.get(&inner_buf).is_some()
2045                            && inner_vs.keyed_states.contains_key(&inner_buf)
2046                        {
2047                            return (inner_leaf, inner_buf);
2048                        }
2049                    }
2050                }
2051            }
2052        }
2053        let outer_buf = mgr
2054            .active_buffer_id()
2055            .expect("Editor always has at least one buffer");
2056        // Validate against `self.buffers` — the group-tab branch above
2057        // already does this for its return; the outer fallback used to
2058        // skip the check and any caller that then did
2059        // `self.buffers.get(&active_buf).unwrap()` would panic. Issue
2060        // #1939: `set_pane_buffer` writes the leaf's `buffer_id` +
2061        // `vs.active_buffer` without touching `vs.open_buffers`, so
2062        // `clean_orphaned_buffers` (which filters by `buffer_tab_ids`)
2063        // can remove a buffer the split manager still points at.
2064        // When that happens, fall back to any live buffer and warn
2065        // loudly — the split manager pointer is stale until something
2066        // repairs it, and we want the underlying state corruption
2067        // visible in logs even though render itself no longer crashes.
2068        if self.buffers.get(&outer_buf).is_some() {
2069            (active_split, outer_buf)
2070        } else if let Some(any) = self.buffers.find_id(|_, _| true) {
2071            tracing::warn!(
2072                stale_buffer_id = ?outer_buf,
2073                fallback_buffer_id = ?any,
2074                active_split = ?active_split,
2075                "effective_active_pair: split manager's active leaf points at \
2076                 a BufferId missing from window.buffers (issue #1939). Falling \
2077                 back to any live buffer; the split tree is in an inconsistent \
2078                 state and should be repaired"
2079            );
2080            (active_split, any)
2081        } else {
2082            // `self.buffers` empty: a bigger invariant violation than
2083            // this helper can recover from. Preserve old behaviour so
2084            // the panic surfaces at the next `.unwrap()` site.
2085            tracing::error!(
2086                stale_buffer_id = ?outer_buf,
2087                active_split = ?active_split,
2088                "effective_active_pair: window.buffers is empty AND the split \
2089                 manager has a stale active buffer — no recovery possible, \
2090                 next render will panic"
2091            );
2092            (active_split, outer_buf)
2093        }
2094    }
2095
2096    /// The id of the buffer currently focused in this window.
2097    #[inline]
2098    pub fn active_buffer(&self) -> BufferId {
2099        let (_, buf) = self.effective_active_pair();
2100        buf
2101    }
2102
2103    /// Width available for tabs in this window. When the file explorer is
2104    /// visible the tabs row only spans the editor area; otherwise it spans
2105    /// the full terminal width.
2106    pub fn effective_tabs_width(&self) -> u16 {
2107        // Start from the chrome left after the editor-global dock, then
2108        // subtract the file explorer — same carve-out order as the
2109        // renderer and `editor_content_area`, so tab-scroll math matches
2110        // the width the tabs actually paint into when the dock is shown.
2111        let chrome = self.terminal_width.saturating_sub(self.dock_cols);
2112        if self.file_explorer_visible && self.file_explorer.is_some() {
2113            let explorer = self.file_explorer_width.to_cols(chrome);
2114            chrome.saturating_sub(explorer)
2115        } else {
2116            chrome
2117        }
2118    }
2119
2120    /// The split id whose `SplitViewState` owns the currently-focused
2121    /// cursors/viewport for this window.
2122    #[inline]
2123    pub fn effective_active_split(&self) -> LeafId {
2124        let (split, _) = self.effective_active_pair();
2125        split
2126    }
2127
2128    /// Read-only handle to this window's active buffer state. Panics
2129    /// if the active buffer is missing — the invariants on
2130    /// `effective_active_pair` guarantee it's present.
2131    pub fn active_state(&self) -> &crate::state::EditorState {
2132        let buf = self.active_buffer();
2133        self.buffers
2134            .get(&buf)
2135            .expect("active buffer must be present in window")
2136    }
2137
2138    /// Mutable handle to this window's active buffer state.
2139    pub fn active_state_mut(&mut self) -> &mut crate::state::EditorState {
2140        let buf = self.active_buffer();
2141        self.buffers
2142            .get_mut(&buf)
2143            .expect("active buffer must be present in window")
2144    }
2145
2146    /// Mutable handle to a specific buffer's editor state, if it is loaded in this window.
2147    pub fn buffer_state_mut(&mut self, id: BufferId) -> Option<&mut crate::state::EditorState> {
2148        self.buffers.get_mut(&id)
2149    }
2150
2151    /// Read-only handle to a specific buffer's editor state, if it is loaded in this window.
2152    pub fn buffer_state(&self, id: BufferId) -> Option<&crate::state::EditorState> {
2153        self.buffers.get(&id)
2154    }
2155
2156    /// Read-only cursor set for the active buffer in the active split.
2157    /// Group panels return their own cursors, not the outer split's
2158    /// stale ones.
2159    pub fn active_cursors(&self) -> &crate::model::cursor::Cursors {
2160        let split_id = self.effective_active_split();
2161        &self
2162            .buffers
2163            .splits()
2164            .expect("active window must have a populated split layout")
2165            .1
2166            .get(&split_id)
2167            .expect("active split must be in view-state map")
2168            .cursors
2169    }
2170
2171    /// Mutable cursor set for the active buffer in the active split.
2172    pub fn active_cursors_mut(&mut self) -> &mut crate::model::cursor::Cursors {
2173        let split_id = self.effective_active_split();
2174        &mut self
2175            .buffers
2176            .splits_mut()
2177            .expect("active window must have a populated split layout")
2178            .1
2179            .get_mut(&split_id)
2180            .expect("active split must be in view-state map")
2181            .cursors
2182    }
2183
2184    /// Read-only event log for the active buffer.
2185    pub fn active_event_log(&self) -> &crate::model::event::EventLog {
2186        let buf = self.active_buffer();
2187        self.event_logs
2188            .get(&buf)
2189            .expect("active buffer must have an event log")
2190    }
2191
2192    /// Mutable event log for the active buffer.
2193    pub fn active_event_log_mut(&mut self) -> &mut crate::model::event::EventLog {
2194        let buf = self.active_buffer();
2195        self.event_logs
2196            .get_mut(&buf)
2197            .expect("active buffer must have an event log")
2198    }
2199
2200    // ---- Preview-tab methods ----
2201
2202    /// Promote a specific buffer from preview to permanent, if it is
2203    /// currently the preview. No-op otherwise.
2204    ///
2205    /// Escalating a preview is a commitment to the file, so this is where
2206    /// the `after_file_open` hook — deferred at preview-open time — finally
2207    /// fires. Because `self.preview` is the single source of truth, this
2208    /// can fire at most once per buffer: a directly-opened (already
2209    /// committed) buffer is never `self.preview`, and a promoted buffer is
2210    /// cleared from `self.preview` here so a later promote is a no-op.
2211    pub fn promote_buffer_from_preview(&mut self, buffer_id: BufferId) {
2212        if self.preview.map(|(_, id)| id) == Some(buffer_id) {
2213            self.preview = None;
2214            self.fire_deferred_after_file_open(buffer_id);
2215        }
2216    }
2217
2218    /// Promote the active buffer from preview to permanent. Called on
2219    /// any buffer mutation so touching a preview buffer commits it.
2220    pub fn promote_active_buffer_from_preview(&mut self) {
2221        let id = self.active_buffer();
2222        self.promote_buffer_from_preview(id);
2223    }
2224
2225    /// Promote the current preview, regardless of which buffer it
2226    /// points at. Used before layout changes (split, close-split,
2227    /// move-tab) where the preview invariant ("anchored to a specific
2228    /// split") would otherwise be broken by the operation itself.
2229    pub fn promote_current_preview(&mut self) {
2230        if let Some((_, id)) = self.preview.take() {
2231            self.fire_deferred_after_file_open(id);
2232        }
2233    }
2234
2235    /// Promote the current preview if it belongs to a split other
2236    /// than `new_split`. Called from split-focus-change paths so
2237    /// that moving focus away from the preview's pane commits it.
2238    pub fn promote_preview_if_not_in_split(&mut self, new_split: LeafId) {
2239        if let Some((preview_split, _)) = self.preview {
2240            if preview_split != new_split {
2241                self.promote_current_preview();
2242            }
2243        }
2244    }
2245
2246    /// Whether the given buffer is currently the preview (ephemeral) tab.
2247    /// Derived from `self.preview`, the single source of truth — at most one
2248    /// buffer is the preview editor-wide.
2249    pub fn is_buffer_preview(&self, buffer_id: BufferId) -> bool {
2250        self.preview.map(|(_, id)| id) == Some(buffer_id)
2251    }
2252
2253    /// The (split, buffer) tuple of the current preview tab, if any.
2254    /// Intended for tests that verify preview anchoring semantics.
2255    pub fn current_preview(&self) -> Option<(LeafId, BufferId)> {
2256        self.preview
2257    }
2258
2259    // ---- Terminal-buffer query helpers ----
2260
2261    /// The [`TerminalBuffer`] record for a buffer, or `None` if it isn't a
2262    /// terminal buffer in this window. The single lookup the helpers below
2263    /// build on.
2264    pub fn terminal_buffer(&self, buffer_id: BufferId) -> Option<&TerminalBuffer> {
2265        self.terminal_buffers.get(&buffer_id)
2266    }
2267
2268    pub fn terminal_buffer_mut(&mut self, buffer_id: BufferId) -> Option<&mut TerminalBuffer> {
2269        self.terminal_buffers.get_mut(&buffer_id)
2270    }
2271
2272    /// Check if a buffer is a terminal buffer (in this window).
2273    pub fn is_terminal_buffer(&self, buffer_id: BufferId) -> bool {
2274        self.terminal_buffer(buffer_id).is_some()
2275    }
2276
2277    /// Get the terminal ID for a buffer (if it's a terminal buffer in
2278    /// this window).
2279    pub fn get_terminal_id(
2280        &self,
2281        buffer_id: BufferId,
2282    ) -> Option<crate::services::terminal::TerminalId> {
2283        self.terminal_buffer(buffer_id).map(|tb| tb.terminal_id)
2284    }
2285
2286    /// The only writer of a terminal's remembered mode — the real
2287    /// live↔scrollback edges (open/resume, Ctrl+Space, scroll-up, exit) all
2288    /// route here. No-op if `buffer_id` isn't a terminal buffer in this window.
2289    pub fn set_terminal_interaction_mode(
2290        &mut self,
2291        buffer_id: BufferId,
2292        mode: TerminalInteractionMode,
2293    ) {
2294        if let Some(tb) = self.terminal_buffer_mut(buffer_id) {
2295            tb.set_mode(mode);
2296        }
2297    }
2298
2299    /// Clear the visual search overlays for the active buffer,
2300    /// preserving search state so F3/Shift+F3 still work.
2301    pub fn clear_search_overlays(&mut self) {
2302        let ns = self.search_namespace.clone();
2303        let state = self.active_state_mut();
2304        state.overlays.clear_namespace(&ns, &mut state.marker_list);
2305    }
2306
2307    /// Clear all search highlights from the active buffer and reset
2308    /// search state.
2309    pub fn clear_search_highlights(&mut self) {
2310        self.clear_search_overlays();
2311        self.search_state = None;
2312    }
2313
2314    /// List the languages with currently-running LSP server handles in
2315    /// this window. Wraps `LspManager::running_servers`.
2316    pub fn running_lsp_servers(&self) -> Vec<String> {
2317        self.lsp.running_servers()
2318    }
2319
2320    /// Number of in-flight completion requests for this window.
2321    pub fn pending_completion_requests_count(&self) -> usize {
2322        self.pending_completion_requests.len()
2323    }
2324
2325    /// Number of stored completion items currently visible in this
2326    /// window's completion popup.
2327    pub fn completion_items_count(&self) -> usize {
2328        self.completion_items.as_ref().map_or(0, |v| v.len())
2329    }
2330
2331    /// Number of initialized (handshake-complete) LSP servers for
2332    /// `language` in this window.
2333    pub fn initialized_lsp_server_count(&self, language: &str) -> usize {
2334        self.lsp
2335            .get_handles(language)
2336            .iter()
2337            .filter(|sh| sh.capabilities.initialized)
2338            .count()
2339    }
2340
2341    /// Number of LSP servers for `language` whose effective capabilities
2342    /// currently advertise `textDocument/completion`. This reflects
2343    /// dynamically-registered providers (`client/registerCapability`), not
2344    /// only the static `initialize` result, so tests can wait for a server
2345    /// like jdtls to register completion before triggering it.
2346    pub fn completion_capable_lsp_server_count(&self, language: &str) -> usize {
2347        self.lsp
2348            .get_handles(language)
2349            .iter()
2350            .filter(|sh| sh.capabilities.completion)
2351            .count()
2352    }
2353
2354    /// Shutdown the LSP server for `language` in this window (marks it
2355    /// disabled until manual restart). Returns true if a server was
2356    /// shutdown, false if no server was running for that language.
2357    pub fn shutdown_lsp_server(&mut self, language: &str) -> bool {
2358        self.lsp.shutdown_server(language)
2359    }
2360
2361    /// Enable event-log streaming to `path` for every buffer's event
2362    /// log in this window.
2363    pub fn enable_event_streaming<P: AsRef<std::path::Path>>(
2364        &mut self,
2365        path: P,
2366    ) -> anyhow::Result<()> {
2367        for event_log in self.event_logs.values_mut() {
2368            event_log.enable_streaming(&path)?;
2369        }
2370        Ok(())
2371    }
2372
2373    /// Log a keystroke against the active buffer's event log. No-op if
2374    /// the active buffer has no log entry.
2375    pub fn log_keystroke(&mut self, key_code: &str, modifiers: &str) {
2376        let buffer_id = self.active_buffer();
2377        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2378            event_log.log_keystroke(key_code, modifiers);
2379        }
2380    }
2381
2382    /// Check if LSP has any active progress tasks (e.g., indexing) in
2383    /// this window.
2384    pub fn has_active_lsp_progress(&self) -> bool {
2385        !self.lsp_progress.is_empty()
2386    }
2387
2388    /// Snapshot of the current LSP progress entries for this window:
2389    /// `(token, title, message)` tuples.
2390    pub fn get_lsp_progress(&self) -> Vec<(String, String, Option<String>)> {
2391        self.lsp_progress
2392            .iter()
2393            .map(|(token, info)| (token.clone(), info.title.clone(), info.message.clone()))
2394            .collect()
2395    }
2396
2397    /// Check if any LSP server for `language` is running in this
2398    /// window. Includes servers registered under another language whose
2399    /// scope accepts `language` (universal servers).
2400    pub fn is_lsp_server_ready(&self, language: &str) -> bool {
2401        use crate::services::async_bridge::LspServerStatus;
2402        self.lsp_server_statuses
2403            .iter()
2404            .any(|((lang, server_name), status)| {
2405                if !matches!(status, LspServerStatus::Running) {
2406                    return false;
2407                }
2408                if lang == language {
2409                    return true;
2410                }
2411                self.lsp
2412                    .server_scope(server_name)
2413                    .map(|scope| scope.accepts(language))
2414                    .unwrap_or(false)
2415            })
2416    }
2417
2418    /// If the active leaf carries `SplitRole::UtilityDock`, move the
2419    /// active leaf back to the user's last regular editor leaf (or any
2420    /// non-dock leaf as a fallback). Called from the file-open path so
2421    /// that opening a file while a utility panel holds focus doesn't
2422    /// turn the dock into a tab strip for ordinary files.
2423    pub fn redirect_active_split_away_from_dock_if_needed(&mut self) {
2424        use crate::view::split::SplitRole;
2425        let Some((mgr, _)) = self.buffers.splits() else {
2426            return;
2427        };
2428        let active = mgr.active_split();
2429        if mgr.leaf_role(active) != Some(SplitRole::UtilityDock) {
2430            return;
2431        }
2432        let is_editor_leaf = |leaf| mgr.leaf_role(leaf) != Some(SplitRole::UtilityDock);
2433        let target = mgr.last_focused_where(is_editor_leaf).or_else(|| {
2434            mgr.root()
2435                .leaf_split_ids()
2436                .into_iter()
2437                .find(|leaf| is_editor_leaf(*leaf))
2438        });
2439        let Some(target) = target else {
2440            return;
2441        };
2442        if target == active {
2443            return;
2444        }
2445        self.split_manager_mut()
2446            .expect("active window must have a populated split layout")
2447            .set_active_split(target);
2448    }
2449
2450    /// Restore per-file state (cursors, scroll, etc.) for a buffer in a
2451    /// specific split, lazily loaded from disk via
2452    /// `PersistedFileWorkspace::load`. No-op if there's no saved state
2453    /// for this path.
2454    pub fn restore_global_file_state(
2455        &mut self,
2456        buffer_id: BufferId,
2457        path: &std::path::Path,
2458        split_id: LeafId,
2459    ) {
2460        use crate::workspace::PersistedFileWorkspace;
2461
2462        let file_state = match PersistedFileWorkspace::load(path) {
2463            Some(state) => state,
2464            None => return,
2465        };
2466
2467        self.restore_buffer_state_in_split(buffer_id, split_id, &file_state);
2468    }
2469
2470    /// Save file state when a buffer is closed (for per-file session
2471    /// persistence). Walks this window's splits to find one that has
2472    /// the buffer; no-op if no split contains it or the buffer isn't
2473    /// a real on-disk file.
2474    pub fn save_file_state_on_close(&self, buffer_id: BufferId) {
2475        use crate::workspace::{
2476            PersistedFileWorkspace, SerializedCursor, SerializedFileState, SerializedScroll,
2477        };
2478
2479        let abs_path = match self.buffer_metadata.get(&buffer_id) {
2480            Some(metadata) => match metadata.file_path() {
2481                Some(path) => path.to_path_buf(),
2482                None => return,
2483            },
2484            None => return,
2485        };
2486
2487        let view_state = self
2488            .buffers
2489            .splits()
2490            .expect("active window must have a populated split layout")
2491            .1
2492            .values()
2493            .find(|vs| vs.has_buffer(buffer_id));
2494
2495        let view_state = match view_state {
2496            Some(vs) => vs,
2497            None => return,
2498        };
2499
2500        let buf_state = match view_state.keyed_states.get(&buffer_id) {
2501            Some(bs) => bs,
2502            None => return,
2503        };
2504
2505        let primary_cursor = buf_state.cursors.primary();
2506        let file_state = SerializedFileState {
2507            cursor: SerializedCursor {
2508                position: primary_cursor.position,
2509                anchor: primary_cursor.anchor,
2510                sticky_column: primary_cursor.sticky_column,
2511            },
2512            additional_cursors: buf_state
2513                .cursors
2514                .iter()
2515                .skip(1)
2516                .map(|(_, cursor)| SerializedCursor {
2517                    position: cursor.position,
2518                    anchor: cursor.anchor,
2519                    sticky_column: cursor.sticky_column,
2520                })
2521                .collect(),
2522            scroll: SerializedScroll {
2523                top_byte: buf_state.viewport.top_byte,
2524                top_view_line_offset: buf_state.viewport.top_view_line_offset,
2525                left_column: buf_state.viewport.left_column,
2526            },
2527            view_mode: Default::default(),
2528            compose_width: None,
2529            // Per-buffer line-number / line-wrap overrides are workspace-scoped,
2530            // not part of the cross-project global per-file state.
2531            line_numbers: None,
2532            line_wrap: None,
2533            plugin_state: std::collections::HashMap::new(),
2534            folds: Vec::new(),
2535        };
2536
2537        PersistedFileWorkspace::save(&abs_path, file_state);
2538        tracing::debug!("Saved file state on close for {:?}", abs_path);
2539    }
2540
2541    /// Remove a pending semantic-token request from this window's tracking maps.
2542    pub(crate) fn take_pending_semantic_token_request(
2543        &mut self,
2544        request_id: u64,
2545    ) -> Option<crate::app::SemanticTokenFullRequest> {
2546        if let Some(request) = self.pending_semantic_token_requests.remove(&request_id) {
2547            self.semantic_tokens_in_flight.remove(&request.buffer_id);
2548            Some(request)
2549        } else {
2550            None
2551        }
2552    }
2553
2554    /// Remove a pending semantic-token range request from this window's tracking maps.
2555    pub(crate) fn take_pending_semantic_token_range_request(
2556        &mut self,
2557        request_id: u64,
2558    ) -> Option<crate::app::SemanticTokenRangeRequest> {
2559        if let Some(request) = self
2560            .pending_semantic_token_range_requests
2561            .remove(&request_id)
2562        {
2563            self.semantic_tokens_range_in_flight
2564                .remove(&request.buffer_id);
2565            Some(request)
2566        } else {
2567            None
2568        }
2569    }
2570
2571    /// Move the cursor to a visible position within the current viewport.
2572    /// Called after scrollbar operations to ensure the cursor is in view.
2573    pub fn move_cursor_to_visible_area(&mut self, split_id: LeafId, buffer_id: BufferId) {
2574        let (top_byte, viewport_height) =
2575            if let Some(view_state) = self.buffers.splits().and_then(|(_, vs)| vs.get(&split_id)) {
2576                (
2577                    view_state.viewport.top_byte,
2578                    view_state.viewport.height as usize,
2579                )
2580            } else {
2581                return;
2582            };
2583
2584        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2585            let buffer_len = state.buffer.len();
2586
2587            let mut iter = state.buffer.line_iterator(top_byte, 80);
2588            let mut bottom_byte = buffer_len;
2589
2590            for _ in 0..viewport_height {
2591                if let Some((pos, line)) = iter.next_line() {
2592                    bottom_byte = pos + line.len();
2593                } else {
2594                    bottom_byte = buffer_len;
2595                    break;
2596                }
2597            }
2598
2599            if let Some(view_state) = self
2600                .split_view_states_mut()
2601                .and_then(|vs| vs.get_mut(&split_id))
2602            {
2603                let cursor_pos = view_state.cursors.primary().position;
2604                if cursor_pos < top_byte || cursor_pos > bottom_byte {
2605                    let cursor = view_state.cursors.primary_mut();
2606                    cursor.position = top_byte;
2607                    // Keep the existing sticky_column value so vertical
2608                    // navigation preserves column.
2609                }
2610            }
2611        }
2612    }
2613
2614    /// Calculate the maximum allowed scroll position so the last line
2615    /// is always at the bottom unless the buffer is smaller than the
2616    /// viewport. Pure function on `Buffer`; lives on `Window` so the
2617    /// scrollbar helpers (also on `Window`) can reach it.
2618    pub fn calculate_max_scroll_position(
2619        buffer: &mut crate::model::buffer::Buffer,
2620        viewport_height: usize,
2621    ) -> usize {
2622        if viewport_height == 0 {
2623            return 0;
2624        }
2625
2626        let buffer_len = buffer.len();
2627        if buffer_len == 0 {
2628            return 0;
2629        }
2630
2631        let mut line_count = 0;
2632        let mut iter = buffer.line_iterator(0, 80);
2633        while iter.next_line().is_some() {
2634            line_count += 1;
2635        }
2636
2637        if line_count <= viewport_height {
2638            return 0;
2639        }
2640
2641        let scrollable_lines = line_count.saturating_sub(viewport_height);
2642
2643        let mut iter = buffer.line_iterator(0, 80);
2644        let mut current_line = 0;
2645        let mut max_byte_pos = 0;
2646
2647        while current_line < scrollable_lines {
2648            if let Some((pos, _content)) = iter.next_line() {
2649                max_byte_pos = pos;
2650                current_line += 1;
2651            } else {
2652                break;
2653            }
2654        }
2655
2656        max_byte_pos
2657    }
2658
2659    /// Find the split whose content or scrollbar area contains the
2660    /// screen cell `(col, row)`. Returns the split id and its buffer
2661    /// id, or `None` when the position falls outside every split's
2662    /// content rect and outside every scrollbar gutter.
2663    pub fn split_at_position(&self, col: u16, row: u16) -> Option<(LeafId, BufferId)> {
2664        for &(split_id, buffer_id, content_rect, scrollbar_rect, _, _) in
2665            &self.layout_cache.split_areas
2666        {
2667            let in_content = col >= content_rect.x
2668                && col < content_rect.x + content_rect.width
2669                && row >= content_rect.y
2670                && row < content_rect.y + content_rect.height;
2671            let in_scrollbar = scrollbar_rect.width > 0
2672                && scrollbar_rect.height > 0
2673                && col >= scrollbar_rect.x
2674                && col < scrollbar_rect.x + scrollbar_rect.width
2675                && row >= scrollbar_rect.y
2676                && row < scrollbar_rect.y + scrollbar_rect.height;
2677            if in_content || in_scrollbar {
2678                return Some((split_id, buffer_id));
2679            }
2680        }
2681        None
2682    }
2683
2684    /// If a per-edit diagnostic-pull debounce has fired, send a fresh
2685    /// `textDocument/diagnostic` request to the language server for the
2686    /// scheduled buffer. Returns false because the new diagnostics arrive
2687    /// asynchronously — the response handler will trigger any redraw.
2688    pub fn check_diagnostic_pull_timer(&mut self) -> bool {
2689        let Some((buffer_id, trigger_time)) = self.scheduled_diagnostic_pull else {
2690            return false;
2691        };
2692
2693        if std::time::Instant::now() < trigger_time {
2694            return false;
2695        }
2696
2697        self.scheduled_diagnostic_pull = None;
2698
2699        let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
2700            return false;
2701        };
2702        let Some(uri) = metadata.file_uri().cloned() else {
2703            return false;
2704        };
2705        let Some(language) = self.buffers.get(&buffer_id).map(|s| s.language.clone()) else {
2706            return false;
2707        };
2708
2709        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
2710        let request_id = self.next_lsp_request_id;
2711        self.next_lsp_request_id += 1;
2712
2713        let lsp = &mut self.lsp;
2714        let Some(sh) = lsp.handle_for_feature_mut(&language, crate::types::LspFeature::Diagnostics)
2715        else {
2716            return false;
2717        };
2718        if let Err(e) =
2719            sh.handle
2720                .document_diagnostic(request_id, uri.as_uri().clone(), previous_result_id)
2721        {
2722            tracing::debug!(
2723                "Failed to pull diagnostics after edit for {}: {}",
2724                uri.as_str(),
2725                e
2726            );
2727        } else {
2728            tracing::debug!(
2729                "Pulling diagnostics after edit for {} (request_id={})",
2730                uri.as_str(),
2731                request_id
2732            );
2733        }
2734
2735        false
2736    }
2737
2738    /// Open a local file in this window (always uses local filesystem,
2739    /// not remote). Used for opening files like the warning log when
2740    /// the editor is connected to a remote server. Returns the buffer
2741    /// id and switches the active buffer to it (via
2742    /// [`Window::set_active_buffer`], so no plugin hook fires — the
2743    /// Editor caller is responsible for re-firing
2744    /// `buffer_activated` if the hook is required).
2745    pub fn open_local_file(&mut self, path: &std::path::Path) -> anyhow::Result<BufferId> {
2746        // Resolve relative paths against this window's root.
2747        let resolved_path = if path.is_relative() {
2748            self.root.join(path)
2749        } else {
2750            path.to_path_buf()
2751        };
2752
2753        // Save user-visible path for language detection before canonicalizing.
2754        let display_path = resolved_path.clone();
2755
2756        // Canonicalize the path.
2757        let canonical_path = resolved_path
2758            .canonicalize()
2759            .unwrap_or_else(|_| resolved_path.clone());
2760        let path = canonical_path.as_path();
2761
2762        // Check if already open.
2763        let already_open = self
2764            .buffers
2765            .iter()
2766            .find(|(_, state)| state.buffer.file_path() == Some(path))
2767            .map(|(id, _)| *id);
2768
2769        if let Some(id) = already_open {
2770            self.set_active_buffer(id);
2771            return Ok(id);
2772        }
2773
2774        // Create new buffer.
2775        let buffer_id = self.alloc_buffer_id();
2776
2777        // Load from canonical path (for I/O and dedup), detect language from
2778        // display path (for glob pattern matching against user-visible names).
2779        let buffer = crate::model::buffer::Buffer::load_from_file(
2780            &canonical_path,
2781            self.config().editor.large_file_threshold_bytes as usize,
2782            std::sync::Arc::clone(&self.resources.local_filesystem),
2783        )?;
2784        let first_line = buffer.first_line_lossy();
2785        let detected =
2786            crate::primitives::detected_language::DetectedLanguage::from_path_with_fallback(
2787                &display_path,
2788                first_line.as_deref(),
2789                &self.resources.grammar_registry,
2790                &self.config().languages,
2791                self.config().default_language.as_deref(),
2792            );
2793        let state = crate::state::EditorState::from_buffer_with_language(buffer, detected);
2794
2795        self.buffers.insert(buffer_id, state);
2796        self.event_logs
2797            .insert(buffer_id, crate::model::event::EventLog::new());
2798
2799        // Create metadata.
2800        let metadata = crate::app::types::BufferMetadata::with_file(
2801            path.to_path_buf(),
2802            &display_path,
2803            &self.root,
2804            self.authority().path_translation.as_ref(),
2805            self.config().editor.auto_read_only,
2806        );
2807        self.buffer_metadata.insert(buffer_id, metadata);
2808
2809        // Add to preferred split's tabs (avoids labeled splits like sidebars).
2810        let target_split = self.preferred_split_for_file();
2811        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
2812        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
2813        // Snapshot config values before taking the mutable view-states borrow
2814        // so the closure body doesn't have to re-borrow `self`.
2815        let cfg = self.config().editor.clone();
2816        if let Some(view_state) = self
2817            .split_view_states_mut()
2818            .expect("active window must have a populated split layout")
2819            .get_mut(&target_split)
2820        {
2821            view_state.add_buffer(buffer_id);
2822            let buf_state = view_state.ensure_buffer_state(buffer_id);
2823            buf_state.apply_config_defaults(
2824                cfg.line_numbers,
2825                cfg.highlight_current_line,
2826                line_wrap,
2827                cfg.wrap_indent,
2828                wrap_column,
2829                cfg.rulers,
2830                cfg.scroll_offset,
2831            );
2832        }
2833
2834        self.set_active_buffer(buffer_id);
2835
2836        let display_name = path.display().to_string();
2837        self.set_status_message(rust_i18n::t!("buffer.opened", name = display_name).to_string());
2838
2839        Ok(buffer_id)
2840    }
2841
2842    /// Mark a buffer in this window as read-only (or writable), keeping
2843    /// the per-buffer metadata `read_only` flag and the editor state's
2844    /// `editing_disabled` flag in sync.
2845    pub fn mark_buffer_read_only(&mut self, buffer_id: BufferId, read_only: bool) {
2846        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
2847            metadata.read_only = read_only;
2848        }
2849        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2850            state.editing_disabled = read_only;
2851        }
2852    }
2853
2854    /// Clear all warning indicators for this window (general + LSP) and
2855    /// post a "Warnings cleared" status message.
2856    pub fn clear_warnings(&mut self) {
2857        self.warning_domains.general.clear();
2858        self.warning_domains.lsp.clear();
2859        self.set_status_message("Warnings cleared".to_string());
2860    }
2861
2862    /// Recompute the LSP warning-domain level for this window from its
2863    /// `lsp_server_statuses` map. Called whenever a server transitions
2864    /// state.
2865    pub fn update_lsp_warning_domain(&mut self) {
2866        // Clone to release the immutable borrow before mutating warning_domains.
2867        let statuses = self.lsp_server_statuses.clone();
2868        self.warning_domains.lsp.update_from_statuses(&statuses);
2869    }
2870
2871    /// Check if semantic highlight debounce timer has expired for any
2872    /// buffer in this window. Returns true if a redraw is needed because
2873    /// the debounce period has elapsed and semantic highlights need to
2874    /// be recomputed.
2875    pub fn check_semantic_highlight_timer(&self) -> bool {
2876        self.buffers.any_needs_semantic_redraw()
2877    }
2878
2879    /// If an active search has placed the cursor inside a match, return that
2880    /// match's byte range.  Used by Ctrl-D ("Add cursor at next match") so a
2881    /// substring search drives the selection — instead of expanding to the
2882    /// whole word — when the user presses Ctrl-D right after searching
2883    /// (issue #1697).
2884    pub fn search_match_at_primary_cursor(&self) -> Option<std::ops::Range<usize>> {
2885        let search_state = self.search_state.as_ref()?;
2886        let pos = self.active_cursors().primary().position;
2887        let idx = match search_state.matches.binary_search(&pos) {
2888            Ok(i) => i,
2889            Err(0) => return None,
2890            Err(i) => i - 1,
2891        };
2892        let start = search_state.matches[idx];
2893        let len = *search_state.match_lengths.get(idx)?;
2894        if pos < start + len {
2895            Some(start..start + len)
2896        } else {
2897            None
2898        }
2899    }
2900
2901    /// Update search highlights in the visible viewport for the active
2902    /// buffer. Caller passes theme colors as parameters because `theme`
2903    /// is editor-global (not yet on `Window.resources`).
2904    pub fn update_search_highlights(
2905        &mut self,
2906        query: &str,
2907        search_fg: ratatui::style::Color,
2908        search_bg: ratatui::style::Color,
2909    ) {
2910        if query.is_empty() {
2911            self.clear_search_highlights();
2912            return;
2913        }
2914
2915        let ns = self.search_namespace.clone();
2916
2917        // Share the one search-regex builder so highlight anchoring (`^`/`$`
2918        // line-matching) stays consistent with the actual search results.
2919        let regex = match self.build_search_regex(query) {
2920            Ok(r) => r,
2921            Err(_) => {
2922                self.clear_search_highlights();
2923                return;
2924            }
2925        };
2926
2927        let active_split = self.effective_active_split();
2928        let (top_byte, visible_height) = self
2929            .buffers
2930            .splits()
2931            .expect("active window must have a populated split layout")
2932            .1
2933            .get(&active_split)
2934            .map(|vs| (vs.viewport.top_byte, vs.viewport.height.saturating_sub(2)))
2935            .unwrap_or((0, 20));
2936
2937        let state = self.active_state_mut();
2938        state.overlays.clear_namespace(&ns, &mut state.marker_list);
2939
2940        let visible_start = top_byte;
2941        let mut visible_end = top_byte;
2942        {
2943            let mut line_iter = state.buffer.line_iterator(top_byte, 80);
2944            for _ in 0..visible_height {
2945                if let Some((line_start, line_content)) = line_iter.next_line() {
2946                    visible_end = line_start + line_content.len();
2947                } else {
2948                    break;
2949                }
2950            }
2951        }
2952        visible_end = visible_end.min(state.buffer.len());
2953        let visible_text = state.get_text_range(visible_start, visible_end);
2954
2955        for mat in regex.find_iter(&visible_text) {
2956            let absolute_pos = visible_start + mat.start();
2957            let match_len = mat.end() - mat.start();
2958            let search_style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
2959            let overlay = crate::view::overlay::Overlay::with_namespace_fixed_end(
2960                &mut state.marker_list,
2961                absolute_pos..(absolute_pos + match_len),
2962                crate::view::overlay::OverlayFace::Style {
2963                    style: search_style,
2964                },
2965                ns.clone(),
2966            )
2967            .with_priority_value(10);
2968            state.overlays.add(overlay);
2969        }
2970    }
2971
2972    /// Re-evaluate committed search highlights around an edited region.
2973    ///
2974    /// Search-match overlays are anchored by markers that merely *track*
2975    /// byte positions through edits; they never re-check whether the text
2976    /// they cover still matches the query. So editing inside a highlighted
2977    /// match (or typing against its boundary, which can break a `\b`
2978    /// whole-word rule) would leave a stale highlight on text that no
2979    /// longer matches. This recomputes matches on just the line(s) touched
2980    /// by the edit and swaps the search overlays in that span, so highlights
2981    /// drop and appear exactly where the text starts/stops matching.
2982    ///
2983    /// `edit_start` / `edit_new_len` are in post-edit byte coordinates (for
2984    /// a deletion, `edit_new_len` is 0). Bounded to the affected lines to
2985    /// keep it viewport-localized rather than a full-buffer rescan.
2986    pub fn reevaluate_search_overlays_around(
2987        &mut self,
2988        edit_start: usize,
2989        edit_new_len: usize,
2990        search_fg: ratatui::style::Color,
2991        search_bg: ratatui::style::Color,
2992    ) {
2993        let query = match self.search_state.as_ref() {
2994            Some(ss) if !ss.query.is_empty() => ss.query.clone(),
2995            _ => return,
2996        };
2997
2998        let ns = self.search_namespace.clone();
2999
3000        // Share the one search-regex builder so re-highlighting after an edit
3001        // keeps `^`/`$` line-anchoring consistent with the search results.
3002        let regex = match self.build_search_regex(&query) {
3003            Ok(r) => r,
3004            Err(_) => return,
3005        };
3006
3007        let state = self.active_state_mut();
3008        let buf_len = state.buffer.len();
3009        let edit_end = edit_start.saturating_add(edit_new_len).min(buf_len);
3010
3011        // Expand the edited byte span to the full line(s) it touches so that
3012        // word-boundary context on either side of the edit is included.
3013        let start_line = state.buffer.get_line_number(edit_start.min(buf_len));
3014        let end_line = state.buffer.get_line_number(edit_end);
3015        let win_start = state.buffer.line_start_offset(start_line).unwrap_or(0);
3016        let win_end = state
3017            .buffer
3018            .line_start_offset(end_line + 1)
3019            .unwrap_or(buf_len)
3020            .min(buf_len);
3021
3022        let text = state.get_text_range(win_start, win_end);
3023
3024        let mut new_overlays = Vec::new();
3025        for mat in regex.find_iter(&text) {
3026            let absolute_pos = win_start + mat.start();
3027            let match_len = mat.end() - mat.start();
3028            let search_style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
3029            new_overlays.push(
3030                crate::view::overlay::Overlay::with_namespace_fixed_end(
3031                    &mut state.marker_list,
3032                    absolute_pos..(absolute_pos + match_len),
3033                    crate::view::overlay::OverlayFace::Style {
3034                        style: search_style,
3035                    },
3036                    ns.clone(),
3037                )
3038                .with_priority_value(10),
3039            );
3040        }
3041
3042        state.overlays.replace_range_in_namespace(
3043            &ns,
3044            &(win_start..win_end),
3045            new_overlays,
3046            &mut state.marker_list,
3047        );
3048    }
3049
3050    // ---- File-explorer leaf delegators ----
3051
3052    /// Whether this window's file-explorer panel is visible.
3053    pub fn file_explorer_is_visible(&self) -> bool {
3054        self.file_explorer_visible && self.file_explorer.is_some()
3055    }
3056
3057    /// Extend the file-explorer selection upward.
3058    pub fn file_explorer_extend_selection_up(&mut self) {
3059        if let Some(explorer) = self.file_explorer.as_mut() {
3060            explorer.extend_selection_up();
3061        }
3062    }
3063
3064    /// Extend the file-explorer selection downward.
3065    pub fn file_explorer_extend_selection_down(&mut self) {
3066        if let Some(explorer) = self.file_explorer.as_mut() {
3067            explorer.extend_selection_down();
3068        }
3069    }
3070
3071    /// Toggle the selection state of the focused file-explorer entry.
3072    pub fn file_explorer_toggle_select(&mut self) {
3073        if let Some(explorer) = self.file_explorer.as_mut() {
3074            explorer.toggle_select();
3075        }
3076    }
3077
3078    /// Select every visible entry in the file explorer.
3079    pub fn file_explorer_select_all(&mut self) {
3080        if let Some(explorer) = self.file_explorer.as_mut() {
3081            explorer.select_all();
3082        }
3083    }
3084
3085    /// Push a character onto the file-explorer search filter.
3086    pub fn file_explorer_search_push_char(&mut self, c: char) {
3087        if let Some(explorer) = self.file_explorer.as_mut() {
3088            explorer.search_push_char(c);
3089            explorer.update_scroll_for_selection();
3090        }
3091    }
3092
3093    /// Pop the last character from the file-explorer search filter.
3094    pub fn file_explorer_search_pop_char(&mut self) {
3095        if let Some(explorer) = self.file_explorer.as_mut() {
3096            explorer.search_pop_char();
3097            explorer.update_scroll_for_selection();
3098        }
3099    }
3100
3101    // ---- LSP scheduling helpers ----
3102
3103    /// Schedule a folding-range refresh for a buffer (debounced). The
3104    /// debounce window timestamp is stored on the window's per-buffer
3105    /// folding-ranges debounce map.
3106    pub fn schedule_folding_ranges_refresh(&mut self, buffer_id: BufferId) {
3107        const FOLDING_RANGES_DEBOUNCE_MS: u64 = 300;
3108        let next_time = std::time::Instant::now()
3109            + std::time::Duration::from_millis(FOLDING_RANGES_DEBOUNCE_MS);
3110        self.folding_ranges_debounce.insert(buffer_id, next_time);
3111    }
3112
3113    /// Schedule a full semantic-tokens refresh for a buffer (debounced).
3114    /// No-op when `enable_semantic_tokens_full` is off in the active
3115    /// config.
3116    pub fn schedule_semantic_tokens_full_refresh(&mut self, buffer_id: BufferId) {
3117        const SEMANTIC_TOKENS_FULL_DEBOUNCE_MS: u64 = 500;
3118        if !self.resources.config.editor.enable_semantic_tokens_full {
3119            return;
3120        }
3121        let next_time = std::time::Instant::now()
3122            + std::time::Duration::from_millis(SEMANTIC_TOKENS_FULL_DEBOUNCE_MS);
3123        self.semantic_tokens_full_debounce
3124            .insert(buffer_id, next_time);
3125    }
3126
3127    /// Forward incremental LSP `didChange` notifications for `buffer_id`
3128    /// to every server registered for the buffer's language. Sends
3129    /// `didOpen` first when a server hasn't yet seen this buffer, and
3130    /// reschedules diagnostic / inlay-hint pulls.
3131    ///
3132    /// Pure per-window operation: every piece of state it touches
3133    /// (`buffer_metadata`, `buffers`, the LSP manager, debounce maps)
3134    /// lives on `Window`. Editor-side wrappers exist only as forwarding
3135    /// shims for legacy call sites.
3136    pub(crate) fn send_lsp_changes_for_buffer(
3137        &mut self,
3138        buffer_id: BufferId,
3139        changes: Vec<lsp_types::TextDocumentContentChangeEvent>,
3140    ) {
3141        const INLAY_HINTS_DEBOUNCE_MS: u64 = 500;
3142
3143        if changes.is_empty() {
3144            return;
3145        }
3146
3147        let metadata = match self.buffer_metadata.get(&buffer_id) {
3148            Some(m) => m,
3149            None => {
3150                tracing::debug!(
3151                    "send_lsp_changes_for_buffer: no metadata for buffer {:?}",
3152                    buffer_id
3153                );
3154                return;
3155            }
3156        };
3157
3158        if !metadata.lsp_enabled {
3159            tracing::debug!("send_lsp_changes_for_buffer: LSP disabled for this buffer");
3160            return;
3161        }
3162
3163        let uri = match metadata.file_uri() {
3164            Some(u) => u.clone(),
3165            None => {
3166                tracing::debug!(
3167                    "send_lsp_changes_for_buffer: no URI for buffer (not a file or URI creation failed)"
3168                );
3169                return;
3170            }
3171        };
3172        let file_path = metadata.file_path().cloned();
3173
3174        let language = match self.buffers.get(&buffer_id).map(|s| s.language.clone()) {
3175            Some(l) => l,
3176            None => {
3177                tracing::debug!(
3178                    "send_lsp_changes_for_buffer: no buffer state for {:?}",
3179                    buffer_id
3180                );
3181                return;
3182            }
3183        };
3184
3185        tracing::trace!(
3186            "send_lsp_changes_for_buffer: sending {} changes to {} in single didChange notification",
3187            changes.len(),
3188            uri.as_str()
3189        );
3190
3191        use crate::services::lsp::manager::LspSpawnResult;
3192        let lsp = &mut self.lsp;
3193
3194        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3195            tracing::debug!(
3196                "send_lsp_changes_for_buffer: LSP not running for {} (auto_start disabled)",
3197                language
3198            );
3199            return;
3200        }
3201
3202        let handles_needing_open: Vec<_> = {
3203            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
3204                return;
3205            };
3206            lsp.get_handles(&language)
3207                .into_iter()
3208                .filter(|sh| !metadata.lsp_opened_with.contains(&sh.handle.id()))
3209                .map(|sh| (sh.name.clone(), sh.handle.id()))
3210                .collect()
3211        };
3212
3213        if !handles_needing_open.is_empty() {
3214            let text = match self
3215                .buffers
3216                .get(&buffer_id)
3217                .and_then(|s| s.buffer.to_string())
3218            {
3219                Some(t) => t,
3220                None => {
3221                    tracing::debug!(
3222                        "send_lsp_changes_for_buffer: buffer text not available for didOpen"
3223                    );
3224                    return;
3225                }
3226            };
3227
3228            let lsp = &mut self.lsp;
3229            for sh in lsp.get_handles_mut(&language) {
3230                if handles_needing_open
3231                    .iter()
3232                    .any(|(_, id)| *id == sh.handle.id())
3233                {
3234                    if let Err(e) =
3235                        sh.handle
3236                            .did_open(uri.as_uri().clone(), text.clone(), language.clone())
3237                    {
3238                        tracing::warn!(
3239                            "Failed to send didOpen to '{}' before didChange: {}",
3240                            sh.name,
3241                            e
3242                        );
3243                    } else {
3244                        tracing::debug!(
3245                            "Sent didOpen for {} to LSP handle '{}' before didChange",
3246                            uri.as_str(),
3247                            sh.name
3248                        );
3249                    }
3250                }
3251            }
3252
3253            if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
3254                for (_, handle_id) in &handles_needing_open {
3255                    metadata.lsp_opened_with.insert(*handle_id);
3256                }
3257            }
3258
3259            // didOpen already contains the full current buffer content, so we must
3260            // NOT also send didChange (which carries pre-edit incremental changes).
3261            // Sending both would corrupt the server's view of the document.
3262            return;
3263        }
3264
3265        let lsp = &mut self.lsp;
3266        let mut any_sent = false;
3267        for sh in lsp.get_handles_mut(&language) {
3268            if let Err(e) = sh.handle.did_change(uri.as_uri().clone(), changes.clone()) {
3269                tracing::warn!("Failed to send didChange to '{}': {}", sh.name, e);
3270            } else {
3271                any_sent = true;
3272            }
3273        }
3274        if any_sent {
3275            tracing::trace!("Successfully sent batched didChange to LSP");
3276
3277            if let Some(state) = self.buffers.get(&buffer_id) {
3278                if let Some(path) = state.buffer.file_path() {
3279                    crate::services::lsp::diagnostics::invalidate_cache_for_file(
3280                        &path.to_string_lossy(),
3281                    );
3282                }
3283            }
3284
3285            self.scheduled_diagnostic_pull = Some((
3286                buffer_id,
3287                std::time::Instant::now() + std::time::Duration::from_millis(1000),
3288            ));
3289
3290            if self.resources.config.editor.enable_inlay_hints {
3291                self.scheduled_inlay_hints_request = Some((
3292                    buffer_id,
3293                    std::time::Instant::now()
3294                        + std::time::Duration::from_millis(INLAY_HINTS_DEBOUNCE_MS),
3295                ));
3296            }
3297        }
3298    }
3299
3300    /// Invalidate cached layouts and view transforms for every split
3301    /// that displays `buffer_id`. Pure window-state mutation: walks
3302    /// the window's split tree and view-state map.
3303    pub fn invalidate_layouts_for_buffer(&mut self, buffer_id: BufferId) {
3304        let Some((mgr, vs_map)) = self.buffers.splits_mut() else {
3305            return;
3306        };
3307        let splits_for_buffer = mgr.splits_for_buffer(buffer_id);
3308        for split_id in splits_for_buffer {
3309            if let Some(view_state) = vs_map.get_mut(&split_id) {
3310                view_state.invalidate_layout();
3311                view_state.view_transform = None;
3312                view_state.view_transform_stale = true;
3313            }
3314        }
3315    }
3316
3317    /// Adjust cursors in other splits that share the same buffer after
3318    /// an edit. The split that originated the event already had its
3319    /// cursors moved by `BufferState::apply`; this method walks every
3320    /// other split displaying the same buffer and shifts (or, for a
3321    /// `BulkEdit`, resets) their cursors so they don't dangle past
3322    /// freshly-deleted text.
3323    pub fn adjust_other_split_cursors_for_event(&mut self, event: &Event) {
3324        let current_buffer_id = self.active_buffer();
3325        let buffer_len = self
3326            .buffers
3327            .get(&current_buffer_id)
3328            .map(|s| s.buffer.len())
3329            .unwrap_or(0);
3330        let Some((mgr, vs_map)) = self.buffers.splits_mut() else {
3331            return;
3332        };
3333        let current_split_id = mgr.active_split();
3334        let splits_for_buffer = mgr.splits_for_buffer(current_buffer_id);
3335
3336        if let Event::BulkEdit { new_cursors, .. } = event {
3337            for split_id in splits_for_buffer {
3338                if split_id == current_split_id {
3339                    continue;
3340                }
3341                if let Some(view_state) = vs_map.get_mut(&split_id) {
3342                    if let Some((_, pos, _)) = new_cursors.first() {
3343                        let new_pos = (*pos).min(buffer_len);
3344                        view_state.cursors.primary_mut().position = new_pos;
3345                        view_state.cursors.primary_mut().anchor = None;
3346                    }
3347                }
3348            }
3349            return;
3350        }
3351
3352        let adjustments: Vec<(usize, usize, usize)> = match event {
3353            Event::Insert { position, text, .. } => {
3354                vec![(*position, 0, text.len())]
3355            }
3356            Event::Delete { range, .. } => {
3357                vec![(range.start, range.len(), 0)]
3358            }
3359            Event::Batch { events, .. } => events
3360                .iter()
3361                .filter_map(|e| match e {
3362                    Event::Insert { position, text, .. } => Some((*position, 0, text.len())),
3363                    Event::Delete { range, .. } => Some((range.start, range.len(), 0)),
3364                    _ => None,
3365                })
3366                .collect(),
3367            _ => Vec::new(),
3368        };
3369
3370        if adjustments.is_empty() {
3371            return;
3372        }
3373
3374        for split_id in splits_for_buffer {
3375            if split_id == current_split_id {
3376                continue;
3377            }
3378            if let Some(view_state) = vs_map.get_mut(&split_id) {
3379                for (edit_pos, old_len, new_len) in &adjustments {
3380                    view_state
3381                        .cursors
3382                        .adjust_for_edit(*edit_pos, *old_len, *new_len);
3383                }
3384            }
3385        }
3386    }
3387
3388    /// Handle scroll events using the active split's viewport.
3389    ///
3390    /// View events (like `Scroll`) target SplitViewState rather than
3391    /// EditorState so scroll limits are correct when view transforms
3392    /// inject extra rows.
3393    pub(crate) fn handle_scroll_event(&mut self, line_offset: isize) {
3394        use crate::view::ui::view_pipeline::ViewLineIterator;
3395
3396        let Some((mgr, _)) = self.buffers.splits() else {
3397            return;
3398        };
3399        let active_split = mgr.active_split();
3400
3401        if let Some(group) = self
3402            .scroll_sync_manager
3403            .find_group_for_split(active_split.into())
3404        {
3405            let left = group.left_split;
3406            let right = group.right_split;
3407            if let Some(vs_map) = self.split_view_states_mut() {
3408                if let Some(vs) = vs_map.get_mut(&LeafId(left)) {
3409                    vs.viewport.set_skip_ensure_visible();
3410                }
3411                if let Some(vs) = vs_map.get_mut(&LeafId(right)) {
3412                    vs.viewport.set_skip_ensure_visible();
3413                }
3414            }
3415        }
3416
3417        let (mgr, vs_map) = self.buffers.splits().expect("splits checked above");
3418        let sync_group = vs_map.get(&active_split).and_then(|vs| vs.sync_group);
3419        let splits_to_scroll = if let Some(group_id) = sync_group {
3420            mgr.get_splits_in_group(group_id, vs_map)
3421        } else {
3422            vec![active_split]
3423        };
3424
3425        let tab_size = self.resources.config.editor.tab_size;
3426        for split_id in splits_to_scroll {
3427            let (mgr, vs_map) = self.buffers.splits().expect("splits checked above");
3428            let Some(buffer_id) = mgr.buffer_for_split(split_id) else {
3429                continue;
3430            };
3431
3432            let view_transform_tokens = vs_map
3433                .get(&split_id)
3434                .and_then(|vs| vs.view_transform.as_ref())
3435                .map(|vt| vt.tokens.clone());
3436
3437            self.buffers
3438                .with_buffer_and_split(buffer_id, split_id, |state, view_state| {
3439                    let soft_breaks = state.collect_soft_break_positions();
3440                    let virtual_lines = state.collect_virtual_line_positions();
3441                    let buffer = &mut state.buffer;
3442                    if let Some(tokens) = view_transform_tokens {
3443                        let view_lines: Vec<_> =
3444                            ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
3445                        view_state
3446                            .viewport
3447                            .scroll_view_lines(&view_lines, line_offset);
3448                    } else if line_offset > 0 {
3449                        view_state.viewport.scroll_down(
3450                            buffer,
3451                            &soft_breaks,
3452                            &virtual_lines,
3453                            line_offset as usize,
3454                        );
3455                    } else {
3456                        view_state.viewport.scroll_up(
3457                            buffer,
3458                            &soft_breaks,
3459                            &virtual_lines,
3460                            line_offset.unsigned_abs(),
3461                        );
3462                    }
3463                    view_state.viewport.set_skip_ensure_visible();
3464                });
3465        }
3466    }
3467
3468    /// Handle a `SetViewport` event using the active split's viewport.
3469    pub(crate) fn handle_set_viewport_event(&mut self, top_line: usize) {
3470        let Some((mgr, _)) = self.buffers.splits() else {
3471            return;
3472        };
3473        let active_split = mgr.active_split();
3474
3475        if self
3476            .scroll_sync_manager
3477            .is_split_synced(active_split.into())
3478        {
3479            if let Some(group) = self
3480                .scroll_sync_manager
3481                .find_group_for_split_mut(active_split.into())
3482            {
3483                let scroll_line = if group.is_left_split(active_split.into()) {
3484                    top_line
3485                } else {
3486                    group.right_to_left_line(top_line)
3487                };
3488                group.set_scroll_line(scroll_line);
3489            }
3490
3491            let (left, right) = match self
3492                .scroll_sync_manager
3493                .find_group_for_split(active_split.into())
3494            {
3495                Some(group) => (group.left_split, group.right_split),
3496                None => return,
3497            };
3498            if let Some(vs_map) = self.split_view_states_mut() {
3499                if let Some(vs) = vs_map.get_mut(&LeafId(left)) {
3500                    vs.viewport.set_skip_ensure_visible();
3501                }
3502                if let Some(vs) = vs_map.get_mut(&LeafId(right)) {
3503                    vs.viewport.set_skip_ensure_visible();
3504                }
3505            }
3506            return;
3507        }
3508
3509        let (mgr, vs_map) = self.buffers.splits().expect("splits checked above");
3510        let sync_group = vs_map.get(&active_split).and_then(|vs| vs.sync_group);
3511        let splits_to_scroll = if let Some(group_id) = sync_group {
3512            mgr.get_splits_in_group(group_id, vs_map)
3513        } else {
3514            vec![active_split]
3515        };
3516
3517        for split_id in splits_to_scroll {
3518            let (mgr, _) = self.buffers.splits().expect("splits checked above");
3519            let Some(buffer_id) = mgr.buffer_for_split(split_id) else {
3520                continue;
3521            };
3522
3523            self.buffers
3524                .with_buffer_and_split(buffer_id, split_id, |state, view_state| {
3525                    view_state.viewport.scroll_to(&mut state.buffer, top_line);
3526                    view_state.viewport.set_skip_ensure_visible();
3527                });
3528        }
3529    }
3530
3531    /// Handle a `Recenter` event using the active split's viewport.
3532    pub(crate) fn handle_recenter_event(&mut self) {
3533        let Some((mgr, vs_map)) = self.buffers.splits() else {
3534            return;
3535        };
3536        let active_split = mgr.active_split();
3537
3538        let sync_group = vs_map.get(&active_split).and_then(|vs| vs.sync_group);
3539        let splits_to_recenter = if let Some(group_id) = sync_group {
3540            mgr.get_splits_in_group(group_id, vs_map)
3541        } else {
3542            vec![active_split]
3543        };
3544
3545        for split_id in splits_to_recenter {
3546            let (mgr, _) = self.buffers.splits().expect("splits checked above");
3547            let Some(buffer_id) = mgr.buffer_for_split(split_id) else {
3548                continue;
3549            };
3550
3551            self.buffers
3552                .with_buffer_and_split(buffer_id, split_id, |state, view_state| {
3553                    let buffer = &mut state.buffer;
3554                    let cursor_pos = view_state.cursors.primary().position;
3555                    // `center_on_position` counts real visual rows, so a
3556                    // recenter in a wrapped document doesn't under-scroll
3557                    // and leave the cursor below the viewport (each logical
3558                    // line above the cursor can span many rows).
3559                    view_state.viewport.center_on_position(buffer, cursor_pos);
3560                    view_state.viewport.set_skip_ensure_visible();
3561                });
3562        }
3563    }
3564
3565    /// Atomically update both sides of the pane-buffer invariant for a
3566    /// given leaf split: the split tree's stored buffer AND the matching
3567    /// `SplitViewState.active_buffer` / `keyed_states` map.
3568    ///
3569    /// This is the one place that's allowed to change "which buffer is
3570    /// shown in pane `leaf`". The two stores can never drift if every
3571    /// caller goes through here (issue #1620).
3572    ///
3573    /// If the leaf has no `SplitViewState` yet (e.g. mid-session-restore,
3574    /// when the SVS is registered later), the tree is still updated and
3575    /// the SVS sync is skipped — the caller is responsible for ensuring
3576    /// the SVS exists by the time any input is routed.
3577    pub fn set_pane_buffer(&mut self, leaf: LeafId, buffer_id: BufferId) {
3578        let (mgr, vs_map) = self
3579            .buffers
3580            .splits_mut()
3581            .expect("active window must have a populated split layout");
3582        mgr.set_split_buffer(leaf, buffer_id);
3583        if let Some(view_state) = vs_map.get_mut(&leaf) {
3584            view_state.switch_buffer(buffer_id);
3585            view_state.add_buffer(buffer_id);
3586        }
3587    }
3588}
3589
3590// Label-defaulting unit tests (`empty_label_defaults_to_root_basename`,
3591// `explicit_label_is_kept`, `empty_label_with_rootless_path_falls_back_to_main`)
3592// were removed when `Window::new` started taking a `WindowResources`
3593// argument — stubbing every editor-global service for a 3-line label
3594// assertion isn't worth the maintenance, and the same behaviour is
3595// already exercised by every `EditorTestHarness::create` path that
3596// names a window.