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