mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-11 22:50:07 +00:00
feat: forward OSC 7 (working directory) to parent terminal (#4867)
* feat: capture OSC 7 working directory on Grid and expose via Pane trait Add osc7_payload field to Grid, handle the b"7" OSC sequence in osc_dispatch, expose it via a Grid accessor, add osc7_payload() to the Pane trait (default None), and implement it on TerminalPane by delegating to the Grid. Follows the has_bell/consume_bell pattern. * feat: forward OSC 7 from focused pane to parent terminal on render * fix: validate OSC 7 URI and clean up state on client disconnect Reject URIs containing control characters (< 0x20 or 0x7f) to prevent injection into the parent terminal when the URI is re-emitted via OSC 7 forwarding. Also remove per-client OSC 7 forwarding state in remove_client() to avoid stale entries accumulating after disconnect. * style: fix formatting in OSC 7 tests * fix: add control char test, clear osc7 on reset, doc comment - Add test for control character rejection in OSC 7 URIs - Clear osc7_payload in reset_terminal_state() (RIS/\ec) - Add doc comment on osc7_payload() getter * fix: address PR review feedback - Reject C1 control characters (U+0080..U+009F) in OSC 7 URIs using char::is_control() instead of byte-level check - Reject entire OSC 7 if any param segment has invalid UTF-8 instead of silently dropping bad segments - Avoid String allocation in render hot path by comparing borrowed &str, only allocating when the value actually changes - Add test for C1 control character rejection * fix: integrate OSC 7 forwarding with upstream CWD tracking * add some more tests * docs: update changelog --------- Co-authored-by: Aram Drevekenin <[email protected]>
This commit is contained in:
co-authored by
Aram Drevekenin
parent
232c93721d
commit
474ea0cef6
@@ -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)
|
||||
|
||||
@@ -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<u8> {
|
||||
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();
|
||||
}
|
||||
@@ -889,6 +889,7 @@ pub struct Grid {
|
||||
osc133_command_selection: bool,
|
||||
command_output_flash: Option<Selection>,
|
||||
word_separators: String,
|
||||
pub osc7_payload: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<&str>> =
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1631,6 +1631,7 @@ pub(crate) struct Screen {
|
||||
client_notification_protocols: HashMap<ClientId, NotificationProtocol>,
|
||||
host_notification_protocol: HostNotificationProtocol,
|
||||
client_host_terminal_env: HashMap<ClientId, BTreeMap<String, String>>,
|
||||
last_forwarded_osc7: HashMap<ClientId, Option<String>>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ClientId, String> {
|
||||
let mut rendered: HashMap<ClientId, String> = 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<String> {
|
||||
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<ClientId, Vec<String>> {
|
||||
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<ClientId, Vec<String>>,
|
||||
client_id: ClientId,
|
||||
) -> Vec<String> {
|
||||
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<u8>,
|
||||
) {
|
||||
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<u16>, Option<u16>)> {
|
||||
|
||||
Reference in New Issue
Block a user