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