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}