Skip to main content

par_term/tab/
constructors.rs

1//! Tab constructors: `Tab::new`, `Tab::new_from_profile`, and `Tab::new_internal`.
2//!
3//! Split from `tab/mod.rs` to keep that file under 500 lines. The `Tab` struct
4//! definition and `Drop` impl remain in `mod.rs`; all constructor logic lives here.
5
6use super::{Tab, TabInitParams};
7use crate::config::Config;
8use crate::config::SessionLogFormat;
9use crate::pane::{Pane, PaneManager};
10use crate::profile::Profile;
11use crate::session_logger::{SessionLogger, create_shared_logger};
12use crate::tab::activity_state::TabActivityMonitor;
13use crate::tab::initial_text::build_initial_text_payload;
14use crate::tab::profile_state::TabProfileState;
15use crate::tab::scripting_state::TabScriptingState;
16use crate::tab::setup::{
17    apply_login_shell_flag, build_shell_env, create_base_terminal, get_shell_command,
18};
19use crate::tab::tmux_state::TabTmuxState;
20use par_term_config::TabId;
21use par_term_terminal::TerminalManager;
22use par_term_terminal::conversion::to_core_restart_policy;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicBool, AtomicU8};
25use tokio::runtime::Runtime;
26use tokio::sync::RwLock;
27
28impl Tab {
29    /// Shared constructor body called by both `Tab::new()` and `Tab::new_from_profile()`.
30    ///
31    /// Both public constructors:
32    /// 1. Create and configure a `TerminalManager` (divergent: shell command, env, login_shell)
33    /// 2. Call this method to handle the identical steps:
34    ///    - Coprocess auto-start loop
35    ///    - Script auto-start loop
36    ///    - Session logging setup
37    ///    - `Arc<RwLock<>>` wrapping
38    ///    - Initial text scheduling (only when `params.runtime` is `Some`)
39    ///    - `Tab` struct construction with all shared default fields
40    ///
41    /// # Arguments
42    /// * `params` — Constructor-specific values (title, working_directory, etc.)
43    /// * `terminal` — Fully configured `TerminalManager` with PTY already spawned
44    /// * `config` — Global config (used for coprocesses, scripts, and session logging)
45    /// * `session_title` — Human-readable title written to the session log file header
46    pub(super) fn new_internal(
47        params: TabInitParams,
48        terminal: TerminalManager,
49        config: &Config,
50        session_title: String,
51    ) -> anyhow::Result<Self> {
52        // Sync triggers from config into the core TriggerRegistry
53        let trigger_security = terminal.sync_triggers(&config.automation.triggers);
54
55        // Auto-start configured coprocesses via the PtySession's built-in manager
56        let mut coprocess_ids = Vec::with_capacity(config.automation.coprocesses.len());
57        for coproc_config in &config.automation.coprocesses {
58            if coproc_config.auto_start {
59                let core_config = par_term_emu_core_rust::coprocess::CoprocessConfig {
60                    command: coproc_config.command.clone(),
61                    args: coproc_config.args.clone(),
62                    cwd: None,
63                    env: par_term_terminal::coprocess_env(),
64                    copy_terminal_output: coproc_config.copy_terminal_output,
65                    restart_policy: to_core_restart_policy(coproc_config.restart_policy),
66                    restart_delay_ms: coproc_config.restart_delay_ms,
67                };
68                match terminal.start_coprocess(core_config) {
69                    Ok(id) => {
70                        log::info!(
71                            "Auto-started coprocess '{}' (id={})",
72                            coproc_config.name,
73                            id
74                        );
75                        coprocess_ids.push(Some(id));
76                    }
77                    Err(e) => {
78                        log::warn!(
79                            "Failed to auto-start coprocess '{}': {}",
80                            coproc_config.name,
81                            e
82                        );
83                        coprocess_ids.push(None);
84                    }
85                }
86            } else {
87                coprocess_ids.push(None);
88            }
89        }
90
91        // Auto-start configured scripts. Runs while `terminal` is still owned
92        // here, so the observer registration needs no lock (the `Arc<RwLock<_>>`
93        // wrap happens further down).
94        let mut scripting = TabScriptingState {
95            coprocess_ids,
96            trigger_prompt_before_run: trigger_security,
97            ..TabScriptingState::default()
98        };
99        for (index, script_config) in config.automation.scripts.iter().enumerate() {
100            if !script_config.should_auto_start() {
101                continue;
102            }
103            match scripting.start_script_at(&terminal, index, script_config) {
104                Ok(id) => {
105                    log::info!("Auto-started script '{}' (id={})", script_config.name, id);
106                    crate::debug_info!(
107                        "SCRIPT",
108                        "auto-start: '{}' index={} id={}",
109                        script_config.name,
110                        index,
111                        id
112                    );
113                }
114                Err(e) => {
115                    log::warn!(
116                        "Failed to auto-start script '{}': {}",
117                        script_config.name,
118                        e
119                    );
120                    crate::debug_error!(
121                        "SCRIPT",
122                        "auto-start FAILED for '{}' index={}: {}",
123                        script_config.name,
124                        index,
125                        e
126                    );
127                }
128            }
129        }
130
131        // Create shared session logger
132        let session_logger = create_shared_logger();
133
134        // Set up session logging if enabled
135        if config.session_log.auto_log_sessions {
136            let logs_dir = config.logs_dir();
137
138            // SEC-010: Ensure the logs directory exists with owner-only permissions
139            // (0o700) so session logs are not world-listable.
140            if let Err(e) = std::fs::create_dir_all(&logs_dir) {
141                log::warn!("Failed to create logs directory {:?}: {}", logs_dir, e);
142            } else {
143                #[cfg(unix)]
144                {
145                    use std::os::unix::fs::PermissionsExt;
146                    let _ =
147                        std::fs::set_permissions(&logs_dir, std::fs::Permissions::from_mode(0o700));
148                }
149            }
150
151            let title_with_ts = Some(format!(
152                "{} - {}",
153                session_title,
154                chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
155            ));
156
157            match SessionLogger::new(
158                config.session_log.session_log_format,
159                &logs_dir,
160                (config.cols, config.rows),
161                title_with_ts,
162            ) {
163                Ok(mut logger) => {
164                    logger.set_redact_passwords(config.session_log.session_log_redact_passwords);
165                    if let Err(e) = logger.start() {
166                        log::warn!("Failed to start session logging: {}", e);
167                    } else {
168                        log::info!("Session logging started: {:?}", logger.output_path());
169
170                        // Set up output callback to record PTY output
171                        let logger_clone = Arc::clone(&session_logger);
172                        terminal.set_output_callback(move |data: &[u8]| {
173                            if let Some(ref mut logger) = *logger_clone.lock() {
174                                logger.record_output(data);
175                            }
176                        });
177
178                        *session_logger.lock() = Some(logger);
179                    }
180                }
181                Err(e) => {
182                    log::warn!("Failed to create session logger: {}", e);
183                }
184            }
185        }
186
187        let terminal = Arc::new(RwLock::new(terminal));
188
189        // v3 session logs need the graphics store at finalization; hand the
190        // logger a handle to the terminal so stop() can run the core lib's
191        // graphics-aware export (the store is crate-internal to emu-core).
192        if config.session_log.session_log_format == SessionLogFormat::AsciicastV3
193            && let Some(ref mut logger) = *session_logger.lock()
194        {
195            let tm = Arc::clone(&terminal);
196            logger.set_v3_exporter(Box::new(move |session| {
197                tm.try_read()
198                    .ok()
199                    .map(|tm| tm.export_session_asciicast_v3(session))
200            }));
201        }
202
203        // Send initial text after optional delay (only when a runtime is provided)
204        if let Some(runtime) = params.runtime
205            && let Some(payload) = build_initial_text_payload(
206                &config.shell.initial_text,
207                config.shell.initial_text_send_newline,
208            )
209        {
210            let delay_ms = config.shell.initial_text_delay_ms;
211            let terminal_clone = Arc::clone(&terminal);
212            runtime.spawn(async move {
213                if delay_ms > 0 {
214                    tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
215                }
216
217                let term = terminal_clone.read().await;
218                if let Err(err) = term.write(&payload) {
219                    log::warn!("Failed to send initial text: {}", err);
220                }
221            });
222        }
223
224        // Always create a PaneManager with one primary pane that wraps `tab.terminal`
225        // (R-32). This eliminates the fallback scroll_state/mouse/bell/cache fields
226        // that were used when pane_manager was None (single-pane mode).
227        let is_active = Arc::new(AtomicBool::new(false));
228        let pane_manager = PaneManager::new_with_existing_terminal(
229            Arc::clone(&terminal),
230            params.working_directory.clone(),
231            Arc::clone(&is_active),
232        );
233
234        Ok(Self {
235            id: params.id,
236            terminal,
237            pane_manager: Some(pane_manager),
238            title: params.title,
239            refresh_task: None,
240            working_directory: params.working_directory,
241            custom_color: None,
242            has_default_title: params.has_default_title,
243            user_named: params.user_named,
244            activity: TabActivityMonitor::default(),
245            session_logger,
246            tmux: TabTmuxState::default(),
247            detected_hostname: None,
248            detected_cwd: None,
249            custom_icon: None,
250            profile: TabProfileState::default(),
251            scripting,
252            was_alt_screen: false,
253            is_active,
254            shutdown_fast: false,
255            is_hidden: false,
256            cached_modify_other_keys_mode: AtomicU8::new(0),
257            cached_application_cursor: AtomicBool::new(false),
258            cached_alt_screen_active: AtomicBool::new(false),
259            cached_has_tmux_child: AtomicBool::new(false),
260        })
261    }
262
263    /// Create a new tab with a terminal session
264    ///
265    /// # Arguments
266    /// * `id` - Unique tab identifier
267    /// * `tab_number` - Display number for the tab (1-indexed)
268    /// * `config` - Terminal configuration
269    /// * `runtime` - Tokio runtime for async operations
270    /// * `working_directory` - Optional working directory to start in
271    /// * `grid_size` - Optional (cols, rows) override. When provided, uses these
272    ///   dimensions instead of config.cols/rows. This ensures the shell starts
273    ///   with the correct dimensions when the renderer has already calculated
274    ///   the grid size accounting for tab bar height.
275    pub fn new(
276        id: TabId,
277        tab_number: usize,
278        config: &Config,
279        runtime: Arc<Runtime>,
280        working_directory: Option<String>,
281        grid_size: Option<(usize, usize)>,
282    ) -> anyhow::Result<Self> {
283        // Create and configure terminal
284        let (mut terminal, _, _) = create_base_terminal(config, grid_size)?;
285
286        // Determine working directory:
287        // 1. If explicitly provided (e.g., from tab_inherit_cwd), use that
288        // 2. Otherwise, use the configured startup directory based on mode
289        let effective_startup_dir = config.get_effective_startup_directory();
290        let work_dir = working_directory
291            .as_deref()
292            .or(effective_startup_dir.as_deref());
293
294        // Get shell command and apply login shell flag
295        let (shell_cmd, mut shell_args) = get_shell_command(config);
296        apply_login_shell_flag(&mut shell_args, config);
297
298        let shell_args_deref = shell_args.as_deref();
299        let shell_env = build_shell_env(config.shell.shell_env.as_ref());
300        terminal.spawn_custom_shell_with_dir(
301            &shell_cmd,
302            shell_args_deref,
303            work_dir,
304            shell_env.as_ref(),
305        )?;
306
307        // Generate initial title based on current tab count, not unique ID
308        let title = format!("Tab {}", tab_number);
309
310        Self::new_internal(
311            TabInitParams {
312                id,
313                title,
314                has_default_title: true,
315                user_named: false,
316                working_directory: working_directory
317                    .or_else(|| config.shell.working_directory.clone()),
318                runtime: Some(runtime),
319            },
320            terminal,
321            config,
322            format!("Tab {}", tab_number),
323        )
324    }
325
326    /// Create a new tab from a profile configuration
327    ///
328    /// The profile can override:
329    /// - Working directory
330    /// - Command and arguments (instead of default shell)
331    /// - Tab name
332    ///
333    /// If a profile specifies a command, it always runs from the profile's working
334    /// directory (or config default if unset).
335    ///
336    /// # Arguments
337    /// * `id` - Unique tab identifier
338    /// * `config` - Terminal configuration
339    /// * `_runtime` - Tokio runtime (unused: profile tabs don't send initial text)
340    /// * `profile` - Profile configuration to use
341    /// * `grid_size` - Optional (cols, rows) override for initial terminal size
342    ///
343    /// # Unique logic vs `Tab::new()`
344    /// This constructor's divergent logic (not shared with `Tab::new()` via `new_internal`):
345    /// - SSH command detection (`profile.ssh_command_args()`)
346    /// - Per-profile `login_shell` override (takes precedence over `config.login_shell`)
347    /// - Per-profile `SHELL` env-var injection when `profile.shell` is set
348    /// - Title derived from `profile.tab_name` → `profile.name` (not "Tab N")
349    /// - Profile tabs do NOT send `config.initial_text` on startup
350    pub fn new_from_profile(
351        id: TabId,
352        config: &Config,
353        _runtime: Arc<Runtime>,
354        profile: &Profile,
355        grid_size: Option<(usize, usize)>,
356    ) -> anyhow::Result<Self> {
357        // Create and configure terminal
358        let (mut terminal, _, _) = create_base_terminal(config, grid_size)?;
359
360        // Determine working directory: profile overrides config startup directory
361        let effective_startup_dir = config.get_effective_startup_directory();
362        let work_dir = profile
363            .working_directory
364            .as_deref()
365            .or(effective_startup_dir.as_deref());
366
367        // Determine command and args with priority:
368        // 0. profile.ssh_host → build ssh command with user/port/identity args
369        // 1. profile.command → use as-is (non-shell commands like tmux, ssh)
370        // 2. profile.shell → use as shell, apply login_shell logic
371        // 3. neither → fall back to global config shell / $SHELL
372        let is_ssh_profile = profile.ssh_host.is_some();
373        let (shell_cmd, mut shell_args) = if let Some(ssh_args) = profile.ssh_command_args() {
374            ("ssh".to_string(), Some(ssh_args))
375        } else if let Some(ref cmd) = profile.command {
376            (cmd.clone(), profile.command_args.clone())
377        } else if let Some(ref shell) = profile.shell {
378            (shell.clone(), None)
379        } else {
380            get_shell_command(config)
381        };
382
383        // Apply login shell flag when using a shell (not a custom command or SSH profile).
384        // Per-profile login_shell overrides global config.login_shell.
385        if profile.command.is_none() && !is_ssh_profile {
386            let use_login_shell = profile.login_shell.unwrap_or(config.shell.login_shell);
387            if use_login_shell {
388                let args = shell_args.get_or_insert_with(Vec::new);
389                #[cfg(not(target_os = "windows"))]
390                if !args.iter().any(|a| a == "-l" || a == "--login") {
391                    args.insert(0, "-l".to_string());
392                }
393            }
394        }
395
396        let shell_args_deref = shell_args.as_deref();
397        let mut shell_env = build_shell_env(config.shell.shell_env.as_ref());
398
399        // When a profile specifies a shell, set the SHELL env var so child
400        // processes (and $SHELL) reflect the selected shell, not the login shell.
401        if profile.command.is_none()
402            && let Some(ref shell_path) = profile.shell
403            && let Some(ref mut env) = shell_env
404        {
405            env.insert("SHELL".to_string(), shell_path.clone());
406        }
407
408        terminal.spawn_custom_shell_with_dir(
409            &shell_cmd,
410            shell_args_deref,
411            work_dir,
412            shell_env.as_ref(),
413        )?;
414
415        // Generate title: use profile tab_name or profile name
416        let title = profile
417            .tab_name
418            .clone()
419            .unwrap_or_else(|| profile.name.clone());
420
421        let working_directory = profile
422            .working_directory
423            .clone()
424            .or_else(|| config.shell.working_directory.clone());
425
426        // Session log title uses profile name (Tab::new uses "Tab N")
427        let session_title = profile.name.clone();
428
429        Self::new_internal(
430            TabInitParams {
431                id,
432                title,
433                has_default_title: false, // Profile-created tabs have explicit names
434                user_named: profile.tab_name.is_some(),
435                working_directory,
436                runtime: None, // Profile tabs don't send initial_text
437            },
438            terminal,
439            config,
440            session_title,
441        )
442    }
443
444    /// Create a new tab wrapping an existing `Pane` (e.g., from a promote operation).
445    ///
446    /// The pane's PTY, scroll state, and session logger are preserved.
447    /// No new shell is spawned — the pane's terminal keeps running.
448    /// The tab shares the pane's `Arc<RwLock<TerminalManager>>` as its primary terminal.
449    pub fn new_from_pane(
450        id: TabId,
451        pane: Pane,
452        _config: &Config,
453        _runtime: Arc<Runtime>,
454        tab_number: usize,
455    ) -> Self {
456        // Clone the pane's terminal Arc as the tab's primary terminal
457        let terminal = Arc::clone(&pane.terminal);
458        let is_active = Arc::clone(&pane.is_active);
459        let session_logger = Arc::clone(&pane.session_logger);
460
461        // Create a PaneManager with this pane as the single root
462        let pane_manager = PaneManager::new_with_pane(pane);
463
464        let title = format!("Tab {}", tab_number);
465
466        Self {
467            id,
468            terminal,
469            pane_manager: Some(pane_manager),
470            title,
471            refresh_task: None,
472            working_directory: None,
473            custom_color: None,
474            has_default_title: true,
475            user_named: false,
476            activity: TabActivityMonitor::default(),
477            session_logger,
478            tmux: TabTmuxState::default(),
479            detected_hostname: None,
480            detected_cwd: None,
481            custom_icon: None,
482            profile: TabProfileState::default(),
483            scripting: TabScriptingState::default(),
484            was_alt_screen: false,
485            is_active,
486            shutdown_fast: false,
487            is_hidden: false,
488            cached_modify_other_keys_mode: AtomicU8::new(0),
489            cached_application_cursor: AtomicBool::new(false),
490            cached_alt_screen_active: AtomicBool::new(false),
491            cached_has_tmux_child: AtomicBool::new(false),
492        }
493    }
494}
495
496/// Minimal stub for use in unit tests (no PTY, no runtime).
497#[cfg(test)]
498impl Tab {
499    pub(crate) fn new_stub(id: TabId, tab_number: usize) -> Self {
500        use crate::session_logger::create_shared_logger;
501
502        // Create a dummy TerminalManager without spawning a shell
503        let terminal =
504            TerminalManager::new_with_scrollback(80, 24, 100).expect("stub terminal creation");
505        let terminal = Arc::new(RwLock::new(terminal));
506        let is_active = Arc::new(AtomicBool::new(false));
507        let pane_manager = PaneManager::new_with_existing_terminal(
508            Arc::clone(&terminal),
509            None,
510            Arc::clone(&is_active),
511        );
512        Self {
513            id,
514            terminal,
515            pane_manager: Some(pane_manager),
516            title: format!("Tab {}", tab_number),
517            refresh_task: None,
518            working_directory: None,
519            custom_color: None,
520            has_default_title: true,
521            user_named: false,
522            activity: TabActivityMonitor::default(),
523            session_logger: create_shared_logger(),
524            tmux: TabTmuxState::default(),
525            detected_hostname: None,
526            detected_cwd: None,
527            custom_icon: None,
528            profile: TabProfileState::default(),
529            scripting: TabScriptingState::default(),
530            was_alt_screen: false,
531            is_active,
532            shutdown_fast: false,
533            is_hidden: false,
534            cached_modify_other_keys_mode: AtomicU8::new(0),
535            cached_application_cursor: AtomicBool::new(false),
536            cached_alt_screen_active: AtomicBool::new(false),
537            cached_has_tmux_child: AtomicBool::new(false),
538        }
539    }
540}
541
542/// Regression tests for script auto-start (issue #220).
543///
544/// `auto_start` was documented as spawning a script at tab creation but was
545/// never wired up — only the Settings UI start button could launch a script.
546/// These drive the real `new_internal` auto-start loop against a terminal that
547/// has no shell spawned, so they run without a PTY on every supported platform.
548#[cfg(test)]
549mod auto_start_tests {
550    use super::*;
551    use crate::config::AutomationConfig;
552    use par_term_config::ScriptConfig;
553
554    /// A command that stays alive reading stdin, so `is_running` is stable for
555    /// the duration of the test. `findstr` is the Windows analogue of `cat`:
556    /// given a pattern and no file argument it reads stdin until EOF.
557    ///
558    /// Invoked directly rather than through `cmd.exe /c`: the shell would treat
559    /// `^` as its escape character (`findstr ^` fails with "Bad command line"),
560    /// and wrapping in a shell would make the real reader a grandchild that
561    /// `Child::kill` does not reap.
562    #[cfg(unix)]
563    const LONG_LIVED: (&str, &[&str]) = ("/bin/cat", &[]);
564    #[cfg(windows)]
565    const LONG_LIVED: (&str, &[&str]) = ("findstr.exe", &["x"]);
566
567    /// Script config pointing at a long-lived process so `is_running` is stable.
568    fn cat_script(name: &str, enabled: bool, auto_start: bool) -> ScriptConfig {
569        let (path, args) = LONG_LIVED;
570        ScriptConfig {
571            name: name.to_string(),
572            enabled,
573            script_path: path.to_string(),
574            args: args.iter().map(|s| s.to_string()).collect(),
575            auto_start,
576            restart_policy: Default::default(),
577            restart_delay_ms: 0,
578            subscriptions: Vec::new(),
579            env_vars: Default::default(),
580            allow_write_text: false,
581            prompt_before_write_text: true,
582            allow_run_command: false,
583            allow_change_config: false,
584            write_text_rate_limit: 0,
585            run_command_rate_limit: 0,
586        }
587    }
588
589    fn tab_with_scripts(scripts: Vec<ScriptConfig>) -> Tab {
590        let config = Config {
591            automation: AutomationConfig {
592                scripts,
593                ..Default::default()
594            },
595            ..Config::default()
596        };
597        let terminal =
598            TerminalManager::new_with_scrollback(80, 24, 100).expect("terminal creation");
599        Tab::new_internal(
600            TabInitParams {
601                id: 1,
602                title: "Tab 1".to_string(),
603                has_default_title: true,
604                user_named: false,
605                working_directory: None,
606                runtime: None,
607            },
608            terminal,
609            &config,
610            "Tab 1".to_string(),
611        )
612        .expect("tab creation")
613    }
614
615    #[test]
616    fn auto_start_script_is_running_after_tab_creation() {
617        let mut tab = tab_with_scripts(vec![cat_script("auto", true, true)]);
618
619        let id = tab
620            .scripting
621            .script_ids
622            .first()
623            .copied()
624            .flatten()
625            .expect("auto_start script must be spawned at tab creation");
626        assert!(
627            tab.scripting.script_manager.is_running(id),
628            "auto-started script process must be alive"
629        );
630        assert!(
631            matches!(tab.scripting.script_forwarders.first(), Some(Some(_))),
632            "auto-started script must have an event forwarder registered"
633        );
634        assert!(
635            matches!(tab.scripting.script_observer_ids.first(), Some(Some(_))),
636            "auto-started script must be registered as a terminal observer"
637        );
638    }
639
640    #[test]
641    fn script_without_auto_start_is_not_spawned() {
642        let tab = tab_with_scripts(vec![cat_script("manual", true, false)]);
643        assert!(
644            tab.scripting
645                .script_ids
646                .first()
647                .copied()
648                .flatten()
649                .is_none(),
650            "auto_start: false must stay manual-start only"
651        );
652    }
653
654    #[test]
655    fn disabled_script_is_not_spawned() {
656        let tab = tab_with_scripts(vec![cat_script("disabled", false, true)]);
657        assert!(
658            tab.scripting
659                .script_ids
660                .first()
661                .copied()
662                .flatten()
663                .is_none(),
664            "enabled: false must veto auto_start: true"
665        );
666    }
667
668    #[test]
669    fn tracking_state_stays_aligned_with_config_indices() {
670        // Per-tab script tracking is indexed by position in `config.scripts`,
671        // so skipped entries must leave holes rather than shifting later ones.
672        let mut tab = tab_with_scripts(vec![
673            cat_script("manual", true, false),
674            cat_script("auto", true, true),
675        ]);
676
677        assert!(
678            tab.scripting
679                .script_ids
680                .first()
681                .copied()
682                .flatten()
683                .is_none(),
684            "index 0 is manual-start and must stay empty"
685        );
686        let id = tab
687            .scripting
688            .script_ids
689            .get(1)
690            .copied()
691            .flatten()
692            .expect("index 1 has auto_start: true and must be spawned");
693        assert!(tab.scripting.script_manager.is_running(id));
694    }
695
696    #[test]
697    fn no_scripts_configured_leaves_tracking_empty() {
698        let tab = tab_with_scripts(Vec::new());
699        assert!(tab.scripting.script_ids.is_empty());
700        assert!(tab.scripting.script_forwarders.is_empty());
701    }
702
703    /// Restarting an index must tear down the previous script first.
704    ///
705    /// A script that exits on its own leaves its observer registered — only an
706    /// explicit stop removes it — so a naive restart would overwrite the
707    /// tracking slot and orphan the old forwarder on the terminal, where it
708    /// keeps accumulating events into a buffer nobody drains.
709    #[test]
710    fn restarting_an_index_does_not_orphan_the_previous_observer() {
711        let config = cat_script("auto", true, true);
712        let mut tab = tab_with_scripts(vec![config.clone()]);
713
714        let first_id = tab
715            .scripting
716            .script_ids
717            .first()
718            .copied()
719            .flatten()
720            .expect("initial auto-start");
721        let first_observer = tab.scripting.script_observer_ids[0].expect("initial observer");
722
723        // Restart the same index, as the Settings UI does after a script exits.
724        let second_id = {
725            let term = tab.terminal.blocking_read();
726            tab.scripting
727                .start_script_at(&term, 0, &config)
728                .expect("restart")
729        };
730
731        assert_ne!(first_id, second_id, "restart must spawn a new process");
732        assert!(
733            !tab.scripting.script_manager.is_running(first_id),
734            "the superseded script process must be stopped"
735        );
736
737        // `remove_observer` reports whether the id was still registered, so a
738        // second removal returning `true` means the restart leaked it.
739        let still_registered = {
740            let term = tab.terminal.blocking_read();
741            term.remove_observer(first_observer)
742        };
743        assert!(
744            !still_registered,
745            "restart must unregister the superseded observer, not orphan it"
746        );
747    }
748
749    /// Stopping a script must leave no observer behind either.
750    #[test]
751    fn clearing_an_index_unregisters_its_observer() {
752        let mut tab = tab_with_scripts(vec![cat_script("auto", true, true)]);
753
754        let observer = tab.scripting.script_observer_ids[0].expect("initial observer");
755        {
756            let term = tab.terminal.blocking_read();
757            tab.scripting.clear_script_at(&term, 0);
758        }
759
760        let still_registered = {
761            let term = tab.terminal.blocking_read();
762            term.remove_observer(observer)
763        };
764        assert!(
765            !still_registered,
766            "stop must unregister the script's observer"
767        );
768        assert!(
769            tab.scripting
770                .script_ids
771                .first()
772                .copied()
773                .flatten()
774                .is_none()
775        );
776        assert!(matches!(
777            tab.scripting.script_forwarders.first(),
778            Some(None)
779        ));
780    }
781}