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 => (self.new_buffer(), true),
189        };
190
191        // Switch to replacement buffer BEFORE updating splits.
192        // Only needed when the closing buffer is the one the user is
193        // looking at — otherwise the current active buffer stays.
194        if closing_active {
195            self.set_active_buffer(replacement_buffer);
196
197            // If we landed on a hidden panel buffer to fill the Group-case
198            // housekeeping slot, scrub the *visible* side effects
199            // (`open_buffers`, `focus_history`) so the panel buffer doesn't
200            // appear as a tab. The `keyed_states` entry `switch_buffer`
201            // inserted has to stay — `active_state()` requires
202            // `active_buffer ∈ keyed_states` — but it's harmless as long as
203            // the plugin-snapshot lookup skips it; see
204            // `snapshot_source_split` in `update_plugin_state_snapshot`.
205            let hidden = self
206                .buffer_metadata
207                .get(&replacement_buffer)
208                .is_some_and(|m| m.hidden_from_tabs);
209            if return_to_group.is_some() && hidden {
210                use crate::view::split::TabTarget;
211                if let Some(vs) = self.split_view_states.get_mut(&active_split) {
212                    vs.open_buffers
213                        .retain(|t| *t != TabTarget::Buffer(replacement_buffer));
214                    vs.focus_history
215                        .retain(|t| *t != TabTarget::Buffer(replacement_buffer));
216                }
217            }
218        }
219
220        // Update all splits that are showing this buffer to show the replacement.
221        // Routed through `set_pane_buffer` so the split tree and the
222        // matching `SplitViewState` stay consistent — updating only the
223        // tree left SVS pointing at the buffer we were about to free,
224        // which caused the click panic in issue #1620.
225        let splits_to_update = self.split_manager.splits_for_buffer(id);
226        for split_id in splits_to_update {
227            self.set_pane_buffer(split_id, replacement_buffer);
228        }
229
230        self.buffers.remove(&id);
231        self.event_logs.remove(&id);
232        self.seen_byte_ranges.remove(&id);
233        self.buffer_metadata.remove(&id);
234        if let Some((request_id, _, _)) = self.semantic_tokens_in_flight.remove(&id) {
235            self.pending_semantic_token_requests.remove(&request_id);
236        }
237        if let Some((request_id, _, _, _)) = self.semantic_tokens_range_in_flight.remove(&id) {
238            self.pending_semantic_token_range_requests
239                .remove(&request_id);
240        }
241        self.semantic_tokens_range_last_request.remove(&id);
242        self.semantic_tokens_range_applied.remove(&id);
243        self.semantic_tokens_full_debounce.remove(&id);
244
245        // Remove buffer from panel_ids mapping if it was a panel buffer
246        // This prevents stale entries when the same panel_id is reused later
247        self.panel_ids.retain(|_, &mut buf_id| buf_id != id);
248
249        // Remove buffer from all splits' open_buffers lists and focus history
250        for view_state in self.split_view_states.values_mut() {
251            view_state.remove_buffer(id);
252            view_state.remove_from_history(id);
253        }
254
255        if closing_active {
256            if created_empty_buffer {
257                self.focus_file_explorer();
258            }
259            if let Some(group_leaf) = return_to_group {
260                self.activate_group_tab(active_split, group_leaf);
261            }
262        }
263
264        // Notify plugins so they can reset any state tied to this buffer
265        // (e.g. a plugin that owns a buffer group clears its `isOpen` flag
266        // when the group is closed via the tab's close button rather than
267        // through the plugin's own close command).
268        self.plugin_manager.run_hook(
269            "buffer_closed",
270            fresh_core::hooks::HookArgs::BufferClosed { buffer_id: id },
271        );
272
273        Ok(())
274    }
275
276    /// Switch to the given buffer
277    pub fn switch_buffer(&mut self, id: BufferId) {
278        if self.buffers.contains_key(&id) && id != self.active_buffer() {
279            // Save current position before switching buffers
280            self.position_history.commit_pending_movement();
281
282            // Also explicitly record current position (in case there was no pending movement)
283            let cursors = self.active_cursors();
284            let position = cursors.primary().position;
285            let anchor = cursors.primary().anchor;
286            self.position_history
287                .record_movement(self.active_buffer(), position, anchor);
288            self.position_history.commit_pending_movement();
289
290            self.set_active_buffer(id);
291        }
292    }
293
294    /// Close the current tab in the current split view.
295    /// If the tab is the last viewport of the underlying buffer, do the same as close_buffer
296    /// (including triggering the save/discard prompt for modified buffers).
297    ///
298    /// When the active tab is a buffer group (its `active_group_tab` is set),
299    /// this closes the entire group rather than the currently-focused inner
300    /// panel buffer. Individual panels are internal details of the group —
301    /// the user closes them all together by closing the group tab.
302    pub fn close_tab(&mut self) {
303        // If the active split has a group tab active, close the whole group
304        // rather than just the focused panel buffer — only the Close-Tab
305        // command (or keybinding) can express "close the group I'm viewing",
306        // so this prelude stays here rather than in `close_tab_in_split`.
307        let active_split = self.split_manager.active_split();
308        if let Some(group_leaf_id) = self
309            .split_view_states
310            .get(&active_split)
311            .and_then(|vs| vs.active_group_tab)
312        {
313            self.close_buffer_group_by_leaf(group_leaf_id);
314            self.set_status_message(t!("buffer.tab_closed").to_string());
315            return;
316        }
317
318        // Delegate to `close_tab_in_split` so the Close-Buffer command,
319        // Alt+W, and the mouse × button all run the same code path —
320        // there should be no difference in behavior between them.
321        let buffer_id = self.active_buffer();
322        self.close_tab_in_split(buffer_id, active_split);
323    }
324
325    /// Close a specific tab (buffer) in a specific split.
326    ///
327    /// This is the single shared implementation used by:
328    ///   * the mouse × button on a tab,
329    ///   * the Close Buffer command (via `close_tab`),
330    ///   * the Close Tab command and the `Alt+W` keybinding (via `close_tab`).
331    ///
332    /// All three paths should behave identically; keep new logic here.
333    /// Returns true if the tab was closed without needing a prompt.
334    pub fn close_tab_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
335        // If closing a terminal buffer while in terminal mode, exit terminal mode
336        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
337            self.terminal_mode = false;
338            self.key_context = crate::input::keybindings::KeyContext::Normal;
339        }
340
341        // Count how many splits have this buffer in their open_buffers
342        let buffer_in_other_splits = self
343            .split_view_states
344            .iter()
345            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
346            .count();
347
348        // Get the split's open buffers
349        let split_tabs = self
350            .split_view_states
351            .get(&split_id)
352            .map(|vs| vs.buffer_tab_ids_vec())
353            .unwrap_or_default();
354
355        let is_last_viewport = buffer_in_other_splits == 0;
356
357        if is_last_viewport {
358            // Last viewport of this buffer - need to close buffer entirely
359            if let Some(state) = self.buffers.get(&buffer_id) {
360                if state.buffer.is_modified() {
361                    // Buffer has unsaved changes - prompt for confirmation
362                    let name = self.get_buffer_display_name(buffer_id);
363                    let save_key = t!("prompt.key.save").to_string();
364                    let discard_key = t!("prompt.key.discard").to_string();
365                    let cancel_key = t!("prompt.key.cancel").to_string();
366                    self.start_prompt(
367                        t!(
368                            "prompt.buffer_modified",
369                            name = name,
370                            save_key = save_key,
371                            discard_key = discard_key,
372                            cancel_key = cancel_key
373                        )
374                        .to_string(),
375                        PromptType::ConfirmCloseBuffer { buffer_id },
376                    );
377                    return false;
378                }
379            }
380            // If this is the only tab in this split AND there are other
381            // splits, close the split rather than swap it to a fallback
382            // buffer.  Mirrors `close_tab()` so mouse-click close and
383            // Close Buffer/Close Tab commands behave the same — without
384            // this, the × button leaves a leftover split showing some
385            // unrelated buffer (observed with the Search/Replace panel).
386            let has_other_splits = self.split_manager.root().count_leaves() > 1;
387            if split_tabs.len() <= 1 && has_other_splits {
388                self.handle_close_split(split_id.into());
389                // handle_close_split also disposes the buffer-less split;
390                // buffer lifetime cleanup happens via its own path.
391                if let Err(e) = self.close_buffer(buffer_id) {
392                    tracing::debug!(
393                        "close_tab_in_split: buffer cleanup after split close failed: {}",
394                        e
395                    );
396                }
397                self.set_status_message(t!("buffer.tab_closed").to_string());
398                return true;
399            }
400            if let Err(e) = self.close_buffer(buffer_id) {
401                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
402            } else {
403                self.set_status_message(t!("buffer.tab_closed").to_string());
404            }
405        } else {
406            // There are other viewports of this buffer - just remove from this split's tabs
407            if split_tabs.len() <= 1 {
408                // This is the only tab in this split - close the split
409                self.handle_close_split(split_id.into());
410                return true;
411            }
412
413            // Find replacement buffer for this split
414            let current_idx = split_tabs
415                .iter()
416                .position(|&id| id == buffer_id)
417                .unwrap_or(0);
418            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
419            let replacement_buffer = split_tabs[replacement_idx];
420
421            // Remove buffer from this split's tabs
422            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
423                view_state.remove_buffer(buffer_id);
424            }
425
426            // Update the split to show the replacement buffer
427            self.split_manager
428                .set_split_buffer(split_id, replacement_buffer);
429
430            self.set_status_message(t!("buffer.tab_closed").to_string());
431        }
432        true
433    }
434
435    /// Close all other tabs in a split, keeping only the specified buffer
436    pub fn close_other_tabs_in_split(&mut self, keep_buffer_id: BufferId, split_id: LeafId) {
437        // Get the split's open buffers
438        let split_tabs = self
439            .split_view_states
440            .get(&split_id)
441            .map(|vs| vs.buffer_tab_ids_vec())
442            .unwrap_or_default();
443
444        // Close all tabs except the one we want to keep
445        let tabs_to_close: Vec<_> = split_tabs
446            .iter()
447            .filter(|&&id| id != keep_buffer_id)
448            .copied()
449            .collect();
450
451        let mut closed = 0;
452        let mut skipped_modified = 0;
453        for buffer_id in tabs_to_close {
454            if self.close_tab_in_split_silent(buffer_id, split_id) {
455                closed += 1;
456            } else {
457                skipped_modified += 1;
458            }
459        }
460
461        // Make sure the kept buffer is active
462        self.split_manager
463            .set_split_buffer(split_id, keep_buffer_id);
464
465        self.set_batch_close_status_message(closed, skipped_modified);
466    }
467
468    /// Close tabs to the right of the specified buffer in a split
469    pub fn close_tabs_to_right_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
470        // Get the split's open buffers
471        let split_tabs = self
472            .split_view_states
473            .get(&split_id)
474            .map(|vs| vs.buffer_tab_ids_vec())
475            .unwrap_or_default();
476
477        // Find the index of the target buffer
478        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
479            return;
480        };
481
482        // Close all tabs after the target
483        let tabs_to_close: Vec<_> = split_tabs.iter().skip(target_idx + 1).copied().collect();
484
485        let mut closed = 0;
486        let mut skipped_modified = 0;
487        for buf_id in tabs_to_close {
488            if self.close_tab_in_split_silent(buf_id, split_id) {
489                closed += 1;
490            } else {
491                skipped_modified += 1;
492            }
493        }
494
495        self.set_batch_close_status_message(closed, skipped_modified);
496    }
497
498    /// Close tabs to the left of the specified buffer in a split
499    pub fn close_tabs_to_left_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
500        // Get the split's open buffers
501        let split_tabs = self
502            .split_view_states
503            .get(&split_id)
504            .map(|vs| vs.buffer_tab_ids_vec())
505            .unwrap_or_default();
506
507        // Find the index of the target buffer
508        let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
509            return;
510        };
511
512        // Close all tabs before the target
513        let tabs_to_close: Vec<_> = split_tabs.iter().take(target_idx).copied().collect();
514
515        let mut closed = 0;
516        let mut skipped_modified = 0;
517        for buf_id in tabs_to_close {
518            if self.close_tab_in_split_silent(buf_id, split_id) {
519                closed += 1;
520            } else {
521                skipped_modified += 1;
522            }
523        }
524
525        self.set_batch_close_status_message(closed, skipped_modified);
526    }
527
528    /// Close all tabs in a split
529    pub fn close_all_tabs_in_split(&mut self, split_id: LeafId) {
530        // Get the split's open buffers
531        let split_tabs = self
532            .split_view_states
533            .get(&split_id)
534            .map(|vs| vs.buffer_tab_ids_vec())
535            .unwrap_or_default();
536
537        let mut closed = 0;
538        let mut skipped_modified = 0;
539
540        // Close all tabs (this will eventually close the split when empty)
541        for buffer_id in split_tabs {
542            if self.close_tab_in_split_silent(buffer_id, split_id) {
543                closed += 1;
544            } else {
545                skipped_modified += 1;
546            }
547        }
548
549        self.set_batch_close_status_message(closed, skipped_modified);
550    }
551
552    /// Set status message for batch close operations
553    fn set_batch_close_status_message(&mut self, closed: usize, skipped_modified: usize) {
554        let message = match (closed, skipped_modified) {
555            (0, 0) => t!("buffer.no_tabs_to_close").to_string(),
556            (0, n) => t!("buffer.skipped_modified", count = n).to_string(),
557            (n, 0) => t!("buffer.closed_tabs", count = n).to_string(),
558            (c, s) => t!("buffer.closed_tabs_skipped", closed = c, skipped = s).to_string(),
559        };
560        self.set_status_message(message);
561    }
562
563    /// Close a tab silently (without setting status message)
564    /// Used internally by batch close operations
565    /// Returns true if the tab was closed, false if it was skipped (e.g., modified buffer)
566    fn close_tab_in_split_silent(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
567        // If closing a terminal buffer while in terminal mode, exit terminal mode
568        if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
569            self.terminal_mode = false;
570            self.key_context = crate::input::keybindings::KeyContext::Normal;
571        }
572
573        // Count how many splits have this buffer in their open_buffers
574        let buffer_in_other_splits = self
575            .split_view_states
576            .iter()
577            .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
578            .count();
579
580        // Get the split's open buffers
581        let split_tabs = self
582            .split_view_states
583            .get(&split_id)
584            .map(|vs| vs.buffer_tab_ids_vec())
585            .unwrap_or_default();
586
587        let is_last_viewport = buffer_in_other_splits == 0;
588
589        if is_last_viewport {
590            // Last viewport of this buffer - need to close buffer entirely
591            // Skip modified buffers to avoid prompting during batch operations
592            if let Some(state) = self.buffers.get(&buffer_id) {
593                if state.buffer.is_modified() {
594                    // Skip modified buffers - don't close them
595                    return false;
596                }
597            }
598            if let Err(e) = self.close_buffer(buffer_id) {
599                tracing::warn!("Failed to close buffer: {}", e);
600            }
601            true
602        } else {
603            // There are other viewports of this buffer - just remove from this split's tabs
604            if split_tabs.len() <= 1 {
605                // This is the only tab in this split - close the split
606                self.handle_close_split(split_id.into());
607                return true;
608            }
609
610            // Find replacement buffer for this split
611            let current_idx = split_tabs
612                .iter()
613                .position(|&id| id == buffer_id)
614                .unwrap_or(0);
615            let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
616            let replacement_buffer = split_tabs.get(replacement_idx).copied();
617
618            // Remove buffer from this split's tabs
619            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
620                view_state.remove_buffer(buffer_id);
621            }
622
623            // Update the split to show the replacement buffer. Route
624            // through set_pane_buffer to keep tree and SVS in lockstep.
625            if let Some(replacement) = replacement_buffer {
626                self.set_pane_buffer(split_id, replacement);
627            }
628            true
629        }
630    }
631
632    /// Switch to next buffer in current split's tabs
633    pub fn next_buffer(&mut self) {
634        self.cycle_tab(1);
635    }
636
637    /// Switch to previous buffer in current split's tabs
638    pub fn prev_buffer(&mut self) {
639        self.cycle_tab(-1);
640    }
641
642    /// Cycle through the active split's tab targets (buffers AND groups).
643    /// Direction: +1 = next, -1 = previous.
644    fn cycle_tab(&mut self, direction: i32) {
645        use crate::view::split::TabTarget;
646
647        let active_split = self.split_manager.active_split();
648        let Some(view_state) = self.split_view_states.get(&active_split) else {
649            return;
650        };
651
652        // Collect visible tab targets, filtering out hidden buffers.
653        let targets: Vec<TabTarget> = view_state
654            .open_buffers
655            .iter()
656            .copied()
657            .filter(|t| match t {
658                TabTarget::Buffer(id) => !self
659                    .buffer_metadata
660                    .get(id)
661                    .map(|m| m.hidden_from_tabs)
662                    .unwrap_or(false),
663                TabTarget::Group(_) => true,
664            })
665            .collect();
666
667        if targets.len() < 2 {
668            return;
669        }
670
671        let current_target = view_state.active_target();
672        let Some(idx) = targets.iter().position(|t| *t == current_target) else {
673            return;
674        };
675
676        let next_idx = if direction > 0 {
677            (idx + 1) % targets.len()
678        } else if idx == 0 {
679            targets.len() - 1
680        } else {
681            idx - 1
682        };
683
684        if targets[next_idx] == current_target {
685            return;
686        }
687
688        // Save current position before switching
689        self.position_history.commit_pending_movement();
690        let cursors = self.active_cursors();
691        let position = cursors.primary().position;
692        let anchor = cursors.primary().anchor;
693        self.position_history
694            .record_movement(self.active_buffer(), position, anchor);
695        self.position_history.commit_pending_movement();
696
697        // Start the slide before the switch so the runner's cached
698        // last-frame captures the OUTGOING tab's content. The new
699        // content gets painted on the next render and the push fires
700        // over it. Direction: next-tab pushes from the right, prev
701        // from the left. Wraparound still follows the user's intent
702        // (Next wraps right, Prev wraps left) so the animation
703        // direction matches the keystroke rather than the idx delta.
704        self.animate_tab_switch(active_split, direction.signum());
705
706        match targets[next_idx] {
707            TabTarget::Buffer(buffer_id) => {
708                self.set_active_buffer(buffer_id);
709            }
710            TabTarget::Group(group_leaf_id) => {
711                self.activate_group_tab(active_split, group_leaf_id);
712            }
713        }
714    }
715
716    /// Navigate back in position history
717    pub fn navigate_back(&mut self) {
718        // Set flag to prevent recording this navigation movement
719        self.in_navigation = true;
720
721        // Commit any pending movement
722        self.position_history.commit_pending_movement();
723
724        // If we're at the end of history (haven't used back yet), save current position
725        // so we can navigate forward to it later
726        if self.position_history.can_go_back() && !self.position_history.can_go_forward() {
727            let cursors = self.active_cursors();
728            let position = cursors.primary().position;
729            let anchor = cursors.primary().anchor;
730            self.position_history
731                .record_movement(self.active_buffer(), position, anchor);
732            self.position_history.commit_pending_movement();
733        }
734
735        // Navigate to the previous position
736        if let Some(entry) = self.position_history.back() {
737            let target_buffer = entry.buffer_id;
738            let target_position = entry.position;
739            let target_anchor = entry.anchor;
740
741            // Switch to the target buffer
742            if self.buffers.contains_key(&target_buffer) {
743                self.set_active_buffer(target_buffer);
744
745                // Move cursor to the saved position
746                let cursors = self.active_cursors();
747                let cursor_id = cursors.primary_id();
748                let old_position = cursors.primary().position;
749                let old_anchor = cursors.primary().anchor;
750                let old_sticky_column = cursors.primary().sticky_column;
751                let event = Event::MoveCursor {
752                    cursor_id,
753                    old_position,
754                    new_position: target_position,
755                    old_anchor,
756                    new_anchor: target_anchor,
757                    old_sticky_column,
758                    new_sticky_column: 0, // Reset sticky column for navigation
759                };
760                let split_id = self.split_manager.active_split();
761                let state = self.buffers.get_mut(&target_buffer).unwrap();
762                let view_state = self.split_view_states.get_mut(&split_id).unwrap();
763                state.apply(&mut view_state.cursors, &event);
764                // Position-history entries can land anywhere in the buffer;
765                // the viewport must scroll to the restored cursor or the user
766                // sees the same page after Ctrl+- / Ctrl+= (#1689).
767                self.ensure_active_cursor_visible_for_navigation(true);
768            }
769        }
770
771        // Clear the flag
772        self.in_navigation = false;
773    }
774
775    /// Navigate forward in position history
776    pub fn navigate_forward(&mut self) {
777        // Set flag to prevent recording this navigation movement
778        self.in_navigation = true;
779
780        if let Some(entry) = self.position_history.forward() {
781            let target_buffer = entry.buffer_id;
782            let target_position = entry.position;
783            let target_anchor = entry.anchor;
784
785            // Switch to the target buffer
786            if self.buffers.contains_key(&target_buffer) {
787                self.set_active_buffer(target_buffer);
788
789                // Move cursor to the saved position
790                let cursors = self.active_cursors();
791                let cursor_id = cursors.primary_id();
792                let old_position = cursors.primary().position;
793                let old_anchor = cursors.primary().anchor;
794                let old_sticky_column = cursors.primary().sticky_column;
795                let event = Event::MoveCursor {
796                    cursor_id,
797                    old_position,
798                    new_position: target_position,
799                    old_anchor,
800                    new_anchor: target_anchor,
801                    old_sticky_column,
802                    new_sticky_column: 0, // Reset sticky column for navigation
803                };
804                let split_id = self.split_manager.active_split();
805                let state = self.buffers.get_mut(&target_buffer).unwrap();
806                let view_state = self.split_view_states.get_mut(&split_id).unwrap();
807                state.apply(&mut view_state.cursors, &event);
808                // Position-history entries can land anywhere in the buffer;
809                // the viewport must scroll to the restored cursor or the user
810                // sees the same page after Ctrl+- / Ctrl+= (#1689).
811                self.ensure_active_cursor_visible_for_navigation(true);
812            }
813        }
814
815        // Clear the flag
816        self.in_navigation = false;
817    }
818}