Skip to main content

par_term/tab/
mod.rs

1//! Tab management for multi-tab terminal support
2//!
3//! This module provides the core tab infrastructure including:
4//! - `Tab`: Represents a single terminal session with its own state (supports split panes)
5//! - `TabManager`: Coordinates multiple tabs within a window
6//! - `TabId`: Unique identifier for each tab
7
8mod initial_text;
9mod manager;
10
11pub use manager::TabManager;
12
13use crate::app::bell::BellState;
14use crate::app::mouse::MouseState;
15use crate::app::render_cache::RenderCache;
16use crate::config::Config;
17use crate::pane::{NavigationDirection, PaneManager, SplitDirection};
18use crate::profile::Profile;
19use crate::scroll_state::ScrollState;
20use crate::session_logger::{SessionLogger, SharedSessionLogger, create_shared_logger};
21use crate::tab::initial_text::build_initial_text_payload;
22use crate::terminal::TerminalManager;
23use par_term_emu_core_rust::coprocess::CoprocessId;
24use std::sync::Arc;
25use tokio::runtime::Runtime;
26use tokio::sync::Mutex;
27use tokio::task::JoinHandle;
28
29/// Configure a terminal with settings from config (theme, clipboard limits, cursor style, unicode)
30fn configure_terminal_from_config(terminal: &mut TerminalManager, config: &Config) {
31    // Set theme from config
32    terminal.set_theme(config.load_theme());
33
34    // Apply clipboard history limits from config
35    terminal.set_max_clipboard_sync_events(config.clipboard_max_sync_events);
36    terminal.set_max_clipboard_event_bytes(config.clipboard_max_event_bytes);
37
38    // Set answerback string for ENQ response (if configured)
39    if !config.answerback_string.is_empty() {
40        terminal.set_answerback_string(Some(config.answerback_string.clone()));
41    }
42
43    // Apply Unicode width configuration
44    let width_config =
45        par_term_emu_core_rust::WidthConfig::new(config.unicode_version, config.ambiguous_width);
46    terminal.set_width_config(width_config);
47
48    // Apply Unicode normalization form
49    terminal.set_normalization_form(config.normalization_form);
50
51    // Initialize cursor style from config
52    use crate::config::CursorStyle as ConfigCursorStyle;
53    use par_term_emu_core_rust::cursor::CursorStyle as TermCursorStyle;
54    let term_style = if config.cursor_blink {
55        match config.cursor_style {
56            ConfigCursorStyle::Block => TermCursorStyle::BlinkingBlock,
57            ConfigCursorStyle::Underline => TermCursorStyle::BlinkingUnderline,
58            ConfigCursorStyle::Beam => TermCursorStyle::BlinkingBar,
59        }
60    } else {
61        match config.cursor_style {
62            ConfigCursorStyle::Block => TermCursorStyle::SteadyBlock,
63            ConfigCursorStyle::Underline => TermCursorStyle::SteadyUnderline,
64            ConfigCursorStyle::Beam => TermCursorStyle::SteadyBar,
65        }
66    };
67    terminal.set_cursor_style(term_style);
68}
69
70/// Get the platform-specific PATH separator
71#[cfg(target_os = "windows")]
72const PATH_SEPARATOR: char = ';';
73#[cfg(not(target_os = "windows"))]
74const PATH_SEPARATOR: char = ':';
75
76/// Build environment variables with an augmented PATH
77///
78/// When launched from Finder on macOS (or similar on other platforms), the PATH may be minimal.
79/// This function augments the PATH with common directories where user tools are installed.
80pub(crate) fn build_shell_env(
81    config_env: Option<&std::collections::HashMap<String, String>>,
82) -> Option<std::collections::HashMap<String, String>> {
83    // Advertise as iTerm.app for maximum compatibility with tools that check
84    // TERM_PROGRAM for feature detection (progress bars, hyperlinks, clipboard, etc.)
85    // par-term supports all the relevant iTerm2 protocols (OSC 8, 9;4, 52, 1337).
86    let mut env = std::collections::HashMap::new();
87    env.insert("TERM_PROGRAM".to_string(), "iTerm.app".to_string());
88    env.insert("TERM_PROGRAM_VERSION".to_string(), "3.6.6".to_string());
89    env.insert("LC_TERMINAL".to_string(), "iTerm2".to_string());
90    env.insert("LC_TERMINAL_VERSION".to_string(), "3.6.6".to_string());
91    // par-term identity marker for shell integration scripts to detect
92    env.insert("__PAR_TERM".to_string(), "1".to_string());
93
94    // ITERM_SESSION_ID: used by Claude Code and other tools for OSC 52 clipboard detection
95    // Format: w{window}t{tab}p{pane}:{UUID}
96    let session_uuid = uuid::Uuid::new_v4();
97    env.insert(
98        "ITERM_SESSION_ID".to_string(),
99        format!("w0t0p0:{session_uuid}"),
100    );
101
102    // Merge user-configured shell_env (user values take precedence)
103    if let Some(config) = config_env {
104        for (key, value) in config {
105            env.insert(key.clone(), value.clone());
106        }
107    }
108
109    // Build augmented PATH with platform-specific extra directories
110    let current_path = std::env::var("PATH").unwrap_or_default();
111    let extra_paths = build_platform_extra_paths();
112    let new_paths: Vec<String> = extra_paths
113        .into_iter()
114        .filter(|p| !p.is_empty() && !current_path.contains(p) && std::path::Path::new(p).exists())
115        .collect();
116
117    let augmented_path = if new_paths.is_empty() {
118        current_path
119    } else {
120        format!(
121            "{}{}{}",
122            new_paths.join(&PATH_SEPARATOR.to_string()),
123            PATH_SEPARATOR,
124            current_path
125        )
126    };
127    env.insert("PATH".to_string(), augmented_path);
128
129    Some(env)
130}
131
132/// Build the list of extra PATH directories for the current platform
133#[cfg(target_os = "windows")]
134fn build_platform_extra_paths() -> Vec<String> {
135    let mut paths = Vec::new();
136
137    if let Some(home) = dirs::home_dir() {
138        // Cargo bin
139        paths.push(
140            home.join(".cargo")
141                .join("bin")
142                .to_string_lossy()
143                .to_string(),
144        );
145        // Scoop
146        paths.push(
147            home.join("scoop")
148                .join("shims")
149                .to_string_lossy()
150                .to_string(),
151        );
152        // Go bin
153        paths.push(home.join("go").join("bin").to_string_lossy().to_string());
154    }
155
156    // Chocolatey
157    paths.push(r"C:\ProgramData\chocolatey\bin".to_string());
158
159    // Common program locations
160    if let Some(local_app_data) = dirs::data_local_dir() {
161        // Python (common location)
162        paths.push(
163            local_app_data
164                .join("Programs")
165                .join("Python")
166                .join("Python312")
167                .join("Scripts")
168                .to_string_lossy()
169                .to_string(),
170        );
171        paths.push(
172            local_app_data
173                .join("Programs")
174                .join("Python")
175                .join("Python311")
176                .join("Scripts")
177                .to_string_lossy()
178                .to_string(),
179        );
180    }
181
182    paths
183}
184
185/// Build the list of extra PATH directories for Unix platforms (macOS/Linux)
186#[cfg(not(target_os = "windows"))]
187fn build_platform_extra_paths() -> Vec<String> {
188    let mut paths = Vec::new();
189
190    if let Some(home) = dirs::home_dir() {
191        // User's home .local/bin (common for pip, pipx, etc.)
192        paths.push(
193            home.join(".local")
194                .join("bin")
195                .to_string_lossy()
196                .to_string(),
197        );
198        // Cargo bin
199        paths.push(
200            home.join(".cargo")
201                .join("bin")
202                .to_string_lossy()
203                .to_string(),
204        );
205        // Go bin
206        paths.push(home.join("go").join("bin").to_string_lossy().to_string());
207        // Nix user profile
208        paths.push(
209            home.join(".nix-profile")
210                .join("bin")
211                .to_string_lossy()
212                .to_string(),
213        );
214    }
215
216    // Nix system profile
217    paths.push("/nix/var/nix/profiles/default/bin".to_string());
218
219    // macOS-specific paths
220    #[cfg(target_os = "macos")]
221    {
222        // Homebrew on Apple Silicon
223        paths.push("/opt/homebrew/bin".to_string());
224        paths.push("/opt/homebrew/sbin".to_string());
225        // Homebrew on Intel Mac
226        paths.push("/usr/local/bin".to_string());
227        paths.push("/usr/local/sbin".to_string());
228        // MacPorts
229        paths.push("/opt/local/bin".to_string());
230    }
231
232    // Linux-specific paths
233    #[cfg(target_os = "linux")]
234    {
235        // Common system paths that might be missing
236        paths.push("/usr/local/bin".to_string());
237        // Snap
238        paths.push("/snap/bin".to_string());
239        // Flatpak exports
240        if let Some(home) = dirs::home_dir() {
241            paths.push(
242                home.join(".local")
243                    .join("share")
244                    .join("flatpak")
245                    .join("exports")
246                    .join("bin")
247                    .to_string_lossy()
248                    .to_string(),
249            );
250        }
251        paths.push("/var/lib/flatpak/exports/bin".to_string());
252    }
253
254    paths
255}
256
257/// Determine the shell command and arguments to use based on config
258fn get_shell_command(config: &Config) -> (String, Option<Vec<String>>) {
259    if let Some(ref custom) = config.custom_shell {
260        (custom.clone(), config.shell_args.clone())
261    } else {
262        #[cfg(target_os = "windows")]
263        {
264            ("powershell.exe".to_string(), None)
265        }
266        #[cfg(not(target_os = "windows"))]
267        {
268            (
269                std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
270                None,
271            )
272        }
273    }
274}
275
276/// Apply login shell flag if configured (Unix only)
277#[cfg(not(target_os = "windows"))]
278fn apply_login_shell_flag(shell_args: &mut Option<Vec<String>>, config: &Config) {
279    if config.login_shell {
280        let args = shell_args.get_or_insert_with(Vec::new);
281        if !args.iter().any(|a| a == "-l" || a == "--login") {
282            args.insert(0, "-l".to_string());
283        }
284    }
285}
286
287#[cfg(target_os = "windows")]
288fn apply_login_shell_flag(_shell_args: &mut Option<Vec<String>>, _config: &Config) {
289    // No-op on Windows
290}
291
292// Re-export TabId from par-term-config for shared access across subcrates
293pub use par_term_config::TabId;
294
295/// A single terminal tab with its own state (supports split panes)
296pub struct Tab {
297    /// Unique identifier for this tab
298    pub id: TabId,
299    /// The terminal session for this tab (legacy - use pane_manager for new code)
300    pub terminal: Arc<Mutex<TerminalManager>>,
301    /// Pane manager for split pane support
302    pub pane_manager: Option<PaneManager>,
303    /// Tab title (from OSC sequences or fallback)
304    pub title: String,
305    /// Whether this tab has unread activity since last viewed
306    pub has_activity: bool,
307    /// Scroll state for this tab (legacy - each pane has its own)
308    pub scroll_state: ScrollState,
309    /// Mouse state for this tab (legacy - each pane has its own)
310    pub mouse: MouseState,
311    /// Bell state for this tab (legacy - each pane has its own)
312    pub bell: BellState,
313    /// Render cache for this tab (legacy - each pane has its own)
314    pub cache: RenderCache,
315    /// Async task for refresh polling
316    pub refresh_task: Option<JoinHandle<()>>,
317    /// Working directory when tab was created (for inheriting)
318    pub working_directory: Option<String>,
319    /// Custom tab color [R, G, B] (0-255), overrides config colors when set
320    pub custom_color: Option<[u8; 3]>,
321    /// Whether the tab has its default "Tab N" title (not set by OSC, CWD, or user)
322    pub has_default_title: bool,
323    /// Last time terminal output (activity) was detected
324    pub last_activity_time: std::time::Instant,
325    /// Last terminal update generation seen (to detect new output)
326    pub last_seen_generation: u64,
327    /// Last activity time for anti-idle keep-alive
328    pub anti_idle_last_activity: std::time::Instant,
329    /// Last terminal generation recorded for anti-idle tracking
330    pub anti_idle_last_generation: u64,
331    /// Whether silence notification has been sent for current idle period
332    pub silence_notified: bool,
333    /// Whether exit notification has been sent for this tab
334    pub exit_notified: bool,
335    /// Session logger for automatic session recording
336    pub session_logger: SharedSessionLogger,
337    /// Whether this tab is in tmux gateway mode
338    pub tmux_gateway_active: bool,
339    /// The tmux pane ID this tab represents (when in gateway mode)
340    pub tmux_pane_id: Option<crate::tmux::TmuxPaneId>,
341    /// Last detected hostname for automatic profile switching (from OSC 7)
342    pub detected_hostname: Option<String>,
343    /// Last detected CWD for automatic profile switching (from OSC 7)
344    pub detected_cwd: Option<String>,
345    /// Profile ID that was auto-applied based on hostname detection
346    pub auto_applied_profile_id: Option<crate::profile::ProfileId>,
347    /// Profile ID that was auto-applied based on directory pattern matching
348    pub auto_applied_dir_profile_id: Option<crate::profile::ProfileId>,
349    /// Icon from auto-applied profile (displayed in tab bar)
350    pub profile_icon: Option<String>,
351    /// Original tab title saved before auto-profile override (restored when profile clears)
352    pub pre_profile_title: Option<String>,
353    /// Badge text override from auto-applied profile (overrides global badge_format)
354    pub badge_override: Option<String>,
355    /// Mapping from config index to coprocess ID (for UI tracking)
356    pub coprocess_ids: Vec<Option<CoprocessId>>,
357    /// Script manager for this tab
358    pub script_manager: crate::scripting::manager::ScriptManager,
359    /// Maps config index to ScriptId for running scripts
360    pub script_ids: Vec<Option<crate::scripting::manager::ScriptId>>,
361    /// Observer IDs registered with the terminal for script event forwarding
362    pub script_observer_ids: Vec<Option<par_term_emu_core_rust::observer::ObserverId>>,
363    /// Event forwarders (shared with observer registration)
364    pub script_forwarders:
365        Vec<Option<std::sync::Arc<crate::scripting::observer::ScriptEventForwarder>>>,
366    /// Trigger-generated scrollbar marks (from MarkLine actions)
367    pub trigger_marks: Vec<crate::scrollback_metadata::ScrollbackMark>,
368    /// Profile saved before SSH auto-switch (for revert on disconnect)
369    pub pre_ssh_switch_profile: Option<crate::profile::ProfileId>,
370    /// Whether current profile was auto-applied due to SSH hostname detection
371    pub ssh_auto_switched: bool,
372    /// When true, Drop impl skips cleanup (terminal Arcs are dropped on background threads)
373    pub(crate) shutdown_fast: bool,
374}
375
376impl Tab {
377    /// Create a new tab with a terminal session
378    ///
379    /// # Arguments
380    /// * `id` - Unique tab identifier
381    /// * `tab_number` - Display number for the tab (1-indexed)
382    /// * `config` - Terminal configuration
383    /// * `runtime` - Tokio runtime for async operations
384    /// * `working_directory` - Optional working directory to start in
385    /// * `grid_size` - Optional (cols, rows) override. When provided, uses these
386    ///   dimensions instead of config.cols/rows. This ensures the shell starts
387    ///   with the correct dimensions when the renderer has already calculated
388    ///   the grid size accounting for tab bar height.
389    pub fn new(
390        id: TabId,
391        tab_number: usize,
392        config: &Config,
393        runtime: Arc<Runtime>,
394        working_directory: Option<String>,
395        grid_size: Option<(usize, usize)>,
396    ) -> anyhow::Result<Self> {
397        // Use provided grid size if available, otherwise fall back to config
398        let (cols, rows) = grid_size.unwrap_or((config.cols, config.rows));
399
400        // Create terminal with scrollback from config
401        let mut terminal =
402            TerminalManager::new_with_scrollback(cols, rows, config.scrollback_lines)?;
403
404        // Apply common terminal configuration
405        configure_terminal_from_config(&mut terminal, config);
406
407        // Determine working directory:
408        // 1. If explicitly provided (e.g., from tab_inherit_cwd), use that
409        // 2. Otherwise, use the configured startup directory based on mode
410        let effective_startup_dir = config.get_effective_startup_directory();
411        let work_dir = working_directory
412            .as_deref()
413            .or(effective_startup_dir.as_deref());
414
415        // Get shell command and apply login shell flag
416        let (shell_cmd, mut shell_args) = get_shell_command(config);
417        apply_login_shell_flag(&mut shell_args, config);
418
419        let shell_args_deref = shell_args.as_deref();
420        let shell_env = build_shell_env(config.shell_env.as_ref());
421        terminal.spawn_custom_shell_with_dir(
422            &shell_cmd,
423            shell_args_deref,
424            work_dir,
425            shell_env.as_ref(),
426        )?;
427
428        // Sync triggers from config into the core TriggerRegistry
429        terminal.sync_triggers(&config.triggers);
430
431        // Auto-start configured coprocesses via the PtySession's built-in manager
432        let mut coprocess_ids = Vec::with_capacity(config.coprocesses.len());
433        for coproc_config in &config.coprocesses {
434            if coproc_config.auto_start {
435                let core_config = par_term_emu_core_rust::coprocess::CoprocessConfig {
436                    command: coproc_config.command.clone(),
437                    args: coproc_config.args.clone(),
438                    cwd: None,
439                    env: crate::terminal::coprocess_env(),
440                    copy_terminal_output: coproc_config.copy_terminal_output,
441                    restart_policy: coproc_config.restart_policy.to_core(),
442                    restart_delay_ms: coproc_config.restart_delay_ms,
443                };
444                match terminal.start_coprocess(core_config) {
445                    Ok(id) => {
446                        log::info!(
447                            "Auto-started coprocess '{}' (id={})",
448                            coproc_config.name,
449                            id
450                        );
451                        coprocess_ids.push(Some(id));
452                    }
453                    Err(e) => {
454                        log::warn!(
455                            "Failed to auto-start coprocess '{}': {}",
456                            coproc_config.name,
457                            e
458                        );
459                        coprocess_ids.push(None);
460                    }
461                }
462            } else {
463                coprocess_ids.push(None);
464            }
465        }
466
467        // Create shared session logger
468        let session_logger = create_shared_logger();
469
470        // Set up session logging if enabled
471        if config.auto_log_sessions {
472            let logs_dir = config.logs_dir();
473            let session_title = Some(format!(
474                "Tab {} - {}",
475                tab_number,
476                chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
477            ));
478
479            match SessionLogger::new(
480                config.session_log_format,
481                &logs_dir,
482                (config.cols, config.rows),
483                session_title,
484            ) {
485                Ok(mut logger) => {
486                    if let Err(e) = logger.start() {
487                        log::warn!("Failed to start session logging: {}", e);
488                    } else {
489                        log::info!("Session logging started: {:?}", logger.output_path());
490
491                        // Set up output callback to record PTY output
492                        let logger_clone = Arc::clone(&session_logger);
493                        terminal.set_output_callback(move |data: &[u8]| {
494                            if let Some(ref mut logger) = *logger_clone.lock() {
495                                logger.record_output(data);
496                            }
497                        });
498
499                        *session_logger.lock() = Some(logger);
500                    }
501                }
502                Err(e) => {
503                    log::warn!("Failed to create session logger: {}", e);
504                }
505            }
506        }
507
508        let terminal = Arc::new(Mutex::new(terminal));
509
510        // Send initial text after optional delay
511        if let Some(payload) =
512            build_initial_text_payload(&config.initial_text, config.initial_text_send_newline)
513        {
514            let delay_ms = config.initial_text_delay_ms;
515            let terminal_clone = Arc::clone(&terminal);
516            runtime.spawn(async move {
517                if delay_ms > 0 {
518                    tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
519                }
520
521                let term = terminal_clone.lock().await;
522                if let Err(err) = term.write(&payload) {
523                    log::warn!("Failed to send initial text: {}", err);
524                }
525            });
526        }
527
528        // Generate initial title based on current tab count, not unique ID
529        let title = format!("Tab {}", tab_number);
530
531        Ok(Self {
532            id,
533            terminal,
534            pane_manager: None, // Created on first split
535            title,
536            has_activity: false,
537            scroll_state: ScrollState::new(),
538            mouse: MouseState::new(),
539            bell: BellState::new(),
540            cache: RenderCache::new(),
541            refresh_task: None,
542            working_directory: working_directory.or_else(|| config.working_directory.clone()),
543            custom_color: None,
544            has_default_title: true,
545            last_activity_time: std::time::Instant::now(),
546            last_seen_generation: 0,
547            anti_idle_last_activity: std::time::Instant::now(),
548            anti_idle_last_generation: 0,
549            silence_notified: false,
550            exit_notified: false,
551            session_logger,
552            tmux_gateway_active: false,
553            tmux_pane_id: None,
554            detected_hostname: None,
555            detected_cwd: None,
556            auto_applied_profile_id: None,
557            auto_applied_dir_profile_id: None,
558            profile_icon: None,
559            pre_profile_title: None,
560            badge_override: None,
561            coprocess_ids,
562            script_manager: crate::scripting::manager::ScriptManager::new(),
563            script_ids: Vec::new(),
564            script_observer_ids: Vec::new(),
565            script_forwarders: Vec::new(),
566            trigger_marks: Vec::new(),
567            pre_ssh_switch_profile: None,
568            ssh_auto_switched: false,
569            shutdown_fast: false,
570        })
571    }
572
573    /// Create a new tab from a profile configuration
574    ///
575    /// The profile can override:
576    /// - Working directory
577    /// - Command and arguments (instead of default shell)
578    /// - Tab name
579    ///
580    /// If a profile specifies a command, it always runs from the profile's working
581    /// directory (or config default if unset).
582    ///
583    /// # Arguments
584    /// * `id` - Unique tab identifier
585    /// * `config` - Terminal configuration
586    /// * `_runtime` - Tokio runtime (unused but kept for API consistency)
587    /// * `profile` - Profile configuration to use
588    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
589    pub fn new_from_profile(
590        id: TabId,
591        config: &Config,
592        _runtime: Arc<Runtime>,
593        profile: &Profile,
594        grid_size: Option<(usize, usize)>,
595    ) -> anyhow::Result<Self> {
596        // Use provided grid size if available, otherwise fall back to config
597        let (cols, rows) = grid_size.unwrap_or((config.cols, config.rows));
598
599        // Create terminal with scrollback from config
600        let mut terminal =
601            TerminalManager::new_with_scrollback(cols, rows, config.scrollback_lines)?;
602
603        // Apply common terminal configuration
604        configure_terminal_from_config(&mut terminal, config);
605
606        // Determine working directory: profile overrides config startup directory
607        let effective_startup_dir = config.get_effective_startup_directory();
608        let work_dir = profile
609            .working_directory
610            .as_deref()
611            .or(effective_startup_dir.as_deref());
612
613        // Determine command and args with priority:
614        // 0. profile.ssh_host → build ssh command with user/port/identity args
615        // 1. profile.command → use as-is (non-shell commands like tmux, ssh)
616        // 2. profile.shell → use as shell, apply login_shell logic
617        // 3. neither → fall back to global config shell / $SHELL
618        let is_ssh_profile = profile.ssh_host.is_some();
619        let (shell_cmd, mut shell_args) = if let Some(ssh_args) = profile.ssh_command_args() {
620            ("ssh".to_string(), Some(ssh_args))
621        } else if let Some(ref cmd) = profile.command {
622            (cmd.clone(), profile.command_args.clone())
623        } else if let Some(ref shell) = profile.shell {
624            (shell.clone(), None)
625        } else {
626            get_shell_command(config)
627        };
628
629        // Apply login shell flag when using a shell (not a custom command or SSH profile).
630        // Per-profile login_shell overrides global config.login_shell.
631        if profile.command.is_none() && !is_ssh_profile {
632            let use_login_shell = profile.login_shell.unwrap_or(config.login_shell);
633            if use_login_shell {
634                let args = shell_args.get_or_insert_with(Vec::new);
635                #[cfg(not(target_os = "windows"))]
636                if !args.iter().any(|a| a == "-l" || a == "--login") {
637                    args.insert(0, "-l".to_string());
638                }
639            }
640        }
641
642        let shell_args_deref = shell_args.as_deref();
643        let mut shell_env = build_shell_env(config.shell_env.as_ref());
644
645        // When a profile specifies a shell, set the SHELL env var so child
646        // processes (and $SHELL) reflect the selected shell, not the login shell.
647        if profile.command.is_none()
648            && let Some(ref shell_path) = profile.shell
649            && let Some(ref mut env) = shell_env
650        {
651            env.insert("SHELL".to_string(), shell_path.clone());
652        }
653
654        terminal.spawn_custom_shell_with_dir(
655            &shell_cmd,
656            shell_args_deref,
657            work_dir,
658            shell_env.as_ref(),
659        )?;
660
661        // Sync triggers from config into the core TriggerRegistry
662        terminal.sync_triggers(&config.triggers);
663
664        // Auto-start configured coprocesses via the PtySession's built-in manager
665        let mut coprocess_ids = Vec::with_capacity(config.coprocesses.len());
666        for coproc_config in &config.coprocesses {
667            if coproc_config.auto_start {
668                let core_config = par_term_emu_core_rust::coprocess::CoprocessConfig {
669                    command: coproc_config.command.clone(),
670                    args: coproc_config.args.clone(),
671                    cwd: None,
672                    env: crate::terminal::coprocess_env(),
673                    copy_terminal_output: coproc_config.copy_terminal_output,
674                    restart_policy: coproc_config.restart_policy.to_core(),
675                    restart_delay_ms: coproc_config.restart_delay_ms,
676                };
677                match terminal.start_coprocess(core_config) {
678                    Ok(id) => {
679                        log::info!(
680                            "Auto-started coprocess '{}' (id={})",
681                            coproc_config.name,
682                            id
683                        );
684                        coprocess_ids.push(Some(id));
685                    }
686                    Err(e) => {
687                        log::warn!(
688                            "Failed to auto-start coprocess '{}': {}",
689                            coproc_config.name,
690                            e
691                        );
692                        coprocess_ids.push(None);
693                    }
694                }
695            } else {
696                coprocess_ids.push(None);
697            }
698        }
699
700        // Create shared session logger
701        let session_logger = create_shared_logger();
702
703        // Set up session logging if enabled
704        if config.auto_log_sessions {
705            let logs_dir = config.logs_dir();
706            let session_title = Some(format!(
707                "{} - {}",
708                profile.name,
709                chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
710            ));
711
712            match SessionLogger::new(
713                config.session_log_format,
714                &logs_dir,
715                (config.cols, config.rows),
716                session_title,
717            ) {
718                Ok(mut logger) => {
719                    if let Err(e) = logger.start() {
720                        log::warn!("Failed to start session logging for profile: {}", e);
721                    } else {
722                        log::info!(
723                            "Session logging started for profile '{}': {:?}",
724                            profile.name,
725                            logger.output_path()
726                        );
727
728                        // Set up output callback to record PTY output
729                        let logger_clone = Arc::clone(&session_logger);
730                        terminal.set_output_callback(move |data: &[u8]| {
731                            if let Some(ref mut logger) = *logger_clone.lock() {
732                                logger.record_output(data);
733                            }
734                        });
735
736                        *session_logger.lock() = Some(logger);
737                    }
738                }
739                Err(e) => {
740                    log::warn!("Failed to create session logger for profile: {}", e);
741                }
742            }
743        }
744
745        let terminal = Arc::new(Mutex::new(terminal));
746
747        // Generate title: use profile tab_name or profile name or default
748        let title = profile
749            .tab_name
750            .clone()
751            .unwrap_or_else(|| profile.name.clone());
752
753        let working_directory = profile
754            .working_directory
755            .clone()
756            .or_else(|| config.working_directory.clone());
757
758        Ok(Self {
759            id,
760            terminal,
761            pane_manager: None, // Created on first split
762            title,
763            has_activity: false,
764            scroll_state: ScrollState::new(),
765            mouse: MouseState::new(),
766            bell: BellState::new(),
767            cache: RenderCache::new(),
768            refresh_task: None,
769            working_directory,
770            custom_color: None,
771            has_default_title: false, // Profile-created tabs have explicit names
772            last_activity_time: std::time::Instant::now(),
773            last_seen_generation: 0,
774            anti_idle_last_activity: std::time::Instant::now(),
775            anti_idle_last_generation: 0,
776            silence_notified: false,
777            exit_notified: false,
778            session_logger,
779            tmux_gateway_active: false,
780            tmux_pane_id: None,
781            detected_hostname: None,
782            detected_cwd: None,
783            auto_applied_profile_id: None,
784            auto_applied_dir_profile_id: None,
785            profile_icon: None,
786            pre_profile_title: None,
787            badge_override: None,
788            coprocess_ids,
789            script_manager: crate::scripting::manager::ScriptManager::new(),
790            script_ids: Vec::new(),
791            script_observer_ids: Vec::new(),
792            script_forwarders: Vec::new(),
793            trigger_marks: Vec::new(),
794            pre_ssh_switch_profile: None,
795            ssh_auto_switched: false,
796            shutdown_fast: false,
797        })
798    }
799
800    /// Check if the visual bell is currently active (within flash duration)
801    pub fn is_bell_active(&self) -> bool {
802        const FLASH_DURATION_MS: u128 = 150;
803        if let Some(flash_start) = self.bell.visual_flash {
804            flash_start.elapsed().as_millis() < FLASH_DURATION_MS
805        } else {
806            false
807        }
808    }
809
810    /// Update tab title from terminal OSC sequences
811    pub fn update_title(&mut self) {
812        if let Ok(term) = self.terminal.try_lock() {
813            let osc_title = term.get_title();
814            if !osc_title.is_empty() {
815                self.title = osc_title;
816                self.has_default_title = false;
817            } else if let Some(cwd) = term.shell_integration_cwd() {
818                // Abbreviate home directory to ~
819                let abbreviated = if let Some(home) = dirs::home_dir() {
820                    cwd.replace(&home.to_string_lossy().to_string(), "~")
821                } else {
822                    cwd
823                };
824                // Use just the last component for brevity
825                if let Some(last) = abbreviated.rsplit('/').next() {
826                    if !last.is_empty() {
827                        self.title = last.to_string();
828                    } else {
829                        self.title = abbreviated;
830                    }
831                } else {
832                    self.title = abbreviated;
833                }
834                self.has_default_title = false;
835            }
836            // Otherwise keep the existing title (e.g., "Tab N")
837        }
838    }
839
840    /// Set the tab's default title based on its position
841    pub fn set_default_title(&mut self, tab_number: usize) {
842        if self.has_default_title {
843            self.title = format!("Tab {}", tab_number);
844        }
845    }
846
847    /// Explicitly set the tab title (for tmux window names, etc.)
848    ///
849    /// This overrides any default title and marks the tab as having a custom title.
850    pub fn set_title(&mut self, title: &str) {
851        self.title = title.to_string();
852        self.has_default_title = false;
853    }
854
855    /// Check if the terminal in this tab is still running
856    #[allow(dead_code)]
857    pub fn is_running(&self) -> bool {
858        if let Ok(term) = self.terminal.try_lock() {
859            term.is_running()
860        } else {
861            true // Assume running if locked
862        }
863    }
864
865    /// Get the current working directory of this tab's shell
866    pub fn get_cwd(&self) -> Option<String> {
867        if let Ok(term) = self.terminal.try_lock() {
868            term.shell_integration_cwd()
869        } else {
870            self.working_directory.clone()
871        }
872    }
873
874    /// Restore a pane layout from a saved session
875    ///
876    /// Replaces the current single-pane layout with a saved pane tree.
877    /// Each leaf in the tree gets a new terminal session with the saved CWD.
878    /// If the build fails, the tab keeps its existing single pane.
879    pub fn restore_pane_layout(
880        &mut self,
881        layout: &crate::session::SessionPaneNode,
882        config: &Config,
883        runtime: Arc<Runtime>,
884    ) {
885        let mut pm = PaneManager::new();
886        pm.set_divider_width(config.pane_divider_width.unwrap_or(1.0));
887        pm.set_divider_hit_width(config.pane_divider_hit_width);
888
889        match pm.build_from_layout(layout, config, runtime) {
890            Ok(()) => {
891                log::info!(
892                    "Restored pane layout for tab {} ({} panes)",
893                    self.id,
894                    pm.pane_count()
895                );
896                self.pane_manager = Some(pm);
897            }
898            Err(e) => {
899                log::warn!(
900                    "Failed to restore pane layout for tab {}: {}, keeping single pane",
901                    self.id,
902                    e
903                );
904            }
905        }
906    }
907
908    /// Parse hostname from an OSC 7 file:// URL
909    ///
910    /// OSC 7 format: `file://hostname/path` or `file:///path` (localhost)
911    /// Returns the hostname if present and not localhost, None otherwise.
912    pub fn parse_hostname_from_osc7_url(url: &str) -> Option<String> {
913        let path = url.strip_prefix("file://")?;
914
915        if path.starts_with('/') {
916            // file:///path - localhost implicit
917            None
918        } else {
919            // file://hostname/path - extract hostname
920            let hostname = path.split('/').next()?;
921            if hostname.is_empty() || hostname == "localhost" {
922                None
923            } else {
924                Some(hostname.to_string())
925            }
926        }
927    }
928
929    /// Check if hostname has changed and update tracking
930    ///
931    /// Returns Some(hostname) if a new remote hostname was detected,
932    /// None if hostname hasn't changed or is local.
933    ///
934    /// This uses the hostname extracted from OSC 7 sequences by the terminal emulator.
935    pub fn check_hostname_change(&mut self) -> Option<String> {
936        let current_hostname = if let Ok(term) = self.terminal.try_lock() {
937            term.shell_integration_hostname()
938        } else {
939            return None;
940        };
941
942        // Check if hostname has changed
943        if current_hostname != self.detected_hostname {
944            let old_hostname = self.detected_hostname.take();
945            self.detected_hostname = current_hostname.clone();
946
947            crate::debug_info!(
948                "PROFILE",
949                "Hostname changed: {:?} -> {:?}",
950                old_hostname,
951                current_hostname
952            );
953
954            // Return the new hostname if it's a remote host (not None/localhost)
955            current_hostname
956        } else {
957            None
958        }
959    }
960
961    /// Check if CWD has changed and update tracking
962    ///
963    /// Returns Some(cwd) if the CWD has changed, None otherwise.
964    /// Uses the CWD reported via OSC 7 by the terminal emulator.
965    pub fn check_cwd_change(&mut self) -> Option<String> {
966        let current_cwd = self.get_cwd();
967
968        if current_cwd != self.detected_cwd {
969            let old_cwd = self.detected_cwd.take();
970            self.detected_cwd = current_cwd.clone();
971
972            crate::debug_info!("PROFILE", "CWD changed: {:?} -> {:?}", old_cwd, current_cwd);
973
974            current_cwd
975        } else {
976            None
977        }
978    }
979
980    /// Clear auto-applied profile tracking
981    ///
982    /// Call this when manually switching profiles or when the hostname
983    /// returns to local, or when disconnecting from tmux.
984    pub fn clear_auto_profile(&mut self) {
985        self.auto_applied_profile_id = None;
986        self.auto_applied_dir_profile_id = None;
987        self.profile_icon = None;
988        if let Some(original) = self.pre_profile_title.take() {
989            self.title = original;
990        }
991        self.badge_override = None;
992    }
993
994    /// Start the refresh polling task for this tab
995    pub fn start_refresh_task(
996        &mut self,
997        runtime: Arc<Runtime>,
998        window: Arc<winit::window::Window>,
999        max_fps: u32,
1000    ) {
1001        let terminal_clone = Arc::clone(&self.terminal);
1002        let refresh_interval_ms = 1000 / max_fps.max(1);
1003
1004        let handle = runtime.spawn(async move {
1005            let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(
1006                refresh_interval_ms as u64,
1007            ));
1008            let mut last_gen = 0;
1009
1010            loop {
1011                interval.tick().await;
1012
1013                let should_redraw = if let Ok(term) = terminal_clone.try_lock() {
1014                    let current_gen = term.update_generation();
1015                    if current_gen > last_gen {
1016                        last_gen = current_gen;
1017                        true
1018                    } else {
1019                        term.has_updates()
1020                    }
1021                } else {
1022                    false
1023                };
1024
1025                if should_redraw {
1026                    window.request_redraw();
1027                }
1028            }
1029        });
1030
1031        self.refresh_task = Some(handle);
1032    }
1033
1034    /// Stop the refresh polling task
1035    pub fn stop_refresh_task(&mut self) {
1036        if let Some(handle) = self.refresh_task.take() {
1037            handle.abort();
1038        }
1039    }
1040
1041    /// Set a custom color for this tab
1042    pub fn set_custom_color(&mut self, color: [u8; 3]) {
1043        self.custom_color = Some(color);
1044    }
1045
1046    /// Clear the custom color for this tab (reverts to default config colors)
1047    pub fn clear_custom_color(&mut self) {
1048        self.custom_color = None;
1049    }
1050
1051    /// Check if this tab has a custom color set
1052    #[allow(dead_code)]
1053    pub fn has_custom_color(&self) -> bool {
1054        self.custom_color.is_some()
1055    }
1056
1057    /// Toggle session logging on/off.
1058    ///
1059    /// Returns `Ok(true)` if logging is now active, `Ok(false)` if stopped.
1060    /// If logging wasn't active and no logger exists, creates a new one.
1061    pub fn toggle_session_logging(&mut self, config: &Config) -> anyhow::Result<bool> {
1062        let mut logger_guard = self.session_logger.lock();
1063
1064        if let Some(ref mut logger) = *logger_guard {
1065            // Logger exists - toggle based on current state
1066            if logger.is_active() {
1067                logger.stop()?;
1068                log::info!("Session logging stopped via hotkey");
1069                Ok(false)
1070            } else {
1071                logger.start()?;
1072                log::info!("Session logging started via hotkey");
1073                Ok(true)
1074            }
1075        } else {
1076            // No logger exists - create one and start it
1077            let logs_dir = config.logs_dir();
1078            if let Err(e) = std::fs::create_dir_all(&logs_dir) {
1079                log::warn!("Failed to create logs directory: {}", e);
1080                return Err(anyhow::anyhow!("Failed to create logs directory: {}", e));
1081            }
1082
1083            // Get terminal dimensions
1084            let dimensions = if let Ok(term) = self.terminal.try_lock() {
1085                term.dimensions()
1086            } else {
1087                (80, 24) // fallback
1088            };
1089
1090            let session_title = Some(format!(
1091                "{} - {}",
1092                self.title,
1093                chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1094            ));
1095
1096            let mut logger = SessionLogger::new(
1097                config.session_log_format,
1098                &logs_dir,
1099                dimensions,
1100                session_title,
1101            )?;
1102
1103            logger.start()?;
1104
1105            // Set up output callback to record PTY output
1106            let logger_clone = Arc::clone(&self.session_logger);
1107            if let Ok(term) = self.terminal.try_lock() {
1108                term.set_output_callback(move |data: &[u8]| {
1109                    if let Some(ref mut logger) = *logger_clone.lock() {
1110                        logger.record_output(data);
1111                    }
1112                });
1113            }
1114
1115            *logger_guard = Some(logger);
1116            log::info!("Session logging created and started via hotkey");
1117            Ok(true)
1118        }
1119    }
1120
1121    /// Check if session logging is currently active.
1122    pub fn is_session_logging_active(&self) -> bool {
1123        if let Some(ref logger) = *self.session_logger.lock() {
1124            logger.is_active()
1125        } else {
1126            false
1127        }
1128    }
1129
1130    // ========================================================================
1131    // Split Pane Support
1132    // ========================================================================
1133
1134    /// Check if this tab has multiple panes (split)
1135    pub fn has_multiple_panes(&self) -> bool {
1136        self.pane_manager
1137            .as_ref()
1138            .is_some_and(|pm| pm.has_multiple_panes())
1139    }
1140
1141    /// Get the number of panes in this tab
1142    pub fn pane_count(&self) -> usize {
1143        self.pane_manager
1144            .as_ref()
1145            .map(|pm| pm.pane_count())
1146            .unwrap_or(1)
1147    }
1148
1149    /// Split the current pane horizontally (panes stacked vertically)
1150    ///
1151    /// Returns the new pane ID if successful.
1152    /// `dpi_scale` converts logical pixel config values to physical pixels.
1153    pub fn split_horizontal(
1154        &mut self,
1155        config: &Config,
1156        runtime: Arc<Runtime>,
1157        dpi_scale: f32,
1158    ) -> anyhow::Result<Option<crate::pane::PaneId>> {
1159        self.split(SplitDirection::Horizontal, config, runtime, dpi_scale)
1160    }
1161
1162    /// Split the current pane vertically (panes side by side)
1163    ///
1164    /// Returns the new pane ID if successful.
1165    /// `dpi_scale` converts logical pixel config values to physical pixels.
1166    pub fn split_vertical(
1167        &mut self,
1168        config: &Config,
1169        runtime: Arc<Runtime>,
1170        dpi_scale: f32,
1171    ) -> anyhow::Result<Option<crate::pane::PaneId>> {
1172        self.split(SplitDirection::Vertical, config, runtime, dpi_scale)
1173    }
1174
1175    /// Split the focused pane in the given direction.
1176    /// `dpi_scale` is used to convert logical pixel config values to physical pixels.
1177    fn split(
1178        &mut self,
1179        direction: SplitDirection,
1180        config: &Config,
1181        runtime: Arc<Runtime>,
1182        dpi_scale: f32,
1183    ) -> anyhow::Result<Option<crate::pane::PaneId>> {
1184        // Check max panes limit
1185        if config.max_panes > 0 && self.pane_count() >= config.max_panes {
1186            log::warn!(
1187                "Cannot split: max panes limit ({}) reached",
1188                config.max_panes
1189            );
1190            return Ok(None);
1191        }
1192
1193        // Initialize pane manager and create initial pane if needed
1194        let needs_initial_pane = self
1195            .pane_manager
1196            .as_ref()
1197            .map(|pm| pm.pane_count() == 0)
1198            .unwrap_or(true);
1199
1200        if needs_initial_pane {
1201            // Create pane manager if it doesn't exist
1202            if self.pane_manager.is_none() {
1203                let mut pm = PaneManager::new();
1204                // Scale from logical pixels (config) to physical pixels for layout
1205                pm.set_divider_width(config.pane_divider_width.unwrap_or(2.0) * dpi_scale);
1206                pm.set_divider_hit_width(config.pane_divider_hit_width * dpi_scale);
1207                self.pane_manager = Some(pm);
1208            }
1209
1210            // Create initial pane with size calculated for AFTER the split
1211            // (since we know a split is about to happen)
1212            if let Some(ref mut pm) = self.pane_manager {
1213                pm.create_initial_pane_for_split(
1214                    direction,
1215                    config,
1216                    Arc::clone(&runtime),
1217                    self.working_directory.clone(),
1218                )?;
1219                log::info!(
1220                    "Created PaneManager for tab {} with initial pane on first split",
1221                    self.id
1222                );
1223            }
1224        }
1225
1226        // Perform the split
1227        if let Some(ref mut pm) = self.pane_manager {
1228            let new_pane_id = pm.split(direction, config, Arc::clone(&runtime))?;
1229            if let Some(id) = new_pane_id {
1230                log::info!("Split tab {} {:?}, new pane {}", self.id, direction, id);
1231            }
1232            Ok(new_pane_id)
1233        } else {
1234            Ok(None)
1235        }
1236    }
1237
1238    /// Close the focused pane
1239    ///
1240    /// Returns true if this was the last pane (tab should close)
1241    pub fn close_focused_pane(&mut self) -> bool {
1242        if let Some(ref mut pm) = self.pane_manager
1243            && let Some(focused_id) = pm.focused_pane_id()
1244        {
1245            let is_last = pm.close_pane(focused_id);
1246            if is_last {
1247                // Last pane closed, clear the pane manager
1248                self.pane_manager = None;
1249            }
1250            return is_last;
1251        }
1252        // No pane manager or no focused pane means single pane tab
1253        true
1254    }
1255
1256    /// Check for exited panes and close them
1257    ///
1258    /// Returns (closed_pane_ids, tab_should_close) where:
1259    /// - `closed_pane_ids`: Vec of pane IDs that were closed
1260    /// - `tab_should_close`: true if all panes have exited (tab should close)
1261    pub fn close_exited_panes(&mut self) -> (Vec<crate::pane::PaneId>, bool) {
1262        let mut closed_panes = Vec::new();
1263
1264        // Get IDs of panes whose shells have exited
1265        let exited_pane_ids: Vec<crate::pane::PaneId> = if let Some(ref pm) = self.pane_manager {
1266            let focused_id = pm.focused_pane_id();
1267            pm.all_panes()
1268                .iter()
1269                .filter_map(|pane| {
1270                    let is_running = pane.is_running();
1271                    crate::debug_info!(
1272                        "PANE_CHECK",
1273                        "Pane {} running={} focused={} bounds=({:.0},{:.0} {:.0}x{:.0})",
1274                        pane.id,
1275                        is_running,
1276                        focused_id == Some(pane.id),
1277                        pane.bounds.x,
1278                        pane.bounds.y,
1279                        pane.bounds.width,
1280                        pane.bounds.height
1281                    );
1282                    if !is_running { Some(pane.id) } else { None }
1283                })
1284                .collect()
1285        } else {
1286            Vec::new()
1287        };
1288
1289        // Close each exited pane
1290        if let Some(ref mut pm) = self.pane_manager {
1291            for pane_id in exited_pane_ids {
1292                crate::debug_info!("PANE_CLOSE", "Closing pane {} - shell exited", pane_id);
1293                let is_last = pm.close_pane(pane_id);
1294                closed_panes.push(pane_id);
1295
1296                if is_last {
1297                    // Last pane closed, clear the pane manager
1298                    self.pane_manager = None;
1299                    return (closed_panes, true);
1300                }
1301            }
1302        }
1303
1304        (closed_panes, false)
1305    }
1306
1307    /// Get the pane manager if split panes are enabled
1308    pub fn pane_manager(&self) -> Option<&PaneManager> {
1309        self.pane_manager.as_ref()
1310    }
1311
1312    /// Get mutable access to the pane manager
1313    pub fn pane_manager_mut(&mut self) -> Option<&mut PaneManager> {
1314        self.pane_manager.as_mut()
1315    }
1316
1317    /// Initialize the pane manager if not already present
1318    ///
1319    /// This is used for tmux integration where we need to create the pane manager
1320    /// before applying a layout.
1321    pub fn init_pane_manager(&mut self) {
1322        if self.pane_manager.is_none() {
1323            self.pane_manager = Some(PaneManager::new());
1324        }
1325    }
1326
1327    /// Set the pane bounds and resize terminals
1328    ///
1329    /// This should be called before creating splits to ensure panes are sized correctly.
1330    /// If the pane manager doesn't exist yet, this creates it with the bounds set.
1331    pub fn set_pane_bounds(
1332        &mut self,
1333        bounds: crate::pane::PaneBounds,
1334        cell_width: f32,
1335        cell_height: f32,
1336    ) {
1337        self.set_pane_bounds_with_padding(bounds, cell_width, cell_height, 0.0);
1338    }
1339
1340    /// Set the pane bounds and resize terminals with padding
1341    ///
1342    /// This should be called before creating splits to ensure panes are sized correctly.
1343    /// The padding parameter accounts for content inset from pane edges.
1344    pub fn set_pane_bounds_with_padding(
1345        &mut self,
1346        bounds: crate::pane::PaneBounds,
1347        cell_width: f32,
1348        cell_height: f32,
1349        padding: f32,
1350    ) {
1351        if self.pane_manager.is_none() {
1352            let mut pm = PaneManager::new();
1353            pm.set_bounds(bounds);
1354            self.pane_manager = Some(pm);
1355        } else if let Some(ref mut pm) = self.pane_manager {
1356            pm.set_bounds(bounds);
1357            pm.resize_all_terminals_with_padding(cell_width, cell_height, padding, 0.0);
1358        }
1359    }
1360
1361    /// Focus the pane at the given pixel coordinates
1362    ///
1363    /// Returns the ID of the newly focused pane, or None if no pane at that position
1364    pub fn focus_pane_at(&mut self, x: f32, y: f32) -> Option<crate::pane::PaneId> {
1365        if let Some(ref mut pm) = self.pane_manager {
1366            pm.focus_pane_at(x, y)
1367        } else {
1368            None
1369        }
1370    }
1371
1372    /// Get the ID of the currently focused pane
1373    pub fn focused_pane_id(&self) -> Option<crate::pane::PaneId> {
1374        self.pane_manager
1375            .as_ref()
1376            .and_then(|pm| pm.focused_pane_id())
1377    }
1378
1379    /// Check if a specific pane is focused
1380    pub fn is_pane_focused(&self, pane_id: crate::pane::PaneId) -> bool {
1381        self.focused_pane_id() == Some(pane_id)
1382    }
1383
1384    /// Navigate to an adjacent pane
1385    pub fn navigate_pane(&mut self, direction: NavigationDirection) {
1386        if let Some(ref mut pm) = self.pane_manager {
1387            pm.navigate(direction);
1388        }
1389    }
1390
1391    /// Check if a position is on a divider
1392    pub fn is_on_divider(&self, x: f32, y: f32) -> bool {
1393        self.pane_manager
1394            .as_ref()
1395            .is_some_and(|pm| pm.is_on_divider(x, y))
1396    }
1397
1398    /// Find divider at position
1399    ///
1400    /// Returns the divider index if found
1401    pub fn find_divider_at(&self, x: f32, y: f32) -> Option<usize> {
1402        self.pane_manager
1403            .as_ref()
1404            .and_then(|pm| pm.find_divider_at(x, y, pm.divider_hit_padding()))
1405    }
1406
1407    /// Get divider info by index
1408    pub fn get_divider(&self, index: usize) -> Option<crate::pane::DividerRect> {
1409        self.pane_manager
1410            .as_ref()
1411            .and_then(|pm| pm.get_divider(index))
1412    }
1413
1414    /// Drag a divider to a new position
1415    pub fn drag_divider(&mut self, divider_index: usize, x: f32, y: f32) {
1416        if let Some(ref mut pm) = self.pane_manager {
1417            pm.drag_divider(divider_index, x, y);
1418        }
1419    }
1420}
1421
1422impl Drop for Tab {
1423    fn drop(&mut self) {
1424        if self.shutdown_fast {
1425            log::info!("Fast-dropping tab {} (cleanup handled externally)", self.id);
1426            return;
1427        }
1428
1429        log::info!("Dropping tab {}", self.id);
1430
1431        // Stop session logging first (before terminal is killed)
1432        if let Some(ref mut logger) = *self.session_logger.lock() {
1433            match logger.stop() {
1434                Ok(path) => {
1435                    log::info!("Session log saved to: {:?}", path);
1436                }
1437                Err(e) => {
1438                    log::warn!("Failed to stop session logging: {}", e);
1439                }
1440            }
1441        }
1442
1443        self.stop_refresh_task();
1444
1445        // Give the task time to abort
1446        std::thread::sleep(std::time::Duration::from_millis(50));
1447
1448        // Kill the terminal
1449        if let Ok(mut term) = self.terminal.try_lock()
1450            && term.is_running()
1451        {
1452            log::info!("Killing terminal for tab {}", self.id);
1453            let _ = term.kill();
1454        }
1455    }
1456}
1457
1458impl Tab {
1459    /// Create a minimal stub tab for unit testing (no PTY, no runtime)
1460    #[cfg(test)]
1461    pub(crate) fn new_stub(id: TabId, tab_number: usize) -> Self {
1462        // Create a dummy TerminalManager without spawning a shell
1463        let terminal =
1464            TerminalManager::new_with_scrollback(80, 24, 100).expect("stub terminal creation");
1465        Self {
1466            id,
1467            terminal: Arc::new(Mutex::new(terminal)),
1468            pane_manager: None,
1469            title: format!("Tab {}", tab_number),
1470            has_activity: false,
1471            scroll_state: ScrollState::new(),
1472            mouse: MouseState::new(),
1473            bell: BellState::new(),
1474            cache: RenderCache::new(),
1475            refresh_task: None,
1476            working_directory: None,
1477            custom_color: None,
1478            has_default_title: true,
1479            last_activity_time: std::time::Instant::now(),
1480            last_seen_generation: 0,
1481            anti_idle_last_activity: std::time::Instant::now(),
1482            anti_idle_last_generation: 0,
1483            silence_notified: false,
1484            exit_notified: false,
1485            session_logger: create_shared_logger(),
1486            tmux_gateway_active: false,
1487            tmux_pane_id: None,
1488            detected_hostname: None,
1489            detected_cwd: None,
1490            auto_applied_profile_id: None,
1491            auto_applied_dir_profile_id: None,
1492            profile_icon: None,
1493            pre_profile_title: None,
1494            badge_override: None,
1495            coprocess_ids: Vec::new(),
1496            script_manager: crate::scripting::manager::ScriptManager::new(),
1497            script_ids: Vec::new(),
1498            script_observer_ids: Vec::new(),
1499            script_forwarders: Vec::new(),
1500            trigger_marks: Vec::new(),
1501            pre_ssh_switch_profile: None,
1502            ssh_auto_switched: false,
1503            shutdown_fast: false,
1504        }
1505    }
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    use super::*;
1511
1512    #[test]
1513    fn test_parse_hostname_from_osc7_url_localhost() {
1514        // file:///path - localhost implicit, should return None
1515        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///home/user"), None);
1516        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
1517        assert_eq!(
1518            Tab::parse_hostname_from_osc7_url("file:///var/log/syslog"),
1519            None
1520        );
1521    }
1522
1523    #[test]
1524    fn test_parse_hostname_from_osc7_url_remote() {
1525        // file://hostname/path - should extract hostname
1526        assert_eq!(
1527            Tab::parse_hostname_from_osc7_url("file://server.example.com/home/user"),
1528            Some("server.example.com".to_string())
1529        );
1530        assert_eq!(
1531            Tab::parse_hostname_from_osc7_url("file://myhost/tmp"),
1532            Some("myhost".to_string())
1533        );
1534        assert_eq!(
1535            Tab::parse_hostname_from_osc7_url("file://192.168.1.100/var/log"),
1536            Some("192.168.1.100".to_string())
1537        );
1538    }
1539
1540    #[test]
1541    fn test_parse_hostname_from_osc7_url_localhost_explicit() {
1542        // file://localhost/path - localhost should return None
1543        assert_eq!(
1544            Tab::parse_hostname_from_osc7_url("file://localhost/home/user"),
1545            None
1546        );
1547    }
1548
1549    #[test]
1550    fn test_parse_hostname_from_osc7_url_invalid() {
1551        // Invalid URLs should return None
1552        assert_eq!(Tab::parse_hostname_from_osc7_url(""), None);
1553        assert_eq!(
1554            Tab::parse_hostname_from_osc7_url("http://example.com"),
1555            None
1556        );
1557        assert_eq!(Tab::parse_hostname_from_osc7_url("/home/user"), None);
1558        assert_eq!(Tab::parse_hostname_from_osc7_url("file://"), None);
1559    }
1560
1561    #[test]
1562    fn test_parse_hostname_from_osc7_url_edge_cases() {
1563        // Empty hostname after file://
1564        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
1565
1566        // Hostname with no path (unusual but valid)
1567        assert_eq!(
1568            Tab::parse_hostname_from_osc7_url("file://host"),
1569            Some("host".to_string())
1570        );
1571    }
1572}