fix: retain workspace navigation during machine switches (#5061)

refs #5019
This commit is contained in:
JJ Liebig
2026-10-07 23:06:20 +02:00
committed by GitHub
parent 376bafc8ab
commit 848075ff8c
3 changed files with 137 additions and 6 deletions
+21
View File
@@ -1164,6 +1164,19 @@ fn local_selection_abandons_every_unfinished_remote_handoff_phase() {
let mut state = ClientState::test_new();
state.shell = Some(shell);
let mut commands = EndpointCommands::default();
commands.enqueue(
ClientEndpointId::Local,
1,
"local-boot".into(),
Box::new(crate::api::schema::Request {
id: "abandoned-workspace-focus".into(),
method: crate::api::schema::Method::WorkspaceFocus(
crate::api::schema::WorkspaceTarget {
workspace_id: "stale-workspace".into(),
},
),
}),
);
let mut pending = Some(abandoned);
let mut serial = 31;
let mut scheduled = None;
@@ -1187,6 +1200,14 @@ fn local_selection_abandons_every_unfinished_remote_handoff_phase() {
assert_eq!(local.target(), &ClientEndpointId::Local);
assert!(!endpoints.active_surface_available());
assert!(state.presentation_frozen);
assert!(commands
.send_next(&ClientEndpointId::Local, &mut endpoints)
.is_empty());
assert!(!local_sent.lock().unwrap().iter().any(|message| {
matches!(message, crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. }
if serde_json::from_str::<crate::api::schema::Request>(request)
.is_ok_and(|request| request.id == "abandoned-workspace-focus"))
}), "a fresh activation must discard focus retained by an older target epoch");
let activations = local_sent
.lock()
.unwrap()
+101
View File
@@ -73,6 +73,107 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) {
(state, endpoint_id)
}
#[test]
fn workspace_navigation_waits_for_the_target_presentation_fence() {
use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
use crate::client::endpoint_commands::EndpointCommands;
struct Capture(std::sync::Arc<std::sync::Mutex<Vec<ClientMessage>>>);
impl crate::client::endpoint::EndpointTransport for Capture {
fn send(&mut self, message: &ClientMessage) -> std::io::Result<()> {
self.0.lock().unwrap().push(message.clone());
Ok(())
}
}
for (key, focused, expected, surface_active) in [
(KeyCode::Down, "ws_1", "ws_2", true),
(KeyCode::Up, "ws_2", "ws_1", true),
(KeyCode::Down, "ws_1", "ws_2", false),
] {
let (mut state, remote) = state_with_remote();
let config: Config =
toml::from_str("[keys]\nnext_workspace = 'ctrl+down'\nprevious_workspace = 'ctrl+up'")
.unwrap();
state.config = ClientShellConfig::from_config(&config);
let mut projection = snapshot();
projection.boot_id = "remote-boot".into();
let mut second = projection.workspaces[0].clone();
second.workspace_id = "ws_2".into();
second.number = 2;
projection.workspaces.push(second);
projection.focused_workspace_id = Some(focused.into());
for workspace in &mut projection.workspaces {
workspace.focused = workspace.workspace_id == focused;
}
state.set_endpoint_snapshot(&remote, Box::new(projection));
assert!(state.activate_endpoint_projection(&remote));
let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut endpoints = EndpointRegistry::empty();
endpoints.insert(
remote.clone(),
Capture(sent.clone()),
7,
EndpointNegotiation::default(),
true,
);
assert!(endpoints.set_active(&remote));
endpoints.set_surface_active(&remote, surface_active);
// Activation publishes this coherent target before releasing its effects fence.
endpoints.freeze_input();
let outcome = state.handle_raw_events(vec![RawInputEvent::Key(
crate::input::TerminalKey::new(key, KeyModifiers::CONTROL),
)]);
assert!(matches!(outcome.actions.as_slice(),
[ClientShellAction::Endpoint { request, .. }]
if matches!(&request.method, crate::api::schema::Method::WorkspaceFocus(target)
if target.workspace_id == expected)));
let mut commands = EndpointCommands::default();
let mut scheduled = None;
crate::client::shell_runtime::dispatch_client_shell_actions(
outcome.actions,
&mut commands,
&mut endpoints,
Some(&mut state),
&mut Vec::new(),
&mut scheduled,
)
.unwrap();
if !surface_active {
assert!(state
.visible_endpoint_notice
.as_ref()
.is_some_and(|notice| notice.title == "Action interrupted"));
assert!(commands.disconnect(&remote).is_empty());
assert!(sent.lock().unwrap().is_empty());
continue;
}
assert!(
state.visible_endpoint_notice.is_none(),
"navigation was cancelled"
);
assert!(
sent.lock().unwrap().is_empty(),
"the fence must still block sends"
);
assert!(!endpoints.active_surface_available());
endpoints.unfreeze_input();
assert!(commands.send_next(&remote, &mut endpoints).is_empty());
let messages = sent.lock().unwrap();
let [ClientMessage::ClientShellEndpointRequest { request, .. }] = messages.as_slice()
else {
panic!("queued navigation must reach the target after the fence");
};
let request: crate::api::schema::Request = serde_json::from_str(request).unwrap();
assert!(
matches!(request.method, crate::api::schema::Method::WorkspaceFocus(target)
if target.workspace_id == expected)
);
}
}
#[cfg(windows)]
#[test]
fn system_notification_clicks_keep_endpoint_and_boot_identity() {
+15 -6
View File
@@ -17,8 +17,16 @@ pub(super) fn dispatch_client_shell_actions(
boot_id,
request,
} => {
if let Some(connection) = endpoints.connection(&endpoint_id).filter(|_| {
endpoints.active_id() == &endpoint_id && endpoints.active_surface_available()
if let Some(connection) = endpoints.connection(&endpoint_id).filter(|connection| {
endpoints.active_id() == &endpoint_id
&& (endpoints.active_surface_available()
// The coherent target is visible before its input fence opens.
// Retain workspace navigation; sending remains gated below.
|| (connection.surface_active
&& matches!(
request.method,
crate::api::schema::Method::WorkspaceFocus(_)
)))
}) {
endpoint_commands.enqueue(endpoint_id, connection.generation, boot_id, request);
} else if let Some(shell) = shell.as_deref_mut() {
@@ -163,10 +171,11 @@ fn install_pending_activation(
next_surface_serial: &mut u64,
activation: endpoint::PendingEndpointActivation,
) {
let retired = activation
.source_command_lane()
.map(|source| endpoint_commands.retire_lane(source))
.unwrap_or_default();
// A fresh target epoch must not replay navigation retained by an abandoned handoff.
let mut retired = endpoint_commands.retire_lane(activation.target());
if let Some(source) = activation.source_command_lane() {
retired.extend(endpoint_commands.retire_lane(source));
}
if let Some(shell) = state.shell.as_mut() {
for request_id in retired {
shell.cancel_endpoint_request(&request_id);