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    /// Close one tab through the fast-shutdown path.
193    ///
194    /// The tab and its panes are marked `shutdown_fast` and their PTYs
195    /// pre-killed, so the inline drop is immediate, and the terminal
196    /// `Arc`s plus session loggers are released on background threads:
197    /// the LAST Arc's drop runs `TerminalManager::drop` →
198    /// `PtySession::drop`, which waits on the reader thread (up to its
199    /// 2 s timeout per session) — unbounded blocking work that must
200    /// never run on the UI thread. Mux teardown calls this because
201    /// detach and daemon death close every mux tab at once, multiplying
202    /// that wait. Same return contract as [`Self::close_tab`].
203    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            // Pre-kill the PTYs (SIGKILL, non-blocking) so the background
219            // drops finish quickly.
220            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            // Fire-and-forget cleanup, exactly like the window-close fast
229            // path (`WindowState::perform_shutdown`): logger stop flushes
230            // buffered I/O and the Arc drops may wait on reader threads —
231            // both belong off the UI thread, in parallel per session.
232            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        // The tab drops inline below with `shutdown_fast` set — Tab::drop
249        // returns immediately; active-tab switching and renumbering are
250        // close_tab's.
251        self.close_tab(id)
252    }
253
254    /// Remove a tab by ID without dropping it, returning the live Tab.
255    ///
256    /// Handles active tab switching and renumbering just like `close_tab`,
257    /// but returns the `Tab` so the caller can keep it alive.
258    ///
259    /// Returns `Some((tab, is_empty))` if the tab was found, `None` otherwise.
260    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 we removed the active tab, switch to another
268        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    /// Insert a live Tab at a specific index and make it active.
284    ///
285    /// The index is clamped to `0..=self.tabs.len()`.
286    ///
287    /// Returns the id the tab holds afterwards, which is **not** always the id
288    /// it arrived with.
289    ///
290    /// A tab arriving from another window ("Move Tab to Window") brings that
291    /// window's id with it, and ids are allocated per manager, so the incoming
292    /// id can already belong to a tab here — window A's tab 2 landing in a
293    /// window that already holds ids 1 and 2. `get_tab` is a linear scan, so a
294    /// duplicate would make every id-keyed lookup resolve to whichever tab sits
295    /// first: setting a badge, writing an approved automation action, or
296    /// closing "that" tab would hit an unrelated terminal. A colliding tab is
297    /// therefore renumbered from this manager's counter.
298    ///
299    /// Advancing the counter past the id it ends up with keeps this window from
300    /// later handing the same id to a new tab, which is what makes a `TabId`
301    /// safe to hold across frames — a queued automation confirmation, an
302    /// "Always Allow" grant, a deferred script action — instead of silently
303    /// resolving to an unrelated terminal once the original tab is gone.
304    ///
305    /// A caller that captured the id before the move must use the returned
306    /// value: the pre-move id may now name a different tab, or no tab at all.
307    #[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            // `next_tab_id` is always past every id in `tabs`, so it is free.
313            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        // Guard the mutation rather than `get_tab`: this is the only path by
329        // which a tab this manager did not allocate enters `tabs`, and the
330        // lookups run per frame and inside loops.
331        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    /// Renumber tabs that have default titles based on their current position
347    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    /// Get a reference to the active tab
354    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    /// Get a mutable reference to the active tab
360    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    /// Get the number of tabs
366    pub fn tab_count(&self) -> usize {
367        self.tabs.len()
368    }
369
370    /// Get the number of visible (non-hidden) tabs
371    pub fn visible_tab_count(&self) -> usize {
372        self.tabs.iter().filter(|t| !t.is_hidden).count()
373    }
374
375    /// Get all visible (non-hidden) tabs as a slice-like iterator
376    pub fn visible_tabs(&self) -> Vec<&Tab> {
377        self.tabs.iter().filter(|t| !t.is_hidden).collect()
378    }
379
380    /// Check if there are multiple tabs
381    pub fn has_multiple_tabs(&self) -> bool {
382        self.tabs.len() > 1
383    }
384
385    /// Get the active tab ID
386    pub fn active_tab_id(&self) -> Option<TabId> {
387        self.active_tab_id
388    }
389
390    /// Get all tabs as a slice
391    pub fn tabs(&self) -> &[Tab] {
392        &self.tabs
393    }
394
395    /// Get all tabs as mutable slice
396    pub fn tabs_mut(&mut self) -> &mut [Tab] {
397        &mut self.tabs
398    }
399
400    /// Drain all tabs from the manager, returning them without dropping
401    ///
402    /// This is used during fast shutdown to extract tabs so their terminals
403    /// can be dropped on background threads in parallel.
404    pub fn drain_tabs(&mut self) -> Vec<Tab> {
405        self.set_active_tab(None);
406        std::mem::take(&mut self.tabs)
407    }
408
409    /// Get a tab by ID
410    pub fn get_tab(&self, id: TabId) -> Option<&Tab> {
411        self.tabs.iter().find(|t| t.id == id)
412    }
413
414    /// Get a mutable reference to a tab by ID
415    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    /// Mark non-active tabs as having activity when they receive output
420    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    /// Update titles for all tabs
429    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    /// Duplicate the active tab (creates new tab with same working directory and color)
441    ///
442    /// # Arguments
443    /// * `config` - Terminal configuration
444    /// * `runtime` - Tokio runtime for async operations
445    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
446    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    /// Duplicate a specific tab by ID (creates new tab with same working directory and color)
460    ///
461    /// # Arguments
462    /// * `source_tab_id` - The ID of the tab to duplicate
463    /// * `config` - Terminal configuration
464    /// * `runtime` - Tokio runtime for async operations
465    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
466    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        // Gather properties from source tab
474        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        // Tab number is based on current count, not unique ID
487        let tab_number = self.tabs.len() + 1;
488        let mut tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
489
490        // Copy tab color from source
491        if let Some(color) = custom_color {
492            tab.set_custom_color(color);
493        }
494
495        // Copy custom icon from source
496        tab.custom_icon = custom_icon;
497
498        // Insert after source tab
499        self.tabs.insert(source_idx + 1, tab);
500
501        self.set_active_tab(Some(id));
502        Ok(Some(id))
503    }
504
505    /// Create and insert a new tab wrapping an existing Pane.
506    ///
507    /// The pane's PTY, scroll state, and session logger are preserved.
508    /// No new shell is spawned. Returns the new tab's ID.
509    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    /// Append an already-built tab, for tests that need a populated manager
533    /// without spawning shells (pair with [`Tab::new_stub`]).
534    #[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    /// Get index of active tab (0-based)
544    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    /// Clean up closed/dead tabs
550    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    /// Create a TabManager with mock tabs for testing (no PTY, no runtime)
587    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            // Create a minimal tab struct directly for testing
592            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        // Move tab 1 from index 0 to index 2
605        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        // Move tab 3 from index 2 to index 0
614        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        // Moving to same position is a no-op
623        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        // Target index 100 should clamp to last position (2)
632        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        // Non-existent tab ID returns false
641        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        // Move first tab to last position
650        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        // Move last tab to first position
659        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        // Create two tabs so removing one leaves the manager non-empty.
672        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        // Customize the target tab so we can assert round-trip fidelity.
680        {
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        // Snapshot preserved fields.
689        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        // Round-trip: remove then re-insert at index 1.
702        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        // A tab moved in from another window brings that window's id. Without
727        // advancing the counter, this manager would later allocate the same id
728        // for an unrelated tab, and anything holding the old id across frames —
729        // a queued automation confirmation, an "Always Allow" grant — would
730        // resolve to the wrong terminal.
731        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        // Window A holds {1,2,3}, window B holds {1,2}; the user moves A's tab
752        // 2 into B. Ids are allocated per window, so B is handed a second tab
753        // claiming id 2. `get_tab` is a linear scan, so without renumbering
754        // every lookup of id 2 would resolve to B's *original* tab and the
755        // moved tab would be unreachable — a badge, or an approved automation
756        // write, would land in the wrong terminal.
757        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        // The tab arriving from window A, carrying window A's id.
767        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        // The lookups are the point: id distinctness below is only the means.
774        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}