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