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 => {
189 // Editor invariants require at least one buffer at all times.
190 // When the user opted out of auto-creating a visible empty
191 // buffer on last close, mark the synthesized buffer as a
192 // placeholder: hidden from tabs *and* skipped during pane
193 // rendering, so the workspace genuinely looks blank.
194 let new_id = self.new_buffer();
195 if !self
196 .config
197 .editor
198 .auto_create_empty_buffer_on_last_buffer_close
199 {
200 if let Some(meta) = self.buffer_metadata.get_mut(&new_id) {
201 meta.hidden_from_tabs = true;
202 meta.synthetic_placeholder = true;
203 }
204 }
205 (new_id, true)
206 }
207 };
208
209 // Switch to replacement buffer BEFORE updating splits.
210 // Only needed when the closing buffer is the one the user is
211 // looking at — otherwise the current active buffer stays.
212 if closing_active {
213 self.set_active_buffer(replacement_buffer);
214
215 // If we landed on a hidden panel buffer to fill the Group-case
216 // housekeeping slot, scrub the *visible* side effects
217 // (`open_buffers`, `focus_history`) so the panel buffer doesn't
218 // appear as a tab. The `keyed_states` entry `switch_buffer`
219 // inserted has to stay — `active_state()` requires
220 // `active_buffer ∈ keyed_states` — but it's harmless as long as
221 // the plugin-snapshot lookup skips it; see
222 // `snapshot_source_split` in `update_plugin_state_snapshot`.
223 let hidden = self
224 .buffer_metadata
225 .get(&replacement_buffer)
226 .is_some_and(|m| m.hidden_from_tabs);
227 if return_to_group.is_some() && hidden {
228 use crate::view::split::TabTarget;
229 if let Some(vs) = self.split_view_states.get_mut(&active_split) {
230 vs.open_buffers
231 .retain(|t| *t != TabTarget::Buffer(replacement_buffer));
232 vs.focus_history
233 .retain(|t| *t != TabTarget::Buffer(replacement_buffer));
234 }
235 }
236 }
237
238 // Update all splits that are showing this buffer to show the replacement.
239 // Routed through `set_pane_buffer` so the split tree and the
240 // matching `SplitViewState` stay consistent — updating only the
241 // tree left SVS pointing at the buffer we were about to free,
242 // which caused the click panic in issue #1620.
243 let splits_to_update = self.split_manager.splits_for_buffer(id);
244 for split_id in splits_to_update {
245 self.set_pane_buffer(split_id, replacement_buffer);
246 }
247
248 self.buffers.remove(&id);
249 self.event_logs.remove(&id);
250 self.seen_byte_ranges.remove(&id);
251 self.buffer_metadata.remove(&id);
252 if let Some((request_id, _, _)) = self.semantic_tokens_in_flight.remove(&id) {
253 self.pending_semantic_token_requests.remove(&request_id);
254 }
255 if let Some((request_id, _, _, _)) = self.semantic_tokens_range_in_flight.remove(&id) {
256 self.pending_semantic_token_range_requests
257 .remove(&request_id);
258 }
259 self.semantic_tokens_range_last_request.remove(&id);
260 self.semantic_tokens_range_applied.remove(&id);
261 self.semantic_tokens_full_debounce.remove(&id);
262
263 // Remove buffer from panel_ids mapping if it was a panel buffer
264 // This prevents stale entries when the same panel_id is reused later
265 self.panel_ids.retain(|_, &mut buf_id| buf_id != id);
266
267 // Remove buffer from all splits' open_buffers lists and focus history
268 for view_state in self.split_view_states.values_mut() {
269 view_state.remove_buffer(id);
270 view_state.remove_from_history(id);
271 }
272
273 if closing_active {
274 if created_empty_buffer && self.config.file_explorer.auto_open_on_last_buffer_close {
275 self.focus_file_explorer();
276 }
277 if let Some(group_leaf) = return_to_group {
278 self.activate_group_tab(active_split, group_leaf);
279 }
280 }
281
282 // Notify plugins so they can reset any state tied to this buffer
283 // (e.g. a plugin that owns a buffer group clears its `isOpen` flag
284 // when the group is closed via the tab's close button rather than
285 // through the plugin's own close command).
286 self.plugin_manager.run_hook(
287 "buffer_closed",
288 fresh_core::hooks::HookArgs::BufferClosed { buffer_id: id },
289 );
290
291 Ok(())
292 }
293
294 /// Switch to the given buffer
295 pub fn switch_buffer(&mut self, id: BufferId) {
296 if self.buffers.contains_key(&id) && id != self.active_buffer() {
297 // Save current position before switching buffers
298 self.position_history.commit_pending_movement();
299
300 // Also explicitly record current position (in case there was no pending movement)
301 let cursors = self.active_cursors();
302 let position = cursors.primary().position;
303 let anchor = cursors.primary().anchor;
304 self.position_history
305 .record_movement(self.active_buffer(), position, anchor);
306 self.position_history.commit_pending_movement();
307
308 self.set_active_buffer(id);
309 }
310 }
311
312 /// Close the current tab in the current split view.
313 /// If the tab is the last viewport of the underlying buffer, do the same as close_buffer
314 /// (including triggering the save/discard prompt for modified buffers).
315 ///
316 /// When the active tab is a buffer group (its `active_group_tab` is set),
317 /// this closes the entire group rather than the currently-focused inner
318 /// panel buffer. Individual panels are internal details of the group —
319 /// the user closes them all together by closing the group tab.
320 pub fn close_tab(&mut self) {
321 // If the active split has a group tab active, close the whole group
322 // rather than just the focused panel buffer — only the Close-Tab
323 // command (or keybinding) can express "close the group I'm viewing",
324 // so this prelude stays here rather than in `close_tab_in_split`.
325 let active_split = self.split_manager.active_split();
326 if let Some(group_leaf_id) = self
327 .split_view_states
328 .get(&active_split)
329 .and_then(|vs| vs.active_group_tab)
330 {
331 self.close_buffer_group_by_leaf(group_leaf_id);
332 self.set_status_message(t!("buffer.tab_closed").to_string());
333 return;
334 }
335
336 // Delegate to `close_tab_in_split` so the Close-Buffer command,
337 // Alt+W, and the mouse × button all run the same code path —
338 // there should be no difference in behavior between them.
339 let buffer_id = self.active_buffer();
340 self.close_tab_in_split(buffer_id, active_split);
341 }
342
343 /// Close a specific tab (buffer) in a specific split.
344 ///
345 /// This is the single shared implementation used by:
346 /// * the mouse × button on a tab,
347 /// * the Close Buffer command (via `close_tab`),
348 /// * the Close Tab command and the `Alt+W` keybinding (via `close_tab`).
349 ///
350 /// All three paths should behave identically; keep new logic here.
351 /// Returns true if the tab was closed without needing a prompt.
352 pub fn close_tab_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
353 // If closing a terminal buffer while in terminal mode, exit terminal mode
354 if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
355 self.terminal_mode = false;
356 self.key_context = crate::input::keybindings::KeyContext::Normal;
357 }
358
359 // Count how many splits have this buffer in their open_buffers
360 let buffer_in_other_splits = self
361 .split_view_states
362 .iter()
363 .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
364 .count();
365
366 // Get the split's open buffers
367 let split_tabs = self
368 .split_view_states
369 .get(&split_id)
370 .map(|vs| vs.buffer_tab_ids_vec())
371 .unwrap_or_default();
372
373 let is_last_viewport = buffer_in_other_splits == 0;
374
375 if is_last_viewport {
376 // Last viewport of this buffer - need to close buffer entirely
377 if let Some(state) = self.buffers.get(&buffer_id) {
378 if state.buffer.is_modified() {
379 // Buffer has unsaved changes - prompt for confirmation
380 let name = self.get_buffer_display_name(buffer_id);
381 let save_key = t!("prompt.key.save").to_string();
382 let discard_key = t!("prompt.key.discard").to_string();
383 let cancel_key = t!("prompt.key.cancel").to_string();
384 self.start_prompt(
385 t!(
386 "prompt.buffer_modified",
387 name = name,
388 save_key = save_key,
389 discard_key = discard_key,
390 cancel_key = cancel_key
391 )
392 .to_string(),
393 PromptType::ConfirmCloseBuffer { buffer_id },
394 );
395 return false;
396 }
397 }
398 // If this is the only tab in this split AND there are other
399 // splits, close the split rather than swap it to a fallback
400 // buffer. Mirrors `close_tab()` so mouse-click close and
401 // Close Buffer/Close Tab commands behave the same — without
402 // this, the × button leaves a leftover split showing some
403 // unrelated buffer (observed with the Search/Replace panel).
404 let has_other_splits = self.split_manager.root().count_leaves() > 1;
405 if split_tabs.len() <= 1 && has_other_splits {
406 self.handle_close_split(split_id.into());
407 // handle_close_split also disposes the buffer-less split;
408 // buffer lifetime cleanup happens via its own path.
409 if let Err(e) = self.close_buffer(buffer_id) {
410 tracing::debug!(
411 "close_tab_in_split: buffer cleanup after split close failed: {}",
412 e
413 );
414 }
415 self.set_status_message(t!("buffer.tab_closed").to_string());
416 return true;
417 }
418 if let Err(e) = self.close_buffer(buffer_id) {
419 self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
420 } else {
421 self.set_status_message(t!("buffer.tab_closed").to_string());
422 }
423 } else {
424 // There are other viewports of this buffer - just remove from this split's tabs
425 if split_tabs.len() <= 1 {
426 // This is the only tab in this split - close the split
427 self.handle_close_split(split_id.into());
428 return true;
429 }
430
431 // Find replacement buffer for this split
432 let current_idx = split_tabs
433 .iter()
434 .position(|&id| id == buffer_id)
435 .unwrap_or(0);
436 let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
437 let replacement_buffer = split_tabs[replacement_idx];
438
439 // Remove buffer from this split's tabs
440 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
441 view_state.remove_buffer(buffer_id);
442 }
443
444 // Update the split to show the replacement buffer
445 self.split_manager
446 .set_split_buffer(split_id, replacement_buffer);
447
448 self.set_status_message(t!("buffer.tab_closed").to_string());
449 }
450 true
451 }
452
453 /// Close all other tabs in a split, keeping only the specified buffer
454 pub fn close_other_tabs_in_split(&mut self, keep_buffer_id: BufferId, split_id: LeafId) {
455 // Get the split's open buffers
456 let split_tabs = self
457 .split_view_states
458 .get(&split_id)
459 .map(|vs| vs.buffer_tab_ids_vec())
460 .unwrap_or_default();
461
462 // Close all tabs except the one we want to keep
463 let tabs_to_close: Vec<_> = split_tabs
464 .iter()
465 .filter(|&&id| id != keep_buffer_id)
466 .copied()
467 .collect();
468
469 let mut closed = 0;
470 let mut skipped_modified = 0;
471 for buffer_id in tabs_to_close {
472 if self.close_tab_in_split_silent(buffer_id, split_id) {
473 closed += 1;
474 } else {
475 skipped_modified += 1;
476 }
477 }
478
479 // Make sure the kept buffer is active
480 self.split_manager
481 .set_split_buffer(split_id, keep_buffer_id);
482
483 self.set_batch_close_status_message(closed, skipped_modified);
484 }
485
486 /// Close tabs to the right of the specified buffer in a split
487 pub fn close_tabs_to_right_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
488 // Get the split's open buffers
489 let split_tabs = self
490 .split_view_states
491 .get(&split_id)
492 .map(|vs| vs.buffer_tab_ids_vec())
493 .unwrap_or_default();
494
495 // Find the index of the target buffer
496 let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
497 return;
498 };
499
500 // Close all tabs after the target
501 let tabs_to_close: Vec<_> = split_tabs.iter().skip(target_idx + 1).copied().collect();
502
503 let mut closed = 0;
504 let mut skipped_modified = 0;
505 for buf_id in tabs_to_close {
506 if self.close_tab_in_split_silent(buf_id, split_id) {
507 closed += 1;
508 } else {
509 skipped_modified += 1;
510 }
511 }
512
513 self.set_batch_close_status_message(closed, skipped_modified);
514 }
515
516 /// Close tabs to the left of the specified buffer in a split
517 pub fn close_tabs_to_left_in_split(&mut self, buffer_id: BufferId, split_id: LeafId) {
518 // Get the split's open buffers
519 let split_tabs = self
520 .split_view_states
521 .get(&split_id)
522 .map(|vs| vs.buffer_tab_ids_vec())
523 .unwrap_or_default();
524
525 // Find the index of the target buffer
526 let Some(target_idx) = split_tabs.iter().position(|&id| id == buffer_id) else {
527 return;
528 };
529
530 // Close all tabs before the target
531 let tabs_to_close: Vec<_> = split_tabs.iter().take(target_idx).copied().collect();
532
533 let mut closed = 0;
534 let mut skipped_modified = 0;
535 for buf_id in tabs_to_close {
536 if self.close_tab_in_split_silent(buf_id, split_id) {
537 closed += 1;
538 } else {
539 skipped_modified += 1;
540 }
541 }
542
543 self.set_batch_close_status_message(closed, skipped_modified);
544 }
545
546 /// Close all tabs in a split
547 pub fn close_all_tabs_in_split(&mut self, split_id: LeafId) {
548 // Get the split's open buffers
549 let split_tabs = self
550 .split_view_states
551 .get(&split_id)
552 .map(|vs| vs.buffer_tab_ids_vec())
553 .unwrap_or_default();
554
555 let mut closed = 0;
556 let mut skipped_modified = 0;
557
558 // Close all tabs (this will eventually close the split when empty)
559 for buffer_id in split_tabs {
560 if self.close_tab_in_split_silent(buffer_id, split_id) {
561 closed += 1;
562 } else {
563 skipped_modified += 1;
564 }
565 }
566
567 self.set_batch_close_status_message(closed, skipped_modified);
568 }
569
570 /// Set status message for batch close operations
571 fn set_batch_close_status_message(&mut self, closed: usize, skipped_modified: usize) {
572 let message = match (closed, skipped_modified) {
573 (0, 0) => t!("buffer.no_tabs_to_close").to_string(),
574 (0, n) => t!("buffer.skipped_modified", count = n).to_string(),
575 (n, 0) => t!("buffer.closed_tabs", count = n).to_string(),
576 (c, s) => t!("buffer.closed_tabs_skipped", closed = c, skipped = s).to_string(),
577 };
578 self.set_status_message(message);
579 }
580
581 /// Close a tab silently (without setting status message)
582 /// Used internally by batch close operations
583 /// Returns true if the tab was closed, false if it was skipped (e.g., modified buffer)
584 fn close_tab_in_split_silent(&mut self, buffer_id: BufferId, split_id: LeafId) -> bool {
585 // If closing a terminal buffer while in terminal mode, exit terminal mode
586 if self.terminal_mode && self.is_terminal_buffer(buffer_id) {
587 self.terminal_mode = false;
588 self.key_context = crate::input::keybindings::KeyContext::Normal;
589 }
590
591 // Count how many splits have this buffer in their open_buffers
592 let buffer_in_other_splits = self
593 .split_view_states
594 .iter()
595 .filter(|(&sid, view_state)| sid != split_id && view_state.has_buffer(buffer_id))
596 .count();
597
598 // Get the split's open buffers
599 let split_tabs = self
600 .split_view_states
601 .get(&split_id)
602 .map(|vs| vs.buffer_tab_ids_vec())
603 .unwrap_or_default();
604
605 let is_last_viewport = buffer_in_other_splits == 0;
606
607 if is_last_viewport {
608 // Last viewport of this buffer - need to close buffer entirely
609 // Skip modified buffers to avoid prompting during batch operations
610 if let Some(state) = self.buffers.get(&buffer_id) {
611 if state.buffer.is_modified() {
612 // Skip modified buffers - don't close them
613 return false;
614 }
615 }
616 if let Err(e) = self.close_buffer(buffer_id) {
617 tracing::warn!("Failed to close buffer: {}", e);
618 }
619 true
620 } else {
621 // There are other viewports of this buffer - just remove from this split's tabs
622 if split_tabs.len() <= 1 {
623 // This is the only tab in this split - close the split
624 self.handle_close_split(split_id.into());
625 return true;
626 }
627
628 // Find replacement buffer for this split
629 let current_idx = split_tabs
630 .iter()
631 .position(|&id| id == buffer_id)
632 .unwrap_or(0);
633 let replacement_idx = if current_idx > 0 { current_idx - 1 } else { 1 };
634 let replacement_buffer = split_tabs.get(replacement_idx).copied();
635
636 // Remove buffer from this split's tabs
637 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
638 view_state.remove_buffer(buffer_id);
639 }
640
641 // Update the split to show the replacement buffer. Route
642 // through set_pane_buffer to keep tree and SVS in lockstep.
643 if let Some(replacement) = replacement_buffer {
644 self.set_pane_buffer(split_id, replacement);
645 }
646 true
647 }
648 }
649
650 /// Switch to next buffer in current split's tabs
651 pub fn next_buffer(&mut self) {
652 self.cycle_tab(1);
653 }
654
655 /// Switch to previous buffer in current split's tabs
656 pub fn prev_buffer(&mut self) {
657 self.cycle_tab(-1);
658 }
659
660 /// Cycle through the active split's tab targets (buffers AND groups).
661 /// Direction: +1 = next, -1 = previous.
662 fn cycle_tab(&mut self, direction: i32) {
663 use crate::view::split::TabTarget;
664
665 let active_split = self.split_manager.active_split();
666 let Some(view_state) = self.split_view_states.get(&active_split) else {
667 return;
668 };
669
670 // Collect visible tab targets, filtering out hidden buffers.
671 let targets: Vec<TabTarget> = view_state
672 .open_buffers
673 .iter()
674 .copied()
675 .filter(|t| match t {
676 TabTarget::Buffer(id) => !self
677 .buffer_metadata
678 .get(id)
679 .map(|m| m.hidden_from_tabs)
680 .unwrap_or(false),
681 TabTarget::Group(_) => true,
682 })
683 .collect();
684
685 if targets.len() < 2 {
686 return;
687 }
688
689 let current_target = view_state.active_target();
690 let Some(idx) = targets.iter().position(|t| *t == current_target) else {
691 return;
692 };
693
694 let next_idx = if direction > 0 {
695 (idx + 1) % targets.len()
696 } else if idx == 0 {
697 targets.len() - 1
698 } else {
699 idx - 1
700 };
701
702 if targets[next_idx] == current_target {
703 return;
704 }
705
706 // Save current position before switching
707 self.position_history.commit_pending_movement();
708 let cursors = self.active_cursors();
709 let position = cursors.primary().position;
710 let anchor = cursors.primary().anchor;
711 self.position_history
712 .record_movement(self.active_buffer(), position, anchor);
713 self.position_history.commit_pending_movement();
714
715 // Start the slide before the switch so the runner's cached
716 // last-frame captures the OUTGOING tab's content. The new
717 // content gets painted on the next render and the push fires
718 // over it. Direction: next-tab pushes from the right, prev
719 // from the left. Wraparound still follows the user's intent
720 // (Next wraps right, Prev wraps left) so the animation
721 // direction matches the keystroke rather than the idx delta.
722 self.animate_tab_switch(active_split, direction.signum());
723
724 match targets[next_idx] {
725 TabTarget::Buffer(buffer_id) => {
726 self.set_active_buffer(buffer_id);
727 }
728 TabTarget::Group(group_leaf_id) => {
729 self.activate_group_tab(active_split, group_leaf_id);
730 }
731 }
732 }
733
734 /// Navigate back in position history
735 pub fn navigate_back(&mut self) {
736 // Set flag to prevent recording this navigation movement
737 self.in_navigation = true;
738
739 // Commit any pending movement
740 self.position_history.commit_pending_movement();
741
742 // If we're at the end of history (haven't used back yet), save current position
743 // so we can navigate forward to it later
744 if self.position_history.can_go_back() && !self.position_history.can_go_forward() {
745 let cursors = self.active_cursors();
746 let position = cursors.primary().position;
747 let anchor = cursors.primary().anchor;
748 self.position_history
749 .record_movement(self.active_buffer(), position, anchor);
750 self.position_history.commit_pending_movement();
751 }
752
753 // Navigate to the previous position
754 if let Some(entry) = self.position_history.back() {
755 let target_buffer = entry.buffer_id;
756 let target_position = entry.position;
757 let target_anchor = entry.anchor;
758
759 // Switch to the target buffer
760 if self.buffers.contains_key(&target_buffer) {
761 self.set_active_buffer(target_buffer);
762
763 // Move cursor to the saved position
764 let cursors = self.active_cursors();
765 let cursor_id = cursors.primary_id();
766 let old_position = cursors.primary().position;
767 let old_anchor = cursors.primary().anchor;
768 let old_sticky_column = cursors.primary().sticky_column;
769 let event = Event::MoveCursor {
770 cursor_id,
771 old_position,
772 new_position: target_position,
773 old_anchor,
774 new_anchor: target_anchor,
775 old_sticky_column,
776 new_sticky_column: 0, // Reset sticky column for navigation
777 };
778 let split_id = self.split_manager.active_split();
779 let state = self.buffers.get_mut(&target_buffer).unwrap();
780 let view_state = self.split_view_states.get_mut(&split_id).unwrap();
781 state.apply(&mut view_state.cursors, &event);
782 // Position-history entries can land anywhere in the buffer;
783 // the viewport must scroll to the restored cursor or the user
784 // sees the same page after Ctrl+- / Ctrl+= (#1689).
785 self.ensure_active_cursor_visible_for_navigation(true);
786 }
787 }
788
789 // Clear the flag
790 self.in_navigation = false;
791 }
792
793 /// Navigate forward in position history
794 pub fn navigate_forward(&mut self) {
795 // Set flag to prevent recording this navigation movement
796 self.in_navigation = true;
797
798 if let Some(entry) = self.position_history.forward() {
799 let target_buffer = entry.buffer_id;
800 let target_position = entry.position;
801 let target_anchor = entry.anchor;
802
803 // Switch to the target buffer
804 if self.buffers.contains_key(&target_buffer) {
805 self.set_active_buffer(target_buffer);
806
807 // Move cursor to the saved position
808 let cursors = self.active_cursors();
809 let cursor_id = cursors.primary_id();
810 let old_position = cursors.primary().position;
811 let old_anchor = cursors.primary().anchor;
812 let old_sticky_column = cursors.primary().sticky_column;
813 let event = Event::MoveCursor {
814 cursor_id,
815 old_position,
816 new_position: target_position,
817 old_anchor,
818 new_anchor: target_anchor,
819 old_sticky_column,
820 new_sticky_column: 0, // Reset sticky column for navigation
821 };
822 let split_id = self.split_manager.active_split();
823 let state = self.buffers.get_mut(&target_buffer).unwrap();
824 let view_state = self.split_view_states.get_mut(&split_id).unwrap();
825 state.apply(&mut view_state.cursors, &event);
826 // Position-history entries can land anywhere in the buffer;
827 // the viewport must scroll to the restored cursor or the user
828 // sees the same page after Ctrl+- / Ctrl+= (#1689).
829 self.ensure_active_cursor_visible_for_navigation(true);
830 }
831 }
832
833 // Clear the flag
834 self.in_navigation = false;
835 }
836}