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}