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