first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
//! Bridge between protocol-level control requests and Warp application models.
|
||||
//!
|
||||
//! The bridge validates protocol version, selectors, credentials, and settings
|
||||
//! before routing each supported action to an app-side handler.
|
||||
|
||||
use ::local_control::auth::CredentialGrant;
|
||||
use ::local_control::{
|
||||
Action, ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope, ResponseEnvelope,
|
||||
};
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::local_control::handlers::{
|
||||
app_state, close, metadata, metadata_config, settings_surfaces,
|
||||
};
|
||||
use crate::local_control::permissions::{
|
||||
ensure_action_allowed, ensure_feature_enabled, ensure_protocol_version,
|
||||
};
|
||||
use crate::local_control::resolver::{validate_action_params, validate_action_target};
|
||||
|
||||
/// WarpUI model that executes already-authenticated local-control actions.
|
||||
pub struct LocalControlBridge {
|
||||
instance_id: Option<InstanceId>,
|
||||
}
|
||||
|
||||
impl Entity for LocalControlBridge {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for LocalControlBridge {}
|
||||
|
||||
impl LocalControlBridge {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self { instance_id: None }
|
||||
}
|
||||
|
||||
pub(super) fn set_instance_id(&mut self, instance_id: InstanceId) {
|
||||
self.instance_id = Some(instance_id);
|
||||
}
|
||||
|
||||
pub(super) fn handle_request(
|
||||
&mut self,
|
||||
request: RequestEnvelope,
|
||||
grant: CredentialGrant,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> ResponseEnvelope {
|
||||
if let Err(error) = ensure_feature_enabled() {
|
||||
return ResponseEnvelope::error(request.request_id, error);
|
||||
}
|
||||
if let Err(error) = ensure_protocol_version(request.protocol_version) {
|
||||
return ResponseEnvelope::error(request.request_id, error);
|
||||
}
|
||||
let Some(instance_id) = &self.instance_id else {
|
||||
return ResponseEnvelope::error(
|
||||
request.request_id,
|
||||
ControlError::new(
|
||||
ErrorCode::BridgeUnavailable,
|
||||
"local-control bridge has no active instance identity",
|
||||
),
|
||||
);
|
||||
};
|
||||
if let Err(error) = validate_request_authority(instance_id, &request.action, &grant) {
|
||||
return ResponseEnvelope::error(request.request_id, error);
|
||||
}
|
||||
if let Err(error) = ensure_action_allowed(request.action.kind, ctx) {
|
||||
return ResponseEnvelope::error(request.request_id, error);
|
||||
}
|
||||
if let Err(error) = validate_action_target(request.action.kind, &request.target) {
|
||||
return ResponseEnvelope::error(request.request_id, error);
|
||||
}
|
||||
let result = match request.action.kind {
|
||||
ActionKind::InstanceList => metadata::instance(&self.instance_id),
|
||||
ActionKind::InstanceInspect => metadata::inspect(&self.instance_id, ctx),
|
||||
ActionKind::AppPing => metadata::ping(&self.instance_id),
|
||||
ActionKind::AppVersion => metadata::version(&self.instance_id),
|
||||
ActionKind::AppActive => metadata::active(&self.instance_id, ctx),
|
||||
ActionKind::CapabilityList => Ok(metadata::capability_list()),
|
||||
ActionKind::CapabilityInspect => metadata::capability_inspect(&request.action),
|
||||
ActionKind::ActionList => Ok(metadata::action_list()),
|
||||
ActionKind::ActionInspect => metadata::action_inspect(&request.action),
|
||||
ActionKind::SurfaceList => metadata::surface_list(ctx),
|
||||
ActionKind::WindowList => metadata::window_list(&request.target, ctx),
|
||||
ActionKind::WindowInspect => metadata::window_inspect(&request.target, ctx),
|
||||
ActionKind::TabList => metadata::tab_list(&request.target, ctx),
|
||||
ActionKind::TabInspect => metadata::tab_inspect(&request.target, ctx),
|
||||
ActionKind::AppFocus
|
||||
| ActionKind::WindowCreate
|
||||
| ActionKind::WindowFocus
|
||||
| ActionKind::TabCreate
|
||||
| ActionKind::TabActivate
|
||||
| ActionKind::TabMove
|
||||
| ActionKind::PaneSplit
|
||||
| ActionKind::PaneFocus
|
||||
| ActionKind::PaneNavigate
|
||||
| ActionKind::PaneResize
|
||||
| ActionKind::PaneMaximize
|
||||
| ActionKind::PaneUnmaximize
|
||||
| ActionKind::SessionActivate
|
||||
| ActionKind::SessionPrevious
|
||||
| ActionKind::SessionNext
|
||||
| ActionKind::SessionReopenClosed
|
||||
| ActionKind::InputInsert
|
||||
| ActionKind::InputReplace
|
||||
| ActionKind::SurfaceSettingsOpen
|
||||
| ActionKind::SurfaceCommandPaletteOpen
|
||||
| ActionKind::SurfaceCommandSearchOpen
|
||||
| ActionKind::SurfaceThemePickerOpen
|
||||
| ActionKind::SurfaceKeybindingsOpen
|
||||
| ActionKind::SurfaceWarpDriveOpen
|
||||
| ActionKind::SurfaceWarpDriveToggle
|
||||
| ActionKind::SurfaceResourceCenterToggle
|
||||
| ActionKind::SurfaceAiAssistantToggle
|
||||
| ActionKind::SurfaceCodeReviewOpen
|
||||
| ActionKind::SurfaceCodeReviewToggle
|
||||
| ActionKind::SurfaceProjectExplorerOpen
|
||||
| ActionKind::SurfaceGlobalSearchOpen
|
||||
| ActionKind::SurfaceConversationListOpen
|
||||
| ActionKind::SurfaceLeftPanelToggle
|
||||
| ActionKind::SurfaceRightPanelToggle
|
||||
| ActionKind::SurfaceVerticalTabsOpen
|
||||
| ActionKind::SurfaceVerticalTabsToggle
|
||||
| ActionKind::SurfaceAgentManagementOpen
|
||||
| ActionKind::FileOpen => app_state::handle(
|
||||
&self.instance_id,
|
||||
request.action.kind,
|
||||
&request.action.params,
|
||||
&request.target,
|
||||
ctx,
|
||||
),
|
||||
ActionKind::TabRename => metadata_config::tab_rename(
|
||||
&self.instance_id,
|
||||
&request.target,
|
||||
&request.action,
|
||||
ctx,
|
||||
),
|
||||
ActionKind::TabResetName => {
|
||||
metadata_config::tab_reset_name(&self.instance_id, &request.target, ctx)
|
||||
}
|
||||
ActionKind::TabColorSet => metadata_config::tab_color_set(
|
||||
&self.instance_id,
|
||||
&request.target,
|
||||
&request.action,
|
||||
ctx,
|
||||
),
|
||||
ActionKind::TabColorClear => {
|
||||
metadata_config::tab_color_clear(&self.instance_id, &request.target, ctx)
|
||||
}
|
||||
ActionKind::PaneList => metadata::pane_list(&request.target, ctx),
|
||||
ActionKind::PaneInspect => metadata::pane_inspect(&request.target, ctx),
|
||||
ActionKind::PaneRename => metadata_config::pane_rename(
|
||||
&self.instance_id,
|
||||
&request.target,
|
||||
&request.action,
|
||||
ctx,
|
||||
),
|
||||
ActionKind::PaneResetName => {
|
||||
metadata_config::pane_reset_name(&self.instance_id, &request.target, ctx)
|
||||
}
|
||||
ActionKind::SessionList => metadata::session_list(&request.target, ctx),
|
||||
ActionKind::SessionInspect => metadata::session_inspect(&request.target, ctx),
|
||||
ActionKind::ThemeList => settings_surfaces::theme_list(ctx),
|
||||
ActionKind::ThemeGet => settings_surfaces::theme_get(ctx),
|
||||
ActionKind::ThemeSet
|
||||
| ActionKind::ThemeSystemSet
|
||||
| ActionKind::ThemeLightSet
|
||||
| ActionKind::ThemeDarkSet => metadata_config::theme_set(
|
||||
&self.instance_id,
|
||||
request.action.kind,
|
||||
&request.action,
|
||||
ctx,
|
||||
),
|
||||
ActionKind::AppearanceGet => settings_surfaces::appearance_get(ctx),
|
||||
ActionKind::AppearanceFontSizeIncrease
|
||||
| ActionKind::AppearanceFontSizeDecrease
|
||||
| ActionKind::AppearanceFontSizeReset
|
||||
| ActionKind::AppearanceZoomIncrease
|
||||
| ActionKind::AppearanceZoomDecrease
|
||||
| ActionKind::AppearanceZoomReset => {
|
||||
metadata_config::appearance_mutation(&self.instance_id, request.action.kind, ctx)
|
||||
}
|
||||
ActionKind::SettingList => settings_surfaces::setting_list(&request.action, ctx),
|
||||
ActionKind::SettingGet => settings_surfaces::setting_get(&request.action, ctx),
|
||||
ActionKind::SettingSet => metadata_config::setting_set(&request.action, ctx),
|
||||
ActionKind::SettingToggle => metadata_config::setting_toggle(&request.action, ctx),
|
||||
ActionKind::KeybindingList => settings_surfaces::keybinding_list(ctx),
|
||||
ActionKind::KeybindingGet => settings_surfaces::keybinding_get(&request.action, ctx),
|
||||
ActionKind::WindowClose => close::window_close(&self.instance_id, &request, ctx),
|
||||
ActionKind::TabClose => close::tab_close(&self.instance_id, &request, ctx),
|
||||
ActionKind::PaneClose => close::pane_close(&self.instance_id, &request, ctx),
|
||||
};
|
||||
match result {
|
||||
Ok(data) => ResponseEnvelope::ok(request.request_id, data),
|
||||
Err(error) => ResponseEnvelope::error(request.request_id, error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_request_authority(
|
||||
instance_id: &InstanceId,
|
||||
action: &Action,
|
||||
grant: &CredentialGrant,
|
||||
) -> Result<(), ControlError> {
|
||||
grant.verify_for_action(instance_id, action.kind)?;
|
||||
if !action.kind.is_implemented() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
format!(
|
||||
"{} is not implemented by this local-control bridge",
|
||||
action.kind.as_str()
|
||||
),
|
||||
));
|
||||
}
|
||||
validate_action_params(action)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! App-side action handlers invoked by the local-control bridge.
|
||||
use ::local_control::{ActionKind, InstanceId};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) mod app_state;
|
||||
pub(super) mod close;
|
||||
pub(super) mod layout;
|
||||
pub(super) mod metadata;
|
||||
pub(super) mod metadata_config;
|
||||
pub(super) mod settings_surfaces;
|
||||
|
||||
/// Standard acknowledgement payload shared by mutation handlers.
|
||||
pub(crate) fn ack(instance_id: &Option<InstanceId>, action: ActionKind) -> serde_json::Value {
|
||||
json!({
|
||||
"action": action.as_str(),
|
||||
"ok": true,
|
||||
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
//! 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::OpenWarpDrive,
|
||||
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::ToggleWarpDrive,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use ::local_control::{ActionKind, ErrorCode};
|
||||
|
||||
use super::{ensure_surface_available, validate_staged_input_text};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_control::handlers::metadata::SurfaceDestination;
|
||||
|
||||
#[test]
|
||||
fn staged_input_rejects_line_breaks_and_control_sequences() {
|
||||
assert!(validate_staged_input_text(ActionKind::InputInsert, "safe staged text").is_ok());
|
||||
|
||||
for text in ["line\nbreak", "line\rbreak", "tab\tbreak", "\u{1b}[31m"] {
|
||||
let error = validate_staged_input_text(ActionKind::InputInsert, text).err();
|
||||
assert!(error.is_some_and(|error| error.code == ErrorCode::InvalidParams));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_surface_open_returns_structured_error() {
|
||||
let flag_guard = FeatureFlag::AgentManagementView.override_enabled(false);
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let error = app
|
||||
.update(|ctx| {
|
||||
ensure_surface_available(
|
||||
ActionKind::SurfaceAgentManagementOpen,
|
||||
SurfaceDestination::AgentManagement,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.expect_err("disabled surface is rejected");
|
||||
assert_eq!(error.code, ErrorCode::UnsupportedAction);
|
||||
assert!(error.message.contains("surface.agent_management.open"));
|
||||
});
|
||||
drop(flag_guard);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Close handlers for local-control window, tab, and pane actions.
|
||||
use ::local_control::protocol::{TabCloseMode, TabCloseParams, TabTarget};
|
||||
use ::local_control::{Action, ActionKind, ControlError, ErrorCode, InstanceId, RequestEnvelope};
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::ModelContext;
|
||||
|
||||
use crate::local_control::handlers::ack;
|
||||
use crate::local_control::resolver::{
|
||||
reject_target_families, tab_index_from_target, target_pane_group, target_pane_id,
|
||||
target_window_id_for_target, target_workspace,
|
||||
};
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::workspace::view::OpenDialogSource;
|
||||
|
||||
fn tab_close_mode(action: &Action) -> Result<TabCloseMode, ControlError> {
|
||||
Ok(action.params_as::<TabCloseParams>()?.mode)
|
||||
}
|
||||
|
||||
fn validate_empty_params(action: &Action) -> Result<(), ControlError> {
|
||||
if action
|
||||
.params
|
||||
.as_object()
|
||||
.is_some_and(serde_json::Map::is_empty)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{} does not accept parameters", action.kind.as_str()),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn window_close(
|
||||
instance_id: &Option<InstanceId>,
|
||||
request: &RequestEnvelope,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
validate_empty_params(&request.action)?;
|
||||
reject_target_families(
|
||||
ActionKind::WindowClose,
|
||||
request.target.tab.is_some()
|
||||
|| request.target.pane.is_some()
|
||||
|| request.target.session.is_some(),
|
||||
"tab, pane, or session selectors",
|
||||
)?;
|
||||
let window_id = target_window_id_for_target(ctx, &request.target, ActionKind::WindowClose)?;
|
||||
ctx.windows()
|
||||
.close_window(window_id, TerminationMode::Cancellable);
|
||||
Ok(ack(instance_id, ActionKind::WindowClose))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_close(
|
||||
instance_id: &Option<InstanceId>,
|
||||
request: &RequestEnvelope,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::TabClose,
|
||||
request.target.pane.is_some() || request.target.session.is_some(),
|
||||
"pane or session selectors",
|
||||
)?;
|
||||
let mode = tab_close_mode(&request.action)?;
|
||||
let workspace = target_workspace(ActionKind::TabClose, &request.target, ctx)?;
|
||||
let closed = workspace.update(ctx, |workspace, ctx| {
|
||||
let selected_index = tab_index_from_target(&request.target, workspace, ctx)?;
|
||||
let tab_count = workspace.tab_count();
|
||||
let tab_indices: Vec<usize> = match mode {
|
||||
TabCloseMode::Target => vec![selected_index],
|
||||
TabCloseMode::Active => {
|
||||
if !matches!(request.target.tab.as_ref(), None | Some(TabTarget::Active)) {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
"tab.close active does not accept a concrete tab selector",
|
||||
));
|
||||
}
|
||||
vec![workspace.active_tab_index()]
|
||||
}
|
||||
TabCloseMode::Others => (0..tab_count)
|
||||
.filter(|index| *index != selected_index)
|
||||
.collect(),
|
||||
TabCloseMode::RightOf => ((selected_index + 1)..tab_count).collect(),
|
||||
};
|
||||
if tab_indices.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
let closed = workspace.close_tabs(
|
||||
tab_indices.into_iter(),
|
||||
OpenDialogSource::CloseTab {
|
||||
tab_index: selected_index,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
Ok(closed)
|
||||
})?;
|
||||
if closed {
|
||||
return Ok(ack(instance_id, ActionKind::TabClose));
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
"tab close was cancelled by an existing app warning",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn pane_close(
|
||||
instance_id: &Option<InstanceId>,
|
||||
request: &RequestEnvelope,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
validate_empty_params(&request.action)?;
|
||||
reject_target_families(
|
||||
ActionKind::PaneClose,
|
||||
request.target.session.is_some(),
|
||||
"session selectors",
|
||||
)?;
|
||||
let pane_group = target_pane_group(ActionKind::PaneClose, &request.target, ctx)?;
|
||||
let pane_id = target_pane_id(ActionKind::PaneClose, &request.target, &pane_group, ctx)?;
|
||||
pane_group.update(ctx, |pane_group, ctx| pane_group.close_pane(pane_id, ctx));
|
||||
Ok(ack(instance_id, ActionKind::PaneClose))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Layout mutation handlers for local-control actions.
|
||||
#[cfg(test)]
|
||||
#[path = "layout_tests.rs"]
|
||||
mod tests;
|
||||
use ::local_control::protocol::{TabCreateParams, TabType, TargetSelector};
|
||||
use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId};
|
||||
use serde::Serialize;
|
||||
#[cfg(feature = "local_tty")]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{ModelContext, TypedActionView};
|
||||
|
||||
use crate::local_control::resolver::{
|
||||
decode_params, target_window_id_for_target, validate_tab_create_target, workspace_for_window,
|
||||
};
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::server::telemetry::AddTabWithShellSource;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
#[cfg(feature = "local_tty")]
|
||||
use crate::terminal::available_shells::AvailableShells;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
#[derive(Serialize)]
|
||||
struct TabCreateResponse<'a> {
|
||||
action: &'static str,
|
||||
created: bool,
|
||||
instance_id: Option<&'a str>,
|
||||
window: TargetWindowResponse,
|
||||
tab: TabCountsResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TargetWindowResponse {
|
||||
selector: &'static str,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TabCountsResponse {
|
||||
id: String,
|
||||
previous_count: usize,
|
||||
count: usize,
|
||||
active_index: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn create_tab(
|
||||
instance_id: &Option<InstanceId>,
|
||||
params: &serde_json::Value,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
validate_tab_create_target(target)?;
|
||||
let window_id = target_window_id_for_target(ctx, target, ActionKind::TabCreate)?;
|
||||
let workspace = workspace_for_window(window_id, ActionKind::TabCreate, ctx)?;
|
||||
let action = tab_create_action(params, ctx)?;
|
||||
let (tab_id, previous_tab_count, tab_count, active_tab_index) =
|
||||
workspace.update(ctx, |workspace, ctx| {
|
||||
let previous_tab_count = workspace.tab_count();
|
||||
workspace.handle_action(&action, ctx);
|
||||
let tab_id = workspace
|
||||
.get_pane_group_view(workspace.active_tab_index())
|
||||
.map(|tab| tab.id().to_string())
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::Internal,
|
||||
"tab.create did not produce an active tab identifier",
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
tab_id,
|
||||
previous_tab_count,
|
||||
workspace.tab_count(),
|
||||
workspace.active_tab_index(),
|
||||
))
|
||||
})?;
|
||||
serde_json::to_value(TabCreateResponse {
|
||||
action: ActionKind::TabCreate.as_str(),
|
||||
created: true,
|
||||
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
window: TargetWindowResponse {
|
||||
selector: "target",
|
||||
id: window_id.to_string(),
|
||||
},
|
||||
tab: TabCountsResponse {
|
||||
id: tab_id,
|
||||
previous_count: previous_tab_count,
|
||||
count: tab_count,
|
||||
active_index: active_tab_index,
|
||||
},
|
||||
})
|
||||
.map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to serialize local-control tab.create response",
|
||||
err.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn tab_create_action(
|
||||
params: &serde_json::Value,
|
||||
ctx: &ModelContext<LocalControlBridge>,
|
||||
) -> Result<WorkspaceAction, ControlError> {
|
||||
let params = decode_params::<TabCreateParams>(params)?;
|
||||
if let Some(shell_name) = params.shell.as_deref() {
|
||||
if matches!(params.tab_type, Some(TabType::Agent | TabType::CloudAgent)) {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"tab.create cannot combine an agent tab type with a shell",
|
||||
));
|
||||
}
|
||||
return Ok(WorkspaceAction::AddTabWithShell {
|
||||
shell: resolve_shell(shell_name, ctx)?,
|
||||
source: AddTabWithShellSource::CommandPalette,
|
||||
});
|
||||
}
|
||||
match params.tab_type {
|
||||
None | Some(TabType::Terminal) => Ok(WorkspaceAction::AddTerminalTab {
|
||||
hide_homepage: false,
|
||||
}),
|
||||
Some(TabType::Agent) => Ok(WorkspaceAction::AddAgentTab),
|
||||
Some(TabType::Default) => Ok(WorkspaceAction::AddDefaultTab),
|
||||
Some(TabType::CloudAgent) => Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
"tab.create does not support cloud-agent tabs",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(unused_variables))]
|
||||
pub(super) fn resolve_shell(
|
||||
name: &str,
|
||||
ctx: &ModelContext<LocalControlBridge>,
|
||||
) -> Result<AvailableShell, ControlError> {
|
||||
#[cfg(feature = "local_tty")]
|
||||
{
|
||||
AvailableShells::as_ref(ctx)
|
||||
.find_by_command_name(name)
|
||||
.or_else(|| AvailableShell::try_from(name).ok())
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("cannot resolve requested shell {name:?}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "local_tty"))]
|
||||
Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
format!("shell selection is unavailable for requested shell {name:?}"),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use ::local_control::protocol::TargetSelector;
|
||||
use ::local_control::InstanceId;
|
||||
use warpui::App;
|
||||
|
||||
use super::create_tab;
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::workspace::view::tests::{initialize_app, mock_workspace};
|
||||
|
||||
#[test]
|
||||
fn tab_create_handler_adds_and_activates_terminal_tab() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let workspace = mock_workspace(&mut app);
|
||||
let previous_count = workspace.read(&app, |workspace, _| workspace.tab_count());
|
||||
let bridge = app.add_singleton_model(LocalControlBridge::new);
|
||||
let instance_id = InstanceId("inst_test".to_owned());
|
||||
|
||||
let response = bridge.update(&mut app, |bridge, ctx| {
|
||||
bridge.set_instance_id(instance_id.clone());
|
||||
create_tab(
|
||||
&Some(instance_id.clone()),
|
||||
&serde_json::json!({}),
|
||||
&TargetSelector::default(),
|
||||
ctx,
|
||||
)
|
||||
.expect("tab.create handler succeeds")
|
||||
});
|
||||
|
||||
workspace.read(&app, |workspace, _| {
|
||||
assert_eq!(workspace.tab_count(), previous_count + 1);
|
||||
assert_eq!(workspace.active_tab_index(), previous_count);
|
||||
});
|
||||
assert_eq!(response["action"], "tab.create");
|
||||
assert_eq!(response["created"], true);
|
||||
assert_eq!(response["instance_id"], "inst_test");
|
||||
assert_eq!(response["tab"]["previous_count"], previous_count);
|
||||
assert_eq!(response["tab"]["count"], previous_count + 1);
|
||||
assert_eq!(response["tab"]["active_index"], previous_count);
|
||||
assert!(response["tab"]["id"].is_string());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
//! Metadata response builders for local-control introspection actions.
|
||||
#[cfg(test)]
|
||||
#[path = "metadata_tests.rs"]
|
||||
mod tests;
|
||||
use ::local_control::protocol::{
|
||||
ActionNameParams, ActiveTargetChain, PaneTarget, SessionTarget, SurfaceListResult,
|
||||
SurfaceSummary, TabTarget, TargetSelector, WindowTarget,
|
||||
};
|
||||
use ::local_control::{
|
||||
Action, ActionKind, ActionMetadata, ControlError, ErrorCode, InstanceId, PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity, ViewHandle, WindowId};
|
||||
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_control::resolver::{reject_target_families, require_active_window_id_for_action};
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::pane_group::{PaneGroup, PaneId};
|
||||
use crate::settings::{AISettings, CodeSettings};
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InstanceResponse<'a> {
|
||||
action: &'static str,
|
||||
instance_id: Option<&'a str>,
|
||||
pid: u32,
|
||||
channel: String,
|
||||
app_id: String,
|
||||
protocol_version: u32,
|
||||
actions: Vec<ActionMetadata>,
|
||||
}
|
||||
|
||||
fn active_session_target(target: &TargetSelector) -> TargetSelector {
|
||||
if !matches!(target.session, Some(SessionTarget::Active)) {
|
||||
return target.clone();
|
||||
}
|
||||
TargetSelector {
|
||||
window: target.window.clone().or(Some(WindowTarget::Active)),
|
||||
tab: target.tab.clone().or(Some(TabTarget::Active)),
|
||||
pane: target.pane.clone().or(Some(PaneTarget::Active)),
|
||||
session: target.session.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_session_entries(
|
||||
entries: Vec<SessionEntry>,
|
||||
session: Option<&SessionTarget>,
|
||||
action: ActionKind,
|
||||
) -> Result<Vec<SessionEntry>, ControlError> {
|
||||
match session {
|
||||
None => Ok(entries),
|
||||
Some(SessionTarget::Active) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.is_active)
|
||||
.collect(),
|
||||
action,
|
||||
"active session",
|
||||
ErrorCode::MissingTarget,
|
||||
),
|
||||
Some(SessionTarget::Id { id }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.pane_id.to_string() == id.0)
|
||||
.collect(),
|
||||
action,
|
||||
"session id",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PingResponse<'a> {
|
||||
action: &'static str,
|
||||
ok: bool,
|
||||
instance_id: Option<&'a str>,
|
||||
protocol_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VersionResponse<'a> {
|
||||
action: &'static str,
|
||||
instance_id: Option<&'a str>,
|
||||
protocol_version: u32,
|
||||
channel: String,
|
||||
app_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct WindowEntry {
|
||||
pub(super) window_id: WindowId,
|
||||
pub(super) index: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct TabEntry {
|
||||
pub(super) window_id: WindowId,
|
||||
pub(super) window_index: usize,
|
||||
pub(super) index: usize,
|
||||
pub(super) workspace_active_tab_index: usize,
|
||||
pub(super) pane_group: ViewHandle<PaneGroup>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct PaneEntry {
|
||||
pub(super) window_id: WindowId,
|
||||
pub(super) window_index: usize,
|
||||
pub(super) tab_id: String,
|
||||
pub(super) tab_index: usize,
|
||||
pub(super) index: usize,
|
||||
pub(super) pane_group: ViewHandle<PaneGroup>,
|
||||
pub(super) pane_id: PaneId,
|
||||
}
|
||||
|
||||
struct SessionEntry {
|
||||
window_id: WindowId,
|
||||
window_index: usize,
|
||||
tab_id: String,
|
||||
tab_index: usize,
|
||||
pane_id: PaneId,
|
||||
pane_index: usize,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum SurfaceDestination {
|
||||
Settings,
|
||||
CommandPalette,
|
||||
CommandSearch,
|
||||
ThemePicker,
|
||||
Keybindings,
|
||||
WarpDrive,
|
||||
ResourceCenter,
|
||||
AiAssistant,
|
||||
CodeReview,
|
||||
ProjectExplorer,
|
||||
GlobalSearch,
|
||||
ConversationList,
|
||||
LeftPanel,
|
||||
RightPanel,
|
||||
VerticalTabs,
|
||||
AgentManagement,
|
||||
}
|
||||
|
||||
impl SurfaceDestination {
|
||||
const ALL: &[Self] = &[
|
||||
Self::Settings,
|
||||
Self::CommandPalette,
|
||||
Self::CommandSearch,
|
||||
Self::ThemePicker,
|
||||
Self::Keybindings,
|
||||
Self::WarpDrive,
|
||||
Self::ResourceCenter,
|
||||
Self::AiAssistant,
|
||||
Self::CodeReview,
|
||||
Self::ProjectExplorer,
|
||||
Self::GlobalSearch,
|
||||
Self::ConversationList,
|
||||
Self::LeftPanel,
|
||||
Self::RightPanel,
|
||||
Self::VerticalTabs,
|
||||
Self::AgentManagement,
|
||||
];
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Settings => "settings",
|
||||
Self::CommandPalette => "command_palette",
|
||||
Self::CommandSearch => "command_search",
|
||||
Self::ThemePicker => "theme_picker",
|
||||
Self::Keybindings => "keybindings",
|
||||
Self::WarpDrive => "warp_drive",
|
||||
Self::ResourceCenter => "resource_center",
|
||||
Self::AiAssistant => "ai_assistant",
|
||||
Self::CodeReview => "code_review",
|
||||
Self::ProjectExplorer => "project_explorer",
|
||||
Self::GlobalSearch => "global_search",
|
||||
Self::ConversationList => "conversation_list",
|
||||
Self::LeftPanel => "left_panel",
|
||||
Self::RightPanel => "right_panel",
|
||||
Self::VerticalTabs => "vertical_tabs",
|
||||
Self::AgentManagement => "agent_management",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn instance(
|
||||
instance_id: &Option<InstanceId>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
to_json_value(InstanceResponse {
|
||||
action: ActionKind::InstanceList.as_str(),
|
||||
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
pid: std::process::id(),
|
||||
channel: ChannelState::channel().to_string(),
|
||||
app_id: ChannelState::app_id().to_string(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
actions: ActionKind::implemented_metadata(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ping(instance_id: &Option<InstanceId>) -> Result<serde_json::Value, ControlError> {
|
||||
to_json_value(PingResponse {
|
||||
action: ActionKind::AppPing.as_str(),
|
||||
ok: true,
|
||||
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn version(instance_id: &Option<InstanceId>) -> Result<serde_json::Value, ControlError> {
|
||||
to_json_value(VersionResponse {
|
||||
action: ActionKind::AppVersion.as_str(),
|
||||
instance_id: instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
channel: ChannelState::channel().to_string(),
|
||||
app_id: ChannelState::app_id().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn active(
|
||||
instance_id: &Option<InstanceId>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
Ok(json!({
|
||||
"action": ActionKind::AppActive.as_str(),
|
||||
"active": active_chain(instance_id, ctx)?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn inspect(
|
||||
instance_id: &Option<InstanceId>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
Ok(json!({
|
||||
"action": ActionKind::InstanceInspect.as_str(),
|
||||
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
"pid": std::process::id(),
|
||||
"channel": ChannelState::channel().to_string(),
|
||||
"app_id": ChannelState::app_id().to_string(),
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"active": active_chain(instance_id, ctx)?,
|
||||
"actions": ActionKind::implemented_metadata(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn action_list() -> serde_json::Value {
|
||||
json!({
|
||||
"action": ActionKind::ActionList.as_str(),
|
||||
"actions": ActionKind::implemented_metadata(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn action_inspect(action: &Action) -> Result<serde_json::Value, ControlError> {
|
||||
let params = action_name_params(action)?;
|
||||
let metadata = action_metadata_for_name(¶ms.action)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::ActionInspect.as_str(),
|
||||
"metadata": metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn capability_list() -> serde_json::Value {
|
||||
json!({
|
||||
"action": ActionKind::CapabilityList.as_str(),
|
||||
"capabilities": ActionKind::implemented_metadata(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn capability_inspect(action: &Action) -> Result<serde_json::Value, ControlError> {
|
||||
let params = action_name_params(action)?;
|
||||
let metadata = action_metadata_for_name(¶ms.action)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::CapabilityInspect.as_str(),
|
||||
"capability": metadata,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn surface_list(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
to_json_value(SurfaceListResult {
|
||||
surfaces: SurfaceDestination::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|destination| {
|
||||
let unavailable_reason =
|
||||
surface_unavailable_reason(destination, ctx).map(str::to_owned);
|
||||
SurfaceSummary {
|
||||
name: destination.name().to_owned(),
|
||||
is_available: unavailable_reason.is_none(),
|
||||
unavailable_reason,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn surface_unavailable_reason(
|
||||
destination: SurfaceDestination,
|
||||
ctx: &AppContext,
|
||||
) -> Option<&'static str> {
|
||||
match destination {
|
||||
SurfaceDestination::Settings
|
||||
| SurfaceDestination::CommandPalette
|
||||
| SurfaceDestination::CommandSearch
|
||||
| SurfaceDestination::ThemePicker
|
||||
| SurfaceDestination::Keybindings
|
||||
| SurfaceDestination::ResourceCenter => None,
|
||||
SurfaceDestination::WarpDrive if !WarpDriveSettings::is_warp_drive_enabled(ctx) => {
|
||||
Some("Warp Drive is disabled")
|
||||
}
|
||||
SurfaceDestination::WarpDrive => None,
|
||||
SurfaceDestination::AiAssistant if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) => {
|
||||
Some("AI features are disabled")
|
||||
}
|
||||
SurfaceDestination::AiAssistant => None,
|
||||
SurfaceDestination::CodeReview | SurfaceDestination::RightPanel
|
||||
if !cfg!(feature = "local_fs") =>
|
||||
{
|
||||
Some("code review is unavailable without local filesystem support")
|
||||
}
|
||||
SurfaceDestination::CodeReview | SurfaceDestination::RightPanel => None,
|
||||
SurfaceDestination::ProjectExplorer
|
||||
if !cfg!(feature = "local_fs")
|
||||
|| !*CodeSettings::as_ref(ctx).show_project_explorer.value() =>
|
||||
{
|
||||
Some("project explorer is unavailable or disabled")
|
||||
}
|
||||
SurfaceDestination::ProjectExplorer => None,
|
||||
SurfaceDestination::GlobalSearch
|
||||
if !cfg!(feature = "local_fs")
|
||||
|| !FeatureFlag::GlobalSearch.is_enabled()
|
||||
|| !*CodeSettings::as_ref(ctx).show_global_search.value() =>
|
||||
{
|
||||
Some("global search is unavailable or disabled")
|
||||
}
|
||||
SurfaceDestination::GlobalSearch => None,
|
||||
SurfaceDestination::ConversationList
|
||||
if !FeatureFlag::AgentViewConversationListView.is_enabled()
|
||||
|| !AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
|
||||
|| !*AISettings::as_ref(ctx).show_conversation_history.value() =>
|
||||
{
|
||||
Some("agent conversation history is unavailable or disabled")
|
||||
}
|
||||
SurfaceDestination::ConversationList => None,
|
||||
SurfaceDestination::LeftPanel
|
||||
if surface_unavailable_reason(SurfaceDestination::ProjectExplorer, ctx).is_some()
|
||||
&& surface_unavailable_reason(SurfaceDestination::GlobalSearch, ctx).is_some()
|
||||
&& surface_unavailable_reason(SurfaceDestination::ConversationList, ctx)
|
||||
.is_some()
|
||||
&& surface_unavailable_reason(SurfaceDestination::WarpDrive, ctx).is_some() =>
|
||||
{
|
||||
Some("the left panel has no available views")
|
||||
}
|
||||
SurfaceDestination::LeftPanel => None,
|
||||
SurfaceDestination::VerticalTabs
|
||||
if !FeatureFlag::VerticalTabs.is_enabled()
|
||||
|| !*TabSettings::as_ref(ctx).use_vertical_tabs.value() =>
|
||||
{
|
||||
Some("vertical tabs are unavailable or disabled")
|
||||
}
|
||||
SurfaceDestination::VerticalTabs => None,
|
||||
SurfaceDestination::AgentManagement
|
||||
if !FeatureFlag::AgentManagementView.is_enabled()
|
||||
|| !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) =>
|
||||
{
|
||||
Some("agent management is unavailable or disabled")
|
||||
}
|
||||
SurfaceDestination::AgentManagement => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn window_list(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::WindowList,
|
||||
target.tab.is_some() || target.pane.is_some() || target.session.is_some(),
|
||||
"tab, pane, or session selectors",
|
||||
)?;
|
||||
let active_window = ctx.windows().active_window();
|
||||
let mut windows = Vec::new();
|
||||
for entry in select_window_entries(target, false, ActionKind::WindowList, ctx)? {
|
||||
windows.push(json!({
|
||||
"window_id": entry.window_id.to_string(),
|
||||
"index": entry.index as u32,
|
||||
"is_active": Some(entry.window_id) == active_window,
|
||||
"has_workspace": workspace_for_window(entry.window_id, ActionKind::WindowList, ctx)?.is_some(),
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"action": ActionKind::WindowList.as_str(),
|
||||
"windows": windows,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn window_inspect(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::WindowInspect,
|
||||
target.tab.is_some() || target.pane.is_some() || target.session.is_some(),
|
||||
"tab, pane, or session selectors",
|
||||
)?;
|
||||
let target = TargetSelector {
|
||||
window: target.window.clone().or(Some(WindowTarget::Active)),
|
||||
tab: None,
|
||||
pane: None,
|
||||
session: None,
|
||||
};
|
||||
let data = window_list(&target, ctx)?;
|
||||
let window = single_entry(data.get("windows"), ActionKind::WindowInspect)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::WindowInspect.as_str(),
|
||||
"window": window,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_list(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::TabList,
|
||||
target.pane.is_some() || target.session.is_some(),
|
||||
"pane or session selectors",
|
||||
)?;
|
||||
let entries = select_tab_entries(target, ActionKind::TabList, ctx)?;
|
||||
let tabs = entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"tab_id": entry.pane_group.id().to_string(),
|
||||
"window_id": entry.window_id.to_string(),
|
||||
"window_index": entry.window_index as u32,
|
||||
"index": entry.index as u32,
|
||||
"is_active": entry.index == entry.workspace_active_tab_index,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"action": ActionKind::TabList.as_str(),
|
||||
"tabs": tabs,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_inspect(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::TabInspect,
|
||||
target.pane.is_some() || target.session.is_some(),
|
||||
"pane or session selectors",
|
||||
)?;
|
||||
let target = TargetSelector {
|
||||
window: target.window.clone(),
|
||||
tab: target.tab.clone().or(Some(TabTarget::Active)),
|
||||
pane: None,
|
||||
session: None,
|
||||
};
|
||||
let data = tab_list(&target, ctx)?;
|
||||
let tab = single_entry(data.get("tabs"), ActionKind::TabInspect)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::TabInspect.as_str(),
|
||||
"tab": tab,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pane_list(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::PaneList,
|
||||
target.session.is_some(),
|
||||
"session selectors",
|
||||
)?;
|
||||
let entries = select_pane_entries(target, ActionKind::PaneList, ctx)?;
|
||||
let mut panes = Vec::new();
|
||||
for entry in entries {
|
||||
let (is_active, has_terminal_session) = entry.pane_group.read(ctx, |pane_group, ctx| {
|
||||
(
|
||||
pane_group.focused_pane_id(ctx) == entry.pane_id,
|
||||
pane_group
|
||||
.terminal_view_from_pane_id(entry.pane_id, ctx)
|
||||
.is_some(),
|
||||
)
|
||||
});
|
||||
panes.push(json!({
|
||||
"pane_id": entry.pane_id.to_string(),
|
||||
"tab_id": entry.tab_id,
|
||||
"tab_index": entry.tab_index as u32,
|
||||
"window_id": entry.window_id.to_string(),
|
||||
"window_index": entry.window_index as u32,
|
||||
"index": entry.index as u32,
|
||||
"is_active": is_active,
|
||||
"has_terminal_session": has_terminal_session,
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"action": ActionKind::PaneList.as_str(),
|
||||
"panes": panes,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pane_inspect(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
reject_target_families(
|
||||
ActionKind::PaneInspect,
|
||||
target.session.is_some(),
|
||||
"session selectors",
|
||||
)?;
|
||||
let target = TargetSelector {
|
||||
window: target.window.clone(),
|
||||
tab: target.tab.clone(),
|
||||
pane: target.pane.clone().or(Some(PaneTarget::Active)),
|
||||
session: None,
|
||||
};
|
||||
let data = pane_list(&target, ctx)?;
|
||||
let pane = single_entry(data.get("panes"), ActionKind::PaneInspect)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::PaneInspect.as_str(),
|
||||
"pane": pane,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn session_list(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let target = active_session_target(target);
|
||||
let pane_entries = select_pane_entries(&target, ActionKind::SessionList, ctx)?;
|
||||
let entries = select_session_entries(
|
||||
session_entries_for_panes(pane_entries, ctx),
|
||||
target.session.as_ref(),
|
||||
ActionKind::SessionList,
|
||||
)?;
|
||||
let sessions = session_values(entries);
|
||||
Ok(json!({
|
||||
"action": ActionKind::SessionList.as_str(),
|
||||
"sessions": sessions,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn session_inspect(
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let target = active_session_target(target);
|
||||
let pane_entries = select_pane_entries(&target, ActionKind::SessionInspect, ctx)?;
|
||||
let entries = select_session_entries(
|
||||
session_entries_for_panes(pane_entries, ctx),
|
||||
target.session.as_ref().or(Some(&SessionTarget::Active)),
|
||||
ActionKind::SessionInspect,
|
||||
)?;
|
||||
let data = json!({ "sessions": session_values(entries) });
|
||||
let session = single_entry(data.get("sessions"), ActionKind::SessionInspect)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::SessionInspect.as_str(),
|
||||
"session": session,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn action_metadata_for_name(action_name: &str) -> Result<ActionMetadata, ControlError> {
|
||||
ActionKind::implemented_metadata()
|
||||
.into_iter()
|
||||
.find(|metadata| metadata.name == action_name)
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::NotAllowlisted,
|
||||
"requested action is not an implemented local-control action",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn action_name_params(action: &Action) -> Result<ActionNameParams, ControlError> {
|
||||
action.params_as()
|
||||
}
|
||||
|
||||
fn to_json_value<T: Serialize>(response: T) -> Result<serde_json::Value, ControlError> {
|
||||
serde_json::to_value(response).map_err(|error| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to serialize local-control metadata response",
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn active_chain(
|
||||
instance_id: &Option<InstanceId>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ActiveTargetChain, ControlError> {
|
||||
let instance_id = instance_id.as_ref().map(|id| id.0.clone());
|
||||
let Some(window_id) = ctx.windows().active_window() else {
|
||||
return Ok(ActiveTargetChain {
|
||||
instance_id,
|
||||
window_id: None,
|
||||
tab_id: None,
|
||||
pane_id: None,
|
||||
session_id: None,
|
||||
});
|
||||
};
|
||||
let window_id_string = window_id.to_string();
|
||||
let Some(workspace) = workspace_for_window(window_id, ActionKind::AppActive, ctx)? else {
|
||||
return Ok(ActiveTargetChain {
|
||||
instance_id,
|
||||
window_id: Some(window_id_string),
|
||||
tab_id: None,
|
||||
pane_id: None,
|
||||
session_id: None,
|
||||
});
|
||||
};
|
||||
let (tab_id, pane_id, session_id) = workspace.read(ctx, |workspace, ctx| {
|
||||
let pane_group = workspace.active_tab_pane_group();
|
||||
let pane_group_ref = pane_group.as_ref(ctx);
|
||||
(
|
||||
Some(pane_group.id().to_string()),
|
||||
Some(pane_group_ref.focused_pane_id(ctx).to_string()),
|
||||
pane_group_ref
|
||||
.active_session_id(ctx)
|
||||
.map(|session_id| PaneId::from(session_id).to_string()),
|
||||
)
|
||||
});
|
||||
Ok(ActiveTargetChain {
|
||||
instance_id,
|
||||
window_id: Some(window_id_string),
|
||||
tab_id,
|
||||
pane_id,
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_window_entries(
|
||||
target: &TargetSelector,
|
||||
force_active_default: bool,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Vec<WindowEntry>, ControlError> {
|
||||
let entries = window_entries(ctx);
|
||||
match target.window.as_ref() {
|
||||
None if force_active_default => {
|
||||
let active =
|
||||
require_active_window_id_for_action(ctx.windows().active_window(), action)?;
|
||||
explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.window_id == active)
|
||||
.collect(),
|
||||
action,
|
||||
"active window",
|
||||
ErrorCode::MissingTarget,
|
||||
)
|
||||
}
|
||||
None => Ok(entries),
|
||||
Some(WindowTarget::Active) => {
|
||||
let active =
|
||||
require_active_window_id_for_action(ctx.windows().active_window(), action)?;
|
||||
explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.window_id == active)
|
||||
.collect(),
|
||||
action,
|
||||
"active window",
|
||||
ErrorCode::MissingTarget,
|
||||
)
|
||||
}
|
||||
Some(WindowTarget::Id { id }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.window_id.to_string() == id.0)
|
||||
.collect(),
|
||||
action,
|
||||
"window id",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
Some(WindowTarget::Index { index }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index as u32 == *index)
|
||||
.collect(),
|
||||
action,
|
||||
"window index",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
Some(WindowTarget::Title { .. }) => Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!(
|
||||
"{} only supports active, opaque window id, and window index selectors",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_tab_entries(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Vec<TabEntry>, ControlError> {
|
||||
let force_active_window = matches!(
|
||||
target.tab,
|
||||
Some(TabTarget::Active | TabTarget::Index { .. })
|
||||
) || matches!(
|
||||
target.pane,
|
||||
Some(PaneTarget::Active | PaneTarget::Index { .. })
|
||||
);
|
||||
let windows = select_window_entries(target, force_active_window, action, ctx)?;
|
||||
let entries = tab_entries_for_windows(windows, action, ctx)?;
|
||||
let requires_active_tab_default = matches!(
|
||||
target.pane,
|
||||
Some(PaneTarget::Active | PaneTarget::Index { .. })
|
||||
);
|
||||
match target.tab.as_ref() {
|
||||
None if requires_active_tab_default => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index == entry.workspace_active_tab_index)
|
||||
.collect(),
|
||||
action,
|
||||
"active tab",
|
||||
ErrorCode::MissingTarget,
|
||||
),
|
||||
None => Ok(entries),
|
||||
Some(TabTarget::Active) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index == entry.workspace_active_tab_index)
|
||||
.collect(),
|
||||
action,
|
||||
"active tab",
|
||||
ErrorCode::MissingTarget,
|
||||
),
|
||||
Some(TabTarget::Id { id }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.pane_group.id().to_string() == id.0)
|
||||
.collect(),
|
||||
action,
|
||||
"tab id",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
Some(TabTarget::Index { index }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index as u32 == *index)
|
||||
.collect(),
|
||||
action,
|
||||
"tab index",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
Some(TabTarget::Title { .. }) => Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!(
|
||||
"{} only supports active, opaque tab id, and tab index selectors",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_pane_entries(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Vec<PaneEntry>, ControlError> {
|
||||
let tabs = select_tab_entries(target, action, ctx)?;
|
||||
let entries = pane_entries_for_tabs(tabs, ctx);
|
||||
match target.pane.as_ref() {
|
||||
None => Ok(entries),
|
||||
Some(PaneTarget::Active) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.pane_group.read(ctx, |pane_group, ctx| {
|
||||
pane_group.focused_pane_id(ctx) == entry.pane_id
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
action,
|
||||
"active pane",
|
||||
ErrorCode::MissingTarget,
|
||||
),
|
||||
Some(PaneTarget::Id { id }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.pane_id.to_string() == id.0)
|
||||
.collect(),
|
||||
action,
|
||||
"pane id",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
Some(PaneTarget::Index { index }) => explicit_matches(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index as u32 == *index)
|
||||
.collect(),
|
||||
action,
|
||||
"pane index",
|
||||
ErrorCode::StaleTarget,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn window_entries(ctx: &mut ModelContext<LocalControlBridge>) -> Vec<WindowEntry> {
|
||||
let mut ids = ctx.window_ids().collect::<Vec<_>>();
|
||||
ids.sort_by_key(ToString::to_string);
|
||||
ids.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, window_id)| WindowEntry { window_id, index })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn tab_entries_for_windows(
|
||||
windows: Vec<WindowEntry>,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Vec<TabEntry>, ControlError> {
|
||||
let mut entries = Vec::new();
|
||||
for window in windows {
|
||||
let Some(workspace) = workspace_for_window(window.window_id, action, ctx)? else {
|
||||
continue;
|
||||
};
|
||||
entries.extend(workspace.read(ctx, |workspace, _| {
|
||||
workspace
|
||||
.tab_views()
|
||||
.enumerate()
|
||||
.map(|(index, pane_group)| TabEntry {
|
||||
window_id: window.window_id,
|
||||
window_index: window.index,
|
||||
index,
|
||||
workspace_active_tab_index: workspace.active_tab_index(),
|
||||
pane_group: pane_group.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(super) fn pane_entries_for_tabs(
|
||||
tabs: Vec<TabEntry>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Vec<PaneEntry> {
|
||||
let mut entries = Vec::new();
|
||||
for tab in tabs {
|
||||
let tab_id = tab.pane_group.id().to_string();
|
||||
let pane_group = tab.pane_group.clone();
|
||||
let pane_ids = tab
|
||||
.pane_group
|
||||
.read(ctx, |pane_group, _| pane_group.visible_pane_ids());
|
||||
entries.extend(
|
||||
pane_ids
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, pane_id)| PaneEntry {
|
||||
window_id: tab.window_id,
|
||||
window_index: tab.window_index,
|
||||
tab_id: tab_id.clone(),
|
||||
tab_index: tab.index,
|
||||
index,
|
||||
pane_group: pane_group.clone(),
|
||||
pane_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn session_entries_for_panes(
|
||||
panes: Vec<PaneEntry>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Vec<SessionEntry> {
|
||||
let mut entries = Vec::new();
|
||||
for pane in panes {
|
||||
let (has_terminal_session, is_active) = pane.pane_group.read(ctx, |pane_group, ctx| {
|
||||
(
|
||||
pane_group
|
||||
.terminal_view_from_pane_id(pane.pane_id, ctx)
|
||||
.is_some(),
|
||||
pane_group.active_session_id(ctx).map(PaneId::from) == Some(pane.pane_id),
|
||||
)
|
||||
});
|
||||
if has_terminal_session {
|
||||
entries.push(SessionEntry {
|
||||
window_id: pane.window_id,
|
||||
window_index: pane.window_index,
|
||||
tab_id: pane.tab_id,
|
||||
tab_index: pane.tab_index,
|
||||
pane_id: pane.pane_id,
|
||||
pane_index: pane.index,
|
||||
is_active,
|
||||
});
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn session_values(entries: Vec<SessionEntry>) -> Vec<Value> {
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"session_id": entry.pane_id.to_string(),
|
||||
"pane_id": entry.pane_id.to_string(),
|
||||
"pane_index": entry.pane_index as u32,
|
||||
"tab_id": entry.tab_id,
|
||||
"tab_index": entry.tab_index as u32,
|
||||
"window_id": entry.window_id.to_string(),
|
||||
"window_index": entry.window_index as u32,
|
||||
"is_active": entry.is_active,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn workspace_for_window(
|
||||
window_id: WindowId,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Option<ViewHandle<Workspace>>, ControlError> {
|
||||
match ctx.views_of_type::<Workspace>(window_id) {
|
||||
None => Ok(None),
|
||||
Some(workspaces) => match workspaces.as_slice() {
|
||||
[] => Ok(None),
|
||||
[workspace] => Ok(Some(workspace.clone())),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!(
|
||||
"{} resolved multiple workspaces in one window",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_matches<T>(
|
||||
matches: Vec<T>,
|
||||
action: ActionKind,
|
||||
selector: &str,
|
||||
missing_code: ErrorCode,
|
||||
) -> Result<Vec<T>, ControlError> {
|
||||
match matches.len() {
|
||||
0 => Err(ControlError::new(
|
||||
missing_code,
|
||||
format!(
|
||||
"{} cannot resolve the requested {selector}",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
1 => Ok(matches),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!(
|
||||
"{} resolved multiple targets by {selector}",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn single_entry(value: Option<&Value>, action: ActionKind) -> Result<Value, ControlError> {
|
||||
let Some(items) = value.and_then(Value::as_array) else {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("{} handler returned malformed metadata", action.as_str()),
|
||||
));
|
||||
};
|
||||
match items.as_slice() {
|
||||
[item] => Ok(item.clone()),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} could not resolve a target", action.as_str()),
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!("{} resolved multiple targets", action.as_str()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
//! Metadata/configuration mutation handlers for local-control actions.
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use ::local_control::protocol::{
|
||||
BooleanValueParams, ColorValueParams, KeyParams, KeyValueParams, PaneTarget, RenameParams,
|
||||
TabTarget, TargetSelector, ThemeNameParams, WindowTarget,
|
||||
};
|
||||
use ::local_control::{ActionKind, ControlError, ErrorCode, InstanceId};
|
||||
use serde_json::json;
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
use warpui::{ModelContext, SingletonEntity as _, WindowId};
|
||||
|
||||
use super::metadata::{
|
||||
pane_entries_for_tabs, tab_entries_for_windows, PaneEntry, TabEntry, WindowEntry,
|
||||
};
|
||||
use super::settings_surfaces::{
|
||||
public_theme_name, rejected_setting_key, setting_summary_for_key, ALLOWLISTED_SETTING_KEYS,
|
||||
};
|
||||
use crate::local_control::handlers::ack;
|
||||
use crate::local_control::resolver::{require_active_window_id_for_action, workspace_for_window};
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::settings::{AccessibilitySettings, FontSettings, InputSettings, ThemeSettings};
|
||||
use crate::tab::SelectedTabColor;
|
||||
use crate::themes::theme::{SelectedSystemThemes, ThemeKind};
|
||||
use crate::user_config::WarpConfig;
|
||||
use crate::window_settings::ZoomLevel;
|
||||
use crate::WindowSettings;
|
||||
|
||||
pub(crate) fn tab_rename(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let title = rename_title(action)?;
|
||||
let entry = select_single_tab_entry(target, ActionKind::TabRename, ctx)?;
|
||||
let tab_id = entry.pane_group.id().to_string();
|
||||
entry.pane_group.update(ctx, |pane_group, ctx| {
|
||||
pane_group.set_title(&title, ctx);
|
||||
});
|
||||
Ok(tab_mutation_result(
|
||||
instance_id,
|
||||
ActionKind::TabRename,
|
||||
tab_id,
|
||||
entry.window_id,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_reset_name(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let entry = select_single_tab_entry(target, ActionKind::TabResetName, ctx)?;
|
||||
let tab_id = entry.pane_group.id().to_string();
|
||||
entry.pane_group.update(ctx, |pane_group, ctx| {
|
||||
pane_group.clear_title(ctx);
|
||||
});
|
||||
Ok(tab_mutation_result(
|
||||
instance_id,
|
||||
ActionKind::TabResetName,
|
||||
tab_id,
|
||||
entry.window_id,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_color_set(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let color = color_value(action)?;
|
||||
let entry = select_single_tab_entry(target, ActionKind::TabColorSet, ctx)?;
|
||||
set_tab_color(entry.clone(), SelectedTabColor::Color(color), ctx)?;
|
||||
Ok(tab_mutation_result(
|
||||
instance_id,
|
||||
ActionKind::TabColorSet,
|
||||
entry.pane_group.id().to_string(),
|
||||
entry.window_id,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_color_clear(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let entry = select_single_tab_entry(target, ActionKind::TabColorClear, ctx)?;
|
||||
set_tab_color(entry.clone(), SelectedTabColor::Cleared, ctx)?;
|
||||
Ok(tab_mutation_result(
|
||||
instance_id,
|
||||
ActionKind::TabColorClear,
|
||||
entry.pane_group.id().to_string(),
|
||||
entry.window_id,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn pane_rename(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let title = rename_title(action)?;
|
||||
let entry = select_single_pane_entry(target, ActionKind::PaneRename, ctx)?;
|
||||
set_pane_name(&entry, Some(title), ctx)?;
|
||||
Ok(pane_mutation_result(
|
||||
instance_id,
|
||||
ActionKind::PaneRename,
|
||||
entry.pane_id,
|
||||
entry.tab_id,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn pane_reset_name(
|
||||
instance_id: &Option<InstanceId>,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let entry = select_single_pane_entry(target, ActionKind::PaneResetName, ctx)?;
|
||||
set_pane_name(&entry, None, ctx)?;
|
||||
Ok(pane_mutation_result(
|
||||
instance_id,
|
||||
ActionKind::PaneResetName,
|
||||
entry.pane_id,
|
||||
entry.tab_id,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn theme_set(
|
||||
instance_id: &Option<InstanceId>,
|
||||
action_kind: ActionKind,
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
match action_kind {
|
||||
ActionKind::ThemeSet => set_theme(theme_name(action)?, ctx)?,
|
||||
ActionKind::ThemeSystemSet => set_system_theme(boolean_value(action)?, ctx)?,
|
||||
ActionKind::ThemeLightSet => set_system_theme_variant(theme_name(action)?, true, ctx)?,
|
||||
ActionKind::ThemeDarkSet => set_system_theme_variant(theme_name(action)?, false, ctx)?,
|
||||
_ => {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
format!("{} is not a theme mutation", action_kind.as_str()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(ack(instance_id, action_kind))
|
||||
}
|
||||
|
||||
pub(crate) fn appearance_mutation(
|
||||
instance_id: &Option<InstanceId>,
|
||||
action_kind: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
match action_kind {
|
||||
ActionKind::AppearanceFontSizeIncrease => {
|
||||
adjust_font_size(FontSizeAdjustment::Increase, ctx)?
|
||||
}
|
||||
ActionKind::AppearanceFontSizeDecrease => {
|
||||
adjust_font_size(FontSizeAdjustment::Decrease, ctx)?
|
||||
}
|
||||
ActionKind::AppearanceFontSizeReset => adjust_font_size(FontSizeAdjustment::Reset, ctx)?,
|
||||
ActionKind::AppearanceZoomIncrease => adjust_zoom(true, ctx)?,
|
||||
ActionKind::AppearanceZoomDecrease => adjust_zoom(false, ctx)?,
|
||||
ActionKind::AppearanceZoomReset => reset_zoom(ctx)?,
|
||||
_ => {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
format!("{} is not an appearance mutation", action_kind.as_str()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(ack(instance_id, action_kind))
|
||||
}
|
||||
|
||||
pub(crate) fn setting_set(
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let (key, value) = key_value(action)?;
|
||||
set_allowlisted_setting(&key, value, ctx)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::SettingSet.as_str(),
|
||||
"setting": setting_summary_for_key(&key, ctx)?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn setting_toggle(
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let key = key_value_key(action)?;
|
||||
let current = setting_summary_for_key(&key, ctx)?;
|
||||
let Some(value) = current.value.as_bool() else {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{key} is not a boolean setting and cannot be toggled"),
|
||||
));
|
||||
};
|
||||
set_allowlisted_setting(&key, json!(!value), ctx)?;
|
||||
Ok(json!({
|
||||
"action": ActionKind::SettingToggle.as_str(),
|
||||
"setting": setting_summary_for_key(&key, ctx)?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn rename_title(action: &::local_control::Action) -> Result<String, ControlError> {
|
||||
let RenameParams { title } = action.params_as()?;
|
||||
if title.trim().is_empty() {
|
||||
Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{} title cannot be empty", action.kind.as_str()),
|
||||
))
|
||||
} else {
|
||||
Ok(title.trim().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn color_value(action: &::local_control::Action) -> Result<AnsiColorIdentifier, ControlError> {
|
||||
let ColorValueParams { color } = action.params_as()?;
|
||||
AnsiColorIdentifier::from_str(&color).map_err(|_| {
|
||||
ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{color} is not a supported tab color"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn theme_name(action: &::local_control::Action) -> Result<String, ControlError> {
|
||||
let ThemeNameParams { theme_name } = action.params_as()?;
|
||||
if theme_name.trim().is_empty() {
|
||||
Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{} theme name cannot be empty", action.kind.as_str()),
|
||||
))
|
||||
} else {
|
||||
Ok(theme_name.trim().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean_value(action: &::local_control::Action) -> Result<bool, ControlError> {
|
||||
Ok(action.params_as::<BooleanValueParams>()?.value)
|
||||
}
|
||||
|
||||
fn key_value(
|
||||
action: &::local_control::Action,
|
||||
) -> Result<(String, serde_json::Value), ControlError> {
|
||||
let KeyValueParams { key, value } = action.params_as()?;
|
||||
Ok((key, value))
|
||||
}
|
||||
|
||||
fn key_value_key(action: &::local_control::Action) -> Result<String, ControlError> {
|
||||
Ok(action.params_as::<KeyParams>()?.key)
|
||||
}
|
||||
|
||||
fn select_single_tab_entry(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<TabEntry, ControlError> {
|
||||
if target.pane.is_some() || target.session.is_some() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!(
|
||||
"{} does not accept pane or session selectors",
|
||||
action.as_str()
|
||||
),
|
||||
));
|
||||
}
|
||||
let entries = select_tab_entries(target, action, ctx)?;
|
||||
match entries.as_slice() {
|
||||
[entry] => Ok(entry.clone()),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires a target tab", action.as_str()),
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!("{} resolved multiple tabs", action.as_str()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_single_pane_entry(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<PaneEntry, ControlError> {
|
||||
if target.session.is_some() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!("{} does not accept session selectors", action.as_str()),
|
||||
));
|
||||
}
|
||||
let tab = select_single_tab_entry_for_pane(target, action, ctx)?;
|
||||
let entries = pane_entries_for_tabs(vec![tab], ctx);
|
||||
if entries.is_empty() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
"target tab has no visible panes",
|
||||
));
|
||||
}
|
||||
let selected = match target.pane.as_ref() {
|
||||
None | Some(PaneTarget::Active) => {
|
||||
let focused = entries
|
||||
.first()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.pane_group
|
||||
.read(ctx, |pane_group, ctx| pane_group.focused_pane_id(ctx))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires an active pane", action.as_str()),
|
||||
)
|
||||
})?;
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.pane_id == focused)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
Some(PaneTarget::Id { id }) => entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.pane_id.to_string() == id.0)
|
||||
.collect::<Vec<_>>(),
|
||||
Some(PaneTarget::Index { index }) => entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index as u32 == *index)
|
||||
.collect::<Vec<_>>(),
|
||||
};
|
||||
match selected.as_slice() {
|
||||
[entry] => Ok(entry.clone()),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!("{} cannot resolve the requested pane", action.as_str()),
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!("{} resolved multiple panes", action.as_str()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_single_tab_entry_for_pane(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<TabEntry, ControlError> {
|
||||
let entries = select_tab_entries(target, action, ctx)?;
|
||||
match entries.as_slice() {
|
||||
[entry] => Ok(entry.clone()),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires a target tab", action.as_str()),
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!("{} resolved multiple tabs", action.as_str()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_tab_entries(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Vec<TabEntry>, ControlError> {
|
||||
let windows = select_window_ids(target, action, ctx)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, window_id)| WindowEntry { window_id, index })
|
||||
.collect::<Vec<_>>();
|
||||
let entries = tab_entries_for_windows(windows, action, ctx)?;
|
||||
match target.tab.as_ref() {
|
||||
None | Some(TabTarget::Active) => Ok(entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index == entry.workspace_active_tab_index)
|
||||
.collect()),
|
||||
Some(TabTarget::Id { id }) => Ok(entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.pane_group.id().to_string() == id.0)
|
||||
.collect()),
|
||||
Some(TabTarget::Index { index }) => Ok(entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.index as u32 == *index)
|
||||
.collect()),
|
||||
Some(TabTarget::Title { title }) => Ok(entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.pane_group.read(ctx, |pane_group, ctx| {
|
||||
pane_group.display_title(ctx) == *title
|
||||
})
|
||||
})
|
||||
.collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_window_ids(
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<Vec<WindowId>, ControlError> {
|
||||
match target.window.as_ref() {
|
||||
None | Some(WindowTarget::Active) => Ok(vec![require_active_window_id_for_action(
|
||||
ctx.windows().active_window(),
|
||||
action,
|
||||
)?]),
|
||||
Some(WindowTarget::Id { id }) => ctx
|
||||
.window_ids()
|
||||
.find(|window_id| window_id.to_string() == id.0)
|
||||
.map(|window_id| vec![window_id])
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!("{} cannot resolve the requested window id", action.as_str()),
|
||||
)
|
||||
}),
|
||||
Some(WindowTarget::Index { .. } | WindowTarget::Title { .. }) => Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!(
|
||||
"{} only supports active and opaque window id selectors",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_tab_color(
|
||||
entry: TabEntry,
|
||||
color: SelectedTabColor,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
let workspace = workspace_for_window(entry.window_id, ActionKind::TabColorSet, ctx)?;
|
||||
workspace.update(ctx, |workspace, ctx| {
|
||||
workspace.set_tab_color(entry.index, color, ctx);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_pane_name(
|
||||
entry: &PaneEntry,
|
||||
title: Option<String>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
entry.pane_group.update(ctx, |pane_group, ctx| {
|
||||
let Some(pane) = pane_group.pane_by_id(entry.pane_id) else {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
"pane metadata mutation cannot resolve the requested pane",
|
||||
));
|
||||
};
|
||||
pane.pane_configuration().update(ctx, |configuration, ctx| {
|
||||
if let Some(title) = title {
|
||||
configuration.set_custom_vertical_tabs_title(title, ctx);
|
||||
} else {
|
||||
configuration.clear_custom_vertical_tabs_title(ctx);
|
||||
}
|
||||
});
|
||||
ctx.emit(crate::pane_group::Event::AppStateChanged);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_theme(
|
||||
theme_name: String,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
let theme = theme_kind_for_name(&theme_name, ctx)?;
|
||||
ThemeSettings::handle(ctx)
|
||||
.update(ctx, |theme_settings, ctx| {
|
||||
theme_settings.use_system_theme.set_value(false, ctx)?;
|
||||
theme_settings.theme_kind.set_value(theme, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::ThemeSet, err))
|
||||
}
|
||||
|
||||
fn set_system_theme(
|
||||
enabled: bool,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
ThemeSettings::handle(ctx)
|
||||
.update(ctx, |theme_settings, ctx| {
|
||||
theme_settings.use_system_theme.set_value(enabled, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::ThemeSystemSet, err))
|
||||
}
|
||||
|
||||
fn set_system_theme_variant(
|
||||
theme_name: String,
|
||||
light: bool,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
let theme = theme_kind_for_name(&theme_name, ctx)?;
|
||||
let action = if light {
|
||||
ActionKind::ThemeLightSet
|
||||
} else {
|
||||
ActionKind::ThemeDarkSet
|
||||
};
|
||||
ThemeSettings::handle(ctx)
|
||||
.update(ctx, |theme_settings, ctx| {
|
||||
let current = theme_settings.selected_system_themes.value().clone();
|
||||
let next = if light {
|
||||
SelectedSystemThemes {
|
||||
light: theme,
|
||||
dark: current.dark,
|
||||
}
|
||||
} else {
|
||||
SelectedSystemThemes {
|
||||
light: current.light,
|
||||
dark: theme,
|
||||
}
|
||||
};
|
||||
theme_settings.selected_system_themes.set_value(next, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(action, err))
|
||||
}
|
||||
|
||||
enum FontSizeAdjustment {
|
||||
Increase,
|
||||
Decrease,
|
||||
Reset,
|
||||
}
|
||||
|
||||
fn adjust_font_size(
|
||||
adjustment: FontSizeAdjustment,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
let current = *FontSettings::as_ref(ctx).monospace_font_size.value();
|
||||
let next = match adjustment {
|
||||
FontSizeAdjustment::Increase => (current + 1.0).clamp(5.0, 25.0),
|
||||
FontSizeAdjustment::Decrease => (current - 1.0).clamp(5.0, 25.0),
|
||||
FontSizeAdjustment::Reset => crate::settings::MonospaceFontSize::default_value(),
|
||||
};
|
||||
FontSettings::handle(ctx)
|
||||
.update(ctx, |font_settings, ctx| {
|
||||
font_settings.monospace_font_size.set_value(next, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::AppearanceFontSizeReset, err))
|
||||
}
|
||||
|
||||
fn adjust_zoom(
|
||||
increase: bool,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
let current = *WindowSettings::as_ref(ctx).zoom_level.value();
|
||||
let next = adjacent_zoom_level(current, increase);
|
||||
WindowSettings::handle(ctx)
|
||||
.update(ctx, |window_settings, ctx| {
|
||||
window_settings.zoom_level.set_value(next, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::AppearanceZoomReset, err))
|
||||
}
|
||||
|
||||
fn reset_zoom(ctx: &mut ModelContext<LocalControlBridge>) -> Result<(), ControlError> {
|
||||
WindowSettings::handle(ctx)
|
||||
.update(ctx, |window_settings, ctx| {
|
||||
window_settings
|
||||
.zoom_level
|
||||
.set_value(ZoomLevel::default_value(), ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::AppearanceZoomReset, err))
|
||||
}
|
||||
|
||||
fn adjacent_zoom_level(current: u16, increase: bool) -> u16 {
|
||||
let default_index = ZoomLevel::VALUES
|
||||
.iter()
|
||||
.position(|zoom| *zoom == ZoomLevel::default_value())
|
||||
.unwrap_or(0);
|
||||
let current_index = ZoomLevel::VALUES
|
||||
.iter()
|
||||
.position(|zoom| *zoom == current)
|
||||
.unwrap_or(default_index);
|
||||
let next_index = if increase {
|
||||
(current_index + 1).min(ZoomLevel::VALUES.len() - 1)
|
||||
} else {
|
||||
current_index.saturating_sub(1)
|
||||
};
|
||||
ZoomLevel::VALUES[next_index]
|
||||
}
|
||||
|
||||
fn set_allowlisted_setting(
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
if !ALLOWLISTED_SETTING_KEYS.contains(&key) {
|
||||
return Err(rejected_setting_key(key));
|
||||
}
|
||||
match key {
|
||||
"appearance.themes.theme" => set_theme(string_setting_value(key, &value)?, ctx),
|
||||
"appearance.themes.system_theme" => set_system_theme(bool_setting_value(key, &value)?, ctx),
|
||||
"appearance.themes.light_theme" => {
|
||||
set_system_theme_variant(string_setting_value(key, &value)?, true, ctx)
|
||||
}
|
||||
"appearance.themes.dark_theme" => {
|
||||
set_system_theme_variant(string_setting_value(key, &value)?, false, ctx)
|
||||
}
|
||||
"appearance.text.font_name" => {
|
||||
let font_name = string_setting_value(key, &value)?;
|
||||
if font_name.trim().is_empty() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"appearance.text.font_name cannot be empty",
|
||||
));
|
||||
}
|
||||
FontSettings::handle(ctx)
|
||||
.update(ctx, |font_settings, ctx| {
|
||||
font_settings.monospace_font_name.set_value(font_name, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
|
||||
}
|
||||
"appearance.text.font_size" => {
|
||||
let font_size = valid_font_size(u32_setting_value(key, &value)?)?;
|
||||
FontSettings::handle(ctx)
|
||||
.update(ctx, |font_settings, ctx| {
|
||||
font_settings.monospace_font_size.set_value(font_size, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
|
||||
}
|
||||
"appearance.window.zoom_level" => {
|
||||
let zoom_level = valid_zoom_level(u32_setting_value(key, &value)?)?;
|
||||
WindowSettings::handle(ctx)
|
||||
.update(ctx, |window_settings, ctx| {
|
||||
window_settings.zoom_level.set_value(zoom_level, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
|
||||
}
|
||||
"terminal.input.syntax_highlighting" => {
|
||||
let enabled = bool_setting_value(key, &value)?;
|
||||
InputSettings::handle(ctx)
|
||||
.update(ctx, |input_settings, ctx| {
|
||||
input_settings.syntax_highlighting.set_value(enabled, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
|
||||
}
|
||||
"terminal.input.error_underlining_enabled" => {
|
||||
let enabled = bool_setting_value(key, &value)?;
|
||||
InputSettings::handle(ctx)
|
||||
.update(ctx, |input_settings, ctx| {
|
||||
input_settings.error_underlining.set_value(enabled, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
|
||||
}
|
||||
"accessibility.accessibility_verbosity" => {
|
||||
let verbosity = accessibility_verbosity_value(key, &value)?;
|
||||
AccessibilitySettings::handle(ctx)
|
||||
.update(ctx, |accessibility_settings, ctx| {
|
||||
accessibility_settings
|
||||
.a11y_verbosity
|
||||
.set_value(verbosity, ctx)
|
||||
})
|
||||
.map_err(|err| settings_write_error(ActionKind::SettingSet, err))
|
||||
}
|
||||
_ => Err(rejected_setting_key(key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_kind_for_name(
|
||||
name: &str,
|
||||
ctx: &ModelContext<LocalControlBridge>,
|
||||
) -> Result<ThemeKind, ControlError> {
|
||||
let matches = WarpConfig::as_ref(ctx)
|
||||
.theme_config()
|
||||
.theme_items()
|
||||
.filter_map(|(kind, _)| (public_theme_name(kind) == name).then_some(kind.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
match matches.as_slice() {
|
||||
[theme] => Ok(theme.clone()),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{name} is not an available theme"),
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{name} matches multiple themes"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_font_size(value: u32) -> Result<f32, ControlError> {
|
||||
if (5..=25).contains(&value) {
|
||||
return Ok(value as f32);
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"font size must be between 5 and 25",
|
||||
))
|
||||
}
|
||||
|
||||
fn valid_zoom_level(value: u32) -> Result<u16, ControlError> {
|
||||
let value = u16::try_from(value).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::InvalidParams,
|
||||
"zoom level is outside the supported range",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
if ZoomLevel::VALUES.contains(&value) {
|
||||
return Ok(value);
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"zoom level must be one of the supported zoom percentages",
|
||||
))
|
||||
}
|
||||
|
||||
fn bool_setting_value(key: &str, value: &serde_json::Value) -> Result<bool, ControlError> {
|
||||
value.as_bool().ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{key} requires a boolean value"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn string_setting_value(key: &str, value: &serde_json::Value) -> Result<String, ControlError> {
|
||||
value.as_str().map(str::to_owned).ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{key} requires a string value"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn u32_setting_value(key: &str, value: &serde_json::Value) -> Result<u32, ControlError> {
|
||||
if let Some(value) = value.as_u64().and_then(|value| u32::try_from(value).ok()) {
|
||||
return Ok(value);
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
format!("{key} requires a non-negative integer value"),
|
||||
))
|
||||
}
|
||||
|
||||
fn accessibility_verbosity_value(
|
||||
key: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<warpui::accessibility::AccessibilityVerbosity, ControlError> {
|
||||
match string_setting_value(key, value)?.as_str() {
|
||||
"Verbose" | "verbose" | "VERBOSE" => {
|
||||
Ok(warpui::accessibility::AccessibilityVerbosity::Verbose)
|
||||
}
|
||||
"Concise" | "concise" | "CONCISE" => {
|
||||
Ok(warpui::accessibility::AccessibilityVerbosity::Concise)
|
||||
}
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::InvalidParams,
|
||||
"accessibility.accessibility_verbosity must be Verbose or Concise",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_write_error(action: ActionKind, err: anyhow::Error) -> ControlError {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
format!("{} failed to update app settings", action.as_str()),
|
||||
err.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn tab_mutation_result(
|
||||
instance_id: &Option<InstanceId>,
|
||||
action: ActionKind,
|
||||
tab_id: String,
|
||||
window_id: WindowId,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"action": action.as_str(),
|
||||
"ok": true,
|
||||
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
"window_id": window_id.to_string(),
|
||||
"tab_id": tab_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn pane_mutation_result(
|
||||
instance_id: &Option<InstanceId>,
|
||||
action: ActionKind,
|
||||
pane_id: PaneId,
|
||||
tab_id: String,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"action": action.as_str(),
|
||||
"ok": true,
|
||||
"instance_id": instance_id.as_ref().map(|id| id.0.as_str()),
|
||||
"tab_id": tab_id,
|
||||
"pane_id": pane_id.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use super::{surface_unavailable_reason, SurfaceDestination};
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
#[test]
|
||||
fn agent_management_surface_reports_feature_flag_unavailable() {
|
||||
let flag_guard = FeatureFlag::AgentManagementView.override_enabled(false);
|
||||
warpui::App::test((), |mut app| async move {
|
||||
assert_eq!(
|
||||
app.update(|ctx| {
|
||||
surface_unavailable_reason(SurfaceDestination::AgentManagement, ctx)
|
||||
}),
|
||||
Some("agent management is unavailable or disabled")
|
||||
);
|
||||
});
|
||||
drop(flag_guard);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use ::local_control::protocol::{
|
||||
AppearanceStateResult, BindingNameParams, KeyParams, KeybindingGetResult, KeybindingListResult,
|
||||
KeybindingSummary, NamespaceParams, SettingGetResult, SettingListResult, SettingSummary,
|
||||
ThemeListResult, ThemeStateResult, ThemeSummary,
|
||||
};
|
||||
use ::local_control::{ControlError, ErrorCode};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use settings::Setting as _;
|
||||
use warpui::keymap::DescriptionContext;
|
||||
use warpui::{ModelContext, SingletonEntity};
|
||||
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::settings::{
|
||||
derived_theme_kind, AccessibilitySettings, FontSettings, InputSettings, ThemeSettings,
|
||||
};
|
||||
use crate::themes::theme::ThemeKind;
|
||||
use crate::user_config::WarpConfig;
|
||||
use crate::util::bindings::trigger_to_keystroke;
|
||||
use crate::WindowSettings;
|
||||
|
||||
pub(super) const ALLOWLISTED_SETTING_KEYS: &[&str] = &[
|
||||
"accessibility.accessibility_verbosity",
|
||||
"appearance.text.font_name",
|
||||
"appearance.text.font_size",
|
||||
"appearance.themes.dark_theme",
|
||||
"appearance.themes.light_theme",
|
||||
"appearance.themes.system_theme",
|
||||
"appearance.themes.theme",
|
||||
"appearance.window.zoom_level",
|
||||
"terminal.input.error_underlining_enabled",
|
||||
"terminal.input.syntax_highlighting",
|
||||
];
|
||||
|
||||
pub(crate) fn theme_list(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
to_control_data(theme_list_result(ctx)?)
|
||||
}
|
||||
|
||||
pub(crate) fn theme_get(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
to_control_data(theme_state_result(ctx)?)
|
||||
}
|
||||
|
||||
pub(crate) fn appearance_get(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
to_control_data(appearance_state_result(ctx)?)
|
||||
}
|
||||
|
||||
pub(crate) fn setting_list(
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let namespace = optional_namespace(action)?;
|
||||
to_control_data(setting_list_result(namespace.as_deref(), ctx)?)
|
||||
}
|
||||
|
||||
pub(crate) fn setting_get(
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let key = key(action)?;
|
||||
to_control_data(setting_get_result(&key, ctx)?)
|
||||
}
|
||||
|
||||
pub(crate) fn keybinding_list(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
to_control_data(KeybindingListResult {
|
||||
keybindings: keybinding_summaries(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn keybinding_get(
|
||||
action: &::local_control::Action,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<serde_json::Value, ControlError> {
|
||||
let binding_name = binding_name(action)?;
|
||||
let keybinding = keybinding_summaries(ctx)
|
||||
.into_iter()
|
||||
.find(|summary| summary.name == binding_name)
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("keybinding.get could not find {binding_name}"),
|
||||
)
|
||||
})?;
|
||||
to_control_data(KeybindingGetResult { keybinding })
|
||||
}
|
||||
|
||||
fn theme_list_result(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ThemeListResult, ControlError> {
|
||||
let current_theme = active_theme_kind(ThemeSettings::as_ref(ctx), ctx);
|
||||
let mut themes = WarpConfig::as_ref(ctx)
|
||||
.theme_config()
|
||||
.theme_items()
|
||||
.map(|(kind, _)| ThemeSummary {
|
||||
name: public_theme_name(kind),
|
||||
is_current: *kind == current_theme,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
themes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(ThemeListResult { themes })
|
||||
}
|
||||
|
||||
fn theme_state_result(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ThemeStateResult, ControlError> {
|
||||
let theme_settings = ThemeSettings::as_ref(ctx);
|
||||
let system_themes = theme_settings.selected_system_themes.value();
|
||||
Ok(ThemeStateResult {
|
||||
name: public_theme_name(theme_settings.theme_kind.value()),
|
||||
follow_system_theme: *theme_settings.use_system_theme.value(),
|
||||
light_theme: Some(public_theme_name(&system_themes.light)),
|
||||
dark_theme: Some(public_theme_name(&system_themes.dark)),
|
||||
})
|
||||
}
|
||||
|
||||
fn appearance_state_result(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<AppearanceStateResult, ControlError> {
|
||||
let theme_settings = ThemeSettings::as_ref(ctx);
|
||||
let font_settings = FontSettings::as_ref(ctx);
|
||||
let window_settings = WindowSettings::as_ref(ctx);
|
||||
let system_themes = theme_settings.selected_system_themes.value();
|
||||
Ok(AppearanceStateResult {
|
||||
theme: Some(public_theme_name(theme_settings.theme_kind.value())),
|
||||
follow_system_theme: *theme_settings.use_system_theme.value(),
|
||||
light_theme: Some(public_theme_name(&system_themes.light)),
|
||||
dark_theme: Some(public_theme_name(&system_themes.dark)),
|
||||
font_size: rounded_u32(*font_settings.monospace_font_size.value()),
|
||||
ui_zoom_percent: Some(u32::from(*window_settings.zoom_level.value())),
|
||||
})
|
||||
}
|
||||
|
||||
fn setting_list_result(
|
||||
namespace: Option<&str>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<SettingListResult, ControlError> {
|
||||
let settings = ALLOWLISTED_SETTING_KEYS
|
||||
.iter()
|
||||
.filter(|key| namespace.is_none_or(|namespace| key.starts_with(namespace)))
|
||||
.map(|key| setting_summary_for_key(key, ctx))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(SettingListResult { settings })
|
||||
}
|
||||
|
||||
fn setting_get_result(
|
||||
key: &str,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<SettingGetResult, ControlError> {
|
||||
Ok(SettingGetResult {
|
||||
setting: setting_summary_for_key(key, ctx)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn rejected_setting_key(key: &str) -> ControlError {
|
||||
ControlError::new(
|
||||
ErrorCode::NotAllowlisted,
|
||||
format!("{key} is not an allowlisted local-control setting"),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn setting_summary_for_key(
|
||||
key: &str,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<SettingSummary, ControlError> {
|
||||
let theme_settings = ThemeSettings::as_ref(ctx);
|
||||
let font_settings = FontSettings::as_ref(ctx);
|
||||
let input_settings = InputSettings::as_ref(ctx);
|
||||
let accessibility_settings = AccessibilitySettings::as_ref(ctx);
|
||||
let window_settings = WindowSettings::as_ref(ctx);
|
||||
match key {
|
||||
"appearance.themes.theme" => Ok(setting_summary(
|
||||
key,
|
||||
json!(public_theme_name(theme_settings.theme_kind.value())),
|
||||
"string",
|
||||
)),
|
||||
"appearance.themes.system_theme" => Ok(setting_summary(
|
||||
key,
|
||||
json!(*theme_settings.use_system_theme.value()),
|
||||
"bool",
|
||||
)),
|
||||
"appearance.themes.light_theme" => Ok(setting_summary(
|
||||
key,
|
||||
json!(public_theme_name(
|
||||
&theme_settings.selected_system_themes.value().light
|
||||
)),
|
||||
"string",
|
||||
)),
|
||||
"appearance.themes.dark_theme" => Ok(setting_summary(
|
||||
key,
|
||||
json!(public_theme_name(
|
||||
&theme_settings.selected_system_themes.value().dark
|
||||
)),
|
||||
"string",
|
||||
)),
|
||||
"appearance.text.font_name" => Ok(setting_summary(
|
||||
key,
|
||||
json!(font_settings.monospace_font_name.value()),
|
||||
"string",
|
||||
)),
|
||||
"appearance.text.font_size" => Ok(setting_summary(
|
||||
key,
|
||||
json!(*font_settings.monospace_font_size.value()),
|
||||
"number",
|
||||
)),
|
||||
"appearance.window.zoom_level" => Ok(setting_summary(
|
||||
key,
|
||||
json!(*window_settings.zoom_level.value()),
|
||||
"number",
|
||||
)),
|
||||
"terminal.input.syntax_highlighting" => Ok(setting_summary(
|
||||
key,
|
||||
json!(*input_settings.syntax_highlighting.value()),
|
||||
"bool",
|
||||
)),
|
||||
"terminal.input.error_underlining_enabled" => Ok(setting_summary(
|
||||
key,
|
||||
json!(*input_settings.error_underlining.value()),
|
||||
"bool",
|
||||
)),
|
||||
"accessibility.accessibility_verbosity" => Ok(setting_summary(
|
||||
key,
|
||||
json!(format!(
|
||||
"{:?}",
|
||||
accessibility_settings.a11y_verbosity.value()
|
||||
)),
|
||||
"string",
|
||||
)),
|
||||
_ => Err(rejected_setting_key(key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn setting_summary(key: &str, value: Value, value_type: &str) -> SettingSummary {
|
||||
SettingSummary {
|
||||
key: key.to_owned(),
|
||||
value,
|
||||
value_type: value_type.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn public_theme_name(theme: &ThemeKind) -> String {
|
||||
theme.to_string()
|
||||
}
|
||||
|
||||
fn active_theme_kind(
|
||||
theme_settings: &ThemeSettings,
|
||||
ctx: &ModelContext<LocalControlBridge>,
|
||||
) -> ThemeKind {
|
||||
derived_theme_kind(theme_settings, ctx.system_theme())
|
||||
}
|
||||
|
||||
fn rounded_u32(value: f32) -> Option<u32> {
|
||||
if value.is_finite() && value >= 0.0 && value <= u32::MAX as f32 {
|
||||
return Some(value.round() as u32);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn keybinding_summaries(ctx: &mut ModelContext<LocalControlBridge>) -> Vec<KeybindingSummary> {
|
||||
let mut keybindings = ctx
|
||||
.editable_bindings()
|
||||
.map(|binding| {
|
||||
let keystroke = trigger_to_keystroke(binding.trigger);
|
||||
KeybindingSummary {
|
||||
name: binding.name.to_owned(),
|
||||
description: binding
|
||||
.description
|
||||
.materialized(ctx)
|
||||
.in_context(DescriptionContext::Default)
|
||||
.to_owned(),
|
||||
group: binding.group.map(str::to_owned),
|
||||
keystroke: keystroke.as_ref().map(|keystroke| keystroke.displayed()),
|
||||
normalized_keystroke: keystroke.map(|keystroke| keystroke.normalized()),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
keybindings.sort_by(|left, right| {
|
||||
left.description
|
||||
.cmp(&right.description)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
keybindings
|
||||
.dedup_by(|left, right| left.name == right.name && left.description == right.description);
|
||||
keybindings
|
||||
}
|
||||
|
||||
fn optional_namespace(action: &::local_control::Action) -> Result<Option<String>, ControlError> {
|
||||
Ok(action.params_as::<NamespaceParams>()?.namespace)
|
||||
}
|
||||
|
||||
fn key(action: &::local_control::Action) -> Result<String, ControlError> {
|
||||
Ok(action.params_as::<KeyParams>()?.key)
|
||||
}
|
||||
|
||||
fn binding_name(action: &::local_control::Action) -> Result<String, ControlError> {
|
||||
Ok(action.params_as::<BindingNameParams>()?.binding_name)
|
||||
}
|
||||
|
||||
fn to_control_data<T: Serialize>(value: T) -> Result<serde_json::Value, ControlError> {
|
||||
serde_json::to_value(value).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to serialize local-control response",
|
||||
err.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//! Running app-side server for local Warp control requests.
|
||||
//!
|
||||
//! This module owns the in-process listener, discovery registration, credential
|
||||
//! broker socket, and request handoff from Axum into the WarpUI model graph.
|
||||
//! It complements `crates/local_control/src/discovery.rs`: that shared module
|
||||
//! defines how clients find and validate candidate instances, while this module
|
||||
//! creates the app-owned endpoints and publishes their routing metadata through
|
||||
//! `RegisteredInstance`.
|
||||
//!
|
||||
//! A client uses all three transports in order. It reads the filesystem record
|
||||
//! to find an instance, connects to that instance's Unix socket to obtain
|
||||
//! temporary authority, and presents that authority to the instance's loopback
|
||||
//! HTTP endpoint with one typed action. The filesystem and socket are therefore
|
||||
//! complementary parts of discovery and credential bootstrap, not competing
|
||||
//! discovery mechanisms.
|
||||
//!
|
||||
//! Credential broker security flow:
|
||||
//!
|
||||
//! ```text
|
||||
//! owner-only discovery record
|
||||
//! (loopback endpoint + broker path; never a token)
|
||||
//! |
|
||||
//! v
|
||||
//! CLI client -- instance-bound Unix socket --> credential broker
|
||||
//! [0600 socket + kernel-reported peer UID]
|
||||
//! |
|
||||
//! v
|
||||
//! feature flag + Settings > Scripting gate
|
||||
//! + protocol + exact action metadata
|
||||
//! |
|
||||
//! v
|
||||
//! short-lived, instance-bound, action-scoped
|
||||
//! bearer grant stored only in process memory
|
||||
//! |
|
||||
//! v
|
||||
//! CLI client -- loopback HTTP + bearer --> /v1/control
|
||||
//! [reject browser Origin + require exact Host
|
||||
//! + validate grant existence, expiry, instance, and scope]
|
||||
//! |
|
||||
//! v
|
||||
//! typed allowlisted action
|
||||
//! |
|
||||
//! v
|
||||
//! main-thread LocalControlBridge
|
||||
//! [re-check current settings before dispatch]
|
||||
//! ```
|
||||
//!
|
||||
//! These boundaries prevent browser-origin clients, other OS users,
|
||||
//! unauthenticated clients that only obtain or guess the HTTP endpoint, stale
|
||||
//! or wrong-instance credentials, and accidentally over-scoped credentials from
|
||||
//! invoking actions. The broker authenticates the OS account, not the calling
|
||||
//! application: malicious software already running as the same user remains
|
||||
//! outside this boundary.
|
||||
//!
|
||||
//! The Settings > Scripting gates used here are local-only settings backed by
|
||||
//! Warp's secure storage provider.
|
||||
//!
|
||||
//! Discovery records never include raw bearer tokens: discovery only exposes
|
||||
//! endpoint metadata and credential broker references while Scripting is enabled.
|
||||
mod bridge;
|
||||
mod handlers;
|
||||
mod permissions;
|
||||
mod resolver;
|
||||
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::fs::Permissions;
|
||||
use std::net::SocketAddr;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ::local_control::auth::CredentialGrant;
|
||||
#[cfg(any(unix, test))]
|
||||
use ::local_control::auth::{CredentialRequest, ScopedCredential};
|
||||
use ::local_control::{
|
||||
ActionKind, AuthToken, ControlEndpoint, ControlError, ControlResponse, ErrorCode,
|
||||
ErrorResponseEnvelope, InstanceId, InstanceRecord, RegisteredInstance, RequestEnvelope,
|
||||
ResponseEnvelope,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::header::{AUTHORIZATION, HOST, ORIGIN};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
pub use bridge::LocalControlBridge;
|
||||
#[cfg(any(unix, test))]
|
||||
use chrono::Duration;
|
||||
use permissions::ensure_feature_enabled;
|
||||
#[cfg(any(unix, test))]
|
||||
use permissions::{ensure_action_allowed, ensure_protocol_version};
|
||||
#[cfg(unix)]
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
const MAX_ACTIVE_CREDENTIALS: usize = 128;
|
||||
|
||||
/// App-owned authority shared by one instance's broker and HTTP listener.
|
||||
///
|
||||
/// Broker-issued bearer tokens map to grants only in this process-local state.
|
||||
/// Knowing the endpoint from discovery is therefore insufficient to authenticate
|
||||
/// an HTTP request.
|
||||
#[derive(Clone)]
|
||||
struct ControlServerState {
|
||||
bridge_spawner: ModelSpawner<LocalControlBridge>,
|
||||
instance_id: InstanceId,
|
||||
expected_host: String,
|
||||
credentials: Arc<Mutex<HashMap<String, CredentialGrant>>>,
|
||||
}
|
||||
/// Process-local publisher, credential broker, and HTTP server for one Warp instance.
|
||||
///
|
||||
/// Holding the runtime and registration keeps both listeners and the discovery
|
||||
/// route alive. Dropping them stops request handling and removes the app's
|
||||
/// published record and broker socket.
|
||||
pub struct LocalControlServer {
|
||||
_runtime: Option<tokio::runtime::Runtime>,
|
||||
control_endpoint: Option<ControlEndpoint>,
|
||||
registered_instance: Option<RegisteredInstance>,
|
||||
}
|
||||
|
||||
impl Entity for LocalControlServer {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for LocalControlServer {}
|
||||
|
||||
impl LocalControlServer {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let mut server = Self {
|
||||
_runtime: None,
|
||||
control_endpoint: None,
|
||||
registered_instance: None,
|
||||
};
|
||||
if let Err(error) = server.refresh_for_settings(ctx) {
|
||||
log::warn!("Failed to refresh local-control server state: {error:#}");
|
||||
}
|
||||
ctx.subscribe_to_model(
|
||||
&crate::settings::LocalControlSettings::handle(ctx),
|
||||
|server, _, _, ctx| {
|
||||
if let Err(error) = server.refresh_for_settings(ctx) {
|
||||
log::warn!("Failed to refresh local-control server state: {error:#}");
|
||||
}
|
||||
},
|
||||
);
|
||||
server
|
||||
}
|
||||
|
||||
/// Starts, refreshes, or removes local-control publication as settings change.
|
||||
fn refresh_for_settings(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> {
|
||||
if !permissions::warp_control_cli_enabled() {
|
||||
self.stop(ctx);
|
||||
return Ok(());
|
||||
}
|
||||
if !local_control_publication_supported() {
|
||||
self.stop(ctx);
|
||||
return Ok(());
|
||||
}
|
||||
if !crate::settings::LocalControlSettings::as_ref(ctx).is_enabled() {
|
||||
self.stop(ctx);
|
||||
return Ok(());
|
||||
}
|
||||
if self._runtime.is_some() {
|
||||
return self.refresh_discovery_record(ctx);
|
||||
}
|
||||
self.start(ctx)
|
||||
}
|
||||
|
||||
/// Stops both listeners and removes the discovery record and broker socket.
|
||||
fn stop(&mut self, _ctx: &mut ModelContext<Self>) {
|
||||
self.registered_instance = None;
|
||||
self.control_endpoint = None;
|
||||
self._runtime = None;
|
||||
}
|
||||
|
||||
/// Binds both transports and publishes the routing record that connects them.
|
||||
///
|
||||
/// Startup first binds an ephemeral loopback HTTP port, publishes that port
|
||||
/// plus the instance-derived broker filename, binds the broker socket, and
|
||||
/// then serves credential issuance and typed control requests concurrently.
|
||||
fn start(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), ControlError> {
|
||||
if self._runtime.is_some() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::Internal,
|
||||
"local-control server is already running",
|
||||
));
|
||||
}
|
||||
ensure_feature_enabled()?;
|
||||
if !local_control_publication_supported() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::LocalControlDisabled,
|
||||
"local control is disabled until this platform enforces discovery-record ACLs",
|
||||
));
|
||||
}
|
||||
if !crate::settings::LocalControlSettings::as_ref(ctx).is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_io()
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to create local-control runtime",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let listener = runtime
|
||||
.block_on(tokio::net::TcpListener::bind(SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
0,
|
||||
))))
|
||||
.map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to bind local-control listener",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let port = listener.local_addr().map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to read local-control listener address",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let control_endpoint = ControlEndpoint::localhost(port.port());
|
||||
let record = discovery_record_for_settings(ctx, control_endpoint.clone());
|
||||
let instance_id = record.instance_id.clone();
|
||||
let bridge_spawner = LocalControlBridge::handle(ctx).update(ctx, |bridge, ctx| {
|
||||
bridge.set_instance_id(instance_id.clone());
|
||||
ctx.spawner()
|
||||
});
|
||||
let registered_instance = RegisteredInstance::register(record)?;
|
||||
#[cfg(unix)]
|
||||
let broker_listener = {
|
||||
let runtime_guard = runtime.enter();
|
||||
let listener = bind_credential_broker(registered_instance.record())?;
|
||||
drop(runtime_guard);
|
||||
listener
|
||||
};
|
||||
let state = ControlServerState {
|
||||
bridge_spawner,
|
||||
instance_id,
|
||||
expected_host: format!("{}:{}", control_endpoint.host, control_endpoint.port),
|
||||
credentials: Arc::default(),
|
||||
};
|
||||
let router = Router::new()
|
||||
.route("/v1/control", post(handle_control_request))
|
||||
.with_state(state.clone());
|
||||
runtime.spawn(async move {
|
||||
if let Err(err) = axum::serve(listener, router).await {
|
||||
log::warn!("local-control listener stopped: {err:#}");
|
||||
}
|
||||
});
|
||||
#[cfg(unix)]
|
||||
runtime.spawn(run_credential_broker(broker_listener, state));
|
||||
let endpoint_url = control_endpoint.url();
|
||||
self._runtime = Some(runtime);
|
||||
self.control_endpoint = Some(control_endpoint);
|
||||
self.registered_instance = Some(registered_instance);
|
||||
log::info!("local-control server started at {endpoint_url}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_discovery_record(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), ControlError> {
|
||||
let Some(control_endpoint) = self.control_endpoint.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(registered_instance) = &mut self.registered_instance else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut record = discovery_record_for_settings(ctx, control_endpoint);
|
||||
record.instance_id = registered_instance.record().instance_id.clone();
|
||||
record.credential_broker = registered_instance.record().credential_broker.clone();
|
||||
registered_instance.update(record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds routing metadata without embedding any bearer credential or secret.
|
||||
///
|
||||
/// The endpoint and derived broker reference are published only while the
|
||||
/// protected Scripting setting permits clients to use them.
|
||||
fn discovery_record_for_settings(
|
||||
ctx: &ModelContext<LocalControlServer>,
|
||||
control_endpoint: ControlEndpoint,
|
||||
) -> InstanceRecord {
|
||||
let endpoint = crate::settings::LocalControlSettings::as_ref(ctx)
|
||||
.is_enabled()
|
||||
.then_some(control_endpoint);
|
||||
InstanceRecord::for_current_process(
|
||||
endpoint,
|
||||
ChannelState::channel().to_string(),
|
||||
ChannelState::app_id().to_string(),
|
||||
ChannelState::app_version().map(str::to_owned),
|
||||
ActionKind::implemented_metadata(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Binds the instance's credential-bootstrap socket and restricts it to the owning user.
|
||||
///
|
||||
/// Any stale socket at the instance-specific path is removed before binding, and
|
||||
/// the new socket is set to owner-only permissions before it accepts clients.
|
||||
/// The path came from a validated instance-derived discovery reference, so a
|
||||
/// record cannot redirect credential requests to an arbitrary socket.
|
||||
#[cfg(unix)]
|
||||
fn bind_credential_broker(
|
||||
record: &InstanceRecord,
|
||||
) -> Result<tokio::net::UnixListener, ControlError> {
|
||||
let socket_path = record.broker_socket_path()?;
|
||||
if socket_path.exists() {
|
||||
std::fs::remove_file(&socket_path).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to remove stale local-control credential broker socket",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let listener = tokio::net::UnixListener::bind(&socket_path).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to bind owner-authenticated local-control credential broker",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
std::fs::set_permissions(&socket_path, Permissions::from_mode(0o600)).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to protect local-control credential broker socket",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Accepts same-user credential requests independently from the HTTP listener.
|
||||
async fn run_credential_broker(listener: tokio::net::UnixListener, state: ControlServerState) {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle_credential_broker_connection(stream, state).await {
|
||||
log::warn!("local-control credential broker connection failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Authenticates the socket peer before decoding and evaluating its request.
|
||||
///
|
||||
/// This ordering makes the kernel-reported OS user, rather than any field in
|
||||
/// caller-controlled JSON, the credential broker's client-identity boundary.
|
||||
async fn handle_credential_broker_connection(
|
||||
mut stream: tokio::net::UnixStream,
|
||||
state: ControlServerState,
|
||||
) -> Result<(), ControlError> {
|
||||
let response = match ensure_same_user_peer(&stream) {
|
||||
Ok(()) => {
|
||||
let mut bytes = Vec::new();
|
||||
stream.read_to_end(&mut bytes).await.map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::InvalidRequest,
|
||||
"failed to read local-control credential request",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
match serde_json::from_slice::<CredentialRequest>(&bytes) {
|
||||
Ok(request) => issue_credential(&state, request)
|
||||
.await
|
||||
.and_then(|credential| serialize_credential_broker_response(&credential)),
|
||||
Err(err) => Err(ControlError::with_details(
|
||||
ErrorCode::InvalidRequest,
|
||||
"failed to decode local-control credential request",
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let bytes = match response {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => serialize_credential_broker_response(&ErrorResponseEnvelope::new(error))?,
|
||||
};
|
||||
stream.write_all(&bytes).await.map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::TransportUnavailable,
|
||||
"failed to write local-control credential response",
|
||||
err.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Requires the kernel-reported peer UID to match Warp's effective UID.
|
||||
///
|
||||
/// This excludes other OS users but does not distinguish trusted Warp code from
|
||||
/// arbitrary processes already running as the same user.
|
||||
fn ensure_same_user_peer(stream: &tokio::net::UnixStream) -> Result<(), ControlError> {
|
||||
ensure_peer_uid(stream, unsafe { libc::geteuid() })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Verifies a socket peer against an expected UID obtained outside request data.
|
||||
fn ensure_peer_uid(stream: &tokio::net::UnixStream, expected_uid: u32) -> Result<(), ControlError> {
|
||||
let peer = stream.peer_cred().map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::UnauthorizedLocalClient,
|
||||
"failed to identify local-control credential broker peer",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
if peer.uid() != expected_uid {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnauthorizedLocalClient,
|
||||
"local-control credential broker peer belongs to a different OS user",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn serialize_credential_broker_response(
|
||||
response: &impl serde::Serialize,
|
||||
) -> Result<Vec<u8>, ControlError> {
|
||||
serde_json::to_vec(response).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::Internal,
|
||||
"failed to serialize local-control credential response",
|
||||
err.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluates current action policy and mints one short-lived exact-action grant.
|
||||
///
|
||||
/// The bearer secret and its grant are retained only in the running instance's
|
||||
/// process-local map; neither is written back into the discovery registry.
|
||||
#[cfg(any(unix, test))]
|
||||
async fn issue_credential(
|
||||
state: &ControlServerState,
|
||||
request: CredentialRequest,
|
||||
) -> Result<ScopedCredential, ControlError> {
|
||||
ensure_feature_enabled()?;
|
||||
ensure_protocol_version(request.protocol_version)?;
|
||||
if !request.action.is_implemented() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnsupportedAction,
|
||||
format!(
|
||||
"{} is not implemented by this local-control bridge",
|
||||
request.action.as_str()
|
||||
),
|
||||
));
|
||||
}
|
||||
state
|
||||
.bridge_spawner
|
||||
.spawn({
|
||||
let action = request.action;
|
||||
move |_, ctx| ensure_action_allowed(action, ctx)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ControlError::new(
|
||||
ErrorCode::BridgeUnavailable,
|
||||
"local-control app bridge is unavailable",
|
||||
)
|
||||
})??;
|
||||
let auth_token = AuthToken::generate();
|
||||
let grant = CredentialGrant::new(
|
||||
state.instance_id.clone(),
|
||||
request.action,
|
||||
Duration::minutes(5),
|
||||
);
|
||||
let mut credentials = state.credentials.lock().map_err(|_| {
|
||||
ControlError::new(
|
||||
ErrorCode::Internal,
|
||||
"local-control credential broker is unavailable",
|
||||
)
|
||||
})?;
|
||||
insert_credential(
|
||||
&mut credentials,
|
||||
auth_token.secret().to_owned(),
|
||||
grant.clone(),
|
||||
);
|
||||
Ok(ScopedCredential {
|
||||
bearer_token: auth_token.secret().to_owned(),
|
||||
grant,
|
||||
})
|
||||
}
|
||||
|
||||
/// Authenticates and hands one typed HTTP request to the app bridge.
|
||||
///
|
||||
/// Header hardening rejects browser-origin and wrong-endpoint requests. The
|
||||
/// process-local credential lookup authenticates the transport, after which the
|
||||
/// bridge revalidates current settings and exact-action authority before
|
||||
/// resolving targets or dispatching a handler.
|
||||
async fn handle_control_request(
|
||||
State(state): State<ControlServerState>,
|
||||
headers: HeaderMap,
|
||||
payload: Bytes,
|
||||
) -> Response {
|
||||
if let Err(error) = validate_loopback_headers(&headers, &state.expected_host) {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ErrorResponseEnvelope::new(error)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(error) = ensure_feature_enabled() {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ErrorResponseEnvelope::new(error)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let auth_header = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let auth_token = match AuthToken::from_authorization_header(auth_header) {
|
||||
Ok(token) => token,
|
||||
Err(error) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponseEnvelope::new(error)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let grant = match state.credentials.lock() {
|
||||
Ok(mut credentials) => lookup_credential(&mut credentials, &auth_token, &state.instance_id),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponseEnvelope::new(ControlError::new(
|
||||
ErrorCode::Internal,
|
||||
"local-control credential broker is unavailable",
|
||||
))),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let grant = match grant {
|
||||
Ok(grant) => grant,
|
||||
Err(error) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponseEnvelope::new(error)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let request = match serde_json::from_slice::<RequestEnvelope>(&payload) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponseEnvelope::new(ControlError::with_details(
|
||||
ErrorCode::InvalidRequest,
|
||||
"failed to decode local-control request",
|
||||
err.to_string(),
|
||||
))),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let request_id = request.request_id;
|
||||
let response = match state
|
||||
.bridge_spawner
|
||||
.spawn(move |bridge, ctx| bridge.handle_request(request, grant, ctx))
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => ResponseEnvelope::error(
|
||||
request_id,
|
||||
ControlError::new(
|
||||
ErrorCode::BridgeUnavailable,
|
||||
"local-control app bridge is unavailable",
|
||||
),
|
||||
),
|
||||
};
|
||||
let status = match &response.response {
|
||||
ControlResponse::Ok { .. } => StatusCode::OK,
|
||||
ControlResponse::Error { .. } => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
(status, Json(response)).into_response()
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn insert_credential(
|
||||
credentials: &mut HashMap<String, CredentialGrant>,
|
||||
secret: String,
|
||||
grant: CredentialGrant,
|
||||
) {
|
||||
credentials.retain(|_, grant| !grant.is_expired());
|
||||
if credentials.len() >= MAX_ACTIVE_CREDENTIALS {
|
||||
let oldest_secret = credentials
|
||||
.iter()
|
||||
.min_by_key(|(_, grant)| grant.issued_at)
|
||||
.map(|(secret, _)| secret.clone());
|
||||
if let Some(oldest_secret) = oldest_secret {
|
||||
credentials.remove(&oldest_secret);
|
||||
}
|
||||
}
|
||||
credentials.insert(secret, grant);
|
||||
}
|
||||
|
||||
/// Resolves an unexpired bearer token issued by this exact running instance.
|
||||
fn lookup_credential(
|
||||
credentials: &mut HashMap<String, CredentialGrant>,
|
||||
auth_token: &AuthToken,
|
||||
instance_id: &InstanceId,
|
||||
) -> Result<CredentialGrant, ControlError> {
|
||||
if credentials
|
||||
.get(auth_token.secret())
|
||||
.is_some_and(CredentialGrant::is_expired)
|
||||
{
|
||||
credentials.remove(auth_token.secret());
|
||||
}
|
||||
let grant = credentials
|
||||
.get(auth_token.secret())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::UnauthorizedLocalClient,
|
||||
"local-control credential is invalid",
|
||||
)
|
||||
})?;
|
||||
grant.verify_for_action(instance_id, grant.action)?;
|
||||
Ok(grant)
|
||||
}
|
||||
fn local_control_publication_supported() -> bool {
|
||||
cfg!(not(target_os = "windows"))
|
||||
}
|
||||
|
||||
/// Performs browser-origin hardening for local-control endpoints.
|
||||
///
|
||||
/// These checks intentionally reject browser-style `Origin` requests and stale
|
||||
/// endpoint selections, but they are not an authorization boundary. Scoped
|
||||
/// bearer credentials and grant validation remain the authority for control
|
||||
/// requests.
|
||||
pub(crate) fn validate_loopback_headers(
|
||||
headers: &HeaderMap,
|
||||
expected_host: &str,
|
||||
) -> Result<(), ControlError> {
|
||||
if headers.contains_key(ORIGIN) {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnauthorizedLocalClient,
|
||||
"browser-origin local-control requests are not allowed",
|
||||
));
|
||||
}
|
||||
let host = headers
|
||||
.get(HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::UnauthorizedLocalClient,
|
||||
"Host header is required for local-control requests",
|
||||
)
|
||||
})?;
|
||||
if host != expected_host {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::UnauthorizedLocalClient,
|
||||
"Host header does not match the selected local-control endpoint",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use bridge::validate_request_authority;
|
||||
#[cfg(test)]
|
||||
pub(crate) use permissions::{capabilities, ensure_settings_allow_action};
|
||||
#[cfg(test)]
|
||||
pub(crate) use resolver::{
|
||||
require_active_window_id, resolve_index_from_ids, resolve_title_from_matches,
|
||||
validate_action_params, validate_tab_create_target,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,442 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ::local_control::auth::{CredentialGrant, CredentialRequest};
|
||||
use ::local_control::protocol::{
|
||||
Action, ActionKind, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector,
|
||||
WindowSelector, WindowTarget,
|
||||
};
|
||||
use ::local_control::{ErrorCode, InstanceId, RequestEnvelope};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::header::{AUTHORIZATION, HOST, ORIGIN};
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use chrono::Duration;
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::SingletonEntity as _;
|
||||
|
||||
#[cfg(unix)]
|
||||
use super::ensure_peer_uid;
|
||||
use super::resolver::validate_action_target;
|
||||
use super::{
|
||||
capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action,
|
||||
handle_control_request, insert_credential, issue_credential, lookup_credential,
|
||||
require_active_window_id, resolve_index_from_ids, resolve_title_from_matches,
|
||||
validate_action_params, validate_loopback_headers, validate_request_authority,
|
||||
validate_tab_create_target, ControlServerState, LocalControlBridge, LocalControlServer,
|
||||
MAX_ACTIVE_CREDENTIALS,
|
||||
};
|
||||
use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings};
|
||||
|
||||
fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings {
|
||||
LocalControlSettings {
|
||||
local_control_mode: LocalControlModeSetting::new(Some(mode)),
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn credential_broker_rejects_peer_from_different_user() {
|
||||
let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair");
|
||||
let actual_uid = stream.peer_cred().expect("peer credentials").uid();
|
||||
let different_uid = if actual_uid == u32::MAX {
|
||||
actual_uid - 1
|
||||
} else {
|
||||
actual_uid + 1
|
||||
};
|
||||
|
||||
let err = ensure_peer_uid(&stream, different_uid).expect_err("different user is rejected");
|
||||
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn credential_broker_accepts_peer_from_same_user() {
|
||||
let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair");
|
||||
let actual_uid = stream.peer_cred().expect("peer credentials").uid();
|
||||
|
||||
ensure_peer_uid(&stream, actual_uid).expect("same user is accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_version_helper_rejects_unsupported_versions() {
|
||||
ensure_protocol_version(::local_control::PROTOCOL_VERSION)
|
||||
.expect("current version is accepted");
|
||||
|
||||
let err = ensure_protocol_version(::local_control::PROTOCOL_VERSION + 1)
|
||||
.expect_err("future protocol version is rejected");
|
||||
assert_eq!(err.code, ErrorCode::ProtocolVersionUnsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_accepts_default_and_window_targets() {
|
||||
validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted");
|
||||
|
||||
validate_tab_create_target(&TargetSelector {
|
||||
window: Some(WindowTarget::Id {
|
||||
id: WindowSelector("window".to_owned()),
|
||||
}),
|
||||
tab: None,
|
||||
pane: None,
|
||||
session: None,
|
||||
})
|
||||
.expect("window id target is accepted");
|
||||
|
||||
validate_tab_create_target(&TargetSelector {
|
||||
window: Some(WindowTarget::Index { index: 0 }),
|
||||
tab: None,
|
||||
pane: None,
|
||||
session: None,
|
||||
})
|
||||
.expect("window index target is accepted");
|
||||
|
||||
validate_tab_create_target(&TargetSelector {
|
||||
window: Some(WindowTarget::Title {
|
||||
title: "window".to_owned(),
|
||||
}),
|
||||
tab: None,
|
||||
pane: None,
|
||||
session: None,
|
||||
})
|
||||
.expect("window title target is accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_rejects_lower_level_targets() {
|
||||
let err = validate_tab_create_target(&TargetSelector {
|
||||
window: None,
|
||||
tab: Some(TabTarget::Id {
|
||||
id: TabSelector("tab".to_owned()),
|
||||
}),
|
||||
pane: None,
|
||||
session: None,
|
||||
})
|
||||
.expect_err("concrete tab target is rejected");
|
||||
assert_eq!(err.code, ErrorCode::InvalidSelector);
|
||||
|
||||
let err = validate_tab_create_target(&TargetSelector {
|
||||
window: None,
|
||||
tab: None,
|
||||
pane: Some(PaneTarget::Id {
|
||||
id: PaneSelector("pane".to_owned()),
|
||||
}),
|
||||
session: None,
|
||||
})
|
||||
.expect_err("concrete pane target is rejected");
|
||||
assert_eq!(err.code, ErrorCode::InvalidSelector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_rejects_unsupported_selector_forms() {
|
||||
let err = validate_tab_create_target(&TargetSelector {
|
||||
window: None,
|
||||
tab: Some(TabTarget::Index { index: 0 }),
|
||||
pane: None,
|
||||
session: None,
|
||||
})
|
||||
.expect_err("indexed tab target is rejected");
|
||||
assert_eq!(err.code, ErrorCode::InvalidSelector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_list_rejects_target_selectors() {
|
||||
let error = validate_action_target(
|
||||
ActionKind::SurfaceList,
|
||||
&TargetSelector {
|
||||
window: Some(WindowTarget::Active),
|
||||
tab: None,
|
||||
pane: None,
|
||||
session: None,
|
||||
},
|
||||
)
|
||||
.expect_err("surface.list is instance-wide");
|
||||
assert_eq!(error.code, ErrorCode::InvalidSelector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_advertises_the_complete_catalog() {
|
||||
assert_eq!(capabilities().len(), 84);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_headers_reject_origin_and_host_mismatch() {
|
||||
let expected_host = "127.0.0.1:1234";
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HOST, HeaderValue::from_static(expected_host));
|
||||
|
||||
validate_loopback_headers(&headers, expected_host).expect("matching host should be accepted");
|
||||
|
||||
headers.insert(ORIGIN, HeaderValue::from_static("https://example.com"));
|
||||
let err =
|
||||
validate_loopback_headers(&headers, expected_host).expect_err("origin should be rejected");
|
||||
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
||||
|
||||
headers.remove(ORIGIN);
|
||||
headers.insert(HOST, HeaderValue::from_static("localhost:1234"));
|
||||
let err = validate_loopback_headers(&headers, expected_host)
|
||||
.expect_err("host mismatch should be rejected");
|
||||
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
||||
|
||||
let headers = HeaderMap::new();
|
||||
let err = validate_loopback_headers(&headers, expected_host)
|
||||
.expect_err("missing host should be rejected");
|
||||
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripting_mode_controls_local_control() {
|
||||
assert!(!settings_with_mode(LocalControlMode::Disabled).is_enabled());
|
||||
assert!(settings_with_mode(LocalControlMode::Enabled).is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_requires_active_window() {
|
||||
let active = warpui::WindowId::from_usize(1);
|
||||
|
||||
assert_eq!(
|
||||
require_active_window_id(Some(active)).expect("active"),
|
||||
active
|
||||
);
|
||||
let err = require_active_window_id(None).expect_err("missing active window");
|
||||
assert_eq!(err.code, ErrorCode::MissingTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_resolution_distinguishes_missing_and_ambiguous_targets() {
|
||||
let missing = resolve_title_from_matches(&[], ActionKind::TabCreate)
|
||||
.expect_err("zero-match title is missing");
|
||||
assert_eq!(missing.code, ErrorCode::MissingTarget);
|
||||
|
||||
let matches = [
|
||||
warpui::WindowId::from_usize(1),
|
||||
warpui::WindowId::from_usize(2),
|
||||
];
|
||||
let ambiguous = resolve_title_from_matches(&matches, ActionKind::TabCreate)
|
||||
.expect_err("multi-match title is ambiguous");
|
||||
assert_eq!(ambiguous.code, ErrorCode::AmbiguousTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_window_index_returns_missing_target() {
|
||||
let err = resolve_index_from_ids(std::iter::empty(), 0, ActionKind::TabCreate)
|
||||
.expect_err("zero-match index is missing");
|
||||
assert_eq!(err.code, ErrorCode::MissingTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_flag_disabled_denies_local_control() {
|
||||
let _flag = FeatureFlag::WarpControlCli.override_enabled(false);
|
||||
let err = ensure_feature_enabled().expect_err("feature flag disabled");
|
||||
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_server_start_is_rejected() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
let server = app.add_model(|_| LocalControlServer {
|
||||
_runtime: Some(runtime),
|
||||
control_endpoint: None,
|
||||
registered_instance: None,
|
||||
});
|
||||
|
||||
let err = server
|
||||
.update(&mut app, |server, ctx| server.start(ctx))
|
||||
.expect_err("duplicate start should fail");
|
||||
assert_eq!(err.code, ErrorCode::Internal);
|
||||
|
||||
server
|
||||
.update(&mut app, |server, _| server._runtime.take())
|
||||
.expect("existing runtime should remain active")
|
||||
.shutdown_background();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripting_disabled_denies_action() {
|
||||
let settings = settings_with_mode(LocalControlMode::Disabled);
|
||||
|
||||
let err = ensure_settings_allow_action(&settings, ActionKind::TabCreate)
|
||||
.expect_err("disabled scripting denies action");
|
||||
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripting_enabled_allows_action() {
|
||||
ensure_settings_allow_action(
|
||||
&settings_with_mode(LocalControlMode::Enabled),
|
||||
ActionKind::TabCreate,
|
||||
)
|
||||
.expect("enabled scripting allows action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_create_rejects_malformed_params() {
|
||||
let err = validate_action_params(&Action {
|
||||
kind: ActionKind::TabCreate,
|
||||
params: serde_json::json!({ "unexpected": true }),
|
||||
})
|
||||
.expect_err("tab.create params must be empty");
|
||||
assert_eq!(err.code, ErrorCode::InvalidParams);
|
||||
|
||||
validate_action_params(&Action {
|
||||
kind: ActionKind::TabCreate,
|
||||
params: serde_json::json!({}),
|
||||
})
|
||||
.expect("empty tab.create params are accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_actions_reject_malformed_params() {
|
||||
let err = validate_action_params(&Action {
|
||||
kind: ActionKind::AppPing,
|
||||
params: serde_json::json!({ "unexpected": true }),
|
||||
})
|
||||
.expect_err("app.ping params must be empty");
|
||||
assert_eq!(err.code, ErrorCode::InvalidParams);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_checks_grant_before_action_params() {
|
||||
let instance_id = InstanceId("inst_test".to_owned());
|
||||
let grant = CredentialGrant::new(
|
||||
instance_id.clone(),
|
||||
ActionKind::AppPing,
|
||||
Duration::minutes(5),
|
||||
);
|
||||
let err = validate_request_authority(
|
||||
&instance_id,
|
||||
&Action {
|
||||
kind: ActionKind::AppVersion,
|
||||
params: serde_json::json!({ "unexpected": true }),
|
||||
},
|
||||
&grant,
|
||||
)
|
||||
.expect_err("wrong-action grant is rejected before params");
|
||||
assert_eq!(err.code, ErrorCode::InsufficientPermissions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_insertion_prunes_expired_and_caps_active_grants() {
|
||||
let mut credentials = HashMap::new();
|
||||
let instance_id = InstanceId("inst_test".to_owned());
|
||||
insert_credential(
|
||||
&mut credentials,
|
||||
"expired".to_owned(),
|
||||
CredentialGrant::new(
|
||||
instance_id.clone(),
|
||||
ActionKind::TabCreate,
|
||||
Duration::minutes(-1),
|
||||
),
|
||||
);
|
||||
insert_credential(
|
||||
&mut credentials,
|
||||
"active".to_owned(),
|
||||
CredentialGrant::new(
|
||||
instance_id.clone(),
|
||||
ActionKind::TabCreate,
|
||||
Duration::minutes(5),
|
||||
),
|
||||
);
|
||||
assert!(!credentials.contains_key("expired"));
|
||||
|
||||
for index in 0..MAX_ACTIVE_CREDENTIALS {
|
||||
insert_credential(
|
||||
&mut credentials,
|
||||
format!("active-{index}"),
|
||||
CredentialGrant::new(
|
||||
instance_id.clone(),
|
||||
ActionKind::TabCreate,
|
||||
Duration::minutes(5),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert_eq!(credentials.len(), MAX_ACTIVE_CREDENTIALS);
|
||||
assert!(credentials.contains_key(&format!("active-{}", MAX_ACTIVE_CREDENTIALS - 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_credential_is_rejected_and_pruned_before_request_decode() {
|
||||
let mut credentials = HashMap::new();
|
||||
let token = ::local_control::AuthToken::from_secret("expired");
|
||||
credentials.insert(
|
||||
token.secret().to_owned(),
|
||||
CredentialGrant::new(
|
||||
InstanceId("inst_test".to_owned()),
|
||||
ActionKind::TabCreate,
|
||||
Duration::minutes(-1),
|
||||
),
|
||||
);
|
||||
|
||||
let err = lookup_credential(
|
||||
&mut credentials,
|
||||
&token,
|
||||
&InstanceId("inst_test".to_owned()),
|
||||
)
|
||||
.expect_err("expired grant is rejected");
|
||||
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
||||
assert!(!credentials.contains_key(token.secret()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_scripting_invalidates_existing_grant_and_prevents_new_grants() {
|
||||
let _flag = FeatureFlag::WarpControlCli.override_enabled(true);
|
||||
warpui::App::test((), |mut app| async move {
|
||||
crate::test_util::settings::initialize_settings_for_tests(&mut app);
|
||||
app.update(|ctx| {
|
||||
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
settings
|
||||
.local_control_mode
|
||||
.set_value(LocalControlMode::Enabled, ctx)
|
||||
})
|
||||
})
|
||||
.expect("local control should enable");
|
||||
|
||||
let instance_id = InstanceId("inst_test".to_owned());
|
||||
let expected_host = "127.0.0.1:1234".to_owned();
|
||||
let bridge = app.add_singleton_model(LocalControlBridge::new);
|
||||
let state = bridge.update(&mut app, |bridge, ctx| {
|
||||
bridge.set_instance_id(instance_id.clone());
|
||||
ControlServerState {
|
||||
bridge_spawner: ctx.spawner(),
|
||||
instance_id: instance_id.clone(),
|
||||
expected_host: expected_host.clone(),
|
||||
credentials: Default::default(),
|
||||
}
|
||||
});
|
||||
let credential = issue_credential(&state, CredentialRequest::new(ActionKind::AppPing))
|
||||
.await
|
||||
.expect("local-control credential should be issued");
|
||||
|
||||
app.update(|ctx| {
|
||||
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
settings
|
||||
.local_control_mode
|
||||
.set_value(LocalControlMode::Disabled, ctx)
|
||||
})
|
||||
})
|
||||
.expect("local control should disable");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HOST,
|
||||
HeaderValue::from_str(&expected_host).expect("valid host"),
|
||||
);
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&credential.authorization_value()).expect("valid credential"),
|
||||
);
|
||||
let request = RequestEnvelope::new(Action::new(ActionKind::AppPing));
|
||||
let response = handle_control_request(
|
||||
State(state.clone()),
|
||||
headers,
|
||||
Bytes::from(serde_json::to_vec(&request).expect("request serializes")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||
|
||||
let err = issue_credential(&state, CredentialRequest::new(ActionKind::AppPing))
|
||||
.await
|
||||
.expect_err("disabled scripting should prevent new grants");
|
||||
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Permission checks for local control.
|
||||
use ::local_control::{ActionKind, ControlError, ErrorCode, PROTOCOL_VERSION};
|
||||
use warpui::{ModelContext, SingletonEntity};
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::settings::LocalControlSettings;
|
||||
|
||||
pub(super) fn warp_control_cli_enabled() -> bool {
|
||||
FeatureFlag::WarpControlCli.is_enabled()
|
||||
}
|
||||
|
||||
pub(super) fn ensure_protocol_version(protocol_version: u32) -> Result<(), ControlError> {
|
||||
if protocol_version == PROTOCOL_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::ProtocolVersionUnsupported,
|
||||
format!("unsupported protocol version {protocol_version}"),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn ensure_feature_enabled() -> Result<(), ControlError> {
|
||||
if warp_control_cli_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ControlError::new(
|
||||
ErrorCode::LocalControlDisabled,
|
||||
"Warp control CLI is disabled by feature flag",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn capabilities() -> Vec<ActionKind> {
|
||||
ActionKind::implemented_metadata()
|
||||
.into_iter()
|
||||
.map(|metadata| metadata.kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn ensure_action_allowed(
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
let settings = LocalControlSettings::as_ref(ctx);
|
||||
ensure_settings_allow_action(settings, action)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_settings_allow_action(
|
||||
settings: &LocalControlSettings,
|
||||
action: ActionKind,
|
||||
) -> Result<(), ControlError> {
|
||||
if !settings.is_enabled() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::LocalControlDisabled,
|
||||
format!("{} is disabled for local control", action.as_str()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Target resolution and parameter validation for retained local-control actions.
|
||||
use ::local_control::protocol::{
|
||||
ActionNameParams, ActionParameterSpec, BindingNameParams, BooleanValueParams, ColorValueParams,
|
||||
DirectionParams, EmptyParams, FileOpenParams, KeyParams, KeyValueParams, NamespaceParams,
|
||||
PageQueryParams, PaneTarget, QueryParams, RenameParams, ResizeParams, SessionTarget,
|
||||
TabActivateParams, TabCloseParams, TabCreateParams, TabTarget, TargetSelector, TextParams,
|
||||
ThemeNameParams, WindowTarget,
|
||||
};
|
||||
use ::local_control::{ActionKind, ControlError, ErrorCode, TargetScope};
|
||||
use warpui::{AppContext, ModelContext, TypedActionView, ViewHandle, WindowId};
|
||||
|
||||
use crate::local_control::handlers::metadata::action_metadata_for_name;
|
||||
use crate::local_control::LocalControlBridge;
|
||||
use crate::pane_group::{ActivationReason, PaneGroup, PaneGroupAction, PaneId};
|
||||
use crate::workspace::{Workspace, WorkspaceAction};
|
||||
|
||||
pub(crate) fn validate_tab_create_target(target: &TargetSelector) -> Result<(), ControlError> {
|
||||
if target.tab.is_some() || target.pane.is_some() || target.session.is_some() {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
"tab.create accepts only a window selector",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_action_params(action: &::local_control::Action) -> Result<(), ControlError> {
|
||||
if !action.kind.is_implemented() {
|
||||
return Ok(());
|
||||
}
|
||||
match action.kind.metadata().parameter_spec {
|
||||
ActionParameterSpec::None => parse_params::<EmptyParams>(action),
|
||||
ActionParameterSpec::ActionName => {
|
||||
let params = action.params_as::<ActionNameParams>()?;
|
||||
action_metadata_for_name(¶ms.action).map(|_| ())
|
||||
}
|
||||
ActionParameterSpec::BindingName => parse_params::<BindingNameParams>(action),
|
||||
ActionParameterSpec::BooleanValue => parse_params::<BooleanValueParams>(action),
|
||||
ActionParameterSpec::ColorValue => parse_params::<ColorValueParams>(action),
|
||||
ActionParameterSpec::Direction => parse_params::<DirectionParams>(action),
|
||||
ActionParameterSpec::FileOpen => parse_params::<FileOpenParams>(action),
|
||||
ActionParameterSpec::Key => parse_params::<KeyParams>(action),
|
||||
ActionParameterSpec::KeyValue => parse_params::<KeyValueParams>(action),
|
||||
ActionParameterSpec::Namespace => parse_params::<NamespaceParams>(action),
|
||||
ActionParameterSpec::PageQuery => parse_params::<PageQueryParams>(action),
|
||||
ActionParameterSpec::Query => parse_params::<QueryParams>(action),
|
||||
ActionParameterSpec::Rename => parse_params::<RenameParams>(action),
|
||||
ActionParameterSpec::Resize => parse_params::<ResizeParams>(action),
|
||||
ActionParameterSpec::TabActivate => parse_params::<TabActivateParams>(action),
|
||||
ActionParameterSpec::TabClose => parse_params::<TabCloseParams>(action),
|
||||
ActionParameterSpec::TabCreate => parse_params::<TabCreateParams>(action),
|
||||
ActionParameterSpec::Text => parse_params::<TextParams>(action),
|
||||
ActionParameterSpec::ThemeName => parse_params::<ThemeNameParams>(action),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_action_target(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
) -> Result<(), ControlError> {
|
||||
let has_target = target.window.is_some()
|
||||
|| target.tab.is_some()
|
||||
|| target.pane.is_some()
|
||||
|| target.session.is_some();
|
||||
let rejects_all_targets = match action.metadata().target_scope {
|
||||
TargetScope::Instance => action != ActionKind::AppFocus,
|
||||
TargetScope::Appearance
|
||||
| TargetScope::Settings
|
||||
| TargetScope::Keybinding
|
||||
| TargetScope::Action
|
||||
| TargetScope::Capability => true,
|
||||
TargetScope::Window
|
||||
| TargetScope::Tab
|
||||
| TargetScope::Pane
|
||||
| TargetScope::Session
|
||||
| TargetScope::Input
|
||||
| TargetScope::Surface
|
||||
| TargetScope::File => false,
|
||||
};
|
||||
if rejects_all_targets && has_target {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!("{} does not accept target selectors", action.as_str()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn target_window_id_for_target(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
target: &TargetSelector,
|
||||
action: ActionKind,
|
||||
) -> Result<WindowId, ControlError> {
|
||||
match target.window.as_ref() {
|
||||
None | Some(WindowTarget::Active) => active_or_single_window_id(ctx, action),
|
||||
Some(WindowTarget::Id { id }) => ctx
|
||||
.window_ids()
|
||||
.find(|window_id| window_id.to_string() == id.0)
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!("{} cannot resolve the requested window id", action.as_str()),
|
||||
)
|
||||
}),
|
||||
Some(WindowTarget::Index { index }) => {
|
||||
resolve_index_from_ids(ctx.window_ids(), *index, action)
|
||||
}
|
||||
Some(WindowTarget::Title { .. }) => Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!(
|
||||
"{} only supports active, opaque window id, and window index selectors",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn require_active_window_id(
|
||||
active_window: Option<WindowId>,
|
||||
) -> Result<WindowId, ControlError> {
|
||||
require_active_window_id_for_action(active_window, ActionKind::TabCreate)
|
||||
}
|
||||
|
||||
pub(crate) fn require_active_window_id_for_action(
|
||||
active_window: Option<WindowId>,
|
||||
action: ActionKind,
|
||||
) -> Result<WindowId, ControlError> {
|
||||
active_window.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires an active Warp window", action.as_str()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn active_or_single_window_id(
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
action: ActionKind,
|
||||
) -> Result<WindowId, ControlError> {
|
||||
if let Some(window_id) = ctx.windows().active_window() {
|
||||
return Ok(window_id);
|
||||
}
|
||||
let window_ids = ctx.window_ids().collect::<Vec<_>>();
|
||||
match window_ids.as_slice() {
|
||||
[window_id] => Ok(*window_id),
|
||||
[] => require_active_window_id_for_action(None, action),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!(
|
||||
"{} requires an explicit window selector when no Warp window is active",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_index_from_ids(
|
||||
ids: impl Iterator<Item = WindowId>,
|
||||
index: u32,
|
||||
action: ActionKind,
|
||||
) -> Result<WindowId, ControlError> {
|
||||
let mut ids = ids.collect::<Vec<_>>();
|
||||
ids.sort_by_key(ToString::to_string);
|
||||
ids.get(index as usize).copied().ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!(
|
||||
"{} cannot resolve the requested window index",
|
||||
action.as_str()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn resolve_title_from_matches(
|
||||
matching: &[WindowId],
|
||||
action: ActionKind,
|
||||
) -> Result<WindowId, ControlError> {
|
||||
match matching {
|
||||
[window_id] => Ok(*window_id),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!(
|
||||
"{} cannot resolve the requested window title",
|
||||
action.as_str()
|
||||
),
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
format!("{} resolved multiple windows by title", action.as_str()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
fn parse_params<T: serde::de::DeserializeOwned>(
|
||||
action: &::local_control::Action,
|
||||
) -> Result<(), ControlError> {
|
||||
action.params_as::<T>().map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn decode_params<T: serde::de::DeserializeOwned>(
|
||||
params: &serde_json::Value,
|
||||
) -> Result<T, ControlError> {
|
||||
serde_json::from_value(params.clone()).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::InvalidParams,
|
||||
"failed to decode action parameters",
|
||||
err.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn reject_target_families(
|
||||
action: ActionKind,
|
||||
rejected: bool,
|
||||
families: &str,
|
||||
) -> Result<(), ControlError> {
|
||||
if rejected {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::InvalidSelector,
|
||||
format!("{} does not accept {families}", action.as_str()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_for_window(
|
||||
window_id: WindowId,
|
||||
action: ActionKind,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ViewHandle<Workspace>, ControlError> {
|
||||
ctx.views_of_type::<Workspace>(window_id)
|
||||
.and_then(|workspaces| workspaces.into_iter().next())
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!(
|
||||
"{} requires a workspace in the target window",
|
||||
action.as_str()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn target_workspace(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ViewHandle<Workspace>, ControlError> {
|
||||
let window_id = target_window_id_for_target(ctx, target, action)?;
|
||||
workspace_for_window(window_id, action, ctx)
|
||||
}
|
||||
|
||||
pub(crate) fn target_pane_group(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ViewHandle<PaneGroup>, ControlError> {
|
||||
let workspace = target_workspace(action, target, ctx)?;
|
||||
workspace.read(ctx, |workspace, ctx| {
|
||||
let tab_index = tab_index_from_target(target, workspace, ctx)?;
|
||||
workspace
|
||||
.tab_views()
|
||||
.nth(tab_index)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!("{} cannot resolve the requested tab", action.as_str()),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn active_target_pane_group(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<ViewHandle<PaneGroup>, ControlError> {
|
||||
reject_target_families(
|
||||
action,
|
||||
action != ActionKind::SessionActivate && target.session.is_some(),
|
||||
"session selectors",
|
||||
)?;
|
||||
let workspace = target_workspace(action, target, ctx)?;
|
||||
if target.tab.is_some() {
|
||||
workspace.update(ctx, |workspace, ctx| {
|
||||
let tab_index = tab_index_from_target(target, workspace, ctx)?;
|
||||
workspace.handle_action(&WorkspaceAction::ActivateTab(tab_index), ctx);
|
||||
Ok::<_, ControlError>(())
|
||||
})?;
|
||||
}
|
||||
target_pane_group(action, target, ctx)
|
||||
}
|
||||
|
||||
pub(crate) fn activate_target(
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
if target.tab.is_some() {
|
||||
workspace.update(ctx, |workspace, ctx| {
|
||||
let tab_index = tab_index_from_target(target, workspace, ctx)?;
|
||||
workspace.handle_action(&WorkspaceAction::ActivateTab(tab_index), ctx);
|
||||
Ok::<_, ControlError>(())
|
||||
})?;
|
||||
}
|
||||
if target.session.is_some() {
|
||||
let pane_group = target_pane_group(action, target, ctx)?;
|
||||
let pane_id = target_session_pane_id(action, target, &pane_group, ctx)?;
|
||||
pane_group.update(ctx, |pane_group, ctx| {
|
||||
pane_group.handle_action(
|
||||
&PaneGroupAction::Activate(pane_id, ActivationReason::Click),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else if target.pane.is_some() {
|
||||
let pane_group = target_pane_group(action, target, ctx)?;
|
||||
focus_explicit_pane_target(action, target, &pane_group, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn focus_explicit_pane_target(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
pane_group: &ViewHandle<PaneGroup>,
|
||||
ctx: &mut ModelContext<LocalControlBridge>,
|
||||
) -> Result<(), ControlError> {
|
||||
if target.pane.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pane_id = target_pane_id(action, target, pane_group, ctx)?;
|
||||
pane_group.update(ctx, |pane_group, ctx| {
|
||||
pane_group.handle_action(
|
||||
&PaneGroupAction::Activate(pane_id, ActivationReason::Click),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn target_pane_id(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
pane_group: &ViewHandle<PaneGroup>,
|
||||
ctx: &AppContext,
|
||||
) -> Result<PaneId, ControlError> {
|
||||
pane_group.read(ctx, |pane_group, ctx| match target.pane.as_ref() {
|
||||
None | Some(PaneTarget::Active) => Ok(pane_group.focused_pane_id(ctx)),
|
||||
Some(PaneTarget::Index { index }) => {
|
||||
let pane_index = usize::try_from(*index).map_err(|err| {
|
||||
ControlError::with_details(
|
||||
ErrorCode::InvalidSelector,
|
||||
"pane index is out of range",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
pane_group.pane_id_from_index(pane_index).ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!(
|
||||
"{} cannot resolve the requested pane index",
|
||||
action.as_str()
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
Some(PaneTarget::Id { id }) => pane_group
|
||||
.visible_pane_ids()
|
||||
.into_iter()
|
||||
.find(|pane_id| pane_id.to_string() == id.0)
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!("{} cannot resolve the requested pane id", action.as_str()),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn tab_index_from_target(
|
||||
target: &TargetSelector,
|
||||
workspace: &Workspace,
|
||||
ctx: &AppContext,
|
||||
) -> Result<usize, ControlError> {
|
||||
match target.tab.as_ref() {
|
||||
Some(TabTarget::Index { index }) => usize::try_from(*index)
|
||||
.ok()
|
||||
.filter(|index| *index < workspace.tab_count())
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
"tab selector index did not match a visible tab",
|
||||
)
|
||||
}),
|
||||
Some(TabTarget::Active) | None => Ok(workspace.active_tab_index()),
|
||||
Some(TabTarget::Id { id }) => workspace
|
||||
.tab_views()
|
||||
.enumerate()
|
||||
.find_map(|(index, pane_group)| (pane_group.id().to_string() == id.0).then_some(index))
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
"tab selector id did not match a visible tab",
|
||||
)
|
||||
}),
|
||||
Some(TabTarget::Title { title }) => {
|
||||
let matching = workspace
|
||||
.tab_views()
|
||||
.enumerate()
|
||||
.filter_map(|(index, pane_group)| {
|
||||
(pane_group.as_ref(ctx).display_title(ctx) == *title).then_some(index)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match matching.as_slice() {
|
||||
[index] => Ok(*index),
|
||||
[] => Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
"tab selector title did not match a visible tab",
|
||||
)),
|
||||
_ => Err(ControlError::new(
|
||||
ErrorCode::AmbiguousTarget,
|
||||
"tab selector title matched multiple visible tabs",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn input_target_pane_id(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
pane_group: &ViewHandle<PaneGroup>,
|
||||
ctx: &AppContext,
|
||||
) -> Result<PaneId, ControlError> {
|
||||
if target.session.is_some() {
|
||||
return target_session_pane_id(action, target, pane_group, ctx);
|
||||
}
|
||||
if target.pane.is_some() {
|
||||
return target_pane_id(action, target, pane_group, ctx);
|
||||
}
|
||||
pane_group
|
||||
.read(ctx, |pane_group, ctx| {
|
||||
pane_group.active_session_id(ctx).map(PaneId::from)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires an active terminal session", action.as_str()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn target_session_pane_id(
|
||||
action: ActionKind,
|
||||
target: &TargetSelector,
|
||||
pane_group: &ViewHandle<PaneGroup>,
|
||||
ctx: &AppContext,
|
||||
) -> Result<PaneId, ControlError> {
|
||||
if target.session.is_none() && target.pane.is_some() {
|
||||
let pane_id = target_pane_id(action, target, pane_group, ctx)?;
|
||||
let has_terminal = pane_group.read(ctx, |pane_group, ctx| {
|
||||
pane_group
|
||||
.terminal_view_from_pane_id(pane_id, ctx)
|
||||
.is_some()
|
||||
});
|
||||
if has_terminal {
|
||||
return Ok(pane_id);
|
||||
}
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires a terminal session target", action.as_str()),
|
||||
));
|
||||
}
|
||||
let session_pane_id =
|
||||
pane_group.read(ctx, |pane_group, ctx| match target.session.as_ref() {
|
||||
None | Some(SessionTarget::Active) => pane_group
|
||||
.active_session_id(ctx)
|
||||
.map(PaneId::from)
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::MissingTarget,
|
||||
format!("{} requires an active terminal session", action.as_str()),
|
||||
)
|
||||
}),
|
||||
Some(SessionTarget::Id { id }) => pane_group
|
||||
.visible_pane_ids()
|
||||
.into_iter()
|
||||
.find(|pane_id| pane_id.to_string() == id.0)
|
||||
.filter(|pane_id| {
|
||||
pane_group
|
||||
.terminal_view_from_pane_id(*pane_id, ctx)
|
||||
.is_some()
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ControlError::new(
|
||||
ErrorCode::StaleTarget,
|
||||
format!(
|
||||
"{} cannot resolve the requested session id",
|
||||
action.as_str()
|
||||
),
|
||||
)
|
||||
}),
|
||||
})?;
|
||||
if target.pane.is_some() {
|
||||
let pane_id = target_pane_id(action, target, pane_group, ctx)?;
|
||||
if pane_id != session_pane_id {
|
||||
return Err(ControlError::new(
|
||||
ErrorCode::TargetStateConflict,
|
||||
format!(
|
||||
"{} pane and session selectors resolve different targets",
|
||||
action.as_str()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(session_pane_id)
|
||||
}
|
||||
Reference in New Issue
Block a user