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