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 remove_tab(&mut self, id: TabId) -> Option<(Tab, bool)> {
199 let idx = self.tabs.iter().position(|t| t.id == id)?;
200
201 log::info!("Removing tab {} (index {}) without dropping", id, idx);
202
203 let tab = self.tabs.remove(idx);
204
205 if self.active_tab_id == Some(id) {
207 let new_id = if self.tabs.is_empty() {
208 None
209 } else {
210 let new_idx = idx.min(self.tabs.len().saturating_sub(1));
211 Some(self.tabs[new_idx].id)
212 };
213 self.set_active_tab(new_id);
214 }
215
216 self.renumber_default_tabs();
217 let is_empty = self.tabs.is_empty();
218 Some((tab, is_empty))
219 }
220
221 #[must_use = "a renumbered tab is only reachable through the returned id"]
246 pub fn insert_tab_at(&mut self, mut tab: Tab, index: usize) -> TabId {
247 let clamped = index.min(self.tabs.len());
248
249 if self.tabs.iter().any(|t| t.id == tab.id) {
250 let renumbered = self.next_tab_id;
252 log::info!(
253 "Inserted tab id {} is already taken in this window; renumbering it to {}",
254 tab.id,
255 renumbered
256 );
257 tab.id = renumbered;
258 }
259
260 let id = tab.id;
261 self.next_tab_id = self.next_tab_id.max(id.saturating_add(1));
262 self.tabs.insert(clamped, tab);
263 self.set_active_tab(Some(id));
264 self.renumber_default_tabs();
265
266 debug_assert_eq!(
270 self.tabs.iter().filter(|t| t.id == id).count(),
271 1,
272 "tab id {id} is not unique after insert"
273 );
274
275 log::info!(
276 "Inserted tab {} at index {} (total: {})",
277 id,
278 clamped,
279 self.tabs.len()
280 );
281 id
282 }
283
284 pub(super) fn renumber_default_tabs(&mut self) {
286 for (idx, tab) in self.tabs.iter_mut().enumerate() {
287 tab.set_default_title(idx + 1);
288 }
289 }
290
291 pub fn active_tab(&self) -> Option<&Tab> {
293 self.active_tab_id
294 .and_then(|id| self.tabs.iter().find(|t| t.id == id))
295 }
296
297 pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
299 let active_id = self.active_tab_id;
300 active_id.and_then(move |id| self.tabs.iter_mut().find(|t| t.id == id))
301 }
302
303 pub fn tab_count(&self) -> usize {
305 self.tabs.len()
306 }
307
308 pub fn visible_tab_count(&self) -> usize {
310 self.tabs.iter().filter(|t| !t.is_hidden).count()
311 }
312
313 pub fn visible_tabs(&self) -> Vec<&Tab> {
315 self.tabs.iter().filter(|t| !t.is_hidden).collect()
316 }
317
318 pub fn has_multiple_tabs(&self) -> bool {
320 self.tabs.len() > 1
321 }
322
323 pub fn active_tab_id(&self) -> Option<TabId> {
325 self.active_tab_id
326 }
327
328 pub fn tabs(&self) -> &[Tab] {
330 &self.tabs
331 }
332
333 pub fn tabs_mut(&mut self) -> &mut [Tab] {
335 &mut self.tabs
336 }
337
338 pub fn drain_tabs(&mut self) -> Vec<Tab> {
343 self.set_active_tab(None);
344 std::mem::take(&mut self.tabs)
345 }
346
347 pub fn get_tab(&self, id: TabId) -> Option<&Tab> {
349 self.tabs.iter().find(|t| t.id == id)
350 }
351
352 pub fn get_tab_mut(&mut self, id: TabId) -> Option<&mut Tab> {
354 self.tabs.iter_mut().find(|t| t.id == id)
355 }
356
357 pub fn mark_activity(&mut self, tab_id: TabId) {
359 if Some(tab_id) != self.active_tab_id
360 && let Some(tab) = self.get_tab_mut(tab_id)
361 {
362 tab.activity.has_activity = true;
363 }
364 }
365
366 pub fn update_all_titles(
368 &mut self,
369 title_mode: par_term_config::TabTitleMode,
370 remote_format: par_term_config::RemoteTabTitleFormat,
371 remote_osc_priority: bool,
372 ) {
373 for tab in &mut self.tabs {
374 tab.update_title(title_mode, remote_format, remote_osc_priority);
375 }
376 }
377
378 pub fn duplicate_active_tab(
385 &mut self,
386 config: &Config,
387 runtime: Arc<Runtime>,
388 grid_size: Option<(usize, usize)>,
389 ) -> Result<Option<TabId>> {
390 if let Some(tab_id) = self.active_tab_id {
391 self.duplicate_tab_by_id(tab_id, config, runtime, grid_size)
392 } else {
393 Ok(None)
394 }
395 }
396
397 pub fn duplicate_tab_by_id(
405 &mut self,
406 source_tab_id: TabId,
407 config: &Config,
408 runtime: Arc<Runtime>,
409 grid_size: Option<(usize, usize)>,
410 ) -> Result<Option<TabId>> {
411 let source_idx = self.tabs.iter().position(|t| t.id == source_tab_id);
413 let source_idx = match source_idx {
414 Some(idx) => idx,
415 None => return Ok(None),
416 };
417 let working_dir = self.tabs[source_idx].get_cwd();
418 let custom_color = self.tabs[source_idx].custom_color;
419 let custom_icon = self.tabs[source_idx].custom_icon.clone();
420
421 let id = self.next_tab_id;
422 self.next_tab_id += 1;
423
424 let tab_number = self.tabs.len() + 1;
426 let mut tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
427
428 if let Some(color) = custom_color {
430 tab.set_custom_color(color);
431 }
432
433 tab.custom_icon = custom_icon;
435
436 self.tabs.insert(source_idx + 1, tab);
438
439 self.set_active_tab(Some(id));
440 Ok(Some(id))
441 }
442
443 pub fn new_tab_from_pane(
448 &mut self,
449 pane: crate::pane::Pane,
450 config: &Config,
451 runtime: Arc<Runtime>,
452 insert_after: Option<TabId>,
453 ) -> TabId {
454 let id = self.next_tab_id;
455 self.next_tab_id += 1;
456 let tab_number = self.tabs.len() + 1;
457 let tab = crate::tab::Tab::new_from_pane(id, pane, config, runtime, tab_number);
458
459 let insert_idx = insert_after
460 .and_then(|after_id| self.tabs.iter().position(|t| t.id == after_id))
461 .map(|idx| idx + 1)
462 .unwrap_or(self.tabs.len());
463
464 self.tabs.insert(insert_idx, tab);
465 self.set_active_tab(Some(id));
466 self.renumber_default_tabs();
467 id
468 }
469
470 #[cfg(test)]
473 pub(crate) fn push_tab_for_test(&mut self, tab: Tab) {
474 self.next_tab_id = self.next_tab_id.max(tab.id + 1);
475 if self.active_tab_id.is_none() {
476 self.active_tab_id = Some(tab.id);
477 }
478 self.tabs.push(tab);
479 }
480
481 pub fn active_tab_index(&self) -> Option<usize> {
483 self.active_tab_id
484 .and_then(|id| self.tabs.iter().position(|t| t.id == id))
485 }
486
487 pub fn cleanup_dead_tabs(&mut self) {
489 let dead_tabs: Vec<TabId> = self
490 .tabs
491 .iter()
492 .filter(|t| !t.is_running())
493 .map(|t| t.id)
494 .collect();
495
496 for id in dead_tabs {
497 log::info!("Cleaning up dead tab {}", id);
498 self.close_tab(id);
499 }
500 }
501}
502
503impl Default for TabManager {
504 fn default() -> Self {
505 Self::new()
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use std::sync::Arc;
513 use tokio::runtime::Builder;
514
515 fn test_runtime() -> Arc<tokio::runtime::Runtime> {
516 Arc::new(
517 Builder::new_current_thread()
518 .enable_all()
519 .build()
520 .expect("build test runtime"),
521 )
522 }
523
524 fn manager_with_ids(ids: &[TabId]) -> TabManager {
526 let mut mgr = TabManager::new();
527 for &id in ids {
528 let tab_number = mgr.tabs.len() + 1;
529 mgr.tabs.push(Tab::new_stub(id, tab_number));
531 mgr.next_tab_id = mgr.next_tab_id.max(id + 1);
532 }
533 if let Some(last) = ids.last() {
534 mgr.active_tab_id = Some(*last);
535 }
536 mgr
537 }
538
539 #[test]
540 fn move_tab_to_index_forward() {
541 let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
542 assert!(mgr.move_tab_to_index(1, 2));
544 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
545 assert_eq!(ids, vec![2, 3, 1, 4]);
546 }
547
548 #[test]
549 fn move_tab_to_index_backward() {
550 let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
551 assert!(mgr.move_tab_to_index(3, 0));
553 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
554 assert_eq!(ids, vec![3, 1, 2, 4]);
555 }
556
557 #[test]
558 fn move_tab_to_index_same_position() {
559 let mut mgr = manager_with_ids(&[1, 2, 3]);
560 assert!(!mgr.move_tab_to_index(2, 1));
562 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
563 assert_eq!(ids, vec![1, 2, 3]);
564 }
565
566 #[test]
567 fn move_tab_to_index_out_of_bounds_clamped() {
568 let mut mgr = manager_with_ids(&[1, 2, 3]);
569 assert!(mgr.move_tab_to_index(1, 100));
571 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
572 assert_eq!(ids, vec![2, 3, 1]);
573 }
574
575 #[test]
576 fn move_tab_to_index_invalid_id() {
577 let mut mgr = manager_with_ids(&[1, 2, 3]);
578 assert!(!mgr.move_tab_to_index(99, 0));
580 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
581 assert_eq!(ids, vec![1, 2, 3]);
582 }
583
584 #[test]
585 fn move_tab_to_index_to_end() {
586 let mut mgr = manager_with_ids(&[1, 2, 3]);
587 assert!(mgr.move_tab_to_index(1, 2));
589 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
590 assert_eq!(ids, vec![2, 3, 1]);
591 }
592
593 #[test]
594 fn move_tab_to_index_to_start() {
595 let mut mgr = manager_with_ids(&[1, 2, 3]);
596 assert!(mgr.move_tab_to_index(3, 0));
598 let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
599 assert_eq!(ids, vec![3, 1, 2]);
600 }
601
602 #[test]
603 #[ignore = "requires PTY spawn"]
604 fn remove_insert_round_trip_preserves_tab_fields() {
605 let mut mgr = TabManager::new();
606 let config = Config::default();
607 let runtime = test_runtime();
608
609 let _ = mgr
611 .new_tab(&config, Arc::clone(&runtime), false, Some((80, 24)))
612 .expect("create tab 1");
613 let id = mgr
614 .new_tab(&config, Arc::clone(&runtime), false, Some((80, 24)))
615 .expect("create tab 2");
616
617 {
619 let tab = mgr.get_tab_mut(id).expect("target tab exists");
620 tab.set_title("my-tab");
621 tab.user_named = true;
622 tab.set_custom_color([10, 20, 30]);
623 tab.custom_icon = Some("\u{f120}".to_string());
624 }
625
626 let snapshot = {
628 let tab = mgr.get_tab(id).expect("target tab exists");
629 (
630 tab.id,
631 tab.title.clone(),
632 tab.has_default_title,
633 tab.user_named,
634 tab.custom_color,
635 tab.custom_icon.clone(),
636 )
637 };
638
639 let (live_tab, is_empty) = mgr.remove_tab(id).expect("remove returns Some");
641 assert!(!is_empty, "manager should still have tab 1");
642 let reinserted = mgr.insert_tab_at(live_tab, 1);
643 assert_eq!(
644 reinserted, id,
645 "a tab returning to the manager it left cannot collide, so it keeps its id"
646 );
647
648 let after = mgr
649 .get_tab(reinserted)
650 .expect("tab still present after round-trip");
651 assert_eq!(after.id, snapshot.0, "id mismatch");
652 assert_eq!(after.title, snapshot.1, "title mismatch");
653 assert_eq!(
654 after.has_default_title, snapshot.2,
655 "has_default_title mismatch"
656 );
657 assert_eq!(after.user_named, snapshot.3, "user_named mismatch");
658 assert_eq!(after.custom_color, snapshot.4, "custom_color mismatch");
659 assert_eq!(after.custom_icon, snapshot.5, "custom_icon mismatch");
660 }
661
662 #[test]
663 fn an_inserted_tab_id_is_never_handed_out_again() {
664 let mut mgr = manager_with_ids(&[1, 2]);
670 let foreign = Tab::new_stub(9, 1);
671
672 let inserted = mgr.insert_tab_at(foreign, 1);
673 assert_eq!(inserted, 9, "an id that is free must be kept as-is");
674
675 let fresh = mgr.next_tab_id;
676 assert!(
677 fresh > 9,
678 "next id {} must not collide with the inserted id 9",
679 fresh
680 );
681 assert!(
682 mgr.tabs().iter().all(|tab| tab.id != fresh),
683 "the next id must be free"
684 );
685 }
686
687 #[test]
688 fn a_moved_in_tab_does_not_shadow_a_tab_that_already_holds_its_id() {
689 const ORIGINAL: [u8; 3] = [10, 20, 30];
696 const MOVED: [u8; 3] = [40, 50, 60];
697
698 let mut window_b = manager_with_ids(&[1, 2]);
699 window_b
700 .get_tab_mut(2)
701 .expect("window B's own tab 2")
702 .set_custom_color(ORIGINAL);
703
704 let mut moved = Tab::new_stub(2, 1);
706 moved.set_custom_color(MOVED);
707
708 let moved_id = window_b.insert_tab_at(moved, 2);
709
710 assert_eq!(window_b.tab_count(), 3, "both tabs must survive the move");
711 assert_eq!(
713 window_b
714 .get_tab(moved_id)
715 .expect("the moved tab is reachable by its returned id")
716 .custom_color,
717 Some(MOVED),
718 "get_tab(returned id) must resolve to the tab that moved in, \
719 not to the tab that was already holding that id"
720 );
721 assert_eq!(
722 window_b
723 .get_tab(2)
724 .expect("window B's original tab 2 is still reachable")
725 .custom_color,
726 Some(ORIGINAL),
727 "the tab that already held id 2 must keep it"
728 );
729 assert_ne!(moved_id, 2, "a colliding id must not be kept");
730 }
731}