diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/actions.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/actions.rs')
| -rw-r--r-- | src/client/shell/actions.rs | 1051 |
1 files changed, 1051 insertions, 0 deletions
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs new file mode 100644 index 00000000..efc9aae9 --- /dev/null +++ b/src/client/shell/actions.rs @@ -0,0 +1,1051 @@ +use super::*; + +impl ClientShellState { + pub(super) fn record_binding( + &mut self, + binding: crate::input::KeybindMatch, + outcome: &mut ClientShellInput, + ) { + match binding { + crate::input::KeybindMatch::Action(crate::input::KeybindAction::Detach) => { + outcome.detach = true; + } + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ToggleSidebar) => { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } + crate::input::KeybindMatch::Action(action) => { + if matches!( + action, + crate::input::KeybindAction::NewWorktree + | crate::input::KeybindAction::OpenWorktree + | crate::input::KeybindAction::RemoveWorktree + ) { + self.begin_worktree_action(action, outcome); + return; + } + if action == crate::input::KeybindAction::OpenNavigator { + self.open_navigator_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Help { + self.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: String::new(), + search_focused: false, + scroll: 0, + })); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::Settings { + self.open_settings_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::OpenNotificationTarget { + self.focus_visible_notification(outcome); + return; + } + if action == crate::input::KeybindAction::ReloadConfig { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + PendingEndpointKind::ReloadConfig, + outcome, + ); + self.reload_client_config(); + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + return; + } + if action == crate::input::KeybindAction::NewWorkspace { + if self.config.prompt_new_workspace_name { + self.open_new_workspace_overlay(); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id: self.workspace_action_id(), + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameWorkspace { + self.open_rename_workspace_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CloseWorkspace { + if let Some(workspace_id) = self.workspace_action_id() { + if self.config.confirm_close { + self.open_confirm_close_overlay(workspace_id); + } else { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group: true, + }, + ), + outcome, + ); + } + } + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name + { + self.open_new_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenameTab { + self.open_rename_tab_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::RenamePane { + self.open_rename_pane_overlay(); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::WorkspacePicker { + self.mobile_switcher_scroll = 0; + self.reveal_mobile_workspace = false; + self.mode = ClientShellMode::Navigate; + self.navigate_workspace_id = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_workspace_id.clone()); + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::EnterResizeMode { + self.mode = ClientShellMode::Resize; + outcome.repaint = true; + return; + } + if action == crate::input::KeybindAction::CopyMode { + if self.enter_copy_mode(outcome) { + outcome.repaint = true; + } + return; + } + if let Some(method) = self.endpoint_method_for_action(action) { + self.push_endpoint_method(method, outcome); + return; + } + outcome.actions.push(ClientShellAction::Keybind(action)); + } + crate::input::KeybindMatch::Command(command) => { + let action = command.action.into(); + let resolved_labels = command.bindings.labels(); + let command_id = self.snapshot.as_deref().and_then(|snapshot| { + if let Some(candidate) = snapshot.commands.iter().find(|candidate| { + candidate.command_id == command.command && candidate.action == action + }) { + return Some(candidate.command_id.clone()); + } + let mut candidates = snapshot.commands.iter().filter(|candidate| { + candidate.action == action + && !resolved_labels.is_empty() + && resolved_labels + .iter() + .all(|label| candidate.binding_labels.contains(label)) + }); + let candidate = candidates.next()?; + candidates + .next() + .is_none() + .then(|| candidate.command_id.clone()) + }); + let Some(command_id) = command_id else { + self.endpoint_error = Some( + "custom command is not available on this endpoint; reload configuration" + .to_owned(), + ); + outcome.repaint = true; + return; + }; + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let selection = (action == crate::protocol::ClientShellCommandAction::PluginAction) + .then(|| { + let selection = self.selection.as_ref()?; + if !selection.is_visible() { + return None; + } + if snapshot.focused_pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return None; + } + let content_revision = self + .pane_surface + .as_ref()? + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id)? + .content_revision; + let (anchor, cursor) = selection.ordered_cells(); + Some(crate::api::schema::PaneSelectionReadParams { + pane_id: selection.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision: Some(content_revision), + }) + }) + .flatten(); + let params = crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: snapshot.focused_workspace_id.clone(), + tab_id: snapshot.focused_tab_id.clone(), + pane_id: snapshot.focused_pane_id.clone(), + selection, + }; + if action == crate::protocol::ClientShellCommandAction::Popup { + self.popup_pending = true; + self.popup_pending_deadline = None; + self.push_endpoint_method_with_kind( + crate::api::schema::Method::CommandInvoke(params), + PendingEndpointKind::PopupCommand, + outcome, + ); + } else { + self.push_endpoint_method( + crate::api::schema::Method::CommandInvoke(params), + outcome, + ); + } + } + } + } + + pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) { + self.request_selection_copy_with_fallback(outcome, None); + } + + pub(super) fn request_selection_copy_with_fallback( + &mut self, + outcome: &mut ClientShellInput, + fallback_key: Option<crate::input::TerminalKey>, + ) { + let Some(selection) = self.selection.as_ref() else { + return; + }; + let pane_id = selection.pane_id.clone(); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) + .map(|pane| pane.content_revision); + let (anchor, cursor) = selection.ordered_cells(); + let fallback = fallback_key.and_then(|key| { + let press = ClientPaneInputEvent::from_terminal_key(key.clone())?; + let tracks_release = matches!( + &press, + ClientPaneInputEvent::Key { + tracks_release: true, + .. + } + ); + let mut message = + super::target_event_message(ClientInputTarget::Pane(pane_id.clone()), press); + if tracks_release { + let release = ClientPaneInputEvent::from_terminal_key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )?; + if let ClientMessage::ClientShellPaneInput { events, .. } = &mut message { + events.push(release); + } + } + Some(message) + }); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { + row: anchor.0, + col: anchor.1, + }, + cursor: crate::api::schema::PaneTextPoint { + row: cursor.0, + col: cursor.1, + }, + content_revision, + }, + ), + PendingEndpointKind::SelectionCopy { fallback }, + outcome, + ); + } + + pub(super) fn request_word_selection( + &mut self, + hit: &PaneHit, + viewport_row: u16, + col: u16, + outcome: &mut ClientShellInput, + ) { + let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, hit.scroll); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.word_selection_generation = self.word_selection_generation.saturating_add(1); + let generation = self.word_selection_generation; + self.pending_word_selection = Some(generation); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { + pane_id: hit.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: 0, + }, + cursor: crate::api::schema::PaneTextPoint { + row: absolute_row, + col: hit.inner_rect.width.saturating_sub(1), + }, + content_revision, + }, + ), + PendingEndpointKind::WordSelection { + pane_id: hit.pane_id.clone(), + absolute_row, + col, + generation, + }, + outcome, + ); + } + + pub(super) fn push_endpoint_method( + &mut self, + method: crate::api::schema::Method, + outcome: &mut ClientShellInput, + ) { + self.push_endpoint_method_with_kind(method, PendingEndpointKind::Generic, outcome); + } + + pub(super) fn push_endpoint_method_with_kind( + &mut self, + method: crate::api::schema::Method, + kind: PendingEndpointKind, + outcome: &mut ClientShellInput, + ) { + let Some(snapshot) = self.snapshot.as_deref() else { + return; + }; + let confirmation_workspace_id = match &method { + crate::api::schema::Method::TabClose(target) => snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == target.tab_id) + .map(|tab| tab.workspace_id.clone()), + crate::api::schema::Method::PaneClose(target) => snapshot + .panes + .iter() + .find(|pane| pane.pane_id == target.pane_id) + .map(|pane| pane.workspace_id.clone()), + _ => None, + }; + let request_id = self.next_request_id; + self.next_request_id = self.next_request_id.saturating_add(1); + let request_id = format!("client-shell:{request_id}"); + self.pending_requests.insert( + request_id.clone(), + PendingEndpointRequest { + boot_id: snapshot.boot_id.clone(), + confirmation_workspace_id, + kind, + }, + ); + outcome.actions.push(ClientShellAction::Endpoint { + boot_id: snapshot.boot_id.clone(), + request: Box::new(crate::api::schema::Request { + id: request_id, + method, + }), + }); + } + + pub(crate) fn receive_endpoint_error(&mut self, message: String) -> bool { + if self.endpoint_error.as_deref() == Some(message.as_str()) { + return false; + } + self.endpoint_error = Some(message); + true + } + + pub(crate) fn handle_endpoint_result( + &mut self, + boot_id: &str, + request_id: &str, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + let Some(pending) = self.pending_requests.remove(request_id) else { + return (false, Vec::new()); + }; + if pending.boot_id != boot_id + || self + .snapshot + .as_deref() + .is_none_or(|snapshot| snapshot.boot_id != boot_id) + { + return (false, Vec::new()); + } + match pending.kind { + PendingEndpointKind::Generic => {} + PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + let key = (version.clone(), id.clone()); + if self.dismissed_product_announcement.as_ref() == Some(&key) { + self.dismissed_product_announcement = None; + } + if self.overlay.is_none() { + if let Some(announcement) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.product_announcement.as_ref()) + .filter(|announcement| { + announcement.version == version && announcement.id == id + }) + { + self.overlay = Some(ClientShellOverlay::ProductAnnouncement( + product_announcement_state(announcement), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::ReleaseNotesDismiss => { + return match result { + Ok(_) => (false, Vec::new()), + Err(error) => { + if self.overlay.is_none() { + if let Some(notes) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.as_ref()) + { + self.overlay = Some(ClientShellOverlay::ReleaseNotes( + release_notes_state(notes), + )); + } + } + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PopupCommand => { + return match result { + Ok(_) => { + self.popup_pending_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); + (false, Vec::new()) + } + Err(error) => { + self.popup_pending = false; + self.popup_pending_deadline = None; + self.endpoint_error = Some(error.message); + (true, Vec::new()) + } + }; + } + PendingEndpointKind::PaneScroll { pane_id, serial } => { + let mut outcome = ClientShellInput::default(); + let repaint = self.complete_pane_scroll(pane_id, serial, result, &mut outcome); + return (repaint, outcome.actions); + } + PendingEndpointKind::SelectionCopy { fallback } => { + let fallback = || { + fallback + .map(ClientShellAction::Request) + .into_iter() + .collect::<Vec<_>>() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { text, .. }) + if !text.is_empty() => + { + let repaint = self.show_copy_feedback(std::time::Instant::now()); + ( + repaint, + vec![ClientShellAction::ClipboardWrite(text.into_bytes())], + ) + } + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + (false, fallback()) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected selection result".to_owned()); + (true, fallback()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, fallback()) + } + }; + } + PendingEndpointKind::WordSelection { + pane_id, + absolute_row, + col, + generation, + } => { + if self.pending_word_selection != Some(generation) + || self.snapshot.as_deref().is_none_or(|snapshot| { + !snapshot.panes.iter().any(|pane| pane.pane_id == pane_id) + }) + { + return (false, Vec::new()); + } + self.pending_word_selection = None; + let row_text = match result { + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: returned_pane_id, + text, + }) if returned_pane_id == pane_id => text, + Ok(crate::api::schema::ResponseResult::PaneSelection { .. }) => { + return (false, Vec::new()) + } + Ok(_) => { + self.endpoint_error = Some( + "endpoint returned an unexpected word-selection result".to_owned(), + ); + return (true, Vec::new()); + } + Err(error) => { + self.endpoint_error = Some(error.message); + return (true, Vec::new()); + } + }; + let Some((start_col, end_col)) = + crate::app::actions::word_bounds_at_column(&row_text, col) + else { + self.selection = None; + return (true, Vec::new()); + }; + let mut selection = crate::selection::Selection::absolute_range( + pane_id, + (absolute_row, start_col), + (absolute_row, end_col), + ); + if !selection.finish() { + return (false, Vec::new()); + } + self.selection = Some(selection); + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + if !self.config.copy_on_select { + return (true, Vec::new()); + } + self.selection_highlight_clear_deadline = + Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); + let mut outcome = ClientShellInput::default(); + self.request_selection_copy(&mut outcome); + return (true, outcome.actions); + } + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect, + fallback_events, + } => { + let completed_before_release = !fallback_events.iter().any(|event| { + event.kind + == crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) + }); + let replay = (self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == pane_id && hit.inner_rect == inner_rect)) + .then_some(fallback_events); + if replay.is_none() { + self.url_click_consumes_until_up = completed_before_release; + } + let replay_action = |events: Option<Vec<crossterm::event::MouseEvent>>| { + events + .map(ClientShellAction::ReplayMouse) + .into_iter() + .collect() + }; + return match result { + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + handled: true, + .. + }) => { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some(url), + handled: false, + }) if crate::app::actions::safe_web_url(&url).is_some() => { + self.url_click_consumes_until_up = completed_before_release; + (false, vec![ClientShellAction::OpenSafeWebUrl(url)]) + } + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { .. }) => { + (false, replay_action(replay)) + } + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected link result".to_owned()); + (true, replay_action(replay)) + } + Err(error) + if matches!( + error.code.as_deref(), + Some("stale_content" | "stale_target") + ) => + { + self.url_click_consumes_until_up = completed_before_release; + (false, Vec::new()) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, replay_action(replay)) + } + }; + } + PendingEndpointKind::CopyMotion { + pane_id, + origin, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { + pane_id: returned_pane_id, + cursor, + content_revision, + }) if returned_pane_id == pane_id => ( + self.apply_copy_motion_target( + &pane_id, + origin, + cursor, + content_revision, + &mut outcome, + ), + true, + ), + Ok(crate::api::schema::ResponseResult::PaneCopyMotion { .. }) => (false, false), + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected copy-motion result".to_owned()); + (true, false) + } + Err(error) => { + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::CopySearch { + pane_id, + origin, + query, + direction, + repeat, + generation, + session_generation, + } => { + let mut outcome = ClientShellInput::default(); + let (repaint, continue_queue) = match result { + Ok(crate::api::schema::ResponseResult::PaneCopySearch { + pane_id: returned_pane_id, + content_revision, + matches, + total, + current, + current_global, + }) if returned_pane_id == pane_id => { + let repaint = self.apply_copy_search_result( + &pane_id, + origin, + query, + direction, + repeat, + generation, + ClientCopySearchResult { + content_revision, + matches, + total, + current: current.and_then(|index| usize::try_from(index).ok()), + current_global, + }, + &mut outcome, + ); + if !repaint { + self.cancel_deferred_copy_after_search(generation); + } + (repaint, repaint) + } + Ok(crate::api::schema::ResponseResult::PaneCopySearch { .. }) => { + self.cancel_deferred_copy_after_search(generation); + (false, false) + } + Ok(_) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = + Some("endpoint returned an unexpected copy-search result".to_owned()); + (true, false) + } + Err(error) => { + self.cancel_deferred_copy_after_search(generation); + self.endpoint_error = Some(error.message); + (true, false) + } + }; + self.complete_copy_operation(session_generation, continue_queue, &mut outcome); + return (repaint || outcome.repaint, outcome.actions); + } + PendingEndpointKind::ReloadConfig => { + let repaint = match result { + Ok(crate::api::schema::ResponseResult::ConfigReload { .. }) => false, + Ok(_) => { + self.endpoint_error = + Some("endpoint returned an unexpected config reload result".to_owned()); + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + return (repaint, Vec::new()); + } + kind @ (PendingEndpointKind::IntegrationList + | PendingEndpointKind::IntegrationInstall) => { + return self.handle_settings_endpoint_result(kind, result); + } + kind => { + return ( + self.handle_worktree_endpoint_result(kind, result), + Vec::new(), + ); + } + } + let repaint = match result { + Ok(_) => false, + Err(error) + if self.config.confirm_close + && error.code.as_deref() == Some("confirmation_required") + && pending.confirmation_workspace_id.is_some() => + { + if let Some(workspace_id) = pending.confirmation_workspace_id { + self.open_confirm_close_overlay(workspace_id); + } + true + } + Err(error) => { + self.endpoint_error = Some(error.message); + true + } + }; + (repaint, Vec::new()) + } + + pub(super) fn endpoint_method_for_action( + &mut self, + action: crate::input::KeybindAction, + ) -> Option<crate::api::schema::Method> { + use crate::api::schema::{ + Method, PaneDirection, PaneFocusDirectionParams, PaneResizeParams, PaneSplitParams, + PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, SplitDirection, + TabCreateParams, TabMoveParams, TabTarget, WorkspaceTarget, + }; + use crate::input::KeybindAction; + + let snapshot = self.snapshot.as_deref()?; + let focused_workspace = snapshot.focused_workspace_id.clone()?; + let focused_tab = snapshot.focused_tab_id.clone(); + let focused_pane = snapshot.focused_pane_id.clone(); + let direction = |action| match action { + KeybindAction::FocusPaneLeft + | KeybindAction::SwapPaneLeft + | KeybindAction::ResizePaneLeft => Some(PaneDirection::Left), + KeybindAction::FocusPaneDown + | KeybindAction::SwapPaneDown + | KeybindAction::ResizePaneDown => Some(PaneDirection::Down), + KeybindAction::FocusPaneUp + | KeybindAction::SwapPaneUp + | KeybindAction::ResizePaneUp => Some(PaneDirection::Up), + KeybindAction::FocusPaneRight + | KeybindAction::SwapPaneRight + | KeybindAction::ResizePaneRight => Some(PaneDirection::Right), + _ => None, + }; + + match action { + KeybindAction::FocusAgent(index) => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + Some(Method::PaneFocus(PaneTarget { + pane_id: agents.get(index)?.clone(), + })) + } + KeybindAction::PreviousAgent | KeybindAction::NextAgent => { + let agents = super::agent_sidebar::ordered_agent_pane_ids( + snapshot, + self.config.agent_panel_sort, + ); + if agents.is_empty() { + return None; + } + let current = agents.iter().position(|pane_id| { + Some(pane_id.as_str()) == snapshot.focused_pane_id.as_deref() + }); + let next = match (current, action) { + (Some(current), KeybindAction::PreviousAgent) => { + (current + agents.len() - 1) % agents.len() + } + (Some(current), KeybindAction::NextAgent) => (current + 1) % agents.len(), + (None, KeybindAction::PreviousAgent) => agents.len() - 1, + (None, KeybindAction::NextAgent) => 0, + _ => unreachable!("relative agent action"), + }; + let pane_id = agents[next].clone(); + if !self + .hits + .agents + .iter() + .any(|(_, visible_pane_id)| visible_pane_id == &pane_id) + { + self.agent_scroll = next.min(self.hits.agent_max_scroll); + } + Some(Method::PaneFocus(PaneTarget { pane_id })) + } + KeybindAction::SwitchWorkspace(index) => { + let entries = self.navigation_workspace_entries(snapshot); + let workspace_id = snapshot + .workspaces + .get(entries.get(index)?.index)? + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::PreviousWorkspace | KeybindAction::NextWorkspace => { + let entries = self.navigation_workspace_entries(snapshot); + if entries.is_empty() { + return None; + } + let current = entries + .iter() + .position(|entry| { + snapshot.workspaces[entry.index].workspace_id == focused_workspace + }) + .unwrap_or(0); + let delta = if action == KeybindAction::PreviousWorkspace { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(entries.len() as isize) as usize; + let workspace_id = snapshot.workspaces[entries[next].index] + .workspace_id + .clone(); + self.reveal_workspace(&workspace_id); + Some(Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) + } + KeybindAction::SwitchTab(index) => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + Some(Method::TabFocus(TabTarget { + tab_id: tabs.get(index)?.tab_id.clone(), + })) + } + KeybindAction::PreviousTab | KeybindAction::NextTab => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + let focused_tab = focused_tab?; + let current = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let delta = if action == KeybindAction::PreviousTab { + -1 + } else { + 1 + }; + let next = (current as isize + delta).rem_euclid(tabs.len() as isize) as usize; + Some(Method::TabFocus(TabTarget { + tab_id: tabs[next].tab_id.clone(), + })) + } + KeybindAction::MoveTabPrevious | KeybindAction::MoveTabNext => { + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == focused_workspace) + .collect::<Vec<_>>(); + if tabs.len() <= 1 { + return None; + } + let focused_tab = focused_tab?; + let source = tabs.iter().position(|tab| tab.tab_id == focused_tab)?; + let insert_index = if action == KeybindAction::MoveTabNext { + if source + 1 >= tabs.len() { + 0 + } else { + source + 2 + } + } else if source == 0 { + tabs.len() + } else { + source - 1 + }; + Some(Method::TabMove(TabMoveParams { + tab_id: focused_tab, + insert_index, + })) + } + KeybindAction::NewTab if !self.config.prompt_new_tab_name => { + Some(Method::TabCreate(TabCreateParams { + workspace_id: Some(focused_workspace), + cwd: None, + focus: true, + label: None, + env: Default::default(), + })) + } + KeybindAction::FocusPaneLeft + | KeybindAction::FocusPaneDown + | KeybindAction::FocusPaneUp + | KeybindAction::FocusPaneRight => { + Some(Method::PaneFocusDirection(PaneFocusDirectionParams { + pane_id: focused_pane, + direction: direction(action)?, + })) + } + KeybindAction::SwapPaneLeft + | KeybindAction::SwapPaneDown + | KeybindAction::SwapPaneUp + | KeybindAction::SwapPaneRight => Some(Method::PaneSwap(PaneSwapParams { + pane_id: focused_pane, + direction: Some(direction(action)?), + source_pane_id: None, + target_pane_id: None, + })), + KeybindAction::SplitVertical | KeybindAction::SplitHorizontal => { + Some(Method::PaneSplit(PaneSplitParams { + workspace_id: Some(focused_workspace), + target_pane_id: focused_pane, + direction: if action == KeybindAction::SplitVertical { + SplitDirection::Right + } else { + SplitDirection::Down + }, + ratio: None, + cwd: None, + focus: true, + right_click: Default::default(), + env: Default::default(), + })) + } + KeybindAction::CloseTab => Some(Method::TabClose(TabTarget { + tab_id: focused_tab?, + })), + KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { + pane_id: focused_pane.clone()?, + })), + KeybindAction::CyclePaneNext | KeybindAction::CyclePanePrevious => { + let focused_tab = focused_tab?; + let panes = snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == focused_tab) + .collect::<Vec<_>>(); + if panes.is_empty() { + return None; + } + let focused_pane = focused_pane?; + let current = panes + .iter() + .position(|pane| pane.pane_id == focused_pane) + .unwrap_or(0); + let next = if action == KeybindAction::CyclePanePrevious { + (current + panes.len() - 1) % panes.len() + } else { + (current + 1) % panes.len() + }; + Some(Method::PaneFocus(PaneTarget { + pane_id: panes[next].pane_id.clone(), + })) + } + KeybindAction::LastPane => { + let pane_id = self.previous_pane_id.as_ref()?; + if Some(pane_id.as_str()) == focused_pane.as_deref() + || !snapshot.panes.iter().any(|pane| &pane.pane_id == pane_id) + { + return None; + } + Some(Method::PaneFocus(PaneTarget { + pane_id: pane_id.clone(), + })) + } + KeybindAction::Zoom => Some(Method::PaneZoom(PaneZoomParams { + pane_id: focused_pane, + mode: PaneZoomMode::Toggle, + })), + KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget { + pane_id: focused_pane?, + })), + KeybindAction::ResizePaneLeft + | KeybindAction::ResizePaneDown + | KeybindAction::ResizePaneUp + | KeybindAction::ResizePaneRight => Some(Method::PaneResize(PaneResizeParams { + pane_id: focused_pane, + direction: direction(action)?, + amount: None, + })), + _ => None, + } + } +} |
