Skip to main content

par_term/tab/
profile_tracking.rs

1//! Tab profile and title tracking methods.
2//!
3//! Provides methods for auto-updating tab titles from OSC sequences, tracking
4//! hostname/CWD changes for automatic profile switching, and managing the
5//! auto-profile lifecycle.
6
7use crate::tab::Tab;
8use crate::ui_constants::VISUAL_BELL_FLASH_DURATION_MS;
9
10impl Tab {
11    /// Check if the visual bell is currently active (within flash duration)
12    pub fn is_bell_active(&self) -> bool {
13        // Use active_bell() to route through the focused pane in split-pane mode.
14        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    /// Update tab title from terminal OSC sequences or shell integration data.
22    ///
23    /// Priority when on a **remote** host (hostname detected via OSC 7):
24    ///   1. Explicit OSC title (`\033]0;...\007`) if `remote_osc_priority` is true
25    ///   2. `remote_format` — formatted from hostname/username/cwd
26    ///
27    /// Priority when **local**:
28    ///   1. Explicit OSC title
29    ///   2. Last CWD component (only in `TabTitleMode::Auto`)
30    ///
31    /// User-named tabs are never auto-updated.
32    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        // User-named tabs are static — never auto-update
39        if self.user_named {
40            return;
41        }
42
43        // Step 2 — Snapshot focused pane ID before the mutable borrow.
44        // This avoids a Rust borrow-checker conflict: all_panes_mut() takes &mut pane_manager,
45        // and we must re-borrow it immutably in Step 4 after the loop ends.
46        let focused_id = self
47            .pane_manager
48            .as_ref()
49            .and_then(|pm| pm.focused_pane_id());
50
51        // Cache per-frame values that are constant across all panes (avoid syscall per pane).
52        let local_hostname = hostname::get().ok().and_then(|h| h.into_string().ok());
53        let home_dir = dirs::home_dir();
54
55        // Step 3 — Iterate all panes and update each one's title from its own terminal.
56        // try_read: intentional — called every frame; blocking would stall rendering.
57        // On contention: skip that pane this frame, no data loss.
58        if let Some(pm) = self.pane_manager.as_mut() {
59            for pane in pm.all_panes_mut() {
60                if let Ok(term) = pane.terminal.try_read() {
61                    let osc_title = term.get_title();
62                    let hostname = term.shell_integration_hostname();
63                    let username = term.shell_integration_username();
64                    let cwd = term.shell_integration_cwd();
65                    drop(term);
66
67                    let is_remote = if let Some(reported_host) = &hostname {
68                        local_hostname
69                            .as_ref()
70                            .map(|local| !reported_host.eq_ignore_ascii_case(local))
71                            .unwrap_or(false)
72                    } else {
73                        false
74                    };
75
76                    if is_remote {
77                        if remote_osc_priority && !osc_title.is_empty() {
78                            pane.title = osc_title;
79                            pane.has_default_title = false;
80                        } else {
81                            pane.title =
82                                format_remote_title(hostname, username, cwd, remote_format);
83                            pane.has_default_title = false;
84                        }
85                    } else if !osc_title.is_empty() {
86                        pane.title = osc_title;
87                        pane.has_default_title = false;
88                    } else if title_mode == par_term_config::TabTitleMode::Auto
89                        && let Some(cwd) = cwd
90                    {
91                        let abbreviated = if let Some(ref home) = home_dir {
92                            cwd.replace(&home.to_string_lossy().to_string(), "~")
93                        } else {
94                            cwd
95                        };
96                        if let Some(last) = abbreviated.rsplit('/').next() {
97                            if !last.is_empty() {
98                                pane.title = last.to_string();
99                            } else {
100                                pane.title = abbreviated;
101                            }
102                        } else {
103                            pane.title = abbreviated;
104                        }
105                        pane.has_default_title = false;
106                    }
107                    // else: keep existing pane.title unchanged this frame
108                }
109            }
110        }
111        // mutable borrow of pane_manager ends here
112
113        // Step 4 — Derive tab.title from the focused pane (immutable re-borrow is now safe).
114        if let Some((focused_id, pm)) = focused_id.zip(self.pane_manager.as_ref())
115            && let Some(pane) = pm.get_pane(focused_id)
116        {
117            self.title = pane.title.clone();
118            self.has_default_title = pane.has_default_title;
119        }
120    }
121
122    /// Set the tab's default title based on its position
123    pub fn set_default_title(&mut self, tab_number: usize) {
124        if self.has_default_title {
125            let title = format!("Tab {}", tab_number);
126            self.title = title.clone();
127            // Also write pane.title for every pane that still has a default title so
128            // update_title()'s Step 4 derivation from pane.title returns "Tab N" correctly
129            // (a brand-new pane has pane.title == "" which would otherwise overwrite).
130            if let Some(pm) = self.pane_manager.as_mut() {
131                for pane in pm.all_panes_mut() {
132                    if pane.has_default_title {
133                        pane.title = title.clone();
134                    }
135                }
136            }
137        }
138    }
139
140    /// Explicitly set the tab title (for tmux window names, etc.)
141    ///
142    /// This overrides any default title and marks the tab as having a custom title.
143    pub fn set_title(&mut self, title: &str) {
144        self.title = title.to_string();
145        self.has_default_title = false;
146        // Sync focused pane so update_title() doesn't overwrite on the next frame.
147        if let Some(pane) = self
148            .pane_manager
149            .as_mut()
150            .and_then(|pm| pm.focused_pane_mut())
151        {
152            pane.title = title.to_string();
153            pane.has_default_title = false;
154        }
155    }
156
157    /// Check if the terminal in this tab is still running
158    pub fn is_running(&self) -> bool {
159        if let Ok(term) = self.terminal.try_read() {
160            term.is_running()
161        } else {
162            true // Assume running if locked
163        }
164    }
165
166    /// Get the current working directory of this tab's shell
167    pub fn get_cwd(&self) -> Option<String> {
168        if let Ok(term) = self.terminal.try_read() {
169            term.shell_integration_cwd()
170        } else {
171            self.working_directory.clone()
172        }
173    }
174
175    /// Set a custom color for this tab
176    pub fn set_custom_color(&mut self, color: [u8; 3]) {
177        self.custom_color = Some(color);
178    }
179
180    /// Clear the custom color for this tab (reverts to default config colors)
181    pub fn clear_custom_color(&mut self) {
182        self.custom_color = None;
183    }
184
185    /// Check if this tab has a custom color set
186    pub fn has_custom_color(&self) -> bool {
187        self.custom_color.is_some()
188    }
189
190    /// Parse hostname from an OSC 7 file:// URL
191    ///
192    /// OSC 7 format: `file://hostname/path` or `file:///path` (localhost)
193    /// Returns the hostname if present and not localhost, None otherwise.
194    pub fn parse_hostname_from_osc7_url(url: &str) -> Option<String> {
195        let path = url.strip_prefix("file://")?;
196
197        if path.starts_with('/') {
198            // file:///path - localhost implicit
199            None
200        } else {
201            // file://hostname/path - extract hostname
202            let hostname = path.split('/').next()?;
203            if hostname.is_empty() || hostname == "localhost" {
204                None
205            } else {
206                Some(hostname.to_string())
207            }
208        }
209    }
210
211    /// Check if hostname has changed and update tracking
212    ///
213    /// Returns Some(hostname) if a new remote hostname was detected,
214    /// None if hostname hasn't changed or is local.
215    ///
216    /// This uses the hostname extracted from OSC 7 sequences by the terminal emulator.
217    pub fn check_hostname_change(&mut self) -> Option<String> {
218        let current_hostname = if let Ok(term) = self.terminal.try_read() {
219            term.shell_integration_hostname()
220        } else {
221            return None;
222        };
223
224        // Check if hostname has changed
225        if current_hostname != self.detected_hostname {
226            let old_hostname = self.detected_hostname.take();
227            self.detected_hostname = current_hostname.clone();
228
229            crate::debug_info!(
230                "PROFILE",
231                "Hostname changed: {:?} -> {:?}",
232                old_hostname,
233                current_hostname
234            );
235
236            // Return the new hostname if it's a remote host (not None/localhost)
237            current_hostname
238        } else {
239            None
240        }
241    }
242
243    /// Check if CWD has changed and update tracking
244    ///
245    /// Returns Some(cwd) if the CWD has changed, None otherwise.
246    /// Uses the CWD reported via OSC 7 by the terminal emulator.
247    pub fn check_cwd_change(&mut self) -> Option<String> {
248        let current_cwd = self.get_cwd();
249
250        if current_cwd != self.detected_cwd {
251            let old_cwd = self.detected_cwd.take();
252            self.detected_cwd = current_cwd.clone();
253
254            crate::debug_info!("PROFILE", "CWD changed: {:?} -> {:?}", old_cwd, current_cwd);
255
256            current_cwd
257        } else {
258            None
259        }
260    }
261
262    /// Clear auto-applied profile tracking
263    ///
264    /// Call this when manually switching profiles or when the hostname
265    /// returns to local, or when disconnecting from tmux.
266    pub fn clear_auto_profile(&mut self) {
267        self.profile.auto_applied_profile_id = None;
268        self.profile.auto_applied_dir_profile_id = None;
269        self.profile.profile_icon = None;
270        if let Some(original) = self.profile.pre_profile_title.take() {
271            self.set_title(&original);
272        }
273        self.profile.badge_override = None;
274    }
275}
276
277/// Format a tab title for a remote host based on the configured format.
278///
279/// Uses the remote username to abbreviate the home directory in `HostAndCwd` mode
280/// (e.g. `/home/alice/projects` → `~/projects`) rather than the local `$HOME`,
281/// which never matches remote paths.
282fn format_remote_title(
283    hostname: Option<String>,
284    username: Option<String>,
285    cwd: Option<String>,
286    format: par_term_config::RemoteTabTitleFormat,
287) -> String {
288    use par_term_config::RemoteTabTitleFormat;
289    let host = hostname.unwrap_or_default();
290    match format {
291        RemoteTabTitleFormat::UserAtHost => {
292            if let Some(user) = username {
293                format!("{}@{}", user, host)
294            } else {
295                host
296            }
297        }
298        RemoteTabTitleFormat::Host => host,
299        RemoteTabTitleFormat::HostAndCwd => {
300            if let Some(cwd) = cwd {
301                let abbrev = if let Some(ref user) = username {
302                    let linux_home = format!("/home/{}", user);
303                    let macos_home = format!("/Users/{}", user);
304                    let abbrev_with = |home: &str| -> Option<String> {
305                        if cwd == home {
306                            Some("~".to_string())
307                        } else if cwd.starts_with(&format!("{}/", home)) {
308                            Some(format!("~{}", &cwd[home.len()..]))
309                        } else {
310                            None
311                        }
312                    };
313                    if let Some(a) = abbrev_with(&linux_home) {
314                        a
315                    } else if let Some(a) = abbrev_with(&macos_home) {
316                        a
317                    } else {
318                        cwd
319                    }
320                } else {
321                    cwd
322                };
323                format!("{}:{}", host, abbrev)
324            } else {
325                host
326            }
327        }
328    }
329}
330
331#[cfg(test)]
332mod format_remote_title_tests {
333    use super::format_remote_title;
334    use par_term_config::RemoteTabTitleFormat;
335
336    #[test]
337    fn user_at_host_with_both() {
338        let result = format_remote_title(
339            Some("server".into()),
340            Some("alice".into()),
341            None,
342            RemoteTabTitleFormat::UserAtHost,
343        );
344        assert_eq!(result, "alice@server");
345    }
346
347    #[test]
348    fn user_at_host_no_username_falls_back_to_host() {
349        let result = format_remote_title(
350            Some("server".into()),
351            None,
352            None,
353            RemoteTabTitleFormat::UserAtHost,
354        );
355        assert_eq!(result, "server");
356    }
357
358    #[test]
359    fn host_only() {
360        let result = format_remote_title(
361            Some("mybox".into()),
362            Some("bob".into()),
363            Some("/home/bob/projects".into()),
364            RemoteTabTitleFormat::Host,
365        );
366        assert_eq!(result, "mybox");
367    }
368
369    #[test]
370    fn host_and_cwd_abbreviates_linux_home() {
371        let result = format_remote_title(
372            Some("server".into()),
373            Some("alice".into()),
374            Some("/home/alice/projects/foo".into()),
375            RemoteTabTitleFormat::HostAndCwd,
376        );
377        assert_eq!(result, "server:~/projects/foo");
378    }
379
380    #[test]
381    fn host_and_cwd_abbreviates_macos_home() {
382        let result = format_remote_title(
383            Some("mac".into()),
384            Some("alice".into()),
385            Some("/Users/alice/dev".into()),
386            RemoteTabTitleFormat::HostAndCwd,
387        );
388        assert_eq!(result, "mac:~/dev");
389    }
390
391    #[test]
392    fn host_and_cwd_no_cwd_falls_back_to_host() {
393        let result = format_remote_title(
394            Some("server".into()),
395            Some("alice".into()),
396            None,
397            RemoteTabTitleFormat::HostAndCwd,
398        );
399        assert_eq!(result, "server");
400    }
401
402    #[test]
403    fn host_and_cwd_unknown_path_no_abbreviation() {
404        let result = format_remote_title(
405            Some("server".into()),
406            Some("alice".into()),
407            Some("/var/log".into()),
408            RemoteTabTitleFormat::HostAndCwd,
409        );
410        assert_eq!(result, "server:/var/log");
411    }
412
413    #[test]
414    fn host_and_cwd_does_not_abbreviate_partial_username_match() {
415        let result = format_remote_title(
416            Some("server".into()),
417            Some("alice".into()),
418            Some("/home/alice2/projects".into()),
419            RemoteTabTitleFormat::HostAndCwd,
420        );
421        assert_eq!(result, "server:/home/alice2/projects");
422    }
423
424    #[test]
425    fn host_and_cwd_exact_home_dir_shows_tilde() {
426        let result = format_remote_title(
427            Some("server".into()),
428            Some("alice".into()),
429            Some("/home/alice".into()),
430            RemoteTabTitleFormat::HostAndCwd,
431        );
432        assert_eq!(result, "server:~");
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use crate::tab::Tab;
439
440    #[test]
441    fn test_parse_hostname_from_osc7_url_localhost() {
442        // file:///path - localhost implicit, should return None
443        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///home/user"), None);
444        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
445        assert_eq!(
446            Tab::parse_hostname_from_osc7_url("file:///var/log/syslog"),
447            None
448        );
449    }
450
451    #[test]
452    fn test_parse_hostname_from_osc7_url_remote() {
453        // file://hostname/path - should extract hostname
454        assert_eq!(
455            Tab::parse_hostname_from_osc7_url("file://server.example.com/home/user"),
456            Some("server.example.com".to_string())
457        );
458        assert_eq!(
459            Tab::parse_hostname_from_osc7_url("file://myhost/tmp"),
460            Some("myhost".to_string())
461        );
462        assert_eq!(
463            Tab::parse_hostname_from_osc7_url("file://192.168.1.100/var/log"),
464            Some("192.168.1.100".to_string())
465        );
466    }
467
468    #[test]
469    fn test_parse_hostname_from_osc7_url_localhost_explicit() {
470        // file://localhost/path - localhost should return None
471        assert_eq!(
472            Tab::parse_hostname_from_osc7_url("file://localhost/home/user"),
473            None
474        );
475    }
476
477    #[test]
478    fn test_parse_hostname_from_osc7_url_invalid() {
479        // Invalid URLs should return None
480        assert_eq!(Tab::parse_hostname_from_osc7_url(""), None);
481        assert_eq!(
482            Tab::parse_hostname_from_osc7_url("http://example.com"),
483            None
484        );
485        assert_eq!(Tab::parse_hostname_from_osc7_url("/home/user"), None);
486        assert_eq!(Tab::parse_hostname_from_osc7_url("file://"), None);
487    }
488
489    #[test]
490    fn test_parse_hostname_from_osc7_url_edge_cases() {
491        // Empty hostname after file://
492        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
493
494        // Hostname with no path (unusual but valid)
495        assert_eq!(
496            Tab::parse_hostname_from_osc7_url("file://host"),
497            Some("host".to_string())
498        );
499    }
500}
501
502#[cfg(test)]
503mod default_title_tests {
504    use crate::tab::Tab;
505
506    /// set_default_title() must write pane.title for default-titled panes
507    /// so that update_title()'s Step 4 derivation doesn't produce an empty string.
508    #[test]
509    fn set_default_title_syncs_pane_title() {
510        let mut tab = Tab::new_stub(1, 1);
511        // Fresh pane: has default title, pane.title starts as ""
512        {
513            let pm = tab.pane_manager.as_ref().unwrap();
514            let pane = pm.focused_pane().unwrap();
515            assert!(pane.has_default_title);
516            assert_eq!(pane.title, "");
517        }
518        tab.set_default_title(3);
519        assert_eq!(tab.title, "Tab 3");
520        // Pane must also be updated so derivation survives the next frame
521        let pm = tab.pane_manager.as_ref().unwrap();
522        let pane = pm.focused_pane().unwrap();
523        assert_eq!(pane.title, "Tab 3");
524        assert!(pane.has_default_title);
525    }
526
527    /// set_default_title() must NOT overwrite panes that already have a real title.
528    #[test]
529    fn set_default_title_skips_non_default_panes() {
530        let mut tab = Tab::new_stub(1, 1);
531        // Simulate pane having received a real title
532        {
533            let pm = tab.pane_manager.as_mut().unwrap();
534            let pane = pm.focused_pane_mut().unwrap();
535            pane.title = "vim".to_string();
536            pane.has_default_title = false;
537        }
538        // tab.has_default_title stays true (simulates multi-pane where focused has real title
539        // but tab-level tracking is slightly stale)
540        tab.has_default_title = true;
541        tab.set_default_title(2);
542        // Pane with a real title must be untouched
543        let pm = tab.pane_manager.as_ref().unwrap();
544        let pane = pm.focused_pane().unwrap();
545        assert_eq!(pane.title, "vim");
546        assert!(!pane.has_default_title);
547    }
548
549    #[test]
550    fn set_title_syncs_focused_pane() {
551        let mut tab = Tab::new_stub(1, 1);
552        tab.set_title("my-session");
553        assert_eq!(tab.title, "my-session");
554        assert!(!tab.has_default_title);
555        let pm = tab.pane_manager.as_ref().unwrap();
556        let pane = pm.focused_pane().unwrap();
557        assert_eq!(pane.title, "my-session");
558        assert!(!pane.has_default_title);
559    }
560}