Skip to main content

fresh/app/window/
mod.rs

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