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
+264
View File
@@ -0,0 +1,264 @@
use std::path::PathBuf;
use crate::app_state::{
AppState, LeafContents, PaneNodeSnapshot, SplitDirection as StateSplitDirection, TabSnapshot,
WindowSnapshot,
};
use crate::themes::theme::AnsiColorIdentifier;
use serde::{Deserialize, Deserializer, Serialize};
#[cfg(test)]
#[path = "launch_config_tests.rs"]
mod tests;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct LaunchConfig {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub active_window_index: Option<usize>,
pub windows: Vec<WindowTemplate>,
}
impl LaunchConfig {
pub fn from_snapshot(name: String, app_state: &AppState) -> Self {
Self {
name,
active_window_index: app_state.active_window_index,
windows: app_state
.windows
.iter()
.filter_map(|window| (!window.quake_mode).then_some(window.clone().into()))
.collect::<Vec<WindowTemplate>>(),
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct WindowTemplate {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub active_tab_index: Option<usize>,
pub tabs: Vec<TabTemplate>,
}
impl From<WindowSnapshot> for WindowTemplate {
fn from(snapshot: WindowSnapshot) -> Self {
let mut active_tab_index = None;
let mut num_valid_tabs = 0;
let tabs = snapshot
.tabs
.into_iter()
.enumerate()
.filter_map(|(i, tab)| {
let tab = tab.try_into().ok()?;
if i == snapshot.active_tab_index {
active_tab_index = Some(num_valid_tabs);
}
num_valid_tabs += 1;
Some(tab)
})
.collect::<Vec<TabTemplate>>();
Self {
active_tab_index,
tabs,
}
}
}
fn is_falsey(val: &Option<bool>) -> bool {
val.is_none_or(|v| !v)
}
/// The mode a leaf pane opens in.
///
/// Used by tab configs to distinguish terminal, agent, and cloud panes.
/// Launch configs always produce `Terminal` (the default).
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PaneMode {
/// A standard terminal shell session.
#[default]
Terminal,
/// A terminal that immediately enters Agent Mode.
Agent,
/// A cloud-mode (ambient agent) pane with no local shell.
Cloud,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(untagged, rename_all = "lowercase")]
pub enum PaneTemplateType {
PaneTemplate {
#[serde(deserialize_with = "deserialize_path")]
cwd: PathBuf,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
commands: Vec<CommandTemplate>,
#[serde(skip_serializing_if = "is_falsey", default)]
is_focused: Option<bool>,
#[serde(default)]
pane_mode: PaneMode,
/// Optional shell override for this pane (e.g. `"pwsh"`, `"zsh"`).
/// Sourced from the `shell` field of a tab config pane node.
#[serde(skip_serializing_if = "Option::is_none", default)]
shell: Option<String>,
},
PaneBranchTemplate {
split_direction: SplitDirection,
panes: Vec<PaneTemplateType>,
},
}
impl TryFrom<PaneNodeSnapshot> for PaneTemplateType {
type Error = ();
#[allow(clippy::unwrap_in_result)]
fn try_from(snapshot: PaneNodeSnapshot) -> Result<Self, ()> {
match snapshot {
PaneNodeSnapshot::Branch(branch) => {
let panes = branch
.children
.iter()
.filter_map(|(_, snapshot)| snapshot.clone().try_into().ok())
.collect::<Vec<PaneTemplateType>>();
match panes.len() {
0 => Err(()),
1 => Ok(panes
.into_iter()
.next()
.expect("Checked that panes has 1 element")),
_ => Ok(Self::PaneBranchTemplate {
split_direction: branch.direction.into(),
panes,
}),
}
}
PaneNodeSnapshot::Leaf(leaf) => match leaf.contents {
LeafContents::Terminal(terminal) => Ok(Self::PaneTemplate {
cwd: PathBuf::from(terminal.cwd.unwrap_or_default()),
commands: Vec::new(),
is_focused: Some(leaf.is_focused),
pane_mode: PaneMode::Terminal,
shell: None,
}),
// Currently, notebook panes cannot be saved in launch configurations.
LeafContents::Notebook(_)
| LeafContents::EnvVarCollection(_)
| LeafContents::Code(_)
| LeafContents::Workflow(_)
| LeafContents::Settings(_)
| LeafContents::AIFact(_)
| LeafContents::CodeReview(_)
| LeafContents::ExecutionProfileEditor
| LeafContents::GetStarted
| LeafContents::NetworkLog
| LeafContents::Welcome { .. }
| LeafContents::AIDocument(_)
| LeafContents::EnvironmentManagement(_)
| LeafContents::AmbientAgent(_) => {
// TODO: Handle AIDocument in launch config
Err(())
}
},
}
}
}
/// Deserializes a string that semantically represents a path, expanding ~ as
/// needed.
fn deserialize_path<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
where
D: Deserializer<'de>,
{
let raw_path = String::deserialize(deserializer)?;
Ok(PathBuf::from(shellexpand::tilde(&raw_path).into_owned()))
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct TabTemplate {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub title: Option<String>,
pub layout: PaneTemplateType,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub color: Option<AnsiColorIdentifier>,
}
impl TryFrom<TabSnapshot> for TabTemplate {
type Error = ();
fn try_from(snapshot: TabSnapshot) -> Result<Self, ()> {
let color = snapshot.color();
Ok(Self {
title: snapshot.custom_title,
layout: snapshot.root.try_into()?,
color,
})
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SplitDirection {
Vertical,
Horizontal,
}
impl From<StateSplitDirection> for SplitDirection {
fn from(snapshot: StateSplitDirection) -> Self {
match snapshot {
StateSplitDirection::Horizontal => Self::Horizontal,
StateSplitDirection::Vertical => Self::Vertical,
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct CommandTemplate {
pub exec: String,
}
impl From<&str> for CommandTemplate {
fn from(s: &str) -> CommandTemplate {
CommandTemplate {
exec: s.to_string(),
}
}
}
// TODO add extra elements to the mock (split panes, multiple tabs, multiple windows)
pub fn make_mock_single_window_launch_config() -> LaunchConfig {
LaunchConfig {
name: "Mocked Config".to_string(),
active_window_index: Some(0),
windows: vec![WindowTemplate {
active_tab_index: Some(0),
tabs: vec![
TabTemplate {
title: Some("First Tab".to_string()),
layout: PaneTemplateType::PaneTemplate {
is_focused: Some(true),
cwd: PathBuf::from("/some/path"),
commands: vec!["echo test_command".into()],
pane_mode: PaneMode::Terminal,
shell: None,
},
color: None,
},
TabTemplate {
title: Some("Second Tab".to_string()),
layout: PaneTemplateType::PaneTemplate {
is_focused: Some(true),
cwd: PathBuf::from("/some/path"),
commands: vec!["echo test_command_on_another_tab".into()],
pane_mode: PaneMode::Terminal,
shell: None,
},
color: None,
},
],
}],
}
}
@@ -0,0 +1,392 @@
use std::path::PathBuf;
use crate::{
app_state::{
AppState, BranchSnapshot, LeafContents, LeafSnapshot, NotebookPaneSnapshot, PaneFlex,
PaneNodeSnapshot, SplitDirection, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot,
},
drive::OpenWarpDriveObjectSettings,
tab::SelectedTabColor,
};
use super::{LaunchConfig, PaneMode, PaneTemplateType};
fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState {
AppState {
windows: vec![WindowSnapshot {
tabs: vec![TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root,
left_panel: None,
right_panel: None,
}],
active_tab_index: 0,
bounds: None,
quake_mode: false,
universal_search_width: None,
warp_ai_width: None,
voltron_width: None,
warp_drive_index_width: None,
left_panel_open: false,
vertical_tabs_panel_open: false,
fullscreen_state: Default::default(),
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
}],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
}
}
fn multi_tab_snapshot(active_tab_index: usize, tabs: Vec<TabSnapshot>) -> AppState {
AppState {
windows: vec![WindowSnapshot {
tabs,
active_tab_index,
bounds: None,
quake_mode: false,
universal_search_width: None,
warp_ai_width: None,
voltron_width: None,
warp_drive_index_width: None,
left_panel_open: false,
vertical_tabs_panel_open: false,
fullscreen_state: Default::default(),
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
}],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
}
}
#[test]
fn test_config_from_snapshot_flattens_single_pane() {
// If only one pane of the branch can be saved into a launch configuration, it should
// be flattened to a single leaf.
let state = single_tab_snapshot(PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![
(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id: None,
settings: OpenWarpDriveObjectSettings::default(),
}),
}),
),
(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/some/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
),
],
}));
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert_eq!(
template.windows[0].tabs[0].layout,
PaneTemplateType::PaneTemplate {
is_focused: Some(true),
cwd: PathBuf::from("/some/dir"),
commands: vec![],
pane_mode: PaneMode::Terminal,
shell: None,
},
)
}
#[test]
fn test_config_from_snapshot_filters_panes() {
let state = single_tab_snapshot(PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![
(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/path/to/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
),
(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: false,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id: None,
settings: OpenWarpDriveObjectSettings::default(),
}),
}),
),
(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: false,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/some/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
),
],
}));
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert_eq!(
template.windows[0].tabs[0].layout,
PaneTemplateType::PaneBranchTemplate {
split_direction: SplitDirection::Vertical.into(),
panes: vec![
PaneTemplateType::PaneTemplate {
is_focused: Some(true),
cwd: PathBuf::from("/path/to/dir"),
commands: vec![],
pane_mode: PaneMode::Terminal,
shell: None,
},
PaneTemplateType::PaneTemplate {
is_focused: Some(false),
cwd: PathBuf::from("/some/dir"),
commands: vec![],
pane_mode: PaneMode::Terminal,
shell: None,
},
]
}
)
}
#[test]
fn test_config_from_snapshot_filters_tabs() {
// If no panes of a tab are valid, it's filtered out entirely.
let state = single_tab_snapshot(PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id: None,
settings: OpenWarpDriveObjectSettings::default(),
}),
}),
)],
}));
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert!(template.windows[0].tabs.is_empty())
}
#[test]
fn test_config_with_active_tab_index() {
let state = multi_tab_snapshot(
1,
vec![
TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root: PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/path/to/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
)],
}),
left_panel: None,
right_panel: None
};
3
],
);
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert_eq!(template.windows[0].active_tab_index, Some(1))
}
#[test]
fn test_config_with_active_tab_index_and_filtered_tabs() {
let state = multi_tab_snapshot(
1,
vec![
TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root: PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id: None,
settings: OpenWarpDriveObjectSettings::default(),
}),
}),
)],
}),
left_panel: None,
right_panel: None,
},
TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root: PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/path/to/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
)],
}),
left_panel: None,
right_panel: None,
},
],
);
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert_eq!(template.windows[0].active_tab_index, Some(0))
}
#[test]
fn test_config_with_active_tab_being_filtered() {
let state = multi_tab_snapshot(
1,
vec![
TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root: PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![],
cwd: Some("/path/to/dir".into()),
is_active: true,
is_read_only: false,
shell_launch_data: None,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
)],
}),
left_panel: None,
right_panel: None,
},
TabSnapshot {
custom_title: None,
default_directory_color: None,
selected_color: SelectedTabColor::default(),
root: PaneNodeSnapshot::Branch(BranchSnapshot {
direction: SplitDirection::Vertical,
children: vec![(
PaneFlex(1.),
PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id: None,
settings: OpenWarpDriveObjectSettings::default(),
}),
}),
)],
}),
left_panel: None,
right_panel: None,
},
],
);
let template = LaunchConfig::from_snapshot("Test".into(), &state);
assert_eq!(template.windows[0].active_tab_index, None)
}
+8
View File
@@ -0,0 +1,8 @@
pub mod launch_config;
pub mod save_modal;
use warpui::AppContext;
pub fn init(app: &mut AppContext) {
save_modal::init(app);
}
+697
View File
@@ -0,0 +1,697 @@
use crate::app_state::{get_app_state, AppState};
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
};
use crate::launch_configs::launch_config::LaunchConfig;
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::TelemetryEvent;
use crate::user_config::launch_configs_dir;
#[cfg(feature = "local_fs")]
use crate::user_config::{util::file_name_to_human_readable_name, WarpConfig};
use crate::util::bindings::keybinding_name_to_display_string;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::FileTarget;
use markdown_parser::{
FormattedText, FormattedTextFragment, FormattedTextInline, FormattedTextLine,
};
use pathfinder_geometry::vector::vec2f;
use serde::{Deserialize, Serialize};
use warp_core::paths::home_relative_path;
use warp_core::ui::theme::Fill;
use warpui::accessibility::{AccessibilityContent, WarpA11yRole};
use warpui::elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
Element, Empty, Flex, FormattedTextElement, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, SavePosition, Shrinkable, Stack, Text,
};
use warpui::keymap::FixedBinding;
use warpui::ui_components::button::{Button, ButtonVariant};
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
const MODAL_WIDTH: f32 = 660.;
const SIDE_PADDING: f32 = 16.;
const BUTTON_SIZE: f32 = 24.;
const DOC_LINK_WIDTH: f32 = 120.;
const SAVE_CONFIG_BUTTON_LABEL: &str = "Save Configuration";
const OPEN_FILE_BUTTON_LABEL: &str = "Open YAML File";
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
ActionRequest::Action(LaunchConfigSaveAction::Close),
id!(LaunchConfigSaveModal::ui_name()),
)]);
app.register_fixed_bindings([FixedBinding::new(
"enter",
ActionRequest::Enter,
id!(LaunchConfigSaveModal::ui_name()),
)]);
}
/// A model for tracking the current source of the snapshot taken of the window configuration
///
/// This is necessary due to a quirk in how the UI Framework handles event handlers / callbacks:
///
/// In order to work around Rusts restriction on having two mutable references to the same data,
/// the framework _removes_ a view from the map of all views before calling a handler (it then
/// immediately re-inserts it into the map afterwards). This means then when a handler is being
/// executed in a given View, that View is _not_ in the global map. This means when taking a
/// snapshot of the current application state (window configuration) the view which calls
/// `get_app_state` will not be included in the snapshot (since it's not in the global map).
///
/// Instead, we create a small Model to cache the snapshot source information (window and view id)
/// and subscribe to any changes to that model from here. Then the model update handler is
/// scheduled after the event handler callback completes. This means that the model update handler is
/// called on the Save Modal directly, rather than the Workspace. This is safe because of two reasons.
/// First, the Save Modal cannot be launched from the Save Modal. Second, the Save Modal doesn't need
/// need to be included in the snapshot of the application state (window configuration).
#[derive(PartialEq)]
enum SnapshotTrigger {
None,
StartSnapshot,
}
impl Entity for SnapshotTrigger {
type Event = ();
}
#[derive(Default)]
struct SaveModalMouseStates {
close_button_state: MouseStateHandle,
documentation_link_state: MouseStateHandle,
save_button_state: MouseStateHandle,
open_file_button_state: MouseStateHandle,
}
/// View that shows up when a user expresses the intent to save their current
/// app state as a launch config.
pub struct LaunchConfigSaveModal {
editor: ViewHandle<EditorView>,
mouse_states: SaveModalMouseStates,
current_app_state: Option<AppState>,
snapshot_source: ModelHandle<SnapshotTrigger>,
save_state: SaveState,
file_name: Option<String>,
open_modal_keybinding_str: String,
}
/// Keeps track of the current lifecycle state of the modal
/// NotSaved => Success(file_name)
/// NotSaved => Failure, ideally this shouldn't happen
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum SaveState {
/// Succeeds to save (String arg is file_name)
Success,
/// Fails to save
Failure(FailureType),
/// Not yet saved
NotSaved,
}
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Copy)]
pub enum FailureType {
FileAlreadyExists,
Other,
}
#[derive(Debug, PartialEq, Clone)]
pub enum ActionRequest {
Action(LaunchConfigSaveAction),
Enter,
}
#[derive(Debug, PartialEq, Clone)]
pub enum LaunchConfigSaveAction {
Close,
Save,
OpenFile,
}
impl LaunchConfigSaveAction {
pub fn from_state(save_state: &SaveState) -> Self {
match save_state {
SaveState::Success => Self::OpenFile,
SaveState::NotSaved => Self::Save,
SaveState::Failure(_) => Self::Close,
}
}
}
#[derive(PartialEq, Eq)]
pub enum LaunchConfigModalEvent {
/// Users who have just created a launch config want to verify that the launch config is saved
/// and test opening the config. When they don't see it, they assume there is a bug.
/// SuccessfullySavedConfig event addresses it.
/// It's called when the new config was just saved. Note that when we save the configuration,
/// it take a moment for the file system to register the change, and us to receive it (as there
/// is a delay in our watcher). But because we actually have the LaunchConfig in our hands
/// already, we may as well save it "manually" to the WarpConfig, while waiting for the update
/// from the file system. This event passes a saved config to the handler to let us do that.
SuccessfullySavedConfig(LaunchConfig),
#[cfg(feature = "local_fs")]
OpenFileWithTarget {
path: std::path::PathBuf,
target: FileTarget,
line_col: Option<warp_util::path::LineAndColumnArg>,
},
Close,
}
impl Entity for LaunchConfigSaveModal {
type Event = LaunchConfigModalEvent;
}
impl LaunchConfigSaveModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let editor = {
let options = SingleLineEditorOptions {
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
ctx.add_typed_action_view(|ctx| EditorView::single_line(options, ctx))
};
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let snapshot_source = ctx.add_model(|_| SnapshotTrigger::None);
ctx.observe(
&snapshot_source,
LaunchConfigSaveModal::on_snapshot_source_change,
);
LaunchConfigSaveModal {
editor,
mouse_states: Default::default(),
current_app_state: Default::default(),
snapshot_source,
save_state: SaveState::NotSaved,
file_name: None,
open_modal_keybinding_str: keybinding_name_to_display_string(
"workspace:toggle_launch_config_palette",
ctx,
)
.unwrap_or_default(),
}
}
pub fn set_snapshot_source(&mut self, ctx: &mut ViewContext<Self>) {
self.snapshot_source.update(ctx, |snapshot_source, ctx| {
*snapshot_source = SnapshotTrigger::StartSnapshot;
ctx.notify();
})
}
fn on_snapshot_source_change(
&mut self,
source: ModelHandle<SnapshotTrigger>,
ctx: &mut ViewContext<Self>,
) {
if *source.as_ref(ctx) == SnapshotTrigger::None {
return;
}
let app_state = get_app_state(ctx);
self.set_current_app_state(app_state);
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
self.handle_action(&ActionRequest::Enter, ctx);
}
EditorEvent::Escape => {
self.handle_action(&ActionRequest::Action(LaunchConfigSaveAction::Close), ctx)
}
_ => ctx.notify(),
}
}
/// Open the saved file if the modal is in the correct state
#[cfg(feature = "local_fs")]
fn open_file(&self, ctx: &mut ViewContext<Self>) {
use crate::util::file::external_editor::EditorSettings;
use crate::util::openable_file_type::resolve_file_target;
if let SaveState::Success = &self.save_state {
if let Some(file_name) = &self.file_name {
let file_path = launch_configs_dir().join(file_name);
// Resolve target and emit event - workspace will handle all cases
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target(&file_path, settings, None);
ctx.emit(LaunchConfigModalEvent::OpenFileWithTarget {
path: file_path,
target,
line_col: None,
});
send_telemetry_from_ctx!(TelemetryEvent::OpenLaunchConfigFile, ctx);
}
}
}
#[cfg(feature = "local_fs")]
fn try_save_launch_config(&mut self, ctx: &mut ViewContext<Self>) {
let file_name_candidate = self.editor.as_ref(ctx).buffer_text(ctx);
let launch_config_name = file_name_to_human_readable_name(&file_name_candidate);
if let Some(app_state) = &self.current_app_state {
let launch_config = LaunchConfig::from_snapshot(launch_config_name, app_state);
match WarpConfig::save_new_launch_config(file_name_candidate, launch_config.clone()) {
Ok(file_name) => {
self.saved_successfully(file_name, ctx);
ctx.emit(LaunchConfigModalEvent::SuccessfullySavedConfig(
launch_config,
));
}
Err(e) => {
log::warn!("Failed to save current session as template. error: {e}");
if e.to_string().contains("File already exists") {
self.failed_save(FailureType::FileAlreadyExists, ctx);
} else {
self.failed_save(FailureType::Other, ctx);
}
}
}
} else {
self.failed_save(FailureType::Other, ctx);
}
ctx.notify();
}
/// Clears the editor
pub fn reset_editor(&mut self, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.set_placeholder_text("launch_config.yaml", ctx);
});
}
/// Closes the modal
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.reset_editor(ctx); // clears editor and resets placeholder
self.current_app_state = None;
self.save_state = SaveState::NotSaved;
ctx.notify();
ctx.emit(LaunchConfigModalEvent::Close);
}
/// Sets the app state to get all the sessions to put in the yaml on save
fn set_current_app_state(&mut self, app_state: AppState) {
self.current_app_state = Some(app_state);
}
fn save_modal_button(
&self,
appearance: &Appearance,
button_text: String,
mouse_state: MouseStateHandle,
disabled: bool,
) -> Button {
let button = appearance
.ui_builder()
.button(ButtonVariant::Accent, mouse_state)
.with_centered_text_label(button_text)
.with_style(UiComponentStyles {
padding: Some(Coords {
top: 10.,
bottom: 10.,
left: 20.,
right: 20.,
}),
..Default::default()
});
if disabled {
button.disabled()
} else {
button
}
}
fn render_save_config_button(
&self,
appearance: &Appearance,
disabled: bool,
) -> Box<dyn Element> {
self.save_modal_button(
appearance,
SAVE_CONFIG_BUTTON_LABEL.to_owned(),
self.mouse_states.save_button_state.clone(),
disabled,
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ActionRequest::Action(LaunchConfigSaveAction::Save));
})
.finish()
}
fn render_open_file_button(&self, appearance: &Appearance) -> Box<dyn Element> {
self.save_modal_button(
appearance,
OPEN_FILE_BUTTON_LABEL.to_owned(),
self.mouse_states.open_file_button_state.clone(),
false,
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ActionRequest::Action(LaunchConfigSaveAction::OpenFile));
})
.finish()
}
/// Helper function to add padding to a button
fn add_button_padding(container: Container) -> Container {
container
.with_padding_top(20.)
.with_padding_bottom(20.)
.with_padding_left(SIDE_PADDING)
.with_padding_right(SIDE_PADDING)
}
/// Renders the button based on the state the modal is in
fn render_button_row(
&self,
appearance: &Appearance,
save_config_disabled: bool,
) -> Box<dyn Element> {
match LaunchConfigSaveAction::from_state(&self.save_state) {
LaunchConfigSaveAction::OpenFile => {
Self::add_button_padding(Container::new(self.render_open_file_button(appearance)))
.finish()
}
LaunchConfigSaveAction::Save => Self::add_button_padding(Container::new(
self.render_save_config_button(appearance, save_config_disabled),
))
.finish(),
LaunchConfigSaveAction::Close => Empty::new().finish(),
}
}
/// Renders a generic text block in a span
fn render_text_block(&self, appearance: &Appearance, text: String) -> Container {
Container::new(
appearance
.ui_builder()
.span(text)
.with_soft_wrap()
.build()
.finish(),
)
.with_padding_left(SIDE_PADDING)
.with_padding_right(SIDE_PADDING)
}
fn render_formatted_text_line(
&self,
appearance: &Appearance,
formatted_text_fragments: FormattedTextInline,
) -> Container {
let formatted_text =
FormattedText::new([FormattedTextLine::Line(formatted_text_fragments)]);
Container::new(
FormattedTextElement::new(
formatted_text,
appearance.ui_font_size(),
appearance.ui_font_family(),
appearance.monospace_font_family(),
appearance.theme().active_ui_text_color().into(),
Default::default(),
)
.finish(),
)
.with_padding_left(SIDE_PADDING)
.with_padding_right(SIDE_PADDING)
}
/// Renders the title of the modal
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let header = Flex::row()
.with_child(
Shrinkable::new(
1.0,
Align::new(
Text::new_inline(
"Save Current Configuration",
appearance.header_font_family(),
appearance.header_font_size(),
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.left()
.finish(),
)
.finish(),
)
.with_child(
appearance
.ui_builder()
.close_button(BUTTON_SIZE, self.mouse_states.close_button_state.clone())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ActionRequest::Action(
LaunchConfigSaveAction::Close,
))
})
.finish(),
)
.finish();
Container::new(header)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.with_padding_left(SIDE_PADDING)
.with_padding_right(SIDE_PADDING)
.with_padding_top(SIDE_PADDING)
.with_padding_bottom(8.)
.finish()
}
pub fn saved_successfully(&mut self, file_name: String, ctx: &mut ViewContext<Self>) {
self.set_save_state(SaveState::Success, Some(file_name));
send_telemetry_from_ctx!(
TelemetryEvent::SaveLaunchConfig {
state: SaveState::Success,
},
ctx
);
ctx.notify();
}
pub fn failed_save(&mut self, failure_type: FailureType, ctx: &mut ViewContext<Self>) {
self.set_save_state(SaveState::Failure(failure_type), None);
send_telemetry_from_ctx!(
TelemetryEvent::SaveLaunchConfig {
state: SaveState::Failure(failure_type)
},
ctx
);
ctx.notify();
}
/// Renders the editor portion of the modal
fn render_editor(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let editor = self.editor.as_ref(app);
let height = editor.line_height(app.font_cache(), appearance);
let editor =
ConstrainedBox::new(Clipped::new(ChildView::new(&self.editor).finish()).finish())
.with_height(height)
.finish();
Container::new(editor)
.with_uniform_padding(SIDE_PADDING)
// TODO theme should be agnostic of different UI elements / features
.with_background(appearance.theme().background())
.finish()
}
/// Renders the entire modal
fn render_modal(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
// Title of modal
let header = Flex::column().with_child(
ConstrainedBox::new(self.render_header(appearance))
.with_max_height(60.)
.finish(),
);
let link_to_docs = Container::new(
ConstrainedBox::new(
appearance
.ui_builder()
.link(
"Link to Documentation".to_string(),
Some(
"https://docs.warp.dev/terminal/sessions/launch-configurations"
.to_string(),
),
None,
self.mouse_states.documentation_link_state.clone(),
)
.soft_wrap(false)
.build()
.finish(),
)
.with_width(DOC_LINK_WIDTH)
.finish(),
)
.with_uniform_padding(SIDE_PADDING)
.finish();
let info = match &self.save_state {
SaveState::Success => header
.with_child(
self.render_formatted_text_line(appearance, vec![
FormattedTextFragment::plain_text("Saved successfully to "),
FormattedTextFragment::inline_code(self.file_name.clone().unwrap_or_default()),
FormattedTextFragment::plain_text(".")
])
.with_padding_bottom(24.)
.finish(),
)
.with_child(link_to_docs),
SaveState::Failure(failure_type) => header.with_child(
self.render_text_block(
appearance,
match failure_type {
FailureType::FileAlreadyExists => {
"Failed to save. A launch configuration with the same name already exists.".to_string()
}
FailureType::Other => "An issue was encountered while saving.".to_string(),
},
)
.with_padding_bottom(24.)
.finish(),
),
SaveState::NotSaved => {
let mut text = "This will save your current configuration of windows, tabs \
and panes to a file so you can easily open it again".to_string();
if self.open_modal_keybinding_str.is_empty() {
text.push('.');
} else {
text.push_str(&format!(" with {}.", self.open_modal_keybinding_str));
}
header
.with_child(
self.render_formatted_text_line(appearance, vec![
FormattedTextFragment::plain_text(text)
]).finish()
)
.with_child(
self.render_formatted_text_line(appearance, vec![
FormattedTextFragment::plain_text("\nThe YAML file is saved to "),
FormattedTextFragment::inline_code(home_relative_path(&launch_configs_dir())),
FormattedTextFragment::plain_text("."),
])
.with_padding_bottom(24.)
.finish(),
)
.with_child(link_to_docs)
.with_child(self.render_editor(app))
}
};
ConstrainedBox::new(
Container::new(
info.with_child(
self.render_button_row(appearance, self.editor.as_ref(app).is_empty(app)),
)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
.with_uniform_margin(35.)
.with_background(appearance.theme().surface_2())
.finish(),
)
.with_width(MODAL_WIDTH)
.finish()
}
fn set_save_state(&mut self, save_state: SaveState, file_name: Option<String>) {
self.save_state = save_state;
if let Some(file_name) = file_name {
self.file_name = Some(file_name);
}
}
}
impl View for LaunchConfigSaveModal {
fn ui_name() -> &'static str {
"LaunchConfigSaveModal"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.editor);
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
// renders the modal in a stack
let mut stack = Stack::new();
stack.add_positioned_child(
SavePosition::new(self.render_modal(app), "save_modal:modal").finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
Container::new(Align::new(stack.finish()).finish())
.with_background_color(Fill::blur().into())
.with_corner_radius(app.windows().window_corner_radius())
.finish()
}
fn accessibility_contents(&self, _ctx: &AppContext) -> Option<AccessibilityContent> {
Some(AccessibilityContent::new(
"Save Config Modal",
"Type the name of the file to which you want to save your
current configuration of windows, tabs, and panes. Use enter to save the
launch configuration, esc to quit the save configuration modal.",
WarpA11yRole::PopoverRole,
))
}
}
impl TypedActionView for LaunchConfigSaveModal {
type Action = ActionRequest;
fn handle_action(&mut self, action: &ActionRequest, ctx: &mut ViewContext<Self>) {
// TODO(vorporeal): We should figure out a better way to handle the
// interactions with the filesystem here, whether it's compiling out
// the save modal more completely or doing something else. Perhaps
// this will become moot when we put launch configs in Warp Drive.
let action = match action {
ActionRequest::Action(action) => action.clone(),
ActionRequest::Enter => LaunchConfigSaveAction::from_state(&self.save_state),
};
match action {
LaunchConfigSaveAction::Close => self.close(ctx),
LaunchConfigSaveAction::Save =>
{
#[cfg(feature = "local_fs")]
if !self.editor.as_ref(ctx).is_empty(ctx) {
self.try_save_launch_config(ctx);
}
}
LaunchConfigSaveAction::OpenFile => {
#[cfg(feature = "local_fs")]
self.open_file(ctx);
self.close(ctx);
}
}
}
}