aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-10-27 12:31:02 +0100
committerGitHub <noreply@github.com>2025-10-27 12:31:02 +0100
commit8762f866380360457c84c1e7ce7973f104468f87 (patch)
treeb437ef473578e2120b67e6d96068e05b7cb88511 /src
parentMigrate from wasmtime to wasmi (#4449) (diff)
downloadsl0ppij-8762f866380360457c84c1e7ce7973f104468f87.tar.gz
sl0ppij-8762f866380360457c84c1e7ce7973f104468f87.tar.xz
sl0ppij-8762f866380360457c84c1e7ce7973f104468f87.zip
Feature: watcher clients (#4487)
* initial implementation * make Screen aware of watchers - slightly buggy * fix: only render focused tab to watcher * track watcher sizes on Screen * pad watcher output as needed * truncate watcher sizes as needed * render on watcher resize * fix watcher first resize * allow watchers to quit with esc, ctrl-c or ctrl-q * fix: send watcher size on initial connection message * refactor: move padding logic to output * fix watcher rendering after all clients disconnected * fix watcher rendering caching issues * crop watcher cursor when out of bounds * remove watchers cleanly on session close * standardize watcher messages ignoring * add e2e test for watcher functionality * update snapshots * add output tests * rustfmt * cleanups * docs(changelog): add PR
Diffstat (limited to 'src')
-rw-r--r--src/commands.rs73
-rw-r--r--src/main.rs2
-rw-r--r--src/tests/e2e/cases.rs208
-rw-r--r--src/tests/e2e/remote_runner.rs54
-rw-r--r--src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality-2.snap28
-rw-r--r--src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality.snap28
6 files changed, 391 insertions, 2 deletions
diff --git a/src/commands.rs b/src/commands.rs
index 0b1570ca..e0d1efc3 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -906,6 +906,79 @@ pub(crate) fn list_aliases(opts: CliArgs) {
process::exit(0);
}
+pub(crate) fn watch_session(session_name: Option<String>, opts: CliArgs) {
+ let (config, _, config_options, _, _) = match Setup::from_cli_args(&opts) {
+ Ok(results) => results,
+ Err(e) => {
+ if let ConfigError::KdlError(error) = e {
+ let report: Report = error.into();
+ eprintln!("{:?}", report);
+ } else {
+ eprintln!("{}", e);
+ }
+ process::exit(1);
+ },
+ };
+
+ // Resolve the session name to watch
+ let client_info = match &session_name {
+ Some(prefix) => match match_session_name(prefix).unwrap() {
+ SessionNameMatch::UniquePrefix(s) | SessionNameMatch::Exact(s) => {
+ ClientInfo::Watch(s, config_options.clone())
+ },
+ SessionNameMatch::AmbiguousPrefix(sessions) => {
+ eprintln!(
+ "Ambiguous selection: multiple sessions names start with '{}':",
+ prefix
+ );
+ print_sessions(
+ sessions
+ .iter()
+ .map(|s| (s.clone(), Duration::default(), false))
+ .collect(),
+ false,
+ false,
+ true,
+ );
+ process::exit(1);
+ },
+ SessionNameMatch::None => {
+ eprintln!("No session with the name '{}' found!", prefix);
+ process::exit(1);
+ },
+ },
+ None => match get_active_session() {
+ ActiveSession::None => {
+ eprintln!("No active zellij sessions found.");
+ process::exit(1);
+ },
+ ActiveSession::One(name) => ClientInfo::Watch(name, config_options.clone()),
+ ActiveSession::Many => {
+ eprintln!("Please specify the session name to watch.");
+ process::exit(1);
+ },
+ },
+ };
+
+ let mut opts = opts.clone();
+ opts.session = Some(client_info.get_session_name().to_string());
+
+ let os_input = get_os_input(get_client_os_input);
+
+ // Start the watcher client
+ start_client_impl(
+ Box::new(os_input),
+ opts,
+ config,
+ config_options,
+ client_info,
+ None, // tab_position_to_focus
+ None, // pane_id_to_focus
+ false, // is_a_reconnect
+ false, // should_create_detached
+ );
+}
+
fn reload_config_from_disk(
config_without_layout: &mut Config,
config_options_without_layout: &mut Options,
diff --git a/src/main.rs b/src/main.rs
index a2f23162..2b2e213a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -187,6 +187,8 @@ fn main() {
commands::list_sessions(no_formatting, short, reverse);
} else if let Some(Command::Sessions(Sessions::ListAliases)) = opts.command {
commands::list_aliases(opts);
+ } else if let Some(Command::Sessions(Sessions::Watch { ref session_name })) = opts.command {
+ commands::watch_session(session_name.clone(), opts);
} else if let Some(Command::Sessions(Sessions::KillAllSessions { yes })) = opts.command {
commands::kill_all_sessions(yes);
} else if let Some(Command::Sessions(Sessions::KillSession { ref target_session })) =
diff --git a/src/tests/e2e/cases.rs b/src/tests/e2e/cases.rs
index bf5b6564..d2096cea 100644
--- a/src/tests/e2e/cases.rs
+++ b/src/tests/e2e/cases.rs
@@ -1,7 +1,10 @@
#![allow(unused)]
use ::insta::assert_snapshot;
-use zellij_utils::{pane_size::Size, position::Position};
+use zellij_utils::{
+ pane_size::Size,
+ position::{Column, Line, Position},
+};
use rand::Rng;
use regex::Regex;
@@ -2545,3 +2548,206 @@ pub fn pin_floating_panes() {
let last_snapshot = account_for_races_in_snapshot(last_snapshot);
assert_snapshot!(last_snapshot);
}
+#[test]
+#[ignore]
+pub fn watcher_client_functionality() {
+ let fake_win_size = Size {
+ cols: 120,
+ rows: 24,
+ };
+ let mut test_attempts = 10;
+ let session_name = "watcher_client_functionality";
+ let (main_client_snapshot, watcher_snapshot) = loop {
+ RemoteRunner::kill_running_sessions(fake_win_size);
+
+ // Step 1: Create main client and wait for it to load
+ let mut main_client =
+ RemoteRunner::new_with_session_name(fake_win_size, session_name, false)
+ .dont_panic()
+ .add_step(Step {
+ name: "Wait for app to load",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.status_bar_appears()
+ && remote_terminal.cursor_position_is(3, 2)
+ },
+ });
+ main_client.run_all_steps();
+
+ // Step 2: Start a background process that produces periodic output
+ main_client = main_client.add_step(Step {
+ name: "Start background output loop",
+ instruction: |mut remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.send_key(b"{ for i in 1 2 3; do sleep 1; echo \"WATCHER_OUTPUT_$i\"; done & } 2>/dev/null");
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ remote_terminal.send_key(&ENTER);
+ true
+ },
+ });
+ main_client.run_all_steps();
+
+ // Step 3: Wait for first output line to appear
+ main_client = main_client.add_step(Step {
+ name: "Wait for first background output",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ let found = remote_terminal.snapshot_contains("WATCHER_OUTPUT_1");
+ found
+ },
+ });
+ main_client.run_all_steps();
+
+ // Step 4: Attach watcher client
+ let mut watcher = RemoteRunner::new_watcher_session(fake_win_size, session_name)
+ .dont_panic()
+ .add_step(Step {
+ name: "Wait for watcher to connect",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.status_bar_appears()
+ // && remote_terminal.cursor_position_is(3, 2)
+ },
+ });
+ watcher.run_all_steps();
+
+ // Step 5: Main client splits pane right
+ main_client = main_client.add_step(Step {
+ name: "Split pane to the right",
+ instruction: |mut remote_terminal: RemoteTerminal| -> bool {
+ if remote_terminal.status_bar_appears() && remote_terminal.cursor_position_is(1, 7)
+ {
+ remote_terminal.send_key(&PANE_MODE);
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ remote_terminal.send_key(&SPLIT_RIGHT_IN_PANE_MODE);
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ true
+ } else {
+ false
+ }
+ },
+ });
+ main_client.run_all_steps();
+
+ // Step 6: Verify watcher sees the split pane
+ watcher = watcher.add_step(Step {
+ name: "Watcher sees split pane",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.cursor_position_is(63, 2) && remote_terminal.snapshot_contains("┐┌")
+ // vertical split indicator
+ },
+ });
+ watcher.run_all_steps();
+
+ // Step 7: Watcher tries to open a new tab (should be ignored)
+ watcher = watcher.add_step(Step {
+ name: "Watcher attempts to open new tab",
+ instruction: |mut remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.send_key(&TAB_MODE);
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ remote_terminal.send_key(&NEW_TAB_IN_TAB_MODE);
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ remote_terminal.send_key(b"some text that should not appear...");
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ true
+ },
+ });
+ watcher.run_all_steps();
+
+ // Step 8: Verify main client didn't receive the tab command
+ main_client = main_client.add_step(Step {
+ name: "Verify no new tab was created",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ // Should still be in Tab #1, not Tab #2
+ remote_terminal.snapshot_contains("Tab #1")
+ && !remote_terminal.snapshot_contains("Tab #2")
+ },
+ });
+ main_client.run_all_steps();
+
+ // Step 9: Watcher sends mouse click (should be ignored)
+ watcher = watcher.add_step(Step {
+ name: "Watcher attempts mouse click",
+ instruction: |mut remote_terminal: RemoteTerminal| -> bool {
+ let click_position = Position {
+ line: Line(10),
+ column: Column(10),
+ };
+ remote_terminal.send_key(&sgr_mouse_report(click_position, 0)); // left click
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ true
+ },
+ });
+ watcher.run_all_steps();
+
+ // Step 10: Main client detaches
+ main_client = main_client.add_step(Step {
+ name: "Main client detaches",
+ instruction: |mut remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.send_key(&SESSION_MODE);
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ remote_terminal.send_key(&DETACH_IN_SESSION_MODE);
+ true
+ },
+ });
+ main_client.run_all_steps();
+
+ // Step 11: Verify watcher receives background output even with no main client
+ watcher = watcher.add_step(Step {
+ name: "Watcher sees WATCHER_OUTPUT_2",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.snapshot_contains("WATCHER_OUTPUT_2")
+ },
+ });
+ watcher.run_all_steps();
+
+ watcher = watcher.add_step(Step {
+ name: "Watcher sees WATCHER_OUTPUT_3",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.snapshot_contains("WATCHER_OUTPUT_3")
+ },
+ });
+ watcher.run_all_steps();
+
+ // Step 12: Main client re-attaches
+ let mut main_client_reattached =
+ RemoteRunner::new_existing_session(fake_win_size, session_name)
+ .dont_panic()
+ .add_step(Step {
+ name: "Main client re-attaches",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ std::thread::sleep(std::time::Duration::from_millis(500)); // wait for watcher
+ // to fail running
+ // commands
+ remote_terminal.status_bar_appears()
+ && remote_terminal.snapshot_contains("┐┌")
+ },
+ });
+ main_client_reattached.run_all_steps();
+
+ watcher.run_all_steps();
+
+ // Step 15: Take final snapshots
+ let main_client_snapshot = main_client_reattached.take_snapshot_after(Step {
+ name: "Take main client final snapshot",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.status_bar_appears()
+ },
+ });
+
+ let watcher_snapshot = watcher.take_snapshot_after(Step {
+ name: "Take watcher final snapshot",
+ instruction: |remote_terminal: RemoteTerminal| -> bool {
+ remote_terminal.status_bar_appears()
+ },
+ });
+
+ if (main_client_reattached.test_timed_out || watcher.test_timed_out) && test_attempts > 0 {
+ test_attempts -= 1;
+ continue;
+ } else {
+ break (main_client_snapshot, watcher_snapshot);
+ }
+ };
+
+ let main_client_snapshot = account_for_races_in_snapshot(main_client_snapshot);
+ let watcher_snapshot = account_for_races_in_snapshot(watcher_snapshot);
+ assert_snapshot!(main_client_snapshot);
+ assert_snapshot!(watcher_snapshot);
+}
diff --git a/src/tests/e2e/remote_runner.rs b/src/tests/e2e/remote_runner.rs
index 54b6c468..a9b345b7 100644
--- a/src/tests/e2e/remote_runner.rs
+++ b/src/tests/e2e/remote_runner.rs
@@ -180,6 +180,20 @@ fn attach_to_existing_session(channel: &mut ssh2::Channel, session_name: &str) {
std::thread::sleep(std::time::Duration::from_secs(3)); // wait until Zellij stops parsing startup ANSI codes from the terminal STDIN
}
+fn watch_existing_session(channel: &mut ssh2::Channel, session_name: &str) {
+ channel
+ .write_all(
+ format!(
+ "{} {} watch {}\n",
+ SET_ENV_VARIABLES, ZELLIJ_EXECUTABLE_LOCATION, session_name
+ )
+ .as_bytes(),
+ )
+ .unwrap();
+ channel.flush().unwrap();
+ std::thread::sleep(std::time::Duration::from_secs(3)); // wait until Zellij stops parsing startup ANSI codes from the terminal STDIN
+}
+
fn start_zellij_without_frames(channel: &mut ssh2::Channel) {
stop_zellij(channel);
channel
@@ -688,6 +702,44 @@ impl RemoteRunner {
reader_thread,
}
}
+ pub fn new_watcher_session(win_size: Size, session_name: &str) -> Self {
+ let sess = ssh_connect_without_timeout();
+ let mut channel = sess.channel_session().unwrap();
+ let mut rows = Dimension::fixed(win_size.rows);
+ let mut cols = Dimension::fixed(win_size.cols);
+ rows.set_inner(win_size.rows);
+ cols.set_inner(win_size.cols);
+ let pane_geom = PaneGeom {
+ x: 0,
+ y: 0,
+ rows,
+ cols,
+ stacked: None,
+ is_pinned: false,
+ logical_position: None,
+ };
+ setup_remote_environment(&mut channel, win_size);
+ watch_existing_session(&mut channel, session_name);
+ let channel = Arc::new(Mutex::new(channel));
+ let last_snapshot = Arc::new(Mutex::new(String::new()));
+ let cursor_coordinates = Arc::new(Mutex::new((0, 0)));
+ sess.set_blocking(false);
+ let reader_thread =
+ read_from_channel(&channel, &last_snapshot, &cursor_coordinates, &pane_geom);
+ RemoteRunner {
+ steps: vec![],
+ channel,
+ currently_running_step: None,
+ current_step_index: 0,
+ retries_left: RETRIES,
+ retry_pause_ms: 100,
+ test_timed_out: false,
+ panic_on_no_retries_left: true,
+ last_snapshot,
+ cursor_coordinates,
+ reader_thread,
+ }
+ }
pub fn new_without_frames(win_size: Size) -> Self {
let sess = ssh_connect();
let mut channel = sess.channel_session().unwrap();
@@ -797,7 +849,7 @@ impl RemoteRunner {
self.retries_left = RETRIES;
self.current_step_index += 1;
} else {
- self.retries_left -= 1;
+ self.retries_left = self.retries_left.saturating_sub(1);
std::thread::sleep(std::time::Duration::from_millis(self.retry_pause_ms as u64));
}
}
diff --git a/src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality-2.snap b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality-2.snap
new file mode 100644
index 00000000..cc4bec39
--- /dev/null
+++ b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality-2.snap
@@ -0,0 +1,28 @@
+---
+source: src/tests/e2e/cases.rs
+expression: watcher_snapshot
+---
+ Zellij (watcher_client_functionality)  Tab #1 
+┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
+│$ { for i in 1 2 3; do sleep 1; echo "WATCHER_OUTPUT_$i"; ││$ █ │
+│done & } 2>/dev/null ││ │
+│$ ││ │
+│$ WATCHER_OUTPUT_1 ││ │
+│WATCHER_OUTPUT_2 ││ │
+│WATCHER_OUTPUT_3 ││ │
+│$ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...
diff --git a/src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality.snap b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality.snap
new file mode 100644
index 00000000..cc6509e0
--- /dev/null
+++ b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__watcher_client_functionality.snap
@@ -0,0 +1,28 @@
+---
+source: src/tests/e2e/cases.rs
+expression: main_client_snapshot
+---
+ Zellij (watcher_client_functionality)  Tab #1 
+┌ Pane #1 ─────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
+│$ { for i in 1 2 3; do sleep 1; echo "WATCHER_OUTPUT_$i"; ││$ █ │
+│done & } 2>/dev/null ││ │
+│$ ││ │
+│$ WATCHER_OUTPUT_1 ││ │
+│WATCHER_OUTPUT_2 ││ │
+│WATCHER_OUTPUT_3 ││ │
+│$ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  ...