1mod 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
29fn configure_terminal_from_config(terminal: &mut TerminalManager, config: &Config) {
31 terminal.set_theme(config.load_theme());
33
34 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 if !config.answerback_string.is_empty() {
40 terminal.set_answerback_string(Some(config.answerback_string.clone()));
41 }
42
43 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 terminal.set_normalization_form(config.normalization_form);
50
51 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#[cfg(target_os = "windows")]
72const PATH_SEPARATOR: char = ';';
73#[cfg(not(target_os = "windows"))]
74const PATH_SEPARATOR: char = ':';
75
76pub(crate) fn build_shell_env(
81 config_env: Option<&std::collections::HashMap<String, String>>,
82) -> Option<std::collections::HashMap<String, String>> {
83 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 env.insert("__PAR_TERM".to_string(), "1".to_string());
93
94 let session_uuid = uuid::Uuid::new_v4();
97 env.insert(
98 "ITERM_SESSION_ID".to_string(),
99 format!("w0t0p0:{session_uuid}"),
100 );
101
102 if let Some(config) = config_env {
104 for (key, value) in config {
105 env.insert(key.clone(), value.clone());
106 }
107 }
108
109 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#[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 paths.push(
140 home.join(".cargo")
141 .join("bin")
142 .to_string_lossy()
143 .to_string(),
144 );
145 paths.push(
147 home.join("scoop")
148 .join("shims")
149 .to_string_lossy()
150 .to_string(),
151 );
152 paths.push(home.join("go").join("bin").to_string_lossy().to_string());
154 }
155
156 paths.push(r"C:\ProgramData\chocolatey\bin".to_string());
158
159 if let Some(local_app_data) = dirs::data_local_dir() {
161 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#[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 paths.push(
193 home.join(".local")
194 .join("bin")
195 .to_string_lossy()
196 .to_string(),
197 );
198 paths.push(
200 home.join(".cargo")
201 .join("bin")
202 .to_string_lossy()
203 .to_string(),
204 );
205 paths.push(home.join("go").join("bin").to_string_lossy().to_string());
207 paths.push(
209 home.join(".nix-profile")
210 .join("bin")
211 .to_string_lossy()
212 .to_string(),
213 );
214 }
215
216 paths.push("/nix/var/nix/profiles/default/bin".to_string());
218
219 #[cfg(target_os = "macos")]
221 {
222 paths.push("/opt/homebrew/bin".to_string());
224 paths.push("/opt/homebrew/sbin".to_string());
225 paths.push("/usr/local/bin".to_string());
227 paths.push("/usr/local/sbin".to_string());
228 paths.push("/opt/local/bin".to_string());
230 }
231
232 #[cfg(target_os = "linux")]
234 {
235 paths.push("/usr/local/bin".to_string());
237 paths.push("/snap/bin".to_string());
239 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
257fn 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#[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 }
291
292pub use par_term_config::TabId;
294
295pub struct Tab {
297 pub id: TabId,
299 pub terminal: Arc<Mutex<TerminalManager>>,
301 pub pane_manager: Option<PaneManager>,
303 pub title: String,
305 pub has_activity: bool,
307 pub scroll_state: ScrollState,
309 pub mouse: MouseState,
311 pub bell: BellState,
313 pub cache: RenderCache,
315 pub refresh_task: Option<JoinHandle<()>>,
317 pub working_directory: Option<String>,
319 pub custom_color: Option<[u8; 3]>,
321 pub has_default_title: bool,
323 pub last_activity_time: std::time::Instant,
325 pub last_seen_generation: u64,
327 pub anti_idle_last_activity: std::time::Instant,
329 pub anti_idle_last_generation: u64,
331 pub silence_notified: bool,
333 pub exit_notified: bool,
335 pub session_logger: SharedSessionLogger,
337 pub tmux_gateway_active: bool,
339 pub tmux_pane_id: Option<crate::tmux::TmuxPaneId>,
341 pub detected_hostname: Option<String>,
343 pub detected_cwd: Option<String>,
345 pub auto_applied_profile_id: Option<crate::profile::ProfileId>,
347 pub auto_applied_dir_profile_id: Option<crate::profile::ProfileId>,
349 pub profile_icon: Option<String>,
351 pub pre_profile_title: Option<String>,
353 pub badge_override: Option<String>,
355 pub coprocess_ids: Vec<Option<CoprocessId>>,
357 pub script_manager: crate::scripting::manager::ScriptManager,
359 pub script_ids: Vec<Option<crate::scripting::manager::ScriptId>>,
361 pub script_observer_ids: Vec<Option<par_term_emu_core_rust::observer::ObserverId>>,
363 pub script_forwarders:
365 Vec<Option<std::sync::Arc<crate::scripting::observer::ScriptEventForwarder>>>,
366 pub trigger_marks: Vec<crate::scrollback_metadata::ScrollbackMark>,
368 pub pre_ssh_switch_profile: Option<crate::profile::ProfileId>,
370 pub ssh_auto_switched: bool,
372 pub(crate) shutdown_fast: bool,
374}
375
376impl Tab {
377 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 let (cols, rows) = grid_size.unwrap_or((config.cols, config.rows));
399
400 let mut terminal =
402 TerminalManager::new_with_scrollback(cols, rows, config.scrollback_lines)?;
403
404 configure_terminal_from_config(&mut terminal, config);
406
407 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 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 terminal.sync_triggers(&config.triggers);
430
431 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 let session_logger = create_shared_logger();
469
470 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 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 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 let title = format!("Tab {}", tab_number);
530
531 Ok(Self {
532 id,
533 terminal,
534 pane_manager: None, 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 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 let (cols, rows) = grid_size.unwrap_or((config.cols, config.rows));
598
599 let mut terminal =
601 TerminalManager::new_with_scrollback(cols, rows, config.scrollback_lines)?;
602
603 configure_terminal_from_config(&mut terminal, config);
605
606 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 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 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 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 terminal.sync_triggers(&config.triggers);
663
664 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 let session_logger = create_shared_logger();
702
703 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 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 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, 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, 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 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 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 let abbreviated = if let Some(home) = dirs::home_dir() {
820 cwd.replace(&home.to_string_lossy().to_string(), "~")
821 } else {
822 cwd
823 };
824 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 }
838 }
839
840 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 pub fn set_title(&mut self, title: &str) {
851 self.title = title.to_string();
852 self.has_default_title = false;
853 }
854
855 #[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 }
863 }
864
865 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 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 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 None
918 } else {
919 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 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 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 current_hostname
956 } else {
957 None
958 }
959 }
960
961 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 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 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 pub fn stop_refresh_task(&mut self) {
1036 if let Some(handle) = self.refresh_task.take() {
1037 handle.abort();
1038 }
1039 }
1040
1041 pub fn set_custom_color(&mut self, color: [u8; 3]) {
1043 self.custom_color = Some(color);
1044 }
1045
1046 pub fn clear_custom_color(&mut self) {
1048 self.custom_color = None;
1049 }
1050
1051 #[allow(dead_code)]
1053 pub fn has_custom_color(&self) -> bool {
1054 self.custom_color.is_some()
1055 }
1056
1057 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 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 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 let dimensions = if let Ok(term) = self.terminal.try_lock() {
1085 term.dimensions()
1086 } else {
1087 (80, 24) };
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 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 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 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 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 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 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 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 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 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 if self.pane_manager.is_none() {
1203 let mut pm = PaneManager::new();
1204 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 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 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 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 self.pane_manager = None;
1249 }
1250 return is_last;
1251 }
1252 true
1254 }
1255
1256 pub fn close_exited_panes(&mut self) -> (Vec<crate::pane::PaneId>, bool) {
1262 let mut closed_panes = Vec::new();
1263
1264 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 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 self.pane_manager = None;
1299 return (closed_panes, true);
1300 }
1301 }
1302 }
1303
1304 (closed_panes, false)
1305 }
1306
1307 pub fn pane_manager(&self) -> Option<&PaneManager> {
1309 self.pane_manager.as_ref()
1310 }
1311
1312 pub fn pane_manager_mut(&mut self) -> Option<&mut PaneManager> {
1314 self.pane_manager.as_mut()
1315 }
1316
1317 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 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 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 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 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 pub fn is_pane_focused(&self, pane_id: crate::pane::PaneId) -> bool {
1381 self.focused_pane_id() == Some(pane_id)
1382 }
1383
1384 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 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 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(50));
1447
1448 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 #[cfg(test)]
1461 pub(crate) fn new_stub(id: TabId, tab_number: usize) -> Self {
1462 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 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 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 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 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 assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
1565
1566 assert_eq!(
1568 Tab::parse_hostname_from_osc7_url("file://host"),
1569 Some("host".to_string())
1570 );
1571 }
1572}