Restore reconnect status from snapshots
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s
This commit is contained in:
+156
-66
@@ -6560,6 +6560,7 @@ async fn run_terminal(
|
||||
// Non-destructive disconnect status line. Off in forward-only mode (no TTY to
|
||||
// draw on) and respecting the client config (default on, env override).
|
||||
let mut disconnect_status = DisconnectStatus::new(resolve_disconnect_status() && !forward_only);
|
||||
let mut status_restore_pending = false;
|
||||
let (forward_tx, mut forward_rx) = mpsc::channel::<ForwardEvent>(1024);
|
||||
let _forward_keepalive = if local_forwards.is_empty() {
|
||||
Some(forward_tx.clone())
|
||||
@@ -6606,6 +6607,7 @@ async fn run_terminal(
|
||||
if let Some(frame) = first_frame {
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(&frame, &mut disconnect_status, &mut status_restore_pending);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
}
|
||||
@@ -6714,6 +6716,11 @@ async fn run_terminal(
|
||||
);
|
||||
refresh_live_addr(&mut addr, &cred)?;
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
last_packet_at = Instant::now();
|
||||
@@ -6768,6 +6775,11 @@ async fn run_terminal(
|
||||
&mut stale_terminal_input_suppress_until,
|
||||
);
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
last_packet_at = Instant::now();
|
||||
@@ -6986,6 +6998,11 @@ async fn run_terminal(
|
||||
);
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
}
|
||||
@@ -7053,6 +7070,11 @@ async fn run_terminal(
|
||||
);
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
wake_repaint_retry_until = None;
|
||||
@@ -7082,8 +7104,6 @@ async fn run_terminal(
|
||||
if packet.header.conn_id != [0u8; 16] && packet.header.conn_id != cred.client_id {
|
||||
continue;
|
||||
}
|
||||
// Contact resumed: clear any disconnect status line immediately.
|
||||
disconnect_status.apply(disconnect_status.on_contact())?;
|
||||
match packet.header.kind {
|
||||
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||
let decrypted = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)
|
||||
@@ -7112,6 +7132,11 @@ async fn run_terminal(
|
||||
);
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
wake_repaint_retry_until = None;
|
||||
@@ -7130,6 +7155,10 @@ async fn run_terminal(
|
||||
}
|
||||
continue;
|
||||
};
|
||||
note_authenticated_contact(
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
)?;
|
||||
let Ok(frame) = protocol::from_body::<Frame>(&plain) else {
|
||||
continue;
|
||||
};
|
||||
@@ -7139,6 +7168,11 @@ async fn run_terminal(
|
||||
predictor.clear_pending()?;
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
wake_repaint_retry_until = None;
|
||||
@@ -7161,6 +7195,25 @@ async fn run_terminal(
|
||||
send_ack(&socket, addr, &cred, &mut send_seq).await?;
|
||||
}
|
||||
PacketKind::Pong => {
|
||||
if protocol::decrypt_body(
|
||||
&packet,
|
||||
&cred.session_key,
|
||||
SERVER_TO_CLIENT,
|
||||
)
|
||||
.or_else(|err| match previous_session_key {
|
||||
Some(prev) => {
|
||||
protocol::decrypt_body(&packet, &prev, SERVER_TO_CLIENT)
|
||||
}
|
||||
None => Err(err),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
note_authenticated_contact(
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
)?;
|
||||
last_packet_at = Instant::now();
|
||||
if should_flush_terminal_input_after_contact(
|
||||
forward_only,
|
||||
@@ -7188,6 +7241,10 @@ async fn run_terminal(
|
||||
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||
continue;
|
||||
};
|
||||
note_authenticated_contact(
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
)?;
|
||||
let Ok(rekey) = protocol::from_body::<Rekey>(&plain) else {
|
||||
continue;
|
||||
};
|
||||
@@ -7240,6 +7297,11 @@ async fn run_terminal(
|
||||
);
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
wake_repaint_retry_until = None;
|
||||
@@ -7800,7 +7862,9 @@ async fn run_terminal(
|
||||
}
|
||||
let mut repainted_this_tick = false;
|
||||
let stale = last_packet_at.elapsed();
|
||||
if stale >= Duration::from_secs(reconnect_timeout_secs.max(1)) {
|
||||
if status_restore_pending
|
||||
|| stale >= Duration::from_secs(reconnect_timeout_secs.max(1))
|
||||
{
|
||||
if let Some(frame) = reconnect(
|
||||
&socket,
|
||||
&mut cred,
|
||||
@@ -7817,6 +7881,11 @@ async fn run_terminal(
|
||||
);
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
last_idle_repaint_attempt_at = Instant::now();
|
||||
@@ -7896,6 +7965,11 @@ async fn run_terminal(
|
||||
&mut stale_terminal_input_suppress_until,
|
||||
);
|
||||
render_frame(&frame)?;
|
||||
note_snapshot_rendered(
|
||||
&frame,
|
||||
&mut disconnect_status,
|
||||
&mut status_restore_pending,
|
||||
);
|
||||
predictor.observe_output(&frame.bytes);
|
||||
last_terminal_frame_at = Instant::now();
|
||||
last_packet_at = Instant::now();
|
||||
@@ -7913,7 +7987,7 @@ async fn run_terminal(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh / clear the non-destructive disconnect status line based
|
||||
// Refresh or retire the reconnect status overlay based
|
||||
// on how long the link has been silent (recomputed after any
|
||||
// reconnect attempt above may have reset `last_packet_at`).
|
||||
if !forward_only {
|
||||
@@ -7922,6 +7996,9 @@ async fn run_terminal(
|
||||
} else {
|
||||
disconnect_status.on_tick(last_packet_at.elapsed())
|
||||
};
|
||||
if action == StatusAction::Forget {
|
||||
status_restore_pending = true;
|
||||
}
|
||||
disconnect_status.apply(action)?;
|
||||
}
|
||||
}
|
||||
@@ -7972,7 +8049,7 @@ async fn run_terminal(
|
||||
}
|
||||
}
|
||||
// Leave the terminal clean on exit: erase any lingering status line.
|
||||
let _ = disconnect_status.apply(StatusAction::Clear);
|
||||
let _ = disconnect_status.apply(StatusAction::Forget);
|
||||
let _ = detach_once(&socket, &cred, send_seq).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -10175,7 +10252,7 @@ fn input_matches_escape(bytes: &[u8], escape_key: Option<&[u8]>) -> bool {
|
||||
!escape_key.is_empty() && bytes.windows(escape_key.len()).any(|w| w == escape_key)
|
||||
}
|
||||
|
||||
/// Resolve whether the non-destructive disconnect status line is enabled.
|
||||
/// Resolve whether the snapshot-restored disconnect status line is enabled.
|
||||
/// `DOSH_DISCONNECT_STATUS` (0/false/off to disable) overrides for quick
|
||||
/// experiments; otherwise the client config's `disconnect_status` applies,
|
||||
/// defaulting to on.
|
||||
@@ -10400,18 +10477,18 @@ const TERMINAL_SNAPSHOT_RESET: &[u8] = concat!(
|
||||
/// idle period (the run loop only pings after 2s of quiet) never flashes it.
|
||||
const DISCONNECT_STATUS_THRESHOLD_SECS: u64 = 2;
|
||||
|
||||
/// Non-destructive, mosh-style disconnect status bar.
|
||||
/// Mosh-style disconnect status bar with snapshot-backed restoration.
|
||||
///
|
||||
/// When server packets stop arriving we paint one blue line on the terminal's
|
||||
/// top row — e.g. `[dosh] reconnecting — last contact 3s ago` — using
|
||||
/// save/restore cursor (ESC 7 / ESC 8) so the real application cursor never
|
||||
/// moves. The run loop suppresses the bar while the server is in the alternate
|
||||
/// screen because a saved cursor does not make writing row 1 non-destructive for
|
||||
/// full-screen TUIs. The bar is refreshed on the status timer tick and cleared
|
||||
/// the instant a packet resumes.
|
||||
/// moves. Painting row 1 necessarily replaces its cells, so authenticated
|
||||
/// recovery retires the overlay without clearing the row and requests an
|
||||
/// authoritative server snapshot to restore it. The run loop suppresses the bar
|
||||
/// while the server is in the alternate screen.
|
||||
///
|
||||
/// The decision logic and the rendered text are pure and unit-tested; only
|
||||
/// [`DisconnectStatus::emit`] / [`DisconnectStatus::emit_clear`] touch stdout.
|
||||
/// [`DisconnectStatus::emit`] touches stdout.
|
||||
struct DisconnectStatus {
|
||||
enabled: bool,
|
||||
/// Whether a status line is currently painted on screen (so we know to clear
|
||||
@@ -10429,12 +10506,32 @@ enum StatusAction {
|
||||
None,
|
||||
/// Paint/repaint the status bar with this exact text on the top row.
|
||||
Show(String),
|
||||
/// Erase the status line (link recovered or feature disabled).
|
||||
Clear,
|
||||
/// Drop tracked status state without touching the terminal.
|
||||
Forget,
|
||||
}
|
||||
|
||||
fn note_authenticated_contact(
|
||||
status: &mut DisconnectStatus,
|
||||
restore_pending: &mut bool,
|
||||
) -> Result<()> {
|
||||
let action = status.on_contact();
|
||||
if action == StatusAction::Forget {
|
||||
*restore_pending = true;
|
||||
}
|
||||
status.apply(action)
|
||||
}
|
||||
|
||||
fn note_snapshot_rendered(
|
||||
frame: &Frame,
|
||||
status: &mut DisconnectStatus,
|
||||
restore_pending: &mut bool,
|
||||
) {
|
||||
if frame.snapshot {
|
||||
status.applied(&StatusAction::Forget);
|
||||
*restore_pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl DisconnectStatus {
|
||||
fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
@@ -10462,7 +10559,7 @@ impl DisconnectStatus {
|
||||
fn on_tick(&self, stale: Duration) -> StatusAction {
|
||||
if !self.enabled {
|
||||
return if self.shown {
|
||||
StatusAction::Clear
|
||||
StatusAction::Forget
|
||||
} else {
|
||||
StatusAction::None
|
||||
};
|
||||
@@ -10475,17 +10572,17 @@ impl DisconnectStatus {
|
||||
StatusAction::Show(text)
|
||||
}
|
||||
} else if self.shown {
|
||||
StatusAction::Clear
|
||||
StatusAction::Forget
|
||||
} else {
|
||||
StatusAction::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure decision for "a packet just arrived": clear any visible status line
|
||||
/// the instant contact resumes, otherwise do nothing.
|
||||
/// Pure decision for authenticated contact. The caller schedules a snapshot
|
||||
/// repaint before forgetting a visible overlay; it never clears row 1.
|
||||
fn on_contact(&self) -> StatusAction {
|
||||
if self.shown {
|
||||
StatusAction::Clear
|
||||
StatusAction::Forget
|
||||
} else {
|
||||
StatusAction::None
|
||||
}
|
||||
@@ -10510,10 +10607,6 @@ impl DisconnectStatus {
|
||||
self.shown = true;
|
||||
self.drawn_text = text.clone();
|
||||
}
|
||||
StatusAction::Clear => {
|
||||
self.shown = false;
|
||||
self.drawn_text.clear();
|
||||
}
|
||||
StatusAction::Forget => {
|
||||
self.shown = false;
|
||||
self.drawn_text.clear();
|
||||
@@ -10526,7 +10619,6 @@ impl DisconnectStatus {
|
||||
fn apply(&mut self, action: StatusAction) -> Result<()> {
|
||||
match &action {
|
||||
StatusAction::Show(text) => self.emit(text)?,
|
||||
StatusAction::Clear => self.emit_clear()?,
|
||||
StatusAction::Forget => {}
|
||||
StatusAction::None => {}
|
||||
}
|
||||
@@ -10541,12 +10633,6 @@ impl DisconnectStatus {
|
||||
let bytes = render_status_overlay(text, rows);
|
||||
emit_status(&bytes)
|
||||
}
|
||||
|
||||
/// Erase the top-row status bar, again with save/restore cursor.
|
||||
fn emit_clear(&self) -> Result<()> {
|
||||
let (_, rows) = terminal_size();
|
||||
emit_status(&render_status_clear(rows))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure: build the escape sequence that paints `text` on a top blue status bar,
|
||||
@@ -10563,16 +10649,6 @@ fn render_status_overlay(text: &str, _rows: u16) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Pure: build the escape sequence that clears the top status bar.
|
||||
fn render_status_clear(_rows: u16) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(12);
|
||||
out.extend_from_slice(b"\x1b7");
|
||||
out.extend_from_slice(b"\x1b[1;1H");
|
||||
out.extend_from_slice(b"\x1b[2K");
|
||||
out.extend_from_slice(b"\x1b8");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn emit_status(bytes: &[u8]) -> Result<()> {
|
||||
let mut stdout = std::io::stdout();
|
||||
@@ -10879,14 +10955,14 @@ mod tests {
|
||||
is_local_status_target, is_resume_response_for_client, latest_release_download_url,
|
||||
load_first_native_identity_with_prompt, local_symlink_target_is_dir,
|
||||
local_username_from_env, native_proxy_udp_warning, newest_client_trace_path_from,
|
||||
parse_dynamic_forward, parse_escape_key, parse_local_forward, parse_remote_forward,
|
||||
parse_single_remote_path, parse_ssh_config, parse_trace_line, parse_trace_options,
|
||||
parse_trace_report_options, parse_trace_summary, parse_update_options,
|
||||
post_submit_hold_duration, queue_or_send_stream_data, queue_pending_user_input,
|
||||
queue_stale_pending_user_input, raw_contains_host_table, recv_response_until,
|
||||
refresh_live_addr, release_artifact_name_for, release_tag_download_url,
|
||||
release_tag_from_effective_url, release_version_from_tag, remote_unix_server_update_script,
|
||||
render_frame_bytes, render_status_clear, render_status_overlay, requested_env,
|
||||
note_authenticated_contact, note_snapshot_rendered, parse_dynamic_forward,
|
||||
parse_escape_key, parse_local_forward, parse_remote_forward, parse_single_remote_path,
|
||||
parse_ssh_config, parse_trace_line, parse_trace_options, parse_trace_report_options,
|
||||
parse_trace_summary, parse_update_options, post_submit_hold_duration,
|
||||
queue_or_send_stream_data, queue_pending_user_input, queue_stale_pending_user_input,
|
||||
raw_contains_host_table, recv_response_until, refresh_live_addr, release_artifact_name_for,
|
||||
release_tag_download_url, release_tag_from_effective_url, release_version_from_tag,
|
||||
remote_unix_server_update_script, render_frame_bytes, render_status_overlay, requested_env,
|
||||
resolve_forward_agent_endpoint, resolved_startup_command, retire_stream_state,
|
||||
retransmit_stream_closes, retransmit_stream_eofs, retransmit_stream_opens,
|
||||
retransmit_stream_window_adjusts, rewrite_forward_command, sanitize_trace_name,
|
||||
@@ -14374,10 +14450,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Contact resuming clears a shown line immediately, and a sub-threshold tick
|
||||
/// also clears it; clearing when nothing is shown is a no-op.
|
||||
/// Contact retires a shown overlay without erasing row 1; the run loop then
|
||||
/// restores the covered cells from an authenticated snapshot.
|
||||
#[test]
|
||||
fn disconnect_status_clears_on_contact_and_recovery() {
|
||||
fn disconnect_status_forgets_on_contact_and_recovery() {
|
||||
let mut status = DisconnectStatus::new(true);
|
||||
|
||||
// Nothing shown yet: contact is a no-op.
|
||||
@@ -14387,27 +14463,29 @@ mod tests {
|
||||
status.applied(&show);
|
||||
assert!(status.shown);
|
||||
|
||||
// A packet arrives: clear immediately.
|
||||
let clear = status.on_contact();
|
||||
assert_eq!(clear, StatusAction::Clear);
|
||||
status.applied(&clear);
|
||||
let forget = status.on_contact();
|
||||
assert_eq!(forget, StatusAction::Forget);
|
||||
status.applied(&forget);
|
||||
assert!(!status.shown);
|
||||
|
||||
// Show again, then a sub-threshold tick (link recovered) clears it too.
|
||||
// A sub-threshold authenticated recovery also requests restoration.
|
||||
let show = status.on_tick(Duration::from_secs(5));
|
||||
status.applied(&show);
|
||||
assert_eq!(status.on_tick(Duration::from_secs(0)), StatusAction::Clear);
|
||||
assert_eq!(status.on_tick(Duration::from_secs(0)), StatusAction::Forget);
|
||||
}
|
||||
|
||||
/// When the feature is disabled the machine never shows, and clears anything
|
||||
/// already on screen (e.g. config toggled off at runtime).
|
||||
/// When disabled the machine never shows, and retires stale state without
|
||||
/// writing destructive row-clearing sequences.
|
||||
#[test]
|
||||
fn disconnect_status_disabled_never_shows() {
|
||||
let mut status = DisconnectStatus::new(false);
|
||||
assert_eq!(status.on_tick(Duration::from_secs(10)), StatusAction::None);
|
||||
// Simulate a stale "shown" flag and confirm it gets cleared.
|
||||
// Simulate a stale "shown" flag and confirm it requests restoration.
|
||||
status.shown = true;
|
||||
assert_eq!(status.on_tick(Duration::from_secs(10)), StatusAction::Clear);
|
||||
assert_eq!(
|
||||
status.on_tick(Duration::from_secs(10)),
|
||||
StatusAction::Forget
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -14424,6 +14502,23 @@ mod tests {
|
||||
assert_eq!(status.drawn_text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_contact_requires_snapshot_to_restore_covered_row() {
|
||||
let mut status = DisconnectStatus::new(true);
|
||||
let show = status.on_tick(Duration::from_secs(3));
|
||||
status.applied(&show);
|
||||
let mut restore_pending = false;
|
||||
|
||||
note_authenticated_contact(&mut status, &mut restore_pending).unwrap();
|
||||
assert!(!status.shown);
|
||||
assert!(restore_pending);
|
||||
|
||||
note_snapshot_rendered(&test_frame(1, false), &mut status, &mut restore_pending);
|
||||
assert!(restore_pending);
|
||||
note_snapshot_rendered(&test_frame(2, true), &mut status, &mut restore_pending);
|
||||
assert!(!restore_pending);
|
||||
}
|
||||
|
||||
/// The rendered overlay must preserve cursor position: save cursor (ESC 7),
|
||||
/// park on the top row, paint the blue reconnect bar, then restore cursor
|
||||
/// (ESC 8). The run loop suppresses this renderer entirely in full-screen
|
||||
@@ -14441,11 +14536,6 @@ mod tests {
|
||||
assert!(bytes.windows(8).any(|w| w == b"\x1b[44;37m"));
|
||||
// Resets attributes before restoring the cursor.
|
||||
assert!(bytes.windows(4).any(|w| w == b"\x1b[0m"));
|
||||
|
||||
let clear = render_status_clear(24);
|
||||
assert!(clear.starts_with(b"\x1b7"));
|
||||
assert!(clear.ends_with(b"\x1b8"));
|
||||
assert!(clear.windows(4).any(|w| w == b"\x1b[2K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user