diff --git a/CHANGELOG.md b/CHANGELOG.md index 7129594b6..1ccfa95a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +* fix: forward OSC7 (cwd changes) to host terminal (https://github.com/zellij-org/zellij/pull/4867) ## [0.45.1] - 2026-08-28 * fix: nested-session detection over SSH (https://github.com/zellij-org/zellij/pull/5522) diff --git a/zellij-integration-tests/tests/osc7_forwarding.rs b/zellij-integration-tests/tests/osc7_forwarding.rs new file mode 100644 index 000000000..5581b760b --- /dev/null +++ b/zellij-integration-tests/tests/osc7_forwarding.rs @@ -0,0 +1,144 @@ +#![cfg(unix)] + +use zellij_integration_tests::{ + claim_first_terminal_and_wait_for_prompt, col, keys, split_down_and_wait_for_prompt, + start_zellij, FakePtyHandle, TestSession, +}; + +const OSC7_OPENER: &[u8] = b"\x1b]7;"; + +fn osc7(uri: &str) -> Vec { + format!("\u{1b}]7;{}\u{1b}\\", uri).into_bytes() +} + +fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool { + occurrences_of(haystack, needle) > 0 +} + +fn occurrences_of(haystack: &[u8], needle: &[u8]) -> usize { + if needle.is_empty() || haystack.len() < needle.len() { + return 0; + } + haystack + .windows(needle.len()) + .filter(|window| *window == needle) + .count() +} + +fn claim_quiet_terminal(zellij: &TestSession) -> FakePtyHandle { + let terminal = claim_first_terminal_and_wait_for_prompt(zellij); + terminal.disable_echo(); + terminal +} + +fn split_down_to_quiet_terminal(zellij: &TestSession) -> FakePtyHandle { + let terminal = split_down_and_wait_for_prompt(zellij); + terminal.disable_echo(); + terminal +} + +fn report_cwd_and_wait(zellij: &TestSession, terminal: &FakePtyHandle, uri: &str) { + terminal.output(&osc7(uri)); + zellij.wait_until_raw_output( + &format!("the host terminal was told about {}", uri), + |raw_bytes| contains_subslice(raw_bytes, &osc7(uri)), + ); +} + +fn echo_and_wait(zellij: &TestSession, terminal: &FakePtyHandle, sentinel: &str) { + terminal.output(format!("{}\r\n", sentinel).as_bytes()); + zellij.wait_until("the pane kept processing its output", |grid_snapshot| { + grid_snapshot.contains(sentinel) + }); +} + +fn focus_upper_pane(zellij: &TestSession) { + zellij.send_stdin(&keys::ctrl('p')); + zellij.send_stdin(&keys::key('k')); + zellij.send_stdin(&keys::ENTER); + zellij.wait_until("focus moved to the upper pane", |grid_snapshot| { + grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(2)) + }); +} + +#[test] +fn a_working_directory_report_reaches_the_host_terminal() { + let mut zellij = start_zellij(); + let terminal = claim_quiet_terminal(&zellij); + + report_cwd_and_wait(&zellij, &terminal, "file://host/tmp"); + + zellij.quit(); +} + +#[test] +fn an_unchanged_working_directory_is_reported_only_once() { + let mut zellij = start_zellij(); + let terminal = claim_quiet_terminal(&zellij); + + report_cwd_and_wait(&zellij, &terminal, "file://host/tmp"); + terminal.output(&osc7("file://host/tmp")); + echo_and_wait(&zellij, &terminal, "second-report-processed"); + + assert_eq!( + occurrences_of(&zellij.raw_bytes(), &osc7("file://host/tmp")), + 1, + "a pane repeating its working directory does not report it again" + ); + + zellij.quit(); +} + +#[test] +fn a_changed_working_directory_is_reported_again() { + let mut zellij = start_zellij(); + let terminal = claim_quiet_terminal(&zellij); + + report_cwd_and_wait(&zellij, &terminal, "file://host/first"); + report_cwd_and_wait(&zellij, &terminal, "file://host/second"); + + zellij.quit(); +} + +#[test] +fn moving_focus_reports_the_working_directory_of_the_newly_focused_pane() { + let mut zellij = start_zellij(); + let upper_terminal = claim_quiet_terminal(&zellij); + report_cwd_and_wait(&zellij, &upper_terminal, "file://host/upper"); + + let lower_terminal = split_down_to_quiet_terminal(&zellij); + report_cwd_and_wait(&zellij, &lower_terminal, "file://host/lower"); + + focus_upper_pane(&zellij); + + zellij.wait_until_raw_output( + "the upper pane's working directory was restated on focus", + |raw_bytes| occurrences_of(raw_bytes, &osc7("file://host/upper")) == 2, + ); + + zellij.quit(); +} + +#[test] +fn a_pane_that_never_reported_a_working_directory_reports_nothing_on_focus() { + let mut zellij = start_zellij(); + let upper_terminal = claim_quiet_terminal(&zellij); + report_cwd_and_wait(&zellij, &upper_terminal, "file://host/upper"); + + let lower_terminal = split_down_to_quiet_terminal(&zellij); + echo_and_wait(&zellij, &lower_terminal, "lower-pane-is-focused"); + + assert_eq!( + occurrences_of(&zellij.raw_bytes(), OSC7_OPENER), + 1, + "focusing a pane with nothing to report leaves the host terminal as it was" + ); + + focus_upper_pane(&zellij); + zellij.wait_until_raw_output( + "the upper pane's working directory was restated on focus", + |raw_bytes| occurrences_of(raw_bytes, &osc7("file://host/upper")) == 2, + ); + + zellij.quit(); +} diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index 2fcd5536f..0b2af88b9 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -889,6 +889,7 @@ pub struct Grid { osc133_command_selection: bool, command_output_flash: Option, word_separators: String, + pub osc7_payload: Option, } impl Grid { @@ -1262,6 +1263,7 @@ impl Grid { osc133_command_selection: true, command_output_flash: None, word_separators: DEFAULT_WORD_SEPARATORS.to_owned(), + osc7_payload: None, } } pub fn set_selection_options(&mut self, osc133_command_selection: bool, word_separators: &str) { @@ -1270,6 +1272,11 @@ impl Grid { self.word_separators = word_separators.to_owned(); } } + /// Returns the last OSC 7 working directory URI reported by the child process, + /// or `None` if no OSC 7 has been received (or was rejected for invalid content). + pub fn osc7_payload(&self) -> Option<&str> { + self.osc7_payload.as_deref() + } pub fn render_full_viewport(&mut self) { self.output_buffer.update_all_lines(); } @@ -2812,6 +2819,7 @@ impl Grid { self.pane_default_fg = None; self.pane_default_bg = None; self.osc133_markers_seen = false; + self.osc7_payload = None; if let Some(images_to_reap) = self.sixel_grid.clear() { self.sixel_grid.reap_images(images_to_reap); } @@ -4591,6 +4599,18 @@ impl Perform for Grid { self.pending_osc7_cwd = Some(path); } } + // Store the raw URI separately for forwarding to the parent terminal. + // Join params[1..] with ";" to preserve semicolons in the URI. + if params.len() >= 2 { + let segments: Option> = + params[1..].iter().map(|x| str::from_utf8(x).ok()).collect(); + if let Some(segments) = segments { + let uri = segments.join(";"); + if !uri.is_empty() && !uri.chars().any(|c| c.is_control()) { + self.osc7_payload = Some(uri); + } + } + } }, // Set color index. diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index c90c50edf..9230b2459 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -1115,6 +1115,9 @@ impl Pane for TerminalPane { fn consume_bell(&mut self) { self.grid.ring_bell = false; } + fn osc7_payload(&self) -> Option<&str> { + self.grid.osc7_payload() + } fn set_bell_notification(&mut self, val: bool) { self.has_bell_notification = val; } diff --git a/zellij-server/src/panes/unit/terminal_pane_tests.rs b/zellij-server/src/panes/unit/terminal_pane_tests.rs index 41c3367ad..a4307d49a 100644 --- a/zellij-server/src/panes/unit/terminal_pane_tests.rs +++ b/zellij-server/src/panes/unit/terminal_pane_tests.rs @@ -790,6 +790,165 @@ pub fn has_bell_reflects_grid_ring_bell() { ); } +#[test] +pub fn osc7_payload_stored_from_pty_bytes() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + assert!( + terminal_pane.osc7_payload().is_none(), + "Initially osc7_payload should be None" + ); + + // Send OSC 7 sequence: \x1b]7;file://localhost/tmp\x1b\\ + let osc7_bytes = b"\x1b]7;file://localhost/tmp\x1b\\"; + terminal_pane.handle_pty_bytes(osc7_bytes.to_vec()); + + assert_eq!( + terminal_pane.drain_osc7_cwd(), + Some(std::path::PathBuf::from("/tmp")), + "OSC 7 should still update Zellij's internal CWD tracking" + ); + assert!(terminal_pane.drain_osc7_cwd().is_none()); + assert_eq!( + terminal_pane.osc7_payload(), + Some("file://localhost/tmp"), + "The forwarding URI should persist after the internal CWD update is drained" + ); +} + +#[test] +pub fn osc7_payload_updated_on_subsequent_osc7() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + let osc7_first = b"\x1b]7;file://localhost/first\x1b\\"; + terminal_pane.handle_pty_bytes(osc7_first.to_vec()); + assert_eq!(terminal_pane.osc7_payload(), Some("file://localhost/first")); + + let osc7_second = b"\x1b]7;file://localhost/second\x1b\\"; + terminal_pane.handle_pty_bytes(osc7_second.to_vec()); + assert_eq!( + terminal_pane.osc7_payload(), + Some("file://localhost/second") + ); +} + +#[test] +pub fn osc7_payload_preserves_percent_encoding() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + let osc7_bytes = b"\x1b]7;file://localhost/path%20with%20spaces\x1b\\"; + terminal_pane.handle_pty_bytes(osc7_bytes.to_vec()); + assert_eq!( + terminal_pane.drain_osc7_cwd(), + Some(std::path::PathBuf::from("/path with spaces")), + ); + assert_eq!( + terminal_pane.osc7_payload(), + Some("file://localhost/path%20with%20spaces"), + ); +} + +#[test] +pub fn osc7_payload_works_with_bel_terminator() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + // BEL (\x07) is an alternative terminator for OSC sequences + let osc7_bytes = b"\x1b]7;file://localhost/tmp\x07"; + terminal_pane.handle_pty_bytes(osc7_bytes.to_vec()); + assert_eq!(terminal_pane.osc7_payload(), Some("file://localhost/tmp"),); +} + +#[test] +pub fn osc7_payload_rejects_c0_control_characters() { + // Test the Grid's osc_dispatch directly, since the VTE parser strips + // control characters before they reach osc_dispatch. This validates + // the defense-in-depth guard in the b"7" handler. + use vte::Perform; + + let mut terminal_pane = make_terminal_pane_for_bell(); + let params: &[&[u8]] = &[b"7", b"file://host/path\x01bad"]; + terminal_pane.grid.osc_dispatch(params, false); + assert!( + terminal_pane.osc7_payload().is_none(), + "URIs with C0 control characters should be rejected" + ); +} + +#[test] +pub fn osc7_payload_rejects_c1_control_characters() { + // C1 control characters (U+0080..U+009F) include ST (U+009C) which + // could terminate the OSC sequence in the parent terminal. + use vte::Perform; + + let mut terminal_pane = make_terminal_pane_for_bell(); + // U+009C (ST) in UTF-8 is 0xC2 0x9C + let params: &[&[u8]] = &[b"7", "file://host/path\u{9c}bad".as_bytes()]; + terminal_pane.grid.osc_dispatch(params, false); + assert!( + terminal_pane.osc7_payload().is_none(), + "URIs with C1 control characters should be rejected" + ); +} + +#[test] +pub fn osc7_payload_rejects_an_empty_payload() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + terminal_pane.handle_pty_bytes(b"\x1b]7;file://localhost/tmp\x1b\\".to_vec()); + terminal_pane.handle_pty_bytes(b"\x1b]7;\x1b\\".to_vec()); + + assert_eq!( + terminal_pane.osc7_payload(), + Some("file://localhost/tmp"), + "An empty OSC 7 is rejected and the previous URI is retained" + ); +} + +#[test] +pub fn osc7_payload_rejects_invalid_utf8() { + use vte::Perform; + + let mut terminal_pane = make_terminal_pane_for_bell(); + terminal_pane.handle_pty_bytes(b"\x1b]7;file://localhost/tmp\x1b\\".to_vec()); + + let params: &[&[u8]] = &[b"7", b"file://localhost/\xff\xfe"]; + terminal_pane.grid.osc_dispatch(params, false); + + assert_eq!( + terminal_pane.osc7_payload(), + Some("file://localhost/tmp"), + "A non-UTF-8 OSC 7 is rejected and the previous URI is retained" + ); +} + +#[test] +pub fn osc7_payload_joins_semicolon_separated_segments() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + terminal_pane.handle_pty_bytes(b"\x1b]7;file://localhost/a;b;c\x1b\\".to_vec()); + + assert_eq!( + terminal_pane.osc7_payload(), + Some("file://localhost/a;b;c"), + "A URI containing semicolons is reassembled from the split params" + ); +} + +#[test] +pub fn osc7_payload_is_cleared_by_a_terminal_reset() { + let mut terminal_pane = make_terminal_pane_for_bell(); + + terminal_pane.handle_pty_bytes(b"\x1b]7;file://localhost/tmp\x1b\\".to_vec()); + assert_eq!(terminal_pane.osc7_payload(), Some("file://localhost/tmp")); + + terminal_pane.handle_pty_bytes(b"\x1bc".to_vec()); + + assert!( + terminal_pane.osc7_payload().is_none(), + "RIS clears the reported working directory" + ); +} + #[test] pub fn frameless_pane_position_is_on_frame() { let mut fake_win_size = PaneGeom { diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 0f6c5c982..5f8f428de 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -1631,6 +1631,7 @@ pub(crate) struct Screen { client_notification_protocols: HashMap, host_notification_protocol: HostNotificationProtocol, client_host_terminal_env: HashMap>, + last_forwarded_osc7: HashMap>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1838,6 +1839,7 @@ impl Screen { client_notification_protocols: HashMap::new(), host_notification_protocol: HostNotificationProtocol::default(), client_host_terminal_env: HashMap::new(), + last_forwarded_osc7: HashMap::new(), } } @@ -4283,7 +4285,36 @@ impl Screen { } } - if non_watcher_output_was_dirty || has_bell { + let mut has_osc7_update = false; + + // Forward OSC 7 (working directory) for each client's focused pane + for (&client_id, &tab_index) in &self.active_tab_ids { + if self.watcher_clients.contains_key(&client_id) { + continue; + } + if let Some(tab) = self.tabs.get(&tab_index) { + let current_osc7 = tab + .get_active_pane(client_id) + .and_then(|pane| pane.osc7_payload()); + let last = self + .last_forwarded_osc7 + .get(&client_id) + .and_then(|s| s.as_deref()); + if current_osc7 != last { + if let Some(uri) = current_osc7 { + output.add_post_vte_instruction_to_client( + client_id, + &format!("\x1b]7;{}\x1b\\", uri), + ); + has_osc7_update = true; + } + self.last_forwarded_osc7 + .insert(client_id, current_osc7.map(|s| s.to_owned())); + } + } + } + + if non_watcher_output_was_dirty || has_bell || has_osc7_update { let serialized_output = output.serialize().context(err_context)?; if !serialized_output.is_empty() { let _ = self @@ -5188,6 +5219,7 @@ impl Screen { } self.client_sizes.remove(&client_id); self.pane_render_subscribers.remove(&client_id); + self.last_forwarded_osc7.remove(&client_id); self.client_host_focused.remove(&client_id); self.client_notification_protocols.remove(&client_id); self.client_host_terminal_env.remove(&client_id); diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index b9202b9cb..5dc2decae 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -700,6 +700,9 @@ pub trait Pane { false } fn consume_bell(&mut self) {} + fn osc7_payload(&self) -> Option<&str> { + None + } fn set_bell_notification(&mut self, _val: bool) {} fn get_bell_notification(&self) -> bool { false diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index ddc7a6732..88586bfde 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -13695,6 +13695,315 @@ fn a_client_whose_host_focus_was_never_reported_counts_as_focused() { ); } +fn collect_rendered_output_per_client( + server_receiver: &ServerReceiver, +) -> HashMap { + let mut rendered: HashMap = HashMap::new(); + while let Ok((instruction, _)) = server_receiver.try_recv() { + if let ServerInstruction::Render(Some(client_map)) = instruction { + for (client_id, content) in client_map { + rendered.entry(client_id).or_default().push_str(&content); + } + } + } + rendered +} + +fn osc7_sequences_in(rendered: &str) -> Vec { + let opener = "\u{1b}]7;"; + let terminator = "\u{1b}\\"; + let mut sequences = vec![]; + let mut rest = rendered; + while let Some(start) = rest.find(opener) { + let payload_onwards = &rest[start + opener.len()..]; + match payload_onwards.find(terminator) { + Some(end) => { + sequences.push(payload_onwards[..end].to_owned()); + rest = &payload_onwards[end + terminator.len()..]; + }, + None => { + sequences.push(payload_onwards.to_owned()); + break; + }, + } + } + sequences +} + +fn render_and_collect_osc7( + screen: &mut Screen, + server_receiver: &ServerReceiver, +) -> HashMap> { + screen.render_to_clients().unwrap(); + collect_rendered_output_per_client(server_receiver) + .into_iter() + .map(|(client_id, rendered)| (client_id, osc7_sequences_in(&rendered))) + .collect() +} + +fn forwarded_osc7_for( + osc7_per_client: &HashMap>, + client_id: ClientId, +) -> Vec { + osc7_per_client.get(&client_id).cloned().unwrap_or_default() +} + +fn emit_osc7_from_pane(screen: &mut Screen, client_id: ClientId, terminal_id: u32, uri: &str) { + emit_bytes_from_pane( + screen, + client_id, + terminal_id, + format!("\u{1b}]7;{}\u{1b}\\", uri).into_bytes(), + ); +} + +fn emit_bytes_from_pane( + screen: &mut Screen, + client_id: ClientId, + terminal_id: u32, + bytes: Vec, +) { + screen + .get_active_tab_mut(client_id) + .unwrap() + .handle_pty_bytes(terminal_id, bytes) + .unwrap(); +} + +fn settle_renders(screen: &mut Screen, server_receiver: &ServerReceiver) { + for _ in 0..3 { + screen.render_to_clients().unwrap(); + } + while server_receiver.try_recv().is_ok() {} +} + +fn screen_with_one_pane_for_osc7() -> (Screen, ServerReceiver) { + let size = Size { + cols: 121, + rows: 20, + }; + let (mut screen, _tty_stdin_bytes, server_receiver) = + create_new_screen_with_capture(size, true, true, true, true); + new_tab(&mut screen, 1, 0); + settle_renders(&mut screen, &server_receiver); + (screen, server_receiver) +} + +fn screen_with_two_panes_for_osc7(session_is_mirrored: bool) -> (Screen, ServerReceiver) { + let size = Size { + cols: 121, + rows: 20, + }; + let (mut screen, _tty_stdin_bytes, server_receiver) = + create_new_screen_with_capture(size, true, true, true, session_is_mirrored); + new_tab(&mut screen, 1, 0); + { + let active_tab = screen.get_active_tab_mut(1).unwrap(); + active_tab + .horizontal_split(PaneId::Terminal(2), None, 1, None, None) + .unwrap(); + active_tab.move_focus_up(1).unwrap(); + } + settle_renders(&mut screen, &server_receiver); + (screen, server_receiver) +} + +#[test] +fn an_osc_7_from_the_focused_pane_is_forwarded_to_the_host_terminal() { + let (mut screen, server_receiver) = screen_with_one_pane_for_osc7(); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/tmp"); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/tmp".to_owned()], + "the working directory of the focused pane reaches the host terminal" + ); +} + +#[test] +fn an_unchanged_osc_7_is_not_forwarded_again() { + let (mut screen, server_receiver) = screen_with_one_pane_for_osc7(); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/tmp"); + render_and_collect_osc7(&mut screen, &server_receiver); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/tmp"); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + + assert!( + forwarded_osc7_for(&forwarded, 1).is_empty(), + "a pane repeating its working directory does not produce a second report" + ); +} + +#[test] +fn a_changed_osc_7_from_the_focused_pane_is_forwarded_again() { + let (mut screen, server_receiver) = screen_with_one_pane_for_osc7(); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/first"); + render_and_collect_osc7(&mut screen, &server_receiver); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/second"); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/second".to_owned()], + "a pane changing directory produces a new report" + ); +} + +#[test] +fn a_bel_terminated_osc_7_is_forwarded_with_a_string_terminator() { + let (mut screen, server_receiver) = screen_with_one_pane_for_osc7(); + + emit_bytes_from_pane(&mut screen, 1, 1, b"\x1b]7;file://host/tmp\x07".to_vec()); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/tmp".to_owned()], + "the report is re-emitted in its string-terminated form regardless of how it arrived" + ); +} + +#[test] +fn an_osc_7_from_an_unfocused_pane_is_not_forwarded() { + let (mut screen, server_receiver) = screen_with_two_panes_for_osc7(true); + + emit_osc7_from_pane(&mut screen, 1, 2, "file://host/unfocused"); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + + assert!( + forwarded_osc7_for(&forwarded, 1).is_empty(), + "only the focused pane reports its working directory" + ); +} + +#[test] +fn changing_focus_forwards_the_osc_7_of_the_newly_focused_pane() { + let (mut screen, server_receiver) = screen_with_two_panes_for_osc7(true); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/first"); + emit_osc7_from_pane(&mut screen, 1, 2, "file://host/second"); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/first".to_owned()], + ); + + screen + .get_active_tab_mut(1) + .unwrap() + .move_focus_down(1) + .unwrap(); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/second".to_owned()], + "focusing the lower pane reports its working directory" + ); + + screen + .get_active_tab_mut(1) + .unwrap() + .move_focus_up(1) + .unwrap(); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/first".to_owned()], + "focusing back reports the first pane's working directory again" + ); +} + +#[test] +fn focusing_a_pane_that_never_reported_an_osc_7_forwards_nothing() { + let (mut screen, server_receiver) = screen_with_two_panes_for_osc7(true); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/first"); + render_and_collect_osc7(&mut screen, &server_receiver); + + screen + .get_active_tab_mut(1) + .unwrap() + .move_focus_down(1) + .unwrap(); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + assert!( + forwarded_osc7_for(&forwarded, 1).is_empty(), + "a pane with nothing to report leaves the host terminal as it was" + ); + + screen + .get_active_tab_mut(1) + .unwrap() + .move_focus_up(1) + .unwrap(); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/first".to_owned()], + "returning to the reporting pane restates its working directory" + ); +} + +#[test] +fn clients_focused_on_different_panes_are_forwarded_their_own_osc_7() { + let size = Size { + cols: 121, + rows: 20, + }; + let (mut screen, server_receiver) = screen_with_two_panes_for_osc7(false); + screen.set_client_size(2, size); + screen.add_client(2, false).unwrap(); + screen + .get_active_tab_mut(2) + .unwrap() + .move_focus_down(2) + .unwrap(); + settle_renders(&mut screen, &server_receiver); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/upper"); + emit_osc7_from_pane(&mut screen, 1, 2, "file://host/lower"); + let forwarded = render_and_collect_osc7(&mut screen, &server_receiver); + + assert_eq!( + forwarded_osc7_for(&forwarded, 1), + vec!["file://host/upper".to_owned()], + "the first client is told about the pane it is focused on" + ); + assert_eq!( + forwarded_osc7_for(&forwarded, 2), + vec!["file://host/lower".to_owned()], + "the second client is told about the pane it is focused on" + ); +} + +#[test] +fn a_departing_client_leaves_no_osc_7_state_behind() { + let size = Size { + cols: 121, + rows: 20, + }; + let (mut screen, server_receiver) = screen_with_one_pane_for_osc7(); + screen.set_client_size(2, size); + screen.add_client(2, false).unwrap(); + + emit_osc7_from_pane(&mut screen, 1, 1, "file://host/tmp"); + render_and_collect_osc7(&mut screen, &server_receiver); + assert!(screen.last_forwarded_osc7.contains_key(&2)); + + screen.remove_client(2).unwrap(); + + assert!( + !screen.last_forwarded_osc7.contains_key(&2), + "the forwarded working directory is forgotten" + ); +} + fn resize_pty_pixel_dimensions( instruction: &PtyWriteInstruction, ) -> Option<(Option, Option)> {