Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions cmux-tui/crates/cmux-tui-machine-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,11 +619,26 @@ pub struct ProviderCapabilities {
pub connect_external_machine: bool,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderActionTarget {
#[default]
Scope,
SelectedMachine,
SelectedWorkspace,
}

fn provider_action_target_is_scope(target: &ProviderActionTarget) -> bool {
*target == ProviderActionTarget::Scope
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderAction {
pub id: OpaqueId,
pub label: String,
#[serde(default, skip_serializing_if = "provider_action_target_is_scope")]
pub target: ProviderActionTarget,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
#[serde(default)]
pub destructive: bool,
#[serde(default)]
Expand Down Expand Up @@ -924,6 +939,10 @@ pub struct InvokeActionParams {
pub action_id: OpaqueId,
#[serde(default)]
pub values: BTreeMap<String, ActionValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_id: Option<OpaqueId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<OpaqueId>,
pub mutation_id: OpaqueId,
}

Expand Down Expand Up @@ -1196,6 +1215,26 @@ mod tests {
assert!(decoded.capabilities.is_empty());
}

#[test]
fn provider_action_context_is_additive_and_legacy_compatible() {
let legacy: ProviderAction = serde_json::from_value(serde_json::json!({
"id": "legacy.action",
"label": "Legacy action"
}))
.unwrap();
assert_eq!(legacy.target, ProviderActionTarget::Scope);
let encoded = serde_json::to_value(&legacy).unwrap();
assert!(encoded.get("target").is_none());

let params: InvokeActionParams = serde_json::from_value(serde_json::json!({
"action_id": "legacy.action",
"mutation_id": "mutation-1"
}))
.unwrap();
assert_eq!(params.machine_id, None);
assert_eq!(params.workspace_id, None);
}

#[test]
fn snapshot_request_matches_the_v1_golden_document() {
let request = RequestEnvelope::new(
Expand Down Expand Up @@ -1358,6 +1397,7 @@ mod tests {
actions: vec![ProviderAction {
id: id("team.invite"),
label: "Invite member".into(),
target: ProviderActionTarget::Scope,
destructive: false,
fields: vec![ActionField {
id: "email".into(),
Expand Down Expand Up @@ -1703,6 +1743,8 @@ mod tests {
ProviderRequest::InvokeAction(InvokeActionParams {
action_id: id("team.invite"),
values: action_values,
machine_id: Some(id("machine")),
workspace_id: Some(id("workspace")),
mutation_id: id("mutation-action"),
}),
ProviderRequest::CloseMachine(CloseMachineParams { connection_id: id("connection") }),
Expand Down
161 changes: 134 additions & 27 deletions cmux-tui/crates/cmux-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ use crate::machine::{
MachineKey, MachineRailSelection, MachineRailTarget, MachineRequest, MachineSession,
MachineUiState, MachineUpdate, MachineUpdateStream, ManagedMachineDescriptor,
ManagedMachineStatus, ManagedWorkspaceDescriptor, ManagedWorkspaceSessionMutation,
ManagedWorkspaceStatus, ProviderActionInputError, WorkspaceCreationMode,
ManagedWorkspaceStatus, ProviderActionContext, ProviderActionInputError, WorkspaceCreationMode,
WorkspaceCreationPolicy, validate_machine_session,
};
use crate::pty_input::{
Expand Down Expand Up @@ -2794,7 +2794,7 @@ pub enum PromptTarget {
Surface(SurfaceId),
ConnectMachine,
ProviderAction(usize),
ConfirmProviderAction(usize),
ConfirmProviderAction,
}

/// Centered rename dialog: a text input with OK/Cancel buttons. The
Expand Down Expand Up @@ -3217,6 +3217,7 @@ pub struct App {
pub clients: Vec<ClientInfo>,
pub client_border_labels: HashMap<SurfaceId, String>,
pub prompt: Option<Prompt>,
pending_provider_action: Option<MachineRequest>,
pub pairing_dialog: Option<PairingDialog>,
pairing_queue: VecDeque<PairingChallenge>,
pub shortcut_help: Option<ShortcutHelp>,
Expand Down Expand Up @@ -4298,6 +4299,7 @@ pub fn run_with_machine_updates(
clients: Vec::new(),
client_border_labels: HashMap::new(),
prompt: None,
pending_provider_action: None,
pairing_dialog: None,
pairing_queue: VecDeque::new(),
shortcut_help: None,
Expand Down Expand Up @@ -4988,7 +4990,7 @@ impl App {
matches!(
prompt.target,
PromptTarget::ProviderAction(_)
| PromptTarget::ConfirmProviderAction(_)
| PromptTarget::ConfirmProviderAction
| PromptTarget::ManagedWorkspace(_)
| PromptTarget::ConfirmPurgeManagedWorkspace(_)
| PromptTarget::ManagedMachine(_)
Expand All @@ -4997,6 +4999,7 @@ impl App {
)
}) {
self.prompt = None;
self.pending_provider_action = None;
}
}
if let Some(previous) = self.machine_ui.as_ref() {
Expand Down Expand Up @@ -7688,14 +7691,7 @@ impl App {
return;
};
match action.fields.as_slice() {
[] if action.destructive => {
self.prompt = Some(Prompt::new(
localization::catalog().sidebar.confirm_destructive_action,
String::new(),
PromptTarget::ConfirmProviderAction(index),
));
}
[] => self.submit_provider_action(index, None),
[] => self.stage_provider_action(index, None),
[field] => {
self.prompt = Some(Prompt::new(
field.label.clone(),
Expand All @@ -7711,15 +7707,50 @@ impl App {
}
}

fn submit_provider_action(&mut self, index: usize, input: Option<&str>) {
let result = self
.machine_ui
fn provider_action_context(&self) -> ProviderActionContext {
let machine_id = self.machine_ui.as_ref().and_then(|ui| {
let active = ui.snapshot.active?;
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
ui.snapshot
.machines
.iter()
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
.find(|machine| machine.key == active)
.map(|machine| machine.id.clone())
});
let workspace_id = self
.tree
.active_workspace()
.map(|workspace| workspace.key.clone())
.filter(|id| !id.is_empty());
ProviderActionContext { machine_id, workspace_id }
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

fn bound_provider_action(
&self,
index: usize,
input: Option<&str>,
) -> Option<Result<(MachineRequest, bool), ProviderActionInputError>> {
let context = self.provider_action_context();
self.machine_ui
.as_ref()
.and_then(|ui| ui.provider.as_ref())
.and_then(|provider| provider.actions.get(index))
.map(|action| action.request(input));
.map(|action| {
action.request(input, &context).map(|request| (request, action.destructive))
})
}

fn stage_provider_action(&mut self, index: usize, input: Option<&str>) {
let result = self.bound_provider_action(index, input);
match result {
Some(Ok(request)) => {
Some(Ok((request, true))) => {
self.pending_provider_action = Some(request);
self.prompt = Some(Prompt::new(
localization::catalog().sidebar.confirm_destructive_action,
String::new(),
PromptTarget::ConfirmProviderAction,
));
}
Some(Ok((request, false))) => {
if let Some(ui) = self.machine_ui.as_mut() {
ui.request = Some(request);
}
Expand Down Expand Up @@ -7903,14 +7934,17 @@ impl App {
return;
}
if let PromptTarget::ProviderAction(index) = prompt.target {
let result = self
.machine_ui
.as_ref()
.and_then(|ui| ui.provider.as_ref())
.and_then(|provider| provider.actions.get(index))
.map(|action| action.request(Some(&input)));
let result = self.bound_provider_action(index, Some(&input));
match result {
Some(Ok(request)) => {
Some(Ok((request, true))) => {
self.pending_provider_action = Some(request);
self.prompt = Some(Prompt::new(
localization::catalog().sidebar.confirm_destructive_action,
String::new(),
PromptTarget::ConfirmProviderAction,
));
}
Some(Ok((request, false))) => {
if let Some(ui) = self.machine_ui.as_mut() {
ui.request = Some(request);
}
Expand All @@ -7924,9 +7958,13 @@ impl App {
}
return;
}
if let PromptTarget::ConfirmProviderAction(index) = prompt.target {
if let PromptTarget::ConfirmProviderAction = prompt.target {
if input.trim() == "CONFIRM" {
self.submit_provider_action(index, None);
if let (Some(request), Some(ui)) =
(self.pending_provider_action.take(), self.machine_ui.as_mut())
{
ui.request = Some(request);
}
} else {
self.status_message =
Some(localization::catalog().sidebar.confirmation_mismatch.to_string());
Expand Down Expand Up @@ -7976,7 +8014,7 @@ impl App {
| PromptTarget::ConfirmDeleteManagedMachine(_)
| PromptTarget::ConfirmPurgeManagedMachine(_)
| PromptTarget::ProviderAction(_)
| PromptTarget::ConfirmProviderAction(_)
| PromptTarget::ConfirmProviderAction
| PromptTarget::ManagedWorkspace(_)
| PromptTarget::ConfirmPurgeManagedWorkspace(_) => {
unreachable!("handled before session mutation")
Expand All @@ -7992,6 +8030,7 @@ impl App {
fn close_prompt(&mut self) {
self.shake_frames = 0;
self.prompt = None;
self.pending_provider_action = None;
}

fn handle_prompt_key(&mut self, key: KeyEvent) -> anyhow::Result<RenderAction> {
Expand Down Expand Up @@ -11525,6 +11564,12 @@ fn provider_action_error_message(error: ProviderActionInputError) -> &'static st
ProviderActionInputError::InvalidInteger => messages.action_invalid_integer,
ProviderActionInputError::BelowMinimum => messages.action_below_minimum,
ProviderActionInputError::AboveMaximum => messages.action_above_maximum,
ProviderActionInputError::MissingSelectedMachine => {
messages.action_missing_selected_machine
}
ProviderActionInputError::MissingSelectedWorkspace => {
messages.action_missing_selected_workspace
}
ProviderActionInputError::UnsupportedFieldCount => {
messages.action_multiple_fields_unsupported
}
Expand Down Expand Up @@ -11634,7 +11679,7 @@ mod tests {
MachineUpdate, ManagedMachineCapabilities, ManagedMachineDescriptor, ManagedMachineStatus,
ManagedWorkspaceCapabilities, ManagedWorkspaceDescriptor, ManagedWorkspaceSessionMutation,
ManagedWorkspaceStatus, ProviderActionDescriptor, ProviderActionFieldDescriptor,
ProviderActionFieldKind, ProviderActionValue, ProviderPresentation,
ProviderActionFieldKind, ProviderActionTarget, ProviderActionValue, ProviderPresentation,
ProviderScopeDescriptor, ProviderScopeKind, WorkspaceCreationMode, WorkspaceCreationPolicy,
};
use crate::pty_input::{
Expand Down Expand Up @@ -17595,6 +17640,7 @@ mod tests {
ProviderActionDescriptor {
id: "invite-member".into(),
label: "Invite member".into(),
target: ProviderActionTarget::Scope,
destructive: false,
fields: vec![ProviderActionFieldDescriptor {
id: "email".into(),
Expand All @@ -17610,6 +17656,7 @@ mod tests {
ProviderActionDescriptor {
id: "manage-billing".into(),
label: "Manage billing".into(),
target: ProviderActionTarget::Scope,
destructive: false,
fields: Vec::new(),
},
Expand Down Expand Up @@ -17699,11 +17746,70 @@ mod tests {
"email".into(),
ProviderActionValue::Text("person@example.com".into())
)]),
machine_id: None,
workspace_id: None,
})
);
assert!(!app.quit);
}

#[test]
fn destructive_workspace_action_binds_context_before_confirmation() {
let mux = Mux::new("provider-workspace-action-test", SurfaceOptions::default());
let workspace_key = "00000000-0000-4000-8000-000000000123";
mux.create_empty_workspace(Some("ports".into()), Some(workspace_key.into()), None).unwrap();
let mut app = test_app(Session::Local(mux));
app.replace_tree(app.session.tree());
let mut ui = provider_controls_ui();
ui.provider.as_mut().unwrap().actions.push(ProviderActionDescriptor {
id: "workspace.port.make_public".into(),
label: "Make workspace port public".into(),
target: ProviderActionTarget::SelectedWorkspace,
destructive: true,
fields: vec![ProviderActionFieldDescriptor {
id: "port".into(),
label: "Port".into(),
kind: ProviderActionFieldKind::Integer,
required: true,
max_length: None,
minimum: Some(1),
maximum: Some(i64::from(u16::MAX)),
placeholder: None,
}],
});
app.machine_ui = Some(ui);

app.begin_provider_action(2);
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap();
let input = app.prompt.as_ref().unwrap().input_rect;
terminal.backend_mut().assert_cursor_position((input.x, input.y));
app.prompt.as_mut().unwrap().input.insert_str("3000");
app.handle_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).unwrap();
assert!(matches!(
app.prompt.as_ref().map(|prompt| &prompt.target),
Some(PromptTarget::ConfirmProviderAction)
));
assert!(
app.machine_ui.as_ref().and_then(|ui| ui.request.as_ref()).is_none(),
"destructive action must wait for explicit confirmation"
);

app.prompt.as_mut().unwrap().input.insert_str("CONFIRM");
terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap();
let ok = app.prompt.as_ref().unwrap().ok;
app.handle_prompt_click(ok.x, ok.y).unwrap();
assert_eq!(
app.machine_ui.as_ref().and_then(|ui| ui.request.as_ref()),
Some(&MachineRequest::InvokeProviderAction {
action_id: "workspace.port.make_public".into(),
values: BTreeMap::from([("port".into(), ProviderActionValue::Integer(3_000))]),
machine_id: Some("managed-41".into()),
workspace_id: Some(workspace_key.into()),
})
);
}

#[test]
fn personal_scope_row_does_not_repeat_the_same_label() {
let mux = Mux::new("provider-personal-scope-style-test", SurfaceOptions::default());
Expand Down Expand Up @@ -19290,6 +19396,7 @@ mod tests {
clients: Vec::new(),
client_border_labels: HashMap::new(),
prompt: None,
pending_provider_action: None,
pairing_dialog: None,
pairing_queue: VecDeque::new(),
shortcut_help: None,
Expand Down
Loading