Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+155
View File
@@ -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;
+165
View File
@@ -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
+56
View File
@@ -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
);
}
+538
View File
@@ -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;
+405
View File
@@ -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()])])
);
}
+419
View File
@@ -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
+236
View File
@@ -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;
+56
View File
@@ -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);
});
});
}
+226
View File
@@ -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 {}
+394
View File
@@ -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;
+71
View File
@@ -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
);
});
}
+388
View File
@@ -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;
+94
View File
@@ -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
}
}
+88
View File
@@ -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 = ();
}