diff --git a/src/app/client_view.rs b/src/app/client_view.rs new file mode 100644 index 0000000000..f994acce3c --- /dev/null +++ b/src/app/client_view.rs @@ -0,0 +1,257 @@ +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, +}; +use super::App; +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, + 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)) + .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) + } + + /// 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 + .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(), + copy_feedback_deadline: None, + selection_autoscroll_deadline: None, + selection_highlight_clear_deadline: None, + } + } + + 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(); + } +} + +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/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/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 1a0c77a3ea..dc68a65682 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,8 +9,10 @@ mod agent_resume; mod agents; mod api; 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 be858678da..526dc7e7ac 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, } @@ -1545,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/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..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::{ @@ -205,6 +207,14 @@ 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, + /// 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. @@ -401,6 +411,9 @@ impl HeadlessServer { #[cfg(unix)] 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, @@ -541,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 { @@ -606,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; @@ -1202,6 +1251,158 @@ 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.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 + /// 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.restore_client_view(view), + None => client.view = Some(self.app.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); + } + } + + /// 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() + .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 +1420,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 +1442,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 +1644,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(); @@ -1448,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 } @@ -2419,6 +2628,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) @@ -2431,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 { @@ -2520,6 +2731,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 +2914,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 @@ -3058,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" @@ -3120,10 +3351,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 +3360,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 +3574,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(); @@ -3645,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 @@ -3669,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); } @@ -4153,6 +4369,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 +4421,9 @@ mod tests { #[cfg(unix)] 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 new file mode 100644 index 0000000000..b320ef59ca --- /dev/null +++ b/src/server/headless/tests/client_view.rs @@ -0,0 +1,439 @@ +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; +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 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 { + 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()) + ); +} + +#[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"]); + 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); +}