From 2a4c82c1d214456076c828160ce064ce3118fdce Mon Sep 17 00:00:00 2001 From: Lucas de Castro Zanoni <91813099+Castrozan@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:05:49 -0300 Subject: [PATCH 1/2] feat: per-client active workspace views Each attached app client now views its own workspace independently instead of mirroring one shared foreground view. A ClientView bundle holds the per-client, workspace-relative state: active and selected workspace by stable id, mode, view geometry, the navigator, the interaction singletons (copy mode, selection, context menu, drag, press tracking), the modal payloads (worktree create/open/remove, rename target, release notes, product announcement, keybind help), the name input, and the per-view scrolls and collapse set. The bundle is saved per ClientConnection and swapped into the single AppState around each client's render and input boundaries, including the resize path before a client is promoted to foreground. Saved views are reconciled against the live workspace set whenever a view is loaded, so workspace removal falls a client back to its selected or the first workspace, and clients attached before the first workspace existed adopt it once created. Server-wide truth (the workspace tree itself, global settings and palette, terminal appearance) stays in shared AppState and is not carried per client. --- src/app/client_view.rs | 150 +++++++++++ src/app/client_view_tests.rs | 78 ++++++ src/app/mod.rs | 1 + src/app/state.rs | 12 +- src/server/clients.rs | 2 + src/server/headless.rs | 108 +++++++- src/server/headless/tests/client_view.rs | 310 +++++++++++++++++++++++ 7 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 src/app/client_view.rs create mode 100644 src/app/client_view_tests.rs create mode 100644 src/server/headless/tests/client_view.rs diff --git a/src/app/client_view.rs b/src/app/client_view.rs new file mode 100644 index 0000000000..4262430367 --- /dev/null +++ b/src/app/client_view.rs @@ -0,0 +1,150 @@ +use super::state::{ + AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState, + MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState, + ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, + WorkspacePressState, WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState, +}; +use crate::layout::PaneId; +use crate::selection::Selection; + +#[cfg(test)] +#[path = "client_view_tests.rs"] +mod tests; + +/// Per-client, workspace-relative view state swapped into AppState around render and input. +pub(crate) struct ClientView { + pub active_workspace_id: Option, + pub selected_workspace_id: Option, + pub mode: Mode, + pub previous_pane_focus: Option, + pub view: ViewState, + pub navigator: NavigatorState, + pub copy_mode: Option, + pub selection: Option, + pub selection_autoscroll: Option, + pub context_menu: Option, + pub drag: Option, + pub workspace_press: Option, + pub tab_press: Option, + pub workspace_scroll: usize, + pub tab_scroll: usize, + pub tab_scroll_follow_active: bool, + pub mobile_switcher_scroll: usize, + pub name_input: String, + pub name_input_replace_on_type: bool, + pub creating_new_tab: bool, + pub rename_pane_target: Option, + pub worktree_create: Option, + pub worktree_open: Option, + pub worktree_remove: Option, + pub release_notes: Option, + pub product_announcement: Option, + pub keybind_help: KeybindHelpState, + pub global_menu: MenuListState, + pub copy_feedback: Option, + pub agent_panel_scroll: usize, + pub collapsed_space_keys: std::collections::HashSet, +} + +impl AppState { + fn active_workspace_id(&self) -> Option { + self.active + .and_then(|idx| self.workspaces.get(idx)) + .map(|ws| ws.id.clone()) + } + + fn selected_workspace_id(&self) -> Option { + self.workspaces.get(self.selected).map(|ws| ws.id.clone()) + } + + pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option { + self.workspaces.iter().position(|ws| ws.id == workspace_id) + } + + fn set_active_by_id(&mut self, workspace_id: Option<&str>) { + self.active = match workspace_id { + Some(workspace_id) => self + .workspace_index_by_id(workspace_id) + .or_else(|| (!self.workspaces.is_empty()).then_some(0)), + None => None, + }; + } + + pub(crate) fn snapshot_client_view(&self) -> ClientView { + ClientView { + active_workspace_id: self.active_workspace_id(), + selected_workspace_id: self.selected_workspace_id(), + mode: self.mode, + previous_pane_focus: self.previous_pane_focus.clone(), + view: self.view.clone(), + navigator: self.navigator.clone(), + copy_mode: self.copy_mode.clone(), + selection: self.selection.clone(), + selection_autoscroll: self.selection_autoscroll.clone(), + context_menu: self.context_menu.clone(), + drag: self.drag.clone(), + workspace_press: self.workspace_press.clone(), + tab_press: self.tab_press.clone(), + workspace_scroll: self.workspace_scroll, + tab_scroll: self.tab_scroll, + tab_scroll_follow_active: self.tab_scroll_follow_active, + mobile_switcher_scroll: self.mobile_switcher_scroll, + name_input: self.name_input.clone(), + name_input_replace_on_type: self.name_input_replace_on_type, + creating_new_tab: self.creating_new_tab, + rename_pane_target: self.rename_pane_target, + worktree_create: self.worktree_create.clone(), + worktree_open: self.worktree_open.clone(), + worktree_remove: self.worktree_remove.clone(), + release_notes: self.release_notes.clone(), + product_announcement: self.product_announcement.clone(), + keybind_help: self.keybind_help.clone(), + global_menu: self.global_menu, + copy_feedback: self.copy_feedback.clone(), + agent_panel_scroll: self.agent_panel_scroll, + collapsed_space_keys: self.collapsed_space_keys.clone(), + } + } + + pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) { + self.set_active_by_id(client_view.active_workspace_id.as_deref()); + self.selected = client_view + .selected_workspace_id + .as_deref() + .and_then(|workspace_id| self.workspace_index_by_id(workspace_id)) + .or(self.active) + .unwrap_or(0); + self.mode = client_view.mode; + if self.mode == Mode::Terminal && self.active.is_none() { + self.mode = Mode::Navigate; + } + self.previous_pane_focus = client_view.previous_pane_focus.clone(); + self.view = client_view.view.clone(); + self.navigator = client_view.navigator.clone(); + self.copy_mode = client_view.copy_mode.clone(); + self.selection = client_view.selection.clone(); + self.selection_autoscroll = client_view.selection_autoscroll.clone(); + self.context_menu = client_view.context_menu.clone(); + self.drag = client_view.drag.clone(); + self.workspace_press = client_view.workspace_press.clone(); + self.tab_press = client_view.tab_press.clone(); + self.workspace_scroll = client_view.workspace_scroll; + self.tab_scroll = client_view.tab_scroll; + self.tab_scroll_follow_active = client_view.tab_scroll_follow_active; + self.mobile_switcher_scroll = client_view.mobile_switcher_scroll; + self.name_input = client_view.name_input.clone(); + self.name_input_replace_on_type = client_view.name_input_replace_on_type; + self.creating_new_tab = client_view.creating_new_tab; + self.rename_pane_target = client_view.rename_pane_target; + self.worktree_create = client_view.worktree_create.clone(); + self.worktree_open = client_view.worktree_open.clone(); + self.worktree_remove = client_view.worktree_remove.clone(); + self.release_notes = client_view.release_notes.clone(); + self.product_announcement = client_view.product_announcement.clone(); + self.keybind_help = client_view.keybind_help.clone(); + self.global_menu = client_view.global_menu; + self.copy_feedback = client_view.copy_feedback.clone(); + self.agent_panel_scroll = client_view.agent_panel_scroll; + self.collapsed_space_keys = client_view.collapsed_space_keys.clone(); + } +} diff --git a/src/app/client_view_tests.rs b/src/app/client_view_tests.rs new file mode 100644 index 0000000000..d7df8c5d3c --- /dev/null +++ b/src/app/client_view_tests.rs @@ -0,0 +1,78 @@ +use super::super::state::{AppState, Mode}; +use crate::workspace::Workspace; + +fn app_state_with_workspaces(names: &[&str]) -> AppState { + let mut state = AppState::test_new(); + for name in names { + state.workspaces.push(Workspace::test_new(name)); + } + state.ensure_test_terminals(); + if !state.workspaces.is_empty() { + state.active = Some(0); + state.selected = 0; + state.mode = Mode::Terminal; + } + state +} + +#[test] +fn snapshot_restore_roundtrips_active_and_selected_workspace() { + let mut state = app_state_with_workspaces(&["one", "two", "three"]); + state.active = Some(2); + state.selected = 0; + let client_view = state.snapshot_client_view(); + + state.active = Some(1); + state.selected = 1; + + state.restore_client_view(&client_view); + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 0); +} + +#[test] +fn modal_mode_stays_per_client_view() { + let mut state = app_state_with_workspaces(&["one", "two"]); + let terminal_client_view = state.snapshot_client_view(); + + state.mode = Mode::Navigate; + state.navigator.query = "two".to_owned(); + let navigating_client_view = state.snapshot_client_view(); + + state.restore_client_view(&terminal_client_view); + assert_eq!(state.mode, Mode::Terminal); + assert!(state.navigator.query.is_empty()); + + state.restore_client_view(&navigating_client_view); + assert_eq!(state.mode, Mode::Navigate); + assert_eq!(state.navigator.query, "two"); +} + +#[test] +fn restore_falls_back_to_first_workspace_when_active_id_is_gone() { + let mut state = app_state_with_workspaces(&["one", "two"]); + state.switch_workspace(1); + let client_view = state.snapshot_client_view(); + + state.workspaces.remove(1); + state.active = Some(0); + state.selected = 0; + + state.restore_client_view(&client_view); + assert_eq!(state.active, Some(0)); + assert_eq!(state.selected, 0); +} + +#[test] +fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() { + let mut state = app_state_with_workspaces(&["one"]); + let client_view = state.snapshot_client_view(); + + state.workspaces.clear(); + state.active = None; + state.selected = 0; + + state.restore_client_view(&client_view); + assert_eq!(state.active, None); + assert_eq!(state.mode, Mode::Navigate); +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 1a0c77a3ea..1fcebcd02b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,7 @@ mod agent_resume; mod agents; mod api; mod api_helpers; +pub(crate) mod client_view; mod config_io; mod creation; mod ids; diff --git a/src/app/state.rs b/src/app/state.rs index be858678da..0db79fc0cd 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -721,12 +721,14 @@ pub(crate) fn text_matches_query(query: &str, text: &str) -> bool { /// Computed view geometry — derived from AppState + terminal size. /// Updated before each render, consumed by render and mouse handling. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ViewLayout { + #[default] Desktop, Mobile, } +#[derive(Clone, Default)] pub struct ViewState { pub layout: ViewLayout, pub sidebar_rect: Rect, @@ -1049,6 +1051,7 @@ pub struct SettingsState { pub original_theme: Option, } +#[derive(Clone)] pub(crate) enum DragTarget { WorkspaceReorder { source_ws_idx: usize, @@ -1089,16 +1092,19 @@ pub(crate) enum DragTarget { } /// Active mouse drag on a split border or sidebar divider. +#[derive(Clone)] pub(crate) struct DragState { pub target: DragTarget, } +#[derive(Clone)] pub(crate) struct WorkspacePressState { pub ws_idx: usize, pub start_col: u16, pub start_row: u16, } +#[derive(Clone)] pub(crate) struct TabPressState { pub ws_idx: usize, pub tab_idx: usize, @@ -1131,6 +1137,7 @@ pub enum ContextMenuKind { } /// Right-click context menu state. +#[derive(Clone)] pub struct ContextMenuState { pub kind: ContextMenuKind, pub x: u16, @@ -1278,6 +1285,7 @@ pub struct CopyFeedback { pub message: String, } +#[derive(Clone)] pub struct ReleaseNotesState { pub version: String, pub body: String, @@ -1285,6 +1293,7 @@ pub struct ReleaseNotesState { pub preview: bool, } +#[derive(Clone)] pub struct ProductAnnouncementState { pub version: String, pub id: String, @@ -1294,6 +1303,7 @@ pub struct ProductAnnouncementState { pub preview: bool, } +#[derive(Clone)] pub struct KeybindHelpState { pub scroll: u16, } diff --git a/src/server/clients.rs b/src/server/clients.rs index 705152eb83..206fd916ba 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -56,6 +56,7 @@ pub(crate) struct ClientConnection { pub(crate) host_mouse_capture_active: Option, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec, + pub(crate) view: Option, /// Channels for sending framed ServerMessage data to the client writer thread. pub(crate) writer: Option, } @@ -117,6 +118,7 @@ impl ClientConnection { render_pending: false, host_mouse_capture_active: None, staged_clipboard_files: Vec::new(), + view: None, writer, } } diff --git a/src/server/headless.rs b/src/server/headless.rs index e6d2b833d2..1eb44245c7 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -205,6 +205,8 @@ pub struct HeadlessServer { next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option, + /// The client whose saved ClientView is currently loaded into the shared AppState. + client_view_owner: Option, /// Server-owned keybindings, restored when foreground clients use server mode. server_keybindings: crate::config::LiveKeybindConfig, /// Full server config warning shown to clients that use server keybindings. @@ -401,6 +403,7 @@ impl HeadlessServer { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + client_view_owner: None, server_keybindings, server_config_diagnostic, server_config_diagnostic_without_keybindings, @@ -1202,6 +1205,87 @@ impl HeadlessServer { ) } + fn store_client_view(&mut self, client_id: u64) { + if let Some(client) = self.clients.get_mut(&client_id) { + client.view = Some(self.app.state.snapshot_client_view()); + } + } + + /// Load `client_id`'s saved view into the shared `AppState` scratch slot so the + /// following render or input runs against that client's own workspace, mode, and + /// modal state. Order matters: first reconcile every saved view against the current + /// workspaces (ids may have shifted), then snapshot the outgoing owner's live state + /// back into its view, then restore the target (or adopt the current state on the + /// client's first focus). A no-op when `client_id` already owns the loaded view. + fn focus_client_view(&mut self, client_id: u64) { + if self.client_view_owner == Some(client_id) { + return; + } + self.reconcile_client_views_with_workspaces(); + if let Some(owner) = self.client_view_owner { + self.store_client_view(owner); + } + let Some(client) = self.clients.get_mut(&client_id) else { + return; + }; + match &client.view { + Some(view) => self.app.state.restore_client_view(view), + None => client.view = Some(self.app.state.snapshot_client_view()), + } + self.client_view_owner = Some(client_id); + } + + /// Focus a client's view before routing its input, but only for full-app clients; + /// terminal attach and observe clients drive a single pane and never own a view. + fn focus_client_view_for_input(&mut self, client_id: u64) { + if self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_full_app_client) + { + self.focus_client_view(client_id); + } + } + + fn reconcile_client_views_with_workspaces(&mut self) { + let default_workspace_id = self.app.state.workspaces.first().map(|ws| ws.id.clone()); + for client in self.clients.values_mut() { + let Some(view) = client.view.as_mut() else { + continue; + }; + if view + .selected_workspace_id + .as_ref() + .is_some_and(|workspace_id| { + self.app.state.workspace_index_by_id(workspace_id).is_none() + }) + { + view.selected_workspace_id = default_workspace_id.clone(); + } + if view + .active_workspace_id + .as_ref() + .is_some_and(|workspace_id| { + self.app.state.workspace_index_by_id(workspace_id).is_none() + }) + { + view.active_workspace_id = view + .selected_workspace_id + .clone() + .or_else(|| default_workspace_id.clone()); + } + if view.active_workspace_id.is_none() && default_workspace_id.is_some() { + view.active_workspace_id = default_workspace_id.clone(); + if view.selected_workspace_id.is_none() { + view.selected_workspace_id = default_workspace_id.clone(); + } + if matches!(view.mode, app::Mode::Navigate | app::Mode::Onboarding) { + view.mode = app::Mode::Terminal; + } + } + } + } + fn promote_client_to_foreground(&mut self, client_id: u64) -> bool { let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { @@ -1219,6 +1303,9 @@ impl HeadlessServer { let next_foreground = latest_app_client(&self.clients); let changed = next_foreground != self.foreground_client_id; self.foreground_client_id = next_foreground; + if let Some(client_id) = next_foreground { + self.focus_client_view(client_id); + } self.sync_foreground_client_state(); changed } @@ -1238,6 +1325,9 @@ impl HeadlessServer { let was_foreground = self.foreground_client_id == Some(client_id); self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); + if self.client_view_owner == Some(client_id) { + self.client_view_owner = None; + } if let Some(removed) = removed { crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode { @@ -1437,6 +1527,7 @@ impl HeadlessServer { return true; } + self.focus_client_view_for_input(client_id); let foreground_changed = self.promote_client_to_foreground(client_id); if foreground_changed { self.resize_shared_runtime_to_effective_size_before_input(); @@ -2419,6 +2510,7 @@ impl HeadlessServer { } } self.update_client_outer_focus_from_events(client_id, &events); + self.focus_client_view_for_input(client_id); let interaction = events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) @@ -2520,6 +2612,7 @@ impl HeadlessServer { ); if !direct_attach_requested { self.foreground_client_id = Some(client_id); + self.focus_client_view(client_id); } if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); @@ -2702,6 +2795,7 @@ impl HeadlessServer { height_px: cell_height_px, }; } + self.focus_client_view_for_input(client_id); self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); true @@ -3120,10 +3214,6 @@ impl HeadlessServer { }}; } - if !self.retained_pty_update_allowed_by_app_state() { - retained_fallback!("unsafe_app_state"); - } - let render_targets = render_targets(&self.clients, self.foreground_client_id); let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] = render_targets.as_slice() @@ -3133,6 +3223,10 @@ impl HeadlessServer { if !matches!(mode, ClientConnectionMode::App) { retained_fallback!("not_app_client"); } + self.focus_client_view(*client_id); + if !self.retained_pty_update_allowed_by_app_state() { + retained_fallback!("unsafe_app_state"); + } let Some(client) = self.clients.get(client_id) else { retained_fallback!("client_missing"); }; @@ -3343,6 +3437,9 @@ impl HeadlessServer { for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { let area = Rect::new(0, 0, cols, rows); let is_app_client = matches!(mode, ClientConnectionMode::App); + if is_app_client { + self.focus_client_view(client_id); + } let mut frame = match mode { ClientConnectionMode::App => { let render_started = crate::render_prof::timer(); @@ -4153,6 +4250,8 @@ mod tests { use crate::app::AppState; use crate::protocol::CursorState; + mod client_view; + fn test_headless_server() -> HeadlessServer { test_headless_server_with_event_hub(api::EventHub::default()) } @@ -4203,6 +4302,7 @@ mod tests { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + client_view_owner: None, server_keybindings, server_config_diagnostic: None, server_config_diagnostic_without_keybindings: None, diff --git a/src/server/headless/tests/client_view.rs b/src/server/headless/tests/client_view.rs new file mode 100644 index 0000000000..82f41ca770 --- /dev/null +++ b/src/server/headless/tests/client_view.rs @@ -0,0 +1,310 @@ +use super::test_headless_server; +use crate::app::Mode; +use crate::protocol::RenderEncoding; +use crate::server::client_transport::ServerEvent; +use crate::server::clients::{ClientConnection, ClientConnectionMode}; +use crate::server::headless::HeadlessServer; +use crate::workspace::Workspace; + +fn insert_test_app_client(server: &mut HeadlessServer, client_id: u64) { + server.clients.insert( + client_id, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(true), + client_id, + RenderEncoding::SemanticFrame, + None, + ), + ); +} + +fn insert_test_terminal_attach_client( + server: &mut HeadlessServer, + client_id: u64, + terminal_id: &str, +) { + server.clients.insert( + client_id, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.to_owned(), + }, + None, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(true), + client_id, + RenderEncoding::SemanticFrame, + false, + None, + ), + ); +} + +fn server_with_workspaces_and_clients(workspace_names: &[&str]) -> HeadlessServer { + let mut server = test_headless_server(); + for name in workspace_names { + server.app.state.workspaces.push(Workspace::test_new(name)); + } + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = Mode::Terminal; + insert_test_app_client(&mut server, 1); + insert_test_app_client(&mut server, 2); + server.foreground_client_id = Some(1); + server +} + +#[test] +fn two_clients_view_different_workspaces_without_mirroring() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + + server.focus_client_view(1); + assert_eq!(server.app.state.active, Some(0)); + + server.focus_client_view(2); + server.app.state.switch_workspace(1); + + server.focus_client_view(1); + assert_eq!(server.app.state.active, Some(0)); + assert_eq!(server.app.state.selected, 0); + + server.focus_client_view(2); + assert_eq!(server.app.state.active, Some(1)); + assert_eq!(server.app.state.selected, 1); +} + +#[test] +fn modal_state_stays_with_the_client_that_opened_it() { + let mut server = server_with_workspaces_and_clients(&["one"]); + + server.focus_client_view(2); + assert_eq!(server.app.state.mode, Mode::Terminal); + + server.focus_client_view(1); + server.app.state.mode = Mode::KeybindHelp; + server.app.state.navigator.query = "help".to_owned(); + + server.focus_client_view(2); + assert_eq!(server.app.state.mode, Mode::Terminal); + assert!(server.app.state.navigator.query.is_empty()); + + server.focus_client_view(1); + assert_eq!(server.app.state.mode, Mode::KeybindHelp); + assert_eq!(server.app.state.navigator.query, "help"); +} + +#[test] +fn workspace_removal_reconciles_other_clients_saved_views() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + let removed_workspace_id = server.app.state.workspaces[1].id.clone(); + let remaining_workspace_id = server.app.state.workspaces[0].id.clone(); + + server.focus_client_view(2); + server.app.state.switch_workspace(1); + server.focus_client_view(1); + assert_eq!( + server.clients[&2] + .view + .as_ref() + .and_then(|view| view.active_workspace_id.clone()), + Some(removed_workspace_id) + ); + + server.app.state.workspaces.remove(1); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.reconcile_client_views_with_workspaces(); + + assert_eq!( + server.clients[&2] + .view + .as_ref() + .and_then(|view| view.active_workspace_id.clone()), + Some(remaining_workspace_id) + ); + + server.focus_client_view(2); + assert_eq!(server.app.state.active, Some(0)); + assert_eq!(server.app.state.mode, Mode::Terminal); +} + +#[test] +fn removing_the_view_owner_clears_ownership_and_refocuses_survivor() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + + server.focus_client_view(1); + server.promote_client_to_foreground(2); + server.focus_client_view(2); + server.app.state.switch_workspace(1); + assert_eq!(server.client_view_owner, Some(2)); + + server.remove_client(2); + assert_eq!(server.client_view_owner, Some(1)); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.app.state.active, Some(0)); +} + +#[test] +fn rename_modal_payload_stays_with_the_client_that_opened_it() { + let mut server = server_with_workspaces_and_clients(&["one"]); + let renamed_pane = *server.app.state.workspaces[0] + .active_tab() + .expect("workspace must have an active tab") + .panes + .keys() + .next() + .expect("active tab must have a pane"); + + server.focus_client_view(2); + server.focus_client_view(1); + server.app.state.mode = Mode::RenamePane; + server.app.state.rename_pane_target = Some(renamed_pane); + server.app.state.name_input = "foo".to_owned(); + + server.focus_client_view(2); + assert_eq!(server.app.state.mode, Mode::Terminal); + assert_eq!(server.app.state.rename_pane_target, None); + assert!(server.app.state.name_input.is_empty()); + + server.focus_client_view(1); + assert_eq!(server.app.state.mode, Mode::RenamePane); + assert_eq!(server.app.state.rename_pane_target, Some(renamed_pane)); + assert_eq!(server.app.state.name_input, "foo"); +} + +#[test] +fn same_workspace_clients_share_workspace_scoped_state() { + let mut server = server_with_workspaces_and_clients(&["one"]); + + server.focus_client_view(2); + server.focus_client_view(1); + assert_eq!(server.app.state.active, Some(0)); + + let split_pane = + server.app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + assert_eq!( + server.app.state.workspaces[0].focused_pane_id(), + Some(split_pane) + ); + + server.focus_client_view(2); + assert_eq!(server.app.state.active, Some(0)); + assert_eq!( + server.app.state.workspaces[0].focused_pane_id(), + Some(split_pane) + ); + assert_eq!( + server.app.state.workspaces[0] + .active_tab() + .expect("workspace must have an active tab") + .panes + .len(), + 2 + ); +} + +#[test] +fn focus_client_view_for_input_swaps_app_clients_and_skips_terminal_attach() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + + server.focus_client_view(1); + server.focus_client_view(2); + server.app.state.switch_workspace(1); + server.focus_client_view(1); + assert_eq!(server.client_view_owner, Some(1)); + assert_eq!(server.app.state.active, Some(0)); + + server.focus_client_view_for_input(2); + assert_eq!(server.client_view_owner, Some(2)); + assert_eq!(server.app.state.active, Some(1)); + + insert_test_terminal_attach_client(&mut server, 3, "term"); + server.focus_client_view_for_input(3); + assert_eq!(server.client_view_owner, Some(2)); + assert_eq!(server.app.state.active, Some(1)); +} + +#[test] +fn client_resize_from_background_client_does_not_mark_owners_tabs_seen() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + + server.focus_client_view(1); + server.focus_client_view(2); + server.app.state.switch_workspace(1); + server.focus_client_view(1); + assert_eq!(server.client_view_owner, Some(1)); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.app.state.active, Some(0)); + + let owner_pane = *server.app.state.workspaces[0] + .active_tab() + .expect("workspace must have an active tab") + .panes + .keys() + .next() + .expect("active tab must have a pane"); + server.app.state.workspaces[0] + .active_tab_mut() + .expect("workspace must have an active tab") + .panes + .get_mut(&owner_pane) + .expect("pane must exist") + .seen = false; + + server.handle_server_event(ServerEvent::ClientResize { + client_id: 2, + cols: 100, + rows: 30, + cell_width_px: 0, + cell_height_px: 0, + }); + + assert!( + !server.app.state.workspaces[0] + .active_tab() + .expect("workspace must have an active tab") + .panes + .get(&owner_pane) + .expect("pane must exist") + .seen + ); +} + +#[test] +fn single_client_focus_adopts_view_and_takes_ownership() { + let mut server = server_with_workspaces_and_clients(&["one"]); + server.clients.remove(&2); + assert!(server.clients[&1].view.is_none()); + assert_eq!(server.client_view_owner, None); + + server.focus_client_view(1); + + assert_eq!(server.client_view_owner, Some(1)); + assert_eq!( + server.clients[&1] + .view + .as_ref() + .and_then(|view| view.active_workspace_id.clone()), + server.app.state.workspaces.first().map(|ws| ws.id.clone()) + ); +} + +#[test] +fn removing_last_client_clears_view_owner_and_foreground() { + let mut server = server_with_workspaces_and_clients(&["one"]); + server.clients.remove(&2); + server.focus_client_view(1); + assert_eq!(server.client_view_owner, Some(1)); + assert_eq!(server.foreground_client_id, Some(1)); + + server.remove_client(1); + + assert_eq!(server.client_view_owner, None); + assert_eq!(server.foreground_client_id, None); +} From 55ebbc02f437f19d2a7210076dfcaea95b1c6edc Mon Sep 17 00:00:00 2001 From: Lucas de Castro Zanoni Date: Thu, 30 Jul 2026 11:51:02 -0300 Subject: [PATCH 2/2] fix: scope per-client view state to its owning client The view swap made the active workspace per-client, but the rest of the server still assumed one loaded state, so deferred commands, expiry timers and broadcast decisions landed on whichever view happened to be swapped in. Four paths are corrected. Deferred requests are harvested from the client that raised them while its view is still loaded, then replayed with that view focused. Without this, one client clicking "+ new workspace" force-switched a different client into the new workspace, and a worktree-create submit could drop silently when the handler ran against a view whose dialog payload was None. Index-bearing view state is canceled when the workspace tree changes. Saved views restore context menus, presses and drags verbatim, and those payloads carry raw indices, so a menu left open while another client closed a workspace could close the wrong one or index out of bounds. Workspace id lookups driven by such an index are now checked, so the worst case is a no-op instead of a panic that takes down every client. Transient expiry deadlines travel with the view that owns the transient. copy_feedback and the selection highlight are per-client, but their deadlines lived on App, so a deadline firing while another view was loaded cleared the wrong state and left the original stuck with nothing to expire it. The loop deadline now also accounts for deadlines parked in saved views. Host mouse capture is computed per client instead of broadcasting one value. With mouse_capture disabled, capture depends on each client's own mode and focused pane, so a client on another workspace was told the wrong mode and stopped receiving the mouse events it needed. --- src/app/client_view.rs | 111 +++++++++++- src/app/deferred_client_requests.rs | 73 ++++++++ src/app/ids.rs | 14 +- src/app/input/navigate.rs | 12 +- src/app/mod.rs | 1 + src/app/runtime.rs | 45 +++-- src/app/state.rs | 19 ++- src/server/headless.rs | 205 ++++++++++++++++++----- src/server/headless/tests/client_view.rs | 131 ++++++++++++++- 9 files changed, 540 insertions(+), 71 deletions(-) create mode 100644 src/app/deferred_client_requests.rs diff --git a/src/app/client_view.rs b/src/app/client_view.rs index 4262430367..f994acce3c 100644 --- a/src/app/client_view.rs +++ b/src/app/client_view.rs @@ -1,9 +1,12 @@ +use std::time::Instant; + use super::state::{ AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState, MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState, - ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, - WorkspacePressState, WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState, + ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState, + WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState, }; +use super::App; use crate::layout::PaneId; use crate::selection::Selection; @@ -44,9 +47,69 @@ pub(crate) struct ClientView { pub copy_feedback: Option, pub agent_panel_scroll: usize, pub collapsed_space_keys: std::collections::HashSet, + pub copy_feedback_deadline: Option, + pub selection_autoscroll_deadline: Option, + pub selection_highlight_clear_deadline: Option, +} + +impl ClientView { + /// The earliest expiry deadline this saved view is waiting on, so the headless + /// loop keeps scheduling wake-ups for transients parked outside the loaded view. + pub(crate) fn next_transient_deadline(&self) -> Option { + [ + self.copy_feedback_deadline, + self.selection_autoscroll_deadline, + self.selection_highlight_clear_deadline, + ] + .into_iter() + .flatten() + .min() + } + + pub(crate) fn has_due_transient(&self, now: Instant) -> bool { + self.next_transient_deadline() + .is_some_and(|deadline| now >= deadline) + } + + /// Drops the interaction state that addresses workspaces and tabs by index. + /// + /// A saved view can hold an open context menu, a press, a drag, or a close + /// confirmation indefinitely, and those payloads carry raw indices that are + /// restored verbatim. Once another client mutates the workspace tree the indices + /// no longer name what the user aimed at, so the pending interaction is canceled + /// rather than replayed against a different workspace. + pub(crate) fn cancel_workspace_index_state(&mut self) { + self.context_menu = None; + self.drag = None; + self.workspace_press = None; + self.tab_press = None; + if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) { + self.mode = if self.active_workspace_id.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + } } impl AppState { + /// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`], + /// applied to the view that is currently swapped in. + pub(crate) fn cancel_workspace_index_state(&mut self) { + self.context_menu = None; + self.drag = None; + self.workspace_press = None; + self.tab_press = None; + if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) { + self.mode = if self.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + } + fn active_workspace_id(&self) -> Option { self.active .and_then(|idx| self.workspaces.get(idx)) @@ -61,6 +124,25 @@ impl AppState { self.workspaces.iter().position(|ws| ws.id == workspace_id) } + /// Whether the host should capture the mouse for a client sitting on this saved + /// view. Capture depends on the client's own mode and active workspace, so a + /// single value computed from the loaded view is wrong for everyone else. + pub(crate) fn should_capture_host_mouse_in_view( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + client_view: &ClientView, + ) -> bool { + self.mouse_capture + || self.focused_pane_requests_mouse_capture_in( + terminal_runtimes, + client_view.mode, + client_view + .active_workspace_id + .as_deref() + .and_then(|workspace_id| self.workspace_index_by_id(workspace_id)), + ) + } + fn set_active_by_id(&mut self, workspace_id: Option<&str>) { self.active = match workspace_id { Some(workspace_id) => self @@ -103,6 +185,9 @@ impl AppState { copy_feedback: self.copy_feedback.clone(), agent_panel_scroll: self.agent_panel_scroll, collapsed_space_keys: self.collapsed_space_keys.clone(), + copy_feedback_deadline: None, + selection_autoscroll_deadline: None, + selection_highlight_clear_deadline: None, } } @@ -148,3 +233,25 @@ impl AppState { self.collapsed_space_keys = client_view.collapsed_space_keys.clone(); } } + +impl App { + /// Saves the loaded view together with the expiry deadlines of its transients. + /// + /// The deadlines live on `App` rather than `AppState`, so leaving them behind + /// would let a deadline fire against another client's loaded state: the transient + /// it was meant to clear would come back on restore with nothing left to expire it. + pub(crate) fn snapshot_client_view(&self) -> ClientView { + let mut client_view = self.state.snapshot_client_view(); + client_view.copy_feedback_deadline = self.copy_feedback_deadline; + client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline; + client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline; + client_view + } + + pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) { + self.state.restore_client_view(client_view); + self.copy_feedback_deadline = client_view.copy_feedback_deadline; + self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline; + self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline; + } +} diff --git a/src/app/deferred_client_requests.rs b/src/app/deferred_client_requests.rs new file mode 100644 index 0000000000..d69b832e6b --- /dev/null +++ b/src/app/deferred_client_requests.rs @@ -0,0 +1,73 @@ +use super::state::AppState; + +/// One-tick-deferred requests raised by a client's input. +/// +/// They live on the shared `AppState` and are not part of `ClientView`, so they +/// survive view swaps: without harvesting they would execute against whichever +/// client's view happens to be loaded when the deferred batch drains, forcing an +/// unrelated client into a new workspace or dropping a modal submit whose payload +/// only exists in the requesting client's view. The headless loop takes them +/// while the raising client still owns the loaded view, then replays each set +/// against that same view. +#[derive(Default)] +pub(crate) struct DeferredClientRequests { + complete_onboarding: bool, + new_workspace: bool, + new_workspace_cwd: Option, + new_tab: bool, + new_tab_name: Option, + new_linked_worktree: Option, + open_existing_worktree: Option, + remove_linked_worktree: Option, + submit_worktree_create: bool, + submit_worktree_open: bool, + submit_worktree_remove: bool, +} + +impl DeferredClientRequests { + pub(crate) fn is_empty(&self) -> bool { + !self.complete_onboarding + && !self.new_workspace + && self.new_workspace_cwd.is_none() + && !self.new_tab + && self.new_tab_name.is_none() + && self.new_linked_worktree.is_none() + && self.open_existing_worktree.is_none() + && self.remove_linked_worktree.is_none() + && !self.submit_worktree_create + && !self.submit_worktree_open + && !self.submit_worktree_remove + } +} + +impl AppState { + pub(crate) fn take_deferred_client_requests(&mut self) -> DeferredClientRequests { + DeferredClientRequests { + complete_onboarding: std::mem::take(&mut self.request_complete_onboarding), + new_workspace: std::mem::take(&mut self.request_new_workspace), + new_workspace_cwd: self.request_new_workspace_cwd.take(), + new_tab: std::mem::take(&mut self.request_new_tab), + new_tab_name: self.requested_new_tab_name.take(), + new_linked_worktree: self.request_new_linked_worktree.take(), + open_existing_worktree: self.request_open_existing_worktree.take(), + remove_linked_worktree: self.request_remove_linked_worktree.take(), + submit_worktree_create: std::mem::take(&mut self.request_submit_worktree_create), + submit_worktree_open: std::mem::take(&mut self.request_submit_worktree_open), + submit_worktree_remove: std::mem::take(&mut self.request_submit_worktree_remove), + } + } + + pub(crate) fn restore_deferred_client_requests(&mut self, requests: DeferredClientRequests) { + self.request_complete_onboarding = requests.complete_onboarding; + self.request_new_workspace = requests.new_workspace; + self.request_new_workspace_cwd = requests.new_workspace_cwd; + self.request_new_tab = requests.new_tab; + self.requested_new_tab_name = requests.new_tab_name; + self.request_new_linked_worktree = requests.new_linked_worktree; + self.request_open_existing_worktree = requests.open_existing_worktree; + self.request_remove_linked_worktree = requests.remove_linked_worktree; + self.request_submit_worktree_create = requests.submit_worktree_create; + self.request_submit_worktree_open = requests.submit_worktree_open; + self.request_submit_worktree_remove = requests.submit_worktree_remove; + } +} diff --git a/src/app/ids.rs b/src/app/ids.rs index 9dc4371e73..3c7cdf7b2c 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -12,8 +12,20 @@ impl App { .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) } + /// The stable id of the workspace at `ws_idx`, or `None` when the index is stale. + /// + /// Callers that take an index from long-lived UI state (a saved context menu, a + /// drag, a press) must use this, because per-client views let such an index + /// outlive the workspace it pointed at. + pub(crate) fn workspace_id_at(&self, ws_idx: usize) -> Option { + self.state + .workspaces + .get(ws_idx) + .map(|workspace| workspace.id.clone()) + } + pub(super) fn public_workspace_id(&self, ws_idx: usize) -> String { - self.state.workspaces[ws_idx].id.clone() + self.workspace_id_at(ws_idx).unwrap_or_default() } pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option { diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index a15976696d..3f39f22d45 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -423,17 +423,23 @@ impl App { } pub(crate) fn focus_workspace_idx_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); + let Some(workspace_id) = self.workspace_id_at(ws_idx) else { + return; + }; self.runtime_workspace_focus("tui.workspace.focus", workspace_id); } pub(crate) fn close_workspace_idx_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); + let Some(workspace_id) = self.workspace_id_at(ws_idx) else { + return; + }; self.runtime_workspace_close("tui.workspace.close", workspace_id); } pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) { - let workspace_id = self.public_workspace_id(source_ws_idx); + let Some(workspace_id) = self.workspace_id_at(source_ws_idx) else { + return; + }; self.runtime_workspace_move( "tui.workspace.move", crate::api::schema::WorkspaceMoveParams { diff --git a/src/app/mod.rs b/src/app/mod.rs index 1fcebcd02b..dc68a65682 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ mod api_helpers; pub(crate) mod client_view; mod config_io; mod creation; +pub(crate) mod deferred_client_requests; mod ids; mod input; mod runtime; diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 83ca2ea6dc..08ca908b1e 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -237,14 +237,7 @@ impl App { } } - if self - .copy_feedback_deadline - .is_some_and(|deadline| now >= deadline) - { - self.copy_feedback_deadline = None; - self.state.copy_feedback = None; - changed = true; - } + changed |= self.expire_due_view_transients(now); if self .next_animation_tick @@ -255,16 +248,6 @@ impl App { changed = true; } - if self - .selection_autoscroll_deadline - .is_some_and(|deadline| now >= deadline) - { - self.tick_selection_autoscroll(now); - changed = true; - } - - changed |= self.clear_due_selection_highlight(now); - self.start_git_status_refresh_if_due(now); if self @@ -311,6 +294,32 @@ impl App { changed } + /// Expires the transients whose state is per-client and whose deadline travels + /// with the client view, so whichever view is loaded only ever expires its own. + pub(crate) fn expire_due_view_transients(&mut self, now: Instant) -> bool { + let mut changed = false; + + if self + .copy_feedback_deadline + .is_some_and(|deadline| now >= deadline) + { + self.copy_feedback_deadline = None; + self.state.copy_feedback = None; + changed = true; + } + + if self + .selection_autoscroll_deadline + .is_some_and(|deadline| now >= deadline) + { + self.tick_selection_autoscroll(now); + changed = true; + } + + changed |= self.clear_due_selection_highlight(now); + changed + } + /// Clears temporary copied-token highlights, such as after double-click copy. pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool { if self diff --git a/src/app/state.rs b/src/app/state.rs index 0db79fc0cd..526dc7e7ac 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1555,18 +1555,29 @@ impl AppState { section == SettingsSection::Integrations && self.integration_updates_available() } - pub(crate) fn focused_pane_requests_mouse_capture_from( + /// Whether the pane focused by the given mode and active workspace asks the host + /// for mouse events. Taking both as arguments keeps this answerable for a view + /// that is not the loaded one, which is what per-client views need. + pub(crate) fn focused_pane_requests_mouse_capture_in( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + mode: Mode, + active_workspace_idx: Option, ) -> bool { - self.mode == Mode::Terminal - && self - .active + mode == Mode::Terminal + && active_workspace_idx .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) .and_then(crate::terminal::TerminalRuntime::input_state) .is_some_and(crate::pane::InputState::mouse_reporting_enabled) } + pub(crate) fn focused_pane_requests_mouse_capture_from( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ) -> bool { + self.focused_pane_requests_mouse_capture_in(terminal_runtimes, self.mode, self.active) + } + pub(crate) fn should_capture_host_mouse_from( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, diff --git a/src/server/headless.rs b/src/server/headless.rs index 1eb44245c7..eaa612f3f8 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -36,6 +36,8 @@ use bytes::Bytes; use crate::api; use crate::app; +use crate::app::client_view::ClientView; +use crate::app::deferred_client_requests::DeferredClientRequests; use crate::config; use crate::events::AppEvent; use crate::ipc::{ @@ -207,6 +209,12 @@ pub struct HeadlessServer { foreground_client_id: Option, /// The client whose saved ClientView is currently loaded into the shared AppState. client_view_owner: Option, + /// Deferred requests harvested from each client's input batch, replayed against + /// the view that raised them instead of whichever view is loaded when they drain. + pending_client_deferred_requests: Vec<(u64, DeferredClientRequests)>, + /// Workspace ids as of the last view reconcile, used to detect a workspace tree + /// change that invalidates index-bearing state held in saved views. + reconciled_workspace_ids: Option>, /// Server-owned keybindings, restored when foreground clients use server mode. server_keybindings: crate::config::LiveKeybindConfig, /// Full server config warning shown to clients that use server keybindings. @@ -404,6 +412,8 @@ impl HeadlessServer { next_client_id: 1, foreground_client_id: None, client_view_owner: None, + pending_client_deferred_requests: Vec::new(), + reconciled_workspace_ids: None, server_keybindings, server_config_diagnostic, server_config_diagnostic_without_keybindings, @@ -544,15 +554,19 @@ impl HeadlessServer { } // 8. Wait for next event. - let next_deadline = self - .app - .next_headless_loop_deadline_with_git_refresh( + let next_deadline = [ + self.app.next_headless_loop_deadline_with_git_refresh( now, needs_render, self.has_app_client(), - ) - .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) - .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); + ), + self.earliest_saved_client_view_deadline(), + ] + .into_iter() + .flatten() + .min() + .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) + .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); let event = { tokio::select! { maybe_api = self.app.api_rx.recv() => match maybe_api { @@ -609,8 +623,40 @@ impl HeadlessServer { Ok(()) } + /// Take the deferred requests this client's input just raised, while its view is + /// still the loaded one, so they can be replayed against it rather than against + /// whichever client's view is loaded when the batch drains. + fn harvest_deferred_requests_from_client(&mut self, client_id: u64) { + if self.client_view_owner != Some(client_id) { + return; + } + let requests = self.app.state.take_deferred_client_requests(); + if requests.is_empty() { + return; + } + self.pending_client_deferred_requests + .push((client_id, requests)); + } + fn handle_deferred_requests_headless(&mut self) -> bool { let mut needs_render = false; + for (client_id, requests) in std::mem::take(&mut self.pending_client_deferred_requests) { + if !self.clients.contains_key(&client_id) { + continue; + } + self.focus_client_view(client_id); + self.app.state.restore_deferred_client_requests(requests); + needs_render |= self.drain_deferred_requests_headless(); + } + needs_render |= self.drain_deferred_requests_headless(); + needs_render + } + + /// Runs the deferred requests currently set on the shared state. Requests raised + /// by client input arrive here with that client's view loaded; requests raised by + /// the socket API or by timers run against the loaded view, as they always have. + fn drain_deferred_requests_headless(&mut self) -> bool { + let mut needs_render = false; if self.app.state.request_complete_onboarding { self.app.state.request_complete_onboarding = false; @@ -1207,8 +1253,44 @@ impl HeadlessServer { fn store_client_view(&mut self, client_id: u64) { if let Some(client) = self.clients.get_mut(&client_id) { - client.view = Some(self.app.state.snapshot_client_view()); + client.view = Some(self.app.snapshot_client_view()); + } + } + + /// Expires transients whose deadline is parked in a saved view. Each such client's + /// view is loaded first so the expiry clears its own state and not the owner's. + fn expire_due_client_view_transients(&mut self, now: Instant) -> bool { + let due_client_ids: Vec = self + .clients + .iter() + .filter(|(client_id, client)| { + client.is_full_app_client() + && self.client_view_owner != Some(**client_id) + && client + .view + .as_ref() + .is_some_and(|view| view.has_due_transient(now)) + }) + .map(|(client_id, _)| *client_id) + .collect(); + + let mut changed = false; + for client_id in due_client_ids { + self.focus_client_view(client_id); + changed |= self.app.expire_due_view_transients(now); } + changed + } + + /// The earliest transient deadline held in a saved view, so the loop still wakes + /// on time for a client whose view is not the loaded one. + fn earliest_saved_client_view_deadline(&self) -> Option { + self.clients + .iter() + .filter(|(client_id, _)| self.client_view_owner != Some(**client_id)) + .filter_map(|(_, client)| client.view.as_ref()) + .filter_map(ClientView::next_transient_deadline) + .min() } /// Load `client_id`'s saved view into the shared `AppState` scratch slot so the @@ -1229,8 +1311,8 @@ impl HeadlessServer { return; }; match &client.view { - Some(view) => self.app.state.restore_client_view(view), - None => client.view = Some(self.app.state.snapshot_client_view()), + Some(view) => self.app.restore_client_view(view), + None => client.view = Some(self.app.snapshot_client_view()), } self.client_view_owner = Some(client_id); } @@ -1247,12 +1329,47 @@ impl HeadlessServer { } } + /// Whether the workspace tree changed since the last reconcile, recording the + /// current ids either way. The first call only records, so a client attaching + /// does not read the initial recording as a change. + fn workspace_tree_changed_since_last_reconcile(&mut self) -> bool { + let first_reconcile = self.reconciled_workspace_ids.is_none(); + let changed = match &self.reconciled_workspace_ids { + Some(reconciled_ids) => { + reconciled_ids.len() != self.app.state.workspaces.len() + || reconciled_ids + .iter() + .zip(&self.app.state.workspaces) + .any(|(reconciled_id, workspace)| reconciled_id != &workspace.id) + } + None => true, + }; + if changed { + self.reconciled_workspace_ids = Some( + self.app + .state + .workspaces + .iter() + .map(|workspace| workspace.id.clone()) + .collect(), + ); + } + changed && !first_reconcile + } + fn reconcile_client_views_with_workspaces(&mut self) { + let workspace_tree_changed = self.workspace_tree_changed_since_last_reconcile(); + if workspace_tree_changed { + self.app.state.cancel_workspace_index_state(); + } let default_workspace_id = self.app.state.workspaces.first().map(|ws| ws.id.clone()); for client in self.clients.values_mut() { let Some(view) = client.view.as_mut() else { continue; }; + if workspace_tree_changed { + view.cancel_workspace_index_state(); + } if view .selected_workspace_id .as_ref() @@ -1539,6 +1656,7 @@ impl HeadlessServer { vec![crate::raw_input::RawInputEvent::Paste(path)], self.foreground_client_id == Some(client_id), ); + self.harvest_deferred_requests_from_client(client_id); true } @@ -2523,6 +2641,7 @@ impl HeadlessServer { let theme_changed = self.update_client_host_theme_from_events(client_id, &events); self.app .route_client_events(events, self.foreground_client_id == Some(client_id)); + self.harvest_deferred_requests_from_client(client_id); if self.app.take_config_reloaded_from_disk() { self.reload_server_config(false); } else { @@ -3152,32 +3271,50 @@ impl HeadlessServer { changed } + /// Streams each client the capture mode its own view asks for. Capture depends on + /// the client's mode and active workspace, so one value broadcast from whichever + /// view is loaded would leave clients on other workspaces without mouse events. fn stream_host_mouse_capture_mode(&mut self) { - let enabled = self + let loaded_view_enabled = self .app .state .should_capture_host_mouse_from(&self.app.terminal_runtimes); - let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) - { - Ok(framed) => framed, - Err(err) => { - warn!(err = %err, "failed to serialize mouse capture mode for clients"); - return; - } - }; + let client_capture_modes: Vec<(u64, bool)> = self + .clients + .iter() + .filter(|(_, client)| client.is_full_app_client()) + .map(|(&client_id, client)| { + let enabled = match &client.view { + Some(view) if self.client_view_owner != Some(client_id) => self + .app + .state + .should_capture_host_mouse_in_view(&self.app.terminal_runtimes, view), + _ => loaded_view_enabled, + }; + (client_id, enabled) + }) + .collect(); let mut broken_clients: Vec = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_full_app_client() { + for (client_id, enabled) in client_capture_modes { + let Some(client) = self.clients.get_mut(&client_id) else { continue; - } + }; if client.host_mouse_capture_active == Some(enabled) { continue; } let Some(writer) = &client.writer else { continue; }; - if writer.control.send(serialized.clone()).is_err() { + let serialized = + match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize mouse capture mode for client"); + continue; + } + }; + if writer.control.send(serialized).is_err() { debug!( client_id, "client writer channel closed during mouse capture update" @@ -3742,15 +3879,8 @@ impl HeadlessServer { } } - if self - .app - .copy_feedback_deadline - .is_some_and(|deadline| now >= deadline) - { - self.app.copy_feedback_deadline = None; - self.app.state.copy_feedback = None; - changed = true; - } + changed |= self.expire_due_client_view_transients(now); + changed |= self.app.expire_due_view_transients(now); if self .app @@ -3766,17 +3896,6 @@ impl HeadlessServer { changed = true; } - if self - .app - .selection_autoscroll_deadline - .is_some_and(|deadline| now >= deadline) - { - self.app.tick_selection_autoscroll(now); - changed = true; - } - - changed |= self.app.clear_due_selection_highlight(now); - if self.has_app_client() { self.app.start_git_status_refresh_if_due(now); } @@ -4303,6 +4422,8 @@ mod tests { next_client_id: 1, foreground_client_id: None, client_view_owner: None, + pending_client_deferred_requests: Vec::new(), + reconciled_workspace_ids: None, server_keybindings, server_config_diagnostic: None, server_config_diagnostic_without_keybindings: None, diff --git a/src/server/headless/tests/client_view.rs b/src/server/headless/tests/client_view.rs index 82f41ca770..b320ef59ca 100644 --- a/src/server/headless/tests/client_view.rs +++ b/src/server/headless/tests/client_view.rs @@ -1,4 +1,7 @@ -use super::test_headless_server; +use std::time::{Duration, Instant}; + +use super::{shutdown_test_runtimes, test_client_writer, test_headless_server}; +use crate::app::state::{ContextMenuKind, ContextMenuState, CopyFeedback, MenuListState}; use crate::app::Mode; use crate::protocol::RenderEncoding; use crate::server::client_transport::ServerEvent; @@ -45,6 +48,20 @@ fn insert_test_terminal_attach_client( ); } +fn attach_test_writer( + server: &mut HeadlessServer, + client_id: u64, +) -> ( + std::sync::mpsc::Receiver>, + std::sync::mpsc::Receiver>, +) { + let (writer, control_rx, render_rx) = test_client_writer(); + if let Some(client) = server.clients.get_mut(&client_id) { + client.writer = Some(writer); + } + (control_rx, render_rx) +} + fn server_with_workspaces_and_clients(workspace_names: &[&str]) -> HeadlessServer { let mut server = test_headless_server(); for name in workspace_names { @@ -295,6 +312,118 @@ fn single_client_focus_adopts_view_and_takes_ownership() { ); } +#[tokio::test] +async fn deferred_workspace_create_lands_on_the_requesting_client_only() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + let second_workspace_id = server.app.state.workspaces[1].id.clone(); + + server.focus_client_view(2); + server.app.state.switch_workspace(1); + server.focus_client_view(1); + + server.app.state.request_new_workspace = true; + server.harvest_deferred_requests_from_client(1); + assert!(!server.app.state.request_new_workspace); + + server.focus_client_view(2); + assert!(server.handle_deferred_requests_headless()); + + server.focus_client_view(2); + let created_workspace_id = server.app.state.workspaces.last().map(|ws| ws.id.clone()); + assert_ne!(created_workspace_id, Some(second_workspace_id.clone())); + assert_eq!( + server.clients[&1] + .view + .as_ref() + .and_then(|view| view.active_workspace_id.clone()), + created_workspace_id + ); + assert_eq!( + server.clients[&2] + .view + .as_ref() + .and_then(|view| view.active_workspace_id.clone()), + Some(second_workspace_id) + ); + + shutdown_test_runtimes(&mut server); +} + +#[test] +fn workspace_tree_change_cancels_index_bearing_state_in_saved_views() { + let mut server = server_with_workspaces_and_clients(&["one", "two", "three"]); + + server.focus_client_view(2); + server.focus_client_view(1); + server.app.state.mode = Mode::ContextMenu; + server.app.state.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Workspace { ws_idx: 2 }, + x: 1, + y: 1, + list: MenuListState::new(0), + }); + + server.focus_client_view(2); + server.app.state.workspaces.remove(0); + server.app.state.active = Some(0); + server.app.state.selected = 0; + + server.focus_client_view(1); + + assert!(server.app.state.context_menu.is_none()); + assert_eq!(server.app.state.mode, Mode::Terminal); +} + +#[tokio::test] +async fn copy_feedback_expires_against_the_client_that_raised_it() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + let now = Instant::now(); + + server.focus_client_view(2); + server.focus_client_view(1); + server.app.state.copy_feedback = Some(CopyFeedback { + message: "copied".to_owned(), + }); + server.app.copy_feedback_deadline = Some(now + Duration::from_millis(1)); + + server.focus_client_view(2); + assert!(server.app.state.copy_feedback.is_none()); + assert!(server.app.copy_feedback_deadline.is_none()); + + server.handle_scheduled_tasks_headless(now + Duration::from_millis(2), false); + + server.focus_client_view(1); + assert!(server.app.state.copy_feedback.is_none()); + assert!(server.app.copy_feedback_deadline.is_none()); +} + +#[tokio::test] +async fn host_mouse_capture_follows_each_clients_own_view() { + let mut server = server_with_workspaces_and_clients(&["one", "two"]); + server.app.state.mouse_capture = false; + let reporting_pane = server.app.state.workspaces[1] + .focused_pane_id() + .expect("workspace must have a focused pane"); + server.app.state.workspaces[1].insert_test_runtime( + reporting_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"\x1b[?1000h"), + ); + let _writers = [ + attach_test_writer(&mut server, 1), + attach_test_writer(&mut server, 2), + ]; + + server.focus_client_view(1); + server.focus_client_view(2); + server.app.state.switch_workspace(1); + server.focus_client_view(1); + + server.stream_host_mouse_capture_mode(); + + assert_eq!(server.clients[&1].host_mouse_capture_active, Some(false)); + assert_eq!(server.clients[&2].host_mouse_capture_active, Some(true)); +} + #[test] fn removing_last_client_clears_view_owner_and_foreground() { let mut server = server_with_workspaces_and_clients(&["one"]);