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