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 anyhow::Result as AnyhowResult;
12use rust_i18n::t;
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use crate::app::warning_domains::WarningDomain;
18use crate::model::event::{BufferId, Event, LeafId};
19use crate::state::EditorState;
20use crate::view::prompt::PromptType;
21use crate::view::split::SplitViewState;
22
23use super::buffer_config_resolve;
24use super::help;
25use super::Editor;
26
27impl Editor {
28    /// Resolve the effective line_wrap setting for a buffer, considering language overrides.
29    pub(super) fn resolve_line_wrap_for_buffer(&self, buffer_id: BufferId) -> bool {
30        match self.buffers.get(&buffer_id) {
31            Some(state) => buffer_config_resolve::line_wrap(&state.language, &self.config),
32            None => self.config.editor.line_wrap,
33        }
34    }
35
36    /// Resolve page view settings for a buffer from its language config.
37    pub(super) fn resolve_page_view_for_buffer(
38        &self,
39        buffer_id: BufferId,
40    ) -> Option<Option<usize>> {
41        let state = self.buffers.get(&buffer_id)?;
42        buffer_config_resolve::page_view(&state.language, &self.config)
43    }
44
45    /// Resolve the effective wrap_column for a buffer, considering language overrides.
46    pub(super) fn resolve_wrap_column_for_buffer(&self, buffer_id: BufferId) -> Option<usize> {
47        match self.buffers.get(&buffer_id) {
48            Some(state) => buffer_config_resolve::wrap_column(&state.language, &self.config),
49            None => self.config.editor.wrap_column,
50        }
51    }
52
53    /// Get the preferred split for opening a file.
54    /// If the active split has no label, use it (normal case).
55    /// Otherwise find an unlabeled leaf so files don't open in labeled splits (e.g., sidebars).
56    pub(super) fn preferred_split_for_file(&self) -> LeafId {
57        let active = self.split_manager.active_split();
58        if self.split_manager.get_label(active.into()).is_none() {
59            return active;
60        }
61        self.split_manager.find_unlabeled_leaf().unwrap_or(active)
62    }
63
64    /// Open a file in "preview" (ephemeral) mode and return its buffer ID.
65    ///
66    /// Used for exploratory single-click opens from the file explorer. If the
67    /// `file_explorer.preview_tabs` setting is disabled, this is equivalent to
68    /// `open_file`.
69    ///
70    /// Semantics (see `Editor::preview` for the full invariants):
71    /// - Preview is anchored to a specific split. At most one preview exists
72    ///   editor-wide.
73    /// - If the file is already open (deduped by canonical path, including
74    ///   symlinks and relative paths, by delegating to `open_file_no_focus`),
75    ///   just switch to it. No preview-state changes in either direction.
76    /// - Otherwise, if there's an existing preview in the **same** target
77    ///   split, close it and replace it. If it's in a **different** split,
78    ///   promote it (walking away is commitment) and start a fresh preview
79    ///   in the target split.
80    /// - Skips writing to position history, so a string of exploratory
81    ///   clicks doesn't flood back/forward navigation with stale entries.
82    ///
83    /// TODO(perf): Each preview swap today triggers LSP didClose + didOpen.
84    /// For heavy language servers (rust-analyzer, tsserver) that's wasteful
85    /// on rapid browsing. A future optimization is to keep the LSP session
86    /// for the outgoing buffer until the user commits to the new one.
87    pub fn open_file_preview(&mut self, path: &Path) -> anyhow::Result<BufferId> {
88        // Feature gate — fall back to normal open when preview tabs are off.
89        if !self.config.file_explorer.preview_tabs {
90            return self.open_file(path);
91        }
92
93        // Decide target split up-front. `open_file_no_focus` will target
94        // the same one (it calls `preferred_split_for_file` internally),
95        // so this mirrors its logic. If that invariant ever drifts we'd
96        // open the preview in one split and track it in another.
97        let target_split = self.preferred_split_for_file();
98
99        // Snapshot the buffer IDs that already back a real file, so we can
100        // tell "opened a previously-unknown file" from "switched to one
101        // that was already open". We delegate the symlink/relative-path
102        // dedup to `open_file_no_focus` (which canonicalizes) — any buffer
103        // with a non-empty file path is a candidate match. Note: the
104        // initial empty buffer has a `BufferKind::File` with an empty
105        // `PathBuf`, and we deliberately exclude it here because
106        // `open_file_no_focus` may *repurpose* that buffer (same ID, new
107        // content) for the newly-opened file.
108        let previously_file_backed: HashSet<BufferId> = self
109            .buffers
110            .iter()
111            .filter_map(|(id, state)| {
112                state.buffer.file_path().and_then(|p| {
113                    if p.as_os_str().is_empty() {
114                        None
115                    } else {
116                        Some(*id)
117                    }
118                })
119            })
120            .collect();
121
122        // Route through `open_file` with position-history suppression.
123        // Using the regular `open_file` path keeps all cross-cutting concerns
124        // (LSP, language detection, split targeting, status message, plugin
125        // hooks) consistent with a normal open.
126        self.suppress_position_history_once = true;
127        let open_result = self.open_file(path);
128        self.suppress_position_history_once = false;
129        let buffer_id = open_result?;
130        let is_new = !previously_file_backed.contains(&buffer_id);
131
132        // Already-open buffer: leave preview state untouched. A previously-
133        // committed tab must not be demoted back to preview, and the existing
134        // preview (if any, in whichever split) is still valid.
135        if !is_new {
136            return Ok(buffer_id);
137        }
138
139        // New buffer. Resolve the existing preview (if any) relative to the
140        // target split.
141        match self.preview.take() {
142            Some((prev_split, old_id)) if prev_split == target_split => {
143                // Same split: close the old preview so the new one takes its
144                // place. If close fails (modified buffer — shouldn't happen
145                // because edits promote, but defend in depth), demote the
146                // orphan to a permanent tab rather than leaving behind an
147                // italic "(preview)" tab that will never be replaced.
148                if let Err(e) = self.close_buffer(old_id) {
149                    tracing::warn!(
150                        "preview: could not replace stale preview buffer {:?}, demoting to permanent: {}",
151                        old_id,
152                        e
153                    );
154                    if let Some(m) = self.buffer_metadata.get_mut(&old_id) {
155                        m.is_preview = false;
156                    }
157                }
158            }
159            Some((_other_split, old_id)) => {
160                // Different split: user walked away from the old preview
161                // before this click. Promote it to permanent — their focus
162                // moving to another split was the commitment signal.
163                if let Some(m) = self.buffer_metadata.get_mut(&old_id) {
164                    m.is_preview = false;
165                }
166            }
167            None => {}
168        }
169
170        // Mark the new buffer as the preview, anchored to its split.
171        if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
172            meta.is_preview = true;
173        }
174        self.preview = Some((target_split, buffer_id));
175
176        Ok(buffer_id)
177    }
178
179    /// Promote a specific buffer from preview to permanent, if it was in
180    /// preview mode. No-op if the buffer is not currently a preview.
181    pub(crate) fn promote_buffer_from_preview(&mut self, buffer_id: BufferId) {
182        if let Some(m) = self.buffer_metadata.get_mut(&buffer_id) {
183            m.is_preview = false;
184        }
185        if let Some((_, id)) = self.preview {
186            if id == buffer_id {
187                self.preview = None;
188            }
189        }
190    }
191
192    /// Promote the active buffer from preview to permanent, if applicable.
193    /// Called on any buffer mutation so that touching a preview buffer
194    /// commits it to a permanent tab.
195    pub(crate) fn promote_active_buffer_from_preview(&mut self) {
196        let id = self.active_buffer();
197        self.promote_buffer_from_preview(id);
198    }
199
200    /// Promote the current preview, regardless of which buffer it points at.
201    /// Used before layout changes (split, close-split, move-tab) where the
202    /// preview invariant ("anchored to a specific split") would otherwise
203    /// be broken by the operation itself.
204    pub(crate) fn promote_current_preview(&mut self) {
205        if let Some((_, id)) = self.preview.take() {
206            if let Some(m) = self.buffer_metadata.get_mut(&id) {
207                m.is_preview = false;
208            }
209        }
210    }
211
212    /// Promote the current preview if it belongs to a split other than
213    /// `new_split`. Called from split-focus-change paths so that moving
214    /// focus away from the preview's pane commits it.
215    pub(crate) fn promote_preview_if_not_in_split(&mut self, new_split: LeafId) {
216        if let Some((preview_split, _)) = self.preview {
217            if preview_split != new_split {
218                self.promote_current_preview();
219            }
220        }
221    }
222
223    /// Whether the given buffer is currently in preview (ephemeral) mode.
224    /// Primarily for tests; production code should use `self.preview`.
225    pub fn is_buffer_preview(&self, buffer_id: BufferId) -> bool {
226        self.buffer_metadata
227            .get(&buffer_id)
228            .map(|m| m.is_preview)
229            .unwrap_or(false)
230    }
231
232    /// Number of open buffers (including hidden/virtual buffers).
233    /// Intended for tests that verify preview tabs don't accumulate.
234    pub fn open_buffer_count(&self) -> usize {
235        self.buffers.len()
236    }
237
238    /// The (split, buffer) tuple of the current preview tab, if any.
239    /// Intended for tests that verify preview anchoring semantics.
240    pub fn current_preview(&self) -> Option<(LeafId, BufferId)> {
241        self.preview
242    }
243
244    /// Navigate to a specific line and column in the active buffer.
245    ///
246    /// Line and column are 1-indexed (matching typical editor conventions).
247    /// If the line is out of bounds, navigates to the last line.
248    /// If the column is out of bounds, navigates to the end of the line.
249    pub fn goto_line_col(&mut self, line: usize, column: Option<usize>) {
250        if line == 0 {
251            return; // Line numbers are 1-indexed
252        }
253
254        let buffer_id = self.active_buffer();
255
256        // Read cursor state from split view state
257        let cursors = self.active_cursors();
258        let cursor_id = cursors.primary_id();
259        let old_position = cursors.primary().position;
260        let old_anchor = cursors.primary().anchor;
261        let old_sticky_column = cursors.primary().sticky_column;
262
263        if let Some(state) = self.buffers.get(&buffer_id) {
264            let has_line_index = state.buffer.line_count().is_some();
265            let has_line_scan = state.buffer.has_line_feed_scan();
266            let buffer_len = state.buffer.len();
267
268            // Convert 1-indexed line to 0-indexed
269            let target_line = line.saturating_sub(1);
270            // Column is also 1-indexed, convert to 0-indexed
271            let target_col = column.map(|c| c.saturating_sub(1)).unwrap_or(0);
272
273            // Track the known exact line number for scanned large files,
274            // since offset_to_position may not be able to reverse-resolve it accurately.
275            let mut known_line: Option<usize> = None;
276
277            let position = if has_line_scan && has_line_index {
278                // Scanned large file: use tree metadata to find exact line offset
279                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
280                let actual_line = target_line.min(max_line);
281                known_line = Some(actual_line);
282                // Need mutable access to potentially read chunk data from disk
283                if let Some(state) = self.buffers.get_mut(&buffer_id) {
284                    state
285                        .buffer
286                        .resolve_line_byte_offset(actual_line)
287                        .map(|offset| (offset + target_col).min(buffer_len))
288                        .unwrap_or(0)
289                } else {
290                    0
291                }
292            } else {
293                // Small file with full line starts or no line index:
294                // use exact line position
295                let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
296                let actual_line = target_line.min(max_line);
297                state.buffer.line_col_to_position(actual_line, target_col)
298            };
299
300            let event = Event::MoveCursor {
301                cursor_id,
302                old_position,
303                new_position: position,
304                old_anchor,
305                new_anchor: None,
306                old_sticky_column,
307                new_sticky_column: target_col,
308            };
309
310            let split_id = self.split_manager.active_split();
311            let state = self.buffers.get_mut(&buffer_id).unwrap();
312            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
313            state.apply(&mut view_state.cursors, &event);
314
315            // For scanned large files, override the line number with the known exact value
316            // since offset_to_position may fall back to proportional estimation.
317            if let Some(line) = known_line {
318                state.primary_cursor_line_number = crate::model::buffer::LineNumber::Absolute(line);
319            }
320        }
321    }
322
323    /// Select a range in the active buffer. Lines/columns are 1-indexed.
324    /// The cursor moves to the end of the range and the anchor is set to the
325    /// start, producing a visual selection.
326    pub fn select_range(
327        &mut self,
328        start_line: usize,
329        start_col: Option<usize>,
330        end_line: usize,
331        end_col: Option<usize>,
332    ) {
333        if start_line == 0 || end_line == 0 {
334            return;
335        }
336
337        let buffer_id = self.active_buffer();
338
339        let cursors = self.active_cursors();
340        let cursor_id = cursors.primary_id();
341        let old_position = cursors.primary().position;
342        let old_anchor = cursors.primary().anchor;
343        let old_sticky_column = cursors.primary().sticky_column;
344
345        if let Some(state) = self.buffers.get(&buffer_id) {
346            let buffer_len = state.buffer.len();
347
348            // Convert 1-indexed to 0-indexed
349            let start_line_0 = start_line.saturating_sub(1);
350            let start_col_0 = start_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
351            let end_line_0 = end_line.saturating_sub(1);
352            let end_col_0 = end_col.map(|c| c.saturating_sub(1)).unwrap_or(0);
353
354            let max_line = state.buffer.line_count().unwrap_or(1).saturating_sub(1);
355
356            let start_pos = state
357                .buffer
358                .line_col_to_position(start_line_0.min(max_line), start_col_0)
359                .min(buffer_len);
360            let end_pos = state
361                .buffer
362                .line_col_to_position(end_line_0.min(max_line), end_col_0)
363                .min(buffer_len);
364
365            let event = Event::MoveCursor {
366                cursor_id,
367                old_position,
368                new_position: end_pos,
369                old_anchor,
370                new_anchor: Some(start_pos),
371                old_sticky_column,
372                new_sticky_column: end_col_0,
373            };
374
375            let split_id = self.split_manager.active_split();
376            let state = self.buffers.get_mut(&buffer_id).unwrap();
377            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
378            state.apply(&mut view_state.cursors, &event);
379        }
380    }
381
382    /// Go to an exact byte offset in the buffer (used in byte-offset mode for large files)
383    pub fn goto_byte_offset(&mut self, offset: usize) {
384        let buffer_id = self.active_buffer();
385
386        let cursors = self.active_cursors();
387        let cursor_id = cursors.primary_id();
388        let old_position = cursors.primary().position;
389        let old_anchor = cursors.primary().anchor;
390        let old_sticky_column = cursors.primary().sticky_column;
391
392        if let Some(state) = self.buffers.get(&buffer_id) {
393            let buffer_len = state.buffer.len();
394            let position = offset.min(buffer_len);
395
396            let event = Event::MoveCursor {
397                cursor_id,
398                old_position,
399                new_position: position,
400                old_anchor,
401                new_anchor: None,
402                old_sticky_column,
403                new_sticky_column: 0,
404            };
405
406            let split_id = self.split_manager.active_split();
407            let state = self.buffers.get_mut(&buffer_id).unwrap();
408            let view_state = self.split_view_states.get_mut(&split_id).unwrap();
409            state.apply(&mut view_state.cursors, &event);
410        }
411    }
412
413    /// Create a new empty buffer
414    pub fn new_buffer(&mut self) -> BufferId {
415        // Save current position before switching to new buffer
416        self.position_history.commit_pending_movement();
417
418        // Explicitly record current position before switching
419        let cursors = self.active_cursors();
420        let position = cursors.primary().position;
421        let anchor = cursors.primary().anchor;
422        self.position_history
423            .record_movement(self.active_buffer(), position, anchor);
424        self.position_history.commit_pending_movement();
425
426        let buffer_id = BufferId(self.next_buffer_id);
427        self.next_buffer_id += 1;
428
429        let mut state = EditorState::new(
430            self.terminal_width,
431            self.terminal_height,
432            self.config.editor.large_file_threshold_bytes as usize,
433            Arc::clone(&self.filesystem),
434        );
435        // Note: line_wrap_enabled is set on SplitViewState.viewport when the split is created
436        state
437            .margins
438            .configure_for_line_numbers(self.config.editor.line_numbers);
439        // Set default line ending for new buffers from config
440        state
441            .buffer
442            .set_default_line_ending(self.config.editor.default_line_ending.to_line_ending());
443        self.buffers.insert(buffer_id, state);
444        self.event_logs
445            .insert(buffer_id, crate::model::event::EventLog::new());
446        self.buffer_metadata
447            .insert(buffer_id, crate::app::types::BufferMetadata::new());
448
449        self.set_active_buffer(buffer_id);
450
451        // Initialize per-buffer view state with config defaults.
452        // Must happen AFTER set_active_buffer, because switch_buffer creates
453        // the new BufferViewState with defaults (show_line_numbers=true).
454        let active_split = self.split_manager.active_split();
455        let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
456        let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
457        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
458            view_state.apply_config_defaults(
459                self.config.editor.line_numbers,
460                self.config.editor.highlight_current_line,
461                line_wrap,
462                self.config.editor.wrap_indent,
463                wrap_column,
464                self.config.editor.rulers.clone(),
465            );
466        }
467
468        self.status_message = Some(t!("buffer.new").to_string());
469
470        buffer_id
471    }
472
473    /// Get the current mouse hover state for testing
474    /// Returns Some((byte_position, screen_x, screen_y)) if hovering over text
475    pub fn get_mouse_hover_state(&self) -> Option<(usize, u16, u16)> {
476        self.mouse_state
477            .lsp_hover_state
478            .map(|(pos, _, x, y)| (pos, x, y))
479    }
480
481    /// Check if a transient popup (hover/signature help) is currently visible
482    pub fn has_transient_popup(&self) -> bool {
483        self.active_state()
484            .popups
485            .top()
486            .is_some_and(|p| p.transient)
487    }
488
489    /// Force check the mouse hover timer (for testing)
490    /// This bypasses the normal 500ms delay
491    pub fn force_check_mouse_hover(&mut self) -> bool {
492        if let Some((byte_pos, _, screen_x, screen_y)) = self.mouse_state.lsp_hover_state {
493            if !self.mouse_state.lsp_hover_request_sent {
494                self.hover.set_screen_position((screen_x, screen_y));
495                match self.request_hover_at_position(byte_pos) {
496                    Ok(true) => {
497                        self.mouse_state.lsp_hover_request_sent = true;
498                        return true;
499                    }
500                    Ok(false) => return false, // no server ready, retry later
501                    Err(e) => {
502                        tracing::debug!("Failed to request hover: {}", e);
503                        return false;
504                    }
505                }
506            }
507        }
508        false
509    }
510}