1use 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 pub(super) fn new_internal(
46 params: TabInitParams,
47 terminal: TerminalManager,
48 config: &Config,
49 session_title: String,
50 ) -> anyhow::Result<Self> {
51 let trigger_security = terminal.sync_triggers(&config.automation.triggers);
53
54 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 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 let session_logger = create_shared_logger();
132
133 if config.session_log.auto_log_sessions {
135 let logs_dir = config.logs_dir();
136
137 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 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 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 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 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 let (mut terminal, _, _) = create_base_terminal(config, grid_size)?;
270
271 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 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 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 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 let (mut terminal, _, _) = create_base_terminal(config, grid_size)?;
344
345 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 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 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 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 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 let session_title = profile.name.clone();
413
414 Self::new_internal(
415 TabInitParams {
416 id,
417 title,
418 has_default_title: false, user_named: profile.tab_name.is_some(),
420 working_directory,
421 runtime: None, },
423 terminal,
424 config,
425 session_title,
426 )
427 }
428
429 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 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 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#[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 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#[cfg(test)]
534mod auto_start_tests {
535 use super::*;
536 use crate::config::AutomationConfig;
537 use par_term_config::ScriptConfig;
538
539 #[cfg(unix)]
548 const LONG_LIVED: (&str, &[&str]) = ("/bin/cat", &[]);
549 #[cfg(windows)]
550 const LONG_LIVED: (&str, &[&str]) = ("findstr.exe", &["x"]);
551
552 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 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 #[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 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 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 #[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}