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                // A user-named pane is static, the same rule as a
61                // user-named tab: OSC titles and CWD never overwrite it.
62                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                    // else: keep existing pane.title unchanged this frame
113                }
114            }
115        }
116        // mutable borrow of pane_manager ends here
117
118        // Step 4 — Derive tab.title from the focused pane (immutable re-borrow is now safe).
119        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    /// Set (or clear) a pane's user title (card 01a0d95dd318).
128    ///
129    /// A non-empty `name` marks the pane user-named — `update_title`'s
130    /// per-pane loop then never overwrites it with an OSC title or CWD, the
131    /// same guard a user-named tab gets. An empty or blank `name` reverts
132    /// the pane to automatic titles. Returns whether the pane exists; the
133    /// caller re-derives titles afterwards (an `update_title` pass).
134    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            // Blank name: revert to automatic titles (RenameTab's blank branch).
144            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    /// The user title of this tab's sole pane, for persistence (card
156    /// 01a0d95dd318). `None` unless the tab has exactly one pane and that
157    /// pane is user-named — multi-pane layouts persist pane titles per-leaf
158    /// in the session pane tree instead, and arrangements (which restore
159    /// single-pane tabs only) can only carry this form.
160    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    /// Restore a persisted sole-pane user title (session/arrangement
171    /// restore). Same field writes as [`Tab::rename_pane`] — a restored
172    /// local pane has no daemon side, so nothing is pushed.
173    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    /// Set the tab's default title based on its position
187    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            // Also write pane.title for every pane that still has a default title so
192            // update_title()'s Step 4 derivation from pane.title returns "Tab N" correctly
193            // (a brand-new pane has pane.title == "" which would otherwise overwrite).
194            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    /// Explicitly set the tab title (for tmux window names, etc.)
205    ///
206    /// This overrides any default title and marks the tab as having a custom title.
207    pub fn set_title(&mut self, title: &str) {
208        self.title = title.to_string();
209        self.has_default_title = false;
210        // Sync focused pane so update_title() doesn't overwrite on the next frame.
211        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    /// Check if the terminal in this tab is still running
222    pub fn is_running(&self) -> bool {
223        if let Ok(term) = self.terminal.try_read() {
224            term.is_running()
225        } else {
226            true // Assume running if locked
227        }
228    }
229
230    /// Get the current working directory of this tab's shell
231    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    /// Set a custom color for this tab
240    pub fn set_custom_color(&mut self, color: [u8; 3]) {
241        self.custom_color = Some(color);
242    }
243
244    /// Clear the custom color for this tab (reverts to default config colors)
245    pub fn clear_custom_color(&mut self) {
246        self.custom_color = None;
247    }
248
249    /// Check if this tab has a custom color set
250    pub fn has_custom_color(&self) -> bool {
251        self.custom_color.is_some()
252    }
253
254    /// Parse hostname from an OSC 7 file:// URL
255    ///
256    /// OSC 7 format: `file://hostname/path` or `file:///path` (localhost)
257    /// Returns the hostname if present and not localhost, None otherwise.
258    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            // file:///path - localhost implicit
263            None
264        } else {
265            // file://hostname/path - extract hostname
266            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    /// Check if hostname has changed and update tracking
276    ///
277    /// Returns Some(hostname) if a new remote hostname was detected,
278    /// None if hostname hasn't changed or is local.
279    ///
280    /// This uses the hostname extracted from OSC 7 sequences by the terminal emulator.
281    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        // Check if hostname has changed
289        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            // Return the new hostname if it's a remote host (not None/localhost)
301            current_hostname
302        } else {
303            None
304        }
305    }
306
307    /// Check if CWD has changed and update tracking
308    ///
309    /// Returns Some(cwd) if the CWD has changed, None otherwise.
310    /// Uses the CWD reported via OSC 7 by the terminal emulator.
311    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    /// Clear auto-applied profile tracking
327    ///
328    /// Call this when manually switching profiles or when the hostname
329    /// returns to local, or when disconnecting from tmux.
330    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
341/// Format a tab title for a remote host based on the configured format.
342///
343/// Uses the remote username to abbreviate the home directory in `HostAndCwd` mode
344/// (e.g. `/home/alice/projects` → `~/projects`) rather than the local `$HOME`,
345/// which never matches remote paths.
346fn 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        // file:///path - localhost implicit, should return None
507        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        // file://hostname/path - should extract hostname
518        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        // file://localhost/path - localhost should return None
535        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        // Invalid URLs should return None
544        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        // Empty hostname after file://
556        assert_eq!(Tab::parse_hostname_from_osc7_url("file:///"), None);
557
558        // Hostname with no path (unusual but valid)
559        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    /// Card 01a0d95dd318 criterion 2: a user-named pane keeps its name
571    /// when the program changes its OSC title (the same skip that keeps a
572    /// user-named tab static; the CWD fallback is skipped by the same
573    /// guard).
574    #[test]
575    fn user_named_pane_keeps_its_title_against_osc() {
576        let mut tab = Tab::new_stub(1, 1);
577        // Feed an OSC 0 title into the stub pane's terminal — the
578        // auto-title loop must pick it up while the pane is not
579        // user-named.
580        {
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        // User-name it: the same OSC title must not reclaim the pane.
602        {
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    /// Card 01a0d95dd318 criterion 1: setting a name marks the pane
620    /// user-named; a blank name reverts it to automatic titles.
621    #[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        // Blank (whitespace-only) input reverts to automatic titles.
645        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    /// The name is trimmed before it lands, and an unknown pane id (a pane
658    /// closed while the rename popup was open) is reported, not silently
659    /// ignored.
660    #[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    /// Card 01a0d95dd318 criterion 4, local half: the sole pane's user
686    /// title round-trips through the persistence helpers (what
687    /// TabSnapshot.pane_user_title carries), and an automatic-titled pane
688    /// captures as None.
689    #[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        // Restore into a fresh tab: the pane comes back user-named.
709        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    /// set_default_title() must write pane.title for default-titled panes
726    /// so that update_title()'s Step 4 derivation doesn't produce an empty string.
727    #[test]
728    fn set_default_title_syncs_pane_title() {
729        let mut tab = Tab::new_stub(1, 1);
730        // Fresh pane: has default title, pane.title starts as ""
731        {
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        // Pane must also be updated so derivation survives the next frame
740        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    /// set_default_title() must NOT overwrite panes that already have a real title.
747    #[test]
748    fn set_default_title_skips_non_default_panes() {
749        let mut tab = Tab::new_stub(1, 1);
750        // Simulate pane having received a real title
751        {
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 stays true (simulates multi-pane where focused has real title
758        // but tab-level tracking is slightly stale)
759        tab.has_default_title = true;
760        tab.set_default_title(2);
761        // Pane with a real title must be untouched
762        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}