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