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