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