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