From 65d6079819cc3147fb1225e96d06fab038762379 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] 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); +}