diff options
| author | Eric Yue <jiacheng.yue@foxmail.com> | 2026-09-20 22:33:50 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-20 16:33:50 +0200 |
| commit | 74505861e40c48e070e711bd4de662a17b0939b3 (patch) | |
| tree | 736ad65c9dba956554529b4169bc54827dc342e3 /src | |
| parent | c00a62dda169beb472fc2f386d0f673ca100dfa4 (diff) | |
fix: keep workspace navigation visible in terminal theme (#4408)
* fix: keep workspace navigation visible in terminal theme
refs #4300
* fix: clear workspace highlight on directional pane focus
refs #4300
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/shell.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/actions.rs | 21 | ||||
| -rw-r--r-- | src/client/shell/composition.rs | 18 | ||||
| -rw-r--r-- | src/client/shell/endpoint_navigation.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/endpoint_sidebar.rs | 15 | ||||
| -rw-r--r-- | src/client/shell/endpoints.rs | 6 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/mobile.rs | 9 | ||||
| -rw-r--r-- | src/client/shell/sidebar.rs | 50 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 4 | ||||
| -rw-r--r-- | src/client/shell/tests/workspace_navigation.rs | 467 | ||||
| -rw-r--r-- | src/client/shell/workspace_navigation.rs | 54 |
13 files changed, 620 insertions, 30 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index a5094397..b9335e06 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -2124,6 +2124,7 @@ async fn run_client_loop( let (effects, notification_repaint) = shell.tick_notifications(now); outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now) + | shell.tick_workspace_highlight(now) | shell.tick_endpoint_error(now); let frame = outcome .repaint diff --git a/src/client/shell.rs b/src/client/shell.rs index 95ee5e78..27d30df9 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -4,7 +4,7 @@ mod actions; mod agent_sidebar; mod aggregate_navigation; mod workspace_navigation; -use workspace_navigation::WorkspaceNavigationTarget; +use workspace_navigation::{PendingWorkspaceHighlight, WorkspaceNavigationTarget}; mod composition; mod config; mod context_menu; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index e3885626..da4edd49 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -141,6 +141,7 @@ impl ClientShellState { return; } if action == crate::input::KeybindAction::WorkspacePicker { + self.pending_workspace_highlight = None; self.mobile_switcher_scroll = 0; self.reveal_mobile_workspace = false; self.mode = ClientShellMode::Navigate; @@ -349,6 +350,19 @@ impl ClientShellState { kind: PendingEndpointKind, outcome: &mut ClientShellInput, ) -> bool { + let changes_focus = match &method { + crate::api::schema::Method::WorkspaceFocus(_) + | crate::api::schema::Method::TabFocus(_) + | crate::api::schema::Method::PaneFocus(_) + | crate::api::schema::Method::PaneFocusDirection(_) => true, + crate::api::schema::Method::WorkspaceCreate(params) => params.focus, + crate::api::schema::Method::TabCreate(params) => params.focus, + crate::api::schema::Method::PaneSplit(params) => params.focus, + _ => false, + }; + if changes_focus { + outcome.repaint |= self.pending_workspace_highlight.take().is_some(); + } if !self.endpoint_is_online(&self.active_endpoint_id) { let label = self.active_endpoint_label().to_owned(); outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready")); @@ -495,6 +509,13 @@ impl ClientShellState { self.endpoint_notice_seen.remove(&timeout_key); } if let Err(error) = &result { + if self + .pending_workspace_highlight + .as_ref() + .is_some_and(|pending| pending.request_id == request_id) + { + self.pending_workspace_highlight = None; + } let code = error.code.as_deref().unwrap_or("invalid_response"); if !matches!( code, diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 10dc6633..4da21e77 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -40,6 +40,12 @@ impl ClientShellState { .navigate_workspace_id .as_ref() .is_some_and(|target| self.navigation_target_valid(target)); + let pending_workspace_highlight = + self.pending_workspace_highlight.as_ref().filter(|pending| { + self.mode != ClientShellMode::Navigate + && pending.target.endpoint_id == self.active_endpoint_id + && self.navigation_target_valid(&pending.target) + }); // A resize invalidates pane geometry, not the healthy Local workspace chrome. let local_snapshot = self.snapshot.as_deref().filter(|_| { self.endpoints.len() == 1 @@ -65,7 +71,8 @@ impl ClientShellState { selected_workspace_id: self .navigate_workspace_id .as_ref() - .filter(|_| valid_navigation_target), + .filter(|_| valid_navigation_target) + .or_else(|| pending_workspace_highlight.map(|pending| &pending.target)), reveal_navigation_workspace: &mut self.reveal_navigation_workspace, dragged_workspace_id: None, workspace_drop_indicator_row: None, @@ -143,6 +150,12 @@ impl ClientShellState { .navigate_workspace_id .as_ref() .is_some_and(|target| self.navigation_target_valid(target)); + let pending_workspace_highlight = + self.pending_workspace_highlight.as_ref().filter(|pending| { + self.mode != ClientShellMode::Navigate + && pending.target.endpoint_id == self.active_endpoint_id + && self.navigation_target_valid(&pending.target) + }); if self.snapshot.is_none() || self.pane_surface.is_none() { return Some(self.compose_unavailable(cols, rows)); } @@ -200,7 +213,8 @@ impl ClientShellState { selected_workspace_id: self .navigate_workspace_id .as_ref() - .filter(|_| valid_navigation_target), + .filter(|_| valid_navigation_target) + .or_else(|| pending_workspace_highlight.map(|pending| &pending.target)), reveal_navigation_workspace: &mut self.reveal_navigation_workspace, dragged_workspace_id, workspace_drop_indicator_row, diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index c15fd434..a1f7ac2c 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -230,6 +230,7 @@ impl ClientShellState { endpoint_id: ClientEndpointId, outcome: &mut ClientShellInput, ) -> bool { + self.pending_workspace_highlight = None; self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); if !online && !endpoint_id.is_local() { @@ -255,6 +256,7 @@ impl ClientShellState { target: ClientEndpointFocusTarget, outcome: &mut ClientShellInput, ) -> bool { + self.pending_workspace_highlight = None; self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); if !online && !endpoint_id.is_local() { diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs index 34d2ccdc..c4f701c3 100644 --- a/src/client/shell/endpoint_sidebar.rs +++ b/src/client/shell/endpoint_sidebar.rs @@ -128,7 +128,13 @@ pub(super) fn render_collapsed( if selected { buffer.set_style(rect, Style::default().bg(selection_background)); } else if focused { - buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + buffer.set_style( + rect, + Style::default().bg(super::sidebar::workspace_active_background( + palette, + state.selected_workspace_id.is_some(), + )), + ); } let stale = endpoint.status != ClientEndpointStatus::Online; let number = format!(" {}", workspace.number); @@ -449,19 +455,16 @@ pub(super) fn render_expanded( super::sidebar::render_workspace_rows( buffer, nested, - workspace, status, config.status_indicators, entry, tokens, - endpoint_active, + endpoint_active && workspace.focused, selected, + state.selected_workspace_id.is_some(), false, palette, ); - if selected && palette.selection_bg == ratatui::style::Color::Reset { - buffer.set_style(nested, Style::default().bg(palette.active_row_bg)); - } if endpoint.status != ClientEndpointStatus::Online { buffer.set_style( rect, diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 3d1d419b..513842ba 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -108,6 +108,9 @@ impl ClientShellState { } pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) { + if endpoint_id == &self.active_endpoint_id { + self.pending_workspace_highlight = None; + } self.retire_endpoint_notifications(endpoint_id); if let Some(endpoint) = self .endpoints @@ -131,6 +134,9 @@ impl ClientShellState { endpoint_id: &ClientEndpointId, status: ClientEndpointStatus, ) { + if endpoint_id == &self.active_endpoint_id && status != ClientEndpointStatus::Online { + self.pending_workspace_highlight = None; + } if let Some(endpoint) = self .endpoints .iter_mut() diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index c6d458c8..f1e7ea25 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -645,6 +645,7 @@ impl ClientShellState { ) { use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; + self.pending_workspace_highlight = None; if key.code == KeyCode::Esc || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 8f06f54b..f4ae0af0 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -768,7 +768,13 @@ fn mobile_items( palette.surface0 } } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused { - palette.surface_dim + if selected_workspace_id.is_some() + && palette.surface0 == ratatui::style::Color::Reset + { + palette.panel_bg + } else { + palette.surface_dim + } } else { palette.panel_bg }; @@ -957,6 +963,7 @@ impl ClientShellState { { self.mobile_switcher_scroll = 0; self.reveal_mobile_workspace = false; + self.pending_workspace_highlight = None; self.mode = ClientShellMode::Navigate; self.navigate_workspace_id = self.focused_navigation_target(); outcome.repaint = true; diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 161aecd5..f94f6bdc 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -4,6 +4,26 @@ use ratatui::{ widgets::{Paragraph, Widget}, }; +fn workspace_selection_background(palette: &Palette) -> ratatui::style::Color { + if palette.selection_bg == ratatui::style::Color::Reset { + palette.active_row_bg + } else { + palette.selection_bg + } +} + +pub(in crate::client::shell) fn workspace_active_background( + palette: &Palette, + navigating: bool, +) -> ratatui::style::Color { + // The fallback cursor shares the active-row color; only fill the cursor while navigating. + if navigating && palette.selection_bg == ratatui::style::Color::Reset { + palette.sidebar_bg + } else { + palette.active_row_bg + } +} + pub(in crate::client::shell) fn collapsed_sidebar_sections( area: Rect, ) -> (Rect, Option<u16>, Rect) { @@ -33,6 +53,8 @@ pub(crate) fn render_collapsed_sidebar( hits: &mut ShellHitMap, ) { let palette = &config.palette; + let selection_background = workspace_selection_background(palette); + let active_background = workspace_active_background(palette, selected_workspace_id.is_some()); render_sidebar_background(buffer, area, palette); let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area); for (index, workspace) in snapshot @@ -48,23 +70,17 @@ pub(crate) fn render_collapsed_sidebar( 1, ); let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); - let selection_background = - if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset { - palette.active_row_bg - } else { - palette.selection_bg - }; if selected { buffer.set_style(rect, Style::default().bg(selection_background)); } else if workspace.focused { - buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + buffer.set_style(rect, Style::default().bg(active_background)); } let number_style = if selected { Style::default() .fg(palette.overlay1) .bg(selection_background) } else if workspace.focused { - Style::default().fg(palette.text).bg(palette.active_row_bg) + Style::default().fg(palette.text).bg(active_background) } else { Style::default().fg(palette.overlay0) }; @@ -308,13 +324,13 @@ pub(crate) fn render_sidebar( render_workspace_rows( buffer, rect, - workspace, status, config.status_indicators, entry, rows, - true, + workspace.focused, selected, + state.selected_workspace_id.is_some(), dragged, palette, ); @@ -639,13 +655,13 @@ pub(in crate::client::shell) fn workspace_rows( pub(in crate::client::shell) fn render_workspace_rows( buffer: &mut Buffer, area: Rect, - workspace: &ClientShellWorkspace, status: crate::api::schema::AgentStatus, indicators: crate::config::StatusIndicatorStyle, entry: &WorkspaceEntry, rows: Vec<Vec<crate::ui::ResolvedToken>>, - endpoint_active: bool, + focused: bool, selected: bool, + navigating: bool, dragged: bool, palette: &Palette, ) { @@ -680,7 +696,7 @@ pub(in crate::client::shell) fn render_workspace_rows( } else { x = x.saturating_add(3); } - let highlighted = endpoint_active && workspace.focused || dragged; + let highlighted = focused || dragged; let workspace_style = Style::default() .fg(if highlighted { palette.text @@ -692,7 +708,7 @@ pub(in crate::client::shell) fn render_workspace_rows( } else { Modifier::empty() }); - let secondary_style = Style::default().fg(if endpoint_active && workspace.focused { + let secondary_style = Style::default().fg(if focused { palette.mauve } else { palette.overlay0 @@ -717,11 +733,11 @@ pub(in crate::client::shell) fn render_workspace_rows( } let background = if selected { - Some(palette.selection_bg) + Some(workspace_selection_background(palette)) } else if dragged { Some(palette.surface1) - } else if endpoint_active && workspace.focused { - Some(palette.active_row_bg) + } else if focused { + Some(workspace_active_background(palette, navigating)) } else { None }; diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 7128db57..55a065d5 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -880,6 +880,7 @@ pub(crate) struct ClientShellState { pub(super) collapsed_endpoints: HashSet<ClientEndpointId>, pub(super) mode: ClientShellMode, pub(super) navigate_workspace_id: Option<WorkspaceNavigationTarget>, + pub(super) pending_workspace_highlight: Option<PendingWorkspaceHighlight>, pub(super) reveal_navigation_workspace: bool, pub(super) overlay: Option<ClientShellOverlay>, pub(super) previous_pane_id: Option<String>, @@ -1043,6 +1044,7 @@ impl ClientShellState { collapsed_endpoints: HashSet::new(), mode: ClientShellMode::Terminal, navigate_workspace_id: None, + pending_workspace_highlight: None, reveal_navigation_workspace: false, overlay, previous_pane_id: None, @@ -1233,6 +1235,7 @@ impl ClientShellState { self.endpoint_error = None; self.endpoint_error_deadline = None; self.navigate_workspace_id = None; + self.pending_workspace_highlight = None; self.overlay = self .config .startup_onboarding @@ -1549,6 +1552,7 @@ impl ClientShellState { } } self.snapshot = Some(snapshot); + self.reconcile_pending_workspace_highlight(); let pending_surface = self.pending_pane_surface.take(); if let Some(surface) = pending_surface { let matching = self.snapshot.as_ref().is_some_and(|snapshot| { diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs index 7ea02066..c117a319 100644 --- a/src/client/shell/tests/workspace_navigation.rs +++ b/src/client/shell/tests/workspace_navigation.rs @@ -72,6 +72,81 @@ fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspa } #[test] +fn local_navigation_highlight_stays_visible_with_terminal_theme() { + use ratatui::style::Color; + + for compact in [false, true] { + for selection_bg in [Color::Reset, Color::Rgb(70, 63, 93)] { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.palette = Palette::terminal(); + config.palette.selection_bg = selection_bg; + let expected_bg = if selection_bg == Color::Reset { + config.palette.active_row_bg + } else { + selection_bg + }; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(workspaces(3))); + state.set_pane_surface(surface()); + state.sidebar_collapsed = compact; + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + + for workspace_id in ["ws_1", "ws_2"] { + assert_selected(&state, &ClientEndpointId::Local, workspace_id); + let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap(); + let selected = workspace_rect(&state, &ClientEndpointId::Local, workspace_id); + for y in selected.y..selected.bottom() { + for x in selected.x..selected.right() { + assert_eq!( + buffer[(x, y)].bg, + expected_bg, + "compact={compact}, {workspace_id}, ({x}, {y})" + ); + } + } + let untouched = workspace_rect(&state, &ClientEndpointId::Local, "ws_3"); + assert_ne!(buffer[(untouched.x, untouched.y)].bg, expected_bg); + if workspace_id != "ws_1" { + let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1"); + assert_eq!( + buffer[(focused.x, focused.y)].bg, + if selection_bg == Color::Reset { + state.config.palette.sidebar_bg + } else { + state.config.palette.active_row_bg + } + ); + assert_ne!(buffer[(focused.x, focused.y)].bg, expected_bg); + } + preview_key(&mut state, b"\x1b[B"); + } + assert_eq!( + state + .snapshot + .as_ref() + .unwrap() + .focused_workspace_id + .as_deref(), + Some("ws_1") + ); + preview_key(&mut state, b"\x1b"); + let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap(); + let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1"); + assert_eq!( + buffer[(focused.x, focused.y)].bg, + state.config.palette.active_row_bg + ); + let cancelled = workspace_rect(&state, &ClientEndpointId::Local, "ws_3"); + assert_eq!( + buffer[(cancelled.x, cancelled.y)].bg, + state.config.palette.sidebar_bg + ); + } + } +} + +#[test] fn navigation_highlights_only_the_preview_and_activates_on_enter() { for (compact, cols) in [(true, 100), (false, 100), (false, 44)] { for terminal_theme in [false, true] { @@ -113,7 +188,13 @@ fn navigation_highlights_only_the_preview_and_activates_on_enter() { assert_ne!(buffer[(other.x + 2, other.y)].bg, color); assert_eq!( buffer[(focused.x + 2, focused.y)].bg, - if cols == 44 { + if terminal_theme { + if cols == 44 { + palette.panel_bg + } else { + palette.sidebar_bg + } + } else if cols == 44 { palette.surface_dim } else { palette.active_row_bg @@ -503,3 +584,387 @@ fn aggregate_navigation_reveals_overflow_and_preserves_order() { assert_selected(&state, &ClientEndpointId::Local, "ws_1"); } } + +fn local_navigation_state(compact: bool) -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.palette = Palette::terminal(); + state.sidebar_collapsed = compact; + state.set_snapshot(Box::new(workspaces(3))); + state.set_pane_surface(surface()); + state.compose(100, 28).unwrap(); + state +} + +fn request_local_navigation(state: &mut ClientShellState, down: usize) -> String { + enter_navigation(state); + for _ in 0..down { + preview_key(state, b"\x1b[B"); + } + let outcome = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else { + panic!("expected a local workspace focus request"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::WorkspaceFocus(_) + )); + request.id.clone() +} + +fn assert_local_highlight(state: &mut ClientShellState, selected_id: &str) { + let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap(); + for workspace_id in ["ws_1", "ws_2", "ws_3"] { + let rect = workspace_rect(state, &ClientEndpointId::Local, workspace_id); + assert_eq!( + (rect.x..rect.right()) + .any(|x| buffer[(x, rect.y)].bg == state.config.palette.active_row_bg), + workspace_id == selected_id, + "expected only {selected_id} highlighted, checking {workspace_id}" + ); + } +} + +fn set_local_focus(state: &mut ClientShellState, workspace_id: &str, revision: u64) { + let mut snapshot = workspaces(3); + snapshot.revision = revision; + snapshot.focused_workspace_id = Some(workspace_id.into()); + for workspace in &mut snapshot.workspaces { + workspace.focused = workspace.workspace_id == workspace_id; + } + state.set_snapshot(Box::new(snapshot)); + let mut frame = surface(); + frame.projection_revision = revision; + state.set_pane_surface(frame); +} + +#[test] +fn accepted_local_navigation_keeps_highlight_until_authoritative_focus() { + for compact in [false, true] { + for response_first in [false, true] { + let mut state = local_navigation_state(compact); + let request_id = request_local_navigation(&mut state, 2); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + assert_eq!( + state + .snapshot + .as_ref() + .unwrap() + .focused_workspace_id + .as_deref(), + Some("ws_1") + ); + assert_eq!(state.focused_pane_id().as_deref(), Some("pane_1")); + assert_local_highlight(&mut state, "ws_3"); + state.invalidate_pane_surface(); + assert_local_highlight(&mut state, "ws_3"); + state.set_pane_surface(surface()); + if response_first { + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert_local_highlight(&mut state, "ws_3"); + } + set_local_focus(&mut state, "ws_1", 2); + assert_local_highlight(&mut state, "ws_3"); + set_local_focus(&mut state, "ws_3", 3); + assert_local_highlight(&mut state, "ws_3"); + if !response_first { + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + } + set_local_focus(&mut state, "ws_2", 4); + assert_local_highlight(&mut state, "ws_2"); + } + } +} + +#[test] +fn failed_local_navigation_releases_only_its_own_highlight() { + for failure in ["rejected", "endpoint_timeout", "cancelled"] { + let mut state = local_navigation_state(false); + let request_id = request_local_navigation(&mut state, 2); + assert_local_highlight(&mut state, "ws_3"); + if failure == "cancelled" { + assert!(state.cancel_endpoint_request(&request_id)); + } else { + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some(failure.into()), + message: "focus failed".into(), + }), + ); + } + assert_local_highlight(&mut state, "ws_1"); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert_local_highlight(&mut state, "ws_1"); + } + for old_down in [1, 2] { + let mut state = local_navigation_state(false); + let old_request = request_local_navigation(&mut state, old_down); + let latest_request = request_local_navigation(&mut state, 2); + state.cancel_endpoint_request(&old_request); + assert_local_highlight(&mut state, "ws_3"); + state.cancel_endpoint_request(&latest_request); + assert_local_highlight(&mut state, "ws_1"); + } +} + +#[test] +fn pending_navigation_highlight_does_not_survive_identity_changes() { + for change in [ + "disconnect", + "retire", + "boot", + "generation", + "deleted", + "endpoint", + ] { + let mut state = local_navigation_state(false); + let request_id = request_local_navigation(&mut state, 2); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert_local_highlight(&mut state, "ws_3"); + let mut snapshot = workspaces(3); + match change { + "disconnect" => { + state.mark_endpoint_disconnected(&ClientEndpointId::Local); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot)); + } + "retire" => { + state.retire_endpoint(&ClientEndpointId::Local); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot)); + } + "boot" => { + snapshot.boot_id = "replacement-boot".into(); + state.set_snapshot(Box::new(snapshot)); + } + "generation" => state.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(snapshot), + ), + "deleted" => { + snapshot.workspaces.pop(); + state.set_snapshot(Box::new(snapshot)); + state.set_snapshot(Box::new(workspaces(3))); + } + "endpoint" => { + let profile = remote_profile(); + let remote = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&remote, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote, Box::new(snapshot)); + assert!(state.activate_endpoint_projection(&remote)); + assert!(state.pending_workspace_highlight.is_none()); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + state.set_endpoint_catalog(&[]); + } + _ => unreachable!(), + } + assert!(state.pending_workspace_highlight.is_none(), "{change}"); + let mut frame = surface(); + frame.boot_id = state.snapshot.as_ref().unwrap().boot_id.clone(); + state.set_pane_surface(frame); + assert_local_highlight(&mut state, "ws_1"); + } +} + +#[test] +fn navigation_highlight_requires_enqueued_focus_and_yields_to_new_intent() { + let mut state = local_navigation_state(false); + state.set_endpoint_methods(Some(Vec::new())); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + let refused = state.handle_input_bytes(b"\r"); + assert!(refused.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.pending_workspace_highlight.is_none()); + // The unsupported-action notice spans the sidebar on this narrow frame. + assert!(state.visible_endpoint_notice.take().is_some()); + assert_local_highlight(&mut state, "ws_1"); + + state.set_endpoint_methods(None); + request_local_navigation(&mut state, 2); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b"); + assert_local_highlight(&mut state, "ws_1"); + + request_local_navigation(&mut state, 2); + let mut unrelated = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::ServerReloadConfig(crate::api::schema::EmptyParams::default()), + &mut unrelated, + ); + let [ClientShellAction::Endpoint { request, .. }] = unrelated.actions.as_slice() else { + panic!("expected unrelated request"); + }; + state.cancel_endpoint_request(&request.id); + assert_local_highlight(&mut state, "ws_3"); + let mut focus = ClientShellInput::default(); + state.focus_or_activate( + ClientEndpointId::Local, + ClientEndpointFocusTarget::Workspace("ws_2".into()), + &mut focus, + ); + assert!(state.pending_workspace_highlight.is_none()); +} + +#[test] +fn directional_pane_focus_releases_an_accepted_workspace_highlight() { + use crate::api::schema::{Method, PaneDirection, ResponseResult}; + + for (key, direction) in [ + (b'h', PaneDirection::Left), + (b'j', PaneDirection::Down), + (b'k', PaneDirection::Up), + (b'l', PaneDirection::Right), + ] { + for rejected in [false, true] { + let mut state = local_navigation_state(false); + let pending_request = request_local_navigation(&mut state, 2); + assert_local_highlight(&mut state, "ws_3"); + preview_key(&mut state, &[0x02]); + let outcome = state.handle_input_bytes(&[key]); + let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else { + panic!("expected a directional pane focus request"); + }; + let Method::PaneFocusDirection(params) = &request.method else { + panic!("expected PaneFocusDirection"); + }; + assert_eq!(params.direction, direction); + assert_eq!(params.pane_id.as_deref(), Some("pane_1")); + assert!(state.pending_workspace_highlight.is_none()); + assert_local_highlight(&mut state, "ws_1"); + let result = if rejected { + Err(ClientShellEndpointError { + code: Some("rejected".into()), + message: "focus rejected".into(), + }) + } else { + Ok(ResponseResult::Ok {}) + }; + state.handle_endpoint_result("boot-1", &pending_request, result); + assert_local_highlight(&mut state, "ws_1"); + } + } +} + +#[test] +fn direct_agent_focus_repaints_when_releasing_a_workspace_highlight() { + let mut config = Config::default(); + config.keys.focus_agent = crate::config::BindingConfig::one("ctrl+alt+1"); + let mut projected = workspaces(3); + projected.agents.push(agent("agent", AgentStatus::Idle, 1)); + + for pending in [false, true] { + let mut state = local_navigation_state(false); + state.config.keybinds = ClientShellConfig::from_config(&config).keybinds; + state.set_snapshot(Box::new(projected.clone())); + state.compose(100, 28).unwrap(); + if pending { + request_local_navigation(&mut state, 2); + assert_local_highlight(&mut state, "ws_3"); + } + + // Direct bindings do not inherit the repaint from leaving prefix mode. + let outcome = + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('1'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ))]); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_1")) + ); + assert!(state.pending_workspace_highlight.is_none()); + assert_eq!(outcome.repaint, pending); + assert_local_highlight(&mut state, "ws_1"); + } +} + +#[test] +fn cancelled_close_does_not_restore_an_older_navigation_highlight() { + let mut state = local_navigation_state(false); + request_local_navigation(&mut state, 2); + state.open_confirm_close_overlay("ws_1".into()); + preview_key(&mut state, b"\x1b"); + assert_eq!(state.mode, ClientShellMode::Navigate); + preview_key(&mut state, b"\x1b"); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert_local_highlight(&mut state, "ws_1"); +} + +#[test] +fn coalesced_navigation_focus_does_not_leave_a_permanent_highlight() { + let mut state = local_navigation_state(false); + let before_request = std::time::Instant::now(); + let request_id = request_local_navigation(&mut state, 2); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + // Another client can focus the original workspace before the server projects + // either change, so a successful request need not produce a new snapshot. + assert!(!state.tick_workspace_highlight(before_request)); + assert_local_highlight(&mut state, "ws_3"); + let now = std::time::Instant::now(); + assert!(state.tick_workspace_highlight(now + std::time::Duration::from_secs(2))); + assert_local_highlight(&mut state, "ws_1"); + assert!(!state.tick_workspace_highlight(now + std::time::Duration::from_secs(3))); +} + +#[test] +fn navigation_highlight_ends_for_noop_focus_and_focused_creation() { + let mut state = local_navigation_state(false); + request_local_navigation(&mut state, 0); + assert!(state.pending_workspace_highlight.is_none()); + set_local_focus(&mut state, "ws_2", 2); + assert_local_highlight(&mut state, "ws_2"); + + for focus in [false, true] { + for method in [ + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id: None, + cwd: None, + focus, + label: None, + env: Default::default(), + }, + ), + crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: Some("ws_1".into()), + cwd: None, + focus, + label: None, + env: Default::default(), + }), + ] { + let mut state = local_navigation_state(false); + request_local_navigation(&mut state, 2); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method(method, &mut outcome); + assert_eq!(state.pending_workspace_highlight.is_none(), focus); + assert_local_highlight(&mut state, if focus { "ws_1" } else { "ws_3" }); + } + } +} diff --git a/src/client/shell/workspace_navigation.rs b/src/client/shell/workspace_navigation.rs index 6139fe0d..19fa1a39 100644 --- a/src/client/shell/workspace_navigation.rs +++ b/src/client/shell/workspace_navigation.rs @@ -9,6 +9,13 @@ pub(super) struct WorkspaceNavigationTarget { generation: Option<u64>, } +/// Display-only continuity while a direct focus request awaits its authoritative snapshot. +pub(super) struct PendingWorkspaceHighlight { + pub(super) target: WorkspaceNavigationTarget, + pub(super) request_id: String, + expires_at: std::time::Instant, +} + impl WorkspaceNavigationTarget { pub(super) fn matches(&self, endpoint_id: &ClientEndpointId, workspace_id: &str) -> bool { &self.endpoint_id == endpoint_id && self.workspace_id == workspace_id @@ -16,6 +23,35 @@ impl WorkspaceNavigationTarget { } impl ClientShellState { + pub(crate) fn tick_workspace_highlight(&mut self, now: std::time::Instant) -> bool { + if self + .pending_workspace_highlight + .as_ref() + .is_some_and(|pending| now >= pending.expires_at) + { + self.pending_workspace_highlight = None; + return true; + } + false + } + + pub(super) fn reconcile_pending_workspace_highlight(&mut self) { + if self + .pending_workspace_highlight + .as_ref() + .is_some_and(|pending| { + pending.target.endpoint_id != self.active_endpoint_id + || !self.navigation_target_valid(&pending.target) + || self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.focused_workspace_id.as_deref() + == Some(pending.target.workspace_id.as_str()) + }) + }) + { + self.pending_workspace_highlight = None; + } + } + pub(super) fn navigation_target( &self, endpoint_id: &ClientEndpointId, @@ -140,11 +176,25 @@ impl ClientShellState { outcome.repaint = true; return; } + let action_index = outcome.actions.len(); if self.focus_or_activate( - target.endpoint_id, - ClientEndpointFocusTarget::Workspace(target.workspace_id), + target.endpoint_id.clone(), + ClientEndpointFocusTarget::Workspace(target.workspace_id.clone()), outcome, ) { + // Endpoint handoffs already retain their coherent source frame in the runtime. + // Only retain a highlight if a focus request was actually enqueued. + if let Some(ClientShellAction::Endpoint { request, .. }) = + outcome.actions.get(action_index) + { + self.pending_workspace_highlight = Some(PendingWorkspaceHighlight { + target, + request_id: request.id.clone(), + // A later focus can be coalesced with this one before a snapshot is sent. + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(1), + }); + self.reconcile_pending_workspace_highlight(); + } self.mode = ClientShellMode::Terminal; self.navigate_workspace_id = None; } |
