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