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