Skip to main content

par_term/tab/
manager.rs

1//! Tab manager for coordinating multiple terminal tabs within a window
2
3use super::{Tab, TabId};
4use crate::config::Config;
5use crate::profile::Profile;
6use anyhow::Result;
7use std::sync::Arc;
8use tokio::runtime::Runtime;
9
10/// Manages multiple terminal tabs within a single window
11pub struct TabManager {
12    /// All tabs in this window, in order
13    pub(super) tabs: Vec<Tab>,
14    /// Currently active tab ID
15    pub(super) active_tab_id: Option<TabId>,
16    /// Counter for generating unique tab IDs
17    next_tab_id: TabId,
18}
19
20impl TabManager {
21    /// Create a new empty tab manager
22    pub fn new() -> Self {
23        Self {
24            tabs: Vec::new(),
25            active_tab_id: None,
26            next_tab_id: 1,
27        }
28    }
29
30    /// Set the active tab, flipping is_active flags on old and new tabs
31    pub(super) fn set_active_tab(&mut self, id: Option<TabId>) {
32        use std::sync::atomic::Ordering;
33        // Deactivate old tab and its panes
34        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        // Activate new tab and its panes
45        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    /// Create a new tab and return its ID
59    ///
60    /// # Arguments
61    /// * `config` - Terminal configuration
62    /// * `runtime` - Tokio runtime for async operations
63    /// * `inherit_cwd_from_active` - Whether to inherit working directory from active tab
64    /// * `grid_size` - Optional (cols, rows) override for initial terminal size.
65    ///   When provided, these dimensions are used instead of config.cols/rows.
66    ///   This is important when the renderer has already calculated the correct
67    ///   grid size accounting for tab bar height.
68    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        // Optionally inherit working directory from active tab
76        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        // Tab number is based on current count, not unique ID
86        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        // Always switch to the new tab
91        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    /// Create a new tab with a specific working directory
99    ///
100    /// Used by arrangement restore to create tabs with saved CWDs.
101    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        // Always switch to the new tab
116        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    /// Create a new tab from a profile configuration
128    ///
129    /// The profile specifies the working directory, command, and tab name.
130    ///
131    /// # Arguments
132    /// * `config` - Terminal configuration
133    /// * `runtime` - Tokio runtime for async operations
134    /// * `profile` - Profile configuration to use
135    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
136    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        // Always switch to the new tab
150        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    /// Close a tab by ID
163    /// Returns true if this was the last tab (window should close)
164    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            // Remove the tab
171            self.tabs.remove(idx);
172
173            // If we closed the active tab, switch to another
174            if self.active_tab_id == Some(id) {
175                let new_id = if self.tabs.is_empty() {
176                    None
177                } else {
178                    // Prefer the tab at the same index (or previous if at end)
179                    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            // Renumber tabs that still have default titles
186            self.renumber_default_tabs();
187        }
188
189        self.tabs.is_empty()
190    }
191
192    /// Remove a tab by ID without dropping it, returning the live Tab.
193    ///
194    /// Handles active tab switching and renumbering just like `close_tab`,
195    /// but returns the `Tab` so the caller can keep it alive.
196    ///
197    /// Returns `Some((tab, is_empty))` if the tab was found, `None` otherwise.
198    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 we removed the active tab, switch to another
206        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    /// Insert a live Tab at a specific index and make it active.
222    ///
223    /// The index is clamped to `0..=self.tabs.len()`.
224    ///
225    /// Returns the id the tab holds afterwards, which is **not** always the id
226    /// it arrived with.
227    ///
228    /// A tab arriving from another window ("Move Tab to Window") brings that
229    /// window's id with it, and ids are allocated per manager, so the incoming
230    /// id can already belong to a tab here — window A's tab 2 landing in a
231    /// window that already holds ids 1 and 2. `get_tab` is a linear scan, so a
232    /// duplicate would make every id-keyed lookup resolve to whichever tab sits
233    /// first: setting a badge, writing an approved automation action, or
234    /// closing "that" tab would hit an unrelated terminal. A colliding tab is
235    /// therefore renumbered from this manager's counter.
236    ///
237    /// Advancing the counter past the id it ends up with keeps this window from
238    /// later handing the same id to a new tab, which is what makes a `TabId`
239    /// safe to hold across frames — a queued automation confirmation, an
240    /// "Always Allow" grant, a deferred script action — instead of silently
241    /// resolving to an unrelated terminal once the original tab is gone.
242    ///
243    /// A caller that captured the id before the move must use the returned
244    /// value: the pre-move id may now name a different tab, or no tab at all.
245    #[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            // `next_tab_id` is always past every id in `tabs`, so it is free.
251            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        // Guard the mutation rather than `get_tab`: this is the only path by
267        // which a tab this manager did not allocate enters `tabs`, and the
268        // lookups run per frame and inside loops.
269        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    /// Renumber tabs that have default titles based on their current position
285    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    /// Get a reference to the active tab
292    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    /// Get a mutable reference to the active tab
298    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    /// Get the number of tabs
304    pub fn tab_count(&self) -> usize {
305        self.tabs.len()
306    }
307
308    /// Get the number of visible (non-hidden) tabs
309    pub fn visible_tab_count(&self) -> usize {
310        self.tabs.iter().filter(|t| !t.is_hidden).count()
311    }
312
313    /// Get all visible (non-hidden) tabs as a slice-like iterator
314    pub fn visible_tabs(&self) -> Vec<&Tab> {
315        self.tabs.iter().filter(|t| !t.is_hidden).collect()
316    }
317
318    /// Check if there are multiple tabs
319    pub fn has_multiple_tabs(&self) -> bool {
320        self.tabs.len() > 1
321    }
322
323    /// Get the active tab ID
324    pub fn active_tab_id(&self) -> Option<TabId> {
325        self.active_tab_id
326    }
327
328    /// Get all tabs as a slice
329    pub fn tabs(&self) -> &[Tab] {
330        &self.tabs
331    }
332
333    /// Get all tabs as mutable slice
334    pub fn tabs_mut(&mut self) -> &mut [Tab] {
335        &mut self.tabs
336    }
337
338    /// Drain all tabs from the manager, returning them without dropping
339    ///
340    /// This is used during fast shutdown to extract tabs so their terminals
341    /// can be dropped on background threads in parallel.
342    pub fn drain_tabs(&mut self) -> Vec<Tab> {
343        self.set_active_tab(None);
344        std::mem::take(&mut self.tabs)
345    }
346
347    /// Get a tab by ID
348    pub fn get_tab(&self, id: TabId) -> Option<&Tab> {
349        self.tabs.iter().find(|t| t.id == id)
350    }
351
352    /// Get a mutable reference to a tab by ID
353    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    /// Mark non-active tabs as having activity when they receive output
358    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    /// Update titles for all tabs
367    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    /// Duplicate the active tab (creates new tab with same working directory and color)
379    ///
380    /// # Arguments
381    /// * `config` - Terminal configuration
382    /// * `runtime` - Tokio runtime for async operations
383    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
384    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    /// Duplicate a specific tab by ID (creates new tab with same working directory and color)
398    ///
399    /// # Arguments
400    /// * `source_tab_id` - The ID of the tab to duplicate
401    /// * `config` - Terminal configuration
402    /// * `runtime` - Tokio runtime for async operations
403    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
404    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        // Gather properties from source tab
412        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        // Tab number is based on current count, not unique ID
425        let tab_number = self.tabs.len() + 1;
426        let mut tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
427
428        // Copy tab color from source
429        if let Some(color) = custom_color {
430            tab.set_custom_color(color);
431        }
432
433        // Copy custom icon from source
434        tab.custom_icon = custom_icon;
435
436        // Insert after source tab
437        self.tabs.insert(source_idx + 1, tab);
438
439        self.set_active_tab(Some(id));
440        Ok(Some(id))
441    }
442
443    /// Create and insert a new tab wrapping an existing Pane.
444    ///
445    /// The pane's PTY, scroll state, and session logger are preserved.
446    /// No new shell is spawned. Returns the new tab's ID.
447    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    /// Append an already-built tab, for tests that need a populated manager
471    /// without spawning shells (pair with [`Tab::new_stub`]).
472    #[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    /// Get index of active tab (0-based)
482    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    /// Clean up closed/dead tabs
488    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    /// Create a TabManager with mock tabs for testing (no PTY, no runtime)
525    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            // Create a minimal tab struct directly for testing
530            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        // Move tab 1 from index 0 to index 2
543        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        // Move tab 3 from index 2 to index 0
552        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        // Moving to same position is a no-op
561        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        // Target index 100 should clamp to last position (2)
570        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        // Non-existent tab ID returns false
579        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        // Move first tab to last position
588        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        // Move last tab to first position
597        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        // Create two tabs so removing one leaves the manager non-empty.
610        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        // Customize the target tab so we can assert round-trip fidelity.
618        {
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        // Snapshot preserved fields.
627        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        // Round-trip: remove then re-insert at index 1.
640        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        // A tab moved in from another window brings that window's id. Without
665        // advancing the counter, this manager would later allocate the same id
666        // for an unrelated tab, and anything holding the old id across frames —
667        // a queued automation confirmation, an "Always Allow" grant — would
668        // resolve to the wrong terminal.
669        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        // Window A holds {1,2,3}, window B holds {1,2}; the user moves A's tab
690        // 2 into B. Ids are allocated per window, so B is handed a second tab
691        // claiming id 2. `get_tab` is a linear scan, so without renumbering
692        // every lookup of id 2 would resolve to B's *original* tab and the
693        // moved tab would be unreachable — a badge, or an approved automation
694        // write, would land in the wrong terminal.
695        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        // The tab arriving from window A, carrying window A's id.
705        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        // The lookups are the point: id distinctness below is only the means.
712        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}