Skip to main content

par_term/app/
tab_ops.rs

1//! Tab management operations for WindowState.
2//!
3//! This module contains methods for creating, closing, and switching between tabs.
4
5use std::sync::Arc;
6
7use crate::profile::{ProfileId, ProfileManager, storage as profile_storage};
8
9use super::window_state::WindowState;
10
11/// Metadata captured when a tab is closed, used for session undo (reopen closed tab).
12pub(crate) struct ClosedTabInfo {
13    pub cwd: Option<String>,
14    pub title: String,
15    pub has_default_title: bool,
16    pub index: usize,
17    pub closed_at: std::time::Instant,
18    pub pane_layout: Option<crate::session::SessionPaneNode>,
19    pub custom_color: Option<[u8; 3]>,
20    /// When `session_undo_preserve_shell` is enabled, the live Tab is kept here
21    /// instead of being dropped. Dropping this ClosedTabInfo will drop the Tab,
22    /// which kills the PTY.
23    pub hidden_tab: Option<crate::tab::Tab>,
24}
25
26impl WindowState {
27    /// Create a new tab, or show profile picker if configured and profiles exist
28    pub fn new_tab_or_show_profiles(&mut self) {
29        if self.config.new_tab_shortcut_shows_profiles && !self.profile_manager.is_empty() {
30            self.tab_bar_ui.show_new_tab_profile_menu = !self.tab_bar_ui.show_new_tab_profile_menu;
31            if let Some(window) = &self.window {
32                window.request_redraw();
33            }
34            log::info!("Toggled new-tab profile menu via shortcut");
35        } else {
36            self.new_tab();
37            log::info!("New tab created");
38        }
39    }
40
41    /// Create a new tab
42    pub fn new_tab(&mut self) {
43        // Check max tabs limit
44        if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
45            log::warn!(
46                "Cannot create new tab: max_tabs limit ({}) reached",
47                self.config.max_tabs
48            );
49            return;
50        }
51
52        // Remember tab count before creating new tab to detect tab bar visibility change
53        let old_tab_count = self.tab_manager.tab_count();
54
55        // Get current grid size from renderer to pass to new tab
56        // This accounts for possible tab bar height changes
57        let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
58
59        match self.tab_manager.new_tab(
60            &self.config,
61            Arc::clone(&self.runtime),
62            self.config.tab_inherit_cwd,
63            grid_size,
64        ) {
65            Ok(tab_id) => {
66                // Check if tab bar visibility changed (e.g., from 1 to 2 tabs with WhenMultiple mode)
67                let new_tab_count = self.tab_manager.tab_count();
68                let old_tab_bar_height = self.tab_bar_ui.get_height(old_tab_count, &self.config);
69                let new_tab_bar_height = self.tab_bar_ui.get_height(new_tab_count, &self.config);
70                let old_tab_bar_width = self.tab_bar_ui.get_width(old_tab_count, &self.config);
71                let new_tab_bar_width = self.tab_bar_ui.get_width(new_tab_count, &self.config);
72
73                // If tab bar dimensions changed, update content offsets and resize ALL existing tabs
74                if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
75                    || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
76                    && let Some(renderer) = &mut self.renderer
77                    && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
78                        self.config.tab_bar_position,
79                        renderer,
80                        new_tab_bar_height,
81                        new_tab_bar_width,
82                    )
83                {
84                    let cell_width = renderer.cell_width();
85                    let cell_height = renderer.cell_height();
86                    let width_px = (new_cols as f32 * cell_width) as usize;
87                    let height_px = (new_rows as f32 * cell_height) as usize;
88
89                    // Resize all EXISTING tabs (not including the new one yet)
90                    for tab in self.tab_manager.tabs_mut() {
91                        if tab.id != tab_id {
92                            if let Ok(mut term) = tab.terminal.try_lock() {
93                                term.set_cell_dimensions(cell_width as u32, cell_height as u32);
94                                let _ = term
95                                    .resize_with_pixels(new_cols, new_rows, width_px, height_px);
96                            }
97                            tab.cache.cells = None;
98                        }
99                    }
100                    log::info!(
101                        "Tab bar appeared (position={:?}), resized existing tabs to {}x{}",
102                        self.config.tab_bar_position,
103                        new_cols,
104                        new_rows
105                    );
106                }
107
108                // Start refresh task for the new tab and resize to match window
109                if let Some(window) = &self.window
110                    && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
111                {
112                    tab.start_refresh_task(
113                        Arc::clone(&self.runtime),
114                        Arc::clone(window),
115                        self.config.max_fps,
116                    );
117
118                    // Resize terminal to match current renderer dimensions
119                    // (which now has the correct content offset)
120                    if let Some(renderer) = &self.renderer
121                        && let Ok(mut term) = tab.terminal.try_lock()
122                    {
123                        let (cols, rows) = renderer.grid_size();
124                        let cell_width = renderer.cell_width();
125                        let cell_height = renderer.cell_height();
126                        let width_px = (cols as f32 * cell_width) as usize;
127                        let height_px = (rows as f32 * cell_height) as usize;
128
129                        // Set cell dimensions
130                        term.set_cell_dimensions(cell_width as u32, cell_height as u32);
131
132                        // Resize terminal to match window size
133                        let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
134                        log::info!(
135                            "Resized new tab {} terminal to {}x{} ({}x{} px)",
136                            tab_id,
137                            cols,
138                            rows,
139                            width_px,
140                            height_px
141                        );
142                    }
143                }
144
145                // Play new tab alert sound if configured
146                self.play_alert_sound(crate::config::AlertEvent::NewTab);
147
148                self.needs_redraw = true;
149                self.request_redraw();
150            }
151            Err(e) => {
152                log::error!("Failed to create new tab: {}", e);
153            }
154        }
155    }
156
157    /// Close the current tab
158    /// Returns true if the window should close (last tab was closed)
159    pub fn close_current_tab(&mut self) -> bool {
160        // Check if we need to show confirmation for running jobs
161        if self.config.confirm_close_running_jobs
162            && let Some(command_name) = self.check_current_tab_running_job()
163            && let Some(tab) = self.tab_manager.active_tab()
164        {
165            let tab_id = tab.id;
166            let tab_title = if tab.title.is_empty() {
167                "Terminal".to_string()
168            } else {
169                tab.title.clone()
170            };
171            self.close_confirmation_ui
172                .show_for_tab(tab_id, &tab_title, &command_name);
173            self.needs_redraw = true;
174            self.request_redraw();
175            return false; // Don't close yet, waiting for confirmation
176        }
177
178        self.close_current_tab_immediately()
179    }
180
181    /// Close the current tab immediately without confirmation
182    /// Returns true if the window should close (last tab was closed)
183    pub fn close_current_tab_immediately(&mut self) -> bool {
184        if let Some(tab_id) = self.tab_manager.active_tab_id() {
185            // Remember tab count before closing to detect tab bar visibility change
186            let old_tab_count = self.tab_manager.tab_count();
187            let old_tab_bar_height = self.tab_bar_ui.get_height(old_tab_count, &self.config);
188            let old_tab_bar_width = self.tab_bar_ui.get_width(old_tab_count, &self.config);
189
190            let is_last_tab = self.tab_manager.tab_count() <= 1;
191            let preserve_shell = self.config.session_undo_preserve_shell
192                && self.config.session_undo_timeout_secs > 0;
193
194            // Capture closed tab metadata for session undo (before destroying the tab)
195            let is_last = if preserve_shell {
196                // Preserve mode: extract the live Tab and store it in ClosedTabInfo
197                if let Some(tab) = self.tab_manager.get_tab(tab_id) {
198                    let cwd = tab.get_cwd();
199                    let title = tab.title.clone();
200                    let has_default_title = tab.has_default_title;
201                    let custom_color = tab.custom_color;
202                    let index = self.tab_manager.active_tab_index().unwrap_or(0);
203
204                    if let Some((mut hidden_tab, is_empty)) = self.tab_manager.remove_tab(tab_id) {
205                        // Stop refresh task to prevent invisible redraws
206                        hidden_tab.stop_refresh_task();
207
208                        let info = ClosedTabInfo {
209                            cwd,
210                            title,
211                            has_default_title,
212                            index,
213                            closed_at: std::time::Instant::now(),
214                            pane_layout: None, // Preserved inside the hidden Tab itself
215                            custom_color,
216                            hidden_tab: Some(hidden_tab),
217                        };
218                        self.closed_tabs.push_front(info);
219                        while self.closed_tabs.len() > self.config.session_undo_max_entries {
220                            self.closed_tabs.pop_back();
221                        }
222                        is_empty
223                    } else {
224                        // Fallback: tab disappeared between get and remove
225                        self.tab_manager.close_tab(tab_id)
226                    }
227                } else {
228                    self.tab_manager.close_tab(tab_id)
229                }
230            } else {
231                // Standard mode: capture metadata, then close (drops the Tab)
232                if self.config.session_undo_timeout_secs > 0
233                    && let Some(tab) = self.tab_manager.get_tab(tab_id)
234                {
235                    let info = ClosedTabInfo {
236                        cwd: tab.get_cwd(),
237                        title: tab.title.clone(),
238                        has_default_title: tab.has_default_title,
239                        index: self.tab_manager.active_tab_index().unwrap_or(0),
240                        closed_at: std::time::Instant::now(),
241                        pane_layout: tab
242                            .pane_manager
243                            .as_ref()
244                            .and_then(|pm| pm.root())
245                            .map(crate::session::capture::capture_pane_node),
246                        custom_color: tab.custom_color,
247                        hidden_tab: None,
248                    };
249                    self.closed_tabs.push_front(info);
250                    while self.closed_tabs.len() > self.config.session_undo_max_entries {
251                        self.closed_tabs.pop_back();
252                    }
253                }
254
255                self.tab_manager.close_tab(tab_id)
256            };
257
258            // Play tab close alert sound if configured
259            self.play_alert_sound(crate::config::AlertEvent::TabClose);
260
261            // Show undo toast (only if not the last tab — window is closing)
262            if !is_last_tab {
263                let key_hint = self
264                    .config
265                    .keybindings
266                    .iter()
267                    .find(|kb| kb.action == "reopen_closed_tab")
268                    .map(|kb| kb.key.clone())
269                    .unwrap_or_else(|| "keybinding".to_string());
270                let timeout = self.config.session_undo_timeout_secs;
271                if timeout > 0 {
272                    self.show_toast(format!(
273                        "Tab closed. Press {} to undo ({timeout}s)",
274                        key_hint
275                    ));
276                }
277            }
278
279            // Check if tab bar visibility changed (e.g., from 2 to 1 tabs with WhenMultiple mode)
280            if !is_last {
281                let new_tab_count = self.tab_manager.tab_count();
282                let new_tab_bar_height = self.tab_bar_ui.get_height(new_tab_count, &self.config);
283                let new_tab_bar_width = self.tab_bar_ui.get_width(new_tab_count, &self.config);
284
285                if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
286                    || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
287                    && let Some(renderer) = &mut self.renderer
288                    && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
289                        self.config.tab_bar_position,
290                        renderer,
291                        new_tab_bar_height,
292                        new_tab_bar_width,
293                    )
294                {
295                    let cell_width = renderer.cell_width();
296                    let cell_height = renderer.cell_height();
297                    let width_px = (new_cols as f32 * cell_width) as usize;
298                    let height_px = (new_rows as f32 * cell_height) as usize;
299
300                    // Resize all remaining tabs
301                    for tab in self.tab_manager.tabs_mut() {
302                        if let Ok(mut term) = tab.terminal.try_lock() {
303                            term.set_cell_dimensions(cell_width as u32, cell_height as u32);
304                            let _ =
305                                term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
306                        }
307                        tab.cache.cells = None;
308                    }
309                    log::info!(
310                        "Tab bar visibility changed (position={:?}), resized remaining tabs to {}x{}",
311                        self.config.tab_bar_position,
312                        new_cols,
313                        new_rows
314                    );
315                }
316            }
317
318            self.needs_redraw = true;
319            self.request_redraw();
320            is_last
321        } else {
322            true // No tabs, window should close
323        }
324    }
325
326    /// Reopen the most recently closed tab at its original position
327    pub fn reopen_closed_tab(&mut self) {
328        // Prune expired entries
329        if self.config.session_undo_timeout_secs > 0 {
330            let timeout =
331                std::time::Duration::from_secs(self.config.session_undo_timeout_secs as u64);
332            let now = std::time::Instant::now();
333            self.closed_tabs
334                .retain(|info| now.duration_since(info.closed_at) < timeout);
335        }
336
337        let info = match self.closed_tabs.pop_front() {
338            Some(info) => info,
339            None => {
340                self.show_toast("No recently closed tabs");
341                return;
342            }
343        };
344
345        // Check max tabs limit
346        if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
347            log::warn!(
348                "Cannot reopen tab: max_tabs limit ({}) reached",
349                self.config.max_tabs
350            );
351            self.show_toast("Cannot reopen tab: max tabs limit reached");
352            // Put the info back so the user can try again after closing another tab
353            self.closed_tabs.push_front(info);
354            return;
355        }
356
357        // Remember tab count before restoring to detect tab bar visibility change
358        let old_tab_count = self.tab_manager.tab_count();
359
360        if let Some(hidden_tab) = info.hidden_tab {
361            // Preserved shell: re-insert the live Tab
362            let tab_id = hidden_tab.id;
363            self.tab_manager.insert_tab_at(hidden_tab, info.index);
364
365            // Handle tab bar visibility change
366            self.handle_tab_bar_resize_after_add(old_tab_count, tab_id);
367
368            // Restart refresh task and resize terminal to match current window
369            if let Some(window) = &self.window
370                && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
371            {
372                tab.start_refresh_task(
373                    Arc::clone(&self.runtime),
374                    Arc::clone(window),
375                    self.config.max_fps,
376                );
377
378                // Invalidate cell cache so content is re-rendered
379                tab.cache.cells = None;
380
381                if let Some(renderer) = &self.renderer
382                    && let Ok(mut term) = tab.terminal.try_lock()
383                {
384                    let (cols, rows) = renderer.grid_size();
385                    let cell_width = renderer.cell_width();
386                    let cell_height = renderer.cell_height();
387                    let width_px = (cols as f32 * cell_width) as usize;
388                    let height_px = (rows as f32 * cell_height) as usize;
389                    term.set_cell_dimensions(cell_width as u32, cell_height as u32);
390                    let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
391                }
392            }
393
394            self.play_alert_sound(crate::config::AlertEvent::NewTab);
395            self.show_toast("Tab restored (session preserved)");
396            self.needs_redraw = true;
397            self.request_redraw();
398        } else {
399            // Metadata-only: create a new tab from CWD (existing behavior)
400            let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
401
402            match self.tab_manager.new_tab_with_cwd(
403                &self.config,
404                Arc::clone(&self.runtime),
405                info.cwd,
406                grid_size,
407            ) {
408                Ok(tab_id) => {
409                    // Handle tab bar visibility change
410                    self.handle_tab_bar_resize_after_add(old_tab_count, tab_id);
411
412                    // Restore title and custom color
413                    if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
414                        if !info.has_default_title {
415                            tab.title = info.title;
416                            tab.has_default_title = false;
417                        }
418                        tab.custom_color = info.custom_color;
419                    }
420
421                    // Move tab to its original position
422                    self.tab_manager.move_tab_to_index(tab_id, info.index);
423
424                    // Start refresh task and resize terminal
425                    if let Some(window) = &self.window
426                        && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
427                    {
428                        tab.start_refresh_task(
429                            Arc::clone(&self.runtime),
430                            Arc::clone(window),
431                            self.config.max_fps,
432                        );
433
434                        if let Some(renderer) = &self.renderer
435                            && let Ok(mut term) = tab.terminal.try_lock()
436                        {
437                            let (cols, rows) = renderer.grid_size();
438                            let cell_width = renderer.cell_width();
439                            let cell_height = renderer.cell_height();
440                            let width_px = (cols as f32 * cell_width) as usize;
441                            let height_px = (rows as f32 * cell_height) as usize;
442                            term.set_cell_dimensions(cell_width as u32, cell_height as u32);
443                            let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
444                        }
445                    }
446
447                    // Restore pane layout if present
448                    if let Some(pane_layout) = &info.pane_layout
449                        && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
450                    {
451                        tab.restore_pane_layout(
452                            pane_layout,
453                            &self.config,
454                            Arc::clone(&self.runtime),
455                        );
456                    }
457
458                    self.play_alert_sound(crate::config::AlertEvent::NewTab);
459                    self.show_toast("Tab restored");
460                    self.needs_redraw = true;
461                    self.request_redraw();
462                }
463                Err(e) => {
464                    log::error!("Failed to reopen closed tab: {}", e);
465                    self.show_toast("Failed to reopen tab");
466                }
467            }
468        }
469    }
470
471    /// Handle tab bar visibility change after adding a tab.
472    /// Resizes existing tabs if the tab bar appearance changed (e.g., from 1 to 2 tabs).
473    fn handle_tab_bar_resize_after_add(
474        &mut self,
475        old_tab_count: usize,
476        new_tab_id: crate::tab::TabId,
477    ) {
478        let new_tab_count = self.tab_manager.tab_count();
479        let old_tab_bar_height = self.tab_bar_ui.get_height(old_tab_count, &self.config);
480        let new_tab_bar_height = self.tab_bar_ui.get_height(new_tab_count, &self.config);
481        let old_tab_bar_width = self.tab_bar_ui.get_width(old_tab_count, &self.config);
482        let new_tab_bar_width = self.tab_bar_ui.get_width(new_tab_count, &self.config);
483
484        if ((new_tab_bar_height - old_tab_bar_height).abs() > 0.1
485            || (new_tab_bar_width - old_tab_bar_width).abs() > 0.1)
486            && let Some(renderer) = &mut self.renderer
487            && let Some((new_cols, new_rows)) = Self::apply_tab_bar_offsets_for_position(
488                self.config.tab_bar_position,
489                renderer,
490                new_tab_bar_height,
491                new_tab_bar_width,
492            )
493        {
494            let cell_width = renderer.cell_width();
495            let cell_height = renderer.cell_height();
496            let width_px = (new_cols as f32 * cell_width) as usize;
497            let height_px = (new_rows as f32 * cell_height) as usize;
498
499            for tab in self.tab_manager.tabs_mut() {
500                if tab.id != new_tab_id {
501                    if let Ok(mut term) = tab.terminal.try_lock() {
502                        term.set_cell_dimensions(cell_width as u32, cell_height as u32);
503                        let _ = term.resize_with_pixels(new_cols, new_rows, width_px, height_px);
504                    }
505                    tab.cache.cells = None;
506                }
507            }
508        }
509    }
510
511    /// Switch to next tab
512    pub fn next_tab(&mut self) {
513        self.copy_mode.exit();
514        self.tab_manager.next_tab();
515        self.clear_and_invalidate();
516    }
517
518    /// Switch to previous tab
519    pub fn prev_tab(&mut self) {
520        self.copy_mode.exit();
521        self.tab_manager.prev_tab();
522        self.clear_and_invalidate();
523    }
524
525    /// Switch to tab by index (1-based)
526    pub fn switch_to_tab_index(&mut self, index: usize) {
527        self.copy_mode.exit();
528        self.tab_manager.switch_to_index(index);
529        self.clear_and_invalidate();
530    }
531
532    /// Move current tab left
533    pub fn move_tab_left(&mut self) {
534        self.tab_manager.move_active_tab_left();
535        self.needs_redraw = true;
536        self.request_redraw();
537    }
538
539    /// Move current tab right
540    pub fn move_tab_right(&mut self) {
541        self.tab_manager.move_active_tab_right();
542        self.needs_redraw = true;
543        self.request_redraw();
544    }
545
546    /// Duplicate current tab
547    pub fn duplicate_tab(&mut self) {
548        // Get current grid size from renderer
549        let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
550
551        match self.tab_manager.duplicate_active_tab(
552            &self.config,
553            Arc::clone(&self.runtime),
554            grid_size,
555        ) {
556            Ok(Some(tab_id)) => {
557                // Start refresh task for the new tab
558                if let Some(window) = &self.window
559                    && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
560                {
561                    tab.start_refresh_task(
562                        Arc::clone(&self.runtime),
563                        Arc::clone(window),
564                        self.config.max_fps,
565                    );
566                }
567                self.needs_redraw = true;
568                self.request_redraw();
569            }
570            Ok(None) => {
571                log::debug!("No active tab to duplicate");
572            }
573            Err(e) => {
574                log::error!("Failed to duplicate tab: {}", e);
575            }
576        }
577    }
578
579    /// Duplicate a specific tab by ID
580    pub fn duplicate_tab_by_id(&mut self, source_tab_id: crate::tab::TabId) {
581        let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
582
583        match self.tab_manager.duplicate_tab_by_id(
584            source_tab_id,
585            &self.config,
586            Arc::clone(&self.runtime),
587            grid_size,
588        ) {
589            Ok(Some(tab_id)) => {
590                if let Some(window) = &self.window
591                    && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
592                {
593                    tab.start_refresh_task(
594                        Arc::clone(&self.runtime),
595                        Arc::clone(window),
596                        self.config.max_fps,
597                    );
598                }
599                self.needs_redraw = true;
600                self.request_redraw();
601            }
602            Ok(None) => {
603                log::debug!("Tab {} not found for duplication", source_tab_id);
604            }
605            Err(e) => {
606                log::error!("Failed to duplicate tab {}: {}", source_tab_id, e);
607            }
608        }
609    }
610
611    /// Check if there are multiple tabs
612    pub fn has_multiple_tabs(&self) -> bool {
613        self.tab_manager.has_multiple_tabs()
614    }
615
616    /// Get the active tab's terminal
617    #[allow(dead_code)]
618    pub fn active_terminal(
619        &self,
620    ) -> Option<&Arc<tokio::sync::Mutex<crate::terminal::TerminalManager>>> {
621        self.tab_manager.active_tab().map(|tab| &tab.terminal)
622    }
623
624    // ========================================================================
625    // Split Pane Operations
626    // ========================================================================
627
628    /// Split the current pane horizontally (panes stacked top/bottom)
629    pub fn split_pane_horizontal(&mut self) {
630        // In tmux mode, send split command to tmux instead
631        if self.is_tmux_connected() && self.split_pane_via_tmux(false) {
632            crate::debug_info!("TMUX", "Sent horizontal split command to tmux");
633            return;
634        }
635        // Fall through to local split if tmux command failed or not connected
636
637        // Calculate status bar height for proper content area
638        let is_tmux_connected = self.is_tmux_connected();
639        let status_bar_height =
640            crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
641        let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
642
643        // Get bounds info from renderer for proper pane sizing
644        let bounds_info = self.renderer.as_ref().map(|r| {
645            let size = r.size();
646            let padding = r.window_padding();
647            let content_offset_y = r.content_offset_y();
648            let cell_width = r.cell_width();
649            let cell_height = r.cell_height();
650            let scale = r.scale_factor();
651            (
652                size,
653                padding,
654                content_offset_y,
655                cell_width,
656                cell_height,
657                scale,
658            )
659        });
660
661        let dpi_scale = bounds_info.map(|b| b.5).unwrap_or(1.0);
662
663        if let Some(tab) = self.tab_manager.active_tab_mut() {
664            // Set pane bounds before split if we have renderer info
665            if let Some((size, padding, content_offset_y, cell_width, cell_height, scale)) =
666                bounds_info
667            {
668                // After split there will be multiple panes, so use 0 padding if configured
669                let effective_padding = if self.config.hide_window_padding_on_split {
670                    0.0
671                } else {
672                    padding
673                };
674                // Scale status_bar_height from logical to physical pixels
675                let physical_status_bar_height =
676                    (status_bar_height + custom_status_bar_height) * scale;
677                let content_width = size.width as f32 - effective_padding * 2.0;
678                let content_height = size.height as f32
679                    - content_offset_y
680                    - effective_padding
681                    - physical_status_bar_height;
682                let bounds = crate::pane::PaneBounds::new(
683                    effective_padding,
684                    content_offset_y,
685                    content_width,
686                    content_height,
687                );
688                tab.set_pane_bounds(bounds, cell_width, cell_height);
689            }
690
691            match tab.split_horizontal(&self.config, Arc::clone(&self.runtime), dpi_scale) {
692                Ok(Some(pane_id)) => {
693                    log::info!("Split pane horizontally, new pane {}", pane_id);
694                    // Clear renderer cells to remove stale single-pane data
695                    if let Some(renderer) = &mut self.renderer {
696                        renderer.clear_all_cells();
697                    }
698                    // Invalidate tab cache
699                    tab.cache.cells = None;
700                    self.needs_redraw = true;
701                    self.request_redraw();
702                }
703                Ok(None) => {
704                    log::info!(
705                        "Horizontal split not yet functional (renderer integration pending)"
706                    );
707                }
708                Err(e) => {
709                    log::error!("Failed to split pane horizontally: {}", e);
710                }
711            }
712        }
713    }
714
715    /// Split the current pane vertically (panes side by side)
716    pub fn split_pane_vertical(&mut self) {
717        // In tmux mode, send split command to tmux instead
718        if self.is_tmux_connected() && self.split_pane_via_tmux(true) {
719            crate::debug_info!("TMUX", "Sent vertical split command to tmux");
720            return;
721        }
722        // Fall through to local split if tmux command failed or not connected
723
724        // Calculate status bar height for proper content area
725        let is_tmux_connected = self.is_tmux_connected();
726        let status_bar_height =
727            crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
728        let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
729
730        // Get bounds info from renderer for proper pane sizing
731        let bounds_info = self.renderer.as_ref().map(|r| {
732            let size = r.size();
733            let padding = r.window_padding();
734            let content_offset_y = r.content_offset_y();
735            let cell_width = r.cell_width();
736            let cell_height = r.cell_height();
737            let scale = r.scale_factor();
738            (
739                size,
740                padding,
741                content_offset_y,
742                cell_width,
743                cell_height,
744                scale,
745            )
746        });
747
748        let dpi_scale = bounds_info.map(|b| b.5).unwrap_or(1.0);
749
750        if let Some(tab) = self.tab_manager.active_tab_mut() {
751            // Set pane bounds before split if we have renderer info
752            if let Some((size, padding, content_offset_y, cell_width, cell_height, scale)) =
753                bounds_info
754            {
755                // After split there will be multiple panes, so use 0 padding if configured
756                let effective_padding = if self.config.hide_window_padding_on_split {
757                    0.0
758                } else {
759                    padding
760                };
761                // Scale status_bar_height from logical to physical pixels
762                let physical_status_bar_height =
763                    (status_bar_height + custom_status_bar_height) * scale;
764                let content_width = size.width as f32 - effective_padding * 2.0;
765                let content_height = size.height as f32
766                    - content_offset_y
767                    - effective_padding
768                    - physical_status_bar_height;
769                let bounds = crate::pane::PaneBounds::new(
770                    effective_padding,
771                    content_offset_y,
772                    content_width,
773                    content_height,
774                );
775                tab.set_pane_bounds(bounds, cell_width, cell_height);
776            }
777
778            match tab.split_vertical(&self.config, Arc::clone(&self.runtime), dpi_scale) {
779                Ok(Some(pane_id)) => {
780                    log::info!("Split pane vertically, new pane {}", pane_id);
781                    // Clear renderer cells to remove stale single-pane data
782                    if let Some(renderer) = &mut self.renderer {
783                        renderer.clear_all_cells();
784                    }
785                    // Invalidate tab cache
786                    tab.cache.cells = None;
787                    self.needs_redraw = true;
788                    self.request_redraw();
789                }
790                Ok(None) => {
791                    log::info!("Vertical split not yet functional (renderer integration pending)");
792                }
793                Err(e) => {
794                    log::error!("Failed to split pane vertically: {}", e);
795                }
796            }
797        }
798    }
799
800    /// Close the focused pane in the current tab
801    ///
802    /// If this is the last pane, the tab is closed.
803    /// Returns true if the window should close (last tab was closed).
804    pub fn close_focused_pane(&mut self) -> bool {
805        // In tmux mode, send kill-pane command to tmux
806        if self.is_tmux_connected() && self.close_pane_via_tmux() {
807            crate::debug_info!("TMUX", "Sent kill-pane command to tmux");
808            // Don't close the local pane - wait for tmux layout change
809            return false;
810        }
811        // Fall through to local close if tmux command failed or not connected
812
813        // Check if we need to show confirmation for running jobs
814        if self.config.confirm_close_running_jobs
815            && let Some(command_name) = self.check_current_pane_running_job()
816            && let Some(tab) = self.tab_manager.active_tab()
817            && let Some(pane_id) = tab.focused_pane_id()
818        {
819            let tab_id = tab.id;
820            let tab_title = if tab.title.is_empty() {
821                "Terminal".to_string()
822            } else {
823                tab.title.clone()
824            };
825            self.close_confirmation_ui
826                .show_for_pane(tab_id, pane_id, &tab_title, &command_name);
827            self.needs_redraw = true;
828            self.request_redraw();
829            return false; // Don't close yet, waiting for confirmation
830        }
831
832        self.close_focused_pane_immediately()
833    }
834
835    /// Close the focused pane immediately without confirmation
836    /// Returns true if the window should close (last tab was closed).
837    fn close_focused_pane_immediately(&mut self) -> bool {
838        if let Some(tab) = self.tab_manager.active_tab_mut()
839            && tab.has_multiple_panes()
840        {
841            let is_last_pane = tab.close_focused_pane();
842            if is_last_pane {
843                // Last pane closed, close the tab
844                return self.close_current_tab_immediately();
845            }
846            self.needs_redraw = true;
847            self.request_redraw();
848            return false;
849        }
850        // Single pane or no tab, close the tab
851        self.close_current_tab_immediately()
852    }
853
854    /// Check if the current tab's terminal has a running job that should trigger confirmation
855    ///
856    /// Returns Some(command_name) if confirmation should be shown, None otherwise.
857    fn check_current_tab_running_job(&self) -> Option<String> {
858        let tab = self.tab_manager.active_tab()?;
859        let term = tab.terminal.try_lock().ok()?;
860        term.should_confirm_close(&self.config.jobs_to_ignore)
861    }
862
863    /// Check if the current pane's terminal has a running job that should trigger confirmation
864    ///
865    /// Returns Some(command_name) if confirmation should be shown, None otherwise.
866    fn check_current_pane_running_job(&self) -> Option<String> {
867        let tab = self.tab_manager.active_tab()?;
868
869        // If the tab has split panes, check the focused pane
870        if tab.has_multiple_panes() {
871            let pane_manager = tab.pane_manager()?;
872            let focused_id = pane_manager.focused_pane_id()?;
873            let pane = pane_manager.get_pane(focused_id)?;
874            let term = pane.terminal.try_lock().ok()?;
875            return term.should_confirm_close(&self.config.jobs_to_ignore);
876        }
877
878        // Single pane - use the tab's terminal
879        let term = tab.terminal.try_lock().ok()?;
880        term.should_confirm_close(&self.config.jobs_to_ignore)
881    }
882
883    /// Check if the current tab has multiple panes
884    pub fn has_multiple_panes(&self) -> bool {
885        self.tab_manager
886            .active_tab()
887            .is_some_and(|tab| tab.has_multiple_panes())
888    }
889
890    /// Navigate to an adjacent pane in the given direction
891    pub fn navigate_pane(&mut self, direction: crate::pane::NavigationDirection) {
892        if let Some(tab) = self.tab_manager.active_tab_mut()
893            && tab.has_multiple_panes()
894        {
895            tab.navigate_pane(direction);
896            self.needs_redraw = true;
897            self.request_redraw();
898        }
899    }
900
901    /// Resize the focused pane in the given direction
902    ///
903    /// Growing left/up decreases the pane's ratio, growing right/down increases it
904    pub fn resize_pane(&mut self, direction: crate::pane::NavigationDirection) {
905        use crate::pane::NavigationDirection;
906
907        // Resize step: 5% per keypress
908        const RESIZE_DELTA: f32 = 0.05;
909
910        // Determine delta based on direction
911        // Right/Down: grow focused pane (positive delta)
912        // Left/Up: shrink focused pane (negative delta)
913        let delta = match direction {
914            NavigationDirection::Right | NavigationDirection::Down => RESIZE_DELTA,
915            NavigationDirection::Left | NavigationDirection::Up => -RESIZE_DELTA,
916        };
917
918        if let Some(tab) = self.tab_manager.active_tab_mut()
919            && let Some(pm) = tab.pane_manager_mut()
920            && let Some(focused_id) = pm.focused_pane_id()
921        {
922            pm.resize_split(focused_id, delta);
923            self.needs_redraw = true;
924            self.request_redraw();
925        }
926    }
927
928    // ========================================================================
929    // Profile Management
930    // ========================================================================
931
932    /// Open a new tab from a profile
933    pub fn open_profile(&mut self, profile_id: ProfileId) {
934        log::debug!("open_profile called with id: {:?}", profile_id);
935
936        // Check max tabs limit
937        if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
938            log::warn!(
939                "Cannot open profile: max_tabs limit ({}) reached",
940                self.config.max_tabs
941            );
942            self.deliver_notification(
943                "Tab Limit Reached",
944                &format!(
945                    "Cannot open profile: maximum of {} tabs already open",
946                    self.config.max_tabs
947                ),
948            );
949            return;
950        }
951
952        let profile = match self.profile_manager.get(&profile_id) {
953            Some(p) => p.clone(),
954            None => {
955                log::error!("Profile not found: {:?}", profile_id);
956                return;
957            }
958        };
959        log::debug!("Found profile: {}", profile.name);
960
961        // Get current grid size from renderer
962        let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
963
964        match self.tab_manager.new_tab_from_profile(
965            &self.config,
966            Arc::clone(&self.runtime),
967            &profile,
968            grid_size,
969        ) {
970            Ok(tab_id) => {
971                // Set profile icon on the new tab
972                if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
973                    tab.profile_icon = profile.icon.clone();
974                }
975
976                // Start refresh task for the new tab and resize to match window
977                if let Some(window) = &self.window
978                    && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
979                {
980                    tab.start_refresh_task(
981                        Arc::clone(&self.runtime),
982                        Arc::clone(window),
983                        self.config.max_fps,
984                    );
985
986                    // Resize terminal to match current renderer dimensions
987                    if let Some(renderer) = &self.renderer
988                        && let Ok(mut term) = tab.terminal.try_lock()
989                    {
990                        let (cols, rows) = renderer.grid_size();
991                        let size = renderer.size();
992                        let width_px = size.width as usize;
993                        let height_px = size.height as usize;
994
995                        term.set_cell_dimensions(
996                            renderer.cell_width() as u32,
997                            renderer.cell_height() as u32,
998                        );
999                        let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
1000                        log::info!(
1001                            "Opened profile '{}' in tab {} ({}x{} at {}x{} px)",
1002                            profile.name,
1003                            tab_id,
1004                            cols,
1005                            rows,
1006                            width_px,
1007                            height_px
1008                        );
1009                    }
1010                }
1011
1012                // Update badge with profile information
1013                self.apply_profile_badge(&profile);
1014
1015                self.needs_redraw = true;
1016                self.request_redraw();
1017            }
1018            Err(e) => {
1019                log::error!("Failed to open profile '{}': {}", profile.name, e);
1020
1021                // Show user-friendly error notification
1022                let error_msg = e.to_string();
1023                let (title, message) = if error_msg.contains("Unable to spawn")
1024                    || error_msg.contains("No viable candidates")
1025                {
1026                    // Extract the command name from the error if possible
1027                    let cmd = profile
1028                        .command
1029                        .as_deref()
1030                        .unwrap_or("the configured command");
1031                    (
1032                        format!("Profile '{}' Failed", profile.name),
1033                        format!(
1034                            "Command '{}' not found. Check that it's installed and in your PATH.",
1035                            cmd
1036                        ),
1037                    )
1038                } else if error_msg.contains("No such file or directory") {
1039                    (
1040                        format!("Profile '{}' Failed", profile.name),
1041                        format!(
1042                            "Working directory not found: {}",
1043                            profile.working_directory.as_deref().unwrap_or("(unknown)")
1044                        ),
1045                    )
1046                } else {
1047                    (
1048                        format!("Profile '{}' Failed", profile.name),
1049                        format!("Failed to start: {}", error_msg),
1050                    )
1051                };
1052                self.deliver_notification(&title, &message);
1053            }
1054        }
1055    }
1056
1057    /// Apply profile badge settings
1058    ///
1059    /// Updates the badge session variables and applies any profile-specific
1060    /// badge configuration (format, color, font, margins, etc.).
1061    pub(crate) fn apply_profile_badge(&mut self, profile: &crate::profile::Profile) {
1062        // Update session.profile_name variable
1063        {
1064            let mut vars = self.badge_state.variables_mut();
1065            vars.profile_name = profile.name.clone();
1066        }
1067
1068        // Apply all profile badge settings (format, color, font, margins, etc.)
1069        self.badge_state.apply_profile_settings(profile);
1070
1071        if profile.badge_text.is_some() {
1072            crate::debug_info!(
1073                "PROFILE",
1074                "Applied profile badge settings: format='{}', color={:?}, alpha={}",
1075                profile.badge_text.as_deref().unwrap_or(""),
1076                profile.badge_color,
1077                profile.badge_color_alpha.unwrap_or(0.0)
1078            );
1079        }
1080
1081        // Mark badge as dirty to trigger re-render
1082        self.badge_state.mark_dirty();
1083    }
1084
1085    /// Toggle the profile drawer visibility
1086    pub fn toggle_profile_drawer(&mut self) {
1087        self.profile_drawer_ui.toggle();
1088        self.needs_redraw = true;
1089        self.request_redraw();
1090    }
1091
1092    /// Save profiles to disk
1093    pub fn save_profiles(&self) {
1094        if let Err(e) = profile_storage::save_profiles(&self.profile_manager) {
1095            log::error!("Failed to save profiles: {}", e);
1096        }
1097    }
1098
1099    /// Update profile manager from modal working copy
1100    pub fn apply_profile_changes(&mut self, profiles: Vec<crate::profile::Profile>) {
1101        self.profile_manager = ProfileManager::from_profiles(profiles);
1102        self.save_profiles();
1103        // Signal that the profiles menu needs to be updated
1104        self.profiles_menu_needs_update = true;
1105    }
1106
1107    /// Check for automatic profile switching based on hostname, SSH command, and directory detection
1108    ///
1109    /// This checks the active tab for hostname and CWD changes (detected via OSC 7),
1110    /// SSH command detection, and applies matching profiles automatically.
1111    /// Priority: explicit user selection > hostname match > SSH command match > directory match > default
1112    ///
1113    /// Returns true if a profile was auto-applied, triggering a redraw.
1114    pub fn check_auto_profile_switch(&mut self) -> bool {
1115        if self.profile_manager.is_empty() {
1116            return false;
1117        }
1118
1119        let mut changed = false;
1120
1121        // --- Hostname-based switching (highest priority) ---
1122        changed |= self.check_auto_hostname_switch();
1123
1124        // --- SSH command-based switching (medium priority, only if no hostname profile active) ---
1125        if !changed {
1126            changed |= self.check_ssh_command_switch();
1127        }
1128
1129        // --- Directory-based switching (lower priority, only if no hostname profile) ---
1130        changed |= self.check_auto_directory_switch();
1131
1132        changed
1133    }
1134
1135    /// Check for hostname-based automatic profile switching
1136    fn check_auto_hostname_switch(&mut self) -> bool {
1137        let tab = match self.tab_manager.active_tab_mut() {
1138            Some(t) => t,
1139            None => return false,
1140        };
1141
1142        let new_hostname = match tab.check_hostname_change() {
1143            Some(h) => h,
1144            None => {
1145                if tab.detected_hostname.is_none() && tab.auto_applied_profile_id.is_some() {
1146                    crate::debug_info!(
1147                        "PROFILE",
1148                        "Clearing auto-applied hostname profile (returned to localhost)"
1149                    );
1150                    tab.auto_applied_profile_id = None;
1151                    tab.profile_icon = None;
1152                    tab.badge_override = None;
1153                    // Restore original tab title
1154                    if let Some(original) = tab.pre_profile_title.take() {
1155                        tab.title = original;
1156                    }
1157
1158                    // Revert SSH auto-switch if active
1159                    if tab.ssh_auto_switched {
1160                        crate::debug_info!(
1161                            "PROFILE",
1162                            "Reverting SSH auto-switch (disconnected from remote host)"
1163                        );
1164                        tab.ssh_auto_switched = false;
1165                        tab.pre_ssh_switch_profile = None;
1166                    }
1167                }
1168                return false;
1169            }
1170        };
1171
1172        // Don't re-apply the same profile
1173        if let Some(existing_profile_id) = tab.auto_applied_profile_id
1174            && let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname)
1175            && profile.id == existing_profile_id
1176        {
1177            return false;
1178        }
1179
1180        if let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname) {
1181            let profile_name = profile.name.clone();
1182            let profile_id = profile.id;
1183            let profile_tab_name = profile.tab_name.clone();
1184            let profile_icon = profile.icon.clone();
1185            let profile_badge_text = profile.badge_text.clone();
1186            let profile_command = profile.command.clone();
1187            let profile_command_args = profile.command_args.clone();
1188
1189            crate::debug_info!(
1190                "PROFILE",
1191                "Auto-switching to profile '{}' for hostname '{}'",
1192                profile_name,
1193                new_hostname
1194            );
1195
1196            // Apply profile visual settings to the tab
1197            if let Some(tab) = self.tab_manager.active_tab_mut() {
1198                // Track SSH auto-switch state for revert on disconnect
1199                if !tab.ssh_auto_switched {
1200                    tab.pre_ssh_switch_profile = tab.auto_applied_profile_id;
1201                    tab.ssh_auto_switched = true;
1202                }
1203
1204                tab.auto_applied_profile_id = Some(profile_id);
1205                tab.profile_icon = profile_icon;
1206
1207                // Save original title before overriding (only if not already saved)
1208                if tab.pre_profile_title.is_none() {
1209                    tab.pre_profile_title = Some(tab.title.clone());
1210                }
1211                // Apply profile tab name (fall back to profile name)
1212                tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1213
1214                // Apply badge text override if configured
1215                if let Some(badge_text) = profile_badge_text {
1216                    tab.badge_override = Some(badge_text);
1217                }
1218
1219                // Execute profile command in the running shell if configured
1220                if let Some(cmd) = profile_command {
1221                    let mut full_cmd = cmd;
1222                    if let Some(args) = profile_command_args {
1223                        for arg in args {
1224                            full_cmd.push(' ');
1225                            full_cmd.push_str(&arg);
1226                        }
1227                    }
1228                    full_cmd.push('\n');
1229
1230                    let terminal_clone = Arc::clone(&tab.terminal);
1231                    self.runtime.spawn(async move {
1232                        let term = terminal_clone.lock().await;
1233                        if let Err(e) = term.write(full_cmd.as_bytes()) {
1234                            log::error!("Failed to execute profile command: {}", e);
1235                        }
1236                    });
1237                }
1238            }
1239
1240            // Apply profile badge settings (color, font, margins, etc.)
1241            self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1242
1243            log::info!(
1244                "Auto-applied profile '{}' for hostname '{}'",
1245                profile_name,
1246                new_hostname
1247            );
1248            true
1249        } else {
1250            crate::debug_info!(
1251                "PROFILE",
1252                "No profile matches hostname '{}' - consider creating one",
1253                new_hostname
1254            );
1255            false
1256        }
1257    }
1258
1259    /// Check for SSH command-based automatic profile switching
1260    ///
1261    /// When the running command is "ssh", parse the target host from the command
1262    /// and try to match a profile by hostname pattern. When SSH disconnects
1263    /// (command changes from "ssh" to something else), revert to the previous profile.
1264    fn check_ssh_command_switch(&mut self) -> bool {
1265        // Extract command info and current SSH state from the active tab
1266        let (current_command, already_switched, has_hostname_profile) = {
1267            let tab = match self.tab_manager.active_tab() {
1268                Some(t) => t,
1269                None => return false,
1270            };
1271
1272            let cmd = if let Ok(term) = tab.terminal.try_lock() {
1273                term.get_running_command_name()
1274            } else {
1275                None
1276            };
1277
1278            (
1279                cmd,
1280                tab.ssh_auto_switched,
1281                tab.auto_applied_profile_id.is_some(),
1282            )
1283        };
1284
1285        let is_ssh = current_command
1286            .as_ref()
1287            .is_some_and(|cmd| cmd == "ssh" || cmd.ends_with("/ssh"));
1288
1289        if is_ssh && !already_switched && !has_hostname_profile {
1290            // SSH just started - try to extract the target host from the command
1291            // Shell integration may report just "ssh" as the command name;
1292            // the actual hostname will come via OSC 7 hostname detection.
1293            // For now, mark that SSH is active so we can revert when it ends.
1294            if let Some(tab) = self.tab_manager.active_tab_mut() {
1295                crate::debug_info!(
1296                    "PROFILE",
1297                    "SSH command detected - waiting for hostname via OSC 7"
1298                );
1299                // Mark SSH as active for revert tracking (the actual profile
1300                // switch will happen via check_auto_hostname_switch when OSC 7 arrives)
1301                tab.ssh_auto_switched = true;
1302            }
1303            false
1304        } else if !is_ssh && already_switched && !has_hostname_profile {
1305            // SSH disconnected and no hostname-based profile is active - revert
1306            if let Some(tab) = self.tab_manager.active_tab_mut() {
1307                crate::debug_info!("PROFILE", "SSH command ended - reverting auto-switch state");
1308                tab.ssh_auto_switched = false;
1309                let _prev_profile = tab.pre_ssh_switch_profile.take();
1310                // Clear any SSH-related visual overrides
1311                tab.profile_icon = None;
1312                tab.badge_override = None;
1313                if let Some(original) = tab.pre_profile_title.take() {
1314                    tab.title = original;
1315                }
1316            }
1317            true // Trigger redraw to reflect reverted state
1318        } else {
1319            false
1320        }
1321    }
1322
1323    /// Check for directory-based automatic profile switching
1324    fn check_auto_directory_switch(&mut self) -> bool {
1325        let tab = match self.tab_manager.active_tab_mut() {
1326            Some(t) => t,
1327            None => return false,
1328        };
1329
1330        // Don't override hostname-based profile (higher priority)
1331        if tab.auto_applied_profile_id.is_some() {
1332            return false;
1333        }
1334
1335        let new_cwd = match tab.check_cwd_change() {
1336            Some(c) => c,
1337            None => return false,
1338        };
1339
1340        // Don't re-apply the same profile
1341        if let Some(existing_profile_id) = tab.auto_applied_dir_profile_id
1342            && let Some(profile) = self.profile_manager.find_by_directory(&new_cwd)
1343            && profile.id == existing_profile_id
1344        {
1345            return false;
1346        }
1347
1348        if let Some(profile) = self.profile_manager.find_by_directory(&new_cwd) {
1349            let profile_name = profile.name.clone();
1350            let profile_id = profile.id;
1351            let profile_tab_name = profile.tab_name.clone();
1352            let profile_icon = profile.icon.clone();
1353            let profile_badge_text = profile.badge_text.clone();
1354            let profile_command = profile.command.clone();
1355            let profile_command_args = profile.command_args.clone();
1356
1357            crate::debug_info!(
1358                "PROFILE",
1359                "Auto-switching to profile '{}' for directory '{}'",
1360                profile_name,
1361                new_cwd
1362            );
1363
1364            // Apply profile visual settings to the tab
1365            if let Some(tab) = self.tab_manager.active_tab_mut() {
1366                tab.auto_applied_dir_profile_id = Some(profile_id);
1367                tab.profile_icon = profile_icon;
1368
1369                // Save original title before overriding (only if not already saved)
1370                if tab.pre_profile_title.is_none() {
1371                    tab.pre_profile_title = Some(tab.title.clone());
1372                }
1373                // Apply profile tab name (fall back to profile name)
1374                tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1375
1376                // Apply badge text override if configured
1377                if let Some(badge_text) = profile_badge_text {
1378                    tab.badge_override = Some(badge_text);
1379                }
1380
1381                // Execute profile command in the running shell if configured
1382                if let Some(cmd) = profile_command {
1383                    let mut full_cmd = cmd;
1384                    if let Some(args) = profile_command_args {
1385                        for arg in args {
1386                            full_cmd.push(' ');
1387                            full_cmd.push_str(&arg);
1388                        }
1389                    }
1390                    full_cmd.push('\n');
1391
1392                    let terminal_clone = Arc::clone(&tab.terminal);
1393                    self.runtime.spawn(async move {
1394                        let term = terminal_clone.lock().await;
1395                        if let Err(e) = term.write(full_cmd.as_bytes()) {
1396                            log::error!("Failed to execute profile command: {}", e);
1397                        }
1398                    });
1399                }
1400            }
1401
1402            // Apply profile badge settings (color, font, margins, etc.)
1403            self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1404
1405            log::info!(
1406                "Auto-applied profile '{}' for directory '{}'",
1407                profile_name,
1408                new_cwd
1409            );
1410            true
1411        } else {
1412            // Clear directory profile if CWD no longer matches any pattern
1413            if let Some(tab) = self.tab_manager.active_tab_mut()
1414                && tab.auto_applied_dir_profile_id.is_some()
1415            {
1416                crate::debug_info!(
1417                    "PROFILE",
1418                    "Clearing auto-applied directory profile (CWD '{}' no longer matches)",
1419                    new_cwd
1420                );
1421                tab.auto_applied_dir_profile_id = None;
1422                tab.profile_icon = None;
1423                tab.badge_override = None;
1424                // Restore original tab title
1425                if let Some(original) = tab.pre_profile_title.take() {
1426                    tab.title = original;
1427                }
1428            }
1429            false
1430        }
1431    }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437    use std::collections::VecDeque;
1438    use std::time::{Duration, Instant};
1439
1440    fn make_info(title: &str, index: usize) -> ClosedTabInfo {
1441        ClosedTabInfo {
1442            cwd: Some("/tmp".to_string()),
1443            title: title.to_string(),
1444            has_default_title: true,
1445            index,
1446            closed_at: Instant::now(),
1447            pane_layout: None,
1448            custom_color: None,
1449            hidden_tab: None,
1450        }
1451    }
1452
1453    #[test]
1454    fn closed_tab_queue_overflow() {
1455        let max = 3;
1456        let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1457        for i in 0..5 {
1458            queue.push_front(make_info(&format!("tab{i}"), i));
1459            while queue.len() > max {
1460                queue.pop_back();
1461            }
1462        }
1463        assert_eq!(queue.len(), max);
1464        // Most recent should be first
1465        assert_eq!(queue.front().unwrap().title, "tab4");
1466        // Oldest kept should be last
1467        assert_eq!(queue.back().unwrap().title, "tab2");
1468    }
1469
1470    #[test]
1471    fn closed_tab_expiry() {
1472        let timeout = Duration::from_millis(50);
1473        let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1474
1475        // Add an already-expired entry
1476        let mut old = make_info("old", 0);
1477        old.closed_at = Instant::now() - Duration::from_millis(100);
1478        queue.push_front(old);
1479
1480        // Add a fresh entry
1481        queue.push_front(make_info("fresh", 1));
1482
1483        let now = Instant::now();
1484        queue.retain(|info| now.duration_since(info.closed_at) < timeout);
1485
1486        assert_eq!(queue.len(), 1);
1487        assert_eq!(queue.front().unwrap().title, "fresh");
1488    }
1489}