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    tabs: Vec<Tab>,
14    /// Currently active tab ID
15    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    /// Create a new tab and return its ID
31    ///
32    /// # Arguments
33    /// * `config` - Terminal configuration
34    /// * `runtime` - Tokio runtime for async operations
35    /// * `inherit_cwd_from_active` - Whether to inherit working directory from active tab
36    /// * `grid_size` - Optional (cols, rows) override for initial terminal size.
37    ///   When provided, these dimensions are used instead of config.cols/rows.
38    ///   This is important when the renderer has already calculated the correct
39    ///   grid size accounting for tab bar height.
40    pub fn new_tab(
41        &mut self,
42        config: &Config,
43        runtime: Arc<Runtime>,
44        inherit_cwd_from_active: bool,
45        grid_size: Option<(usize, usize)>,
46    ) -> Result<TabId> {
47        // Optionally inherit working directory from active tab
48        let working_dir = if inherit_cwd_from_active {
49            self.active_tab().and_then(|tab| tab.get_cwd())
50        } else {
51            None
52        };
53
54        let id = self.next_tab_id;
55        self.next_tab_id += 1;
56
57        // Tab number is based on current count, not unique ID
58        let tab_number = self.tabs.len() + 1;
59        let tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
60        self.tabs.push(tab);
61
62        // Always switch to the new tab
63        self.active_tab_id = Some(id);
64
65        log::info!("Created new tab {} (total: {})", id, self.tabs.len());
66
67        Ok(id)
68    }
69
70    /// Create a new tab with a specific working directory
71    ///
72    /// Used by arrangement restore to create tabs with saved CWDs.
73    pub fn new_tab_with_cwd(
74        &mut self,
75        config: &Config,
76        runtime: Arc<Runtime>,
77        working_dir: Option<String>,
78        grid_size: Option<(usize, usize)>,
79    ) -> Result<TabId> {
80        let id = self.next_tab_id;
81        self.next_tab_id += 1;
82
83        let tab_number = self.tabs.len() + 1;
84        let tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
85        self.tabs.push(tab);
86
87        // Always switch to the new tab
88        self.active_tab_id = Some(id);
89
90        log::info!(
91            "Created new tab {} with cwd (total: {})",
92            id,
93            self.tabs.len()
94        );
95
96        Ok(id)
97    }
98
99    /// Create a new tab from a profile configuration
100    ///
101    /// The profile specifies the working directory, command, and tab name.
102    ///
103    /// # Arguments
104    /// * `config` - Terminal configuration
105    /// * `runtime` - Tokio runtime for async operations
106    /// * `profile` - Profile configuration to use
107    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
108    pub fn new_tab_from_profile(
109        &mut self,
110        config: &Config,
111        runtime: Arc<Runtime>,
112        profile: &Profile,
113        grid_size: Option<(usize, usize)>,
114    ) -> Result<TabId> {
115        let id = self.next_tab_id;
116        self.next_tab_id += 1;
117
118        let tab = Tab::new_from_profile(id, config, runtime, profile, grid_size)?;
119        self.tabs.push(tab);
120
121        // Always switch to the new tab
122        self.active_tab_id = Some(id);
123
124        log::info!(
125            "Created new tab {} from profile '{}' (total: {})",
126            id,
127            profile.name,
128            self.tabs.len()
129        );
130
131        Ok(id)
132    }
133
134    /// Close a tab by ID
135    /// Returns true if this was the last tab (window should close)
136    pub fn close_tab(&mut self, id: TabId) -> bool {
137        let index = self.tabs.iter().position(|t| t.id == id);
138
139        if let Some(idx) = index {
140            log::info!("Closing tab {} (index {})", id, idx);
141
142            // Remove the tab
143            self.tabs.remove(idx);
144
145            // If we closed the active tab, switch to another
146            if self.active_tab_id == Some(id) {
147                self.active_tab_id = if self.tabs.is_empty() {
148                    None
149                } else {
150                    // Prefer the tab at the same index (or previous if at end)
151                    let new_idx = idx.min(self.tabs.len().saturating_sub(1));
152                    Some(self.tabs[new_idx].id)
153                };
154            }
155
156            // Renumber tabs that still have default titles
157            self.renumber_default_tabs();
158        }
159
160        self.tabs.is_empty()
161    }
162
163    /// Remove a tab by ID without dropping it, returning the live Tab.
164    ///
165    /// Handles active tab switching and renumbering just like `close_tab`,
166    /// but returns the `Tab` so the caller can keep it alive.
167    ///
168    /// Returns `Some((tab, is_empty))` if the tab was found, `None` otherwise.
169    pub fn remove_tab(&mut self, id: TabId) -> Option<(Tab, bool)> {
170        let idx = self.tabs.iter().position(|t| t.id == id)?;
171
172        log::info!("Removing tab {} (index {}) without dropping", id, idx);
173
174        let tab = self.tabs.remove(idx);
175
176        // If we removed the active tab, switch to another
177        if self.active_tab_id == Some(id) {
178            self.active_tab_id = if self.tabs.is_empty() {
179                None
180            } else {
181                let new_idx = idx.min(self.tabs.len().saturating_sub(1));
182                Some(self.tabs[new_idx].id)
183            };
184        }
185
186        self.renumber_default_tabs();
187        let is_empty = self.tabs.is_empty();
188        Some((tab, is_empty))
189    }
190
191    /// Insert a live Tab at a specific index and make it active.
192    ///
193    /// The index is clamped to `0..=self.tabs.len()`.
194    pub fn insert_tab_at(&mut self, tab: Tab, index: usize) {
195        let clamped = index.min(self.tabs.len());
196        let id = tab.id;
197        self.tabs.insert(clamped, tab);
198        self.active_tab_id = Some(id);
199        self.renumber_default_tabs();
200        log::info!(
201            "Inserted tab {} at index {} (total: {})",
202            id,
203            clamped,
204            self.tabs.len()
205        );
206    }
207
208    /// Renumber tabs that have default titles based on their current position
209    fn renumber_default_tabs(&mut self) {
210        for (idx, tab) in self.tabs.iter_mut().enumerate() {
211            tab.set_default_title(idx + 1);
212        }
213    }
214
215    /// Get a reference to the active tab
216    pub fn active_tab(&self) -> Option<&Tab> {
217        self.active_tab_id
218            .and_then(|id| self.tabs.iter().find(|t| t.id == id))
219    }
220
221    /// Get a mutable reference to the active tab
222    pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
223        let active_id = self.active_tab_id;
224        active_id.and_then(move |id| self.tabs.iter_mut().find(|t| t.id == id))
225    }
226
227    /// Switch to a tab by ID
228    pub fn switch_to(&mut self, id: TabId) {
229        if self.tabs.iter().any(|t| t.id == id) {
230            // Clear activity indicator when switching to tab
231            if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == id) {
232                tab.has_activity = false;
233            }
234            self.active_tab_id = Some(id);
235            log::debug!("Switched to tab {}", id);
236        }
237    }
238
239    /// Switch to the next tab (wraps around)
240    pub fn next_tab(&mut self) {
241        if self.tabs.len() <= 1 {
242            return;
243        }
244
245        if let Some(active_id) = self.active_tab_id {
246            let current_idx = self
247                .tabs
248                .iter()
249                .position(|t| t.id == active_id)
250                .unwrap_or(0);
251            let next_idx = (current_idx + 1) % self.tabs.len();
252            let next_id = self.tabs[next_idx].id;
253            self.switch_to(next_id);
254        }
255    }
256
257    /// Switch to the previous tab (wraps around)
258    pub fn prev_tab(&mut self) {
259        if self.tabs.len() <= 1 {
260            return;
261        }
262
263        if let Some(active_id) = self.active_tab_id {
264            let current_idx = self
265                .tabs
266                .iter()
267                .position(|t| t.id == active_id)
268                .unwrap_or(0);
269            let prev_idx = if current_idx == 0 {
270                self.tabs.len() - 1
271            } else {
272                current_idx - 1
273            };
274            let prev_id = self.tabs[prev_idx].id;
275            self.switch_to(prev_id);
276        }
277    }
278
279    /// Switch to tab by index (1-based for Cmd+1-9)
280    pub fn switch_to_index(&mut self, index: usize) {
281        if index > 0 && index <= self.tabs.len() {
282            let id = self.tabs[index - 1].id;
283            self.switch_to(id);
284        }
285    }
286
287    /// Move a tab left or right
288    /// direction: -1 for left, 1 for right
289    pub fn move_tab(&mut self, id: TabId, direction: i32) {
290        if let Some(current_idx) = self.tabs.iter().position(|t| t.id == id) {
291            let new_idx = if direction < 0 {
292                if current_idx == 0 {
293                    self.tabs.len() - 1
294                } else {
295                    current_idx - 1
296                }
297            } else if current_idx >= self.tabs.len() - 1 {
298                0
299            } else {
300                current_idx + 1
301            };
302
303            if new_idx != current_idx {
304                let tab = self.tabs.remove(current_idx);
305                self.tabs.insert(new_idx, tab);
306                log::debug!("Moved tab {} from index {} to {}", id, current_idx, new_idx);
307                // Renumber tabs that still have default titles
308                self.renumber_default_tabs();
309            }
310        }
311    }
312
313    /// Move a tab to a specific index (used by drag-and-drop reordering)
314    /// Returns true if the tab was actually moved, false if not found or already at target
315    pub fn move_tab_to_index(&mut self, id: TabId, target_index: usize) -> bool {
316        let current_idx = match self.tabs.iter().position(|t| t.id == id) {
317            Some(idx) => idx,
318            None => return false,
319        };
320
321        let clamped_target = target_index.min(self.tabs.len().saturating_sub(1));
322        if clamped_target == current_idx {
323            return false;
324        }
325
326        let tab = self.tabs.remove(current_idx);
327        self.tabs.insert(clamped_target, tab);
328        log::debug!(
329            "Moved tab {} from index {} to {}",
330            id,
331            current_idx,
332            clamped_target
333        );
334        self.renumber_default_tabs();
335        true
336    }
337
338    /// Move active tab left
339    pub fn move_active_tab_left(&mut self) {
340        if let Some(id) = self.active_tab_id {
341            self.move_tab(id, -1);
342        }
343    }
344
345    /// Move active tab right
346    pub fn move_active_tab_right(&mut self) {
347        if let Some(id) = self.active_tab_id {
348            self.move_tab(id, 1);
349        }
350    }
351
352    /// Get the number of tabs
353    pub fn tab_count(&self) -> usize {
354        self.tabs.len()
355    }
356
357    /// Check if there are multiple tabs
358    pub fn has_multiple_tabs(&self) -> bool {
359        self.tabs.len() > 1
360    }
361
362    /// Get the active tab ID
363    pub fn active_tab_id(&self) -> Option<TabId> {
364        self.active_tab_id
365    }
366
367    /// Get all tabs as a slice
368    pub fn tabs(&self) -> &[Tab] {
369        &self.tabs
370    }
371
372    /// Get all tabs as mutable slice
373    pub fn tabs_mut(&mut self) -> &mut [Tab] {
374        &mut self.tabs
375    }
376
377    /// Drain all tabs from the manager, returning them without dropping
378    ///
379    /// This is used during fast shutdown to extract tabs so their terminals
380    /// can be dropped on background threads in parallel.
381    pub fn drain_tabs(&mut self) -> Vec<Tab> {
382        self.active_tab_id = None;
383        std::mem::take(&mut self.tabs)
384    }
385
386    /// Get a tab by ID
387    #[allow(dead_code)]
388    pub fn get_tab(&self, id: TabId) -> Option<&Tab> {
389        self.tabs.iter().find(|t| t.id == id)
390    }
391
392    /// Get a mutable reference to a tab by ID
393    #[allow(dead_code)]
394    pub fn get_tab_mut(&mut self, id: TabId) -> Option<&mut Tab> {
395        self.tabs.iter_mut().find(|t| t.id == id)
396    }
397
398    /// Mark non-active tabs as having activity when they receive output
399    #[allow(dead_code)]
400    pub fn mark_activity(&mut self, tab_id: TabId) {
401        if Some(tab_id) != self.active_tab_id
402            && let Some(tab) = self.get_tab_mut(tab_id)
403        {
404            tab.has_activity = true;
405        }
406    }
407
408    /// Update titles for all tabs
409    pub fn update_all_titles(&mut self) {
410        for tab in &mut self.tabs {
411            tab.update_title();
412        }
413    }
414
415    /// Duplicate the active tab (creates new tab with same working directory and color)
416    ///
417    /// # Arguments
418    /// * `config` - Terminal configuration
419    /// * `runtime` - Tokio runtime for async operations
420    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
421    pub fn duplicate_active_tab(
422        &mut self,
423        config: &Config,
424        runtime: Arc<Runtime>,
425        grid_size: Option<(usize, usize)>,
426    ) -> Result<Option<TabId>> {
427        if let Some(tab_id) = self.active_tab_id {
428            self.duplicate_tab_by_id(tab_id, config, runtime, grid_size)
429        } else {
430            Ok(None)
431        }
432    }
433
434    /// Duplicate a specific tab by ID (creates new tab with same working directory and color)
435    ///
436    /// # Arguments
437    /// * `source_tab_id` - The ID of the tab to duplicate
438    /// * `config` - Terminal configuration
439    /// * `runtime` - Tokio runtime for async operations
440    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
441    pub fn duplicate_tab_by_id(
442        &mut self,
443        source_tab_id: TabId,
444        config: &Config,
445        runtime: Arc<Runtime>,
446        grid_size: Option<(usize, usize)>,
447    ) -> Result<Option<TabId>> {
448        // Gather properties from source tab
449        let source_idx = self.tabs.iter().position(|t| t.id == source_tab_id);
450        let source_idx = match source_idx {
451            Some(idx) => idx,
452            None => return Ok(None),
453        };
454        let working_dir = self.tabs[source_idx].get_cwd();
455        let custom_color = self.tabs[source_idx].custom_color;
456
457        let id = self.next_tab_id;
458        self.next_tab_id += 1;
459
460        // Tab number is based on current count, not unique ID
461        let tab_number = self.tabs.len() + 1;
462        let mut tab = Tab::new(id, tab_number, config, runtime, working_dir, grid_size)?;
463
464        // Copy tab color from source
465        if let Some(color) = custom_color {
466            tab.set_custom_color(color);
467        }
468
469        // Insert after source tab
470        self.tabs.insert(source_idx + 1, tab);
471
472        self.active_tab_id = Some(id);
473        Ok(Some(id))
474    }
475
476    /// Get index of active tab (0-based)
477    #[allow(dead_code)]
478    pub fn active_tab_index(&self) -> Option<usize> {
479        self.active_tab_id
480            .and_then(|id| self.tabs.iter().position(|t| t.id == id))
481    }
482
483    /// Clean up closed/dead tabs
484    #[allow(dead_code)]
485    pub fn cleanup_dead_tabs(&mut self) {
486        let dead_tabs: Vec<TabId> = self
487            .tabs
488            .iter()
489            .filter(|t| !t.is_running())
490            .map(|t| t.id)
491            .collect();
492
493        for id in dead_tabs {
494            log::info!("Cleaning up dead tab {}", id);
495            self.close_tab(id);
496        }
497    }
498}
499
500impl Default for TabManager {
501    fn default() -> Self {
502        Self::new()
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    /// Create a TabManager with mock tabs for testing (no PTY, no runtime)
511    fn manager_with_ids(ids: &[TabId]) -> TabManager {
512        let mut mgr = TabManager::new();
513        for &id in ids {
514            let tab_number = mgr.tabs.len() + 1;
515            // Create a minimal tab struct directly for testing
516            mgr.tabs.push(Tab::new_stub(id, tab_number));
517            mgr.next_tab_id = mgr.next_tab_id.max(id + 1);
518        }
519        if let Some(last) = ids.last() {
520            mgr.active_tab_id = Some(*last);
521        }
522        mgr
523    }
524
525    #[test]
526    fn move_tab_to_index_forward() {
527        let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
528        // Move tab 1 from index 0 to index 2
529        assert!(mgr.move_tab_to_index(1, 2));
530        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
531        assert_eq!(ids, vec![2, 3, 1, 4]);
532    }
533
534    #[test]
535    fn move_tab_to_index_backward() {
536        let mut mgr = manager_with_ids(&[1, 2, 3, 4]);
537        // Move tab 3 from index 2 to index 0
538        assert!(mgr.move_tab_to_index(3, 0));
539        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
540        assert_eq!(ids, vec![3, 1, 2, 4]);
541    }
542
543    #[test]
544    fn move_tab_to_index_same_position() {
545        let mut mgr = manager_with_ids(&[1, 2, 3]);
546        // Moving to same position is a no-op
547        assert!(!mgr.move_tab_to_index(2, 1));
548        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
549        assert_eq!(ids, vec![1, 2, 3]);
550    }
551
552    #[test]
553    fn move_tab_to_index_out_of_bounds_clamped() {
554        let mut mgr = manager_with_ids(&[1, 2, 3]);
555        // Target index 100 should clamp to last position (2)
556        assert!(mgr.move_tab_to_index(1, 100));
557        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
558        assert_eq!(ids, vec![2, 3, 1]);
559    }
560
561    #[test]
562    fn move_tab_to_index_invalid_id() {
563        let mut mgr = manager_with_ids(&[1, 2, 3]);
564        // Non-existent tab ID returns false
565        assert!(!mgr.move_tab_to_index(99, 0));
566        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
567        assert_eq!(ids, vec![1, 2, 3]);
568    }
569
570    #[test]
571    fn move_tab_to_index_to_end() {
572        let mut mgr = manager_with_ids(&[1, 2, 3]);
573        // Move first tab to last position
574        assert!(mgr.move_tab_to_index(1, 2));
575        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
576        assert_eq!(ids, vec![2, 3, 1]);
577    }
578
579    #[test]
580    fn move_tab_to_index_to_start() {
581        let mut mgr = manager_with_ids(&[1, 2, 3]);
582        // Move last tab to first position
583        assert!(mgr.move_tab_to_index(3, 0));
584        let ids: Vec<TabId> = mgr.tabs.iter().map(|t| t.id).collect();
585        assert_eq!(ids, vec![3, 1, 2]);
586    }
587}