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