Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
pub mod util;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "native.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
|
||||
mod imp;
|
||||
|
||||
use crate::tab_configs::{TabConfig, TabConfigError};
|
||||
use crate::themes::theme::WarpThemeConfig;
|
||||
use crate::{
|
||||
launch_configs::launch_config::LaunchConfig, themes::theme::ThemeKind,
|
||||
workflows::workflow::Workflow,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub use imp::load_workflows;
|
||||
pub use imp::{load_launch_configs, load_theme_configs};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref LAUNCH_CONFIG_COMMENT: String = format!(
|
||||
"# Warp Launch Configuration
|
||||
#
|
||||
#
|
||||
# Use this to start a certain configuration of windows, tabs, and panes.
|
||||
# Open the launch configuration palette to access and open any launch configuration.
|
||||
#
|
||||
# This file defines your launch configuration.
|
||||
# More on how to do so here:
|
||||
# https://docs.warp.dev/terminal/sessions/launch-configurations
|
||||
#
|
||||
# All launch configurations are stored under {}.
|
||||
# Edit them anytime!
|
||||
#
|
||||
# You can also add commands that run on-start for your launch configurations like so:
|
||||
# ---
|
||||
# name: Example with Command
|
||||
# windows:
|
||||
# - tabs:
|
||||
# - layout:
|
||||
# cwd: /Users/warp-user/project
|
||||
# commands:
|
||||
# - exec: code .
|
||||
",
|
||||
warp_core::paths::home_relative_path(&crate::user_config::launch_configs_dir())
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum WarpConfigUpdateEvent {
|
||||
Themes,
|
||||
#[cfg_attr(not(feature = "local_fs"), expect(dead_code))]
|
||||
LocalUserWorkflows,
|
||||
LaunchConfigs,
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
TabConfigs,
|
||||
/// Emitted when one or more tab config files failed to parse.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
TabConfigErrors(Vec<TabConfigError>),
|
||||
/// The settings file (`settings.toml`) was created, modified, or deleted.
|
||||
#[cfg_attr(not(feature = "local_fs"), expect(dead_code))]
|
||||
Settings,
|
||||
/// One or more settings in `settings.toml` could not be loaded.
|
||||
#[cfg_attr(not(feature = "local_fs"), expect(dead_code))]
|
||||
SettingsErrors(crate::settings::SettingsFileError),
|
||||
/// A previously-errored settings reload succeeded with no errors.
|
||||
#[cfg_attr(not(feature = "local_fs"), expect(dead_code))]
|
||||
SettingsErrorsCleared,
|
||||
}
|
||||
|
||||
/// Singleton model containing user configurable file entities like themes, launch configs, and
|
||||
/// workflows.
|
||||
///
|
||||
/// Emits events when entities are changed, which are detected via filesystem
|
||||
/// watchers on the user's `data_dir()` (themes, workflows, launch configs,
|
||||
/// tab configs, etc.) and, on platforms where it differs, `config_local_dir()`
|
||||
/// (`settings.toml`, `keybindings.yaml`, `user_preferences.json`).
|
||||
#[derive(Default)]
|
||||
pub struct WarpConfig {
|
||||
launch_configs: Vec<LaunchConfig>,
|
||||
tab_configs: Vec<TabConfig>,
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
tab_config_errors: Vec<TabConfigError>,
|
||||
theme_config: WarpThemeConfig,
|
||||
local_user_workflows: Vec<Workflow>,
|
||||
}
|
||||
|
||||
/// Platform-independent parts of WarpConfig.
|
||||
///
|
||||
/// Additional platform-dependent functionality can be found in impl blocks
|
||||
/// in native.rs and wasm.rs.
|
||||
impl WarpConfig {
|
||||
#[cfg(test)]
|
||||
pub fn mock(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
theme_config: WarpThemeConfig::new(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn launch_configs(&self) -> &Vec<LaunchConfig> {
|
||||
&self.launch_configs
|
||||
}
|
||||
|
||||
pub fn tab_configs(&self) -> &Vec<TabConfig> {
|
||||
&self.tab_configs
|
||||
}
|
||||
|
||||
pub fn theme_config(&self) -> &WarpThemeConfig {
|
||||
&self.theme_config
|
||||
}
|
||||
|
||||
pub fn local_user_workflows(&self) -> &Vec<Workflow> {
|
||||
&self.local_user_workflows
|
||||
}
|
||||
|
||||
/// Saving the newly created launch configuration to the WarpConfig that we currently
|
||||
/// have.
|
||||
pub fn append_launch_config(
|
||||
&mut self,
|
||||
launch_config: &LaunchConfig,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if !self.launch_configs.contains(launch_config) {
|
||||
self.launch_configs.push(launch_config.to_owned());
|
||||
ctx.emit(WarpConfigUpdateEvent::LaunchConfigs);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_theme_config(
|
||||
&mut self,
|
||||
theme_config: WarpThemeConfig,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.theme_config = theme_config;
|
||||
ctx.emit(WarpConfigUpdateEvent::Themes);
|
||||
}
|
||||
|
||||
pub fn add_new_theme_to_config(
|
||||
&mut self,
|
||||
theme_name: ThemeKind,
|
||||
theme: WarpTheme,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.theme_config.add_new_theme(theme_name, theme);
|
||||
ctx.emit(WarpConfigUpdateEvent::Themes);
|
||||
}
|
||||
|
||||
/// Eagerly removes a tab config by its source path and emits a `TabConfigs` event.
|
||||
/// (Used after deleting the file on disk so the menu updates immediately
|
||||
/// rather than waiting for the filesystem watcher.)
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn remove_tab_config_by_path(&mut self, path: &Path, ctx: &mut ModelContext<Self>) {
|
||||
let before = self.tab_configs.len();
|
||||
self.tab_configs
|
||||
.retain(|c| c.source_path.as_deref() != Some(path));
|
||||
if self.tab_configs.len() != before {
|
||||
ctx.emit(WarpConfigUpdateEvent::TabConfigs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base directory in which all of the user's data is stored.
|
||||
fn base_dir() -> PathBuf {
|
||||
warp_core::paths::data_dir()
|
||||
}
|
||||
|
||||
/// Returns the path to the directory containing the user's custom themes.
|
||||
pub fn themes_dir() -> PathBuf {
|
||||
warp_core::paths::themes_dir()
|
||||
}
|
||||
|
||||
/// Returns the path to the directory containing the user's custom workflows.
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub fn workflows_dir() -> PathBuf {
|
||||
crate::workflows::local_workflows::workflows_dir(base_dir())
|
||||
}
|
||||
|
||||
/// Returns the path to the directory containing the user's launch
|
||||
/// configurations.
|
||||
pub fn launch_configs_dir() -> PathBuf {
|
||||
base_dir().join("launch_configurations")
|
||||
}
|
||||
|
||||
/// Returns the path to the directory containing the user's tab configs.
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub fn tab_configs_dir() -> PathBuf {
|
||||
base_dir().join("tab_configs")
|
||||
}
|
||||
|
||||
/// Returns the path to the directory containing the built-in default tab configs.
|
||||
/// These are shipped with Warp and user-editable (Warp does not overwrite modifications).
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub fn default_tab_configs_dir() -> PathBuf {
|
||||
base_dir().join("default_tab_configs")
|
||||
}
|
||||
|
||||
/// Returns whether the path points to a tab config TOML file under one of Warp's
|
||||
/// tab config directories.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn is_tab_config_toml(path: &Path) -> bool {
|
||||
let is_toml = path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension == "toml");
|
||||
if !is_toml {
|
||||
return false;
|
||||
}
|
||||
|
||||
[tab_configs_dir(), default_tab_configs_dir()]
|
||||
.into_iter()
|
||||
.any(|dir| path.starts_with(dir))
|
||||
}
|
||||
|
||||
/// Ensures `~/.warp/default_tab_configs/worktree.toml` exists, creating it
|
||||
/// from the embedded template if missing. Returns the path to the file.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn ensure_default_worktree_config() -> PathBuf {
|
||||
let dir = default_tab_configs_dir();
|
||||
let path = dir.join("worktree.toml");
|
||||
if !path.exists() {
|
||||
log::info!("Default worktree config missing; creating at {path:?}");
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
log::warn!("Failed to create default_tab_configs dir at {dir:?}: {e:?}");
|
||||
return path;
|
||||
}
|
||||
const TEMPLATE: &str = include_str!("../../resources/tab_configs/default_worktree.toml");
|
||||
if let Err(e) = std::fs::write(&path, TEMPLATE) {
|
||||
log::warn!("Failed to write default worktree config at {path:?}: {e:?}");
|
||||
} else {
|
||||
log::info!("Default worktree config created at {path:?}");
|
||||
}
|
||||
} else {
|
||||
log::info!("Default worktree config already exists at {path:?}");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn materialize_default_worktree_config(
|
||||
template_toml: &str,
|
||||
config_name: &str,
|
||||
repo_path: &str,
|
||||
pane_type: &str,
|
||||
) -> Result<(String, TabConfig), String> {
|
||||
let worktree_path = crate::tab_configs::tab_config::generated_worktree_path_string(
|
||||
Path::new(repo_path),
|
||||
"{{autogenerated_branch_name}}",
|
||||
);
|
||||
let mut toml_value = toml::from_str::<toml::Value>(template_toml)
|
||||
.map_err(|e| format!("failed to parse default worktree template: {e:?}"))?;
|
||||
|
||||
if let Some(doc) = toml_value.as_table_mut() {
|
||||
doc.insert(
|
||||
"name".to_string(),
|
||||
toml::Value::String(config_name.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
replace_default_worktree_placeholders(&mut toml_value, repo_path, pane_type, &worktree_path);
|
||||
|
||||
if let Some(doc) = toml_value.as_table_mut() {
|
||||
if let Some(params) = doc.get_mut("params").and_then(toml::Value::as_table_mut) {
|
||||
params.remove("repo");
|
||||
params.remove("pane_type");
|
||||
if params.is_empty() {
|
||||
doc.remove("params");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let toml_content = toml::to_string_pretty(&toml_value)
|
||||
.map_err(|e| format!("failed to serialize default worktree config: {e:?}"))?;
|
||||
let tab_config = toml::from_str::<TabConfig>(&toml_content)
|
||||
.map_err(|e| format!("failed to parse materialized worktree config: {e:?}"))?;
|
||||
|
||||
Ok((toml_content, tab_config))
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn replace_default_worktree_placeholders(
|
||||
value: &mut toml::Value,
|
||||
repo_path: &str,
|
||||
pane_type: &str,
|
||||
worktree_path: &str,
|
||||
) {
|
||||
match value {
|
||||
toml::Value::String(string) => {
|
||||
*string = string
|
||||
.replace(
|
||||
"{{worktree_path_prefix}}{{autogenerated_branch_name}}",
|
||||
worktree_path,
|
||||
)
|
||||
.replace("{{repo}}", repo_path)
|
||||
.replace("{{pane_type}}", pane_type)
|
||||
.replace("{{worktree_path_prefix}}", "");
|
||||
}
|
||||
toml::Value::Array(array) => {
|
||||
for value in array {
|
||||
replace_default_worktree_placeholders(value, repo_path, pane_type, worktree_path);
|
||||
}
|
||||
}
|
||||
toml::Value::Table(table) => {
|
||||
for (_, value) in table.iter_mut() {
|
||||
replace_default_worktree_placeholders(value, repo_path, pane_type, worktree_path);
|
||||
}
|
||||
}
|
||||
toml::Value::Boolean(_)
|
||||
| toml::Value::Datetime(_)
|
||||
| toml::Value::Float(_)
|
||||
| toml::Value::Integer(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a path for a new tab config file that does not yet exist in `dir`.
|
||||
/// Tries `my_tab_config.toml`, then `my_tab_config_1.toml`, `my_tab_config_2.toml`, etc.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn find_unused_tab_config_path(dir: &Path) -> PathBuf {
|
||||
find_unused_toml_path(dir, "my_tab_config")
|
||||
}
|
||||
|
||||
/// Returns a `.toml` path in `dir` that does not yet exist.
|
||||
///
|
||||
/// Tries `{base_name}.toml`, then `{base_name}_1.toml`, `{base_name}_2.toml`, etc.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn find_unused_toml_path(dir: &Path, base_name: &str) -> PathBuf {
|
||||
let base = dir.join(format!("{base_name}.toml"));
|
||||
if !base.exists() {
|
||||
return base;
|
||||
}
|
||||
let mut n = 1u32;
|
||||
loop {
|
||||
let candidate = dir.join(format!("{base_name}_{n}.toml"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitizes a suggested TOML filename base into a lowercase ASCII-ish stem.
|
||||
///
|
||||
/// Preserves ASCII letters, digits, hyphens, and underscores. All other
|
||||
/// characters are replaced with underscores, repeated underscores are collapsed,
|
||||
/// and leading/trailing underscores are removed.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn sanitize_toml_base_name(base_name: &str) -> String {
|
||||
let mut sanitized = String::with_capacity(base_name.len());
|
||||
let mut last_was_underscore = false;
|
||||
|
||||
for c in base_name.chars().flat_map(char::to_lowercase) {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
sanitized.push(c);
|
||||
last_was_underscore = c == '_';
|
||||
} else if !last_was_underscore && !sanitized.is_empty() {
|
||||
sanitized.push('_');
|
||||
last_was_underscore = true;
|
||||
}
|
||||
}
|
||||
|
||||
sanitized = sanitized.trim_matches('_').to_string();
|
||||
if sanitized.is_empty() {
|
||||
"worktree".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a path for a new worktree tab config that does not yet exist in `dir`.
|
||||
/// Uses the branch name to create a descriptive filename like `worktree_my-branch.toml`.
|
||||
///
|
||||
/// The caller is expected to pass a branch name that has already been validated
|
||||
/// (alphanumeric, hyphens, underscores only) so no sanitization is performed here.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn find_unused_worktree_config_path(dir: &Path, branch_name: &str) -> PathBuf {
|
||||
let base = dir.join(format!("worktree_{branch_name}.toml"));
|
||||
if !base.exists() {
|
||||
return base;
|
||||
}
|
||||
let mut n = 1u32;
|
||||
loop {
|
||||
let candidate = dir.join(format!("worktree_{branch_name}_{n}.toml"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WarpConfig {
|
||||
type Event = WarpConfigUpdateEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for WarpConfig {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::launch_configs::launch_config::PaneTemplateType;
|
||||
use crate::tab_configs::render_tab_config;
|
||||
use crate::tab_configs::tab_config::{generated_worktree_repo_dir, TabConfigPaneType};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_default_tab_configs_dir_uses_underscores() {
|
||||
assert!(default_tab_configs_dir().ends_with("default_tab_configs"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_is_tab_config_toml_matches_user_tab_configs() {
|
||||
let path = tab_configs_dir().join("my_tab_config.toml");
|
||||
assert!(is_tab_config_toml(&path));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_is_tab_config_toml_matches_default_tab_configs() {
|
||||
let path = default_tab_configs_dir().join("worktree.toml");
|
||||
assert!(is_tab_config_toml(&path));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_is_tab_config_toml_rejects_non_toml_paths() {
|
||||
let path = tab_configs_dir().join("my_tab_config.yaml");
|
||||
assert!(!is_tab_config_toml(&path));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_is_tab_config_toml_rejects_tomls_outside_tab_config_dirs() {
|
||||
let path = launch_configs_dir().join("workspace.toml");
|
||||
assert!(!is_tab_config_toml(&path));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_materialize_default_worktree_config_bakes_repo_and_pane_type_only() {
|
||||
let template = include_str!("../../resources/tab_configs/default_worktree.toml");
|
||||
let repo_path = "/tmp/example-repo";
|
||||
let (toml_content, tab_config) =
|
||||
materialize_default_worktree_config(template, "Worktree: example-repo", repo_path, "agent")
|
||||
.expect("expected template materialization to succeed");
|
||||
|
||||
assert!(toml_content.contains("name = \"Worktree: example-repo\""));
|
||||
assert!(toml_content.contains(repo_path));
|
||||
assert!(toml_content.contains("type = \"agent\""));
|
||||
assert!(toml_content.contains("{{autogenerated_branch_name}}"));
|
||||
assert!(toml_content.contains(
|
||||
&generated_worktree_repo_dir(Path::new(repo_path))
|
||||
.display()
|
||||
.to_string()
|
||||
));
|
||||
assert!(!toml_content.contains("{{repo}}"));
|
||||
assert!(!toml_content.contains("{{pane_type}}"));
|
||||
assert!(!toml_content.contains("{{worktree_path_prefix}}"));
|
||||
assert!(!toml_content.contains("[params.repo]"));
|
||||
assert!(!toml_content.contains("[params.pane_type]"));
|
||||
|
||||
assert!(tab_config.params.is_empty());
|
||||
assert_eq!(tab_config.name, "Worktree: example-repo");
|
||||
assert_eq!(tab_config.panes.len(), 1);
|
||||
assert_eq!(tab_config.panes[0].directory.as_deref(), Some(repo_path));
|
||||
assert_eq!(
|
||||
tab_config.panes[0].pane_type,
|
||||
Some(TabConfigPaneType::Agent)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_materialized_default_worktree_config_renders_full_worktree_path() {
|
||||
let template = include_str!("../../resources/tab_configs/default_worktree.toml");
|
||||
let repo_path = "/tmp/example-repo";
|
||||
let (_, tab_config) =
|
||||
materialize_default_worktree_config(template, "Worktree: example-repo", repo_path, "agent")
|
||||
.expect("expected template materialization to succeed");
|
||||
|
||||
let (_, pane_template) = render_tab_config(&tab_config, &HashMap::new(), Some("my-feature"));
|
||||
|
||||
if let PaneTemplateType::PaneTemplate { commands, .. } = pane_template {
|
||||
let expected_worktree_path = generated_worktree_repo_dir(Path::new(repo_path))
|
||||
.join("my-feature")
|
||||
.display()
|
||||
.to_string();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
format!("git worktree add -b my-feature {expected_worktree_path}")
|
||||
);
|
||||
assert_eq!(commands[1].exec, format!("cd {expected_worktree_path}"));
|
||||
} else {
|
||||
panic!("expected terminal pane template");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_sanitize_toml_base_name_replaces_spaces_and_dots() {
|
||||
assert_eq!(sanitize_toml_base_name("My Project.v2"), "my_project_v2");
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn test_sanitize_toml_base_name_falls_back_for_empty_result() {
|
||||
assert_eq!(sanitize_toml_base_name("..."), "worktree");
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use itertools::Itertools;
|
||||
use repo_metadata::RepositoryUpdate;
|
||||
use warpui::{ModelContext, SingletonEntity};
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::tab_configs::{TabConfig, TabConfigError};
|
||||
use crate::themes::theme::WarpThemeConfig;
|
||||
use crate::warp_managed_paths_watcher::{
|
||||
repository_update_touches_path, repository_update_touches_prefix, WarpManagedPathsWatcher,
|
||||
WarpManagedPathsWatcherEvent,
|
||||
};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
|
||||
use super::util::{
|
||||
for_each_dir_entry, has_name, is_config_file, parse_multi_launch_config_dir_entry,
|
||||
parse_multi_workflow_dir_entry, parse_single_theme_dir_entry, parse_tab_config_dir_entry,
|
||||
};
|
||||
use super::{
|
||||
launch_configs_dir, tab_configs_dir, themes_dir, workflows_dir, WarpConfigUpdateEvent,
|
||||
LAUNCH_CONFIG_COMMENT,
|
||||
};
|
||||
|
||||
impl super::WarpConfig {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
// Load launch configs, and workflows from disk asynchronously on a background
|
||||
// thread.
|
||||
//
|
||||
// Themes are required during initialization by `Settings`, so we load this synchronously
|
||||
// on startup. We should investigate the possibility of offloading theme loading to a
|
||||
// background thread in the future.
|
||||
let _ = ctx.spawn(
|
||||
async move { load_launch_configs(&launch_configs_dir()) },
|
||||
|me, launch_configs, ctx| {
|
||||
me.launch_configs = launch_configs;
|
||||
ctx.emit(WarpConfigUpdateEvent::LaunchConfigs);
|
||||
},
|
||||
);
|
||||
if FeatureFlag::TabConfigs.is_enabled() {
|
||||
let _ = ctx.spawn(
|
||||
async move { load_tab_configs(&tab_configs_dir()) },
|
||||
|me, (tab_configs, tab_config_errors), ctx| {
|
||||
me.tab_configs = tab_configs;
|
||||
me.tab_config_errors = tab_config_errors;
|
||||
ctx.emit(WarpConfigUpdateEvent::TabConfigs);
|
||||
// Don't emit TabConfigErrors on startup — the error toast
|
||||
// should only appear when the user saves a config file,
|
||||
// not on app restart.
|
||||
},
|
||||
);
|
||||
}
|
||||
let _ = ctx.spawn(
|
||||
async move { load_workflows(&workflows_dir()) },
|
||||
|me, user_workflows, ctx| {
|
||||
me.local_user_workflows = user_workflows;
|
||||
ctx.emit(WarpConfigUpdateEvent::LocalUserWorkflows);
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(
|
||||
&WarpManagedPathsWatcher::handle(ctx),
|
||||
Self::handle_warp_managed_paths_event,
|
||||
);
|
||||
|
||||
Self {
|
||||
theme_config: load_theme_configs(&themes_dir()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_warp_managed_paths_event(
|
||||
&mut self,
|
||||
event: &WarpManagedPathsWatcherEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let WarpManagedPathsWatcherEvent::FilesChanged(update) = event;
|
||||
|
||||
if update_touches_dir(update, &themes_dir()) {
|
||||
let theme_dir = themes_dir();
|
||||
let _ = ctx.spawn(
|
||||
async move { load_theme_configs(&theme_dir) },
|
||||
|me, theme_config, ctx| {
|
||||
me.theme_config = theme_config;
|
||||
ctx.emit(WarpConfigUpdateEvent::Themes);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if update_touches_dir(update, &workflows_dir()) {
|
||||
let workflow_dir = workflows_dir();
|
||||
let _ = ctx.spawn(
|
||||
async move { load_workflows(&workflow_dir) },
|
||||
|me, workflows, ctx| {
|
||||
me.local_user_workflows = workflows;
|
||||
ctx.emit(WarpConfigUpdateEvent::LocalUserWorkflows);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if update_touches_dir(update, &launch_configs_dir()) {
|
||||
let launch_config_dir = launch_configs_dir();
|
||||
let _ = ctx.spawn(
|
||||
async move { load_launch_configs(&launch_config_dir) },
|
||||
|me, launch_configs, ctx| {
|
||||
me.launch_configs = launch_configs;
|
||||
ctx.emit(WarpConfigUpdateEvent::LaunchConfigs);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if FeatureFlag::TabConfigs.is_enabled() && update_touches_dir(update, &tab_configs_dir()) {
|
||||
let tab_config_dir = tab_configs_dir();
|
||||
let _ = ctx.spawn(
|
||||
async move { load_tab_configs(&tab_config_dir) },
|
||||
|me, (configs, errors), ctx| {
|
||||
me.tab_configs = configs;
|
||||
me.tab_config_errors = errors.clone();
|
||||
ctx.emit(WarpConfigUpdateEvent::TabConfigs);
|
||||
if !errors.is_empty() {
|
||||
ctx.emit(WarpConfigUpdateEvent::TabConfigErrors(errors));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if FeatureFlag::SettingsFile.is_enabled()
|
||||
&& update_touches_path(update, &crate::settings::user_preferences_toml_file_path())
|
||||
{
|
||||
ctx.emit(WarpConfigUpdateEvent::Settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// This method takes a file name candidate (appends .yaml if missing) and a LaunchConfig as
|
||||
/// arguments. It saves the file and returns the filename used if successful.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn save_new_launch_config(
|
||||
file_name: String,
|
||||
launch_config: LaunchConfig,
|
||||
) -> Result<String> {
|
||||
let file_name = if is_config_file(&file_name) {
|
||||
file_name.trim().into()
|
||||
} else {
|
||||
format!("{file_name}.yaml")
|
||||
};
|
||||
|
||||
if !has_name(file_name.trim()) {
|
||||
return Err(anyhow!("File name is empty"));
|
||||
};
|
||||
|
||||
let path = crate::user_config::launch_configs_dir().join(&file_name);
|
||||
if path.exists() {
|
||||
return Err(anyhow!("File already exists"));
|
||||
};
|
||||
|
||||
let file = crate::util::file::create_file(path)?;
|
||||
let mut writer = io::BufWriter::new(file);
|
||||
writer.write_all(LAUNCH_CONFIG_COMMENT.as_bytes())?;
|
||||
serde_yaml::to_writer(writer, &launch_config)?;
|
||||
Ok(file_name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_theme_configs(theme_path: &Path) -> WarpThemeConfig {
|
||||
let mut theme_configs = WarpThemeConfig::new();
|
||||
for_each_dir_entry(theme_path, parse_single_theme_dir_entry)
|
||||
.into_iter()
|
||||
.for_each(|(theme_name, theme)| theme_configs.add_new_theme(theme_name, theme));
|
||||
theme_configs
|
||||
}
|
||||
|
||||
/// Loads all workflows relative to the `workflow_path`. A YAML file might
|
||||
/// contain multiple workflows.
|
||||
pub fn load_workflows(workflow_path: &Path) -> Vec<Workflow> {
|
||||
for_each_dir_entry(workflow_path, parse_multi_workflow_dir_entry)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// Loads all launch configs relative to the `launch_config_path`. Each workflow is assumed to be in an
|
||||
/// individual YAML file.
|
||||
pub fn load_launch_configs(launch_config_path: &Path) -> Vec<LaunchConfig> {
|
||||
for_each_dir_entry(launch_config_path, parse_multi_launch_config_dir_entry)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// Loads all tab configs from `tab_config_path`. Each tab config is an individual TOML file.
|
||||
///
|
||||
/// Returns successfully parsed configs and any errors for files that failed to parse.
|
||||
pub fn load_tab_configs(tab_config_path: &Path) -> (Vec<TabConfig>, Vec<TabConfigError>) {
|
||||
let results = for_each_dir_entry(tab_config_path, parse_tab_config_dir_entry);
|
||||
let mut configs = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(config) => configs.push(config),
|
||||
Err(error) => errors.push(error),
|
||||
}
|
||||
}
|
||||
(configs, errors)
|
||||
}
|
||||
|
||||
fn update_touches_dir(update: &RepositoryUpdate, path: &Path) -> bool {
|
||||
let canonical_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
repository_update_touches_prefix(update, path)
|
||||
|| repository_update_touches_prefix(update, &canonical_path)
|
||||
}
|
||||
|
||||
fn update_touches_path(update: &RepositoryUpdate, path: &Path) -> bool {
|
||||
let canonical_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
repository_update_touches_path(update, path)
|
||||
|| repository_update_touches_path(update, &canonical_path)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Allowing dead code when targeting wasm as most of the functions in this
|
||||
// module are only used on native.
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::de::DeserializeOwned;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::tab_configs::{TabConfig, TabConfigError};
|
||||
use crate::themes::theme::{ThemeKind, WarpTheme, WarpThemeConfig};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
|
||||
const CONFIG_FILE_SUFFIXES: &[&str] = &[".yaml", ".yml"];
|
||||
const TOML_CONFIG_FILE_SUFFIX: &str = ".toml";
|
||||
|
||||
fn get_file_name(item: &DirEntry) -> Option<String> {
|
||||
match item.metadata() {
|
||||
Ok(metadata) if metadata.is_file() => item.file_name().to_str().map(|s| s.to_string()),
|
||||
// The item was something else, like a directory.
|
||||
Ok(_) => None,
|
||||
// The file was deleted between when we generated the DirEntry and now.
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_yaml<R>(path: PathBuf) -> anyhow::Result<R>
|
||||
where
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let file = fs::File::open(path.as_path())?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
|
||||
let u: R = serde_yaml::from_reader(reader)?;
|
||||
Ok(u)
|
||||
}
|
||||
|
||||
pub fn from_toml<R>(path: PathBuf) -> anyhow::Result<R>
|
||||
where
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let contents = fs::read_to_string(path.as_path())?;
|
||||
let u: R = toml::from_str(&contents)?;
|
||||
Ok(u)
|
||||
}
|
||||
|
||||
/// Deserializes a `DirEntry` into an object of type `G` if the file is a valid
|
||||
/// config file containing a single item.
|
||||
fn parse_single_item_file<T, F, G>(item: &DirEntry, post_deserialize_fn: F) -> Option<G>
|
||||
where
|
||||
F: Fn(String, T) -> G,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
if let Some(file_name) = get_file_name(item) {
|
||||
if is_config_file(&file_name) {
|
||||
let parsed = from_yaml::<T>(item.path().into());
|
||||
match parsed {
|
||||
Ok(parsed) => return Some(post_deserialize_fn(file_name, parsed)),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse config file at {file_name:?} with error: {e:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn from_multi_doc_yaml<R>(path: PathBuf) -> anyhow::Result<Vec<R>>
|
||||
where
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let file = fs::File::open(path.as_path())?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
|
||||
serde_yaml::Deserializer::from_reader(reader)
|
||||
.map(|document| R::deserialize(document).map_err(Into::into))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deserializes a `DirEntry` into an object of type `Vec<G>` if the file is a
|
||||
/// valid config file containing one or more items.
|
||||
fn parse_multi_item_file<T, F, G>(item: &DirEntry, post_deserialize_fn: F) -> Option<Vec<G>>
|
||||
where
|
||||
F: Fn(String, T) -> G,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
if let Some(file_name) = get_file_name(item) {
|
||||
if is_config_file(&file_name) {
|
||||
let parsed = from_multi_doc_yaml::<T>(item.path().into());
|
||||
match parsed {
|
||||
Ok(parsed) => {
|
||||
return Some(
|
||||
parsed
|
||||
.into_iter()
|
||||
.map(|val| post_deserialize_fn(file_name.clone(), val))
|
||||
.collect_vec(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse config file at {file_name:?} with error: {e:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
let lowercase = s.to_lowercase();
|
||||
let mut chars = lowercase.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|first_letter| first_letter.to_uppercase())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(chars)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn file_name_to_human_readable_name(file_name: &str) -> String {
|
||||
let name = if let Some(suffix) = CONFIG_FILE_SUFFIXES
|
||||
.iter()
|
||||
.find(|&suffix| file_name.ends_with(suffix))
|
||||
{
|
||||
file_name.strip_suffix(suffix).unwrap_or(file_name)
|
||||
} else {
|
||||
file_name
|
||||
};
|
||||
|
||||
name_to_camel_case(name)
|
||||
}
|
||||
|
||||
fn name_to_camel_case(name: &str) -> String {
|
||||
// Camel Case conversion (with spaces) treating each '_' as word separator.
|
||||
// solarized_dark.yaml => Solarized Dark
|
||||
// SOLARIZED_DARK.yaml => Solarized Dark
|
||||
// SolarizedDark.yaml => Solarizeddark (note: no '_', so treating as single word)
|
||||
name.split('_').map(title_case).join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn parse_single_theme_dir_entry(item: &DirEntry) -> Option<(ThemeKind, WarpTheme)> {
|
||||
parse_single_item_file(item, |file_name, mut theme: WarpTheme| {
|
||||
// If the name exists in the .yaml, we use it. Otherwise we treat a "human readable" version of the filename as the theme name.
|
||||
let theme_kind = if let Some(name) = theme.name() {
|
||||
WarpThemeConfig::file_to_theme(name, item.path().into())
|
||||
} else {
|
||||
let name = file_name_to_human_readable_name(file_name.as_str());
|
||||
theme.set_name(name.clone());
|
||||
WarpThemeConfig::file_to_theme(name, item.path().into())
|
||||
};
|
||||
|
||||
(theme_kind, theme)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_multi_workflow_dir_entry(item: &DirEntry) -> Option<Vec<Workflow>> {
|
||||
parse_multi_item_file(item, |_, workflow| workflow)
|
||||
}
|
||||
|
||||
pub(super) fn parse_multi_launch_config_dir_entry(item: &DirEntry) -> Option<Vec<LaunchConfig>> {
|
||||
parse_multi_item_file(item, |_file_name, config| config)
|
||||
}
|
||||
|
||||
pub(super) fn parse_tab_config_dir_entry(
|
||||
item: &DirEntry,
|
||||
) -> Option<Result<TabConfig, TabConfigError>> {
|
||||
let file_name = get_file_name(item)?;
|
||||
if !is_toml_file(&file_name) {
|
||||
return None;
|
||||
}
|
||||
let parsed = from_toml::<TabConfig>(item.path().into());
|
||||
Some(
|
||||
parsed
|
||||
.map(|mut config| {
|
||||
config.source_path = Some(item.path().into());
|
||||
config
|
||||
})
|
||||
.map_err(|e| TabConfigError {
|
||||
file_name,
|
||||
file_path: item.path().into(),
|
||||
error_message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs the given function on each `DirEntry` within the `Path`. If the path is not a directory,
|
||||
/// an empty `Vector` is returned. It works recursively, covering directories within a given path.
|
||||
pub(super) fn for_each_dir_entry<F, T>(path: &Path, dir_entry_fn: F) -> Vec<T>
|
||||
where
|
||||
F: Fn(&DirEntry) -> Option<T>,
|
||||
{
|
||||
if path.is_dir() {
|
||||
WalkDir::new(path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|item| dir_entry_fn(&item))
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Must end with config file suffix
|
||||
pub(super) fn is_config_file(file_name: &str) -> bool {
|
||||
CONFIG_FILE_SUFFIXES
|
||||
.iter()
|
||||
.any(|&suffix| file_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
/// Must end with `.toml`
|
||||
pub(super) fn is_toml_file(file_name: &str) -> bool {
|
||||
file_name.ends_with(TOML_CONFIG_FILE_SUFFIX)
|
||||
}
|
||||
|
||||
/// Must have a name beyond the suffix
|
||||
pub(super) fn has_name(file_name: &str) -> bool {
|
||||
CONFIG_FILE_SUFFIXES
|
||||
.iter()
|
||||
.all(|&suffix| file_name != suffix && !file_name.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "util_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,30 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn title_case_test() {
|
||||
assert_eq!("Test", title_case("test"));
|
||||
assert_eq!("Test", title_case("TEST"));
|
||||
assert_eq!("Test", title_case("Test"));
|
||||
assert_eq!("Zażółć", title_case("Zażółć"));
|
||||
assert_eq!("Zażółć", title_case("ZAŻÓŁĆ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_name_to_human_readable_name_test() {
|
||||
assert_eq!(
|
||||
"Solarized Dark",
|
||||
file_name_to_human_readable_name("solarized_dark")
|
||||
);
|
||||
assert_eq!(
|
||||
"Solarized Dark",
|
||||
file_name_to_human_readable_name("solarized_dark.yaml")
|
||||
);
|
||||
assert_eq!(
|
||||
"Solarized Dark",
|
||||
file_name_to_human_readable_name("SOLARIZED_DARK.yaml")
|
||||
);
|
||||
assert_eq!(
|
||||
"Solarizeddark",
|
||||
file_name_to_human_readable_name("solarizeddark.yaml")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use std::path::Path;
|
||||
|
||||
use warpui::ModelContext;
|
||||
|
||||
use crate::launch_configs::launch_config::LaunchConfig;
|
||||
use crate::themes::theme::WarpThemeConfig;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
|
||||
impl super::WarpConfig {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
launch_configs: Default::default(),
|
||||
tab_configs: Default::default(),
|
||||
tab_config_errors: Default::default(),
|
||||
theme_config: WarpThemeConfig::new(),
|
||||
local_user_workflows: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads all themes relative to the `workflow_path`.
|
||||
pub fn load_theme_configs(_theme_path: &Path) -> WarpThemeConfig {
|
||||
// There's no local filesystem for wasm, so we'll never be able to retrieve
|
||||
// themes from any path.
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Loads all workflows relative to the `workflow_path`.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn load_workflows(_workflow_path: &Path) -> Vec<Workflow> {
|
||||
// There's no local filesystem for wasm, so we'll never be able to retrieve
|
||||
// workflows from any path.
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Loads all launch configs relative to the `launch_config_path`.
|
||||
pub fn load_launch_configs(_launch_config_path: &Path) -> Vec<LaunchConfig> {
|
||||
// There's no local filesystem for wasm, so we'll never be able to retrieve
|
||||
// launch configs from any path.
|
||||
Default::default()
|
||||
}
|
||||
Reference in New Issue
Block a user