//! Safe app-state mutation and visible UI intent handlers for local-control actions. #[cfg(test)] #[path = "app_state_tests.rs"] mod tests; #[cfg(feature = "local_fs")] use std::path::PathBuf; use std::str::FromStr; use ::local_control::protocol::{ Direction as ControlDirection, DirectionParams, FileOpenParams, PageQueryParams, QueryParams, ResizeParams, TabActivateParams, TabActivationMode, TabCreateParams, TabTarget, TabType, TargetSelector, TextParams, }; use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId}; use serde_json::json; use warp_util::path::LineAndColumnArg; #[cfg(feature = "local_fs")] use warpui::SingletonEntity; use warpui::{AppContext, ModelContext, TypedActionView}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; use crate::local_control::handlers::ack; use crate::local_control::handlers::layout::{create_tab, resolve_shell}; use crate::local_control::handlers::metadata::{surface_unavailable_reason, SurfaceDestination}; use crate::local_control::resolver::{ activate_target, active_target_pane_group, decode_params, focus_explicit_pane_target, input_target_pane_id, reject_target_families, tab_index_from_target, target_pane_group, target_pane_id, target_session_pane_id, target_window_id_for_target, target_workspace, }; use crate::local_control::LocalControlBridge; use crate::palette::PaletteMode; use crate::pane_group::{ActivationReason, Direction, PaneGroupAction}; use crate::server::telemetry::PaletteSource; use crate::settings_view::SettingsSection; #[cfg(feature = "local_fs")] use crate::util::file::external_editor::EditorSettings; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::{resolve_file_target_to_open_in_warp, EditorLayout}; #[cfg(feature = "local_fs")] use crate::workspace::PaneViewLocator; use crate::workspace::{CommandSearchOptions, InitContent, WorkspaceAction}; const MAX_PANE_RESIZE_STEPS: u32 = 1_000; pub(crate) fn handle( instance_id: &Option, action: ActionKind, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { match action { ActionKind::AppFocus | ActionKind::WindowFocus => { focus_window(instance_id, action, target, ctx) } ActionKind::WindowCreate => window_create(instance_id, params, target, ctx), ActionKind::TabCreate => create_tab(instance_id, params, target, ctx), ActionKind::TabActivate => tab_activate(instance_id, params, target, ctx), ActionKind::TabMove => tab_move(instance_id, params, target, ctx), ActionKind::PaneSplit => pane_split(instance_id, params, target, ctx), ActionKind::PaneFocus | ActionKind::SessionActivate => { pane_focus(instance_id, action, target, ctx) } ActionKind::PaneNavigate => pane_direction_action(instance_id, action, params, target, ctx), ActionKind::PaneResize => pane_resize(instance_id, params, target, ctx), ActionKind::PaneMaximize => pane_maximize(instance_id, true, target, ctx), ActionKind::PaneUnmaximize => pane_maximize(instance_id, false, target, ctx), ActionKind::SessionPrevious => workspace_action( instance_id, action, WorkspaceAction::CyclePrevSession, target, ctx, ), ActionKind::SurfaceKeybindingsOpen => surface_workspace_action( instance_id, action, SurfaceDestination::Keybindings, WorkspaceAction::ShowSettingsPage(SettingsSection::Keybindings), target, ctx, ), ActionKind::SurfaceWarpDriveOpen => surface_workspace_action( instance_id, action, SurfaceDestination::WarpDrive, WorkspaceAction::OpenGalaxyDrive, target, ctx, ), ActionKind::SurfaceAgentManagementOpen => surface_workspace_action( instance_id, action, SurfaceDestination::AgentManagement, WorkspaceAction::OpenAgentManagementView, target, ctx, ), ActionKind::SessionNext => workspace_action( instance_id, action, WorkspaceAction::CycleNextSession, target, ctx, ), ActionKind::SessionReopenClosed => session_reopen_closed(instance_id, target, ctx), ActionKind::InputInsert => input_text(instance_id, action, params, target, false, ctx), ActionKind::InputReplace => input_text(instance_id, action, params, target, true, ctx), ActionKind::SurfaceSettingsOpen => surface_settings_open(instance_id, params, target, ctx), ActionKind::SurfaceCommandPaletteOpen => surface_palette_open( instance_id, action, PaletteMode::Command, params, target, ctx, ), ActionKind::SurfaceCommandSearchOpen => { surface_command_search_open(instance_id, params, target, ctx) } ActionKind::SurfaceThemePickerOpen => surface_theme_picker_open(instance_id, target, ctx), ActionKind::SurfaceWarpDriveToggle => workspace_action( instance_id, action, WorkspaceAction::ToggleGalaxyDrive, target, ctx, ), ActionKind::SurfaceResourceCenterToggle => workspace_action( instance_id, action, WorkspaceAction::ToggleResourceCenter, target, ctx, ), ActionKind::SurfaceAiAssistantToggle => workspace_action( instance_id, action, WorkspaceAction::ToggleAIAssistant, target, ctx, ), ActionKind::SurfaceCodeReviewOpen => surface_code_review_open(instance_id, target, ctx), ActionKind::SurfaceCodeReviewToggle | ActionKind::SurfaceRightPanelToggle => { workspace_action( instance_id, action, WorkspaceAction::ToggleRightPanel, target, ctx, ) } ActionKind::SurfaceProjectExplorerOpen => surface_workspace_action( instance_id, action, SurfaceDestination::ProjectExplorer, WorkspaceAction::OpenProjectExplorer, target, ctx, ), ActionKind::SurfaceGlobalSearchOpen => surface_workspace_action( instance_id, action, SurfaceDestination::GlobalSearch, WorkspaceAction::OpenGlobalSearch, target, ctx, ), ActionKind::SurfaceConversationListOpen => surface_workspace_action( instance_id, action, SurfaceDestination::ConversationList, WorkspaceAction::OpenConversationListView, target, ctx, ), ActionKind::SurfaceLeftPanelToggle => workspace_action( instance_id, action, WorkspaceAction::ToggleLeftPanel, target, ctx, ), ActionKind::SurfaceVerticalTabsOpen => surface_workspace_action( instance_id, action, SurfaceDestination::VerticalTabs, WorkspaceAction::OpenVerticalTabsPanel, target, ctx, ), ActionKind::SurfaceVerticalTabsToggle => workspace_action( instance_id, action, WorkspaceAction::ToggleVerticalTabsPanel, target, ctx, ), ActionKind::FileOpen => file_open(instance_id, params, target, ctx), _ => Err(ControlError::new( ErrorCode::UnsupportedAction, format!("{} is not a safe app-state handler action", action.as_str()), )), } } fn focus_window( instance_id: &Option, action: ActionKind, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { reject_target_families( action, target.tab.is_some() || target.pane.is_some() || target.session.is_some(), "tab, pane, or session selectors", )?; let window_id = target_window_id_for_target(ctx, target, action)?; ctx.windows().show_window_and_focus_app(window_id); Ok(ack(instance_id, action)) } fn window_create( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { reject_target_families( ActionKind::WindowCreate, target.window.is_some() || target.tab.is_some() || target.pane.is_some() || target.session.is_some(), "target selectors", )?; let params = decode_params::(params)?; match params.tab_type { None | Some(TabType::Terminal | TabType::Default) => {} Some(TabType::Agent | TabType::CloudAgent) => { return Err(ControlError::new( ErrorCode::UnsupportedAction, "window.create only supports terminal or default window types", )); } } match params.shell.as_deref() { Some(shell_name) => { let shell = resolve_shell(shell_name, ctx)?; ctx.dispatch_global_action("root_view:open_new_with_shell", Some(shell)); } None => ctx.dispatch_global_action("root_view:open_new", ()), } Ok(ack(instance_id, ActionKind::WindowCreate)) } fn workspace_action( instance_id: &Option, action_kind: ActionKind, action: WorkspaceAction, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let workspace = target_workspace(action_kind, target, ctx)?; activate_target(&workspace, action_kind, target, ctx)?; workspace.update(ctx, |workspace, ctx| { workspace.handle_action(&action, ctx); }); Ok(ack(instance_id, action_kind)) } fn surface_workspace_action( instance_id: &Option, action_kind: ActionKind, destination: SurfaceDestination, action: WorkspaceAction, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { ensure_surface_available(action_kind, destination, ctx)?; workspace_action(instance_id, action_kind, action, target, ctx) } fn surface_theme_picker_open( instance_id: &Option, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let action = ActionKind::SurfaceThemePickerOpen; ensure_surface_available(action, SurfaceDestination::ThemePicker, ctx)?; let workspace = target_workspace(action, target, ctx)?; activate_target(&workspace, action, target, ctx)?; workspace.update(ctx, |workspace, ctx| { if !workspace.is_theme_chooser_open() { workspace.handle_action(&WorkspaceAction::ShowThemeChooserForActiveTheme, ctx); } }); Ok(ack(instance_id, action)) } fn surface_code_review_open( instance_id: &Option, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let action = ActionKind::SurfaceCodeReviewOpen; ensure_surface_available(action, SurfaceDestination::CodeReview, ctx)?; let workspace = target_workspace(action, target, ctx)?; activate_target(&workspace, action, target, ctx)?; #[cfg(feature = "local_fs")] { let pane_group = target_pane_group(action, target, ctx)?; let pane_id = target_pane_id(action, target, &pane_group, ctx)?; let has_repository = pane_group.read(ctx, |pane_group, ctx| { pane_group .terminal_view_from_pane_id(pane_id, ctx) .is_some_and(|terminal| terminal.as_ref(ctx).current_repo_path().is_some()) }); if !has_repository { return Err(ControlError::new( ErrorCode::TargetStateConflict, "surface.code_review.open requires an active terminal in a repository", )); } workspace.update(ctx, |workspace, ctx| { workspace.handle_action( &WorkspaceAction::OpenCodeReviewPanel(PaneViewLocator { pane_group_id: pane_group.id(), pane_id, }), ctx, ); }); Ok(ack(instance_id, action)) } #[cfg(not(feature = "local_fs"))] Err(ControlError::new( ErrorCode::UnsupportedAction, "surface.code_review.open is unavailable without local filesystem support", )) } fn ensure_surface_available( action: ActionKind, destination: SurfaceDestination, ctx: &AppContext, ) -> Result<(), ControlError> { let Some(reason) = surface_unavailable_reason(destination, ctx) else { return Ok(()); }; Err(ControlError::new( ErrorCode::UnsupportedAction, format!("{} is unavailable: {reason}", action.as_str()), )) } fn session_reopen_closed( instance_id: &Option, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { reject_target_families( ActionKind::SessionReopenClosed, target.tab.is_some() || target.pane.is_some() || target.session.is_some(), "tab, pane, or session selectors", )?; let window_id = target_window_id_for_target(ctx, target, ActionKind::SessionReopenClosed)?; ctx.windows().show_window_and_focus_app(window_id); workspace_action( instance_id, ActionKind::SessionReopenClosed, WorkspaceAction::ReopenClosedSession, target, ctx, ) } fn tab_activate( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { reject_target_families( ActionKind::TabActivate, target.pane.is_some() || target.session.is_some(), "pane or session selectors", )?; let mode = decode_params::(params)?.mode; if !matches!(mode, TabActivationMode::Target) && !matches!(target.tab.as_ref(), None | Some(TabTarget::Active)) { return Err(ControlError::new( ErrorCode::InvalidSelector, "tab.activate navigation modes do not accept a concrete tab selector", )); } let workspace = target_workspace(ActionKind::TabActivate, target, ctx)?; workspace.update(ctx, |workspace, ctx| { let action = match mode { TabActivationMode::Target => { WorkspaceAction::ActivateTab(tab_index_from_target(target, workspace, ctx)?) } TabActivationMode::Previous => WorkspaceAction::ActivatePrevTab, TabActivationMode::Next => WorkspaceAction::ActivateNextTab, TabActivationMode::Last => WorkspaceAction::ActivateLastTab, }; workspace.handle_action(&action, ctx); Ok::<_, ControlError>(()) })?; Ok(ack(instance_id, ActionKind::TabActivate)) } fn tab_move( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { reject_target_families( ActionKind::TabMove, target.pane.is_some() || target.session.is_some(), "pane or session selectors", )?; let direction = direction_param(params)?; let workspace = target_workspace(ActionKind::TabMove, target, ctx)?; workspace.update(ctx, |workspace, ctx| { let index = tab_index_from_target(target, workspace, ctx)?; let action = match direction { ControlDirection::Left => WorkspaceAction::MoveTabLeft(index), ControlDirection::Right => WorkspaceAction::MoveTabRight(index), ControlDirection::Up | ControlDirection::Down | ControlDirection::Previous | ControlDirection::Next => { return Err(ControlError::new( ErrorCode::InvalidParams, "tab.move only accepts left or right", )); } }; workspace.handle_action(&action, ctx); Ok::<_, ControlError>(()) })?; Ok(ack(instance_id, ActionKind::TabMove)) } fn pane_direction_action( instance_id: &Option, action_kind: ActionKind, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let direction = direction_param(params)?; let action = match action_kind { ActionKind::PaneNavigate => match direction { ControlDirection::Left => PaneGroupAction::NavigateLeft, ControlDirection::Right => PaneGroupAction::NavigateRight, ControlDirection::Up => PaneGroupAction::NavigateUp, ControlDirection::Down => PaneGroupAction::NavigateDown, ControlDirection::Previous => PaneGroupAction::NavigatePrev, ControlDirection::Next => PaneGroupAction::NavigateNext, }, _ => return invalid_params(action_kind), }; pane_group_action(instance_id, action_kind, target, action, 1, ctx) } /// Splits the targeted pane and reports the created pane's opaque id so /// callers do not need to diff `pane.list` to find the new pane. fn pane_split( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let action_kind = ActionKind::PaneSplit; let direction = pane_direction(direction_param(params)?)?; let pane_group = active_target_pane_group(action_kind, target, ctx)?; focus_explicit_pane_target(action_kind, target, &pane_group, ctx)?; let panes_before = pane_group.read(ctx, |pane_group, _| pane_group.visible_pane_ids()); pane_group.update(ctx, |pane_group, ctx| { pane_group.handle_action(&PaneGroupAction::Add(direction), ctx); }); let created = pane_group .read(ctx, |pane_group, _| pane_group.visible_pane_ids()) .into_iter() .find(|pane_id| !panes_before.contains(pane_id)); let mut response = ack(instance_id, action_kind); if let Some(created) = created { response["pane"] = json!({ "id": created.to_string() }); } Ok(response) } fn pane_focus( instance_id: &Option, action_kind: ActionKind, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { if target.pane.is_none() && (action_kind != ActionKind::SessionActivate || target.session.is_none()) { return Err(ControlError::new( ErrorCode::InvalidSelector, format!("{} requires a pane or session target", action_kind.as_str()), )); } let pane_group = active_target_pane_group(action_kind, target, ctx)?; let pane_id = if action_kind == ActionKind::SessionActivate { target_session_pane_id(action_kind, target, &pane_group, ctx)? } else { target_pane_id(action_kind, target, &pane_group, ctx)? }; pane_group.update(ctx, |pane_group, ctx| { pane_group.handle_action( &PaneGroupAction::Activate(pane_id, ActivationReason::Click), ctx, ); }); Ok(ack(instance_id, action_kind)) } fn pane_resize( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let ResizeParams { direction, amount } = decode_params(params)?; let amount = amount.unwrap_or(1); if amount > MAX_PANE_RESIZE_STEPS { return Err(ControlError::new( ErrorCode::InvalidParams, format!("pane.resize amount cannot exceed {MAX_PANE_RESIZE_STEPS}"), )); } let action = match direction { ControlDirection::Left => PaneGroupAction::ResizeLeft, ControlDirection::Right => PaneGroupAction::ResizeRight, ControlDirection::Up => PaneGroupAction::ResizeUp, ControlDirection::Down => PaneGroupAction::ResizeDown, ControlDirection::Previous | ControlDirection::Next => { return Err(ControlError::new( ErrorCode::InvalidParams, "pane.resize only accepts left, right, up, or down", )); } }; pane_group_action( instance_id, ActionKind::PaneResize, target, action, amount, ctx, ) } fn pane_maximize( instance_id: &Option, should_maximize: bool, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let action_kind = if should_maximize { ActionKind::PaneMaximize } else { ActionKind::PaneUnmaximize }; let pane_group = active_target_pane_group(action_kind, target, ctx)?; focus_explicit_pane_target(action_kind, target, &pane_group, ctx)?; let is_maximized = pane_group.read(ctx, |pane_group, ctx| { pane_group.is_focused_pane_maximized(ctx) }); if is_maximized != should_maximize { pane_group.update(ctx, |pane_group, ctx| { pane_group.handle_action(&PaneGroupAction::ToggleMaximizePane, ctx); }); } Ok(ack(instance_id, action_kind)) } fn pane_group_action( instance_id: &Option, action_kind: ActionKind, target: &TargetSelector, action: PaneGroupAction, repetitions: u32, ctx: &mut ModelContext, ) -> Result { let pane_group = active_target_pane_group(action_kind, target, ctx)?; focus_explicit_pane_target(action_kind, target, &pane_group, ctx)?; pane_group.update(ctx, |pane_group, ctx| { for _ in 0..repetitions { pane_group.handle_action(&action, ctx); } }); Ok(ack(instance_id, action_kind)) } fn input_text( instance_id: &Option, action_kind: ActionKind, params: &serde_json::Value, target: &TargetSelector, replace_buffer: bool, ctx: &mut ModelContext, ) -> Result { let text = text_param(params)?; validate_staged_input_text(action_kind, &text)?; let pane_group = target_pane_group(action_kind, target, ctx)?; let pane_id = input_target_pane_id(action_kind, target, &pane_group, ctx)?; let terminal_view = pane_group .read(ctx, |pane_group, ctx| { pane_group.terminal_view_from_pane_id(pane_id, ctx) }) .ok_or_else(|| { ControlError::new( ErrorCode::MissingTarget, format!("{} requires a terminal input target", action_kind.as_str()), ) })?; terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.input().update(ctx, |input, ctx| { if replace_buffer { input.replace_buffer_content(&text, ctx); } else { input.append_to_buffer(&text, ctx); } }); }); Ok(ack(instance_id, action_kind)) } pub(super) fn validate_staged_input_text( action: ActionKind, text: &str, ) -> Result<(), ControlError> { if text .chars() .any(|character| character == '\n' || character == '\r' || character.is_control()) { return Err(ControlError::new( ErrorCode::InvalidParams, format!( "{} rejects newlines, carriage returns, and control characters", action.as_str() ), )); } Ok(()) } fn surface_settings_open( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let PageQueryParams { page, query } = decode_params(params)?; let section = page.map(settings_section).transpose()?; let action = match (section, query) { (Some(section), Some(search_query)) => WorkspaceAction::ShowSettingsPageWithSearch { search_query, section: Some(section), }, (Some(section), None) => WorkspaceAction::ShowSettingsPage(section), (None, Some(search_query)) => WorkspaceAction::ShowSettingsPageWithSearch { search_query, section: None, }, (None, None) => WorkspaceAction::ShowSettings, }; workspace_action( instance_id, ActionKind::SurfaceSettingsOpen, action, target, ctx, ) } fn settings_section(page: String) -> Result { let section = SettingsSection::from_str(&page).map_err(|_| { ControlError::new( ErrorCode::InvalidParams, format!("surface.settings.open cannot resolve settings page {page:?}"), ) })?; if section == SettingsSection::WarpDrive { return Err(ControlError::new( ErrorCode::UnsupportedAction, "surface.settings.open does not open Warp Drive settings", )); } Ok(section) } fn surface_palette_open( instance_id: &Option, action_kind: ActionKind, mode: PaletteMode, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let query = decode_params::(params)?.query; workspace_action( instance_id, action_kind, WorkspaceAction::OpenPalette { mode, source: PaletteSource::Keybinding, query, }, target, ctx, ) } fn surface_command_search_open( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let query = decode_params::(params)?.query; let init_content = query .map(InitContent::Custom) .unwrap_or(InitContent::FromInputBuffer); workspace_action( instance_id, ActionKind::SurfaceCommandSearchOpen, WorkspaceAction::ShowCommandSearch(CommandSearchOptions { filter: None, init_content, }), target, ctx, ) } fn file_open( instance_id: &Option, params: &serde_json::Value, target: &TargetSelector, ctx: &mut ModelContext, ) -> Result { let params = decode_params::(params)?; if params.path.is_empty() { return Err(ControlError::new( ErrorCode::InvalidParams, "file.open requires a non-empty path", )); } let line_and_column = line_and_column(¶ms)?; let workspace = target_workspace(ActionKind::FileOpen, target, ctx)?; activate_target(&workspace, ActionKind::FileOpen, target, ctx)?; #[cfg(feature = "local_fs")] { let path = PathBuf::from(params.path); let layout = params.new_tab.then_some(EditorLayout::NewTab); let file_target = resolve_file_target_to_open_in_warp(&path, EditorSettings::as_ref(ctx), layout); workspace.update(ctx, |workspace, ctx| { workspace.open_file_with_target( path.clone(), file_target, line_and_column, CodeSource::Link { path, range_start: None, range_end: None, }, ctx, ); }); Ok(ack(instance_id, ActionKind::FileOpen)) } #[cfg(not(feature = "local_fs"))] Err(ControlError::new( ErrorCode::UnsupportedAction, "file.open is unavailable without local filesystem support", )) } fn direction_param(params: &serde_json::Value) -> Result { Ok(decode_params::(params)?.direction) } fn text_param(params: &serde_json::Value) -> Result { Ok(decode_params::(params)?.text) } fn invalid_params(action: ActionKind) -> Result { Err(ControlError::new( ErrorCode::InvalidParams, format!( "{} received parameters with the wrong shape", action.as_str() ), )) } fn pane_direction(direction: ControlDirection) -> Result { match direction { ControlDirection::Left => Ok(Direction::Left), ControlDirection::Right => Ok(Direction::Right), ControlDirection::Up => Ok(Direction::Up), ControlDirection::Down => Ok(Direction::Down), ControlDirection::Previous | ControlDirection::Next => Err(ControlError::new( ErrorCode::InvalidParams, "pane.split only accepts left, right, up, or down", )), } } fn line_and_column(params: &FileOpenParams) -> Result, ControlError> { let Some(line) = params.line else { if params.column.is_some() { return Err(ControlError::new( ErrorCode::InvalidParams, "file.open column requires a line", )); } return Ok(None); }; let line_num = usize::try_from(line).map_err(|err| { ControlError::with_details( ErrorCode::InvalidParams, "file.open line is out of range", err.to_string(), ) })?; let column_num = params .column .map(usize::try_from) .transpose() .map_err(|err| { ControlError::with_details( ErrorCode::InvalidParams, "file.open column is out of range", err.to_string(), ) })?; Ok(Some(LineAndColumnArg { line_num, column_num, })) }