Skip to main content

fresh/app/
buffer_close.rs

1//! Buffer-close and tab-management orchestrators on `Editor`.
2//!
3//! Closing a buffer in this editor is non-trivial: it involves removing
4//! the buffer from the registry, cleaning up LSP state and semantic
5//! tokens, deciding what to focus next via the focus-history LRU,
6//! adjusting split tab lists, and (for terminal buffers) tearing down
7//! the terminal manager. The whole cluster lives here.
8//!
9//! Also includes tab navigation (next/prev/cycle, navigate_back/forward,
10//! switch_buffer) which depends on the same focus-history machinery.
11
12use rust_i18n::t;
13
14use crate::model::event::{BufferId, Event, LeafId};
15use crate::view::prompt::PromptType;
16
17use super::Editor;
18
19impl Editor {
20    /// Close the given buffer
21    pub fn close_buffer(&mut self, id: BufferId) -> anyhow::Result<()> {
22        // Check for unsaved changes
23        if let Some(state) = self.buffers.get(&id) {
24            if state.buffer.is_modified() {
25                return Err(anyhow::anyhow!("Buffer has unsaved changes"));
26            }
27        }
28        self.close_buffer_internal(id)
29    }
30
31    /// Force close the given buffer without checking for unsaved changes
32    /// Use this when the user has already confirmed they want to discard changes
33    pub fn force_close_buffer(&mut self, id: BufferId) -> anyhow::Result<()> {
34        self.close_buffer_internal(id)
35    }
36
37    /// Internal helper to close a buffer (shared by close_buffer and force_close_buffer)
38    fn close_buffer_internal(&mut self, id: BufferId) -> anyhow::Result<()> {
39        // Clear preview tracking if we're closing the current preview buffer.
40        // This keeps `preview` from pointing at a freed buffer id.
41        if let Some((_, preview_id)) = self.preview {
42            if preview_id == id {
43                self.preview = None;
44            }
45        }
46
47        // Complete any --wait tracking for this buffer
48        if let Some((wait_id, _)) = self.wait_tracking.remove(&id) {
49            self.completed_waits.push(wait_id);
50        }
51
52        // Save file state before closing (for per-file session persistence)
53        self.save_file_state_on_close(id);
54
55        // Delete recovery data for explicitly closed buffers (including unnamed)
56        if let Err(e) = self.delete_buffer_recovery(id) {
57            tracing::debug!("Failed to delete buffer recovery on close: {}", e);
58        }
59
60        // If closing a terminal buffer, clean up terminal-related data structures
61        if let Some(terminal_id) = self.terminal_buffers.remove(&id) {
62            // Close the terminal process
63            self.terminal_manager.close(terminal_id);
64
65            // Clean up backing/rendering file
66            let backing_file = self.terminal_backing_files.remove(&terminal_id);
67            if let Some(ref path) = backing_file {
68                // Best-effort cleanup of temporary terminal files.
69                #[allow(clippy::let_underscore_must_use)]
70                let _ = self.authority.filesystem.remove_file(path);
71            }
72            // Clean up raw log file
73            if let Some(log_file) = self.terminal_log_files.remove(&terminal_id) {
74                if backing_file.as_ref() != Some(&log_file) {
75                    // Best-effort cleanup of temporary terminal files.
76                    #[allow(clippy::let_underscore_must_use)]
77                    let _ = self.authority.filesystem.remove_file(&log_file);
78                }
79            }
80
81            // Remove from terminal_mode_resume to prevent stale entries
82            self.terminal_mode_resume.remove(&id);
83
84            // Exit terminal mode if we were in it
85            if self.terminal_mode {
86                self.terminal_mode = false;
87                self.key_context = crate::input::keybindings::KeyContext::Normal;
88            }
89        }
90
91        // Walk the focus-history LRU (most recent first) to find the tab
92        // the user should land on. This naturally handles both buffer and
93        // group tabs — whichever the user was looking at most recently wins.
94        let active_split = self.split_manager.active_split();
95
96        let replacement_target: Option<crate::view::split::TabTarget> =
97            self.split_view_states.get(&active_split).and_then(|vs| {
98                use crate::view::split::TabTarget;
99                vs.focus_history.iter().rev().find_map(|t| match t {
100                    TabTarget::Buffer(bid) if *bid == id => None, // skip the closing buffer
101                    TabTarget::Buffer(bid) => {
102                        // Skip hidden-from-tabs buffers (panel helpers etc.)
103                        let hidden = self
104                            .buffer_metadata
105                            .get(bid)
106                            .map(|m| m.hidden_from_tabs)
107                            .unwrap_or(false);
108                        if hidden || !self.buffers.contains_key(bid) {
109                            None
110                        } else {
111                            Some(*t)
112                        }
113                    }
114                    TabTarget::Group(leaf) => {
115                        // Only if the group still exists
116                        if self.grouped_subtrees.contains_key(leaf) {
117                            Some(*t)
118                        } else {
119                            None
120                        }
121                    }
122                })
123            });
124
125        // Any visible buffer other than the one being closed. Used as the
126        // general fallback (no LRU target or LRU points at a gone group).
127        let fallback_buffer: Option<BufferId> = self
128            .buffers
129            .keys()
130            .find(|&&bid| {
131                bid != id
132                    && !self
133                        .buffer_metadata
134                        .get(&bid)
135                        .map(|m| m.hidden_from_tabs)
136                        .unwrap_or(false)
137            })
138            .copied();
139
140        // Capture before the replacement computation — new_buffer() has the
141        // side effect of calling set_active_buffer which changes active_buffer().
142        let closing_active = self.active_buffer() == id;
143
144        // Pick the BufferId that becomes the host split's `active_buffer`.
145        // When `return_to_group` is set, `active_buffer` is a housekeeping
146        // fiction — nothing renders it — so any existing buffer works; we
147        // just need to avoid synthesizing a phantom `[No Name]` when a real
148        // option exists. A synthetic buffer fires only when the editor has
149        // literally no other buffer left.
150        let return_to_group = match replacement_target {
151            Some(crate::view::split::TabTarget::Group(leaf)) => Some(leaf),
152            _ => None,
153        };
154
155        let direct_replacement = match replacement_target {
156            Some(crate::view::split::TabTarget::Buffer(bid)) => Some(bid),
157            _ => None,
158        };
159
160        // Prefer a buffer already keyed in the host split: `switch_buffer`
161        // inserts a default BufferViewState for any new active_buffer, which
162        // for hidden panel buffers becomes a shadow entry (cursor=0) that
163        // the plugin-state snapshot could non-deterministically prefer over
164        // the panel split's authoritative copy. Picking something already
165        // keyed sidesteps that insert. (We clean up after the fact if a
166        // shadow does get created — see below.)
167        let already_keyed = return_to_group.and_then(|_| {
168            self.split_view_states
169                .get(&active_split)?
170                .keyed_states
171                .keys()
172                .find(|&&bid| bid != id)
173                .copied()
174        });
175
176        // Absolute last-resort pool for the Group case: any buffer at all,
177        // including hidden panel ones. The shadow cleanup below keeps
178        // those invisible.
179        let any_remaining =
180            return_to_group.and_then(|_| self.buffers.keys().copied().find(|&bid| bid != id));
181
182        let (replacement_buffer, created_empty_buffer) = match direct_replacement
183            .or(already_keyed)
184            .or(fallback_buffer)
185            .or(any_remaining)
186        {
187            Some(bid) => (bid, false),
188            None => {
189                // Editor invariants require at least one buffer at all times.
190                // When the user opted out of auto-creating a visible empty
191                // buffer on last close, mark the synthesized buffer as a
192                // placeholder: hidden from tabs *and* skipped during pane
193                // rendering, so the workspace genuinely looks blank.
194                let new_id = self.new_buffer();
195                if !self
196                    .config
197                    .editor
198                    .auto_create_empty_buffer_on_last_buffer_close
199                {
200                    if let Some(meta) = self.buffer_metadata.get_mut(&new_id) {
201                        meta.hidden_from_tabs = true;
202                        meta.synthetic_placeholder = true;
203                    }
204                }
205                (new_id, true)
206            }
207        };
208
209        // Switch to replacement buffer BEFORE updating splits.
210        // Only needed when the closing buffer is the one the user is
211        // looking at — otherwise the current active buffer stays.
212        if closing_active {
213            self.set_active_buffer(replacement_buffer);
214
215            // If we landed on a hidden panel buffer to fill the Group-case
216            // housekeeping slot, scrub the *visible* side effects
217            // (`open_buffers`, `focus_history`) so the panel buffer doesn't
218            // appear as a tab. The `keyed_states` entry `switch_buffer`
219            // inserted has to stay — `active_state()` requires
220            // `active_buffer ∈ keyed_states` — but it's harmless as long as
221            // the plugin-snapshot lookup skips it; see
222            // `snapshot_source_split` in `update_plugin_state_snapshot`.
223            let hidden = self
224                .buffer_metadata
225                .get(&replacement_buffer)
226                .is_some_and(|m| m.hidden_from_tabs);
227            if return_to_group.is_some() && hidden {
228                use crate::view::split::TabTarget;
229                if let Some(vs) = self.split_view_states.get_mut(&active_split) {
230                    vs.open_buffers
231                        .retain(|t| *t != TabTarget::Buffer(replacement_buffer));
232                    vs.focus_history
233                        .retain(|t| *t != TabTarget::Buffer(replacement_buffer));
234                }
235            }
236        }
237
238        // Update all splits that are showing this buffer to show the replacement.
239        // Routed through `set_pane_buffer` so the split tree and the
240        // matching `SplitViewState` stay consistent — updating only the
241        // tree left SVS pointing at the buffer we were about to free,
242        // which caused the click panic in issue #1620.
243        let splits_to_update = self.split_manager.splits_for_buffer(id);
244        for split_id in splits_to_update {
245            self.set_pane_buffer(split_id, replacement_buffer);
246        }
247
248        self.buffers.remove(&id);
249        self.event_logs.remove(&id);
250        self.seen_byte_ranges.remove(&id);
251        self.buffer_metadata.remove(&id);
252        if let Some((request_id, _, _)) = self.semantic_tokens_in_flight.remove(&id) {
253            self.pending_semantic_token_requests.remove(&request_id);
254        }
255        if let Some((request_id, _, _, _)) = self.semantic_tokens_range_in_flight.remove(&id) {
256            self.pending_semantic_token_range_requests
257                .remove(&request_id);
258        }
259        self.semantic_tokens_range_last_request.remove(&id);
260        self.semantic_tokens_range_applied.remove(&id);
261        self.semantic_tokens_full_debounce.remove(&id);
262
263        // Remove buffer from panel_ids mapping if it was a panel buffer
264        // This prevents stale entries when the same panel_id is reused later
265        self.panel_ids.retain(|_, &mut buf_id| buf_id != id);
266
267        // Remove buffer from all splits' open_buffers lists and focus history
268        for view_state in self.split_view_states.values_mut() {
269            view_state.remove_buffer(id);
270            view_state.remove_from_history(id);
271        }
272
273        if closing_active {
274            if created_empty_buffer && self.config.file_explorer.auto_open_on_last_buffer_close {
275                self.focus_file_explorer();
276            }
277            if let Some(group_leaf) = return_to_group {
278                self.activate_group_tab(active_split, group_leaf);
279            }
280        }
281
282        // Notify plugins so they can reset any state tied to this buffer
283        // (e.g. a plugin that owns a buffer group clears its `isOpen` flag
284        // when the group is closed via the tab's close button rather than
285        // through the plugin's own close command).
286        self.plugin_manager.run_hook(
287            "buffer_closed",
288            fresh_core::hooks::HookArgs::BufferClosed { buffer_id: id },
289        );
290
291        Ok(())
292    }
293
294    /// Switch to the given buffer
295    pub fn switch_buffer(&mut self, id: BufferId) {
296        if self.buffers.contains_key(&id) && id != self.active_buffer() {
297            // Save current position before switching buffers
298            self.position_history.commit_pending_movement();
299
300            // Also explicitly record current position (in case there was no pending movement)
301            let cursors = self.active_cursors();
302            let position = cursors.primary().position;
303            let anchor = cursors.primary().anchor;
304            self.position_history
305                .record_movement(self.active_buffer(), position, anchor);
306            self.position_history.commit_pending_movement();
307
308            self.set_active_buffer(id);
309        }
310    }
311
312    /// Close the current tab in the current split view.
313    /// If the tab is the last viewport of the underlying buffer, do the same as close_buffer
314    /// (including triggering the save/discard prompt for modified buffers).
315    ///
316    /// When the active tab is a buffer group (its `active_group_tab` is set),
317    /// this closes the entire group rather than the currently-focused inner
318    /// panel buffer. Individual panels are internal details of the group —
319    /// the user closes them all together by closing the group tab.
320    pub fn close_tab(&mut self) {
321        // If the active split has a group tab active, close the whole group
322        // rather than just the focused panel buffer — only the Close-Tab
323        // command (or keybinding) can express "close the group I'm viewing",
324        // so this prelude stays here rather than in `close_tab_in_split`.
325        let active_split = self.split_manager.active_split();
326        if let Some(group_leaf_id) = self
327            .split_view_states
328            .get(&active_split)
329            .and_then(|vs| vs.active_group_tab)
330        {
331            self.close_buffer_group_by_leaf(group_leaf_id);
332            self.set_status_message(t!("buffer.tab_closed").to_string());
333            return;
334        }
335
336        // Delegate to `close_tab_in_split` so the Close-Buffer command,
337        // Alt+W, and the mouse × button all run the same code path —
338        // there should be no difference in behavior between them.
339        let buffer_id = self.active_buffer();
340        self.close_tab_in_split(buffer_id, active_split);
341    }
342
343    /// Close a specific tab (buffer) in a specific split.
344    ///
345    /// This is the single shared implementation used by:
346    ///   * the mouse × button on a tab,
347    ///   * the Close Buffer command (via `close_tab`),
348    ///   * the Close Tab command and the `Alt+W` keybinding (via `close_tab`).
349    ///
350    /// All three paths should behave identically; keep new logic here.
351    /// Returns true if the tab was closed without needing a prompt.
352    pub fn close_tab_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
353        // If closing a terminal buffer while in terminal mode, exit terminal mode
354        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
355            self.terminal_mode = false;
356            self.key_context = crate::input::keybindings::KeyContext::Normal;
357        }
358
359        // Count how many splits have this buffer in their open_buffers
360        let buffer_in_other_splits = self
361            .split_view_states
362            .iter()
363            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
364            .count();
365
366        // Get the split's open buffers
367        let split_tabs = self
368            .split_view_states
369            .get(&split_id)
370            .map(|vs| vs.buffer_tab_ids_vec())
371            .unwrap_or_default();
372
373        let is_last_viewport = buffer_in_other_splits == 0;
374
375        if is_last_viewport {
376            // Last viewport of this buffer - need to close buffer entirely
377            if let Some(state) = self.buffers.get(&buffer_id) {
378                if state.buffer.is_modified() {
379                    // Buffer has unsaved changes - prompt for confirmation
380                    let name = self.get_buffer_display_name(buffer_id);
381                    let save_key = t!("prompt.key.save").to_string();
382                    let discard_key = t!("prompt.key.discard").to_string();
383                    let cancel_key = t!("prompt.key.cancel").to_string();
384                    self.start_prompt(
385                        t!(
386                            "prompt.buffer_modified",
387                            name = name,
388                            save_key = save_key,
389                            discard_key = discard_key,
390                            cancel_key = cancel_key
391                        )
392                        .to_string(),
393                        PromptType::ConfirmCloseBuffer { buffer_id },
394                    );
395                    return false;
396                }
397            }
398            // If this is the only tab in this split AND there are other
399            // splits, close the split rather than swap it to a fallback
400            // buffer.  Mirrors `close_tab()` so mouse-click close and
401            // Close Buffer/Close Tab commands behave the same — without
402            // this, the × button leaves a leftover split showing some
403            // unrelated buffer (observed with the Search/Replace panel).
404            let has_other_splits = self.split_manager.root().count_leaves() > 1;
405            if split_tabs.len() <= 1 && has_other_splits {
406                self.handle_close_split(split_id.into());
407                // handle_close_split also disposes the buffer-less split;
408                // buffer lifetime cleanup happens via its own path.
409                if let Err(e) = self.close_buffer(buffer_id) {
410                    tracing::debug!(
411                        "close_tab_in_split: buffer cleanup after split close failed: {}",
412                        e
413                    );
414                }
415                self.set_status_message(t!("buffer.tab_closed").to_string());
416                return true;
417            }
418            if let Err(e) = self.close_buffer(buffer_id) {
419                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
420            } else {
421                self.set_status_message(t!("buffer.tab_closed").to_string());
422            }
423        } else {
424            // There are other viewports of this buffer - just remove from this split's tabs
425            if split_tabs.len() <= 1 {
426                // This is the only tab in this split - close the split
427                self.handle_close_split(split_id.into());
428                return true;
429            }
430
431            // Find replacement buffer for this split
432            let current_idx = split_tabs
433                .iter()
434                .position(|&id| id == buffer_id)
435                .unwrap_or(0);
436            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
437            let replacement_buffer = split_tabs[replacement_idx];
438
439            // Remove buffer from this split's tabs
440            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
441                view_state.remove_buffer(buffer_id);
442            }
443
444            // Update the split to show the replacement buffer
445            self.split_manager
446                .set_split_buffer(split_id, replacement_buffer);
447
448            self.set_status_message(t!("buffer.tab_closed").to_string());
449        }
450        true
451    }
452
453    /// Close all other tabs in a split, keeping only the specified buffer
454    pub fn close_other_tabs_in_split(&mut self, keep_buffer_id: BufferId, split_id: LeafId) {
455        // Get the split's open buffers
456        let split_tabs = self
457            .split_view_states
458            .get(&split_id)
459            .map(|vs| vs.buffer_tab_ids_vec())
460            .unwrap_or_default();
461
462        // Close all tabs except the one we want to keep
463        let tabs_to_close: Vec<_> = split_tabs
464            .iter()
465            .filter(|&&id| id != keep_buffer_id)
466            .copied()
467            .collect();
468
469        let mut closed = 0;
470        let mut skipped_modified = 0;
471        for buffer_id in tabs_to_close {
472            if self.close_tab_in_split_silent(buffer_id, split_id) {
473                closed += 1;
474            } else {
475                skipped_modified += 1;
476            }
477        }
478
479        // Make sure the kept buffer is active
480        self.split_manager
481            .set_split_buffer(split_id, keep_buffer_id);
482
483        self.set_batch_close_status_message(closed, skipped_modified);
484    }
485
486    /// Close tabs to the right of the specified buffer in a split
487    pub fn close_tabs_to_right_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
488        // Get the split's open buffers
489        let split_tabs = self
490            .split_view_states
491            .get(&split_id)
492            .map(|vs| vs.buffer_tab_ids_vec())
493            .unwrap_or_default();
494
495        // Find the index of the target buffer
496        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
497            return;
498        };
499
500        // Close all tabs after the target
501        let tabs_to_close: Vec<_> = split_tabs.iter().skip(target_idx + 1).copied().collect();
502
503        let mut closed = 0;
504        let mut skipped_modified = 0;
505        for buf_id in tabs_to_close {
506            if self.close_tab_in_split_silent(buf_id, split_id) {
507                closed += 1;
508            } else {
509                skipped_modified += 1;
510            }
511        }
512
513        self.set_batch_close_status_message(closed, skipped_modified);
514    }
515
516    /// Close tabs to the left of the specified buffer in a split
517    pub fn close_tabs_to_left_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
518        // Get the split's open buffers
519        let split_tabs = self
520            .split_view_states
521            .get(&split_id)
522            .map(|vs| vs.buffer_tab_ids_vec())
523            .unwrap_or_default();
524
525        // Find the index of the target buffer
526        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
527            return;
528        };
529
530        // Close all tabs before the target
531        let tabs_to_close: Vec<_> = split_tabs.iter().take(target_idx).copied().collect();
532
533        let mut closed = 0;
534        let mut skipped_modified = 0;
535        for buf_id in tabs_to_close {
536            if self.close_tab_in_split_silent(buf_id, split_id) {
537                closed += 1;
538            } else {
539                skipped_modified += 1;
540            }
541        }
542
543        self.set_batch_close_status_message(closed, skipped_modified);
544    }
545
546    /// Close all tabs in a split
547    pub fn close_all_tabs_in_split(&mut self, split_id: LeafId) {
548        // Get the split's open buffers
549        let split_tabs = self
550            .split_view_states
551            .get(&split_id)
552            .map(|vs| vs.buffer_tab_ids_vec())
553            .unwrap_or_default();
554
555        let mut closed = 0;
556        let mut skipped_modified = 0;
557
558        // Close all tabs (this will eventually close the split when empty)
559        for buffer_id in split_tabs {
560            if self.close_tab_in_split_silent(buffer_id, split_id) {
561                closed += 1;
562            } else {
563                skipped_modified += 1;
564            }
565        }
566
567        self.set_batch_close_status_message(closed, skipped_modified);
568    }
569
570    /// Set status message for batch close operations
571    fn set_batch_close_status_message(&mut self, closed: usize, skipped_modified: usize) {
572        let message = match (closed, skipped_modified) {
573            (0, 0) => t!("buffer.no_tabs_to_close").to_string(),
574            (0, n) => t!("buffer.skipped_modified", count = n).to_string(),
575            (n, 0) => t!("buffer.closed_tabs", count = n).to_string(),
576            (c, s) => t!("buffer.closed_tabs_skipped", closed = c, skipped = s).to_string(),
577        };
578        self.set_status_message(message);
579    }
580
581    /// Close a tab silently (without setting status message)
582    /// Used internally by batch close operations
583    /// Returns true if the tab was closed, false if it was skipped (e.g., modified buffer)
584    fn close_tab_in_split_silent(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
585        // If closing a terminal buffer while in terminal mode, exit terminal mode
586        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
587            self.terminal_mode = false;
588            self.key_context = crate::input::keybindings::KeyContext::Normal;
589        }
590
591        // Count how many splits have this buffer in their open_buffers
592        let buffer_in_other_splits = self
593            .split_view_states
594            .iter()
595            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
596            .count();
597
598        // Get the split's open buffers
599        let split_tabs = self
600            .split_view_states
601            .get(&split_id)
602            .map(|vs| vs.buffer_tab_ids_vec())
603            .unwrap_or_default();
604
605        let is_last_viewport = buffer_in_other_splits == 0;
606
607        if is_last_viewport {
608            // Last viewport of this buffer - need to close buffer entirely
609            // Skip modified buffers to avoid prompting during batch operations
610            if let Some(state) = self.buffers.get(&buffer_id) {
611                if state.buffer.is_modified() {
612                    // Skip modified buffers - don't close them
613                    return false;
614                }
615            }
616            if let Err(e) = self.close_buffer(buffer_id) {
617                tracing::warn!("Failed to close buffer: {}", e);
618            }
619            true
620        } else {
621            // There are other viewports of this buffer - just remove from this split's tabs
622            if split_tabs.len() <= 1 {
623                // This is the only tab in this split - close the split
624                self.handle_close_split(split_id.into());
625                return true;
626            }
627
628            // Find replacement buffer for this split
629            let current_idx = split_tabs
630                .iter()
631                .position(|&id| id == buffer_id)
632                .unwrap_or(0);
633            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
634            let replacement_buffer = split_tabs.get(replacement_idx).copied();
635
636            // Remove buffer from this split's tabs
637            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
638                view_state.remove_buffer(buffer_id);
639            }
640
641            // Update the split to show the replacement buffer. Route
642            // through set_pane_buffer to keep tree and SVS in lockstep.
643            if let Some(replacement) = replacement_buffer {
644                self.set_pane_buffer(split_id, replacement);
645            }
646            true
647        }
648    }
649
650    /// Switch to next buffer in current split's tabs
651    pub fn next_buffer(&mut self) {
652        self.cycle_tab(1);
653    }
654
655    /// Switch to previous buffer in current split's tabs
656    pub fn prev_buffer(&mut self) {
657        self.cycle_tab(-1);
658    }
659
660    /// Cycle through the active split's tab targets (buffers AND groups).
661    /// Direction: +1 = next, -1 = previous.
662    fn cycle_tab(&mut self, direction: i32) {
663        use crate::view::split::TabTarget;
664
665        let active_split = self.split_manager.active_split();
666        let Some(view_state) = self.split_view_states.get(&active_split) else {
667            return;
668        };
669
670        // Collect visible tab targets, filtering out hidden buffers.
671        let targets: Vec<TabTarget> = view_state
672            .open_buffers
673            .iter()
674            .copied()
675            .filter(|t| match t {
676                TabTarget::Buffer(id) => !self
677                    .buffer_metadata
678                    .get(id)
679                    .map(|m| m.hidden_from_tabs)
680                    .unwrap_or(false),
681                TabTarget::Group(_) => true,
682            })
683            .collect();
684
685        if targets.len() < 2 {
686            return;
687        }
688
689        let current_target = view_state.active_target();
690        let Some(idx) = targets.iter().position(|t| *t == current_target) else {
691            return;
692        };
693
694        let next_idx = if direction > 0 {
695            (idx + 1) % targets.len()
696        } else if idx == 0 {
697            targets.len() - 1
698        } else {
699            idx - 1
700        };
701
702        if targets[next_idx] == current_target {
703            return;
704        }
705
706        // Save current position before switching
707        self.position_history.commit_pending_movement();
708        let cursors = self.active_cursors();
709        let position = cursors.primary().position;
710        let anchor = cursors.primary().anchor;
711        self.position_history
712            .record_movement(self.active_buffer(), position, anchor);
713        self.position_history.commit_pending_movement();
714
715        // Start the slide before the switch so the runner's cached
716        // last-frame captures the OUTGOING tab's content. The new
717        // content gets painted on the next render and the push fires
718        // over it. Direction: next-tab pushes from the right, prev
719        // from the left. Wraparound still follows the user's intent
720        // (Next wraps right, Prev wraps left) so the animation
721        // direction matches the keystroke rather than the idx delta.
722        self.animate_tab_switch(active_split, direction.signum());
723
724        match targets[next_idx] {
725            TabTarget::Buffer(buffer_id) => {
726                self.set_active_buffer(buffer_id);
727            }
728            TabTarget::Group(group_leaf_id) => {
729                self.activate_group_tab(active_split, group_leaf_id);
730            }
731        }
732    }
733
734    /// Navigate back in position history
735    pub fn navigate_back(&mut self) {
736        // Set flag to prevent recording this navigation movement
737        self.in_navigation = true;
738
739        // Commit any pending movement
740        self.position_history.commit_pending_movement();
741
742        // If we're at the end of history (haven't used back yet), save current position
743        // so we can navigate forward to it later
744        if self.position_history.can_go_back() && !self.position_history.can_go_forward() {
745            let cursors = self.active_cursors();
746            let position = cursors.primary().position;
747            let anchor = cursors.primary().anchor;
748            self.position_history
749                .record_movement(self.active_buffer(), position, anchor);
750            self.position_history.commit_pending_movement();
751        }
752
753        // Navigate to the previous position
754        if let Some(entry) = self.position_history.back() {
755            let target_buffer = entry.buffer_id;
756            let target_position = entry.position;
757            let target_anchor = entry.anchor;
758
759            // Switch to the target buffer
760            if self.buffers.contains_key(&target_buffer) {
761                self.set_active_buffer(target_buffer);
762
763                // Move cursor to the saved position
764                let cursors = self.active_cursors();
765                let cursor_id = cursors.primary_id();
766                let old_position = cursors.primary().position;
767                let old_anchor = cursors.primary().anchor;
768                let old_sticky_column = cursors.primary().sticky_column;
769                let event = Event::MoveCursor {
770                    cursor_id,
771                    old_position,
772                    new_position: target_position,
773                    old_anchor,
774                    new_anchor: target_anchor,
775                    old_sticky_column,
776                    new_sticky_column: 0, // Reset sticky column for navigation
777                };
778                let split_id = self.split_manager.active_split();
779                let state = self.buffers.get_mut(&target_buffer).unwrap();
780                let view_state = self.split_view_states.get_mut(&split_id).unwrap();
781                state.apply(&mut view_state.cursors, &event);
782                // Position-history entries can land anywhere in the buffer;
783                // the viewport must scroll to the restored cursor or the user
784                // sees the same page after Ctrl+- / Ctrl+= (#1689).
785                self.ensure_active_cursor_visible_for_navigation(true);
786            }
787        }
788
789        // Clear the flag
790        self.in_navigation = false;
791    }
792
793    /// Navigate forward in position history
794    pub fn navigate_forward(&mut self) {
795        // Set flag to prevent recording this navigation movement
796        self.in_navigation = true;
797
798        if let Some(entry) = self.position_history.forward() {
799            let target_buffer = entry.buffer_id;
800            let target_position = entry.position;
801            let target_anchor = entry.anchor;
802
803            // Switch to the target buffer
804            if self.buffers.contains_key(&target_buffer) {
805                self.set_active_buffer(target_buffer);
806
807                // Move cursor to the saved position
808                let cursors = self.active_cursors();
809                let cursor_id = cursors.primary_id();
810                let old_position = cursors.primary().position;
811                let old_anchor = cursors.primary().anchor;
812                let old_sticky_column = cursors.primary().sticky_column;
813                let event = Event::MoveCursor {
814                    cursor_id,
815                    old_position,
816                    new_position: target_position,
817                    old_anchor,
818                    new_anchor: target_anchor,
819                    old_sticky_column,
820                    new_sticky_column: 0, // Reset sticky column for navigation
821                };
822                let split_id = self.split_manager.active_split();
823                let state = self.buffers.get_mut(&target_buffer).unwrap();
824                let view_state = self.split_view_states.get_mut(&split_id).unwrap();
825                state.apply(&mut view_state.cursors, &event);
826                // Position-history entries can land anywhere in the buffer;
827                // the viewport must scroll to the restored cursor or the user
828                // sees the same page after Ctrl+- / Ctrl+= (#1689).
829                self.ensure_active_cursor_visible_for_navigation(true);
830            }
831        }
832
833        // Clear the flag
834        self.in_navigation = false;
835    }
836}