Skip to main content

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        //
140        // `OpenKind::Preview` additionally tells the open path that this is a
141        // browse, not a deliberate open, so the `after_file_open` plugin hook
142        // is deferred — otherwise plugins raise intrusive UI (e.g. the
143        // asm-lsp `.asm-lsp.toml` config-offer popup) over each file the user
144        // arrows past. The hook fires later if/when this preview is escalated
145        // to a permanent tab.
146        self.active_window_mut().suppress_position_history_once = true;
147        let open_result =
148            self.open_file_with_kind(path, super::file_open_orchestrators::OpenKind::Preview);
149        self.active_window_mut().suppress_position_history_once = false;
150        let buffer_id = open_result?;
151        let is_new = !previously_file_backed.contains(&buffer_id);
152
153        // Already-open buffer: leave preview state untouched. A previously-
154        // committed tab must not be demoted back to preview, and the existing
155        // preview (if any, in whichever split) is still valid.
156        if !is_new {
157            return Ok(buffer_id);
158        }
159
160        // New buffer. Resolve the existing preview (if any) relative to the
161        // target split. `preview.take()` clears the single source of truth;
162        // each arm below decides whether the displaced buffer was *committed*
163        // (escalated → fire its deferred `after_file_open`) or merely
164        // *discarded* (closed → no hook).
165        match self.active_window_mut().preview.take() {
166            Some((prev_split, old_id)) if prev_split == target_split => {
167                // Same split: close the old preview so the new one takes its
168                // place. Replacement is not a commitment, so no hook. If close
169                // fails (modified buffer — shouldn't happen because edits
170                // promote, but defend in depth), the buffer stays open as a
171                // permanent tab, which *is* a commitment → fire its deferred
172                // hook.
173                if let Err(e) = self.close_buffer(old_id) {
174                    tracing::warn!(
175                        "preview: could not replace stale preview buffer {:?}, demoting to permanent: {}",
176                        old_id,
177                        e
178                    );
179                    self.active_window().fire_deferred_after_file_open(old_id);
180                }
181            }
182            Some((_other_split, old_id)) => {
183                // Different split: user walked away from the old preview
184                // before this click. Their focus moving to another split was
185                // the commitment signal → escalate it and fire the hook.
186                self.active_window().fire_deferred_after_file_open(old_id);
187            }
188            None => {}
189        }
190
191        // Anchor the new buffer as the preview — the single source of truth.
192        self.active_window_mut().preview = Some((target_split, buffer_id));
193
194        Ok(buffer_id)
195    }
196
197    // `promote_buffer_from_preview`, `promote_active_buffer_from_preview`,
198    // `promote_current_preview`, `promote_preview_if_not_in_split`,
199    // `is_buffer_preview`, `current_preview` moved to `impl Window`
200    // (in `window.rs`). Editor callers reach them via
201    // `self.active_window_mut().X(...)`.
202
203    /// Re-point every buffer whose file path sits at or under `old_root`
204    /// to the equivalent location under `new_root`. Returns the ids of
205    /// the buffers that were actually relocated.
206    ///
207    /// Handles three shapes of path change uniformly:
208    ///
209    /// - Single-file rename: `old_root = /a/foo.txt`, `new_root = /a/bar.txt`
210    ///   → the buffer for foo.txt re-points to bar.txt.
211    /// - Directory rename: `old_root = /a/dir`, `new_root = /a/renamed`
212    ///   → every buffer for a file inside `dir` (e.g. `/a/dir/x.txt`)
213    ///   re-points under `/a/renamed` (`/a/renamed/x.txt`).
214    /// - Cut+paste move: `old_root = /a/foo.txt`, `new_root = /b/foo.txt`
215    ///   → the buffer for the moved file re-points to its new home.
216    ///
217    /// For each affected buffer we update the persistence path on the
218    /// Buffer itself, rebuild the `BufferMetadata::kind` (new path + new
219    /// LSP URI), and recompute the display name. Without this, a save
220    /// on the buffer would write to the old (now gone or stale) path
221    /// and silently resurrect / duplicate the file.
222    pub(crate) fn relocate_buffers_for_rename(
223        &mut self,
224        old_root: &std::path::Path,
225        new_root: &std::path::Path,
226    ) -> Vec<BufferId> {
227        let affected = self.buffer_ids_under_path(old_root);
228        for &id in &affected {
229            let Some(state) = self
230                .windows
231                .get(&self.active_window)
232                .map(|w| &w.buffers)
233                .expect("active window present")
234                .get(&id)
235            else {
236                continue;
237            };
238            let Some(current) = state.buffer.file_path().map(|p| p.to_path_buf()) else {
239                continue;
240            };
241            // For buffers equal to old_root, the new path is simply
242            // new_root. For buffers under old_root (directory case),
243            // strip the old prefix and re-root under new_root.
244            let new_path = if current == old_root {
245                new_root.to_path_buf()
246            } else if let Ok(relative) = current.strip_prefix(old_root) {
247                new_root.join(relative)
248            } else {
249                // Defensive: buffer_ids_under_path already filtered, so
250                // this shouldn't happen. Skip rather than corrupt state.
251                continue;
252            };
253
254            if let Some(state) = self
255                .windows
256                .get_mut(&self.active_window)
257                .map(|w| &mut w.buffers)
258                .expect("active window present")
259                .get_mut(&id)
260            {
261                state.buffer.rename_file_path(new_path.clone());
262            }
263            let file_uri = super::types::LspUri::from_host_path(
264                &new_path,
265                self.authority().path_translation.as_ref(),
266            );
267            let display_name =
268                super::BufferMetadata::display_name_for_path(&new_path, self.working_dir());
269            if let Some(metadata) = self.active_window_mut().buffer_metadata.get_mut(&id) {
270                metadata.kind = super::BufferKind::File {
271                    path: new_path.clone(),
272                    uri: file_uri,
273                };
274                metadata.display_name = display_name;
275            }
276        }
277        affected
278    }
279
280    // `promote_current_preview`, `promote_preview_if_not_in_split`,
281    // `is_buffer_preview`, `current_preview` moved to `impl Window`.
282
283    /// Number of open buffers (including hidden/virtual buffers).
284    /// Intended for tests that verify preview tabs don't accumulate.
285    pub fn open_buffer_count(&self) -> usize {
286        self.active_window().buffers.len()
287    }
288
289    /// Navigate to a specific line and column in the active buffer.
290    ///
291    /// Line and column are 1-indexed (matching typical editor conventions).
292    /// If the line is out of bounds, navigates to the last line.
293    /// If the column is out of bounds, navigates to the end of the line.
294    pub fn goto_line_col(&mut self, line: usize, column: Option<usize>) {
295        if line == 0 {
296            return; // Line numbers are 1-indexed
297        }
298
299        let buffer_id = self.active_buffer();
300
301        // Read cursor state from split view state
302        let cursors = self.active_cursors();
303        let old_cursor = cursors.primary().clone();
304        let cursor_id = cursors.primary_id();
305        let old_position = cursors.primary().position;
306        let old_anchor = cursors.primary().anchor;
307        let old_sticky_column = cursors.primary().sticky_column;
308
309        if let Some(state) = self
310            .windows
311            .get(&self.active_window)
312            .map(|w| &w.buffers)
313            .expect("active window present")
314            .get(&buffer_id)
315        {
316            let has_line_index = state.buffer.line_count().is_some();
317            let has_line_scan = state.buffer.has_line_feed_scan();
318            let buffer_len = state.buffer.len();
319
320            // Convert 1-indexed line to 0-indexed
321            let target_line = line.saturating_sub(1);
322            // Column is also 1-indexed, convert to 0-indexed
323            let target_col = column.map(|c| c.saturating_sub(1)).unwrap_or(0);
324
325            // Track the known exact line number for scanned large files,
326            // since offset_to_position may not be able to reverse-resolve it accurately.
327            let mut known_line: Option<usize> = None;
328
329            let position = if has_line_scan && has_line_index {
330                // Scanned large file: use tree metadata to find exact line offset
331                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
332                let actual_line = target_line.min(max_line);
333                known_line = Some(actual_line);
334                // Need mutable access to potentially read chunk data from disk
335                if let Some(state) = self
336                    .windows
337                    .get_mut(&self.active_window)
338                    .map(|w| &mut w.buffers)
339                    .expect("active window present")
340                    .get_mut(&buffer_id)
341                {
342                    state
343                        .buffer
344                        .resolve_line_byte_offset(actual_line)
345                        .map(|offset| (offset + target_col).min(buffer_len))
346                        .unwrap_or(0)
347                } else {
348                    0
349                }
350            } else {
351                // Small file with full line starts or no line index:
352                // use exact line position
353                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
354                let actual_line = target_line.min(max_line);
355                state.buffer.line_col_to_position(actual_line, target_col)
356            };
357
358            // Preserve anchor if deselect_on_move is false (Emacs mark mode)
359            let new_anchor = if old_cursor.deselect_on_move {
360                None
361            } else {
362                old_cursor.anchor
363            };
364
365            let event = Event::MoveCursor {
366                cursor_id,
367                old_position,
368                new_position: position,
369                old_anchor,
370                new_anchor,
371                old_sticky_column,
372                new_sticky_column: target_col,
373            };
374
375            let split_id = self
376                .windows
377                .get(&self.active_window)
378                .and_then(|w| w.buffers.splits())
379                .map(|(mgr, _)| mgr)
380                .expect("active window must have a populated split layout")
381                .active_split();
382            self.active_window_mut()
383                .apply_event_to_buffer(buffer_id, split_id, &event);
384
385            // For scanned large files, override the line number with the known exact value
386            // since offset_to_position may fall back to proportional estimation.
387            if let Some(line) = known_line {
388                if let Some(state) = self.active_window_mut().buffers.get_mut(&buffer_id) {
389                    state.primary_cursor_line_number =
390                        crate::model::buffer::LineNumber::Absolute(line);
391                }
392            }
393
394            // Center the target line in the viewport. The default
395            // `ensure_visible` behavior only scrolls just enough to reveal
396            // the cursor, which pins a forward jump to the bottom row — and
397            // for live-preview jumps (Quick Open `:N`, Goto Line prompt) the
398            // suggestion/prompt popup overlays the bottom of the screen,
399            // obscuring the very line the user is navigating to. Recentering
400            // puts the target in the middle so it stays visible.
401            self.apply_event_to_active_buffer(&Event::Recenter);
402        }
403    }
404
405    /// Select a range in the active buffer. Lines/columns are 1-indexed.
406    /// The cursor moves to the end of the range and the anchor is set to the
407    /// start, producing a visual selection.
408    pub fn select_range(
409        &mut self,
410        start_line: usize,
411        start_col: Option<usize>,
412        end_line: usize,
413        end_col: Option<usize>,
414    ) {
415        if start_line == 0 || end_line == 0 {
416            return;
417        }
418
419        let buffer_id = self.active_buffer();
420
421        let cursors = self.active_cursors();
422        let cursor_id = cursors.primary_id();
423        let old_position = cursors.primary().position;
424        let old_anchor = cursors.primary().anchor;
425        let old_sticky_column = cursors.primary().sticky_column;
426
427        if let Some(state) = self
428            .windows
429            .get(&self.active_window)
430            .map(|w| &w.buffers)
431            .expect("active window present")
432            .get(&buffer_id)
433        {
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
464                .windows
465                .get(&self.active_window)
466                .and_then(|w| w.buffers.splits())
467                .map(|(mgr, _)| mgr)
468                .expect("active window must have a populated split layout")
469                .active_split();
470            self.active_window_mut()
471                .apply_event_to_buffer(buffer_id, split_id, &event);
472        }
473    }
474
475    /// Go to an exact byte offset in the buffer (used in byte-offset mode for large files)
476    pub fn goto_byte_offset(&mut self, offset: usize) {
477        let buffer_id = self.active_buffer();
478
479        let cursors = self.active_cursors();
480        let cursor_id = cursors.primary_id();
481        let old_position = cursors.primary().position;
482        let old_anchor = cursors.primary().anchor;
483        let old_sticky_column = cursors.primary().sticky_column;
484
485        if let Some(state) = self
486            .windows
487            .get(&self.active_window)
488            .map(|w| &w.buffers)
489            .expect("active window present")
490            .get(&buffer_id)
491        {
492            let buffer_len = state.buffer.len();
493            let position = offset.min(buffer_len);
494
495            let event = Event::MoveCursor {
496                cursor_id,
497                old_position,
498                new_position: position,
499                old_anchor,
500                new_anchor: None,
501                old_sticky_column,
502                new_sticky_column: 0,
503            };
504
505            let split_id = self
506                .windows
507                .get(&self.active_window)
508                .and_then(|w| w.buffers.splits())
509                .map(|(mgr, _)| mgr)
510                .expect("active window must have a populated split layout")
511                .active_split();
512            self.active_window_mut()
513                .apply_event_to_buffer(buffer_id, split_id, &event);
514        }
515    }
516
517    /// Create a new empty buffer
518    pub fn new_buffer(&mut self) -> BufferId {
519        // Save current position before switching to new buffer
520        self.active_window_mut()
521            .position_history
522            .commit_pending_movement();
523
524        // Explicitly record current position before switching
525        let cursors = self.active_cursors();
526        let position = cursors.primary().position;
527        let anchor = cursors.primary().anchor;
528        let active_buffer_id = self.active_buffer();
529        let ph = &mut self.active_window_mut().position_history;
530        ph.record_movement(active_buffer_id, position, anchor);
531        ph.commit_pending_movement();
532
533        let buffer_id = self.alloc_buffer_id();
534
535        let mut state = EditorState::new(
536            self.terminal_width,
537            self.terminal_height,
538            self.config.editor.large_file_threshold_bytes as usize,
539            Arc::clone(&self.authority().filesystem),
540        );
541        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
542        state
543            .margins
544            .configure_for_line_numbers(self.config.editor.line_numbers);
545        // Set default line ending for new buffers from config
546        state
547            .buffer
548            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
549        state.reference_highlight_overlay.enabled = self.config.editor.highlight_occurrences;
550        self.windows
551            .get_mut(&self.active_window)
552            .map(|w| &mut w.buffers)
553            .expect("active window present")
554            .insert(buffer_id, state);
555        self.active_window_mut()
556            .event_logs
557            .insert(buffer_id, crate::model::event::EventLog::new());
558        self.active_window_mut()
559            .buffer_metadata
560            .insert(buffer_id, crate::app::types::BufferMetadata::new());
561
562        self.set_active_buffer(buffer_id);
563
564        // Initialize per-buffer view state with config defaults.
565        // Must happen AFTER set_active_buffer, because switch_buffer creates
566        // the new BufferViewState with defaults (show_line_numbers=true).
567        let active_split = self
568            .windows
569            .get(&self.active_window)
570            .and_then(|w| w.buffers.splits())
571            .map(|(mgr, _)| mgr)
572            .expect("active window must have a populated split layout")
573            .active_split();
574        let line_wrap = self.active_window().resolve_line_wrap_for_buffer(buffer_id);
575        let wrap_column = self
576            .active_window()
577            .resolve_wrap_column_for_buffer(buffer_id);
578        if let Some(view_state) = self
579            .windows
580            .get_mut(&self.active_window)
581            .and_then(|w| w.split_view_states_mut())
582            .expect("active window must have a populated split layout")
583            .get_mut(&active_split)
584        {
585            view_state.apply_config_defaults(
586                self.config.editor.line_numbers,
587                self.config.editor.highlight_current_line,
588                line_wrap,
589                self.config.editor.wrap_indent,
590                wrap_column,
591                self.config.editor.rulers.clone(),
592                self.config.editor.scroll_offset,
593            );
594        }
595
596        self.active_window_mut().status_message = Some(t!("buffer.new").to_string());
597
598        buffer_id
599    }
600
601    /// Get the current mouse hover state for testing
602    /// Returns Some((byte_position, screen_x, screen_y)) if hovering over text
603    pub fn get_mouse_hover_state(&self) -> Option<(usize, u16, u16)> {
604        self.active_window()
605            .mouse_state
606            .lsp_hover_state
607            .map(|(pos, _, x, y)| (pos, x, y))
608    }
609
610    /// Check if a transient popup (hover/signature help) is currently visible
611    pub fn has_transient_popup(&self) -> bool {
612        self.active_state()
613            .popups
614            .top()
615            .is_some_and(|p| p.transient)
616    }
617
618    /// Force check the mouse hover timer (for testing)
619    /// This bypasses the normal 500ms delay
620    pub fn force_check_mouse_hover(&mut self) -> bool {
621        if let Some((byte_pos, _, screen_x, screen_y)) =
622            self.active_window_mut().mouse_state.lsp_hover_state
623        {
624            if !self.active_window_mut().mouse_state.lsp_hover_request_sent {
625                self.active_window_mut()
626                    .hover
627                    .set_screen_position((screen_x, screen_y));
628                match self.request_hover_at_position(byte_pos) {
629                    Ok(true) => {
630                        self.active_window_mut().mouse_state.lsp_hover_request_sent = true;
631                        return true;
632                    }
633                    Ok(false) => return false, // no server ready, retry later
634                    Err(e) => {
635                        tracing::debug!("Failed to request hover: {}", e);
636                        return false;
637                    }
638                }
639            }
640        }
641        false
642    }
643}