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