1use super::{Tab, TabId};
4use crate::config::Config;
5use crate::profile::Profile;
6use anyhow::Result;
7use std::sync::Arc;
8use tokio::runtime::Runtime;
9
10pub struct TabManager {
12 pub(super) tabs: Vec<Tab>,
14 pub(super) active_tab_id: Option<TabId>,
16 next_tab_id: TabId,
18}
19
20impl TabManager {
21 pub fn new() -> Self {
23 Self {
24 tabs: Vec::new(),
25 active_tab_id: None,
26 next_tab_id: 1,
27 }
28 }
29
30 pub(super) fn set_active_tab(&mut self, id: Option<TabId>) {
32 use std::sync::atomic::Ordering;
33 if let Some(old_id) = self.active_tab_id
35 && let Some(old_tab) = self.tabs.iter().find(|t| t.id == old_id)
36 {
37 old_tab.is_active.store(false, Ordering::Relaxed);
38 if let Some(ref pm) = old_tab.pane_manager {
39 for pane in pm.all_panes() {
40 pane.is_active.store(false, Ordering::Relaxed);
41 }
42 }
43 }
44 if let Some(new_id) = id
46 && let Some(new_tab) = self.tabs.iter().find(|t| t.id == new_id)
47 {
48 new_tab.is_active.store(true, Ordering::Relaxed);
49 if let Some(ref pm) = new_tab.pane_manager {
50 for pane in pm.all_panes() {
51 pane.is_active.store(true, Ordering::Relaxed);
52 }
53 }
54 }
55 self.active_tab_id = id;
56 }
57
58 pub fn new_tab(
69 &mut self,
70 config: &Config,
71 runtime: Arc<Runtime>,
72 inherit_cwd_from_active: bool,
73 grid_size: Option<(usize, usize)>,
74 ) -> Result<TabId> {
75 let working_dir = if inherit_cwd_from_active {
77 self.active_tab().and_then(|tab| tab.get_cwd())
78 } else {
79 None
80 };
81
82 let id = self.next_tab_id;
83 self.next_tab_id += 1;
84
85 let tab_number = self.tabs.len() + 1;
87 let tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
88 self.tabs.push(tab);
89
90 self.set_active_tab(Some(id));
92
93 log::info!("Created new tab {} (total: {})", id, self.tabs.len());
94
95 Ok(id)
96 }
97
98 pub fn new_tab_with_cwd(
102 &mut self,
103 config: &Config,
104 runtime: Arc<Runtime>,
105 working_dir: Option<String>,
106 grid_size: Option<(usize, usize)>,
107 ) -> Result<TabId> {
108 let id = self.next_tab_id;
109 self.next_tab_id += 1;
110
111 let tab_number = self.tabs.len() + 1;
112 let tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
113 self.tabs.push(tab);
114
115 self.set_active_tab(Some(id));
117
118 log::info!(
119 "Created new tab {} with cwd (total: {})",
120 id,
121 self.tabs.len()
122 );
123
124 Ok(id)
125 }
126
127 pub fn new_tab_from_profile(
137 &mut self,
138 config: &Config,
139 runtime: Arc<Runtime>,
140 profile: &Profile,
141 grid_size: Option<(usize, usize)>,
142 ) -> Result<TabId> {
143 let id = self.next_tab_id;
144 self.next_tab_id += 1;
145
146 let tab = Tab::new_from_profile(id, config, runtime, profile, grid_size)?;
147 self.tabs.push(tab);
148
149 self.set_active_tab(Some(id));
151
152 log::info!(
153 "Created new tab {} from profile '{}' (total: {})",
154 id,
155 profile.name,
156 self.tabs.len()
157 );
158
159 Ok(id)
160 }
161
162 pub fn close_tab(&mut self, id: TabId) -> bool {
165 let index = self.tabs.iter().position(|t| t.id == id);
166
167 if let Some(idx) = index {
168 log::info!("Closing tab {} (index {})", id, idx);
169
170 self.tabs.remove(idx);
172
173 if self.active_tab_id == Some(id) {
175 let new_id = if self.tabs.is_empty() {
176 None
177 } else {
178 let new_idx = idx.min(self.tabs.len().saturating_sub(1));
180 Some(self.tabs[new_idx].id)
181 };
182 self.set_active_tab(new_id);
183 }
184
185 self.renumber_default_tabs();
187 }
188
189 self.tabs.is_empty()
190 }
191
192 pub fn close_tab_fast(&mut self, id: TabId) -> bool {
204 if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id) {
205 tab.stop_refresh_task();
206 let mut terminal_arcs = vec![Arc::clone(&tab.terminal)];
207 let mut session_loggers = vec![Arc::clone(&tab.session_logger)];
208 if let Some(ref mut pm) = tab.pane_manager {
209 for pane in pm.all_panes_mut() {
210 pane.stop_refresh_task();
211 session_loggers.push(Arc::clone(&pane.session_logger));
212 terminal_arcs.push(Arc::clone(&pane.terminal));
213 pane.shutdown_fast = true;
214 }
215 }
216 tab.shutdown_fast = true;
217
218 for arc in &terminal_arcs {
221 if let Ok(mut term) = arc.try_write()
222 && term.is_running()
223 {
224 let _ = term.kill();
225 }
226 }
227
228 let _ = std::thread::Builder::new()
233 .name("mux-logger-cleanup".into())
234 .spawn(move || {
235 for logger_arc in session_loggers {
236 if let Some(ref mut logger) = *logger_arc.lock() {
237 let _ = logger.stop();
238 }
239 }
240 });
241 for (i, arc) in terminal_arcs.into_iter().enumerate() {
242 let _ = std::thread::Builder::new()
243 .name(format!("mux-pty-cleanup-{i}"))
244 .spawn(move || drop(arc));
245 }
246 }
247
248 self.close_tab(id)
252 }
253
254 pub fn remove_tab(&mut self, id: TabId) -> Option<(Tab, bool)> {
261 let idx = self.tabs.iter().position(|t| t.id == id)?;
262
263 log::info!("Removing tab {} (index {}) without dropping", id, idx);
264
265 let tab = self.tabs.remove(idx);
266
267 if self.active_tab_id == Some(id) {
269 let new_id = if self.tabs.is_empty() {
270 None
271 } else {
272 let new_idx = idx.min(self.tabs.len().saturating_sub(1));
273 Some(self.tabs[new_idx].id)
274 };
275 self.set_active_tab(new_id);
276 }
277
278 self.renumber_default_tabs();
279 let is_empty = self.tabs.is_empty();
280 Some((tab, is_empty))
281 }
282
283 #[must_use = "a renumbered tab is only reachable through the returned id"]
308 pub fn insert_tab_at(&mut self, mut tab: Tab, index: usize) -> TabId {
309 let clamped = index.min(self.tabs.len());
310
311 if self.tabs.iter().any(|t| t.id == tab.id) {
312 let renumbered = self.next_tab_id;
314 log::info!(
315 "Inserted tab id {} is already taken in this window; renumbering it to {}",
316 tab.id,
317 renumbered
318 );
319 tab.id = renumbered;
320 }
321
322 let id = tab.id;
323 self.next_tab_id = self.next_tab_id.max(id.saturating_add(1));
324 self.tabs.insert(clamped, tab);
325 self.set_active_tab(Some(id));
326 self.renumber_default_tabs();
327
328 debug_assert_eq!(
332 self.tabs.iter().filter(|t| t.id == id).count(),
333 1,
334 "tab id {id} is not unique after insert"
335 );
336
337 log::info!(
338 "Inserted tab {} at index {} (total: {})",
339 id,
340 clamped,
341 self.tabs.len()
342 );
343 id
344 }
345
346 pub(super) fn renumber_default_tabs(&mut self) {
348 for (idx, tab) in self.tabs.iter_mut().enumerate() {
349 tab.set_default_title(idx + 1);
350 }
351 }
352
353 pub fn active_tab(&self) -> Option<&Tab> {
355 self.active_tab_id
356 .and_then(|id| self.tabs.iter().find(|t| t.id == id))
357 }
358
359 pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
361 let active_id = self.active_tab_id;
362 active_id.and_then(move |id| self.tabs.iter_mut().find(|t| t.id == id))
363 }
364
365 pub fn tab_count(&self) -> usize {
367 self.tabs.len()
368 }
369
370 pub fn visible_tab_count(&self) -> usize {
372 self.tabs.iter().filter(|t| !t.is_hidden).count()
373 }
374
375 pub fn visible_tabs(&self) -> Vec<&Tab> {
377 self.tabs.iter().filter(|t| !t.is_hidden).collect()
378 }
379
380 pub fn has_multiple_tabs(&self) -> bool {
382 self.tabs.len() > 1
383 }
384
385 pub fn active_tab_id(&self) -> Option<TabId> {
387 self.active_tab_id
388 }
389
390 pub fn tabs(&self) -> &[Tab] {
392 &self.tabs
393 }
394
395 pub fn tabs_mut(&mut self) -> &mut [Tab] {
397 &mut self.tabs
398 }
399
400 pub fn drain_tabs(&mut self) -> Vec<Tab> {
405 self.set_active_tab(None);
406 std::mem::take(&mut self.tabs)
407 }
408
409 pub fn get_tab(&self, id: TabId) -> Option<&Tab> {
411 self.tabs.iter().find(|t| t.id == id)
412 }
413
414 pub fn get_tab_mut(&mut self, id: TabId) -> Option<&mut Tab> {
416 self.tabs.iter_mut().find(|t| t.id == id)
417 }
418
419 pub fn mark_activity(&mut self, tab_id: TabId) {
421 if Some(tab_id) != self.active_tab_id
422 && let Some(tab) = self.get_tab_mut(tab_id)
423 {
424 tab.activity.has_activity = true;
425 }
426 }
427
428 pub fn update_all_titles(
430 &mut self,
431 title_mode: par_term_config::TabTitleMode,
432 remote_format: par_term_config::RemoteTabTitleFormat,
433 remote_osc_priority: bool,
434 ) {
435 for tab in &mut self.tabs {
436 tab.update_title(title_mode, remote_format, remote_osc_priority);
437 }
438 }
439
440 pub fn duplicate_active_tab(
447 &mut self,
448 config: &Config,
449 runtime: Arc<Runtime>,
450 grid_size: Option<(usize, usize)>,
451 ) -> Result<Option<TabId>> {
452 if let Some(tab_id) = self.active_tab_id {
453 self.duplicate_tab_by_id(tab_id, config, runtime, grid_size)
454 } else {
455 Ok(None)
456 }
457 }
458
459 pub fn duplicate_tab_by_id(
467 &mut self,
468 source_tab_id: TabId,
469 config: &Config,
470 runtime: Arc<Runtime>,
471 grid_size: Option<(usize, usize)>,
472 ) -> Result<Option<TabId>> {
473 let source_idx = self.tabs.iter().position(|t| t.id == source_tab_id);
475 let source_idx = match source_idx {
476 Some(idx) => idx,
477 None => return Ok(None),
478 };
479 let working_dir = self.tabs[source_idx].get_cwd();
480 let custom_color = self.tabs[source_idx].custom_color;
481 let custom_icon = self.tabs[source_idx].custom_icon.clone();
482
483 let id = self.next_tab_id;
484 self.next_tab_id += 1;
485
486 let tab_number = self.tabs.len() + 1;
488 let mut tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
489
490 if let Some(color) = custom_color {
492 tab.set_custom_color(color);
493 }
494
495 tab.custom_icon = custom_icon;
497
498 self.tabs.insert(source_idx + 1, tab);
500
501 self.set_active_tab(Some(id));
502 Ok(Some(id))
503 }
504
505 pub fn new_tab_from_pane(
510 &mut self,
511 pane: crate::pane::Pane,
512 config: &Config,
513 runtime: Arc<Runtime>,
514 insert_after: Option<TabId>,
515 ) -> TabId {
516 let id = self.next_tab_id;
517 self.next_tab_id += 1;
518 let tab_number = self.tabs.len() + 1;
519 let tab = crate::tab::Tab::new_from_pane(id, pane, config, runtime, tab_number);
520
521 let insert_idx = insert_after
522 .and_then(|after_id| self.tabs.iter().position(|t| t.id == after_id))
523 .map(|idx| idx + 1)
524 .unwrap_or(self.tabs.len());
525
526 self.tabs.insert(insert_idx, tab);
527 self.set_active_tab(Some(id));
528 self.renumber_default_tabs();
529 id
530 }
531
532 #[cfg(test)]
535 pub(crate) fn push_tab_for_test(&mut self, tab: Tab) {
536 self.next_tab_id = self.next_tab_id.max(tab.id + 1);
537 if self.active_tab_id.is_none() {
538 self.active_tab_id = Some(tab.id);
539 }
540 self.tabs.push(tab);
541 }
542
543 pub fn active_tab_index(&self) -> Option<usize> {
545 self.active_tab_id
546 .and_then(|id| self.tabs.iter().position(|t| t.id == id))
547 }
548
549 pub fn cleanup_dead_tabs(&mut self) {
551 let dead_tabs: Vec<TabId> = self
552 .tabs
553 .iter()
554 .filter(|t| !t.is_running())
555 .map(|t| t.id)
556 .collect();
557
558 for id in dead_tabs {
559 log::info!("Cleaning up dead tab {}", id);
560 self.close_tab(id);
561 }
562 }
563}
564
565impl Default for TabManager {
566 fn default() -> Self {
567 Self::new()
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use std::sync::Arc;
575 use tokio::runtime::Builder;
576
577 fn test_runtime() -> Arc<tokio::runtime::Runtime> {
578 Arc::new(
579 Builder::new_current_thread()
580 .enable_all()
581 .build()
582 .expect("build test runtime"),
583 )
584 }
585
586 fn manager_with_ids(ids: &[TabId]) -> TabManager {
588 let mut mgr = TabManager::new();
589 for &id in ids {
590 let tab_number = mgr.tabs.len() + 1;
591 mgr.tabs.push(Tab::new_stub(id, tab_number));
593 mgr.next_tab_id = mgr.next_tab_id.max(id + 1);
594 }
595 if let Some(last) = ids.last() {
596 mgr.active_tab_id = Some(*last);
597 }
598 mgr
599 }
600
601 #[test]
602 fn move_tab_to_index_forward() {
603 let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
604 assert!(mgr.move_tab_to_index(1, 2));
606 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
607 assert_eq!(ids, vec![2, 3, 1, 4]);
608 }
609
610 #[test]
611 fn move_tab_to_index_backward() {
612 let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
613 assert!(mgr.move_tab_to_index(3, 0));
615 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
616 assert_eq!(ids, vec![3, 1, 2, 4]);
617 }
618
619 #[test]
620 fn move_tab_to_index_same_position() {
621 let mut mgr = manager_with_ids(&[1, 2, 3]);
622 assert!(!mgr.move_tab_to_index(2, 1));
624 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
625 assert_eq!(ids, vec![1, 2, 3]);
626 }
627
628 #[test]
629 fn move_tab_to_index_out_of_bounds_clamped() {
630 let mut mgr = manager_with_ids(&[1, 2, 3]);
631 assert!(mgr.move_tab_to_index(1, 100));
633 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
634 assert_eq!(ids, vec![2, 3, 1]);
635 }
636
637 #[test]
638 fn move_tab_to_index_invalid_id() {
639 let mut mgr = manager_with_ids(&[1, 2, 3]);
640 assert!(!mgr.move_tab_to_index(99, 0));
642 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
643 assert_eq!(ids, vec![1, 2, 3]);
644 }
645
646 #[test]
647 fn move_tab_to_index_to_end() {
648 let mut mgr = manager_with_ids(&[1, 2, 3]);
649 assert!(mgr.move_tab_to_index(1, 2));
651 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
652 assert_eq!(ids, vec![2, 3, 1]);
653 }
654
655 #[test]
656 fn move_tab_to_index_to_start() {
657 let mut mgr = manager_with_ids(&[1, 2, 3]);
658 assert!(mgr.move_tab_to_index(3, 0));
660 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
661 assert_eq!(ids, vec![3, 1, 2]);
662 }
663
664 #[test]
665 #[ignore = "requires PTY spawn"]
666 fn remove_insert_round_trip_preserves_tab_fields() {
667 let mut mgr = TabManager::new();
668 let config = Config::default();
669 let runtime = test_runtime();
670
671 let _ = mgr
673 .new_tab(&config, Arc::clone(&runtime), false, Some((80, 24)))
674 .expect("create tab 1");
675 let id = mgr
676 .new_tab(&config, Arc::clone(&runtime), false, Some((80, 24)))
677 .expect("create tab 2");
678
679 {
681 let tab = mgr.get_tab_mut(id).expect("target tab exists");
682 tab.set_title("my-tab");
683 tab.user_named = true;
684 tab.set_custom_color([10, 20, 30]);
685 tab.custom_icon = Some("\u{f120}".to_string());
686 }
687
688 let snapshot = {
690 let tab = mgr.get_tab(id).expect("target tab exists");
691 (
692 tab.id,
693 tab.title.clone(),
694 tab.has_default_title,
695 tab.user_named,
696 tab.custom_color,
697 tab.custom_icon.clone(),
698 )
699 };
700
701 let (live_tab, is_empty) = mgr.remove_tab(id).expect("remove returns Some");
703 assert!(!is_empty, "manager should still have tab 1");
704 let reinserted = mgr.insert_tab_at(live_tab, 1);
705 assert_eq!(
706 reinserted, id,
707 "a tab returning to the manager it left cannot collide, so it keeps its id"
708 );
709
710 let after = mgr
711 .get_tab(reinserted)
712 .expect("tab still present after round-trip");
713 assert_eq!(after.id, snapshot.0, "id mismatch");
714 assert_eq!(after.title, snapshot.1, "title mismatch");
715 assert_eq!(
716 after.has_default_title, snapshot.2,
717 "has_default_title mismatch"
718 );
719 assert_eq!(after.user_named, snapshot.3, "user_named mismatch");
720 assert_eq!(after.custom_color, snapshot.4, "custom_color mismatch");
721 assert_eq!(after.custom_icon, snapshot.5, "custom_icon mismatch");
722 }
723
724 #[test]
725 fn an_inserted_tab_id_is_never_handed_out_again() {
726 let mut mgr = manager_with_ids(&[1, 2]);
732 let foreign = Tab::new_stub(9, 1);
733
734 let inserted = mgr.insert_tab_at(foreign, 1);
735 assert_eq!(inserted, 9, "an id that is free must be kept as-is");
736
737 let fresh = mgr.next_tab_id;
738 assert!(
739 fresh > 9,
740 "next id {} must not collide with the inserted id 9",
741 fresh
742 );
743 assert!(
744 mgr.tabs().iter().all(|tab| tab.id != fresh),
745 "the next id must be free"
746 );
747 }
748
749 #[test]
750 fn a_moved_in_tab_does_not_shadow_a_tab_that_already_holds_its_id() {
751 const ORIGINAL: [u8; 3] = [10, 20, 30];
758 const MOVED: [u8; 3] = [40, 50, 60];
759
760 let mut window_b = manager_with_ids(&[1, 2]);
761 window_b
762 .get_tab_mut(2)
763 .expect("window B's own tab 2")
764 .set_custom_color(ORIGINAL);
765
766 let mut moved = Tab::new_stub(2, 1);
768 moved.set_custom_color(MOVED);
769
770 let moved_id = window_b.insert_tab_at(moved, 2);
771
772 assert_eq!(window_b.tab_count(), 3, "both tabs must survive the move");
773 assert_eq!(
775 window_b
776 .get_tab(moved_id)
777 .expect("the moved tab is reachable by its returned id")
778 .custom_color,
779 Some(MOVED),
780 "get_tab(returned id) must resolve to the tab that moved in, \
781 not to the tab that was already holding that id"
782 );
783 assert_eq!(
784 window_b
785 .get_tab(2)
786 .expect("window B's original tab 2 is still reachable")
787 .custom_color,
788 Some(ORIGINAL),
789 "the tab that already held id 2 must keep it"
790 );
791 assert_ne!(moved_id, 2, "a colliding id must not be kept");
792 }
793}