1use crate::tab::Tab;
8use crate::ui_constants::VISUAL_BELL_FLASH_DURATION_MS;
9
10impl Tab {
11 pub fn is_bell_active(&self) -> bool {
13 if let Some(flash_start) = self.active_bell().visual_flash {
15 flash_start.elapsed().as_millis() < VISUAL_BELL_FLASH_DURATION_MS
16 } else {
17 false
18 }
19 }
20
21 pub fn update_title(
33 &mut self,
34 title_mode: par_term_config::TabTitleMode,
35 remote_format: par_term_config::RemoteTabTitleFormat,
36 remote_osc_priority: bool,
37 ) {
38 if self.user_named {
40 return;
41 }
42
43 let focused_id = self
47 .pane_manager
48 .as_ref()
49 .and_then(|pm| pm.focused_pane_id());
50
51 let local_hostname = hostname::get().ok().and_then(|h| h.into_string().ok());
53 let home_dir = dirs::home_dir();
54
55 if let Some(pm) = self.pane_manager.as_mut() {
59 for pane in pm.all_panes_mut() {
60 if pane.user_named {
63 continue;
64 }
65 if let Ok(term) = pane.terminal.try_read() {
66 let osc_title = term.get_title();
67 let hostname = term.shell_integration_hostname();
68 let username = term.shell_integration_username();
69 let cwd = term.shell_integration_cwd();
70 drop(term);
71
72 let is_remote = if let Some(reported_host) = &hostname {
73 local_hostname
74 .as_ref()
75 .map(|local| !reported_host.eq_ignore_ascii_case(local))
76 .unwrap_or(false)
77 } else {
78 false
79 };
80
81 if is_remote {
82 if remote_osc_priority && !osc_title.is_empty() {
83 pane.title = osc_title;
84 pane.has_default_title = false;
85 } else {
86 pane.title =
87 format_remote_title(hostname, username, cwd, remote_format);
88 pane.has_default_title = false;
89 }
90 } else if !osc_title.is_empty() {
91 pane.title = osc_title;
92 pane.has_default_title = false;
93 } else if title_mode == par_term_config::TabTitleMode::Auto
94 && let Some(cwd) = cwd
95 {
96 let abbreviated = if let Some(ref home) = home_dir {
97 cwd.replace(&home.to_string_lossy().to_string(), "~")
98 } else {
99 cwd
100 };
101 if let Some(last) = abbreviated.rsplit('/').next() {
102 if !last.is_empty() {
103 pane.title = last.to_string();
104 } else {
105 pane.title = abbreviated;
106 }
107 } else {
108 pane.title = abbreviated;
109 }
110 pane.has_default_title = false;
111 }
112 }
114 }
115 }
116 if let Some((focused_id, pm)) = focused_id.zip(self.pane_manager.as_ref())
120 && let Some(pane) = pm.get_pane(focused_id)
121 {
122 self.title = pane.title.clone();
123 self.has_default_title = pane.has_default_title;
124 }
125 }
126
127 pub fn rename_pane(&mut self, pane_id: crate::pane::PaneId, name: &str) -> bool {
135 let Some(pm) = self.pane_manager.as_mut() else {
136 return false;
137 };
138 let Some(pane) = pm.get_pane_mut(pane_id) else {
139 return false;
140 };
141 let name = name.trim();
142 if name.is_empty() {
143 pane.user_named = false;
145 pane.has_default_title = true;
146 pane.title = String::new();
147 } else {
148 pane.user_named = true;
149 pane.has_default_title = false;
150 pane.title = name.to_string();
151 }
152 true
153 }
154
155 pub fn sole_pane_user_title(&self) -> Option<String> {
161 let pm = self.pane_manager.as_ref()?;
162 if pm.pane_count() != 1 {
163 return None;
164 }
165 let panes = pm.all_panes();
166 let pane = panes.first()?;
167 pane.user_named.then(|| pane.title.clone())
168 }
169
170 pub fn restore_sole_pane_title(&mut self, title: &str) {
174 let Some(pm) = self.pane_manager.as_mut() else {
175 return;
176 };
177 if pm.pane_count() != 1 {
178 return;
179 }
180 let Some(pane_id) = pm.focused_pane_id() else {
181 return;
182 };
183 self.rename_pane(pane_id, title);
184 }
185
186 pub fn set_default_title(&mut self, tab_number: usize) {
188 if self.has_default_title {
189 let title = format!("Tab {}", tab_number);
190 self.title = title.clone();
191 if let Some(pm) = self.pane_manager.as_mut() {
195 for pane in pm.all_panes_mut() {
196 if pane.has_default_title {
197 pane.title = title.clone();
198 }
199 }
200 }
201 }
202 }
203
204 pub fn set_title(&mut self, title: &str) {
208 self.title = title.to_string();
209 self.has_default_title = false;
210 if let Some(pane) = self
212 .pane_manager
213 .as_mut()
214 .and_then(|pm| pm.focused_pane_mut())
215 {
216 pane.title = title.to_string();
217 pane.has_default_title = false;
218 }
219 }
220
221 pub fn is_running(&self) -> bool {
223 if let Ok(term) = self.terminal.try_read() {
224 term.is_running()
225 } else {
226 true }
228 }
229
230 pub fn get_cwd(&self) -> Option<String> {
232 if let Ok(term) = self.terminal.try_read() {
233 term.shell_integration_cwd()
234 } else {
235 self.working_directory.clone()
236 }
237 }
238
239 pub fn set_custom_color(&mut self, color: [u8; 3]) {
241 self.custom_color = Some(color);
242 }
243
244 pub fn clear_custom_color(&mut self) {
246 self.custom_color = None;
247 }
248
249 pub fn has_custom_color(&self) -> bool {
251 self.custom_color.is_some()
252 }
253
254 pub fn parse_hostname_from_osc7_url(url: &str) -> Option<String> {
259 let path = url.strip_prefix("file://")?;
260
261 if path.starts_with('/') {
262 None
264 } else {
265 let hostname = path.split('/').next()?;
267 if hostname.is_empty() || hostname == "localhost" {
268 None
269 } else {
270 Some(hostname.to_string())
271 }
272 }
273 }
274
275 pub fn check_hostname_change(&mut self) -> Option<String> {
282 let current_hostname = if let Ok(term) = self.terminal.try_read() {
283 term.shell_integration_hostname()
284 } else {
285 return None;
286 };
287
288 if current_hostname != self.detected_hostname {
290 let old_hostname = self.detected_hostname.take();
291 self.detected_hostname = current_hostname.clone();
292
293 crate::debug_info!(
294 "PROFILE",
295 "Hostname changed: {:?} -> {:?}",
296 old_hostname,
297 current_hostname
298 );
299
300 current_hostname
302 } else {
303 None
304 }
305 }
306
307 pub fn check_cwd_change(&mut self) -> Option<String> {
312 let current_cwd = self.get_cwd();
313
314 if current_cwd != self.detected_cwd {
315 let old_cwd = self.detected_cwd.take();
316 self.detected_cwd = current_cwd.clone();
317
318 crate::debug_info!("PROFILE", "CWD changed: {:?} -> {:?}", old_cwd, current_cwd);
319
320 current_cwd
321 } else {
322 None
323 }
324 }
325
326 pub fn clear_auto_profile(&mut self) {
331 self.profile.auto_applied_profile_id = None;
332 self.profile.auto_applied_dir_profile_id = None;
333 self.profile.profile_icon = None;
334 if let Some(original) = self.profile.pre_profile_title.take() {
335 self.set_title(&original);
336 }
337 self.profile.badge_override = None;
338 }
339}
340
341fn format_remote_title(
347 hostname: Option<String>,
348 username: Option<String>,
349 cwd: Option<String>,
350 format: par_term_config::RemoteTabTitleFormat,
351) -> String {
352 use par_term_config::RemoteTabTitleFormat;
353 let host = hostname.unwrap_or_default();
354 match format {
355 RemoteTabTitleFormat::UserAtHost => {
356 if let Some(user) = username {
357 format!("{}@{}", user, host)
358 } else {
359 host
360 }
361 }
362 RemoteTabTitleFormat::Host => host,
363 RemoteTabTitleFormat::HostAndCwd => {
364 if let Some(cwd) = cwd {
365 let abbrev = if let Some(ref user) = username {
366 let linux_home = format!("/home/{}", user);
367 let macos_home = format!("/Users/{}", user);
368 let abbrev_with = |home: &str| -> Option<String> {
369 if cwd == home {
370 Some("~".to_string())
371 } else if cwd.starts_with(&format!("{}/", home)) {
372 Some(format!("~{}", &cwd[home.len()..]))
373 } else {
374 None
375 }
376 };
377 if let Some(a) = abbrev_with(&linux_home) {
378 a
379 } else if let Some(a) = abbrev_with(&macos_home) {
380 a
381 } else {
382 cwd
383 }
384 } else {
385 cwd
386 };
387 format!("{}:{}", host, abbrev)
388 } else {
389 host
390 }
391 }
392 }
393}
394
395#[cfg(test)]
396mod format_remote_title_tests {
397 use super::format_remote_title;
398 use par_term_config::RemoteTabTitleFormat;
399
400 #[test]
401 fn user_at_host_with_both() {
402 let result = format_remote_title(
403 Some("server".into()),
404 Some("alice".into()),
405 None,
406 RemoteTabTitleFormat::UserAtHost,
407 );
408 assert_eq!(result, "alice@server");
409 }
410
411 #[test]
412 fn user_at_host_no_username_falls_back_to_host() {
413 let result = format_remote_title(
414 Some("server".into()),
415 None,
416 None,
417 RemoteTabTitleFormat::UserAtHost,
418 );
419 assert_eq!(result, "server");
420 }
421
422 #[test]
423 fn host_only() {
424 let result = format_remote_title(
425 Some("mybox".into()),
426 Some("bob".into()),
427 Some("/home/bob/projects".into()),
428 RemoteTabTitleFormat::Host,
429 );
430 assert_eq!(result, "mybox");
431 }
432
433 #[test]
434 fn host_and_cwd_abbreviates_linux_home() {
435 let result = format_remote_title(
436 Some("server".into()),
437 Some("alice".into()),
438 Some("/home/alice/projects/foo".into()),
439 RemoteTabTitleFormat::HostAndCwd,
440 );
441 assert_eq!(result, "server:~/projects/foo");
442 }
443
444 #[test]
445 fn host_and_cwd_abbreviates_macos_home() {
446 let result = format_remote_title(
447 Some("mac".into()),
448 Some("alice".into()),
449 Some("/Users/alice/dev".into()),
450 RemoteTabTitleFormat::HostAndCwd,
451 );
452 assert_eq!(result, "mac:~/dev");
453 }
454
455 #[test]
456 fn host_and_cwd_no_cwd_falls_back_to_host() {
457 let result = format_remote_title(
458 Some("server".into()),
459 Some("alice".into()),
460 None,
461 RemoteTabTitleFormat::HostAndCwd,
462 );
463 assert_eq!(result, "server");
464 }
465
466 #[test]
467 fn host_and_cwd_unknown_path_no_abbreviation() {
468 let result = format_remote_title(
469 Some("server".into()),
470 Some("alice".into()),
471 Some("/var/log".into()),
472 RemoteTabTitleFormat::HostAndCwd,
473 );
474 assert_eq!(result, "server:/var/log");
475 }
476
477 #[test]
478 fn host_and_cwd_does_not_abbreviate_partial_username_match() {
479 let result = format_remote_title(
480 Some("server".into()),
481 Some("alice".into()),
482 Some("/home/alice2/projects".into()),
483 RemoteTabTitleFormat::HostAndCwd,
484 );
485 assert_eq!(result, "server:/home/alice2/projects");
486 }
487
488 #[test]
489 fn host_and_cwd_exact_home_dir_shows_tilde() {
490 let result = format_remote_title(
491 Some("server".into()),
492 Some("alice".into()),
493 Some("/home/alice".into()),
494 RemoteTabTitleFormat::HostAndCwd,
495 );
496 assert_eq!(result, "server:~");
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use crate::tab::Tab;
503
504 #[test]
505 fn test_parse_hostname_from_osc7_url_localhost() {
506 assert_eq!(Tab::parse_hostname_from_osc7_url("file:///home/user"), None);
508 assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
509 assert_eq!(
510 Tab::parse_hostname_from_osc7_url("file:///var/log/syslog"),
511 None
512 );
513 }
514
515 #[test]
516 fn test_parse_hostname_from_osc7_url_remote() {
517 assert_eq!(
519 Tab::parse_hostname_from_osc7_url("file://server.example.com/home/user"),
520 Some("server.example.com".to_string())
521 );
522 assert_eq!(
523 Tab::parse_hostname_from_osc7_url("file://myhost/tmp"),
524 Some("myhost".to_string())
525 );
526 assert_eq!(
527 Tab::parse_hostname_from_osc7_url("file://192.168.1.100/var/log"),
528 Some("192.168.1.100".to_string())
529 );
530 }
531
532 #[test]
533 fn test_parse_hostname_from_osc7_url_localhost_explicit() {
534 assert_eq!(
536 Tab::parse_hostname_from_osc7_url("file://localhost/home/user"),
537 None
538 );
539 }
540
541 #[test]
542 fn test_parse_hostname_from_osc7_url_invalid() {
543 assert_eq!(Tab::parse_hostname_from_osc7_url(""), None);
545 assert_eq!(
546 Tab::parse_hostname_from_osc7_url("http://example.com"),
547 None
548 );
549 assert_eq!(Tab::parse_hostname_from_osc7_url("/home/user"), None);
550 assert_eq!(Tab::parse_hostname_from_osc7_url("file://"), None);
551 }
552
553 #[test]
554 fn test_parse_hostname_from_osc7_url_edge_cases() {
555 assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
557
558 assert_eq!(
560 Tab::parse_hostname_from_osc7_url("file://host"),
561 Some("host".to_string())
562 );
563 }
564}
565
566#[cfg(test)]
567mod pane_rename_tests {
568 use crate::tab::Tab;
569
570 #[test]
575 fn user_named_pane_keeps_its_title_against_osc() {
576 let mut tab = Tab::new_stub(1, 1);
577 {
581 let pm = tab.pane_manager.as_mut().expect("pane manager");
582 let pane = pm.focused_pane_mut().expect("focused pane");
583 if let Ok(term) = pane.terminal.try_read() {
584 term.process_data(b"\x1b]0;vim terminal\x07");
585 }
586 }
587 tab.update_title(
588 par_term_config::TabTitleMode::Auto,
589 par_term_config::RemoteTabTitleFormat::default(),
590 false,
591 );
592 {
593 let pm = tab.pane_manager.as_ref().expect("pane manager");
594 let pane = pm.focused_pane().expect("focused pane");
595 assert_eq!(
596 pane.title, "vim terminal",
597 "the OSC title applies while the pane is not user-named"
598 );
599 }
600
601 {
603 let pm = tab.pane_manager.as_mut().expect("pane manager");
604 let pane = pm.focused_pane_mut().expect("focused pane");
605 pane.user_named = true;
606 pane.title = "my pane".to_string();
607 }
608 tab.update_title(
609 par_term_config::TabTitleMode::Auto,
610 par_term_config::RemoteTabTitleFormat::default(),
611 false,
612 );
613 let pm = tab.pane_manager.as_ref().expect("pane manager");
614 let pane = pm.focused_pane().expect("focused pane");
615 assert_eq!(pane.title, "my pane", "a user-named pane is static");
616 assert!(pane.user_named);
617 }
618
619 #[test]
622 fn rename_pane_sets_and_clears_the_user_name() {
623 let mut tab = Tab::new_stub(1, 1);
624 let pane_id = tab
625 .pane_manager
626 .as_ref()
627 .expect("pane manager")
628 .focused_pane_id()
629 .expect("focused pane");
630
631 assert!(tab.rename_pane(pane_id, "build box"));
632 {
633 let pane = tab
634 .pane_manager
635 .as_ref()
636 .expect("pane manager")
637 .focused_pane()
638 .expect("focused pane");
639 assert_eq!(pane.title, "build box");
640 assert!(pane.user_named);
641 assert!(!pane.has_default_title);
642 }
643
644 assert!(tab.rename_pane(pane_id, " "));
646 let pane = tab
647 .pane_manager
648 .as_ref()
649 .expect("pane manager")
650 .focused_pane()
651 .expect("focused pane");
652 assert!(pane.title.is_empty(), "the old name must be dropped now");
653 assert!(!pane.user_named);
654 assert!(pane.has_default_title);
655 }
656
657 #[test]
661 fn rename_pane_trims_and_reports_unknown_panes() {
662 let mut tab = Tab::new_stub(1, 1);
663 let pane_id = tab
664 .pane_manager
665 .as_ref()
666 .expect("pane manager")
667 .focused_pane_id()
668 .expect("focused pane");
669
670 assert!(tab.rename_pane(pane_id, " spaced "));
671 let pane = tab
672 .pane_manager
673 .as_ref()
674 .expect("pane manager")
675 .focused_pane()
676 .expect("focused pane");
677 assert_eq!(pane.title, "spaced", "surrounding whitespace is trimmed");
678
679 assert!(
680 !tab.rename_pane(u64::MAX, "nope"),
681 "an unmapped pane id must report failure"
682 );
683 }
684
685 #[test]
690 fn sole_pane_user_title_round_trips_through_the_helpers() {
691 let mut tab = Tab::new_stub(1, 1);
692 assert_eq!(
693 tab.sole_pane_user_title(),
694 None,
695 "an automatic-titled pane captures as None"
696 );
697
698 tab.rename_pane(
699 tab.pane_manager
700 .as_ref()
701 .expect("pane manager")
702 .focused_pane_id()
703 .expect("focused pane"),
704 "build box",
705 );
706 assert_eq!(tab.sole_pane_user_title(), Some("build box".to_string()));
707
708 let mut restored = Tab::new_stub(1, 1);
710 restored.restore_sole_pane_title("build box");
711 let pane = restored
712 .pane_manager
713 .as_ref()
714 .expect("pane manager")
715 .focused_pane()
716 .expect("focused pane");
717 assert_eq!(pane.title, "build box");
718 assert!(pane.user_named);
719 }
720}
721#[cfg(test)]
722mod default_title_tests {
723 use crate::tab::Tab;
724
725 #[test]
728 fn set_default_title_syncs_pane_title() {
729 let mut tab = Tab::new_stub(1, 1);
730 {
732 let pm = tab.pane_manager.as_ref().unwrap();
733 let pane = pm.focused_pane().unwrap();
734 assert!(pane.has_default_title);
735 assert_eq!(pane.title, "");
736 }
737 tab.set_default_title(3);
738 assert_eq!(tab.title, "Tab 3");
739 let pm = tab.pane_manager.as_ref().unwrap();
741 let pane = pm.focused_pane().unwrap();
742 assert_eq!(pane.title, "Tab 3");
743 assert!(pane.has_default_title);
744 }
745
746 #[test]
748 fn set_default_title_skips_non_default_panes() {
749 let mut tab = Tab::new_stub(1, 1);
750 {
752 let pm = tab.pane_manager.as_mut().unwrap();
753 let pane = pm.focused_pane_mut().unwrap();
754 pane.title = "vim".to_string();
755 pane.has_default_title = false;
756 }
757 tab.has_default_title = true;
760 tab.set_default_title(2);
761 let pm = tab.pane_manager.as_ref().unwrap();
763 let pane = pm.focused_pane().unwrap();
764 assert_eq!(pane.title, "vim");
765 assert!(!pane.has_default_title);
766 }
767
768 #[test]
769 fn set_title_syncs_focused_pane() {
770 let mut tab = Tab::new_stub(1, 1);
771 tab.set_title("my-session");
772 assert_eq!(tab.title, "my-session");
773 assert!(!tab.has_default_title);
774 let pm = tab.pane_manager.as_ref().unwrap();
775 let pane = pm.focused_pane().unwrap();
776 assert_eq!(pane.title, "my-session");
777 assert!(!pane.has_default_title);
778 }
779}