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}