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