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                // Scale status_bar_height from logical to physical pixels
669                let physical_status_bar_height =
670                    (status_bar_height + custom_status_bar_height) * scale;
671                let content_width = size.width as f32 - padding * 2.0;
672                let content_height =
673                    size.height as f32 - content_offset_y - padding - physical_status_bar_height;
674                let bounds = crate::pane::PaneBounds::new(
675                    padding,
676                    content_offset_y,
677                    content_width,
678                    content_height,
679                );
680                tab.set_pane_bounds(bounds, cell_width, cell_height);
681            }
682
683            match tab.split_horizontal(&self.config, Arc::clone(&self.runtime), dpi_scale) {
684                Ok(Some(pane_id)) => {
685                    log::info!("Split pane horizontally, new pane {}", pane_id);
686                    // Clear renderer cells to remove stale single-pane data
687                    if let Some(renderer) = &mut self.renderer {
688                        renderer.clear_all_cells();
689                    }
690                    // Invalidate tab cache
691                    tab.cache.cells = None;
692                    self.needs_redraw = true;
693                    self.request_redraw();
694                }
695                Ok(None) => {
696                    log::info!(
697                        "Horizontal split not yet functional (renderer integration pending)"
698                    );
699                }
700                Err(e) => {
701                    log::error!("Failed to split pane horizontally: {}", e);
702                }
703            }
704        }
705    }
706
707    /// Split the current pane vertically (panes side by side)
708    pub fn split_pane_vertical(&mut self) {
709        // In tmux mode, send split command to tmux instead
710        if self.is_tmux_connected() && self.split_pane_via_tmux(true) {
711            crate::debug_info!("TMUX", "Sent vertical split command to tmux");
712            return;
713        }
714        // Fall through to local split if tmux command failed or not connected
715
716        // Calculate status bar height for proper content area
717        let is_tmux_connected = self.is_tmux_connected();
718        let status_bar_height =
719            crate::tmux_status_bar_ui::TmuxStatusBarUI::height(&self.config, is_tmux_connected);
720        let custom_status_bar_height = self.status_bar_ui.height(&self.config, self.is_fullscreen);
721
722        // Get bounds info from renderer for proper pane sizing
723        let bounds_info = self.renderer.as_ref().map(|r| {
724            let size = r.size();
725            let padding = r.window_padding();
726            let content_offset_y = r.content_offset_y();
727            let cell_width = r.cell_width();
728            let cell_height = r.cell_height();
729            let scale = r.scale_factor();
730            (
731                size,
732                padding,
733                content_offset_y,
734                cell_width,
735                cell_height,
736                scale,
737            )
738        });
739
740        let dpi_scale = bounds_info.map(|b| b.5).unwrap_or(1.0);
741
742        if let Some(tab) = self.tab_manager.active_tab_mut() {
743            // Set pane bounds before split if we have renderer info
744            if let Some((size, padding, content_offset_y, cell_width, cell_height, scale)) =
745                bounds_info
746            {
747                // Scale status_bar_height from logical to physical pixels
748                let physical_status_bar_height =
749                    (status_bar_height + custom_status_bar_height) * scale;
750                let content_width = size.width as f32 - padding * 2.0;
751                let content_height =
752                    size.height as f32 - content_offset_y - padding - physical_status_bar_height;
753                let bounds = crate::pane::PaneBounds::new(
754                    padding,
755                    content_offset_y,
756                    content_width,
757                    content_height,
758                );
759                tab.set_pane_bounds(bounds, cell_width, cell_height);
760            }
761
762            match tab.split_vertical(&self.config, Arc::clone(&self.runtime), dpi_scale) {
763                Ok(Some(pane_id)) => {
764                    log::info!("Split pane vertically, new pane {}", pane_id);
765                    // Clear renderer cells to remove stale single-pane data
766                    if let Some(renderer) = &mut self.renderer {
767                        renderer.clear_all_cells();
768                    }
769                    // Invalidate tab cache
770                    tab.cache.cells = None;
771                    self.needs_redraw = true;
772                    self.request_redraw();
773                }
774                Ok(None) => {
775                    log::info!("Vertical split not yet functional (renderer integration pending)");
776                }
777                Err(e) => {
778                    log::error!("Failed to split pane vertically: {}", e);
779                }
780            }
781        }
782    }
783
784    /// Close the focused pane in the current tab
785    ///
786    /// If this is the last pane, the tab is closed.
787    /// Returns true if the window should close (last tab was closed).
788    pub fn close_focused_pane(&mut self) -> bool {
789        // In tmux mode, send kill-pane command to tmux
790        if self.is_tmux_connected() && self.close_pane_via_tmux() {
791            crate::debug_info!("TMUX", "Sent kill-pane command to tmux");
792            // Don't close the local pane - wait for tmux layout change
793            return false;
794        }
795        // Fall through to local close if tmux command failed or not connected
796
797        // Check if we need to show confirmation for running jobs
798        if self.config.confirm_close_running_jobs
799            && let Some(command_name) = self.check_current_pane_running_job()
800            && let Some(tab) = self.tab_manager.active_tab()
801            && let Some(pane_id) = tab.focused_pane_id()
802        {
803            let tab_id = tab.id;
804            let tab_title = if tab.title.is_empty() {
805                "Terminal".to_string()
806            } else {
807                tab.title.clone()
808            };
809            self.close_confirmation_ui
810                .show_for_pane(tab_id, pane_id, &tab_title, &command_name);
811            self.needs_redraw = true;
812            self.request_redraw();
813            return false; // Don't close yet, waiting for confirmation
814        }
815
816        self.close_focused_pane_immediately()
817    }
818
819    /// Close the focused pane immediately without confirmation
820    /// Returns true if the window should close (last tab was closed).
821    fn close_focused_pane_immediately(&mut self) -> bool {
822        if let Some(tab) = self.tab_manager.active_tab_mut()
823            && tab.has_multiple_panes()
824        {
825            let is_last_pane = tab.close_focused_pane();
826            if is_last_pane {
827                // Last pane closed, close the tab
828                return self.close_current_tab_immediately();
829            }
830            self.needs_redraw = true;
831            self.request_redraw();
832            return false;
833        }
834        // Single pane or no tab, close the tab
835        self.close_current_tab_immediately()
836    }
837
838    /// Check if the current tab's terminal has a running job that should trigger confirmation
839    ///
840    /// Returns Some(command_name) if confirmation should be shown, None otherwise.
841    fn check_current_tab_running_job(&self) -> Option<String> {
842        let tab = self.tab_manager.active_tab()?;
843        let term = tab.terminal.try_lock().ok()?;
844        term.should_confirm_close(&self.config.jobs_to_ignore)
845    }
846
847    /// Check if the current pane's terminal has a running job that should trigger confirmation
848    ///
849    /// Returns Some(command_name) if confirmation should be shown, None otherwise.
850    fn check_current_pane_running_job(&self) -> Option<String> {
851        let tab = self.tab_manager.active_tab()?;
852
853        // If the tab has split panes, check the focused pane
854        if tab.has_multiple_panes() {
855            let pane_manager = tab.pane_manager()?;
856            let focused_id = pane_manager.focused_pane_id()?;
857            let pane = pane_manager.get_pane(focused_id)?;
858            let term = pane.terminal.try_lock().ok()?;
859            return term.should_confirm_close(&self.config.jobs_to_ignore);
860        }
861
862        // Single pane - use the tab's terminal
863        let term = tab.terminal.try_lock().ok()?;
864        term.should_confirm_close(&self.config.jobs_to_ignore)
865    }
866
867    /// Check if the current tab has multiple panes
868    pub fn has_multiple_panes(&self) -> bool {
869        self.tab_manager
870            .active_tab()
871            .is_some_and(|tab| tab.has_multiple_panes())
872    }
873
874    /// Navigate to an adjacent pane in the given direction
875    pub fn navigate_pane(&mut self, direction: crate::pane::NavigationDirection) {
876        if let Some(tab) = self.tab_manager.active_tab_mut()
877            && tab.has_multiple_panes()
878        {
879            tab.navigate_pane(direction);
880            self.needs_redraw = true;
881            self.request_redraw();
882        }
883    }
884
885    /// Resize the focused pane in the given direction
886    ///
887    /// Growing left/up decreases the pane's ratio, growing right/down increases it
888    pub fn resize_pane(&mut self, direction: crate::pane::NavigationDirection) {
889        use crate::pane::NavigationDirection;
890
891        // Resize step: 5% per keypress
892        const RESIZE_DELTA: f32 = 0.05;
893
894        // Determine delta based on direction
895        // Right/Down: grow focused pane (positive delta)
896        // Left/Up: shrink focused pane (negative delta)
897        let delta = match direction {
898            NavigationDirection::Right | NavigationDirection::Down => RESIZE_DELTA,
899            NavigationDirection::Left | NavigationDirection::Up => -RESIZE_DELTA,
900        };
901
902        if let Some(tab) = self.tab_manager.active_tab_mut()
903            && let Some(pm) = tab.pane_manager_mut()
904            && let Some(focused_id) = pm.focused_pane_id()
905        {
906            pm.resize_split(focused_id, delta);
907            self.needs_redraw = true;
908            self.request_redraw();
909        }
910    }
911
912    // ========================================================================
913    // Profile Management
914    // ========================================================================
915
916    /// Open a new tab from a profile
917    pub fn open_profile(&mut self, profile_id: ProfileId) {
918        log::debug!("open_profile called with id: {:?}", profile_id);
919
920        // Check max tabs limit
921        if self.config.max_tabs > 0 && self.tab_manager.tab_count() >= self.config.max_tabs {
922            log::warn!(
923                "Cannot open profile: max_tabs limit ({}) reached",
924                self.config.max_tabs
925            );
926            self.deliver_notification(
927                "Tab Limit Reached",
928                &format!(
929                    "Cannot open profile: maximum of {} tabs already open",
930                    self.config.max_tabs
931                ),
932            );
933            return;
934        }
935
936        let profile = match self.profile_manager.get(&profile_id) {
937            Some(p) => p.clone(),
938            None => {
939                log::error!("Profile not found: {:?}", profile_id);
940                return;
941            }
942        };
943        log::debug!("Found profile: {}", profile.name);
944
945        // Get current grid size from renderer
946        let grid_size = self.renderer.as_ref().map(|r| r.grid_size());
947
948        match self.tab_manager.new_tab_from_profile(
949            &self.config,
950            Arc::clone(&self.runtime),
951            &profile,
952            grid_size,
953        ) {
954            Ok(tab_id) => {
955                // Set profile icon on the new tab
956                if let Some(tab) = self.tab_manager.get_tab_mut(tab_id) {
957                    tab.profile_icon = profile.icon.clone();
958                }
959
960                // Start refresh task for the new tab and resize to match window
961                if let Some(window) = &self.window
962                    && let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
963                {
964                    tab.start_refresh_task(
965                        Arc::clone(&self.runtime),
966                        Arc::clone(window),
967                        self.config.max_fps,
968                    );
969
970                    // Resize terminal to match current renderer dimensions
971                    if let Some(renderer) = &self.renderer
972                        && let Ok(mut term) = tab.terminal.try_lock()
973                    {
974                        let (cols, rows) = renderer.grid_size();
975                        let size = renderer.size();
976                        let width_px = size.width as usize;
977                        let height_px = size.height as usize;
978
979                        term.set_cell_dimensions(
980                            renderer.cell_width() as u32,
981                            renderer.cell_height() as u32,
982                        );
983                        let _ = term.resize_with_pixels(cols, rows, width_px, height_px);
984                        log::info!(
985                            "Opened profile '{}' in tab {} ({}x{} at {}x{} px)",
986                            profile.name,
987                            tab_id,
988                            cols,
989                            rows,
990                            width_px,
991                            height_px
992                        );
993                    }
994                }
995
996                // Update badge with profile information
997                self.apply_profile_badge(&profile);
998
999                self.needs_redraw = true;
1000                self.request_redraw();
1001            }
1002            Err(e) => {
1003                log::error!("Failed to open profile '{}': {}", profile.name, e);
1004
1005                // Show user-friendly error notification
1006                let error_msg = e.to_string();
1007                let (title, message) = if error_msg.contains("Unable to spawn")
1008                    || error_msg.contains("No viable candidates")
1009                {
1010                    // Extract the command name from the error if possible
1011                    let cmd = profile
1012                        .command
1013                        .as_deref()
1014                        .unwrap_or("the configured command");
1015                    (
1016                        format!("Profile '{}' Failed", profile.name),
1017                        format!(
1018                            "Command '{}' not found. Check that it's installed and in your PATH.",
1019                            cmd
1020                        ),
1021                    )
1022                } else if error_msg.contains("No such file or directory") {
1023                    (
1024                        format!("Profile '{}' Failed", profile.name),
1025                        format!(
1026                            "Working directory not found: {}",
1027                            profile.working_directory.as_deref().unwrap_or("(unknown)")
1028                        ),
1029                    )
1030                } else {
1031                    (
1032                        format!("Profile '{}' Failed", profile.name),
1033                        format!("Failed to start: {}", error_msg),
1034                    )
1035                };
1036                self.deliver_notification(&title, &message);
1037            }
1038        }
1039    }
1040
1041    /// Apply profile badge settings
1042    ///
1043    /// Updates the badge session variables and applies any profile-specific
1044    /// badge configuration (format, color, font, margins, etc.).
1045    pub(crate) fn apply_profile_badge(&mut self, profile: &crate::profile::Profile) {
1046        // Update session.profile_name variable
1047        {
1048            let mut vars = self.badge_state.variables_mut();
1049            vars.profile_name = profile.name.clone();
1050        }
1051
1052        // Apply all profile badge settings (format, color, font, margins, etc.)
1053        self.badge_state.apply_profile_settings(profile);
1054
1055        if profile.badge_text.is_some() {
1056            crate::debug_info!(
1057                "PROFILE",
1058                "Applied profile badge settings: format='{}', color={:?}, alpha={}",
1059                profile.badge_text.as_deref().unwrap_or(""),
1060                profile.badge_color,
1061                profile.badge_color_alpha.unwrap_or(0.0)
1062            );
1063        }
1064
1065        // Mark badge as dirty to trigger re-render
1066        self.badge_state.mark_dirty();
1067    }
1068
1069    /// Toggle the profile drawer visibility
1070    pub fn toggle_profile_drawer(&mut self) {
1071        self.profile_drawer_ui.toggle();
1072        self.needs_redraw = true;
1073        self.request_redraw();
1074    }
1075
1076    /// Save profiles to disk
1077    pub fn save_profiles(&self) {
1078        if let Err(e) = profile_storage::save_profiles(&self.profile_manager) {
1079            log::error!("Failed to save profiles: {}", e);
1080        }
1081    }
1082
1083    /// Update profile manager from modal working copy
1084    pub fn apply_profile_changes(&mut self, profiles: Vec<crate::profile::Profile>) {
1085        self.profile_manager = ProfileManager::from_profiles(profiles);
1086        self.save_profiles();
1087        // Signal that the profiles menu needs to be updated
1088        self.profiles_menu_needs_update = true;
1089    }
1090
1091    /// Check for automatic profile switching based on hostname, SSH command, and directory detection
1092    ///
1093    /// This checks the active tab for hostname and CWD changes (detected via OSC 7),
1094    /// SSH command detection, and applies matching profiles automatically.
1095    /// Priority: explicit user selection > hostname match > SSH command match > directory match > default
1096    ///
1097    /// Returns true if a profile was auto-applied, triggering a redraw.
1098    pub fn check_auto_profile_switch(&mut self) -> bool {
1099        if self.profile_manager.is_empty() {
1100            return false;
1101        }
1102
1103        let mut changed = false;
1104
1105        // --- Hostname-based switching (highest priority) ---
1106        changed |= self.check_auto_hostname_switch();
1107
1108        // --- SSH command-based switching (medium priority, only if no hostname profile active) ---
1109        if !changed {
1110            changed |= self.check_ssh_command_switch();
1111        }
1112
1113        // --- Directory-based switching (lower priority, only if no hostname profile) ---
1114        changed |= self.check_auto_directory_switch();
1115
1116        changed
1117    }
1118
1119    /// Check for hostname-based automatic profile switching
1120    fn check_auto_hostname_switch(&mut self) -> bool {
1121        let tab = match self.tab_manager.active_tab_mut() {
1122            Some(t) => t,
1123            None => return false,
1124        };
1125
1126        let new_hostname = match tab.check_hostname_change() {
1127            Some(h) => h,
1128            None => {
1129                if tab.detected_hostname.is_none() && tab.auto_applied_profile_id.is_some() {
1130                    crate::debug_info!(
1131                        "PROFILE",
1132                        "Clearing auto-applied hostname profile (returned to localhost)"
1133                    );
1134                    tab.auto_applied_profile_id = None;
1135                    tab.profile_icon = None;
1136                    tab.badge_override = None;
1137                    // Restore original tab title
1138                    if let Some(original) = tab.pre_profile_title.take() {
1139                        tab.title = original;
1140                    }
1141
1142                    // Revert SSH auto-switch if active
1143                    if tab.ssh_auto_switched {
1144                        crate::debug_info!(
1145                            "PROFILE",
1146                            "Reverting SSH auto-switch (disconnected from remote host)"
1147                        );
1148                        tab.ssh_auto_switched = false;
1149                        tab.pre_ssh_switch_profile = None;
1150                    }
1151                }
1152                return false;
1153            }
1154        };
1155
1156        // Don't re-apply the same profile
1157        if let Some(existing_profile_id) = tab.auto_applied_profile_id
1158            && let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname)
1159            && profile.id == existing_profile_id
1160        {
1161            return false;
1162        }
1163
1164        if let Some(profile) = self.profile_manager.find_by_hostname(&new_hostname) {
1165            let profile_name = profile.name.clone();
1166            let profile_id = profile.id;
1167            let profile_tab_name = profile.tab_name.clone();
1168            let profile_icon = profile.icon.clone();
1169            let profile_badge_text = profile.badge_text.clone();
1170            let profile_command = profile.command.clone();
1171            let profile_command_args = profile.command_args.clone();
1172
1173            crate::debug_info!(
1174                "PROFILE",
1175                "Auto-switching to profile '{}' for hostname '{}'",
1176                profile_name,
1177                new_hostname
1178            );
1179
1180            // Apply profile visual settings to the tab
1181            if let Some(tab) = self.tab_manager.active_tab_mut() {
1182                // Track SSH auto-switch state for revert on disconnect
1183                if !tab.ssh_auto_switched {
1184                    tab.pre_ssh_switch_profile = tab.auto_applied_profile_id;
1185                    tab.ssh_auto_switched = true;
1186                }
1187
1188                tab.auto_applied_profile_id = Some(profile_id);
1189                tab.profile_icon = profile_icon;
1190
1191                // Save original title before overriding (only if not already saved)
1192                if tab.pre_profile_title.is_none() {
1193                    tab.pre_profile_title = Some(tab.title.clone());
1194                }
1195                // Apply profile tab name (fall back to profile name)
1196                tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1197
1198                // Apply badge text override if configured
1199                if let Some(badge_text) = profile_badge_text {
1200                    tab.badge_override = Some(badge_text);
1201                }
1202
1203                // Execute profile command in the running shell if configured
1204                if let Some(cmd) = profile_command {
1205                    let mut full_cmd = cmd;
1206                    if let Some(args) = profile_command_args {
1207                        for arg in args {
1208                            full_cmd.push(' ');
1209                            full_cmd.push_str(&arg);
1210                        }
1211                    }
1212                    full_cmd.push('\n');
1213
1214                    let terminal_clone = Arc::clone(&tab.terminal);
1215                    self.runtime.spawn(async move {
1216                        let term = terminal_clone.lock().await;
1217                        if let Err(e) = term.write(full_cmd.as_bytes()) {
1218                            log::error!("Failed to execute profile command: {}", e);
1219                        }
1220                    });
1221                }
1222            }
1223
1224            // Apply profile badge settings (color, font, margins, etc.)
1225            self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1226
1227            log::info!(
1228                "Auto-applied profile '{}' for hostname '{}'",
1229                profile_name,
1230                new_hostname
1231            );
1232            true
1233        } else {
1234            crate::debug_info!(
1235                "PROFILE",
1236                "No profile matches hostname '{}' - consider creating one",
1237                new_hostname
1238            );
1239            false
1240        }
1241    }
1242
1243    /// Check for SSH command-based automatic profile switching
1244    ///
1245    /// When the running command is "ssh", parse the target host from the command
1246    /// and try to match a profile by hostname pattern. When SSH disconnects
1247    /// (command changes from "ssh" to something else), revert to the previous profile.
1248    fn check_ssh_command_switch(&mut self) -> bool {
1249        // Extract command info and current SSH state from the active tab
1250        let (current_command, already_switched, has_hostname_profile) = {
1251            let tab = match self.tab_manager.active_tab() {
1252                Some(t) => t,
1253                None => return false,
1254            };
1255
1256            let cmd = if let Ok(term) = tab.terminal.try_lock() {
1257                term.get_running_command_name()
1258            } else {
1259                None
1260            };
1261
1262            (
1263                cmd,
1264                tab.ssh_auto_switched,
1265                tab.auto_applied_profile_id.is_some(),
1266            )
1267        };
1268
1269        let is_ssh = current_command
1270            .as_ref()
1271            .is_some_and(|cmd| cmd == "ssh" || cmd.ends_with("/ssh"));
1272
1273        if is_ssh && !already_switched && !has_hostname_profile {
1274            // SSH just started - try to extract the target host from the command
1275            // Shell integration may report just "ssh" as the command name;
1276            // the actual hostname will come via OSC 7 hostname detection.
1277            // For now, mark that SSH is active so we can revert when it ends.
1278            if let Some(tab) = self.tab_manager.active_tab_mut() {
1279                crate::debug_info!(
1280                    "PROFILE",
1281                    "SSH command detected - waiting for hostname via OSC 7"
1282                );
1283                // Mark SSH as active for revert tracking (the actual profile
1284                // switch will happen via check_auto_hostname_switch when OSC 7 arrives)
1285                tab.ssh_auto_switched = true;
1286            }
1287            false
1288        } else if !is_ssh && already_switched && !has_hostname_profile {
1289            // SSH disconnected and no hostname-based profile is active - revert
1290            if let Some(tab) = self.tab_manager.active_tab_mut() {
1291                crate::debug_info!("PROFILE", "SSH command ended - reverting auto-switch state");
1292                tab.ssh_auto_switched = false;
1293                let _prev_profile = tab.pre_ssh_switch_profile.take();
1294                // Clear any SSH-related visual overrides
1295                tab.profile_icon = None;
1296                tab.badge_override = None;
1297                if let Some(original) = tab.pre_profile_title.take() {
1298                    tab.title = original;
1299                }
1300            }
1301            true // Trigger redraw to reflect reverted state
1302        } else {
1303            false
1304        }
1305    }
1306
1307    /// Check for directory-based automatic profile switching
1308    fn check_auto_directory_switch(&mut self) -> bool {
1309        let tab = match self.tab_manager.active_tab_mut() {
1310            Some(t) => t,
1311            None => return false,
1312        };
1313
1314        // Don't override hostname-based profile (higher priority)
1315        if tab.auto_applied_profile_id.is_some() {
1316            return false;
1317        }
1318
1319        let new_cwd = match tab.check_cwd_change() {
1320            Some(c) => c,
1321            None => return false,
1322        };
1323
1324        // Don't re-apply the same profile
1325        if let Some(existing_profile_id) = tab.auto_applied_dir_profile_id
1326            && let Some(profile) = self.profile_manager.find_by_directory(&new_cwd)
1327            && profile.id == existing_profile_id
1328        {
1329            return false;
1330        }
1331
1332        if let Some(profile) = self.profile_manager.find_by_directory(&new_cwd) {
1333            let profile_name = profile.name.clone();
1334            let profile_id = profile.id;
1335            let profile_tab_name = profile.tab_name.clone();
1336            let profile_icon = profile.icon.clone();
1337            let profile_badge_text = profile.badge_text.clone();
1338            let profile_command = profile.command.clone();
1339            let profile_command_args = profile.command_args.clone();
1340
1341            crate::debug_info!(
1342                "PROFILE",
1343                "Auto-switching to profile '{}' for directory '{}'",
1344                profile_name,
1345                new_cwd
1346            );
1347
1348            // Apply profile visual settings to the tab
1349            if let Some(tab) = self.tab_manager.active_tab_mut() {
1350                tab.auto_applied_dir_profile_id = Some(profile_id);
1351                tab.profile_icon = profile_icon;
1352
1353                // Save original title before overriding (only if not already saved)
1354                if tab.pre_profile_title.is_none() {
1355                    tab.pre_profile_title = Some(tab.title.clone());
1356                }
1357                // Apply profile tab name (fall back to profile name)
1358                tab.title = profile_tab_name.unwrap_or_else(|| profile_name.clone());
1359
1360                // Apply badge text override if configured
1361                if let Some(badge_text) = profile_badge_text {
1362                    tab.badge_override = Some(badge_text);
1363                }
1364
1365                // Execute profile command in the running shell if configured
1366                if let Some(cmd) = profile_command {
1367                    let mut full_cmd = cmd;
1368                    if let Some(args) = profile_command_args {
1369                        for arg in args {
1370                            full_cmd.push(' ');
1371                            full_cmd.push_str(&arg);
1372                        }
1373                    }
1374                    full_cmd.push('\n');
1375
1376                    let terminal_clone = Arc::clone(&tab.terminal);
1377                    self.runtime.spawn(async move {
1378                        let term = terminal_clone.lock().await;
1379                        if let Err(e) = term.write(full_cmd.as_bytes()) {
1380                            log::error!("Failed to execute profile command: {}", e);
1381                        }
1382                    });
1383                }
1384            }
1385
1386            // Apply profile badge settings (color, font, margins, etc.)
1387            self.apply_profile_badge(&self.profile_manager.get(&profile_id).unwrap().clone());
1388
1389            log::info!(
1390                "Auto-applied profile '{}' for directory '{}'",
1391                profile_name,
1392                new_cwd
1393            );
1394            true
1395        } else {
1396            // Clear directory profile if CWD no longer matches any pattern
1397            if let Some(tab) = self.tab_manager.active_tab_mut()
1398                && tab.auto_applied_dir_profile_id.is_some()
1399            {
1400                crate::debug_info!(
1401                    "PROFILE",
1402                    "Clearing auto-applied directory profile (CWD '{}' no longer matches)",
1403                    new_cwd
1404                );
1405                tab.auto_applied_dir_profile_id = None;
1406                tab.profile_icon = None;
1407                tab.badge_override = None;
1408                // Restore original tab title
1409                if let Some(original) = tab.pre_profile_title.take() {
1410                    tab.title = original;
1411                }
1412            }
1413            false
1414        }
1415    }
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use super::*;
1421    use std::collections::VecDeque;
1422    use std::time::{Duration, Instant};
1423
1424    fn make_info(title: &str, index: usize) -> ClosedTabInfo {
1425        ClosedTabInfo {
1426            cwd: Some("/tmp".to_string()),
1427            title: title.to_string(),
1428            has_default_title: true,
1429            index,
1430            closed_at: Instant::now(),
1431            pane_layout: None,
1432            custom_color: None,
1433            hidden_tab: None,
1434        }
1435    }
1436
1437    #[test]
1438    fn closed_tab_queue_overflow() {
1439        let max = 3;
1440        let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1441        for i in 0..5 {
1442            queue.push_front(make_info(&format!("tab{i}"), i));
1443            while queue.len() > max {
1444                queue.pop_back();
1445            }
1446        }
1447        assert_eq!(queue.len(), max);
1448        // Most recent should be first
1449        assert_eq!(queue.front().unwrap().title, "tab4");
1450        // Oldest kept should be last
1451        assert_eq!(queue.back().unwrap().title, "tab2");
1452    }
1453
1454    #[test]
1455    fn closed_tab_expiry() {
1456        let timeout = Duration::from_millis(50);
1457        let mut queue: VecDeque<ClosedTabInfo> = VecDeque::new();
1458
1459        // Add an already-expired entry
1460        let mut old = make_info("old", 0);
1461        old.closed_at = Instant::now() - Duration::from_millis(100);
1462        queue.push_front(old);
1463
1464        // Add a fresh entry
1465        queue.push_front(make_info("fresh", 1));
1466
1467        let now = Instant::now();
1468        queue.retain(|info| now.duration_since(info.closed_at) < timeout);
1469
1470        assert_eq!(queue.len(), 1);
1471        assert_eq!(queue.front().unwrap().title, "fresh");
1472    }
1473}