854 lines
30 KiB
Rust
854 lines
30 KiB
Rust
//! 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<InstanceId>,
|
|
action: ActionKind,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
action: ActionKind,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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::<TabCreateParams>(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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
action: WorkspaceAction,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
destination: SurfaceDestination,
|
|
action: WorkspaceAction,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
ensure_surface_available(action_kind, destination, ctx)?;
|
|
workspace_action(instance_id, action_kind, action, target, ctx)
|
|
}
|
|
|
|
fn surface_theme_picker_open(
|
|
instance_id: &Option<InstanceId>,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
reject_target_families(
|
|
ActionKind::TabActivate,
|
|
target.pane.is_some() || target.session.is_some(),
|
|
"pane or session selectors",
|
|
)?;
|
|
let mode = decode_params::<TabActivateParams>(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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
should_maximize: bool,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
target: &TargetSelector,
|
|
action: PaneGroupAction,
|
|
repetitions: u32,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
replace_buffer: bool,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
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<SettingsSection, ControlError> {
|
|
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<InstanceId>,
|
|
action_kind: ActionKind,
|
|
mode: PaletteMode,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
let query = decode_params::<QueryParams>(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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
let query = decode_params::<QueryParams>(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<InstanceId>,
|
|
params: &serde_json::Value,
|
|
target: &TargetSelector,
|
|
ctx: &mut ModelContext<LocalControlBridge>,
|
|
) -> Result<serde_json::Value, ControlError> {
|
|
let params = decode_params::<FileOpenParams>(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<ControlDirection, ControlError> {
|
|
Ok(decode_params::<DirectionParams>(params)?.direction)
|
|
}
|
|
fn text_param(params: &serde_json::Value) -> Result<String, ControlError> {
|
|
Ok(decode_params::<TextParams>(params)?.text)
|
|
}
|
|
fn invalid_params<T>(action: ActionKind) -> Result<T, ControlError> {
|
|
Err(ControlError::new(
|
|
ErrorCode::InvalidParams,
|
|
format!(
|
|
"{} received parameters with the wrong shape",
|
|
action.as_str()
|
|
),
|
|
))
|
|
}
|
|
|
|
fn pane_direction(direction: ControlDirection) -> Result<Direction, ControlError> {
|
|
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<Option<LineAndColumnArg>, 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,
|
|
}))
|
|
}
|