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 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 cursor_id = cursors.primary_id();
330        let old_position = cursors.primary().position;
331        let old_anchor = cursors.primary().anchor;
332        let old_sticky_column = cursors.primary().sticky_column;
333
334        if let Some(state) = self.buffers.get(&buffer_id) {
335            let has_line_index = state.buffer.line_count().is_some();
336            let has_line_scan = state.buffer.has_line_feed_scan();
337            let buffer_len = state.buffer.len();
338
339            // Convert 1-indexed line to 0-indexed
340            let target_line = line.saturating_sub(1);
341            // Column is also 1-indexed, convert to 0-indexed
342            let target_col = column.map(|c| c.saturating_sub(1)).unwrap_or(0);
343
344            // Track the known exact line number for scanned large files,
345            // since offset_to_position may not be able to reverse-resolve it accurately.
346            let mut known_line: Option<usize> = None;
347
348            let position = if has_line_scan && has_line_index {
349                // Scanned large file: use tree metadata to find exact line offset
350                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
351                let actual_line = target_line.min(max_line);
352                known_line = Some(actual_line);
353                // Need mutable access to potentially read chunk data from disk
354                if let Some(state) = self.buffers.get_mut(&buffer_id) {
355                    state
356                        .buffer
357                        .resolve_line_byte_offset(actual_line)
358                        .map(|offset| (offset + target_col).min(buffer_len))
359                        .unwrap_or(0)
360                } else {
361                    0
362                }
363            } else {
364                // Small file with full line starts or no line index:
365                // use exact line position
366                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
367                let actual_line = target_line.min(max_line);
368                state.buffer.line_col_to_position(actual_line, target_col)
369            };
370
371            let event = Event::MoveCursor {
372                cursor_id,
373                old_position,
374                new_position: position,
375                old_anchor,
376                new_anchor: None,
377                old_sticky_column,
378                new_sticky_column: target_col,
379            };
380
381            let split_id = self.split_manager.active_split();
382            let state = self.buffers.get_mut(&buffer_id).unwrap();
383            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
384            state.apply(&mut view_state.cursors, &event);
385
386            // For scanned large files, override the line number with the known exact value
387            // since offset_to_position may fall back to proportional estimation.
388            if let Some(line) = known_line {
389                state.primary_cursor_line_number = crate::model::buffer::LineNumber::Absolute(line);
390            }
391
392            // Center the target line in the viewport. The default
393            // `ensure_visible` behavior only scrolls just enough to reveal
394            // the cursor, which pins a forward jump to the bottom row — and
395            // for live-preview jumps (Quick Open `:N`, Goto Line prompt) the
396            // suggestion/prompt popup overlays the bottom of the screen,
397            // obscuring the very line the user is navigating to. Recentering
398            // puts the target in the middle so it stays visible.
399            self.apply_event_to_active_buffer(&Event::Recenter);
400        }
401    }
402
403    /// Select a range in the active buffer. Lines/columns are 1-indexed.
404    /// The cursor moves to the end of the range and the anchor is set to the
405    /// start, producing a visual selection.
406    pub fn select_range(
407        &mut self,
408        start_line: usize,
409        start_col: Option<usize>,
410        end_line: usize,
411        end_col: Option<usize>,
412    ) {
413        if start_line == 0 || end_line == 0 {
414            return;
415        }
416
417        let buffer_id = self.active_buffer();
418
419        let cursors = self.active_cursors();
420        let cursor_id = cursors.primary_id();
421        let old_position = cursors.primary().position;
422        let old_anchor = cursors.primary().anchor;
423        let old_sticky_column = cursors.primary().sticky_column;
424
425        if let Some(state) = self.buffers.get(&buffer_id) {
426            let buffer_len = state.buffer.len();
427
428            // Convert 1-indexed to 0-indexed
429            let start_line_0 = start_line.saturating_sub(1);
430            let start_col_0 = start_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
431            let end_line_0 = end_line.saturating_sub(1);
432            let end_col_0 = end_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
433
434            let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
435
436            let start_pos = state
437                .buffer
438                .line_col_to_position(start_line_0.min(max_line), start_col_0)
439                .min(buffer_len);
440            let end_pos = state
441                .buffer
442                .line_col_to_position(end_line_0.min(max_line), end_col_0)
443                .min(buffer_len);
444
445            let event = Event::MoveCursor {
446                cursor_id,
447                old_position,
448                new_position: end_pos,
449                old_anchor,
450                new_anchor: Some(start_pos),
451                old_sticky_column,
452                new_sticky_column: end_col_0,
453            };
454
455            let split_id = self.split_manager.active_split();
456            let state = self.buffers.get_mut(&buffer_id).unwrap();
457            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
458            state.apply(&mut view_state.cursors, &event);
459        }
460    }
461
462    /// Go to an exact byte offset in the buffer (used in byte-offset mode for large files)
463    pub fn goto_byte_offset(&mut self, offset: usize) {
464        let buffer_id = self.active_buffer();
465
466        let cursors = self.active_cursors();
467        let cursor_id = cursors.primary_id();
468        let old_position = cursors.primary().position;
469        let old_anchor = cursors.primary().anchor;
470        let old_sticky_column = cursors.primary().sticky_column;
471
472        if let Some(state) = self.buffers.get(&buffer_id) {
473            let buffer_len = state.buffer.len();
474            let position = offset.min(buffer_len);
475
476            let event = Event::MoveCursor {
477                cursor_id,
478                old_position,
479                new_position: position,
480                old_anchor,
481                new_anchor: None,
482                old_sticky_column,
483                new_sticky_column: 0,
484            };
485
486            let split_id = self.split_manager.active_split();
487            let state = self.buffers.get_mut(&buffer_id).unwrap();
488            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
489            state.apply(&mut view_state.cursors, &event);
490        }
491    }
492
493    /// Create a new empty buffer
494    pub fn new_buffer(&mut self) -> BufferId {
495        // Save current position before switching to new buffer
496        self.position_history.commit_pending_movement();
497
498        // Explicitly record current position before switching
499        let cursors = self.active_cursors();
500        let position = cursors.primary().position;
501        let anchor = cursors.primary().anchor;
502        self.position_history
503            .record_movement(self.active_buffer(), position, anchor);
504        self.position_history.commit_pending_movement();
505
506        let buffer_id = BufferId(self.next_buffer_id);
507        self.next_buffer_id += 1;
508
509        let mut state = EditorState::new(
510            self.terminal_width,
511            self.terminal_height,
512            self.config.editor.large_file_threshold_bytes as usize,
513            Arc::clone(&self.authority.filesystem),
514        );
515        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
516        state
517            .margins
518            .configure_for_line_numbers(self.config.editor.line_numbers);
519        // Set default line ending for new buffers from config
520        state
521            .buffer
522            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
523        self.buffers.insert(buffer_id, state);
524        self.event_logs
525            .insert(buffer_id, crate::model::event::EventLog::new());
526        self.buffer_metadata
527            .insert(buffer_id, crate::app::types::BufferMetadata::new());
528
529        self.set_active_buffer(buffer_id);
530
531        // Initialize per-buffer view state with config defaults.
532        // Must happen AFTER set_active_buffer, because switch_buffer creates
533        // the new BufferViewState with defaults (show_line_numbers=true).
534        let active_split = self.split_manager.active_split();
535        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
536        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
537        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
538            view_state.apply_config_defaults(
539                self.config.editor.line_numbers,
540                self.config.editor.highlight_current_line,
541                line_wrap,
542                self.config.editor.wrap_indent,
543                wrap_column,
544                self.config.editor.rulers.clone(),
545            );
546        }
547
548        self.status_message = Some(t!("buffer.new").to_string());
549
550        buffer_id
551    }
552
553    /// Get the current mouse hover state for testing
554    /// Returns Some((byte_position, screen_x, screen_y)) if hovering over text
555    pub fn get_mouse_hover_state(&self) -> Option<(usize, u16, u16)> {
556        self.mouse_state
557            .lsp_hover_state
558            .map(|(pos, _, x, y)| (pos, x, y))
559    }
560
561    /// Check if a transient popup (hover/signature help) is currently visible
562    pub fn has_transient_popup(&self) -> bool {
563        self.active_state()
564            .popups
565            .top()
566            .is_some_and(|p| p.transient)
567    }
568
569    /// Force check the mouse hover timer (for testing)
570    /// This bypasses the normal 500ms delay
571    pub fn force_check_mouse_hover(&mut self) -> bool {
572        if let Some((byte_pos, _, screen_x, screen_y)) = self.mouse_state.lsp_hover_state {
573            if !self.mouse_state.lsp_hover_request_sent {
574                self.hover.set_screen_position((screen_x, screen_y));
575                match self.request_hover_at_position(byte_pos) {
576                    Ok(true) => {
577                        self.mouse_state.lsp_hover_request_sent = true;
578                        return true;
579                    }
580                    Ok(false) => return false, // no server ready, retry later
581                    Err(e) => {
582                        tracing::debug!("Failed to request hover: {}", e);
583                        return false;
584                    }
585                }
586            }
587        }
588        false
589    }
590}