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