Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings_value::SettingsValue;
|
||||
use warp_core::{
|
||||
define_settings_group,
|
||||
settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud},
|
||||
};
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::persistence::{CloudModel, CloudModelEvent},
|
||||
CloudObject as _,
|
||||
},
|
||||
drive::CloudObjectTypeAndId,
|
||||
server::ids::SyncId,
|
||||
};
|
||||
|
||||
define_settings_group!(WorkflowAliases, settings: [
|
||||
aliases: Aliases {
|
||||
type: Vec<WorkflowAlias>,
|
||||
default: vec![],
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
storage_key: "WorkflowAliases",
|
||||
}
|
||||
]);
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, schemars::JsonSchema, SettingsValue)]
|
||||
#[schemars(description = "A shortcut alias for a Warp Drive workflow.")]
|
||||
pub struct WorkflowAlias {
|
||||
#[schemars(description = "The alias text that triggers this workflow.")]
|
||||
pub alias: String,
|
||||
#[schemars(description = "The identifier of the linked workflow.")]
|
||||
pub workflow_id: SyncId,
|
||||
#[schemars(description = "Pre-filled argument values for the workflow.")]
|
||||
pub arguments: Option<HashMap<String, String>>,
|
||||
#[schemars(description = "The identifier of the environment variable collection to use.")]
|
||||
pub env_vars: Option<SyncId>,
|
||||
}
|
||||
|
||||
impl WorkflowAliases {
|
||||
/// Call once to subscribe to UpdateManager notifications that a workflow has been deleted.
|
||||
pub fn connect(&self, ctx: &mut ModelContext<Self>) {
|
||||
ctx.subscribe_to_model(&CloudModel::handle(ctx), |me, event, ctx| {
|
||||
let result = match event {
|
||||
CloudModelEvent::ObjectTrashed {
|
||||
type_and_id: CloudObjectTypeAndId::Workflow(server_id),
|
||||
..
|
||||
} => me.remove_aliases_for_workflow(*server_id, ctx),
|
||||
_ => Result::Ok(()),
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Error removing aliases for workflow: {e:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_all_aliases(&self) -> &[WorkflowAlias] {
|
||||
&self.aliases
|
||||
}
|
||||
|
||||
/// A mapping of all aliases, for autocomplete.
|
||||
pub fn autocomplete_data(&self, ctx: &AppContext) -> HashMap<String, String> {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let mut alias_data = HashMap::with_capacity(self.aliases.len());
|
||||
for alias in self.aliases.iter() {
|
||||
if let Some(backing_workflow) = cloud_model.get_workflow(&alias.workflow_id) {
|
||||
alias_data.insert(alias.alias.clone(), backing_workflow.display_name());
|
||||
}
|
||||
}
|
||||
alias_data
|
||||
}
|
||||
|
||||
// potentially support autocomplete
|
||||
pub fn match_alias(&self, input_text: &str) -> Option<WorkflowAlias> {
|
||||
self.aliases
|
||||
.iter()
|
||||
.find(|alias| alias.alias == input_text)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn get_aliases_for_workflow(&self, workflow_id: SyncId) -> Vec<&WorkflowAlias> {
|
||||
self.aliases
|
||||
.iter()
|
||||
.filter(|alias| alias.workflow_id == workflow_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set_aliases(
|
||||
&mut self,
|
||||
aliases_to_add: Vec<WorkflowAlias>,
|
||||
ctx: &mut ModelContext<WorkflowAliases>,
|
||||
) -> Result<(), Error> {
|
||||
let mut aliases = self.aliases.clone();
|
||||
let to_exclude = aliases_to_add
|
||||
.iter()
|
||||
.map(|a| a.alias.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
aliases.retain(|a| !to_exclude.contains(&a.alias));
|
||||
aliases.extend(aliases_to_add);
|
||||
|
||||
self.aliases.set_value(aliases, ctx)
|
||||
}
|
||||
|
||||
pub fn remove_aliases(
|
||||
&mut self,
|
||||
aliases_to_exclude: Vec<String>,
|
||||
ctx: &mut ModelContext<WorkflowAliases>,
|
||||
) -> Result<(), Error> {
|
||||
let mut aliases = self.aliases.clone();
|
||||
let to_exclude = aliases_to_exclude.into_iter().collect::<HashSet<_>>();
|
||||
aliases.retain(|a| !to_exclude.contains(&a.alias));
|
||||
self.aliases.set_value(aliases, ctx)
|
||||
}
|
||||
|
||||
/// Migrate all aliases from one workflow id to another.
|
||||
/// Useful when a workflow id changes, like on initial save.
|
||||
pub fn update_workflow_id(
|
||||
&mut self,
|
||||
old_workflow_id: SyncId,
|
||||
new_workflow_id: SyncId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), Error> {
|
||||
let mut aliases = self.aliases.clone();
|
||||
for alias in aliases.iter_mut() {
|
||||
if alias.workflow_id == old_workflow_id {
|
||||
alias.workflow_id = new_workflow_id;
|
||||
}
|
||||
}
|
||||
self.aliases.set_value(aliases, ctx)
|
||||
}
|
||||
|
||||
pub fn remove_aliases_for_workflow(
|
||||
&mut self,
|
||||
workflow_id: SyncId,
|
||||
ctx: &mut ModelContext<WorkflowAliases>,
|
||||
) -> Result<(), Error> {
|
||||
let aliases = self
|
||||
.aliases
|
||||
.iter()
|
||||
.filter(|a| a.workflow_id != workflow_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
self.aliases.set_value(aliases, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "aliases_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,165 @@
|
||||
use crate::settings::init_and_register_user_preferences;
|
||||
|
||||
use super::*;
|
||||
use settings::manager::SettingsManager;
|
||||
|
||||
#[test]
|
||||
fn test_set_aliases() {
|
||||
let workflow_1: SyncId = SyncId::ServerId(1.into());
|
||||
let workflow_2: SyncId = SyncId::ServerId(2.into());
|
||||
let alias_1: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias1".to_string(),
|
||||
workflow_id: workflow_1,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
let alias_2: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias2".to_string(),
|
||||
workflow_id: workflow_1,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
let alias_3: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias3".to_string(),
|
||||
workflow_id: workflow_2,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_and_register_user_preferences);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
|
||||
WorkflowAliases::register(&mut app);
|
||||
|
||||
app.update(|app| {
|
||||
let aliases = WorkflowAliases::as_ref(app).get_all_aliases();
|
||||
assert_eq!(0, aliases.len());
|
||||
});
|
||||
|
||||
app.update(|app| {
|
||||
WorkflowAliases::handle(app).update(app, |aliases, ctx| {
|
||||
let _ = aliases.set_aliases(vec![alias_1.clone(), alias_2.clone()], ctx);
|
||||
});
|
||||
let aliases = WorkflowAliases::as_ref(app).get_all_aliases();
|
||||
assert_eq!(2, aliases.len());
|
||||
});
|
||||
|
||||
app.update(|app| {
|
||||
WorkflowAliases::handle(app).update(app, |aliases, ctx| {
|
||||
let _ = aliases.set_aliases(vec![alias_3.clone()], ctx);
|
||||
});
|
||||
assert_eq!(WorkflowAliases::as_ref(app).get_all_aliases().len(), 3);
|
||||
assert_eq!(
|
||||
WorkflowAliases::as_ref(app)
|
||||
.get_aliases_for_workflow(workflow_1)
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
WorkflowAliases::as_ref(app)
|
||||
.get_aliases_for_workflow(workflow_2)
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_aliases_replacement() {
|
||||
// Test that replacing aliases works correctly.
|
||||
let workflow_1: SyncId = SyncId::ServerId(1.into());
|
||||
let alias_1: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias1".to_string(),
|
||||
workflow_id: workflow_1,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_and_register_user_preferences);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
|
||||
WorkflowAliases::register(&mut app);
|
||||
|
||||
app.update(|app| {
|
||||
let aliases = WorkflowAliases::as_ref(app).get_all_aliases();
|
||||
assert_eq!(0, aliases.len());
|
||||
});
|
||||
|
||||
app.update(|app| {
|
||||
WorkflowAliases::handle(app).update(app, |aliases, ctx| {
|
||||
let _ = aliases.set_aliases(vec![alias_1.clone()], ctx);
|
||||
});
|
||||
let aliases = WorkflowAliases::as_ref(app).get_all_aliases();
|
||||
assert_eq!(1, aliases.len());
|
||||
});
|
||||
|
||||
app.update(|app| {
|
||||
WorkflowAliases::handle(app).update(app, |aliases, ctx| {
|
||||
let _ = aliases.set_aliases(vec![alias_1.clone()], ctx);
|
||||
});
|
||||
assert_eq!(WorkflowAliases::as_ref(app).get_all_aliases().len(), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_aliases() {
|
||||
let workflow_1: SyncId = SyncId::ServerId(1.into());
|
||||
let workflow_2: SyncId = SyncId::ServerId(2.into());
|
||||
let alias_1: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias1".to_string(),
|
||||
workflow_id: workflow_1,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
let alias_2: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias2".to_string(),
|
||||
workflow_id: workflow_1,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
let alias_3: WorkflowAlias = WorkflowAlias {
|
||||
alias: "alias3".to_string(),
|
||||
workflow_id: workflow_2,
|
||||
arguments: None,
|
||||
env_vars: None,
|
||||
};
|
||||
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_and_register_user_preferences);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
|
||||
WorkflowAliases::register(&mut app);
|
||||
|
||||
app.update(|app| {
|
||||
WorkflowAliases::handle(app).update(app, |aliases, ctx| {
|
||||
let _ = aliases
|
||||
.set_aliases(vec![alias_1.clone(), alias_2.clone(), alias_3.clone()], ctx);
|
||||
});
|
||||
let aliases = WorkflowAliases::as_ref(app).get_all_aliases();
|
||||
assert_eq!(3, aliases.len());
|
||||
});
|
||||
|
||||
app.update(|app| {
|
||||
WorkflowAliases::handle(app).update(app, |aliases, ctx| {
|
||||
let _ = aliases.remove_aliases(vec![alias_3.alias.clone()], ctx);
|
||||
});
|
||||
assert_eq!(WorkflowAliases::as_ref(app).get_all_aliases().len(), 2);
|
||||
assert_eq!(
|
||||
WorkflowAliases::as_ref(app)
|
||||
.get_aliases_for_workflow(workflow_1)
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
WorkflowAliases::as_ref(app)
|
||||
.get_aliases_for_workflow(workflow_2)
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
use crate::workflows::categories::{CategoriesView, WorkflowMatchType};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::WorkflowType;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn test_workflow_matches() {
|
||||
let workflow = Arc::new(WorkflowType::Local(Workflow::Command {
|
||||
name: "g workflow_name it ".into(),
|
||||
command: "command_name git".to_string(),
|
||||
tags: vec!["foo".into(), "bar".into()],
|
||||
description: None,
|
||||
arguments: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![],
|
||||
environment_variables: None,
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
CategoriesView::matches_workflow(&workflow, "foo"),
|
||||
WorkflowMatchType::Tag
|
||||
);
|
||||
assert_eq!(
|
||||
CategoriesView::matches_workflow(&workflow, "bar"),
|
||||
WorkflowMatchType::Tag
|
||||
);
|
||||
|
||||
// The Workflow name has higher precedence than the command.
|
||||
assert!(matches!(
|
||||
CategoriesView::matches_workflow(&workflow, "name"),
|
||||
WorkflowMatchType::Name { .. }
|
||||
));
|
||||
|
||||
// Git matches both the name and the command, but fuzzy matches command with a higher score.
|
||||
assert!(matches!(
|
||||
CategoriesView::matches_workflow(&workflow, "git"),
|
||||
WorkflowMatchType::Command { .. }
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
CategoriesView::matches_workflow(&workflow, "command"),
|
||||
WorkflowMatchType::Command { .. }
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
CategoriesView::matches_workflow(&workflow, "command"),
|
||||
WorkflowMatchType::Command { .. }
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
CategoriesView::matches_workflow(&workflow, "gibberish"),
|
||||
WorkflowMatchType::Unmatched
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! This module contains utilities for computing helper data structures used to render and
|
||||
//! implement the Workflows UI in the info box and the terminal input.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
ops::Range,
|
||||
};
|
||||
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use string_offset::{ByteOffset, CharCounter, CharOffset};
|
||||
|
||||
use crate::server::ids::SyncId;
|
||||
|
||||
use super::workflow::{ArgumentType, Workflow};
|
||||
|
||||
lazy_static! {
|
||||
/// Regex for escaped arguments in workflow command.
|
||||
static ref ESCAPED_ARGUMENTS_PATTERN: Regex = Regex::new(r"\{\{\{([^{}]+)\}\}\}").expect("Escaped argument regex should be valid.");
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct WorkflowArgumentIndex(usize);
|
||||
|
||||
impl From<usize> for WorkflowArgumentIndex {
|
||||
fn from(num: usize) -> Self {
|
||||
Self(num)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for WorkflowArgumentIndex {
|
||||
type Target = usize;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for inputting Workflow arguments within the editor.
|
||||
#[derive(Debug)]
|
||||
struct WorkflowArgument<'a> {
|
||||
/// The index of the argument in the list of arguments.
|
||||
argument_index: WorkflowArgumentIndex,
|
||||
argument_name: &'a str,
|
||||
/// The argument type of the argument, which includes IDs of objects it references.
|
||||
argument_type: &'a ArgumentType,
|
||||
/// The text the workflow should replace the argument identifier with.
|
||||
replacement_text: &'a str,
|
||||
/// The byte indices of the argument in the workflow.
|
||||
byte_range: Range<ByteOffset>,
|
||||
/// The character indices of the argument in the workflow.
|
||||
char_range: Range<CharOffset>,
|
||||
}
|
||||
|
||||
/// Helper struct containing computed metadata about the workflow and its arguments, used to render
|
||||
/// the workflows UI in both the input editor and workflows "info box".
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowDisplayData {
|
||||
/// The command with replaced arguments. Arguments are replaced with their default values (if
|
||||
/// they exist) or with their display "placeholders" (e.g. "{{argument_name}}"), or in the case
|
||||
/// of executed workflows from history, the arguments used when executing the workflow.
|
||||
pub command_with_replaced_arguments: String,
|
||||
|
||||
/// A vector of `ByteOffset` ranges representing the ranges in the original command string
|
||||
/// replaced with argument values.
|
||||
pub replaced_ranges: Vec<Range<ByteOffset>>,
|
||||
|
||||
/// Index of workflow argument index (in the workflow.arguments() list) mapped to a vector of
|
||||
/// indices of the workflow argument instances in the actual replaced command. For instance,
|
||||
/// if workflow.arguments = ["foo", "bar"] and the workflow is "echo {{foo}} {{bar}} {{foo}}",
|
||||
/// the entry for "foo" would be (0, [0, 2]).
|
||||
pub argument_index_to_highlight_index_map: HashMap<WorkflowArgumentIndex, Vec<usize>>,
|
||||
|
||||
/// Index of workflow argument index (in the workflow.arguments() list) mapped to a vector of
|
||||
/// [`CharOffset`] ranges in the replaced command indicating the ranges in the
|
||||
/// replaced command where text was replaced for the corresponding argument. For example,
|
||||
/// if workflow.arguments = ["foo", "bar"] and the workflow is "echo {{foo}} {{bar}} {{foo}}",
|
||||
/// the entry for "foo" would be [5-8, 13-16].
|
||||
pub argument_index_to_char_range_map: HashMap<WorkflowArgumentIndex, Vec<Range<CharOffset>>>,
|
||||
|
||||
pub argument_index_to_object_id_map: HashMap<WorkflowArgumentIndex, SyncId>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum WorkflowCommandPart {
|
||||
CommandPart(String),
|
||||
Argument { name: String, value: String },
|
||||
}
|
||||
|
||||
impl WorkflowCommandPart {
|
||||
fn to_command_string(&self) -> &String {
|
||||
match self {
|
||||
Self::CommandPart(value) => value,
|
||||
Self::Argument { name: _, value } => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkflowCommandDisplayData {
|
||||
command_parts: Vec<WorkflowCommandPart>,
|
||||
}
|
||||
|
||||
impl WorkflowCommandDisplayData {
|
||||
/// Use this to change the value of an argument when you want to value to be reflected in the
|
||||
/// command.
|
||||
///
|
||||
/// This iterates over the command_parts, finds the argument by name and replaces the matched
|
||||
/// item the vector.
|
||||
/// O(n) where n corresponds to the number of arguments in a workflow
|
||||
/// An assumption here is that command will not have many arguments to the point that we will
|
||||
/// notice a performance hit.
|
||||
pub fn set_argument_value(&mut self, argument_name: String, new_value: String) {
|
||||
let new_parts = &self
|
||||
.command_parts
|
||||
.iter()
|
||||
.map(|elem| match elem {
|
||||
WorkflowCommandPart::Argument { name, value } => {
|
||||
if argument_name == *name {
|
||||
WorkflowCommandPart::Argument {
|
||||
name: argument_name.clone(),
|
||||
value: new_value.clone(),
|
||||
}
|
||||
} else {
|
||||
WorkflowCommandPart::Argument {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkflowCommandPart::CommandPart(value) => {
|
||||
WorkflowCommandPart::CommandPart(value.clone())
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
self.command_parts.clone_from(new_parts);
|
||||
}
|
||||
|
||||
pub fn get_argument_values(&self) -> HashMap<String, String> {
|
||||
let args = self
|
||||
.command_parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
if let WorkflowCommandPart::Argument { name, value } = part {
|
||||
Some((name.clone(), value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
args
|
||||
}
|
||||
|
||||
/// Prints out the command as a string
|
||||
pub fn to_command_string(&self) -> String {
|
||||
self.command_parts
|
||||
.iter()
|
||||
.map(|part| part.to_command_string().as_str())
|
||||
.collect_vec()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Gets the argument byteoffset ranges. This can be used to highlight ranges for arguments in
|
||||
/// editors.
|
||||
pub fn argument_ranges(&self) -> Vec<Range<ByteOffset>> {
|
||||
let mut current_index = 0;
|
||||
let mut ranges = vec![];
|
||||
for part in &self.command_parts {
|
||||
match part {
|
||||
WorkflowCommandPart::CommandPart(value) => current_index += value.len(),
|
||||
WorkflowCommandPart::Argument { name: _, value } => {
|
||||
ranges.push(
|
||||
ByteOffset::from(current_index)
|
||||
..ByteOffset::from(current_index + value.len()),
|
||||
);
|
||||
current_index += value.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Create an empty command display data. Used for new workflows that don't have commands yet.
|
||||
pub fn new_empty() -> WorkflowCommandDisplayData {
|
||||
WorkflowCommandDisplayData {
|
||||
command_parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a command display data from an existing workflow.
|
||||
pub fn new_from_workflow(workflow: &Workflow) -> WorkflowCommandDisplayData {
|
||||
let (workflow_command, workflow_arguments) = parse_and_escape_workflow(workflow);
|
||||
|
||||
let mut command_parts: VecDeque<WorkflowCommandPart> = VecDeque::new();
|
||||
|
||||
let mut start = 0;
|
||||
for arg in workflow_arguments {
|
||||
// Capture command up until the argument
|
||||
command_parts.push_back(WorkflowCommandPart::CommandPart(String::from(
|
||||
&workflow_command[start..arg.byte_range.start.as_usize()],
|
||||
)));
|
||||
|
||||
// Capture the argument itself
|
||||
command_parts.push_back(WorkflowCommandPart::Argument {
|
||||
name: String::from(arg.argument_name),
|
||||
value: String::from(arg.replacement_text),
|
||||
});
|
||||
start = arg.byte_range.end.as_usize();
|
||||
}
|
||||
|
||||
// Capature the rest of the command with no arguments
|
||||
if start != workflow_command.len() {
|
||||
command_parts.push_back(WorkflowCommandPart::CommandPart(String::from(
|
||||
&workflow_command[start..],
|
||||
)));
|
||||
}
|
||||
|
||||
WorkflowCommandDisplayData {
|
||||
command_parts: Vec::from(command_parts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes workflow display data for displaying the workflow in the input editor and info box
|
||||
/// when the workflow is selected for execution.
|
||||
pub fn compute_workflow_display_data(workflow: &Workflow) -> WorkflowDisplayData {
|
||||
compute_workflow_display_data_internal(workflow, None)
|
||||
}
|
||||
|
||||
/// Computes workflow display data for displaying the workflow in the input editor and info box
|
||||
/// allowing argument override.
|
||||
pub fn compute_workflow_display_data_with_overrides(
|
||||
workflow: &Workflow,
|
||||
override_argument_values: HashMap<String, String>,
|
||||
) -> WorkflowDisplayData {
|
||||
let values = override_argument_values
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, ArgumentValue(v)))
|
||||
.collect();
|
||||
compute_workflow_display_data_internal(workflow, Some(values))
|
||||
}
|
||||
|
||||
/// Computes workflow display data for displaying the workflow in the input editor and info box
|
||||
/// when a history command associated with a workflow is selected for execution.
|
||||
pub fn compute_workflow_display_data_for_history_command(
|
||||
history_command: &str,
|
||||
workflow: &Workflow,
|
||||
) -> Option<WorkflowDisplayData> {
|
||||
let (workflow_command, workflow_arguments) = parse_and_escape_workflow(workflow);
|
||||
|
||||
let argument_values = parse_argument_values_from_command(
|
||||
history_command,
|
||||
workflow_command.as_str(),
|
||||
&workflow_arguments,
|
||||
)?;
|
||||
|
||||
let argument_values = argument_values
|
||||
.into_iter()
|
||||
.zip(workflow_arguments)
|
||||
.map(|(value, argument)| (argument.argument_name.to_owned(), value))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Some(compute_workflow_display_data_internal(
|
||||
workflow,
|
||||
Some(argument_values),
|
||||
))
|
||||
}
|
||||
|
||||
fn compute_workflow_display_data_internal(
|
||||
workflow: &Workflow,
|
||||
override_argument_values: Option<HashMap<String, ArgumentValue>>,
|
||||
) -> WorkflowDisplayData {
|
||||
let (command, workflow_arguments) = parse_and_escape_workflow(workflow);
|
||||
let mut command_with_replaced_arguments = command.to_owned();
|
||||
|
||||
let mut delta_bytes = 0_isize;
|
||||
let mut delta_chars = 0_isize;
|
||||
let mut replaced_ranges = vec![];
|
||||
let mut argument_index_to_highlight_index_map = HashMap::new();
|
||||
let mut argument_index_to_char_range_map = HashMap::new();
|
||||
let mut argument_index_to_object_id_map = HashMap::new();
|
||||
|
||||
// Compute the final command (with the argument identifiers replaced with the argument name)
|
||||
// and its corresponding text style ranges.
|
||||
for (highlight_index, workflow_argument) in workflow_arguments.into_iter().enumerate() {
|
||||
let original_char_range = workflow_argument.char_range;
|
||||
let original_byte_range = workflow_argument.byte_range;
|
||||
let replacement_text = override_argument_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.get(workflow_argument.argument_name))
|
||||
.map(|value| value.0.as_str())
|
||||
.unwrap_or(workflow_argument.replacement_text);
|
||||
|
||||
// Compute the range of the argument within the workflow and replace the range in the
|
||||
// original workflow command with the argument name.
|
||||
let text_byte_range = original_byte_range.start.add_signed(delta_bytes)
|
||||
..original_byte_range.end.add_signed(delta_bytes);
|
||||
let text_char_range = original_char_range.start.add_signed(delta_chars)
|
||||
..original_char_range.end.add_signed(delta_chars);
|
||||
|
||||
command_with_replaced_arguments.replace_range(
|
||||
text_byte_range.start.as_usize()..text_byte_range.end.as_usize(),
|
||||
replacement_text,
|
||||
);
|
||||
|
||||
// Compute the delta between the replacement text and the original length of the
|
||||
// argument within the command. This is inclusive of the curly braces around the
|
||||
// argument name, which is why we add 4 here (since there are two curly braces on each
|
||||
// side).
|
||||
let original_argument_byte_length = workflow_argument.argument_name.len() + 4;
|
||||
delta_bytes += replacement_text.len() as isize - original_argument_byte_length as isize;
|
||||
let original_argument_char_length = workflow_argument.argument_name.chars().count() + 4;
|
||||
delta_chars +=
|
||||
replacement_text.chars().count() as isize - original_argument_char_length as isize;
|
||||
|
||||
argument_index_to_highlight_index_map
|
||||
.entry(workflow_argument.argument_index)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(highlight_index);
|
||||
|
||||
replaced_ranges
|
||||
.push(text_byte_range.start..(text_byte_range.start + replacement_text.len()));
|
||||
|
||||
argument_index_to_char_range_map
|
||||
.entry(workflow_argument.argument_index)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(
|
||||
text_char_range.start..(text_char_range.start + replacement_text.chars().count()),
|
||||
);
|
||||
|
||||
if let ArgumentType::Enum { enum_id } = workflow_argument.argument_type {
|
||||
argument_index_to_object_id_map.insert(workflow_argument.argument_index, *enum_id);
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowDisplayData {
|
||||
command_with_replaced_arguments,
|
||||
replaced_ranges,
|
||||
argument_index_to_highlight_index_map,
|
||||
argument_index_to_char_range_map,
|
||||
argument_index_to_object_id_map,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove extra brackets from escaped arguments in workflow command and compute a list of workflow arguments and their positions in the escaped command.
|
||||
fn parse_and_escape_workflow(workflow: &Workflow) -> (String, Vec<WorkflowArgument<'_>>) {
|
||||
let (escaped_content, shift_indices) = replace_escaped_brackets_in_command(workflow.content());
|
||||
let workflow_arguments = parse_workflow_arguments(workflow, shift_indices);
|
||||
|
||||
(escaped_content, workflow_arguments)
|
||||
}
|
||||
|
||||
/// Replaces the triple brackets used for indicating escaped arguments in the command with double brackets.
|
||||
/// Returns both the modified command and a vector representing the start indices of the escaped arguments, based on the original command.
|
||||
fn replace_escaped_brackets_in_command(command: &str) -> (String, Vec<usize>) {
|
||||
let mut escaped_indices = Vec::new();
|
||||
let mut new_command = command.to_owned();
|
||||
let mut num_removed_brackets = 0;
|
||||
|
||||
// Iterate through escaped args and remove one bracket from either end
|
||||
for cap in ESCAPED_ARGUMENTS_PATTERN.captures_iter(command) {
|
||||
let escaped_arg = cap.get(0).expect("First regex group always exists");
|
||||
|
||||
new_command.replace_range(
|
||||
escaped_arg.start() - num_removed_brackets..escaped_arg.end() - num_removed_brackets,
|
||||
&escaped_arg.as_str()[1..escaped_arg.len() - 1], // remove one bracket from either end
|
||||
);
|
||||
|
||||
// Store the location of the escaped argument
|
||||
escaped_indices.push(escaped_arg.start());
|
||||
num_removed_brackets += 2;
|
||||
}
|
||||
|
||||
// Return the new command and the start index of escaped args that had brackets removed
|
||||
(new_command, escaped_indices)
|
||||
}
|
||||
|
||||
/// Given a `workflow` and a vector `escaped_indices`, which is presumed to be a vector representing the start
|
||||
/// indices of escaped arguments in the `workflow` command as generated by `replace_escaped_brackets_in_command`,
|
||||
/// return a vector of parsed WorkflowArgument objects.
|
||||
fn parse_workflow_arguments(
|
||||
workflow: &Workflow,
|
||||
escaped_indices: Vec<usize>,
|
||||
) -> Vec<WorkflowArgument<'_>> {
|
||||
workflow
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|argument| (argument, format!("{{{{{}}}}}", argument.name())))
|
||||
.enumerate()
|
||||
.flat_map(|(argument_index, (argument, argument_placeholder))| {
|
||||
let mut char_counter = CharCounter::new(workflow.content());
|
||||
workflow
|
||||
.content()
|
||||
.match_indices(argument_placeholder.as_str())
|
||||
.filter(|(argument_start_index, _)| {
|
||||
// Skip any escaped arguments (this case only occurs when an escaped argument has the same inner text as a real argument)
|
||||
*argument_start_index == 0
|
||||
|| !escaped_indices.contains(&(*argument_start_index - 1))
|
||||
})
|
||||
.map(|(argument_start_index, argument_placeholder)| {
|
||||
// Based on how many escape argument brackets we have already removed, shift over the stored argument range.
|
||||
let range_offset = escaped_indices
|
||||
.iter()
|
||||
.filter(|x| **x < argument_start_index)
|
||||
.count()
|
||||
* 2;
|
||||
let byte_start = ByteOffset::from(argument_start_index - range_offset);
|
||||
let byte_end = ByteOffset::from(
|
||||
argument_start_index + argument_placeholder.len() - range_offset,
|
||||
);
|
||||
|
||||
let char_start = char_counter
|
||||
.char_offset(byte_start)
|
||||
.unwrap_or(CharOffset::from(byte_start.as_usize()));
|
||||
let char_end = char_counter
|
||||
.char_offset(byte_end)
|
||||
.unwrap_or(CharOffset::from(byte_end.as_usize()));
|
||||
|
||||
WorkflowArgument {
|
||||
argument_index: argument_index.into(),
|
||||
argument_name: argument.name(),
|
||||
argument_type: &argument.arg_type,
|
||||
replacement_text: argument
|
||||
.default_value()
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| argument.name()),
|
||||
byte_range: byte_start..byte_end,
|
||||
char_range: char_start..char_end,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.sorted_by_key(|workflow_argument| workflow_argument.byte_range.start)
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArgumentValue(String);
|
||||
|
||||
/// Attempts to parse argument values from `command`, as specified in the given `workflow_command`.
|
||||
/// `workflow_arguments` are presumed to be the result of calling `parse_and_escape_workflow` on the
|
||||
/// given `workflow`.
|
||||
///
|
||||
/// If successful, returns a `Vec` containing the inferred argument values in the order they
|
||||
/// appeared in `command`.
|
||||
///
|
||||
/// If `command` doesn't appear to match the given `workflow`, returns `None`.
|
||||
fn parse_argument_values_from_command(
|
||||
history_command: &str,
|
||||
workflow_command: &str,
|
||||
workflow_arguments: &Vec<WorkflowArgument>,
|
||||
) -> Option<Vec<ArgumentValue>> {
|
||||
// Short-circuit if the workflow has no arguments, in which case we can just compare the commands directly.
|
||||
// It's possible to unify the codepath below to handle the no-argument corner case, but I don't think it's
|
||||
// actually worth the complexity.
|
||||
if workflow_arguments.is_empty() {
|
||||
return if history_command == workflow_command {
|
||||
Some(vec![])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
// Compute a list of the "static" segments of the workflow command (e.g. the segments of the
|
||||
// workflow command that are not argument placeholders). For example, the segments for workflow
|
||||
// "echo {{foo}}; cat {{bar}}" would be ["echo ", "; cat "].
|
||||
//
|
||||
// These are matched against the command, with the unmatched parts of the
|
||||
// command inferred to be argument values.
|
||||
let mut static_workflow_segments = VecDeque::new();
|
||||
|
||||
let mut start = 0;
|
||||
for arg in workflow_arguments {
|
||||
static_workflow_segments
|
||||
.push_back(&workflow_command[start..arg.byte_range.start.as_usize()]);
|
||||
start = arg.byte_range.end.as_usize();
|
||||
}
|
||||
if start != workflow_command.len() {
|
||||
static_workflow_segments.push_back(&workflow_command[start..]);
|
||||
}
|
||||
|
||||
// Iterate through history command, matching each static workflow segment in order against the
|
||||
// command.
|
||||
let mut argument_values = vec![];
|
||||
let mut end_of_last_matched_segment = 0;
|
||||
|
||||
// This is the number of chars in `command`, which is distinct from command.len(), which returns
|
||||
// the number of bytes in `command` and doesn't properly account for multi-byte chars.
|
||||
// let command_char_count = command.chars().count();
|
||||
let mut char_iter = history_command.char_indices().map(|(i, _)| i).peekable();
|
||||
while let Some(&i) = char_iter.peek() {
|
||||
// Attempt to match the next static workflow command segment against suffix of
|
||||
// `command` starting from `i`.
|
||||
let did_match_segment = match static_workflow_segments.front() {
|
||||
Some(segment) => history_command[i..].starts_with(segment),
|
||||
None => {
|
||||
argument_values.push(history_command[i..].to_owned());
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if did_match_segment {
|
||||
// If the segment matched, then we infer the unmatched prefix up until `i` to be a
|
||||
// workflow argument value.
|
||||
let matched_segment = static_workflow_segments.pop_front();
|
||||
if i != end_of_last_matched_segment {
|
||||
argument_values.push(history_command[end_of_last_matched_segment..i].to_owned());
|
||||
}
|
||||
for _ in 0..matched_segment.expect("should exist").chars().count() {
|
||||
char_iter.next();
|
||||
}
|
||||
if let Some(i) = char_iter.peek().copied() {
|
||||
end_of_last_matched_segment = i;
|
||||
}
|
||||
} else {
|
||||
char_iter.next();
|
||||
}
|
||||
}
|
||||
|
||||
if argument_values.len() != workflow_arguments.len() || end_of_last_matched_segment == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(argument_values.into_iter().map(ArgumentValue).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the given `command` is an instance of the given `workflow`.
|
||||
pub fn command_matches_workflow(command: &str, workflow: &Workflow) -> bool {
|
||||
let (workflow_command, workflow_arguments) = parse_and_escape_workflow(workflow);
|
||||
parse_argument_values_from_command(command, workflow_command.as_str(), &workflow_arguments)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "command_parser_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,405 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use crate::workflows::workflow::{Argument, Workflow};
|
||||
|
||||
use super::{compute_workflow_display_data, compute_workflow_display_data_for_history_command};
|
||||
|
||||
lazy_static! {
|
||||
static ref WORKFLOW: Workflow = Workflow::Command {
|
||||
name: "Run single integration test with display".to_owned(),
|
||||
command:
|
||||
"RUST_BACKTRACE=full WARP_SHELL_PATH={{shell_path}} cargo run -p integration --bin \
|
||||
integration --features=with_real_display_in_integration_tests -- {{test_name}}"
|
||||
.to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "shell_path".to_owned(),
|
||||
default_value: Some("/bin/bash".to_owned()),
|
||||
description: None,
|
||||
arg_type: Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "test_name".to_owned(),
|
||||
default_value: None,
|
||||
description: None,
|
||||
arg_type: Default::default()
|
||||
}
|
||||
],
|
||||
description: None,
|
||||
tags: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![
|
||||
warp_workflows::Shell::Zsh,
|
||||
warp_workflows::Shell::Bash,
|
||||
warp_workflows::Shell::Fish,
|
||||
],
|
||||
environment_variables: None,
|
||||
};
|
||||
static ref WORKFLOW_MULTIPLE_INSTANCES_SAME_PARAMETER: Workflow = Workflow::Command {
|
||||
name: "Echo my name 3 times".to_owned(),
|
||||
command: r#"echo {{name}} {{name}} {{name}}"#.to_owned(),
|
||||
arguments: vec![Argument {
|
||||
name: "name".to_owned(),
|
||||
default_value: Some("Zach".to_owned()),
|
||||
description: None,
|
||||
arg_type: Default::default(),
|
||||
},],
|
||||
description: None,
|
||||
tags: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![
|
||||
warp_workflows::Shell::Zsh,
|
||||
warp_workflows::Shell::Bash,
|
||||
warp_workflows::Shell::Fish,
|
||||
],
|
||||
environment_variables: None,
|
||||
};
|
||||
static ref WORKFLOW_NO_PARAMETERS: Workflow = Workflow::Command {
|
||||
name: "Print numbers 1 to 13".to_owned(),
|
||||
command: r#"for i in {0..13}; do echo $i; done"#.to_owned(),
|
||||
arguments: vec![],
|
||||
description: None,
|
||||
tags: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![
|
||||
warp_workflows::Shell::Bash,
|
||||
warp_workflows::Shell::Fish,
|
||||
warp_workflows::Shell::Zsh,
|
||||
],
|
||||
environment_variables: None,
|
||||
};
|
||||
static ref WORKFLOW_WITH_ESCAPES: Workflow = Workflow::Command {
|
||||
name: "Workflow with escaped arguments".to_owned(),
|
||||
command:
|
||||
r#"docker history --no-trunc --format {{arg1}} {{{.ID}}}: {{{.CreatedBy}}} {{{arg2}}} {{arg2}}"#
|
||||
.to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "arg1".to_owned(),
|
||||
default_value: Some("default1".to_owned()),
|
||||
description: None,
|
||||
arg_type: Default::default()
|
||||
},
|
||||
Argument {
|
||||
name: "arg2".to_owned(),
|
||||
default_value: None,
|
||||
description: None,
|
||||
arg_type: Default::default()
|
||||
}
|
||||
],
|
||||
description: None,
|
||||
tags: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![
|
||||
warp_workflows::Shell::Zsh,
|
||||
warp_workflows::Shell::Bash,
|
||||
warp_workflows::Shell::Fish,
|
||||
],
|
||||
environment_variables: None,
|
||||
};
|
||||
static ref WORKFLOW_WITH_DUPLICATES_AND_ESCAPES: Workflow = Workflow::Command {
|
||||
name: "Workflow with escaped arguments".to_owned(),
|
||||
command:
|
||||
r#"{{{hi}}} {{hi}} {{{{{{hi}} {{{{hi}}}} {{{{{hi}}} {{hi}}"#
|
||||
.to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "hi".to_owned(),
|
||||
default_value: None,
|
||||
description: None,
|
||||
arg_type: Default::default()
|
||||
},
|
||||
],
|
||||
description: None,
|
||||
tags: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![
|
||||
warp_workflows::Shell::Zsh,
|
||||
warp_workflows::Shell::Bash,
|
||||
warp_workflows::Shell::Fish,
|
||||
],
|
||||
environment_variables: None,
|
||||
};
|
||||
|
||||
static ref WORKFLOW_WITH_MULTIBYTE_CHARS: Workflow = Workflow::Command {
|
||||
name: "Workflow with multiyte chars".to_owned(),
|
||||
command:
|
||||
r#"echo "hello 😎{{name}}🤠{{name}}"#
|
||||
.to_owned(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "name".to_owned(),
|
||||
default_value: None,
|
||||
description: None,
|
||||
arg_type: Default::default()
|
||||
},
|
||||
],
|
||||
description: None,
|
||||
tags: vec![],
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![
|
||||
warp_workflows::Shell::Zsh,
|
||||
warp_workflows::Shell::Bash,
|
||||
warp_workflows::Shell::Fish,
|
||||
],
|
||||
environment_variables: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_linked_history_command() {
|
||||
// This command should be parsed as a workflow-linked command.
|
||||
//
|
||||
// It passes "/opt/homebrew/bin/fish" for the {{shell_path}} parameter and
|
||||
// test_command_search_loads_history for the {{test_name}} parameter.
|
||||
let linked_history_command =
|
||||
"RUST_BACKTRACE=full WARP_SHELL_PATH=/opt/homebrew/bin/fish cargo run -p integration --bin \
|
||||
integration --features=with_real_display_in_integration_tests -- \
|
||||
test_command_search_loads_history";
|
||||
let display_data =
|
||||
compute_workflow_display_data_for_history_command(linked_history_command, &WORKFLOW)
|
||||
.expect("WorkflowDisplayData should be Some()");
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
linked_history_command,
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![36.into()..58.into(), 155.into()..188.into()]
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.argument_index_to_char_range_map,
|
||||
HashMap::from([
|
||||
(0.into(), vec![36.into()..58.into()]),
|
||||
(1.into(), vec![155.into()..188.into()])
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_linked_history_command_with_multiple_instances_same_parameter(
|
||||
) {
|
||||
// This command should be parsed as a workflow-linked command.
|
||||
//
|
||||
// The workflow contains multiple instances of the same parameter
|
||||
let linked_history_command = r#"echo warp warp warp"#;
|
||||
let display_data = compute_workflow_display_data_for_history_command(
|
||||
linked_history_command,
|
||||
&WORKFLOW_MULTIPLE_INSTANCES_SAME_PARAMETER,
|
||||
)
|
||||
.expect("WorkflowDisplayData should be Some()");
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
linked_history_command,
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![
|
||||
5.into()..9.into(),
|
||||
10.into()..14.into(),
|
||||
15.into()..19.into()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.argument_index_to_char_range_map,
|
||||
HashMap::from([(
|
||||
0.into(),
|
||||
vec![
|
||||
5.into()..9.into(),
|
||||
10.into()..14.into(),
|
||||
15.into()..19.into()
|
||||
]
|
||||
),])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_linked_history_command_with_no_parameters() {
|
||||
let linked_history_command = r#"for i in {0..13}; do echo $i; done"#;
|
||||
let display_data = compute_workflow_display_data_for_history_command(
|
||||
linked_history_command,
|
||||
&WORKFLOW_NO_PARAMETERS,
|
||||
)
|
||||
.expect("WorkflowDisplayData should be Some()");
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
linked_history_command,
|
||||
);
|
||||
assert!(display_data.replaced_ranges.is_empty());
|
||||
assert!(display_data.argument_index_to_char_range_map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_linked_history_command_with_multibyte_chars() {
|
||||
let linked_history_command = r#"echo 😎🤠 😎🤠 😎🤠"#;
|
||||
let display_data = compute_workflow_display_data_for_history_command(
|
||||
linked_history_command,
|
||||
&WORKFLOW_MULTIPLE_INSTANCES_SAME_PARAMETER,
|
||||
)
|
||||
.expect("WorkflowDisplayData should be Some()");
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
linked_history_command,
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![
|
||||
5.into()..13.into(),
|
||||
14.into()..22.into(),
|
||||
23.into()..31.into(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.argument_index_to_char_range_map,
|
||||
HashMap::from([(
|
||||
0.into(),
|
||||
vec![
|
||||
5.into()..7.into(),
|
||||
8.into()..10.into(),
|
||||
11.into()..13.into()
|
||||
]
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_unlinked_history_command() {
|
||||
let unlinked_history_command = r#"echo foo"#;
|
||||
assert!(
|
||||
compute_workflow_display_data_for_history_command(unlinked_history_command, &WORKFLOW)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_unlinked_history_command_with_no_parameters() {
|
||||
let unlinked_history_command = r#"echo foo"#;
|
||||
assert!(compute_workflow_display_data_for_history_command(
|
||||
unlinked_history_command,
|
||||
&WORKFLOW_NO_PARAMETERS
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_similar_but_unlinked_history_command() {
|
||||
// This command is missing the "-p" from the workflow's command, so should not be linked to the
|
||||
// command.
|
||||
let similar_but_unlinked_history_command =
|
||||
"RUST_BACKTRACE=full WARP_SHELL_PATH=/opt/homebrew/bin/fish cargo run integration --bin \
|
||||
integration --features=with_real_display_in_integration_tests -- \
|
||||
test_command_search_loads_history";
|
||||
assert!(compute_workflow_display_data_for_history_command(
|
||||
similar_but_unlinked_history_command,
|
||||
&WORKFLOW
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_with_escaped_arguments() {
|
||||
let display_data = compute_workflow_display_data(&WORKFLOW_WITH_ESCAPES);
|
||||
let correct_command =
|
||||
"docker history --no-trunc --format default1 {{.ID}}: {{.CreatedBy}} {{arg2}} arg2";
|
||||
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
correct_command
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![35.into()..43.into(), 77.into()..81.into()]
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.argument_index_to_char_range_map,
|
||||
HashMap::from([
|
||||
(0.into(), vec![35.into()..43.into()]),
|
||||
(1.into(), vec![77.into()..81.into()])
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_with_duplicates_and_escaped_arguments() {
|
||||
let display_data = compute_workflow_display_data(&WORKFLOW_WITH_DUPLICATES_AND_ESCAPES);
|
||||
let correct_command = "{{hi}} hi {{{{hi {{{hi}}} {{{{hi}} hi";
|
||||
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
correct_command
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![
|
||||
7.into()..9.into(),
|
||||
14.into()..16.into(),
|
||||
35.into()..37.into()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.argument_index_to_char_range_map,
|
||||
HashMap::from([(
|
||||
0.into(),
|
||||
vec![
|
||||
7.into()..9.into(),
|
||||
14.into()..16.into(),
|
||||
35.into()..37.into()
|
||||
]
|
||||
),])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_for_linked_history_command_with_escaped_args() {
|
||||
let linked_history_command = "{{hi}} foo {{{{foo {{{hi}}} {{{{hi}} foo";
|
||||
|
||||
let display_data = compute_workflow_display_data_for_history_command(
|
||||
linked_history_command,
|
||||
&WORKFLOW_WITH_DUPLICATES_AND_ESCAPES,
|
||||
)
|
||||
.expect("WorkflowDisplayData should be Some()");
|
||||
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
linked_history_command,
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![
|
||||
7.into()..10.into(),
|
||||
15.into()..18.into(),
|
||||
37.into()..40.into()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_workflow_display_data_with_multibyte_chars() {
|
||||
let display_data = compute_workflow_display_data(&WORKFLOW_WITH_MULTIBYTE_CHARS);
|
||||
assert_eq!(
|
||||
display_data.command_with_replaced_arguments.as_str(),
|
||||
r#"echo "hello 😎name🤠name"#
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.replaced_ranges,
|
||||
vec![16.into()..20.into(), 24.into()..28.into()]
|
||||
);
|
||||
assert_eq!(
|
||||
display_data.argument_index_to_char_range_map,
|
||||
HashMap::from([(0.into(), vec![13.into()..17.into(), 18.into()..22.into()])])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use serde::{
|
||||
de::{self, MapAccess, Visitor},
|
||||
ser::SerializeStruct,
|
||||
Deserialize, Deserializer, Serialize, Serializer,
|
||||
};
|
||||
use serde_yaml::Value;
|
||||
use strum::VariantNames as _;
|
||||
use strum_macros::{Display, EnumString, VariantNames};
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use std::{collections::HashMap, fmt, result::Result, str::FromStr};
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
server::ids::{ClientId, SyncId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
workflow::{Argument, ArgumentType, Workflow},
|
||||
workflow_enum::{EnumVariants, WorkflowEnum},
|
||||
};
|
||||
|
||||
/// Separate structure for exporting arguments. This new structure holds explicit enum information,
|
||||
/// unlike the `Argument` struct which just holds the enum_id. It is also flatter than the normal `Argument`
|
||||
/// struct, making it easier to use serde's built-in serialize and deserialize methods.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct ExportArgument {
|
||||
pub name: String,
|
||||
#[serde(flatten, deserialize_with = "deserialize_arg_type")]
|
||||
pub arg_type: ExportArgumentType,
|
||||
pub description: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
|
||||
#[serde(tag = "arg_type")]
|
||||
enum ExportArgumentType {
|
||||
#[default]
|
||||
Text,
|
||||
Enum {
|
||||
enum_name: String,
|
||||
|
||||
// This field is Some() for static enums
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enum_variants: Option<Vec<String>>,
|
||||
|
||||
// This field is Some() for dynamic enums
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enum_command: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExportArgument {
|
||||
/// Create a new ExportArgument given an Argument
|
||||
fn new(argument: &Argument, app: &AppContext) -> Self {
|
||||
let arg_type = match argument.arg_type {
|
||||
ArgumentType::Text => ExportArgumentType::Text,
|
||||
ArgumentType::Enum { enum_id } => CloudModel::as_ref(app)
|
||||
.get_workflow_enum(&enum_id)
|
||||
.map(|workflow_enum| {
|
||||
let model = &workflow_enum.model().string_model;
|
||||
let enum_name = model.name.clone();
|
||||
|
||||
let mut enum_variants = None;
|
||||
let mut enum_command = None;
|
||||
|
||||
match &model.variants {
|
||||
EnumVariants::Static(variants) => enum_variants = Some(variants.clone()),
|
||||
EnumVariants::Dynamic(command) => enum_command = Some(command.clone()),
|
||||
};
|
||||
ExportArgumentType::Enum {
|
||||
enum_name,
|
||||
enum_variants,
|
||||
enum_command,
|
||||
}
|
||||
})
|
||||
.unwrap_or(ExportArgumentType::Text),
|
||||
};
|
||||
|
||||
ExportArgument {
|
||||
name: argument.name.clone(),
|
||||
arg_type,
|
||||
description: argument.description.clone(),
|
||||
default_value: argument.default_value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an ExportArgument to an Argument and create a new WorkflowEnum, if possible
|
||||
fn to_argument(argument: ExportArgument) -> (Argument, Option<(ClientId, WorkflowEnum)>) {
|
||||
let mut new_enum_info = None;
|
||||
|
||||
let name = argument.name;
|
||||
let description = argument.description;
|
||||
let default_value = argument.default_value;
|
||||
|
||||
let arg_type = match argument.arg_type {
|
||||
ExportArgumentType::Text => ArgumentType::Text,
|
||||
ExportArgumentType::Enum {
|
||||
enum_name,
|
||||
enum_variants,
|
||||
enum_command,
|
||||
} => {
|
||||
let workflow_enum =
|
||||
Self::try_into_workflow_enum(enum_name, enum_variants, enum_command);
|
||||
|
||||
match workflow_enum {
|
||||
Ok(enum_data) => {
|
||||
let client_id = ClientId::default();
|
||||
new_enum_info = Some((client_id, enum_data));
|
||||
ArgumentType::Enum {
|
||||
enum_id: SyncId::ClientId(client_id),
|
||||
}
|
||||
}
|
||||
// If we are missing some enum info, use the default type instead
|
||||
Err(_) => {
|
||||
log::warn!("Tried to deserialize an enum argument without any static variants or dynamic command provided, defaulting to {:?} argument", ArgumentType::default());
|
||||
ArgumentType::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
Argument {
|
||||
name,
|
||||
arg_type,
|
||||
description,
|
||||
default_value,
|
||||
},
|
||||
new_enum_info,
|
||||
)
|
||||
}
|
||||
|
||||
/// Try to create a new workflow enum from parsed data
|
||||
fn try_into_workflow_enum(
|
||||
enum_name: String,
|
||||
enum_variants: Option<Vec<String>>,
|
||||
enum_command: Option<String>,
|
||||
) -> Result<WorkflowEnum, anyhow::Error> {
|
||||
// Always create unshared enums on import
|
||||
let is_shared = false;
|
||||
|
||||
// Try to grab variants or command
|
||||
let variants = if let Some(variants) = enum_variants {
|
||||
EnumVariants::Static(variants)
|
||||
} else if let Some(command) = enum_command {
|
||||
EnumVariants::Dynamic(command)
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Missing valid enum variants"));
|
||||
};
|
||||
|
||||
Ok(WorkflowEnum {
|
||||
name: enum_name,
|
||||
is_shared,
|
||||
variants,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom serialize function used to export a workflow to YAML
|
||||
pub fn export_serialize<S>(
|
||||
workflow: &Workflow,
|
||||
serializer: S,
|
||||
app: &AppContext,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("Workflow", 9)?;
|
||||
|
||||
let export_args: Vec<ExportArgument> = workflow
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|arg| ExportArgument::new(arg, app))
|
||||
.collect();
|
||||
match workflow {
|
||||
Workflow::Command {
|
||||
name,
|
||||
description,
|
||||
command,
|
||||
tags,
|
||||
source_url,
|
||||
author,
|
||||
author_url,
|
||||
shells,
|
||||
..
|
||||
} => {
|
||||
if let Some(description) = description {
|
||||
state.serialize_field("description", description)?;
|
||||
}
|
||||
state.serialize_field("name", name)?;
|
||||
state.serialize_field("command", command)?;
|
||||
state.serialize_field("description", description)?;
|
||||
state.serialize_field("arguments", &export_args)?;
|
||||
state.serialize_field("tags", tags)?;
|
||||
state.serialize_field("source_url", source_url)?;
|
||||
state.serialize_field("author", author)?;
|
||||
state.serialize_field("author_url", author_url)?;
|
||||
state.serialize_field("shells", shells)?;
|
||||
}
|
||||
Workflow::AgentMode {
|
||||
name,
|
||||
description,
|
||||
query,
|
||||
..
|
||||
} => {
|
||||
state.serialize_field("type", "agent_mode")?;
|
||||
state.serialize_field("name", name)?;
|
||||
state.serialize_field("query", query)?;
|
||||
state.serialize_field("description", description)?;
|
||||
state.serialize_field("arguments", &export_args)?;
|
||||
}
|
||||
}
|
||||
|
||||
state.end()
|
||||
}
|
||||
|
||||
/// Macro for deserializing workflow fields, given an associated variant on Field, a string name, a variable name, an expected type, a
|
||||
/// and an optional flag, which is true when the field is optional as we deserialize.
|
||||
macro_rules! extract_fields {
|
||||
($map:expr; $(($field:ident, $name:literal, $var:ident, $type:ty, $optional:expr)),* $(,)?) => {{
|
||||
$(let mut $var = None;)*
|
||||
|
||||
while let Some(key) = $map.next_key()? {
|
||||
match key {
|
||||
$(Field::$field => {
|
||||
if $var.is_some() {
|
||||
return Err(de::Error::duplicate_field($name));
|
||||
}
|
||||
$var = Some($map.next_value()?);
|
||||
})*
|
||||
}
|
||||
}
|
||||
|
||||
$(
|
||||
let $var: $type = if $optional {
|
||||
$var.unwrap_or_default()
|
||||
} else {
|
||||
$var.ok_or_else(|| de::Error::missing_field($name))?
|
||||
};
|
||||
)*
|
||||
|
||||
($($var),*)
|
||||
}};
|
||||
}
|
||||
|
||||
/// Custom deserialize function used to import a workflow from YAML
|
||||
pub fn export_deserialize<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<(Workflow, HashMap<ClientId, WorkflowEnum>), D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
/// We use `strum` here to derive `from_str` and `VARIANTS`, which allows us to convert between
|
||||
/// field variants and strings a lot more easily.
|
||||
#[derive(Display, EnumString, VariantNames)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
enum Field {
|
||||
Name,
|
||||
Command,
|
||||
Tags,
|
||||
Description,
|
||||
Arguments,
|
||||
SourceUrl,
|
||||
Author,
|
||||
AuthorUrl,
|
||||
Shells,
|
||||
EnvironmentVariables,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Field {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Field, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct FieldVisitor;
|
||||
|
||||
impl Visitor<'_> for FieldVisitor {
|
||||
type Value = Field;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("workflow identifier")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Field, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
match Field::from_str(value) {
|
||||
Ok(field) => Ok(field),
|
||||
Err(_) => Err(de::Error::unknown_field(value, FIELDS)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_identifier(FieldVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkflowVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for WorkflowVisitor {
|
||||
type Value = (Workflow, HashMap<ClientId, WorkflowEnum>);
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("struct Workflow")
|
||||
}
|
||||
|
||||
fn visit_map<V>(
|
||||
self,
|
||||
mut map: V,
|
||||
) -> Result<(Workflow, HashMap<ClientId, WorkflowEnum>), V::Error>
|
||||
where
|
||||
V: MapAccess<'de>,
|
||||
{
|
||||
// Use the macro to extract values for each field
|
||||
let (
|
||||
name,
|
||||
command,
|
||||
description,
|
||||
export_arguments,
|
||||
tags,
|
||||
source_url,
|
||||
author,
|
||||
author_url,
|
||||
shells,
|
||||
environment_variables,
|
||||
) = extract_fields!(
|
||||
map; (Name, "name", name, String, false),
|
||||
(Command, "command", command, String, false),
|
||||
(Description, "description", description, Option<String>, true),
|
||||
(Arguments, "arguments", arguments, Vec<ExportArgument>, true),
|
||||
(Tags, "tags", tags, Vec<String>, true),
|
||||
(SourceUrl, "source_url", source_url, Option<String>, true),
|
||||
(Author, "author", author, Option<String>, true),
|
||||
(AuthorUrl, "author_url", author_url, Option<String>, true),
|
||||
(Shells, "shells", shells, Vec<warp_workflows::Shell>, true),
|
||||
(EnvironmentVariables, "environment_variables", environment_variables, Option<SyncId>, true),
|
||||
);
|
||||
|
||||
// Convert the ExportArguments to Arguments, and get a list of workflow enums that need to be created
|
||||
let (arguments, potential_enums): (
|
||||
Vec<Argument>,
|
||||
Vec<Option<(ClientId, WorkflowEnum)>>,
|
||||
) = export_arguments
|
||||
.into_iter()
|
||||
.map(ExportArgument::to_argument)
|
||||
.unzip();
|
||||
let workflow_enums = potential_enums.into_iter().flatten().collect();
|
||||
|
||||
Ok((
|
||||
Workflow::Command {
|
||||
name,
|
||||
command,
|
||||
description,
|
||||
arguments,
|
||||
tags,
|
||||
source_url,
|
||||
author,
|
||||
author_url,
|
||||
shells,
|
||||
environment_variables,
|
||||
},
|
||||
workflow_enums,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
const FIELDS: &[&str] = Field::VARIANTS;
|
||||
deserializer.deserialize_struct("Workflow", FIELDS, WorkflowVisitor)
|
||||
}
|
||||
|
||||
/// Custom deserialization for argument types, used to both `flatten` the argument type
|
||||
/// and allow for the specification of `default` behavior.
|
||||
///
|
||||
/// We need to specify default behavior to remain compatible with old workflow formats.
|
||||
///
|
||||
/// Necessary because serde currently does not support the use of `flatten` with a `default`,
|
||||
/// related GitHub issue here: https://github.com/serde-rs/serde/issues/1626
|
||||
fn deserialize_arg_type<'de, D>(deserializer: D) -> Result<ExportArgumentType, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value: Value = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
let arg_type = match value.get("arg_type").and_then(|value| value.as_str()) {
|
||||
Some("Text") => ExportArgumentType::Text,
|
||||
Some("Enum") => {
|
||||
let enum_name = value
|
||||
.get("enum_name")
|
||||
.and_then(|s| s.as_str().map(|s| s.to_string()));
|
||||
|
||||
let enum_variants = value
|
||||
.get("enum_variants")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.and_then(|seq| {
|
||||
seq.iter()
|
||||
.map(|s| s.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
});
|
||||
let enum_command = value
|
||||
.get("enum_command")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
|
||||
// If we don't have an enum name, default to a text argument
|
||||
match enum_name {
|
||||
Some(enum_name) => ExportArgumentType::Enum {
|
||||
enum_name,
|
||||
enum_variants,
|
||||
enum_command,
|
||||
},
|
||||
None => ExportArgumentType::default(),
|
||||
}
|
||||
}
|
||||
_ => ExportArgumentType::default(),
|
||||
};
|
||||
|
||||
Ok(arg_type)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use warp_util::path::ShellFamily;
|
||||
use warp_workflows::workflows as global_workflows;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::platform::OperatingSystem;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::user_config::load_workflows;
|
||||
use crate::{terminal::model::session::Session, user_config::WarpConfig};
|
||||
|
||||
use super::{workflow::Workflow, WorkflowSource};
|
||||
|
||||
pub fn workflows_dir(base_dir: impl AsRef<Path>) -> PathBuf {
|
||||
base_dir.as_ref().join("workflows")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UseCache {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
/// Singleton model that loads and caches local (non-WarpDrive) workflows.
|
||||
pub struct LocalWorkflows {
|
||||
app_workflows: Vec<Workflow>,
|
||||
|
||||
global_workflows: Vec<Workflow>,
|
||||
|
||||
project_workflows: HashMap<PathBuf, Vec<Workflow>>,
|
||||
}
|
||||
|
||||
impl LocalWorkflows {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
app_workflows: app_workflows(),
|
||||
global_workflows: global_workflows().into_iter().map(Workflow::from).collect(), // convert from public-facing Workflow type to warp-internal Workflow type
|
||||
project_workflows: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over hardcoded "application" workflows included in the Warp binary.
|
||||
pub fn app_workflows(&self) -> impl Iterator<Item = &Workflow> {
|
||||
self.app_workflows.iter()
|
||||
}
|
||||
|
||||
/// Returns an iterator over the static set of workflows for 3rd party tools loaded from Warp's
|
||||
/// workflows GitHub repo.
|
||||
pub fn global_workflows(
|
||||
&self,
|
||||
session: Option<Arc<Session>>,
|
||||
) -> impl Iterator<Item = &Workflow> {
|
||||
let top_level_commands = session.map(|session| {
|
||||
session
|
||||
.top_level_commands()
|
||||
.map(|command| command.to_lowercase())
|
||||
.collect::<HashSet<_>>()
|
||||
});
|
||||
|
||||
self.global_workflows.iter().filter(move |workflow| {
|
||||
let Some(top_level_commands) = top_level_commands.as_ref() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if let Some(first_token) = workflow
|
||||
.command()
|
||||
.and_then(|command| command.split_ascii_whitespace().next())
|
||||
{
|
||||
// Show any workflows that start with punctuation, as we can't
|
||||
// be sure of what the first executable token is.
|
||||
return first_token.starts_with(|c: char| c.is_ascii_punctuation())
|
||||
// Show any workflows that start with a token matching the top-level
|
||||
// commands available in the user's session.
|
||||
|| top_level_commands.contains(&first_token.to_lowercase());
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns an iterator over file-based workflows loaded from the `.warp/workflows` directory in
|
||||
/// the `working_directory`.
|
||||
///
|
||||
/// The loaded workflows vector is cached.
|
||||
///
|
||||
/// If `use_cache` is `UseCache::Yes` and there is an existing cached vector, returns an
|
||||
/// iterator over the cached workflows.
|
||||
///
|
||||
/// If `use_cache` is `UseCache::No`, reads the workflows from disk regardless of whether or
|
||||
/// not there is an existing cached vector and updates the cached vector.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn project_workflows(
|
||||
&mut self,
|
||||
working_directory: &Path,
|
||||
use_cache: UseCache,
|
||||
) -> impl Iterator<Item = &Workflow> {
|
||||
let has_cached_copy = self.project_workflows.contains_key(working_directory);
|
||||
if !has_cached_copy || use_cache == UseCache::No {
|
||||
let repo_workflows = load_project_workflows(working_directory);
|
||||
self.project_workflows
|
||||
.insert(working_directory.to_owned(), repo_workflows);
|
||||
}
|
||||
|
||||
self.project_workflows
|
||||
.get(working_directory)
|
||||
.expect("Workflows should exist; they were just inserted")
|
||||
.iter()
|
||||
}
|
||||
|
||||
/// Returns the workflow with the given `command` along with its corresponding
|
||||
/// `WorkflowSource`.
|
||||
///
|
||||
/// `command` is the parameterized `command` value of the workflow, e.g. `echo {{foo}}` if the
|
||||
/// workflow contains a parameter called "foo".
|
||||
pub fn workflow_with_command(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
command: &str,
|
||||
) -> Option<(WorkflowSource, Workflow)> {
|
||||
self.app_workflows
|
||||
.iter()
|
||||
.map(|workflow| (WorkflowSource::App, workflow))
|
||||
.chain(
|
||||
self.global_workflows
|
||||
.iter()
|
||||
.map(|workflow| (WorkflowSource::Global, workflow)),
|
||||
)
|
||||
.chain(
|
||||
self.project_workflows
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|workflow| (WorkflowSource::Project, workflow)),
|
||||
)
|
||||
.chain(
|
||||
WarpConfig::as_ref(ctx)
|
||||
.local_user_workflows()
|
||||
.iter()
|
||||
.map(|workflow| (WorkflowSource::Local, workflow)),
|
||||
)
|
||||
.find(|(_, workflow)| {
|
||||
if let Workflow::Command {
|
||||
command: workflow_command,
|
||||
..
|
||||
} = workflow
|
||||
{
|
||||
workflow_command == command
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.map(|(workflow_source, workflow)| (workflow_source, workflow.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for LocalWorkflows {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for LocalWorkflows {}
|
||||
|
||||
/// Returns all app workflows.
|
||||
fn app_workflows() -> Vec<Workflow> {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
let shell_family = OperatingSystem::get().default_shell_family();
|
||||
self::prompt_chip_logging_workflow(shell_family)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads project-level workflows (if any) from the warp config directory in the current working
|
||||
/// directory.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(super) fn load_project_workflows(path: &Path) -> Vec<Workflow> {
|
||||
match git2::Repository::discover(path) {
|
||||
Ok(repository) => repository.workdir().map_or(Vec::new(), |workdir| {
|
||||
load_workflows(&workflows_dir(
|
||||
workdir.join(warp_core::paths::WARP_CONFIG_DIR),
|
||||
))
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `tail` or equivalent command on the given path.
|
||||
/// Note: On Windows this may cause a lossy conversion if the path is not valid UTF-8.
|
||||
pub fn tail_command_for_shell(shell_family: ShellFamily, path: &PathBuf) -> String {
|
||||
match shell_family {
|
||||
// Use debug formatting for `PathBuf` so that any non-Unicode components of the path get
|
||||
// escaped. This will also add quotes around the path, so there's no need to add them in
|
||||
// the format string.
|
||||
ShellFamily::Posix => format!("tail -f {path:?}"),
|
||||
// We avoid the debug formatting here so that backslashes don't get escaped, which is not
|
||||
// desireable for PowerShell. Note that this may be lossy conversion if the path is not
|
||||
// valid UTF-8.
|
||||
ShellFamily::PowerShell => {
|
||||
format!("Get-Content -Wait -Tail 10 -Path \"{}\"", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn prompt_chip_logging_workflow(shell_family: ShellFamily) -> Option<Workflow> {
|
||||
if !warp_core::channel::ChannelState::enable_debug_features() {
|
||||
return None;
|
||||
}
|
||||
let log_file_path = crate::context_chips::logging::log_file_path().ok()?;
|
||||
Some(Workflow::Command {
|
||||
name: "Tail prompt chip log".into(),
|
||||
command: tail_command_for_shell(shell_family, &log_file_path),
|
||||
tags: vec!["warp".into(), "debug".into()],
|
||||
description: Some(
|
||||
"Shows the diagnostic log of shell commands run by prompt context chips (dogfood only)"
|
||||
.into(),
|
||||
),
|
||||
arguments: vec![],
|
||||
source_url: None,
|
||||
author: Some("Warp".into()),
|
||||
author_url: None,
|
||||
shells: vec![],
|
||||
environment_variables: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "local_workflows_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn initialize_app(app: &App) {
|
||||
app.add_singleton_model(WarpConfig::mock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_global_workflows_for_session() {
|
||||
App::test((), |app| async move {
|
||||
initialize_app(&app);
|
||||
|
||||
let local_workflows = app.add_singleton_model(LocalWorkflows::new);
|
||||
|
||||
// Create a session that has "tr" as the only available command.
|
||||
let session = Session::test();
|
||||
session.set_external_commands(["tr"]);
|
||||
|
||||
local_workflows.read(&app, move |local_workflows, _| {
|
||||
// Verify that all workflows either start with "tr" or punctuation.
|
||||
local_workflows
|
||||
.global_workflows(Some(Arc::new(session)))
|
||||
.for_each(|workflow| {
|
||||
let command = workflow.command().expect("Workflow is Command Workflow");
|
||||
assert!(
|
||||
command.starts_with("tr")
|
||||
|| command.starts_with(|c: char| c.is_ascii_punctuation()),
|
||||
"found workflow that should have been filtered: {command}"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workflow_by_command_name() {
|
||||
App::test((), |app| async move {
|
||||
initialize_app(&app);
|
||||
|
||||
let local_workflows = app.add_singleton_model(LocalWorkflows::new);
|
||||
|
||||
let global_workflow_command = r#"{{command}} 1> {{file}}"#;
|
||||
local_workflows.read(&app, move |local_workflows, ctx| {
|
||||
// Verify that all workflows either start with punctuation or with "tr".
|
||||
let Some((workflow_source, workflow)) =
|
||||
local_workflows.workflow_with_command(ctx, global_workflow_command)
|
||||
else {
|
||||
panic!("Did not find workflow with command {global_workflow_command}");
|
||||
};
|
||||
assert_eq!(workflow.name(), "Redirect stdout");
|
||||
assert_eq!(workflow_source, WorkflowSource::Global);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use super::{workflow::Workflow, CloudWorkflowModel};
|
||||
use crate::{
|
||||
cloud_object::{model::persistence::CloudModel, GenericCloudObject, Owner},
|
||||
drive::OpenWarpDriveObjectSettings,
|
||||
pane_group::{PaneContent, WorkflowPane},
|
||||
safe_warn,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ClientId, SyncId},
|
||||
},
|
||||
workflows::{workflow_view::WorkflowView, WorkflowViewMode},
|
||||
PaneViewLocator, WindowId,
|
||||
};
|
||||
use std::collections::{hash_map::Entry, HashMap};
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
pub struct WorkflowManager {
|
||||
panes_by_hashed_id: HashMap<String, WorkflowPaneData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkflowOpenSource {
|
||||
Existing(SyncId),
|
||||
New {
|
||||
title: Option<String>,
|
||||
|
||||
/// The "content" of the workflow.
|
||||
/// For `Command` workflows, this is the command.
|
||||
/// For `AgentMode` workflows, this is the AI query.
|
||||
content: Option<String>,
|
||||
|
||||
owner: Owner,
|
||||
initial_folder_id: Option<SyncId>,
|
||||
is_for_agent_mode: bool,
|
||||
},
|
||||
NewFromWorkflow {
|
||||
workflow: Box<Workflow>,
|
||||
owner: Owner,
|
||||
initial_folder_id: Option<SyncId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkflowManager {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&UpdateManager::handle(ctx),
|
||||
Self::handle_update_manager_event,
|
||||
);
|
||||
|
||||
WorkflowManager {
|
||||
panes_by_hashed_id: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_pane(&self, source: &WorkflowOpenSource) -> Option<(WindowId, PaneViewLocator)> {
|
||||
match source {
|
||||
WorkflowOpenSource::Existing(workflow_id) => {
|
||||
let pane_data = self.panes_by_hashed_id.get(&workflow_id.uid())?;
|
||||
Some((pane_data.window_id, pane_data.locator))
|
||||
}
|
||||
WorkflowOpenSource::New { .. } | WorkflowOpenSource::NewFromWorkflow { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_pane(
|
||||
&mut self,
|
||||
source: &WorkflowOpenSource,
|
||||
settings: &OpenWarpDriveObjectSettings,
|
||||
mode: WorkflowViewMode,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> WorkflowPane {
|
||||
let view = ctx.add_typed_action_view(window_id, WorkflowView::new_in_pane);
|
||||
|
||||
match source {
|
||||
WorkflowOpenSource::Existing(workflow_id) => {
|
||||
let workflow = CloudModel::as_ref(ctx).get_workflow(workflow_id).cloned();
|
||||
if let Some(workflow) = workflow {
|
||||
view.update(ctx, |view, ctx| view.load(workflow, settings, mode, ctx));
|
||||
} else {
|
||||
// If the workflow doesn't exist, try waiting for initial load and trying again
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.wait_for_initial_load_then_load(
|
||||
*workflow_id,
|
||||
settings,
|
||||
mode,
|
||||
window_id,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
WorkflowOpenSource::New {
|
||||
title,
|
||||
content,
|
||||
owner,
|
||||
initial_folder_id,
|
||||
is_for_agent_mode,
|
||||
} => view.update(ctx, |view, ctx| {
|
||||
view.open_new_workflow(
|
||||
title.clone(),
|
||||
content.clone(),
|
||||
*owner,
|
||||
*initial_folder_id,
|
||||
*is_for_agent_mode,
|
||||
SyncId::ClientId(ClientId::default()),
|
||||
ctx,
|
||||
)
|
||||
}),
|
||||
WorkflowOpenSource::NewFromWorkflow {
|
||||
workflow,
|
||||
owner,
|
||||
initial_folder_id,
|
||||
} => {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.load(
|
||||
GenericCloudObject::new_local(
|
||||
CloudWorkflowModel::new(*workflow.clone()),
|
||||
*owner,
|
||||
*initial_folder_id,
|
||||
ClientId::default(),
|
||||
),
|
||||
&OpenWarpDriveObjectSettings::default(),
|
||||
mode,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowPane::new(view, ctx)
|
||||
}
|
||||
|
||||
pub fn register_pane(
|
||||
&mut self,
|
||||
pane: &WorkflowPane,
|
||||
pane_group_id: EntityId,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let workflow_id = pane.get_view(ctx).as_ref(ctx).workflow_id();
|
||||
let entry = self.panes_by_hashed_id.entry(workflow_id.uid());
|
||||
if let Entry::Vacant(entry) = entry {
|
||||
entry.insert(WorkflowPaneData {
|
||||
workflow_id,
|
||||
window_id,
|
||||
locator: PaneViewLocator {
|
||||
pane_group_id,
|
||||
pane_id: pane.id(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
safe_warn!(
|
||||
safe: ("Ignoring duplicate Workflow pane registration"),
|
||||
full: ("Ignoring duplicate Workflow pane registration for {workflow_id}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deregister_pane(&mut self, pane: &WorkflowPane, ctx: &mut ModelContext<Self>) {
|
||||
let workflow_id = pane.get_view(ctx).as_ref(ctx).workflow_id();
|
||||
|
||||
// If a workflow pane is restored, the workflow may have been reopened in the meantime. In
|
||||
// that case, don't let closing the original pane clear out the new pane.
|
||||
if let Entry::Occupied(entry) = self.panes_by_hashed_id.entry(workflow_id.uid()) {
|
||||
if entry.get().locator.pane_id == pane.id() {
|
||||
entry.remove();
|
||||
} else {
|
||||
log::warn!(
|
||||
"Ignoring duplicate registration of panes for {}",
|
||||
workflow_id.uid()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
event: &UpdateManagerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !matches!(&result.success_type, OperationSuccessType::Success) {
|
||||
return;
|
||||
}
|
||||
if let ObjectOperation::Create { .. } = result.operation {
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
let Some(server_id) = CloudModel::as_ref(ctx)
|
||||
.get_workflow_by_uid(&server_id.uid())
|
||||
.and_then(|workflow| workflow.id.into_server())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(client_id) = result.client_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(mut pane) = self.panes_by_hashed_id.remove(&client_id.to_string()) {
|
||||
pane.workflow_id = SyncId::ServerId(server_id);
|
||||
self.panes_by_hashed_id
|
||||
.insert(server_id.uid().clone(), pane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.panes_by_hashed_id.clear();
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkflowPaneData {
|
||||
workflow_id: SyncId,
|
||||
window_id: WindowId,
|
||||
locator: PaneViewLocator,
|
||||
}
|
||||
|
||||
impl Entity for WorkflowManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for WorkflowManager {}
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
pub mod categories;
|
||||
use anyhow::Result;
|
||||
use workflow::Workflow;
|
||||
|
||||
pub mod aliases;
|
||||
pub mod command_parser;
|
||||
pub mod export_workflow;
|
||||
pub mod info_box;
|
||||
pub mod local_workflows;
|
||||
pub mod manager;
|
||||
pub mod workflow;
|
||||
pub mod workflow_enum;
|
||||
pub mod workflow_view;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::cloud_object::{
|
||||
CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
|
||||
GenericCloudObject, GenericServerObject, ObjectType, Revision, ServerCloudObject,
|
||||
UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
|
||||
use crate::drive::items::workflow::WarpDriveWorkflow;
|
||||
use crate::drive::items::WarpDriveItem;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::notebooks::{NotebookId, NotebookLocation};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::object::ObjectClient;
|
||||
use crate::server::sync_queue::{QueueItem, SerializedModel};
|
||||
use async_trait::async_trait;
|
||||
pub use categories::{CategoriesView, CategoriesViewEvent, WorkflowsViewAction};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
categories::init(app);
|
||||
self::workflow_view::init(app);
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)]
|
||||
pub enum WorkflowSource {
|
||||
Global,
|
||||
Local,
|
||||
Project,
|
||||
Team {
|
||||
team_uid: ServerId,
|
||||
},
|
||||
PersonalCloud,
|
||||
WarpAI,
|
||||
Notebook {
|
||||
notebook_id: Option<NotebookId>,
|
||||
team_uid: Option<ServerId>,
|
||||
location: NotebookLocation,
|
||||
},
|
||||
|
||||
/// A hardcoded workflow type that allows Warp to surface features as Workflows (e.g.
|
||||
/// a command to see our network log)
|
||||
App,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, PartialOrd)]
|
||||
pub enum WorkflowSelectionSource {
|
||||
WarpDrive,
|
||||
CommandPalette,
|
||||
UniversalSearch,
|
||||
Voltron,
|
||||
WarpAI,
|
||||
Notebook,
|
||||
SlashMenu,
|
||||
UpArrowHistory,
|
||||
WorkflowView,
|
||||
AgentMode,
|
||||
Undefined,
|
||||
Alias,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum WorkflowViewMode {
|
||||
View,
|
||||
Edit,
|
||||
Create,
|
||||
}
|
||||
|
||||
impl WorkflowViewMode {
|
||||
/// The editing mode supported for a workflow.
|
||||
///
|
||||
/// Editing is disabled if the user does not have edit permissions.
|
||||
pub fn supported_edit_mode(workflow_id: Option<SyncId>, app: &AppContext) -> Self {
|
||||
let can_edit = workflow_id
|
||||
.map(|id| {
|
||||
CloudViewModel::as_ref(app)
|
||||
.object_editability(&id.uid(), app)
|
||||
.can_edit()
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if !FeatureFlag::SharedWithMe.is_enabled() || can_edit {
|
||||
Self::Edit
|
||||
} else {
|
||||
Self::View
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewing mode supported for this workflow.
|
||||
///
|
||||
/// Viewing is disabled if the user is allowed to edit the workflow and in a context where
|
||||
/// running workflows is supported.
|
||||
pub fn supported_view_mode(workflow_id: Option<SyncId>, app: &AppContext) -> Self {
|
||||
let can_edit = workflow_id
|
||||
.map(|id| {
|
||||
CloudViewModel::as_ref(app)
|
||||
.object_editability(&id.uid(), app)
|
||||
.can_edit()
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if FeatureFlag::SharedWithMe.is_enabled() && !can_edit {
|
||||
Self::View
|
||||
} else if ContextFlag::RunWorkflow.is_enabled() {
|
||||
Self::Edit
|
||||
} else {
|
||||
Self::View
|
||||
}
|
||||
}
|
||||
|
||||
fn is_editable(&self) -> bool {
|
||||
match self {
|
||||
Self::View => false,
|
||||
Self::Edit | Self::Create => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub struct WorkflowId(ServerId);
|
||||
crate::server_id_traits! { WorkflowId, "Workflow" }
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum AIWorkflowOrigin {
|
||||
CommandSearch,
|
||||
AgentMode,
|
||||
LegacyWarpAI,
|
||||
}
|
||||
|
||||
/// Wrapper type for a workflow that may be saved locally or using cloud sync.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum WorkflowType {
|
||||
/// Saved workflows sourced from local, global, project, app collections, saved locally.
|
||||
Local(Workflow),
|
||||
/// Saved workflows from personal or team collections, saved using cloud-sync.
|
||||
Cloud(Box<CloudWorkflow>),
|
||||
/// Ephemeral/transient workflows created from Warp AI output
|
||||
AIGenerated {
|
||||
workflow: Workflow,
|
||||
origin: AIWorkflowOrigin,
|
||||
},
|
||||
/// A workflow that's part of a cloud notebook.
|
||||
Notebook(Workflow),
|
||||
}
|
||||
|
||||
impl WorkflowType {
|
||||
pub fn as_workflow(&self) -> &Workflow {
|
||||
match self {
|
||||
WorkflowType::Local(workflow) => workflow,
|
||||
WorkflowType::AIGenerated { workflow, .. } => workflow,
|
||||
WorkflowType::Cloud(workflow) => &workflow.model().data,
|
||||
WorkflowType::Notebook(workflow) => workflow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the contained [`Workflow`], consuming `self`.
|
||||
pub fn take_workflow(self) -> Workflow {
|
||||
match self {
|
||||
WorkflowType::Local(workflow) => workflow,
|
||||
WorkflowType::AIGenerated { workflow, .. } => workflow,
|
||||
WorkflowType::Cloud(workflow) => workflow.model().data.clone(),
|
||||
WorkflowType::Notebook(workflow) => workflow,
|
||||
}
|
||||
}
|
||||
|
||||
/// The object type and ID for the cloud object containing this workflow, if there is
|
||||
/// one. This is currently only supported for cloud workflows, not workflows within notebooks.
|
||||
pub fn object_id(&self) -> Option<CloudObjectTypeAndId> {
|
||||
match self {
|
||||
WorkflowType::Cloud(workflow) => Some(CloudObjectTypeAndId::Workflow(workflow.id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_id(&self) -> Option<SyncId> {
|
||||
match self {
|
||||
WorkflowType::Cloud(workflow) => Some(workflow.id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_id(&self) -> Option<WorkflowId> {
|
||||
match self.object_id() {
|
||||
Some(CloudObjectTypeAndId::Workflow(id)) => id.into_server().map(Into::into),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// We don't show env var selection for Agent Mode suggested commands.
|
||||
pub(super) fn should_show_env_var_selection(&self) -> bool {
|
||||
!matches!(self, WorkflowType::AIGenerated { .. },)
|
||||
}
|
||||
}
|
||||
|
||||
/// The model for a `CloudWorkflow`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CloudWorkflowModel {
|
||||
pub data: Workflow,
|
||||
}
|
||||
|
||||
impl CloudWorkflowModel {
|
||||
pub fn new(workflow: Workflow) -> Self {
|
||||
Self { data: workflow }
|
||||
}
|
||||
}
|
||||
|
||||
/// `CloudWorkflow` is a workflow retrieved from the server.
|
||||
pub type CloudWorkflow = GenericCloudObject<WorkflowId, CloudWorkflowModel>;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl CloudModelType for CloudWorkflowModel {
|
||||
type CloudObjectType = CloudWorkflow;
|
||||
type IdType = WorkflowId;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
if self.data.is_agent_mode_workflow() {
|
||||
"Prompt"
|
||||
} else {
|
||||
"Workflow"
|
||||
}
|
||||
}
|
||||
|
||||
fn object_type(&self) -> ObjectType {
|
||||
ObjectType::Workflow
|
||||
}
|
||||
|
||||
fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId {
|
||||
CloudObjectTypeAndId::Workflow(id)
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.data.name().to_string()
|
||||
}
|
||||
|
||||
fn set_display_name(&mut self, name: &str) {
|
||||
self.data.set_name(name);
|
||||
}
|
||||
|
||||
fn upsert_event(&self, workflow: &CloudWorkflow) -> ModelEvent {
|
||||
ModelEvent::UpsertWorkflow {
|
||||
workflow: workflow.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_upsert_event(objects: &[CloudWorkflow]) -> ModelEvent {
|
||||
ModelEvent::UpsertWorkflows(objects.to_vec())
|
||||
}
|
||||
|
||||
fn create_object_queue_item(
|
||||
&self,
|
||||
workflow: &CloudWorkflow,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
initiated_by: InitiatedBy,
|
||||
) -> Option<QueueItem> {
|
||||
if let SyncId::ClientId(client_id) = workflow.id {
|
||||
return Some(QueueItem::CreateWorkflow {
|
||||
object_type: self.object_type(),
|
||||
owner: workflow.permissions.owner,
|
||||
model: Arc::new(workflow.model().clone()),
|
||||
initial_folder_id: workflow.metadata.folder_id,
|
||||
entrypoint,
|
||||
id: client_id,
|
||||
initiated_by,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
workflow: &CloudWorkflow,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateWorkflow {
|
||||
// Note that this is intentionally a deep clone of the model because we are grabbing
|
||||
// a snapshot to update at a moment in time.
|
||||
model: workflow.model().clone().into(),
|
||||
id: workflow.id,
|
||||
revision: revision_ts.or_else(|| workflow.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_update_after_server_conflict(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn serialized(&self) -> SerializedModel {
|
||||
SerializedModel::new(
|
||||
serde_json::to_string(&self.data).expect("failed to serialize workflow"),
|
||||
)
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::Workflow(server_workflow) = server_cloud_object {
|
||||
return Some(CloudWorkflowModel {
|
||||
data: server_workflow.model.data.clone(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn send_create_request(
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
request: CreateObjectRequest,
|
||||
) -> Result<CreateCloudObjectResult> {
|
||||
object_client.create_workflow(request).await
|
||||
}
|
||||
|
||||
async fn send_update_request(
|
||||
&self,
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
server_id: ServerId,
|
||||
revision: Option<Revision>,
|
||||
) -> Result<UpdateCloudObjectResult<GenericServerObject<WorkflowId, Self>>> {
|
||||
object_client
|
||||
.update_workflow(
|
||||
server_id.into(),
|
||||
serde_json::to_string(&self.data)?.into(),
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
id: SyncId,
|
||||
_appearance: &Appearance,
|
||||
workflow: &CloudWorkflow,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
Some(Box::new(WarpDriveWorkflow::new(
|
||||
self.cloud_object_type_and_id(id),
|
||||
workflow.clone(),
|
||||
)))
|
||||
}
|
||||
|
||||
fn can_export(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Workflow> for CloudWorkflow {
|
||||
fn eq(&self, other: &Workflow) -> bool {
|
||||
self.model().data == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<CloudWorkflow> for CloudWorkflow {
|
||||
fn eq(&self, other: &CloudWorkflow) -> bool {
|
||||
self.model().data == other.model().data && self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CloudWorkflow> for Workflow {
|
||||
fn from(cloud_workflow: CloudWorkflow) -> Self {
|
||||
cloud_workflow.model().data.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CloudWorkflow> for Workflow {
|
||||
fn from(cloud_workflow: &CloudWorkflow) -> Self {
|
||||
cloud_workflow.model().data.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,71 @@
|
||||
use warpui::App;
|
||||
|
||||
use crate::server::ids::SyncId;
|
||||
|
||||
use super::workflow::{Argument, Workflow};
|
||||
|
||||
#[test]
|
||||
fn test_serialize_cloud_workflow() {
|
||||
App::test((), |_app| async move {
|
||||
let sample_workflow = Workflow::new("Test name", "Command name");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Workflow>(
|
||||
serde_json::to_string(&sample_workflow)
|
||||
.expect("Serialized workflow.")
|
||||
.as_str()
|
||||
)
|
||||
.expect("Deserialized workflow."),
|
||||
sample_workflow
|
||||
);
|
||||
|
||||
let arguments = vec![Argument {
|
||||
name: "Argument".to_string(),
|
||||
description: Some("no".to_string()),
|
||||
default_value: None,
|
||||
arg_type: Default::default(),
|
||||
}];
|
||||
let arguments_workflow = sample_workflow.clone().with_arguments(arguments);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Workflow>(
|
||||
serde_json::to_string(&arguments_workflow)
|
||||
.expect("Serialized workflow.")
|
||||
.as_str()
|
||||
)
|
||||
.expect("Deserialized workflow."),
|
||||
arguments_workflow
|
||||
);
|
||||
|
||||
let description_workflow = sample_workflow.with_description("cool description".to_string());
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Workflow>(
|
||||
serde_json::to_string(&description_workflow)
|
||||
.expect("Serialized workflow.")
|
||||
.as_str()
|
||||
)
|
||||
.expect("Deserialized workflow."),
|
||||
description_workflow
|
||||
);
|
||||
|
||||
let workflow_with_additional_fields = Workflow::Command {
|
||||
name: "Test".to_string(),
|
||||
command: "Command".to_string(),
|
||||
tags: vec![],
|
||||
description: None,
|
||||
arguments: vec![],
|
||||
source_url: Some("url".to_string()),
|
||||
author: Some("author_name".to_string()),
|
||||
author_url: None,
|
||||
shells: vec![],
|
||||
environment_variables: Some(SyncId::ServerId(123.into())),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Workflow>(
|
||||
serde_json::to_string(&workflow_with_additional_fields)
|
||||
.expect("Serialized workflow.")
|
||||
.as_str()
|
||||
)
|
||||
.expect("Deserialized workflow."),
|
||||
workflow_with_additional_fields
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use warp_workflows;
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::generic_string_model::GenericStringObjectId, server::ids::SyncId,
|
||||
};
|
||||
|
||||
/// Workflow model to be used inside of `warp-internal`
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Workflow {
|
||||
AgentMode {
|
||||
name: String,
|
||||
|
||||
/// The query to be inserted in the terminal input.
|
||||
query: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
arguments: Vec<Argument>,
|
||||
},
|
||||
#[serde(untagged)]
|
||||
Command {
|
||||
name: String,
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Vec<Argument>,
|
||||
source_url: Option<String>,
|
||||
author: Option<String>,
|
||||
author_url: Option<String>,
|
||||
#[serde(default)]
|
||||
shells: Vec<warp_workflows::Shell>,
|
||||
#[serde(default)]
|
||||
environment_variables: Option<SyncId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Workflow {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::AgentMode { name, .. } => name.as_str(),
|
||||
Self::Command { name, .. } => name.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The core "content" of the workflow.
|
||||
///
|
||||
/// For Command workflows, this is the shell command. For Agent Mode workflows, this is the
|
||||
/// query.
|
||||
pub fn content(&self) -> &str {
|
||||
match self {
|
||||
Self::AgentMode { query, .. } => query,
|
||||
Self::Command { command, .. } => command,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt(&self) -> Option<&str> {
|
||||
if let Self::AgentMode { query, .. } = self {
|
||||
Some(query.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&self) -> Option<&str> {
|
||||
if let Self::Command { command, .. } = self {
|
||||
Some(command.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<&String> {
|
||||
match self {
|
||||
Self::AgentMode { description, .. } => description.as_ref(),
|
||||
Self::Command { description, .. } => description.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arguments(&self) -> &Vec<Argument> {
|
||||
match self {
|
||||
Self::AgentMode { arguments, .. } => arguments,
|
||||
Self::Command { arguments, .. } => arguments,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> Option<&Vec<String>> {
|
||||
match self {
|
||||
Self::Command { tags, .. } => Some(tags),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_url(&self) -> Option<&String> {
|
||||
match self {
|
||||
Self::Command { source_url, .. } => source_url.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn author_name(&self) -> Option<&String> {
|
||||
match self {
|
||||
Self::Command { author, .. } => author.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shells(&self) -> Option<&Vec<warp_workflows::Shell>> {
|
||||
match self {
|
||||
Self::Command { shells, .. } => Some(shells),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_command_workflow(&self) -> bool {
|
||||
matches!(self, Self::Command { .. })
|
||||
}
|
||||
|
||||
pub fn is_agent_mode_workflow(&self) -> bool {
|
||||
matches!(self, Self::AgentMode { .. })
|
||||
}
|
||||
|
||||
/// Returns `true` if the workflow name starts with the given character (case-insensitive).
|
||||
///
|
||||
/// Used by prompt search datasources to prefix-match on single-character queries, where
|
||||
/// fuzzy matching would be unreliable.
|
||||
pub fn name_starts_with_char_ignore_case(&self, c: char) -> bool {
|
||||
self.name()
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|first| first.eq_ignore_ascii_case(&c))
|
||||
}
|
||||
|
||||
/// Return a list of every enum ID referenced by this workflow.
|
||||
pub fn get_enum_ids(&self) -> Vec<SyncId> {
|
||||
self.arguments()
|
||||
.iter()
|
||||
.filter_map(|arg| match arg.arg_type {
|
||||
ArgumentType::Enum { enum_id } => Some(enum_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return a list of every enum ID that has been synced to the server, used for telemetry.
|
||||
pub fn get_server_enum_ids(&self) -> Vec<GenericStringObjectId> {
|
||||
self.arguments()
|
||||
.iter()
|
||||
.filter_map(|arg| match arg.arg_type {
|
||||
ArgumentType::Enum { enum_id } => enum_id.into_server(),
|
||||
_ => None,
|
||||
})
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn default_env_vars(&self) -> Option<SyncId> {
|
||||
match self {
|
||||
Workflow::Command {
|
||||
environment_variables,
|
||||
..
|
||||
} => *environment_variables,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given two IDs, replace any instance of the old ID referenced by this workflow with the new ID.
|
||||
/// Returns `true` if any instances of the old_id were present.
|
||||
pub fn replace_object_id(&mut self, old_id: SyncId, new_id: SyncId) -> bool {
|
||||
let mut changed = false;
|
||||
let arguments = match self {
|
||||
Self::Command {
|
||||
ref mut arguments, ..
|
||||
} => arguments,
|
||||
Self::AgentMode {
|
||||
ref mut arguments, ..
|
||||
} => arguments,
|
||||
};
|
||||
for arg in arguments.iter_mut() {
|
||||
match &mut arg.arg_type {
|
||||
ArgumentType::Enum { enum_id } if *enum_id == old_id => {
|
||||
*enum_id = new_id;
|
||||
changed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Self::Command {
|
||||
ref mut environment_variables,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
if *environment_variables == Some(old_id) {
|
||||
*environment_variables = Some(new_id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Workflow::Command {
|
||||
name: name.into(),
|
||||
command: command.into(),
|
||||
tags: Vec::new(),
|
||||
arguments: Vec::new(),
|
||||
description: None,
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: Vec::new(),
|
||||
environment_variables: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_arguments(mut self, new_arguments: Vec<Argument>) -> Self {
|
||||
match self {
|
||||
Workflow::AgentMode {
|
||||
ref mut arguments, ..
|
||||
} => {
|
||||
*arguments = new_arguments;
|
||||
}
|
||||
Workflow::Command {
|
||||
ref mut arguments, ..
|
||||
} => {
|
||||
*arguments = new_arguments;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, new_description: String) -> Self {
|
||||
match self {
|
||||
Workflow::AgentMode {
|
||||
ref mut description,
|
||||
..
|
||||
} => {
|
||||
*description = Some(new_description);
|
||||
}
|
||||
Workflow::Command {
|
||||
ref mut description,
|
||||
..
|
||||
} => {
|
||||
*description = Some(new_description);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_name(&mut self, new_name: &str) {
|
||||
match self {
|
||||
Workflow::AgentMode { ref mut name, .. } => new_name.clone_into(name),
|
||||
Workflow::Command { ref mut name, .. } => new_name.clone_into(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a warp-internal Workflow model from a public-facing workflow
|
||||
/// https://github.com/warpdotdev/workflows/blob/main/workflow-types/src/lib.rs
|
||||
impl From<warp_workflows::Workflow> for Workflow {
|
||||
fn from(workflow: warp_workflows::Workflow) -> Self {
|
||||
Workflow::Command {
|
||||
name: workflow.name,
|
||||
command: workflow.command,
|
||||
description: workflow.description,
|
||||
arguments: workflow.arguments.into_iter().map(Argument::from).collect(),
|
||||
tags: workflow.tags,
|
||||
source_url: workflow.source_url,
|
||||
author: workflow.author,
|
||||
author_url: workflow.author_url,
|
||||
shells: workflow.shells,
|
||||
environment_variables: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Argument model to be used in `warp-internal`
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, Default)]
|
||||
pub struct Argument {
|
||||
pub name: String,
|
||||
/// The type of the argument to the workflow
|
||||
#[serde(flatten, deserialize_with = "deserialize_arg_type")]
|
||||
pub arg_type: ArgumentType,
|
||||
pub description: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
impl From<warp_workflows::Argument> for Argument {
|
||||
fn from(arg: warp_workflows::Argument) -> Self {
|
||||
Argument {
|
||||
name: arg.name,
|
||||
arg_type: ArgumentType::Text, // public workflows only have text arguments
|
||||
description: arg.description,
|
||||
default_value: arg.default_value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
pub fn new(name: impl Into<String>, arg_type: ArgumentType) -> Self {
|
||||
Argument {
|
||||
arg_type,
|
||||
name: name.into(),
|
||||
description: None,
|
||||
default_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_default(mut self, default: impl Into<String>) -> Self {
|
||||
self.default_value = Some(default.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &Option<String> {
|
||||
&self.description
|
||||
}
|
||||
|
||||
pub fn arg_type(&self) -> &ArgumentType {
|
||||
&self.arg_type
|
||||
}
|
||||
|
||||
pub fn default_value(&self) -> &Option<String> {
|
||||
&self.default_value
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the workflow argument
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)]
|
||||
#[serde(tag = "arg_type")]
|
||||
#[derive(Default)]
|
||||
pub enum ArgumentType {
|
||||
#[default]
|
||||
Text,
|
||||
Enum {
|
||||
/// The ID of the associated WorkflowEnum Generic String Object
|
||||
enum_id: SyncId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Custom deserialization for argument types, used to both `flatten` the argument type
|
||||
/// and allow for the specification of `default` behavior.
|
||||
///
|
||||
/// Necessary because serde currently does not support the use of `flatten` with a `default`,
|
||||
/// related GitHub issue here: https://github.com/serde-rs/serde/issues/1626
|
||||
fn deserialize_arg_type<'de, D>(deserializer: D) -> Result<ArgumentType, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value: Value = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
let arg_type = match value.get("arg_type").and_then(|value| value.as_str()) {
|
||||
Some("Text") => ArgumentType::Text,
|
||||
Some("Enum") => {
|
||||
let enum_id = value
|
||||
.get("enum_id")
|
||||
.ok_or(serde::de::Error::missing_field("enum_id"))?;
|
||||
let deserialized_id = SyncId::deserialize(enum_id)
|
||||
.map_err(|_| serde::de::Error::custom("Unable to parse enum_id"))?;
|
||||
ArgumentType::Enum {
|
||||
enum_id: deserialized_id,
|
||||
}
|
||||
}
|
||||
_ => ArgumentType::default(),
|
||||
};
|
||||
|
||||
Ok(arg_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workflow_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,94 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Revision, ServerCloudObject,
|
||||
},
|
||||
server::sync_queue::QueueItem,
|
||||
};
|
||||
|
||||
/// Data model for a workflow enum, one type of argument that can be inserted into a workflow
|
||||
/// A workflow enum can either be static or dynamic, as determined by the type of `EnumVariants` it uses
|
||||
///
|
||||
/// A `Static` enum contains a finite set of user-specified string values
|
||||
/// A `Dynamic` enum contains a single shell command, which is executed to determine suggested variants for that argument
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, PartialOrd)]
|
||||
pub struct WorkflowEnum {
|
||||
/// Enum name
|
||||
pub name: String,
|
||||
/// Whether or not the variable should be visible to other workflows
|
||||
pub is_shared: bool,
|
||||
/// The variants for this enum
|
||||
pub variants: EnumVariants,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, PartialOrd)]
|
||||
pub enum EnumVariants {
|
||||
Static(Vec<String>), // contains the explicit variants for a static enum
|
||||
Dynamic(String), // contains the value of the shell command associated with the dynamic enum
|
||||
}
|
||||
|
||||
pub type CloudWorkflowEnum = GenericCloudObject<GenericStringObjectId, CloudWorkflowEnumModel>;
|
||||
pub type CloudWorkflowEnumModel = GenericStringModel<WorkflowEnum, JsonSerializer>;
|
||||
|
||||
impl StringModel for WorkflowEnum {
|
||||
type CloudObjectType = CloudWorkflowEnum;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
"WorkflowEnum"
|
||||
}
|
||||
|
||||
fn should_enforce_revisions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(Self::json_object_type())
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.model_type_name().to_owned()
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &Self::CloudObjectType,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateWorkflowEnum {
|
||||
model: object.model().clone().into(),
|
||||
id: object.id,
|
||||
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::WorkflowEnum(server_workflow_enum) = server_cloud_object {
|
||||
return Some(server_workflow_enum.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for WorkflowEnum {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::WorkflowEnum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::{
|
||||
cloud_object::model::generic_string_model::GenericStringObjectId,
|
||||
server::ids::{ClientId, HashableId, ServerId, SyncId},
|
||||
workflows::workflow::{Argument, ArgumentType, Workflow},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_workflow_serialization_with_enum_params() {
|
||||
let workflow = Workflow::Command {
|
||||
name: "name".to_string(),
|
||||
command: "command".to_string(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "text".to_string(),
|
||||
arg_type: ArgumentType::Text,
|
||||
description: None,
|
||||
default_value: Some("default".to_string()),
|
||||
},
|
||||
Argument {
|
||||
name: "server id enum".to_string(),
|
||||
arg_type: ArgumentType::Enum {
|
||||
enum_id: SyncId::from(GenericStringObjectId::from(ServerId::from(123))),
|
||||
},
|
||||
description: Some("description".to_string()),
|
||||
default_value: None,
|
||||
},
|
||||
Argument {
|
||||
name: "client id enum".to_string(),
|
||||
arg_type: ArgumentType::Enum {
|
||||
enum_id: SyncId::ClientId(
|
||||
ClientId::from_hash("Client-06d26381-ac61-4a4a-8a23-a3431f1d340c")
|
||||
.expect("should be able to construct ClientId from hash"),
|
||||
),
|
||||
},
|
||||
description: Some("description".to_string()),
|
||||
default_value: None,
|
||||
},
|
||||
],
|
||||
description: None,
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![],
|
||||
tags: vec![],
|
||||
environment_variables: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&workflow).expect("failed to serialize");
|
||||
let correct_serialized = r#"{"name":"name","command":"command","tags":[],"description":null,"arguments":[{"name":"text","arg_type":"Text","description":null,"default_value":"default"},{"name":"server id enum","arg_type":"Enum","enum_id":"test_uid00000000000123","description":"description","default_value":null},{"name":"client id enum","arg_type":"Enum","enum_id":"Client-06d26381-ac61-4a4a-8a23-a3431f1d340c","description":"description","default_value":null}],"source_url":null,"author":null,"author_url":null,"shells":[],"environment_variables":null}"#;
|
||||
|
||||
assert_eq!(
|
||||
serialized, correct_serialized,
|
||||
"Workflow should serialize correctly"
|
||||
);
|
||||
|
||||
let deserialized: Workflow =
|
||||
serde_json::from_str(serialized.as_str()).expect("failed to deserialized");
|
||||
|
||||
assert_eq!(deserialized, workflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_mode_workflow_serialization() {
|
||||
let workflow = Workflow::AgentMode {
|
||||
name: "name".to_string(),
|
||||
query: "query {{text}}".to_string(),
|
||||
arguments: vec![Argument {
|
||||
name: "text".to_string(),
|
||||
arg_type: ArgumentType::Text,
|
||||
description: None,
|
||||
default_value: Some("default".to_string()),
|
||||
}],
|
||||
description: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&workflow).expect("failed to serialize");
|
||||
let correct_serialized = r#"{"type":"agent_mode","name":"name","query":"query {{text}}","arguments":[{"name":"text","arg_type":"Text","description":null,"default_value":"default"}]}"#;
|
||||
|
||||
assert_eq!(
|
||||
serialized, correct_serialized,
|
||||
"Workflow should serialize correctly"
|
||||
);
|
||||
|
||||
let deserialized: Workflow =
|
||||
serde_json::from_str(serialized.as_str()).expect("failed to deserialized");
|
||||
|
||||
assert_eq!(deserialized, workflow);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::editor::NavigationKey;
|
||||
use warpui::{
|
||||
elements::ChildView,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
drive::workflows::enum_creation_dialog::WorkflowEnumData,
|
||||
editor::{
|
||||
EditOrigin, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
SingleLineEditorOptions, TextOptions,
|
||||
},
|
||||
server::ids::SyncId,
|
||||
view_components::{Dropdown, DropdownItem},
|
||||
workflows::{workflow::ArgumentType, workflow_enum::EnumVariants},
|
||||
};
|
||||
|
||||
/// Width of the argument editor in alias mode.
|
||||
pub const ALIAS_ARGUMENT_EDITOR_WIDTH: f32 = 300.;
|
||||
const EDITOR_FONT_SIZE: f32 = 14.;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AliasArgumentSelectorAction {
|
||||
AliasValueSet(String),
|
||||
}
|
||||
|
||||
/// Whether this argument is a text argument, a static enum argument, or a dynamic enum argument.
|
||||
/// This is separate from ArgumentType because that requires a query to determine if the enum is
|
||||
/// static or dynamic.
|
||||
enum AliasArgumentType {
|
||||
Text,
|
||||
StaticEnum,
|
||||
DynamicEnum,
|
||||
}
|
||||
|
||||
/// A widget to select the value for an alias argument to a workflow.
|
||||
///
|
||||
/// If the argument is a string type, the user can enter the value as a string.
|
||||
/// If the argument is a static enum, the user can select a value from the list of options.
|
||||
/// If the argument is a dynamic enum, the user can enter the value as a string. The dynamic enum is environment
|
||||
/// specific, so it doesn't make sense to choose from a set of options.
|
||||
pub struct AliasArgumentSelector {
|
||||
string_argument_editor: ViewHandle<EditorView>,
|
||||
dropdown: ViewHandle<Dropdown<AliasArgumentSelectorAction>>,
|
||||
argument_type: AliasArgumentType,
|
||||
}
|
||||
|
||||
impl AliasArgumentSelector {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let text = TextOptions {
|
||||
font_size_override: Some(EDITOR_FONT_SIZE),
|
||||
font_family_override: Some(appearance.ui_font_family()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text,
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&editor, |_me, editor, event, ctx| match event {
|
||||
EditorEvent::Edited(origin) => {
|
||||
if *origin != EditOrigin::SystemEdit {
|
||||
ctx.emit(AliasArgumentSelectorEvent::ValueSet(
|
||||
editor.as_ref(ctx).buffer_text(ctx).clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
EditorEvent::Navigate(nav_key) => {
|
||||
ctx.emit(AliasArgumentSelectorEvent::Navigate(*nav_key));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut d = Dropdown::new(ctx);
|
||||
d.set_menu_width(ALIAS_ARGUMENT_EDITOR_WIDTH, ctx);
|
||||
d.set_top_bar_max_width(ALIAS_ARGUMENT_EDITOR_WIDTH);
|
||||
d
|
||||
});
|
||||
|
||||
AliasArgumentSelector {
|
||||
string_argument_editor: editor,
|
||||
dropdown,
|
||||
argument_type: AliasArgumentType::Text,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the type of argument, and optionally the value of the argument.
|
||||
/// enum_data: This should be the map of unsaved enum data from the workflow editor.
|
||||
pub fn set_argument(
|
||||
&mut self,
|
||||
argument_type: &ArgumentType,
|
||||
value: Option<&String>,
|
||||
enum_data: &HashMap<SyncId, WorkflowEnumData>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
ctx.notify();
|
||||
match argument_type {
|
||||
ArgumentType::Text => {
|
||||
self.set_string_argument(value, ctx);
|
||||
self.argument_type = AliasArgumentType::Text;
|
||||
}
|
||||
ArgumentType::Enum { enum_id } => {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
|
||||
// Get the variants from the unsaved enum data, if it exists.
|
||||
// Otherwise, pull it from the cloud model.
|
||||
let enum_variants = enum_data
|
||||
.get(enum_id)
|
||||
.and_then(|workflow_enum| workflow_enum.new_data.clone())
|
||||
.or_else(|| {
|
||||
cloud_model.get_workflow_enum(enum_id).map(|workflow_enum| {
|
||||
workflow_enum.model().string_model.variants.clone()
|
||||
})
|
||||
});
|
||||
|
||||
match enum_variants {
|
||||
Some(EnumVariants::Static(variants)) => {
|
||||
self.argument_type = AliasArgumentType::StaticEnum;
|
||||
|
||||
// Add the variants to the dropdown.
|
||||
let items: Vec<_> = variants
|
||||
.iter()
|
||||
.map(|variant| {
|
||||
DropdownItem::new(
|
||||
variant.clone(),
|
||||
AliasArgumentSelectorAction::AliasValueSet(variant.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(items, ctx);
|
||||
if let Some(value) = value {
|
||||
dropdown.set_selected_by_name(value, ctx);
|
||||
} else {
|
||||
dropdown.set_selected_to_none(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(EnumVariants::Dynamic(_)) => {
|
||||
self.argument_type = AliasArgumentType::DynamicEnum;
|
||||
self.set_string_argument(value, ctx);
|
||||
}
|
||||
None => {
|
||||
log::info!("No enum variants found for enum_id: {enum_id:?}");
|
||||
self.argument_type = AliasArgumentType::Text;
|
||||
self.set_string_argument(value, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the value of the string argument editor.
|
||||
fn set_string_argument(&mut self, value: Option<&String>, ctx: &mut ViewContext<Self>) {
|
||||
self.string_argument_editor.update(ctx, |editor, ctx| {
|
||||
if let Some(value) = value {
|
||||
editor.system_reset_buffer_text(value, ctx);
|
||||
} else {
|
||||
editor.system_clear_buffer(true, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AliasArgumentSelector {
|
||||
fn ui_name() -> &'static str {
|
||||
"AliasArgumentSelector"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
match self.argument_type {
|
||||
AliasArgumentType::Text | AliasArgumentType::DynamicEnum => {
|
||||
ctx.focus(&self.string_argument_editor);
|
||||
}
|
||||
AliasArgumentType::StaticEnum => {
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.toggle_expanded(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
match self.argument_type {
|
||||
AliasArgumentType::Text | AliasArgumentType::DynamicEnum => {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(self.string_argument_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 5.,
|
||||
bottom: 5.,
|
||||
left: 12.,
|
||||
right: 4.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_width(ALIAS_ARGUMENT_EDITOR_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
AliasArgumentType::StaticEnum => ChildView::new(&self.dropdown).finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AliasArgumentSelector {
|
||||
type Action = AliasArgumentSelectorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AliasArgumentSelectorAction::AliasValueSet(value) => {
|
||||
ctx.emit(AliasArgumentSelectorEvent::ValueSet(value.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AliasArgumentSelectorEvent {
|
||||
ValueSet(String),
|
||||
Navigate(NavigationKey),
|
||||
}
|
||||
|
||||
impl Entity for AliasArgumentSelector {
|
||||
type Event = AliasArgumentSelectorEvent;
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
use std::{cmp::Ordering, collections::HashMap};
|
||||
|
||||
use anyhow::Error;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::{
|
||||
features::FeatureFlag,
|
||||
ui::{
|
||||
appearance::Appearance,
|
||||
theme::{color::internal_colors::neutral_4, Fill},
|
||||
},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseState, MouseStateHandle, ParentElement, Radius,
|
||||
},
|
||||
ui_components::{
|
||||
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity as _, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
editor::{
|
||||
EditOrigin, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
SingleLineEditorOptions, TextOptions, ValidInputType,
|
||||
},
|
||||
send_telemetry_from_ctx,
|
||||
server::{ids::SyncId, telemetry::TelemetrySpace},
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
workflows::aliases::{WorkflowAlias, WorkflowAliases},
|
||||
TelemetryEvent,
|
||||
};
|
||||
|
||||
/// Width of the alias name editor.
|
||||
const ALIAS_EDITOR_WIDTH: f32 = 100.;
|
||||
|
||||
/// Minimum size of an alias pill.
|
||||
const ALIAS_PILL_MIN_WIDTH: f32 = 48.;
|
||||
|
||||
/// Padding within all alias pills.
|
||||
const ALIAS_PILL_VERTICAL_PADDING: f32 = 4.;
|
||||
const ALIAS_PILL_HORIZONTAL_PADDING: f32 = 8.;
|
||||
const ALIAS_PILL_VERTICAL_MARGIN: f32 = 2.;
|
||||
|
||||
/// Dimensions for button icons.
|
||||
const ICON_BUTTON_SIZE: f32 = 16.;
|
||||
|
||||
pub struct AliasBar {
|
||||
selected_alias: Option<usize>,
|
||||
renaming_alias: Option<usize>,
|
||||
aliases: Vec<AliasState>,
|
||||
name_editor: ViewHandle<EditorView>,
|
||||
template_button_mouse_state: MouseStateHandle,
|
||||
add_button_mouse_state: MouseStateHandle,
|
||||
|
||||
/// True if any aliases have been modified. Since aliases are saved in bulk, we don't need to
|
||||
/// track this per-alias.
|
||||
is_dirty: bool,
|
||||
|
||||
workflow_id: SyncId,
|
||||
deleted_aliases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AliasBarAction {
|
||||
Add,
|
||||
Select(usize),
|
||||
Deselect,
|
||||
Remove(usize),
|
||||
Rename(usize),
|
||||
StopRenaming,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AliasBarEvent {
|
||||
SelectedAliasChanged,
|
||||
AliasesUpdated,
|
||||
}
|
||||
|
||||
impl Entity for AliasBar {
|
||||
type Event = AliasBarEvent;
|
||||
}
|
||||
|
||||
impl AliasBar {
|
||||
pub fn new(workflow_id: SyncId, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let name_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut view = EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text: TextOptions {
|
||||
font_size_override: Some(14.),
|
||||
font_family_override: Some(Appearance::as_ref(ctx).ui_font_family()),
|
||||
..Default::default()
|
||||
},
|
||||
valid_input_type: ValidInputType::NoSpaces,
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
view.set_placeholder_text("alias name", ctx);
|
||||
|
||||
view
|
||||
});
|
||||
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| {
|
||||
me.handle_name_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
let aliases = WorkflowAliases::as_ref(ctx)
|
||||
.get_aliases_for_workflow(workflow_id)
|
||||
.into_iter()
|
||||
.map(AliasState::from)
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
selected_alias: None,
|
||||
renaming_alias: None,
|
||||
aliases,
|
||||
name_editor,
|
||||
template_button_mouse_state: Default::default(),
|
||||
add_button_mouse_state: Default::default(),
|
||||
is_dirty: false,
|
||||
workflow_id,
|
||||
deleted_aliases: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current workflow's space for telemetry events.
|
||||
fn workflow_space(&self, app: &AppContext) -> Option<TelemetrySpace> {
|
||||
let workflow = CloudModel::as_ref(app).get_workflow(&self.workflow_id)?;
|
||||
Some(workflow.space(app).into())
|
||||
}
|
||||
|
||||
fn mark_dirty(&mut self, is_dirty: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.is_dirty = is_dirty;
|
||||
ctx.emit(AliasBarEvent::AliasesUpdated);
|
||||
}
|
||||
|
||||
pub fn set_workflow_id(&mut self, workflow_id: SyncId, ctx: &mut ViewContext<Self>) {
|
||||
self.aliases = WorkflowAliases::as_ref(ctx)
|
||||
.get_aliases_for_workflow(workflow_id)
|
||||
.into_iter()
|
||||
.map(AliasState::from)
|
||||
.collect();
|
||||
self.selected_alias = None;
|
||||
self.renaming_alias = None;
|
||||
self.workflow_id = workflow_id;
|
||||
self.mark_dirty(false, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn has_selected_alias(&self) -> bool {
|
||||
self.selected_alias.is_some()
|
||||
}
|
||||
|
||||
/// Prepopulated argument values for the current alias.
|
||||
pub fn current_argument_values(&self) -> Option<&HashMap<String, String>> {
|
||||
self.selected_alias
|
||||
.and_then(|index| self.aliases.get(index))
|
||||
.map(|alias| &alias.argument_values)
|
||||
}
|
||||
pub fn get_all_argument_values(&self) -> Vec<String> {
|
||||
self.aliases
|
||||
.iter()
|
||||
.flat_map(|alias| alias.argument_values.values())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Update an argument value for the current alias.
|
||||
pub fn set_current_argument_value(
|
||||
&mut self,
|
||||
name: &str,
|
||||
value: String,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(alias) = self
|
||||
.selected_alias
|
||||
.and_then(|index| self.aliases.get_mut(index))
|
||||
{
|
||||
if value.is_empty() {
|
||||
alias.argument_values.remove(name);
|
||||
} else {
|
||||
alias.argument_values.insert(name.to_string(), value);
|
||||
}
|
||||
|
||||
self.mark_dirty(true, ctx);
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WorkflowAliasArgumentEdited {
|
||||
workflow_id: self.workflow_id.into_server().map(Into::into),
|
||||
workflow_space: self.workflow_space(ctx)
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the associated environment variables for the current alias.
|
||||
pub fn set_current_env_vars(&mut self, sync_id: Option<SyncId>, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(alias) = self
|
||||
.selected_alias
|
||||
.and_then(|index| self.aliases.get_mut(index))
|
||||
{
|
||||
if alias.env_vars != sync_id {
|
||||
alias.env_vars = sync_id;
|
||||
self.mark_dirty(true, ctx);
|
||||
|
||||
let env_vars_space = sync_id
|
||||
.and_then(|id| CloudModel::as_ref(ctx).get_env_var_collection(&id))
|
||||
.map(|env_vars| env_vars.space(ctx))
|
||||
.map(Into::into);
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WorkflowAliasEnvVarsAttached {
|
||||
workflow_id: self.workflow_id.into_server().map(Into::into),
|
||||
workflow_space: self.workflow_space(ctx),
|
||||
env_vars_id: sync_id.and_then(|id| id.into_server()).map(Into::into),
|
||||
env_vars_space,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment variables associated with the current alias.
|
||||
pub fn current_env_vars(&self) -> Option<SyncId> {
|
||||
self.selected_alias
|
||||
.and_then(|index| self.aliases.get(index))
|
||||
.and_then(|alias| alias.env_vars)
|
||||
}
|
||||
|
||||
/// Whether or not there are unsaved changes to any aliases.
|
||||
pub fn has_unsaved_changes(&self) -> bool {
|
||||
self.is_dirty
|
||||
}
|
||||
|
||||
pub fn save(&mut self, ctx: &mut ViewContext<Self>) -> Result<(), Error> {
|
||||
self.mark_dirty(false, ctx);
|
||||
WorkflowAliases::handle(ctx).update(ctx, |aliases, ctx| {
|
||||
// Reset the deleted aliases.
|
||||
let deleted_aliases = std::mem::take(&mut self.deleted_aliases);
|
||||
aliases.remove_aliases(deleted_aliases, ctx)?;
|
||||
|
||||
let aliases_to_add = self
|
||||
.aliases
|
||||
.iter()
|
||||
.map(|alias| WorkflowAlias {
|
||||
alias: alias.alias_name.clone(),
|
||||
workflow_id: self.workflow_id,
|
||||
arguments: Some(alias.argument_values.clone()),
|
||||
env_vars: alias.env_vars,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
aliases.set_aliases(aliases_to_add, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Internal helper that sets the selected alias and notifies observers.
|
||||
fn set_selected_alias(&mut self, index: Option<usize>, ctx: &mut ViewContext<Self>) {
|
||||
self.selected_alias = index;
|
||||
ctx.notify();
|
||||
ctx.emit(AliasBarEvent::SelectedAliasChanged);
|
||||
}
|
||||
|
||||
fn select_alias(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::WorkflowAliases.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
if index >= self.aliases.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_selected_alias(Some(index), ctx);
|
||||
}
|
||||
|
||||
fn deselect_alias(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::WorkflowAliases.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_selected_alias(None, ctx);
|
||||
}
|
||||
|
||||
fn add_alias(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::WorkflowAliases.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.renaming_alias = Some(self.aliases.len());
|
||||
self.set_selected_alias(self.renaming_alias, ctx);
|
||||
self.name_editor
|
||||
.update(ctx, |editor, ctx| editor.system_clear_buffer(true, ctx));
|
||||
ctx.focus(&self.name_editor);
|
||||
self.aliases.push(AliasState::new(String::new()));
|
||||
self.is_dirty = true;
|
||||
ctx.emit(AliasBarEvent::AliasesUpdated);
|
||||
ctx.notify();
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WorkflowAliasAdded {
|
||||
workflow_id: self.workflow_id.into_server().map(Into::into),
|
||||
workflow_space: self.workflow_space(ctx),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn remove_alias(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::WorkflowAliases.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let removed = self.aliases.remove(index);
|
||||
self.deleted_aliases.push(removed.alias_name.clone());
|
||||
if let Some(selected_index) = &mut self.selected_alias {
|
||||
match (*selected_index).cmp(&index) {
|
||||
Ordering::Less => (),
|
||||
Ordering::Equal => {
|
||||
self.selected_alias = None;
|
||||
}
|
||||
Ordering::Greater => {
|
||||
*selected_index -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.is_dirty = true;
|
||||
ctx.emit(AliasBarEvent::AliasesUpdated);
|
||||
ctx.notify();
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WorkflowAliasRemoved {
|
||||
workflow_id: self.workflow_id.into_server().map(Into::into),
|
||||
workflow_space: self.workflow_space(ctx),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn rename_alias(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::WorkflowAliases.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(alias) = self.aliases.get(index) {
|
||||
self.deleted_aliases.push(alias.alias_name.clone());
|
||||
self.name_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&alias.alias_name, ctx);
|
||||
});
|
||||
self.renaming_alias = Some(index);
|
||||
ctx.focus(&self.name_editor);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_renaming_alias(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.renaming_alias = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_name_editor(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Dismiss::new(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(self.name_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: ALIAS_PILL_VERTICAL_PADDING,
|
||||
bottom: ALIAS_PILL_VERTICAL_PADDING,
|
||||
left: ALIAS_PILL_HORIZONTAL_PADDING,
|
||||
right: ALIAS_PILL_HORIZONTAL_PADDING,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_width(ALIAS_EDITOR_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(AliasBarAction::StopRenaming))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn handle_name_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Enter
|
||||
| EditorEvent::ShiftEnter
|
||||
| EditorEvent::Escape
|
||||
| EditorEvent::Blurred => {
|
||||
self.stop_renaming_alias(ctx);
|
||||
}
|
||||
EditorEvent::Edited(EditOrigin::UserTyped | EditOrigin::UserInitiated) => {
|
||||
if let Some(alias) = self
|
||||
.renaming_alias
|
||||
.and_then(|index| self.aliases.get_mut(index))
|
||||
{
|
||||
alias.alias_name = self.name_editor.as_ref(ctx).buffer_text(ctx);
|
||||
self.is_dirty = true;
|
||||
ctx.emit(AliasBarEvent::AliasesUpdated);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AliasBar {
|
||||
fn ui_name() -> &'static str {
|
||||
"AliasBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if !self.aliases.is_empty() {
|
||||
// Only show the button to switch back to the workflow template if there are also
|
||||
// aliases.
|
||||
let template_button = Container::new(
|
||||
build_alias_pill(
|
||||
self.template_button_mouse_state.clone(),
|
||||
self.selected_alias.is_none(),
|
||||
appearance,
|
||||
|_state, background| {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span("Default")
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance.theme().main_text_color(background).into_solid(),
|
||||
),
|
||||
font_size: Some(14.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
},
|
||||
)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(AliasBarAction::Deselect))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
row.add_child(template_button);
|
||||
}
|
||||
|
||||
// TODO: this should be horizontally scrollable or use a Wrap
|
||||
|
||||
row.add_children(self.aliases.iter().enumerate().map(|(idx, alias)| {
|
||||
if Some(idx) == self.renaming_alias {
|
||||
self.render_name_editor(appearance)
|
||||
} else {
|
||||
alias.render(idx, self, appearance)
|
||||
}
|
||||
}));
|
||||
|
||||
let add_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, self.add_button_mouse_state.clone())
|
||||
.with_text_and_icon_label(
|
||||
TextAndIcon::new(
|
||||
TextAndIconAlignment::IconFirst,
|
||||
"Add alias",
|
||||
Icon::Plus.to_warpui_icon(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background()),
|
||||
),
|
||||
MainAxisSize::Min,
|
||||
MainAxisAlignment::Start,
|
||||
vec2f(ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
|
||||
)
|
||||
.with_inner_padding(4.),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: ALIAS_PILL_VERTICAL_PADDING,
|
||||
bottom: ALIAS_PILL_VERTICAL_PADDING,
|
||||
left: ALIAS_PILL_HORIZONTAL_PADDING,
|
||||
right: ALIAS_PILL_HORIZONTAL_PADDING,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(AliasBarAction::Add))
|
||||
.finish();
|
||||
row.add_child(add_button);
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_vertical_padding(8.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AliasBar {
|
||||
type Action = AliasBarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AliasBarAction::Add => self.add_alias(ctx),
|
||||
AliasBarAction::Select(index) => self.select_alias(*index, ctx),
|
||||
AliasBarAction::Deselect => self.deselect_alias(ctx),
|
||||
AliasBarAction::Remove(index) => self.remove_alias(*index, ctx),
|
||||
AliasBarAction::Rename(index) => self.rename_alias(*index, ctx),
|
||||
AliasBarAction::StopRenaming => {
|
||||
self.stop_renaming_alias(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AliasState {
|
||||
alias_name: String,
|
||||
argument_values: HashMap<String, String>,
|
||||
env_vars: Option<SyncId>,
|
||||
pill_mouse_state_handle: MouseStateHandle,
|
||||
delete_mouse_state_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl From<&WorkflowAlias> for AliasState {
|
||||
fn from(alias: &WorkflowAlias) -> Self {
|
||||
Self {
|
||||
alias_name: alias.alias.clone(),
|
||||
argument_values: alias.arguments.clone().unwrap_or_default(),
|
||||
pill_mouse_state_handle: Default::default(),
|
||||
delete_mouse_state_handle: Default::default(),
|
||||
env_vars: alias.env_vars,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AliasState {
|
||||
fn new(name: String) -> Self {
|
||||
Self {
|
||||
alias_name: name,
|
||||
argument_values: Default::default(),
|
||||
env_vars: None,
|
||||
pill_mouse_state_handle: Default::default(),
|
||||
delete_mouse_state_handle: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
index: usize,
|
||||
alias_bar: &AliasBar,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let name = self.alias_name.clone();
|
||||
let delete_handle = self.delete_mouse_state_handle.clone();
|
||||
let pill = build_alias_pill(
|
||||
self.pill_mouse_state_handle.clone(),
|
||||
alias_bar.selected_alias == Some(index),
|
||||
appearance,
|
||||
|_state, background| {
|
||||
let name = appearance
|
||||
.ui_builder()
|
||||
.span(name)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance.theme().main_text_color(background).into_solid(),
|
||||
),
|
||||
font_size: Some(14.),
|
||||
// Remove the default span padding, since the entire pill is padded.
|
||||
padding: Some(Coords::uniform(0.)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_margin_right(4.)
|
||||
.finish();
|
||||
|
||||
let padded_name = ConstrainedBox::new(name)
|
||||
.with_min_width(ALIAS_PILL_MIN_WIDTH - ICON_BUTTON_SIZE)
|
||||
.finish();
|
||||
|
||||
let delete_button = icon_button(appearance, Icon::X, false, delete_handle)
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords::uniform(0.)),
|
||||
width: Some(ICON_BUTTON_SIZE),
|
||||
height: Some(ICON_BUTTON_SIZE),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AliasBarAction::Remove(index))
|
||||
})
|
||||
.finish();
|
||||
Flex::row()
|
||||
.with_children([padded_name, delete_button])
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
},
|
||||
)
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(AliasBarAction::Select(index)))
|
||||
.on_double_click(move |ctx, _, _| ctx.dispatch_typed_action(AliasBarAction::Rename(index)))
|
||||
.finish();
|
||||
|
||||
Container::new(pill).with_margin_right(8.).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_alias_pill<F: FnOnce(&MouseState, Fill) -> Box<dyn Element>>(
|
||||
hover_state: MouseStateHandle,
|
||||
is_active: bool,
|
||||
appearance: &Appearance,
|
||||
build_content: F,
|
||||
) -> Hoverable {
|
||||
Hoverable::new(hover_state, move |state| {
|
||||
let background = if state.is_hovered() {
|
||||
Fill::Solid(neutral_4(appearance.theme()))
|
||||
} else if is_active {
|
||||
appearance.theme().surface_2()
|
||||
} else {
|
||||
appearance.theme().background()
|
||||
};
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(build_content(state, background))
|
||||
.with_vertical_padding(ALIAS_PILL_VERTICAL_PADDING)
|
||||
.with_horizontal_padding(ALIAS_PILL_HORIZONTAL_PADDING)
|
||||
.with_vertical_margin(ALIAS_PILL_VERTICAL_MARGIN)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(background)
|
||||
.finish(),
|
||||
)
|
||||
.with_min_width(ALIAS_PILL_MIN_WIDTH)
|
||||
.finish()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use itertools::Itertools;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::{features::FeatureFlag, ui::appearance::Appearance};
|
||||
use warp_editor::editor::NavigationKey;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildView, ConstrainedBox, Container, CrossAxisAlignment, Fill, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement, Shrinkable,
|
||||
},
|
||||
text_layout::TextStyle,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, SingletonEntity as _, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
drive::workflows::{
|
||||
workflow_arg_selector::{WorkflowArgSelector, WorkflowArgSelectorStyles},
|
||||
workflow_arg_type_helpers::{self, ArgumentTypeEditor},
|
||||
},
|
||||
editor::{
|
||||
EditOrigin, EditorView, Event as EditorEvent, InteractionState,
|
||||
PlainTextEditorViewAction as EditorAction,
|
||||
},
|
||||
pane_group::PaneEvent,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
workflows::workflow::Workflow,
|
||||
workspace::WorkspaceAction,
|
||||
};
|
||||
|
||||
use super::alias_argument_selector::{AliasArgumentSelector, AliasArgumentSelectorEvent};
|
||||
|
||||
use super::{
|
||||
WorkflowAction, WorkflowView, WorkflowViewEvent, BUTTON_BORDER_RADIUS, EDITOR_FONT_SIZE,
|
||||
HORIZONTAL_TEXT_INPUT_PADDING, SECTION_SPACING, VERTICAL_TEXT_INPUT_PADDING,
|
||||
WORKFLOW_PARAMETER_HIGHLIGHT_COLOR,
|
||||
};
|
||||
|
||||
const ARGUMENT_INPUT_HEIGHT: f32 = 30.;
|
||||
const ARGUMENT_LABEL_TEXT: &str = "Arguments";
|
||||
const ARGUMENT_LABEL_HEIGHT: f32 = 20.;
|
||||
const ARGUMENT_LABEL_MARGIN_BOTTOM: f32 = 5.;
|
||||
const ARGUMENT_DESCRIPTION_PLACEHOLDER_TEXT: &str = "Description";
|
||||
const ARGUMENT_ALIAS_DESCRIPTION_PLACEHOLDER_TEXT: &str = "Value (optional)";
|
||||
const ARGUMENT_DEFAULT_VALUE_PLACEHOLDER_TEXT: &str = "Default value (optional)";
|
||||
pub const DEFAULT_ARGUMENT_PREFIX: &str = "argument";
|
||||
|
||||
/// Width of the argument editor in alias mode.
|
||||
pub const ALIAS_ARGUMENT_EDITOR_WIDTH: f32 = 300.;
|
||||
|
||||
/// Which version of the argument-editing section to show.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArgumentEditorMode {
|
||||
/// Edit argument definitions, as part of editing the workflow itself.
|
||||
WorkflowDefinition,
|
||||
/// Edit argument values for an alias.
|
||||
Alias,
|
||||
/// Edit argument values to fill out and copy.
|
||||
Viewer,
|
||||
}
|
||||
|
||||
pub struct ArgumentEditorRow {
|
||||
pub(super) name: String,
|
||||
pub(super) description_editor: ViewHandle<EditorView>,
|
||||
pub(super) default_value_editor: ViewHandle<EditorView>,
|
||||
pub(super) argument_editor: ViewHandle<EditorView>,
|
||||
pub arg_type_editor: ViewHandle<WorkflowArgSelector>,
|
||||
// The editor for alias arguments. Can be a text editor or a dropdown.
|
||||
pub alias_argument_selector: ViewHandle<AliasArgumentSelector>,
|
||||
}
|
||||
|
||||
impl ArgumentTypeEditor for ArgumentEditorRow {
|
||||
fn arg_type_editor(&self) -> &ViewHandle<WorkflowArgSelector> {
|
||||
&self.arg_type_editor
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkflowView {
|
||||
pub(super) fn update_arguments_rows(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
match self
|
||||
.arguments_rows
|
||||
.len()
|
||||
.cmp(&self.arguments_state.arguments.len())
|
||||
{
|
||||
Ordering::Equal => {
|
||||
self.arguments_state
|
||||
.arguments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, argument)| {
|
||||
self.arguments_rows[index].name.clone_from(&argument.name);
|
||||
});
|
||||
}
|
||||
Ordering::Less | Ordering::Greater => {
|
||||
// first, get rid of all rows that have names not present in the updated args state
|
||||
let argument_names = self
|
||||
.arguments_state
|
||||
.arguments
|
||||
.iter()
|
||||
.map(|argument| argument.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
self.arguments_rows
|
||||
.retain(|row| argument_names.contains(&row.name));
|
||||
|
||||
// next, go over each item in the args state, and either add a row at this position,
|
||||
// or skip over it if we've found a match
|
||||
self.arguments_state
|
||||
.arguments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, argument)| {
|
||||
// if we reach the end of the state struct and we still
|
||||
// haven't inserted a row OR we find a mismatched name,
|
||||
// we know to add a row at this particular index
|
||||
if index == self.arguments_rows.len()
|
||||
|| !argument.name.eq(&self.arguments_rows[index].name)
|
||||
{
|
||||
let description_editor = Self::create_editor_handle(
|
||||
ctx,
|
||||
Some(EDITOR_FONT_SIZE),
|
||||
Some(ui_font_family),
|
||||
Some(ARGUMENT_DESCRIPTION_PLACEHOLDER_TEXT),
|
||||
false, /* vim_keybindings */
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
ctx.subscribe_to_view(
|
||||
&description_editor,
|
||||
|me, emitter, event, ctx| {
|
||||
me.handle_argument_editor_event(emitter, event, ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let default_value_editor = Self::create_editor_handle(
|
||||
ctx,
|
||||
Some(EDITOR_FONT_SIZE),
|
||||
Some(ui_font_family),
|
||||
Some(ARGUMENT_DEFAULT_VALUE_PLACEHOLDER_TEXT),
|
||||
false, /* vim_keybindings */
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
ctx.subscribe_to_view(
|
||||
&default_value_editor,
|
||||
|me, emitter, event, ctx| {
|
||||
me.handle_argument_editor_event(emitter, event, ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let argument_editor = Self::create_editor_handle(
|
||||
ctx,
|
||||
Some(EDITOR_FONT_SIZE),
|
||||
Some(ui_font_family),
|
||||
None, // none at first will be updated later
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
ctx.subscribe_to_view(&argument_editor, |me, emitter, event, ctx| {
|
||||
me.handle_argument_editor_event(emitter, event, ctx);
|
||||
});
|
||||
|
||||
let arg_type_editor = ctx.add_typed_action_view(|ctx| {
|
||||
WorkflowArgSelector::new(
|
||||
WorkflowArgSelectorStyles {
|
||||
editor_padding: Coords {
|
||||
left: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
right: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
top: VERTICAL_TEXT_INPUT_PADDING,
|
||||
bottom: VERTICAL_TEXT_INPUT_PADDING,
|
||||
},
|
||||
height: Some(ARGUMENT_INPUT_HEIGHT),
|
||||
width: None,
|
||||
dropdown_background: |appearance| {
|
||||
appearance.theme().surface_2()
|
||||
},
|
||||
border_color: |appearance| {
|
||||
appearance.theme().foreground().with_opacity(20)
|
||||
},
|
||||
border_radius: BUTTON_BORDER_RADIUS,
|
||||
},
|
||||
&self.all_workflow_enums,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&arg_type_editor, |me, emitter, event, ctx| {
|
||||
me.handle_type_selector_event(emitter, event, ctx);
|
||||
});
|
||||
|
||||
let alias_argument_selector =
|
||||
ctx.add_typed_action_view(AliasArgumentSelector::new);
|
||||
|
||||
ctx.subscribe_to_view(
|
||||
&alias_argument_selector,
|
||||
|me, emitter, event, ctx| {
|
||||
me.handle_alias_argument_selector_event(emitter, event, ctx);
|
||||
},
|
||||
);
|
||||
|
||||
self.arguments_rows.insert(
|
||||
index,
|
||||
ArgumentEditorRow {
|
||||
name: argument.name.clone(),
|
||||
description_editor,
|
||||
default_value_editor,
|
||||
argument_editor,
|
||||
arg_type_editor,
|
||||
alias_argument_selector,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy argument information (defaults, types, etc.) into the editors for each argument.
|
||||
///
|
||||
/// This assumes that [`Self::update_arguments_rows`] has been called first.
|
||||
pub(super) fn load_argument_data(
|
||||
&mut self,
|
||||
workflow_data: &Workflow,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
workflow_data
|
||||
.arguments()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, argument)| {
|
||||
if let Some(description) = &argument.description {
|
||||
self.arguments_rows[index]
|
||||
.description_editor
|
||||
.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text_with_base_buffer(description.as_str(), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
self.arguments_rows[index]
|
||||
.arg_type_editor
|
||||
.update(ctx, |selector, ctx| {
|
||||
selector.set_workflow_enums(&self.all_workflow_enums, ctx);
|
||||
workflow_arg_type_helpers::load_argument_into_selector(
|
||||
selector,
|
||||
argument,
|
||||
&mut self.all_workflow_enums,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
if let Some(default_value) = &argument.default_value {
|
||||
self.arguments_rows[index]
|
||||
.default_value_editor
|
||||
.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text_with_base_buffer(default_value.as_str(), ctx);
|
||||
});
|
||||
|
||||
// Argument editor is used in the view mode only. We're updating the
|
||||
// placeholder to reflect the default value of this argument
|
||||
// (when a user hasn't manually changed the argument in view mode).
|
||||
self.arguments_rows[index]
|
||||
.argument_editor
|
||||
.update(ctx, |editor, ctx| {
|
||||
editor.set_placeholder_text(default_value.as_str(), ctx);
|
||||
});
|
||||
} else {
|
||||
// Clear the argument editor if there is no default value.
|
||||
self.arguments_rows[index]
|
||||
.argument_editor
|
||||
.update(ctx, |editor, _| {
|
||||
editor.clear_all_placeholder_text();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn has_dirty_argument_editor(&self, app: &AppContext) -> bool {
|
||||
self.arguments_rows.iter().any(|row| {
|
||||
let selector_is_dirty = {
|
||||
let editor = row.arg_type_editor.as_ref(app);
|
||||
let editor_is_dirty = editor.is_dirty(app);
|
||||
let enum_is_dirty = editor
|
||||
.get_selected_enum()
|
||||
.and_then(|id| self.all_workflow_enums.get(&id))
|
||||
.map(|enum_data| enum_data.new_data.is_some())
|
||||
.unwrap_or(false);
|
||||
editor_is_dirty || enum_is_dirty
|
||||
};
|
||||
|
||||
selector_is_dirty
|
||||
|| row.default_value_editor.as_ref(app).is_dirty(app)
|
||||
|| row.description_editor.as_ref(app).is_dirty(app)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_alias_argument_selector_event(
|
||||
&mut self,
|
||||
handle: ViewHandle<AliasArgumentSelector>,
|
||||
event: &AliasArgumentSelectorEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
AliasArgumentSelectorEvent::ValueSet(value) => {
|
||||
self.arguments_rows.iter().for_each(|row| {
|
||||
if row.alias_argument_selector == handle
|
||||
&& self.alias_bar.as_ref(ctx).has_selected_alias()
|
||||
{
|
||||
self.alias_bar.update(ctx, |bar, ctx| {
|
||||
bar.set_current_argument_value(&row.name, value.clone(), ctx);
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
AliasArgumentSelectorEvent::Navigate(NavigationKey::Tab) => {
|
||||
self.arguments_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, row)| {
|
||||
if row.alias_argument_selector == handle {
|
||||
// If there's another row, tab to its alias argument selector.
|
||||
if let Some(next_row) = self
|
||||
.arguments_rows
|
||||
.get(index + 1)
|
||||
.or(self.arguments_rows.first())
|
||||
{
|
||||
ctx.focus(&next_row.alias_argument_selector)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
AliasArgumentSelectorEvent::Navigate(NavigationKey::ShiftTab) => {
|
||||
self.arguments_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, row)| {
|
||||
if row.alias_argument_selector == handle {
|
||||
// If there's a previous row, tab to its argument editor.
|
||||
let previous_row = match index {
|
||||
0 => self.arguments_rows.last(),
|
||||
_ => self.arguments_rows.get(index - 1),
|
||||
};
|
||||
if let Some(previous_row) = previous_row {
|
||||
ctx.focus(&previous_row.alias_argument_selector)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an event from one of the argument definition editors.
|
||||
pub(super) fn handle_argument_editor_event(
|
||||
&mut self,
|
||||
handle: ViewHandle<EditorView>,
|
||||
event: &EditorEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
// because the number of editor views we have depends on how many arguments
|
||||
// are in the command or query, tabbing/shift-tabbing is slightly complex.
|
||||
// `handle_argument_editor_event` is used for all of these views, so broadly
|
||||
// speaking there are two steps for each interaction:
|
||||
// 1. iterate through every row, looking for which editor fired this event
|
||||
// 2. decide what editor to focus next based on what editor's ahead/behind us
|
||||
EditorEvent::Navigate(NavigationKey::Tab) => {
|
||||
self.arguments_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, row)| {
|
||||
// tabbing in a description editor just means we focus
|
||||
// the corresponding default value editor
|
||||
if row.description_editor == handle {
|
||||
ctx.focus(&row.arg_type_editor);
|
||||
} else if row.default_value_editor == handle {
|
||||
// if we have another row ahead of us, tabbing in the default
|
||||
// value editor moves to the following row's description editor.
|
||||
// otherwise, it wraps around to the title.
|
||||
match self.arguments_rows.get(index + 1) {
|
||||
Some(next_row) => ctx.focus(&next_row.description_editor),
|
||||
None => ctx.focus(&self.name_editor),
|
||||
}
|
||||
} else if row.argument_editor == handle {
|
||||
// If there's another row, tab to its argument editor.
|
||||
if let Some(next_row) = self
|
||||
.arguments_rows
|
||||
.get(index + 1)
|
||||
.or(self.arguments_rows.first())
|
||||
{
|
||||
ctx.focus(&next_row.argument_editor)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
EditorEvent::Navigate(NavigationKey::ShiftTab) => {
|
||||
self.arguments_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, row)| {
|
||||
// if we have another row behind us, shift-tabbing in the description
|
||||
// editor moves to the previous row's default value editor.
|
||||
// otherwise, it focuses the content editor.
|
||||
if row.description_editor == handle {
|
||||
if index == 0 {
|
||||
ctx.focus(&self.content_editor);
|
||||
} else {
|
||||
ctx.focus(&self.arguments_rows[index - 1].arg_type_editor);
|
||||
}
|
||||
// shift-tabbing in a default value editor just means we
|
||||
// focus the corresponding default value editor
|
||||
} else if row.default_value_editor == handle {
|
||||
ctx.focus(&row.description_editor);
|
||||
} else if row.argument_editor == handle {
|
||||
// If there's a previous row, tab to its argument editor.
|
||||
let previous_row = match index {
|
||||
0 => self.arguments_rows.last(),
|
||||
_ => self.arguments_rows.get(index - 1),
|
||||
};
|
||||
if let Some(previous_row) = previous_row {
|
||||
ctx.focus(&previous_row.argument_editor)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
EditorEvent::Edited(origin) => {
|
||||
self.arguments_rows.iter().for_each(|row| {
|
||||
if row.argument_editor == handle {
|
||||
let mut updated_args = handle.as_ref(ctx).buffer_text(ctx);
|
||||
|
||||
if self.alias_bar.as_ref(ctx).has_selected_alias() {
|
||||
// When switching between aliases, we repopulate all the argument
|
||||
// editors - don't count that as an edit to the alias.
|
||||
if *origin != EditOrigin::SystemEdit {
|
||||
self.alias_bar.update(ctx, |bar, ctx| {
|
||||
bar.set_current_argument_value(&row.name, updated_args, ctx);
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// if we don't have anything filled use the default arguments
|
||||
if updated_args.is_empty() {
|
||||
updated_args =
|
||||
row.default_value_editor.as_ref(ctx).buffer_text(ctx);
|
||||
}
|
||||
|
||||
// if there are no default arguments use the argument name
|
||||
if updated_args.is_empty() {
|
||||
updated_args.clone_from(&row.name);
|
||||
}
|
||||
|
||||
self.command_display_data
|
||||
.set_argument_value(row.name.clone(), updated_args);
|
||||
|
||||
let text_style_ranges = self
|
||||
.command_display_data
|
||||
.argument_ranges()
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
(
|
||||
range,
|
||||
TextStyle::new().with_background_color(ColorU::from_u32(
|
||||
WORKFLOW_PARAMETER_HIGHLIGHT_COLOR,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
self.view_only_content_editor.update(ctx, |editor, ctx| {
|
||||
// first make it editable so we can make changes
|
||||
editor.set_interaction_state(InteractionState::Editable, ctx);
|
||||
editor.clear_buffer(ctx);
|
||||
|
||||
editor.insert_with_styles(
|
||||
self.command_display_data.to_command_string().as_str(),
|
||||
//&updated_ranges,
|
||||
&text_style_ranges,
|
||||
EditorAction::SystemInsert,
|
||||
ctx,
|
||||
);
|
||||
|
||||
// once done revert to being selectable only
|
||||
editor.set_interaction_state(InteractionState::Selectable, ctx);
|
||||
});
|
||||
|
||||
if !self.is_for_agent_mode {
|
||||
// debounce the syntax highlighting change to avoid flicker per
|
||||
// keystroke and only do the highlighting when the editing has ended.
|
||||
// The flicker would occur because we replace the buffer above with
|
||||
// insert_with_styles for capturing arguments changes and then perform
|
||||
// the syntax highlighting here.
|
||||
self.view_only_content_editor_highlight_model.update(
|
||||
ctx,
|
||||
|model, _ctx| {
|
||||
model.debounce_highlight();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
EditorEvent::Activate => {
|
||||
ctx.emit(WorkflowViewEvent::Pane(PaneEvent::FocusSelf));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the arguments area.
|
||||
pub(super) fn render_arguments_section(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let mode = if self.alias_bar.as_ref(app).has_selected_alias() {
|
||||
ArgumentEditorMode::Alias
|
||||
} else if self.is_editable() {
|
||||
ArgumentEditorMode::WorkflowDefinition
|
||||
} else {
|
||||
ArgumentEditorMode::Viewer
|
||||
};
|
||||
|
||||
// If there are no arguments to fill out in view mode, don't show the arguments section.
|
||||
if mode == ArgumentEditorMode::Viewer && self.arguments_rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut arguments_section = Flex::column();
|
||||
arguments_section.add_child(self.render_arguments_section_header(appearance));
|
||||
|
||||
match mode {
|
||||
ArgumentEditorMode::WorkflowDefinition | ArgumentEditorMode::Viewer => {
|
||||
arguments_section.add_child(self.render_arguments_editors(appearance))
|
||||
}
|
||||
ArgumentEditorMode::Alias => {
|
||||
arguments_section.add_child(self.render_alias_arguments(appearance, app));
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::WorkflowAliases.is_enabled()
|
||||
&& matches!(
|
||||
mode,
|
||||
ArgumentEditorMode::WorkflowDefinition | ArgumentEditorMode::Alias
|
||||
)
|
||||
&& !self.is_for_agent_mode
|
||||
{
|
||||
arguments_section.add_child(self.render_env_vars_selector(appearance, app));
|
||||
}
|
||||
|
||||
Some(arguments_section.finish())
|
||||
}
|
||||
|
||||
fn render_arguments_section_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let mut arguments_section_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
arguments_section_row.add_child(
|
||||
Shrinkable::new(
|
||||
2.,
|
||||
self.render_section_header(ARGUMENT_LABEL_TEXT, appearance),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let theme = appearance.theme();
|
||||
let sub_text_color = theme.sub_text_color(theme.background()).into_solid();
|
||||
|
||||
if self.is_editable() {
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
arguments_section_row.add_child(
|
||||
icon_button(
|
||||
appearance,
|
||||
Icon::Plus,
|
||||
false,
|
||||
self.ui_state_handles.add_variable_state.clone(),
|
||||
)
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Add a workflow argument".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(WorkflowAction::AddArgument))
|
||||
.finish(),
|
||||
)
|
||||
} else {
|
||||
arguments_section_row.add_child(Shrinkable::new(
|
||||
1.,
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span("Fill out the arguments in this workflow and copy it to run in your terminal session")
|
||||
.with_soft_wrap()
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(EDITOR_FONT_SIZE),
|
||||
font_color: Some(sub_text_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(40.)
|
||||
.finish()
|
||||
)
|
||||
.finish()
|
||||
);
|
||||
}
|
||||
|
||||
arguments_section_row.finish()
|
||||
}
|
||||
|
||||
fn render_arguments_editors(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let children: Vec<Box<dyn Element>> = self
|
||||
.arguments_state
|
||||
.arguments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, argument)| {
|
||||
let description_handle = &self.arguments_rows[index].description_editor;
|
||||
let argument_handle = &self.arguments_rows[index].argument_editor;
|
||||
|
||||
let text_span = appearance
|
||||
.ui_builder()
|
||||
.span(argument.name.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_size: Some(14.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let bg = Fill::from(ColorU::from(appearance.theme().subshell_background()));
|
||||
let mut description_container = appearance
|
||||
.ui_builder()
|
||||
.text_input(description_handle.clone());
|
||||
description_container = if self.is_editable() {
|
||||
description_container.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
left: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
right: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
top: VERTICAL_TEXT_INPUT_PADDING,
|
||||
bottom: VERTICAL_TEXT_INPUT_PADDING,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
description_container.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
left: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
right: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
top: VERTICAL_TEXT_INPUT_PADDING,
|
||||
bottom: VERTICAL_TEXT_INPUT_PADDING,
|
||||
}),
|
||||
background: Some(bg),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
let description_input = ConstrainedBox::new(description_container.build().finish())
|
||||
.with_height(ARGUMENT_INPUT_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let input = if self.is_editable() {
|
||||
let arg_type_selector_handle = &self.arguments_rows[index].arg_type_editor;
|
||||
Container::new(ChildView::new(arg_type_selector_handle).finish()).finish()
|
||||
} else {
|
||||
ConstrainedBox::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(argument_handle.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
left: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
right: HORIZONTAL_TEXT_INPUT_PADDING,
|
||||
top: VERTICAL_TEXT_INPUT_PADDING,
|
||||
bottom: VERTICAL_TEXT_INPUT_PADDING,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_height(ARGUMENT_INPUT_HEIGHT)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let argument_inputs = ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Shrinkable::new(1., Container::new(description_input).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(input).with_margin_left(8.).finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
// only show the argument name above if we are in edit mode
|
||||
column.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(text_span)
|
||||
.with_min_height(ARGUMENT_LABEL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(ARGUMENT_LABEL_MARGIN_BOTTOM)
|
||||
.finish(),
|
||||
);
|
||||
column.add_child(argument_inputs);
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_bottom(SECTION_SPACING)
|
||||
.finish()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_children(children)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Render editors for filling out arguments in an alias.
|
||||
fn render_alias_arguments(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut arguments = Flex::column();
|
||||
let theme = appearance.theme();
|
||||
|
||||
for (index, argument) in self.arguments_state.arguments.iter().enumerate() {
|
||||
let name = appearance
|
||||
.ui_builder()
|
||||
.span(argument.name.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_size: Some(14.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_margin_bottom(8.)
|
||||
.finish();
|
||||
arguments.add_child(name);
|
||||
|
||||
let mut current_description = self.arguments_rows[index]
|
||||
.description_editor
|
||||
.as_ref(app)
|
||||
.buffer_text(app);
|
||||
|
||||
let mut styles = UiComponentStyles {
|
||||
font_size: Some(13.),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// If the description is empty, show a placeholder text.
|
||||
if current_description.is_empty() {
|
||||
current_description.push_str(ARGUMENT_ALIAS_DESCRIPTION_PLACEHOLDER_TEXT);
|
||||
styles.font_color = Some(theme.sub_text_color(theme.background()).into_solid());
|
||||
}
|
||||
|
||||
let description = appearance
|
||||
.ui_builder()
|
||||
.span(current_description)
|
||||
.with_style(styles)
|
||||
.build()
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(5.)
|
||||
.finish();
|
||||
|
||||
let value =
|
||||
ChildView::new(&self.arguments_rows[index].alias_argument_selector).finish();
|
||||
|
||||
arguments.add_child(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_children([description, Shrinkable::new(1., value).finish()])
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
arguments.finish()
|
||||
}
|
||||
|
||||
fn render_env_vars_selector(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let action_element = if self.env_vars_selector.as_ref(app).has_env_vars(app) {
|
||||
Shrinkable::new(1., ChildView::new(&self.env_vars_selector).finish()).finish()
|
||||
} else {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.ui_state_handles
|
||||
.add_environment_variables_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
.with_centered_text_label("Add environment variables".to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::CreatePersonalEnvVarCollection);
|
||||
})
|
||||
.finish()
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_children([
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span("Environment variables")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(13.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
action_element,
|
||||
])
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use itertools::Itertools as _;
|
||||
use warpui::{
|
||||
elements::ChildView, Element as _, Entity, SingletonEntity as _, TypedActionView, View,
|
||||
ViewAsRef, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::persistence::{CloudModel, CloudModelEvent},
|
||||
CloudObject as _, GenericStringObjectFormat, JsonObjectType,
|
||||
},
|
||||
drive::CloudObjectTypeAndId,
|
||||
server::ids::SyncId,
|
||||
view_components::{DropdownItem, FilterableDropdown, FilterableDropdownOrientation},
|
||||
};
|
||||
|
||||
/// A reusable [`View`] for choosing environment variable collections.
|
||||
pub struct EnvVarSelector {
|
||||
dropdown: ViewHandle<FilterableDropdown<EnvVarSelectorAction>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum EnvVarSelectorAction {
|
||||
Select(Option<SyncId>),
|
||||
}
|
||||
|
||||
pub enum EnvVarSelectorEvent {
|
||||
SelectionChanged(Option<SyncId>),
|
||||
Refreshed,
|
||||
}
|
||||
|
||||
/// The default width for the env var selector dropdown.
|
||||
const DEFAULT_DROPDOWN_WIDTH: f32 = super::argument_editor::ALIAS_ARGUMENT_EDITOR_WIDTH;
|
||||
|
||||
impl EnvVarSelector {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&CloudModel::handle(ctx), |me, _, event, ctx| {
|
||||
me.handle_cloud_model_event(event, ctx);
|
||||
});
|
||||
|
||||
let dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = FilterableDropdown::new(ctx);
|
||||
dropdown.set_top_bar_max_width(DEFAULT_DROPDOWN_WIDTH);
|
||||
dropdown.set_menu_width(DEFAULT_DROPDOWN_WIDTH, ctx);
|
||||
dropdown
|
||||
});
|
||||
|
||||
let mut selector = Self { dropdown };
|
||||
selector.refresh_dropdown_items(ctx);
|
||||
selector
|
||||
}
|
||||
|
||||
pub fn set_orientation(
|
||||
&mut self,
|
||||
orientation: FilterableDropdownOrientation,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.dropdown
|
||||
.update(ctx, |dropdown, _ctx| dropdown.set_orientation(orientation));
|
||||
}
|
||||
|
||||
pub fn set_width(&mut self, width: f32, ctx: &mut ViewContext<Self>) {
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_top_bar_max_width(width);
|
||||
dropdown.set_menu_width(width, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_selected_env_vars(&mut self, id: Option<SyncId>, ctx: &mut ViewContext<Self>) {
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_action(EnvVarSelectorAction::Select(id), ctx)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn has_env_vars<C>(&self, ctx: &C) -> bool
|
||||
where
|
||||
C: ViewAsRef,
|
||||
{
|
||||
// We add a `None` item, so there are env vars iff there is more than one item.
|
||||
self.dropdown.as_ref(ctx).len() > 1
|
||||
}
|
||||
|
||||
fn refresh_dropdown_items(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let mut env_vars = CloudModel::as_ref(ctx)
|
||||
.get_all_active_env_var_collections()
|
||||
.map(|collection| (collection.display_name(), collection.sync_id()))
|
||||
.collect_vec();
|
||||
env_vars.sort_unstable_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let remove_item = std::iter::once(DropdownItem::new(
|
||||
"None",
|
||||
EnvVarSelectorAction::Select(None),
|
||||
));
|
||||
|
||||
let env_var_items = env_vars
|
||||
.into_iter()
|
||||
.map(|(name, id)| DropdownItem::new(name, EnvVarSelectorAction::Select(Some(id))));
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(remove_item.chain(env_var_items).collect(), ctx)
|
||||
});
|
||||
ctx.emit(EnvVarSelectorEvent::Refreshed);
|
||||
}
|
||||
|
||||
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
CloudModelEvent::ObjectUpdated { type_and_id, .. }
|
||||
| CloudModelEvent::ObjectCreated { type_and_id }
|
||||
| CloudModelEvent::ObjectUntrashed { type_and_id, .. }
|
||||
| CloudModelEvent::ObjectTrashed { type_and_id, .. } => {
|
||||
if matches!(
|
||||
type_and_id,
|
||||
CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(
|
||||
JsonObjectType::EnvVarCollection
|
||||
),
|
||||
..
|
||||
}
|
||||
) {
|
||||
self.refresh_dropdown_items(ctx);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for EnvVarSelector {
|
||||
type Event = EnvVarSelectorEvent;
|
||||
}
|
||||
|
||||
impl View for EnvVarSelector {
|
||||
fn ui_name() -> &'static str {
|
||||
"EnvVarSelector"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
ChildView::new(&self.dropdown).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for EnvVarSelector {
|
||||
type Action = EnvVarSelectorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
let EnvVarSelectorAction::Select(id) = action;
|
||||
ctx.emit(EnvVarSelectorEvent::SelectionChanged(*id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::Sender;
|
||||
use string_offset::ByteOffset;
|
||||
use warp_completer::completer::SuggestionTypeName;
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::ViewHandle;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::completer::SessionAgnosticContext;
|
||||
use crate::debounce::debounce;
|
||||
use crate::editor::{EditorView, TextStyleOperation};
|
||||
use crate::terminal::input::decorations::{
|
||||
parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot,
|
||||
};
|
||||
|
||||
/// Debounce for syntax highlighting workflow
|
||||
pub const DEBOUNCE_INPUT_DECORATION_PERIOD: Duration = Duration::from_millis(500);
|
||||
|
||||
pub struct SyntaxHighlightable {
|
||||
editor_handle: ViewHandle<EditorView>,
|
||||
syntax_highlighting_handle: Option<SpawnedFutureHandle>,
|
||||
debounce_input_background_tx: Sender<()>,
|
||||
}
|
||||
|
||||
impl SyntaxHighlightable {
|
||||
pub fn new(editor_handle: ViewHandle<EditorView>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let (debounce_input_background_tx, debounce_input_background_rx) =
|
||||
async_channel::unbounded();
|
||||
|
||||
let _ = ctx.spawn_stream_local(
|
||||
debounce(
|
||||
DEBOUNCE_INPUT_DECORATION_PERIOD,
|
||||
debounce_input_background_rx,
|
||||
),
|
||||
|me, _, ctx| me.highlight_syntax(ctx),
|
||||
|_me, _ctx| {},
|
||||
);
|
||||
|
||||
Self {
|
||||
editor_handle,
|
||||
syntax_highlighting_handle: None,
|
||||
debounce_input_background_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debounce_highlight(&mut self) {
|
||||
let _ = self.debounce_input_background_tx.try_send(());
|
||||
}
|
||||
|
||||
pub fn highlight_syntax(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(handle) = self.syntax_highlighting_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let buffer_text = self.editor_handle.as_ref(ctx).buffer_text(ctx);
|
||||
|
||||
let completion_context = SessionAgnosticContext::new(CommandRegistry::global_instance());
|
||||
self.syntax_highlighting_handle =
|
||||
Some(
|
||||
ctx.spawn(
|
||||
async move {
|
||||
parse_current_commands_and_tokens(buffer_text, &completion_context).await
|
||||
},
|
||||
move |highlightable, parsed_tokens, ctx| {
|
||||
highlightable.update_with_parsed_tokens(parsed_tokens, ctx);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn update_with_parsed_tokens(
|
||||
&mut self,
|
||||
parsed_tokens: ParsedTokensSnapshot,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.editor_handle.as_ref(ctx).buffer_text(ctx) != parsed_tokens.buffer_text {
|
||||
log::warn!("Stale syntax highlighting for workflow, will not apply it");
|
||||
return;
|
||||
}
|
||||
|
||||
let ranges = self.parsed_token_to_color_style_ranges(parsed_tokens.parsed_tokens, ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let terminal_colors_normal = appearance.theme().terminal_colors().normal;
|
||||
for (suggestion_type, ranges) in ranges {
|
||||
let color: AnsiColorIdentifier = suggestion_type.into();
|
||||
self.editor_handle.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
ranges,
|
||||
TextStyleOperation::default()
|
||||
.set_syntax_color(color.to_ansi_color(&terminal_colors_normal).into()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn parsed_token_to_color_style_ranges(
|
||||
&mut self,
|
||||
parsed_tokens: Vec<ParsedTokenData>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> HashMap<SuggestionTypeName, Vec<Range<ByteOffset>>> {
|
||||
let mut ranges: HashMap<SuggestionTypeName, Vec<Range<ByteOffset>>> = HashMap::new();
|
||||
for token_data in parsed_tokens {
|
||||
if let Some(description) = &token_data.token_description {
|
||||
let suggestion_type = description.suggestion_type;
|
||||
let range = ByteOffset::from(token_data.token.span.start())
|
||||
..ByteOffset::from(token_data.token.span.end());
|
||||
ranges
|
||||
.entry(suggestion_type.to_name())
|
||||
.or_default()
|
||||
.push(range);
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
ranges
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SyntaxHighlightable {
|
||||
type Event = ();
|
||||
}
|
||||
Reference in New Issue
Block a user