fresh/app/buffer_management.rs
1//! Buffer management operations for the Editor.
2//!
3//! This module contains all methods related to buffer lifecycle and navigation:
4//! - Opening files (with and without focus)
5//! - Creating new buffers (regular and virtual)
6//! - Closing buffers and tabs
7//! - Switching between buffers
8//! - Navigate back/forward in position history
9//! - Buffer state persistence
10
11use rust_i18n::t;
12use std::collections::HashSet;
13use std::path::Path;
14use std::sync::Arc;
15
16use crate::model::event::{BufferId, Event, LeafId};
17use crate::state::EditorState;
18
19use super::buffer_config_resolve;
20use super::Editor;
21
22impl crate::app::window::Window {
23 /// Resolve the effective line_wrap setting for a buffer, considering language overrides.
24 pub(crate) fn resolve_line_wrap_for_buffer(&self, buffer_id: BufferId) -> bool {
25 match self.buffers.get(&buffer_id) {
26 Some(state) => buffer_config_resolve::line_wrap(&state.language, self.config()),
27 None => self.config().editor.line_wrap,
28 }
29 }
30
31 /// Resolve page view settings for a buffer from its language config.
32 pub(crate) fn resolve_page_view_for_buffer(
33 &self,
34 buffer_id: BufferId,
35 ) -> Option<Option<usize>> {
36 let state = self.buffers.get(&buffer_id)?;
37 buffer_config_resolve::page_view(&state.language, self.config())
38 }
39
40 /// Resolve the effective wrap_column for a buffer, considering language overrides.
41 pub(crate) fn resolve_wrap_column_for_buffer(&self, buffer_id: BufferId) -> Option<usize> {
42 match self.buffers.get(&buffer_id) {
43 Some(state) => buffer_config_resolve::wrap_column(&state.language, self.config()),
44 None => self.config().editor.wrap_column,
45 }
46 }
47
48 /// Get the preferred split for opening a file.
49 /// If the active split has no label, use it (normal case).
50 /// Otherwise find an unlabeled leaf so files don't open in labeled splits (e.g., sidebars).
51 pub(crate) fn preferred_split_for_file(&self) -> LeafId {
52 let (mgr, _) = self
53 .buffers
54 .splits()
55 .expect("active window must have a populated split layout");
56 let active = mgr.active_split();
57 if mgr.get_label(active.into()).is_none() {
58 return active;
59 }
60 mgr.find_unlabeled_leaf().unwrap_or(active)
61 }
62}
63
64impl Editor {
65 /// Open a file in "preview" (ephemeral) mode and return its buffer ID.
66 ///
67 /// Used for exploratory single-click opens from the file explorer. If the
68 /// `file_explorer.preview_tabs` setting is disabled, this is equivalent to
69 /// `open_file`.
70 ///
71 /// Semantics (see `Editor::preview` for the full invariants):
72 /// - Preview is anchored to a specific split. At most one preview exists
73 /// editor-wide.
74 /// - If the file is already open (deduped by canonical path, including
75 /// symlinks and relative paths, by delegating to `open_file_no_focus`),
76 /// just switch to it. No preview-state changes in either direction.
77 /// - Otherwise, if there's an existing preview in the **same** target
78 /// split, close it and replace it. If it's in a **different** split,
79 /// promote it (walking away is commitment) and start a fresh preview
80 /// in the target split.
81 /// - Skips writing to position history, so a string of exploratory
82 /// clicks doesn't flood back/forward navigation with stale entries.
83 ///
84 /// TODO(perf): Each preview swap today triggers LSP didClose + didOpen.
85 /// For heavy language servers (rust-analyzer, tsserver) that's wasteful
86 /// on rapid browsing. A future optimization is to keep the LSP session
87 /// for the outgoing buffer until the user commits to the new one.
88 pub fn open_file_preview(&mut self, path: &Path) -> anyhow::Result<BufferId> {
89 // Dismiss any popup on the buffer being left. The explorer's preview
90 // gesture (mouse single-click *and* keyboard arrow nav both route
91 // through this function) is a focus shift away from the editor pane;
92 // an LSP popup anchored to the previous buffer's cursor must not
93 // follow the user across previews. Doing the cleanup here is the
94 // single dedup point — both input paths get it for free, and the
95 // popup is gone in the next render so a subsequent re-preview of the
96 // same file doesn't resurrect it.
97 if self.active_state().popups.is_visible() {
98 self.clear_popups();
99 }
100
101 // Feature gate — fall back to normal open when preview tabs are off.
102 if !self.config.file_explorer.preview_tabs {
103 return self.open_file(path);
104 }
105
106 // Decide target split up-front. `open_file_no_focus` will target
107 // the same one (it calls `preferred_split_for_file` internally),
108 // so this mirrors its logic. If that invariant ever drifts we'd
109 // open the preview in one split and track it in another.
110 let target_split = self.active_window().preferred_split_for_file();
111
112 // Snapshot the buffer IDs that already back a real file, so we can
113 // tell "opened a previously-unknown file" from "switched to one
114 // that was already open". We delegate the symlink/relative-path
115 // dedup to `open_file_no_focus` (which canonicalizes) — any buffer
116 // with a non-empty file path is a candidate match. Note: the
117 // initial empty buffer has a `BufferKind::File` with an empty
118 // `PathBuf`, and we deliberately exclude it here because
119 // `open_file_no_focus` may *repurpose* that buffer (same ID, new
120 // content) for the newly-opened file.
121 let previously_file_backed: HashSet<BufferId> = self
122 .buffers()
123 .iter()
124 .filter_map(|(id, state)| {
125 state.buffer.file_path().and_then(|p| {
126 if p.as_os_str().is_empty() {
127 None
128 } else {
129 Some(*id)
130 }
131 })
132 })
133 .collect();
134
135 // Route through `open_file` with position-history suppression.
136 // Using the regular `open_file` path keeps all cross-cutting concerns
137 // (LSP, language detection, split targeting, status message, plugin
138 // hooks) consistent with a normal open.
139 self.active_window_mut().suppress_position_history_once = true;
140 let open_result = self.open_file(path);
141 self.active_window_mut().suppress_position_history_once = false;
142 let buffer_id = open_result?;
143 let is_new = !previously_file_backed.contains(&buffer_id);
144
145 // Already-open buffer: leave preview state untouched. A previously-
146 // committed tab must not be demoted back to preview, and the existing
147 // preview (if any, in whichever split) is still valid.
148 if !is_new {
149 return Ok(buffer_id);
150 }
151
152 // New buffer. Resolve the existing preview (if any) relative to the
153 // target split.
154 match self.active_window_mut().preview.take() {
155 Some((prev_split, old_id)) if prev_split == target_split => {
156 // Same split: close the old preview so the new one takes its
157 // place. If close fails (modified buffer — shouldn't happen
158 // because edits promote, but defend in depth), demote the
159 // orphan to a permanent tab rather than leaving behind an
160 // italic "(preview)" tab that will never be replaced.
161 if let Err(e) = self.close_buffer(old_id) {
162 tracing::warn!(
163 "preview: could not replace stale preview buffer {:?}, demoting to permanent: {}",
164 old_id,
165 e
166 );
167 if let Some(m) = self.active_window_mut().buffer_metadata.get_mut(&old_id) {
168 m.is_preview = false;
169 }
170 }
171 }
172 Some((_other_split, old_id)) => {
173 // Different split: user walked away from the old preview
174 // before this click. Promote it to permanent — their focus
175 // moving to another split was the commitment signal.
176 if let Some(m) = self.active_window_mut().buffer_metadata.get_mut(&old_id) {
177 m.is_preview = false;
178 }
179 }
180 None => {}
181 }
182
183 // Mark the new buffer as the preview, anchored to its split.
184 if let Some(meta) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
185 meta.is_preview = true;
186 }
187 self.active_window_mut().preview = Some((target_split, buffer_id));
188
189 Ok(buffer_id)
190 }
191
192 // `promote_buffer_from_preview`, `promote_active_buffer_from_preview`,
193 // `promote_current_preview`, `promote_preview_if_not_in_split`,
194 // `is_buffer_preview`, `current_preview` moved to `impl Window`
195 // (in `window.rs`). Editor callers reach them via
196 // `self.active_window_mut().X(...)`.
197
198 /// Re-point every buffer whose file path sits at or under `old_root`
199 /// to the equivalent location under `new_root`. Returns the ids of
200 /// the buffers that were actually relocated.
201 ///
202 /// Handles three shapes of path change uniformly:
203 ///
204 /// - Single-file rename: `old_root = /a/foo.txt`, `new_root = /a/bar.txt`
205 /// → the buffer for foo.txt re-points to bar.txt.
206 /// - Directory rename: `old_root = /a/dir`, `new_root = /a/renamed`
207 /// → every buffer for a file inside `dir` (e.g. `/a/dir/x.txt`)
208 /// re-points under `/a/renamed` (`/a/renamed/x.txt`).
209 /// - Cut+paste move: `old_root = /a/foo.txt`, `new_root = /b/foo.txt`
210 /// → the buffer for the moved file re-points to its new home.
211 ///
212 /// For each affected buffer we update the persistence path on the
213 /// Buffer itself, rebuild the `BufferMetadata::kind` (new path + new
214 /// LSP URI), and recompute the display name. Without this, a save
215 /// on the buffer would write to the old (now gone or stale) path
216 /// and silently resurrect / duplicate the file.
217 pub(crate) fn relocate_buffers_for_rename(
218 &mut self,
219 old_root: &std::path::Path,
220 new_root: &std::path::Path,
221 ) -> Vec<BufferId> {
222 let affected = self.buffer_ids_under_path(old_root);
223 for &id in &affected {
224 let Some(state) = self
225 .windows
226 .get(&self.active_window)
227 .map(|w| &w.buffers)
228 .expect("active window present")
229 .get(&id)
230 else {
231 continue;
232 };
233 let Some(current) = state.buffer.file_path().map(|p| p.to_path_buf()) else {
234 continue;
235 };
236 // For buffers equal to old_root, the new path is simply
237 // new_root. For buffers under old_root (directory case),
238 // strip the old prefix and re-root under new_root.
239 let new_path = if current == old_root {
240 new_root.to_path_buf()
241 } else if let Ok(relative) = current.strip_prefix(old_root) {
242 new_root.join(relative)
243 } else {
244 // Defensive: buffer_ids_under_path already filtered, so
245 // this shouldn't happen. Skip rather than corrupt state.
246 continue;
247 };
248
249 if let Some(state) = self
250 .windows
251 .get_mut(&self.active_window)
252 .map(|w| &mut w.buffers)
253 .expect("active window present")
254 .get_mut(&id)
255 {
256 state.buffer.rename_file_path(new_path.clone());
257 }
258 let file_uri = super::types::LspUri::from_host_path(
259 &new_path,
260 self.authority().path_translation.as_ref(),
261 );
262 let display_name =
263 super::BufferMetadata::display_name_for_path(&new_path, self.working_dir());
264 if let Some(metadata) = self.active_window_mut().buffer_metadata.get_mut(&id) {
265 metadata.kind = super::BufferKind::File {
266 path: new_path.clone(),
267 uri: file_uri,
268 };
269 metadata.display_name = display_name;
270 }
271 }
272 affected
273 }
274
275 // `promote_current_preview`, `promote_preview_if_not_in_split`,
276 // `is_buffer_preview`, `current_preview` moved to `impl Window`.
277
278 /// Number of open buffers (including hidden/virtual buffers).
279 /// Intended for tests that verify preview tabs don't accumulate.
280 pub fn open_buffer_count(&self) -> usize {
281 self.active_window().buffers.len()
282 }
283
284 /// Navigate to a specific line and column in the active buffer.
285 ///
286 /// Line and column are 1-indexed (matching typical editor conventions).
287 /// If the line is out of bounds, navigates to the last line.
288 /// If the column is out of bounds, navigates to the end of the line.
289 pub fn goto_line_col(&mut self, line: usize, column: Option<usize>) {
290 if line == 0 {
291 return; // Line numbers are 1-indexed
292 }
293
294 let buffer_id = self.active_buffer();
295
296 // Read cursor state from split view state
297 let cursors = self.active_cursors();
298 let old_cursor = cursors.primary().clone();
299 let cursor_id = cursors.primary_id();
300 let old_position = cursors.primary().position;
301 let old_anchor = cursors.primary().anchor;
302 let old_sticky_column = cursors.primary().sticky_column;
303
304 if let Some(state) = self
305 .windows
306 .get(&self.active_window)
307 .map(|w| &w.buffers)
308 .expect("active window present")
309 .get(&buffer_id)
310 {
311 let has_line_index = state.buffer.line_count().is_some();
312 let has_line_scan = state.buffer.has_line_feed_scan();
313 let buffer_len = state.buffer.len();
314
315 // Convert 1-indexed line to 0-indexed
316 let target_line = line.saturating_sub(1);
317 // Column is also 1-indexed, convert to 0-indexed
318 let target_col = column.map(|c| c.saturating_sub(1)).unwrap_or(0);
319
320 // Track the known exact line number for scanned large files,
321 // since offset_to_position may not be able to reverse-resolve it accurately.
322 let mut known_line: Option<usize> = None;
323
324 let position = if has_line_scan && has_line_index {
325 // Scanned large file: use tree metadata to find exact line offset
326 let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
327 let actual_line = target_line.min(max_line);
328 known_line = Some(actual_line);
329 // Need mutable access to potentially read chunk data from disk
330 if let Some(state) = self
331 .windows
332 .get_mut(&self.active_window)
333 .map(|w| &mut w.buffers)
334 .expect("active window present")
335 .get_mut(&buffer_id)
336 {
337 state
338 .buffer
339 .resolve_line_byte_offset(actual_line)
340 .map(|offset| (offset + target_col).min(buffer_len))
341 .unwrap_or(0)
342 } else {
343 0
344 }
345 } else {
346 // Small file with full line starts or no line index:
347 // use exact line position
348 let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
349 let actual_line = target_line.min(max_line);
350 state.buffer.line_col_to_position(actual_line, target_col)
351 };
352
353 // Preserve anchor if deselect_on_move is false (Emacs mark mode)
354 let new_anchor = if old_cursor.deselect_on_move {
355 None
356 } else {
357 old_cursor.anchor
358 };
359
360 let event = Event::MoveCursor {
361 cursor_id,
362 old_position,
363 new_position: position,
364 old_anchor,
365 new_anchor,
366 old_sticky_column,
367 new_sticky_column: target_col,
368 };
369
370 let split_id = self
371 .windows
372 .get(&self.active_window)
373 .and_then(|w| w.buffers.splits())
374 .map(|(mgr, _)| mgr)
375 .expect("active window must have a populated split layout")
376 .active_split();
377 self.active_window_mut()
378 .apply_event_to_buffer(buffer_id, split_id, &event);
379
380 // For scanned large files, override the line number with the known exact value
381 // since offset_to_position may fall back to proportional estimation.
382 if let Some(line) = known_line {
383 if let Some(state) = self.active_window_mut().buffers.get_mut(&buffer_id) {
384 state.primary_cursor_line_number =
385 crate::model::buffer::LineNumber::Absolute(line);
386 }
387 }
388
389 // Center the target line in the viewport. The default
390 // `ensure_visible` behavior only scrolls just enough to reveal
391 // the cursor, which pins a forward jump to the bottom row — and
392 // for live-preview jumps (Quick Open `:N`, Goto Line prompt) the
393 // suggestion/prompt popup overlays the bottom of the screen,
394 // obscuring the very line the user is navigating to. Recentering
395 // puts the target in the middle so it stays visible.
396 self.apply_event_to_active_buffer(&Event::Recenter);
397 }
398 }
399
400 /// Select a range in the active buffer. Lines/columns are 1-indexed.
401 /// The cursor moves to the end of the range and the anchor is set to the
402 /// start, producing a visual selection.
403 pub fn select_range(
404 &mut self,
405 start_line: usize,
406 start_col: Option<usize>,
407 end_line: usize,
408 end_col: Option<usize>,
409 ) {
410 if start_line == 0 || end_line == 0 {
411 return;
412 }
413
414 let buffer_id = self.active_buffer();
415
416 let cursors = self.active_cursors();
417 let cursor_id = cursors.primary_id();
418 let old_position = cursors.primary().position;
419 let old_anchor = cursors.primary().anchor;
420 let old_sticky_column = cursors.primary().sticky_column;
421
422 if let Some(state) = self
423 .windows
424 .get(&self.active_window)
425 .map(|w| &w.buffers)
426 .expect("active window present")
427 .get(&buffer_id)
428 {
429 let buffer_len = state.buffer.len();
430
431 // Convert 1-indexed to 0-indexed
432 let start_line_0 = start_line.saturating_sub(1);
433 let start_col_0 = start_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
434 let end_line_0 = end_line.saturating_sub(1);
435 let end_col_0 = end_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
436
437 let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
438
439 let start_pos = state
440 .buffer
441 .line_col_to_position(start_line_0.min(max_line), start_col_0)
442 .min(buffer_len);
443 let end_pos = state
444 .buffer
445 .line_col_to_position(end_line_0.min(max_line), end_col_0)
446 .min(buffer_len);
447
448 let event = Event::MoveCursor {
449 cursor_id,
450 old_position,
451 new_position: end_pos,
452 old_anchor,
453 new_anchor: Some(start_pos),
454 old_sticky_column,
455 new_sticky_column: end_col_0,
456 };
457
458 let split_id = self
459 .windows
460 .get(&self.active_window)
461 .and_then(|w| w.buffers.splits())
462 .map(|(mgr, _)| mgr)
463 .expect("active window must have a populated split layout")
464 .active_split();
465 self.active_window_mut()
466 .apply_event_to_buffer(buffer_id, split_id, &event);
467 }
468 }
469
470 /// Go to an exact byte offset in the buffer (used in byte-offset mode for large files)
471 pub fn goto_byte_offset(&mut self, offset: usize) {
472 let buffer_id = self.active_buffer();
473
474 let cursors = self.active_cursors();
475 let cursor_id = cursors.primary_id();
476 let old_position = cursors.primary().position;
477 let old_anchor = cursors.primary().anchor;
478 let old_sticky_column = cursors.primary().sticky_column;
479
480 if let Some(state) = self
481 .windows
482 .get(&self.active_window)
483 .map(|w| &w.buffers)
484 .expect("active window present")
485 .get(&buffer_id)
486 {
487 let buffer_len = state.buffer.len();
488 let position = offset.min(buffer_len);
489
490 let event = Event::MoveCursor {
491 cursor_id,
492 old_position,
493 new_position: position,
494 old_anchor,
495 new_anchor: None,
496 old_sticky_column,
497 new_sticky_column: 0,
498 };
499
500 let split_id = self
501 .windows
502 .get(&self.active_window)
503 .and_then(|w| w.buffers.splits())
504 .map(|(mgr, _)| mgr)
505 .expect("active window must have a populated split layout")
506 .active_split();
507 self.active_window_mut()
508 .apply_event_to_buffer(buffer_id, split_id, &event);
509 }
510 }
511
512 /// Create a new empty buffer
513 pub fn new_buffer(&mut self) -> BufferId {
514 // Save current position before switching to new buffer
515 self.active_window_mut()
516 .position_history
517 .commit_pending_movement();
518
519 // Explicitly record current position before switching
520 let cursors = self.active_cursors();
521 let position = cursors.primary().position;
522 let anchor = cursors.primary().anchor;
523 let active_buffer_id = self.active_buffer();
524 let ph = &mut self.active_window_mut().position_history;
525 ph.record_movement(active_buffer_id, position, anchor);
526 ph.commit_pending_movement();
527
528 let buffer_id = self.alloc_buffer_id();
529
530 let mut state = EditorState::new(
531 self.terminal_width,
532 self.terminal_height,
533 self.config.editor.large_file_threshold_bytes as usize,
534 Arc::clone(&self.authority().filesystem),
535 );
536 // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
537 state
538 .margins
539 .configure_for_line_numbers(self.config.editor.line_numbers);
540 // Set default line ending for new buffers from config
541 state
542 .buffer
543 .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
544 state.reference_highlight_overlay.enabled = self.config.editor.highlight_occurrences;
545 self.windows
546 .get_mut(&self.active_window)
547 .map(|w| &mut w.buffers)
548 .expect("active window present")
549 .insert(buffer_id, state);
550 self.active_window_mut()
551 .event_logs
552 .insert(buffer_id, crate::model::event::EventLog::new());
553 self.active_window_mut()
554 .buffer_metadata
555 .insert(buffer_id, crate::app::types::BufferMetadata::new());
556
557 self.set_active_buffer(buffer_id);
558
559 // Initialize per-buffer view state with config defaults.
560 // Must happen AFTER set_active_buffer, because switch_buffer creates
561 // the new BufferViewState with defaults (show_line_numbers=true).
562 let active_split = self
563 .windows
564 .get(&self.active_window)
565 .and_then(|w| w.buffers.splits())
566 .map(|(mgr, _)| mgr)
567 .expect("active window must have a populated split layout")
568 .active_split();
569 let line_wrap = self.active_window().resolve_line_wrap_for_buffer(buffer_id);
570 let wrap_column = self
571 .active_window()
572 .resolve_wrap_column_for_buffer(buffer_id);
573 if let Some(view_state) = self
574 .windows
575 .get_mut(&self.active_window)
576 .and_then(|w| w.split_view_states_mut())
577 .expect("active window must have a populated split layout")
578 .get_mut(&active_split)
579 {
580 view_state.apply_config_defaults(
581 self.config.editor.line_numbers,
582 self.config.editor.highlight_current_line,
583 line_wrap,
584 self.config.editor.wrap_indent,
585 wrap_column,
586 self.config.editor.rulers.clone(),
587 self.config.editor.scroll_offset,
588 );
589 }
590
591 self.active_window_mut().status_message = Some(t!("buffer.new").to_string());
592
593 buffer_id
594 }
595
596 /// Get the current mouse hover state for testing
597 /// Returns Some((byte_position, screen_x, screen_y)) if hovering over text
598 pub fn get_mouse_hover_state(&self) -> Option<(usize, u16, u16)> {
599 self.active_window()
600 .mouse_state
601 .lsp_hover_state
602 .map(|(pos, _, x, y)| (pos, x, y))
603 }
604
605 /// Check if a transient popup (hover/signature help) is currently visible
606 pub fn has_transient_popup(&self) -> bool {
607 self.active_state()
608 .popups
609 .top()
610 .is_some_and(|p| p.transient)
611 }
612
613 /// Force check the mouse hover timer (for testing)
614 /// This bypasses the normal 500ms delay
615 pub fn force_check_mouse_hover(&mut self) -> bool {
616 if let Some((byte_pos, _, screen_x, screen_y)) =
617 self.active_window_mut().mouse_state.lsp_hover_state
618 {
619 if !self.active_window_mut().mouse_state.lsp_hover_request_sent {
620 self.active_window_mut()
621 .hover
622 .set_screen_position((screen_x, screen_y));
623 match self.request_hover_at_position(byte_pos) {
624 Ok(true) => {
625 self.active_window_mut().mouse_state.lsp_hover_request_sent = true;
626 return true;
627 }
628 Ok(false) => return false, // no server ready, retry later
629 Err(e) => {
630 tracing::debug!("Failed to request hover: {}", e);
631 return false;
632 }
633 }
634 }
635 }
636 false
637 }
638}