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
@@ -0,0 +1,415 @@
use std::{cell::RefCell, collections::HashMap};
use settings::{Setting, ToggleableSetting};
use warp_core::features::FeatureFlag;
use warpui::{
elements::{Flex, MouseStateHandle, ParentElement},
ui_components::{components::UiComponent, switch::SwitchStateHandle},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings_view::settings_page::{
render_body_item, render_dropdown_item, AdditionalInfo, LocalOnlyIconState, ToggleState,
},
util::file::external_editor::{
settings::{
EditorChoice, EditorLayout, OpenCodePanelsFileEditor, OpenFileEditor, OpenFileLayout,
PreferMarkdownViewer, PreferTabbedEditorView,
},
EditorSettings, SUPPORTED_EDITORS,
},
view_components::{Dropdown, DropdownItem},
};
const TABBED_FILE_VIEWER_TOGGLE_HEADER: &str = "Group files into single editor pane";
const TABBED_FILE_VIEWER_TOGGLE_DESCRIPTION: &str = "When this setting is on, any files opened in the same tab will be automatically grouped into a single editor pane.";
#[derive(Debug, Clone)]
pub enum ExternalEditorAction {
SetEditor(EditorChoice),
SetCodePanelsEditor(EditorChoice),
SetLayout(EditorLayout),
TogglePreferMarkdownViewer,
ToggleTabbedEditorView,
OpenUrl(String),
}
pub struct ExternalEditorView {
editor_dropdown: ViewHandle<Dropdown<ExternalEditorAction>>,
code_panels_editor_dropdown: ViewHandle<Dropdown<ExternalEditorAction>>,
layout_dropdown: ViewHandle<Dropdown<ExternalEditorAction>>,
tabbed_editor_view_mouse_state: SwitchStateHandle,
prefer_markdown_viewer_switch: SwitchStateHandle,
markdown_viewer_mouse_state: MouseStateHandle,
local_only_icon_states: RefCell<HashMap<String, MouseStateHandle>>,
}
impl ExternalEditorView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let settings = EditorSettings::handle(ctx);
let editor_to_open_files = *settings.as_ref(ctx).open_file_editor;
let code_panels_editor_to_open_files = *settings.as_ref(ctx).open_code_panels_file_editor;
let layout_to_open_files = *settings.as_ref(ctx).open_file_layout;
let editor_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
Self::init_editor_dropdown(
&editor_to_open_files,
&mut dropdown,
ExternalEditorAction::SetEditor,
ctx,
);
dropdown
});
let code_panels_editor_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
Self::init_editor_dropdown(
&code_panels_editor_to_open_files,
&mut dropdown,
ExternalEditorAction::SetCodePanelsEditor,
ctx,
);
dropdown
});
let layout_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
Self::init_layout_dropdown(&layout_to_open_files, &mut dropdown, ctx);
dropdown
});
ctx.subscribe_to_model(
&EditorSettings::handle(ctx),
|me, editor_settings, _, ctx| {
me.editor_dropdown.update(ctx, |dropdown, ctx| {
let editor = *editor_settings.as_ref(ctx).open_file_editor;
Self::init_editor_dropdown(
&editor,
dropdown,
ExternalEditorAction::SetEditor,
ctx,
);
});
me.code_panels_editor_dropdown.update(ctx, |dropdown, ctx| {
let editor = *editor_settings.as_ref(ctx).open_code_panels_file_editor;
Self::init_editor_dropdown(
&editor,
dropdown,
ExternalEditorAction::SetCodePanelsEditor,
ctx,
);
});
ctx.notify()
},
);
Self {
editor_dropdown,
code_panels_editor_dropdown,
layout_dropdown,
tabbed_editor_view_mouse_state: Default::default(),
prefer_markdown_viewer_switch: Default::default(),
markdown_viewer_mouse_state: Default::default(),
local_only_icon_states: Default::default(),
}
}
fn init_layout_dropdown(
layout_to_open_files: &EditorLayout,
dropdown: &mut Dropdown<ExternalEditorAction>,
ctx: &mut ViewContext<Dropdown<ExternalEditorAction>>,
) {
let default_option_text = "Split Pane";
let default_app = DropdownItem::new(
default_option_text,
ExternalEditorAction::SetLayout(EditorLayout::SplitPane),
);
let mut items = vec![default_app];
items.push(DropdownItem::new(
"New Tab",
ExternalEditorAction::SetLayout(EditorLayout::NewTab),
));
dropdown.set_items(items, ctx);
match layout_to_open_files {
EditorLayout::SplitPane => dropdown.set_selected_by_name(default_option_text, ctx),
EditorLayout::NewTab => dropdown.set_selected_by_name("New Tab", ctx),
};
}
fn init_editor_dropdown(
editor_to_open_files: &EditorChoice,
dropdown: &mut Dropdown<ExternalEditorAction>,
mut make_action: impl FnMut(EditorChoice) -> ExternalEditorAction,
ctx: &mut ViewContext<Dropdown<ExternalEditorAction>>,
) {
let default_option_text = "Default App";
let default_app = DropdownItem::new(
default_option_text,
make_action(EditorChoice::SystemDefault),
);
let mut items = vec![default_app];
items.push(DropdownItem::new("Warp", make_action(EditorChoice::Warp)));
if FeatureFlag::AllowOpeningFileLinksUsingEditorEnv.is_enabled() {
items.push(DropdownItem::new(
"$EDITOR",
make_action(EditorChoice::EnvEditor),
));
}
for editor in SUPPORTED_EDITORS {
if editor.is_installed(ctx) {
let editor_name = format!("{editor}");
items.push(DropdownItem::new(
editor_name,
make_action(EditorChoice::ExternalEditor(*editor)),
));
}
}
dropdown.set_items(items, ctx);
match editor_to_open_files {
EditorChoice::ExternalEditor(editor) => {
dropdown.set_selected_by_name(format!("{editor}"), ctx)
}
EditorChoice::Warp => dropdown.set_selected_by_name("Warp", ctx),
EditorChoice::EnvEditor => dropdown.set_selected_by_name("$EDITOR", ctx),
EditorChoice::SystemDefault => dropdown.set_selected_by_name(default_option_text, ctx),
};
}
/// Handles [`ExternalEditorAction::SetEditor`] by updating the external editor settings.
fn set_editor(&mut self, editor: &EditorChoice, ctx: &mut ViewContext<Self>) {
EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.open_file_editor.set_value(*editor, ctx));
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "SetEditor".to_string(),
value: format!("{editor:?}")
},
ctx
);
}
fn set_code_panels_editor(&mut self, editor: &EditorChoice, ctx: &mut ViewContext<Self>) {
EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.open_code_panels_file_editor
.set_value(*editor, ctx));
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "SetCodePanelsEditor".to_string(),
value: format!("{editor:?}")
},
ctx
);
}
// Handles [`ExternalEditorAction::SetLayout`] by updating the external editor layout settings.
fn set_layout(&mut self, layout: &EditorLayout, ctx: &mut ViewContext<Self>) {
EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.open_file_layout.set_value(*layout, ctx));
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "SetLayout".to_string(),
value: format!("{layout:?}")
},
ctx
);
}
/// Handles [`ExternalEditorAction::TogglePreferMarkdownViewer`]
/// preference.
fn toggle_prefer_markdown_viewer(&mut self, ctx: &mut ViewContext<Self>) {
let new_value = EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = settings.prefer_markdown_viewer.toggle_and_save_value(ctx);
report_if_error!(new_value);
new_value.unwrap_or(PreferMarkdownViewer::default_value())
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "TogglePreferMarkdownViewer".to_string(),
value: new_value.to_string()
},
ctx
);
}
/// Handles [`ExternalEditorAction::TogglePreferTabbedEditorView`] by updating the tabbed file viewer preference.
fn toggle_prefer_tabbed_editor_view(&mut self, ctx: &mut ViewContext<Self>) {
let new_value = EditorSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = settings
.prefer_tabbed_editor_view
.toggle_and_save_value(ctx);
report_if_error!(new_value);
new_value.unwrap_or(PreferTabbedEditorView::default_value())
});
send_telemetry_from_ctx!(
TelemetryEvent::FeaturesPageAction {
action: "ToggleTabbedEditorView".to_string(),
value: new_value.to_string()
},
ctx
);
}
}
impl Entity for ExternalEditorView {
type Event = ();
}
impl View for ExternalEditorView {
fn ui_name() -> &'static str {
"ExternalEditorView"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let default_editor = render_dropdown_item(
appearance,
"Choose an editor to open file links",
None,
None,
LocalOnlyIconState::for_setting(
OpenFileEditor::storage_key(),
OpenFileEditor::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
None,
&self.editor_dropdown,
);
let code_panels_editor = render_dropdown_item(
appearance,
"Choose an editor to open files from the code review panel, project explorer, and global search",
None,
None,
LocalOnlyIconState::for_setting(
OpenCodePanelsFileEditor::storage_key(),
OpenCodePanelsFileEditor::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
None,
&self.code_panels_editor_dropdown,
);
let default_layout = render_dropdown_item(
appearance,
"Choose a layout to open files in Warp",
None,
None,
LocalOnlyIconState::for_setting(
OpenFileLayout::storage_key(),
OpenFileLayout::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
None,
&self.layout_dropdown,
);
let mut column = Flex::column()
.with_child(default_editor)
.with_child(code_panels_editor)
.with_child(default_layout);
if FeatureFlag::TabbedEditorView.is_enabled() {
column.add_child(render_body_item::<ExternalEditorAction>(
TABBED_FILE_VIEWER_TOGGLE_HEADER.into(),
None,
LocalOnlyIconState::for_setting(
PreferTabbedEditorView::storage_key(),
PreferTabbedEditorView::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.tabbed_editor_view_mouse_state.clone())
.check(
*EditorSettings::as_ref(app)
.prefer_tabbed_editor_view
.value(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ExternalEditorAction::ToggleTabbedEditorView);
})
.finish(),
Some(TABBED_FILE_VIEWER_TOGGLE_DESCRIPTION.into()),
));
}
column.add_child(render_body_item::<ExternalEditorAction>(
"Open Markdown files in Warp's Markdown Viewer by default".to_string(),
Some(AdditionalInfo {
mouse_state: self.markdown_viewer_mouse_state.clone(),
on_click_action: Some(ExternalEditorAction::OpenUrl(
"https://docs.warp.dev/terminal/more-features/markdown-viewer".to_string(),
)),
secondary_text: None,
tooltip_override_text: None,
}),
LocalOnlyIconState::for_setting(
PreferMarkdownViewer::storage_key(),
PreferMarkdownViewer::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.prefer_markdown_viewer_switch.clone())
.check(*EditorSettings::as_ref(app).prefer_markdown_viewer.value())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ExternalEditorAction::TogglePreferMarkdownViewer);
})
.finish(),
None,
));
column.finish()
}
}
impl TypedActionView for ExternalEditorView {
type Action = ExternalEditorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ExternalEditorAction::SetEditor(editor) => self.set_editor(editor, ctx),
ExternalEditorAction::SetCodePanelsEditor(editor) => {
self.set_code_panels_editor(editor, ctx)
}
ExternalEditorAction::SetLayout(layout) => self.set_layout(layout, ctx),
ExternalEditorAction::TogglePreferMarkdownViewer => {
self.toggle_prefer_markdown_viewer(ctx)
}
ExternalEditorAction::ToggleTabbedEditorView => {
self.toggle_prefer_tabbed_editor_view(ctx);
}
ExternalEditorAction::OpenUrl(url) => {
ctx.open_url(url.as_str());
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
pub mod undo_close;
pub use undo_close::UndoCloseView;
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
pub mod external_editor;
pub use external_editor::ExternalEditorView;
}
}
cfg_if::cfg_if! {
if #[cfg(feature = "local_tty")] {
pub mod startup_shell;
pub use startup_shell::StartupShellView;
pub mod working_directory;
pub use working_directory::WorkingDirectoryView;
}
}
@@ -0,0 +1,274 @@
use warpui::{
elements::{CrossAxisAlignment, Fill, Flex, ParentElement, Shrinkable},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{EditorView, Event, SingleLineEditorOptions, TextOptions},
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
terminal::{
available_shells::{AvailableShell, AvailableShells},
local_tty::shell::is_valid_path_or_command_for_supported_shell,
session_settings::{SessionSettings, SessionSettingsChangedEvent},
},
view_components::{dropdown::TOP_MENU_BAR_HEIGHT, Dropdown, DropdownItem},
};
/// A view for configuring the initial shell for new sessions. This can be the
/// user's login shell, the default installed version of zsh, bash, or fish,
/// or an arbitrary user-provided path.
pub struct StartupShellView {
/// This dropdown is for selecting between the login shell, supported shells,
/// and a custom shell.
shell_dropdown: ViewHandle<Dropdown<NewSessionShellAction>>,
/// This flags whether or not to show the custom path editor. It's toggled
/// when the user chooses different dropdown options.
should_display_editor: bool,
/// If the user chose a custom shell path, they enter it in this editor.
custom_path_editor: ViewHandle<EditorView>,
/// This holds the current validity of the user's custom shell path, for
/// drawing an error border if it's invalid.
is_custom_path_valid: bool,
}
#[derive(Debug, Clone)]
pub enum NewSessionShellAction {
/// Changes the user's startup shell to the given option. This also hides
/// the custom shell path editor if a non-custom shell was chosen.
Set(AvailableShell),
/// Displays the custom shell path editor.
ShowCustomPathInput,
}
impl NewSessionShellAction {
/// Produces a [`TelemetryEvent`] that corresponds to this UI action.
///
/// This tracks both high-level information about which shells users select
/// and when they switch to the custom path UI (so we can see if they're
/// trying to use a custom shell but are unable to).
fn telemetry_event(&self) -> TelemetryEvent {
match self {
NewSessionShellAction::Set(option) => TelemetryEvent::FeaturesPageAction {
action: "NewSessionShellOverride".to_string(),
value: option.telemetry_value(),
},
NewSessionShellAction::ShowCustomPathInput => TelemetryEvent::FeaturesPageAction {
action: "ShowCustomPathInput".to_string(),
value: String::new(),
},
}
}
}
impl StartupShellView {
/// Creates a new `StartupShellView`. The UI is initialized with the user's
/// current startup shell setting.
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let custom_shell_text = AvailableShells::handle(ctx).read(ctx, |shells, ctx| {
shells.get_user_preferred_shell(ctx).get_custom_path()
});
ctx.subscribe_to_model(&SessionSettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
SessionSettingsChangedEvent::StartupShellOverride { .. }
) {
Self::update_dropdown_state(me.shell_dropdown.clone(), ctx);
me.maybe_update_editor_state(ctx);
}
ctx.notify()
});
let shell_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
dropdown.set_top_bar_max_width(200.);
dropdown
});
Self::update_dropdown_state(shell_dropdown.clone(), ctx);
let shell_editor = ctx.add_typed_action_view(|ctx| {
let appearance_handle = Appearance::handle(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_font_size(appearance_handle.as_ref(ctx)),
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("Executable path", ctx);
if let Some(shell) = custom_shell_text.as_ref() {
editor.set_buffer_text(shell, ctx);
}
editor
});
ctx.subscribe_to_view(&shell_editor, move |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
Self {
shell_dropdown,
custom_path_editor: shell_editor,
is_custom_path_valid: true,
should_display_editor: custom_shell_text.is_some(),
}
}
fn maybe_update_editor_state(&mut self, ctx: &mut ViewContext<Self>) {
let custom_shell_path = AvailableShells::handle(ctx).read(ctx, |shells, ctx| {
shells.get_user_preferred_shell(ctx).get_custom_path()
});
if let Some(custom_shell_path) = custom_shell_path {
self.should_display_editor = true;
self.custom_path_editor.update(ctx, |editor_view, ctx| {
editor_view.set_buffer_text(&custom_shell_path, ctx);
});
}
}
fn update_dropdown_state(
dropdown: ViewHandle<Dropdown<NewSessionShellAction>>,
ctx: &mut ViewContext<Self>,
) {
dropdown.update(ctx, |dropdown, ctx| {
let mut items = vec![DropdownItem::new(
"Default",
NewSessionShellAction::Set(AvailableShell::default()),
)];
let shell_to_index = AvailableShells::handle(ctx).read(ctx, |model, _| {
let mut shell_to_index = std::collections::HashMap::new();
// Iterate over each shell in the model and add it to the dropdown if it's valid.
for shell_entry in model.get_available_shells() {
items.push(DropdownItem::new(
model.display_name_for_shell(shell_entry),
NewSessionShellAction::Set(shell_entry.clone()),
));
shell_to_index.insert(shell_entry.clone(), items.len() - 1);
}
shell_to_index
});
items.push(DropdownItem::new(
"Custom",
NewSessionShellAction::ShowCustomPathInput,
));
let custom_index = items.len() - 1;
dropdown.set_items(items, ctx);
let selected_shell = AvailableShells::as_ref(ctx).get_user_preferred_shell(ctx);
let selected_index = if selected_shell.get_custom_path().is_some() {
custom_index
} else {
shell_to_index.get(&selected_shell).copied().unwrap_or(0)
};
dropdown.set_selected_by_index(selected_index, ctx);
});
}
/// This callback updates the startup shell override setting based on user
/// input. If the user hits Enter or the input loses focus, the new setting
/// is saved (they can also save it by clicking outside of the text field).
fn handle_editor_event(&mut self, event: &Event, ctx: &mut ViewContext<Self>) {
match event {
Event::Edited(_) => {
let buffer_text = self.custom_path_editor.as_ref(ctx).buffer_text(ctx);
let new_validity = is_valid_path_or_command_for_supported_shell(&buffer_text);
if new_validity != self.is_custom_path_valid {
self.is_custom_path_valid = new_validity;
ctx.notify();
}
}
Event::Blurred | Event::Enter => {
let buffer_text = self.custom_path_editor.as_ref(ctx).buffer_text(ctx);
if let Ok(shell) = AvailableShell::try_from(buffer_text.as_str()) {
self.handle_action(&NewSessionShellAction::Set(shell), ctx);
}
}
_ => (),
}
}
}
impl Entity for StartupShellView {
type Event = ();
}
impl View for StartupShellView {
fn ui_name() -> &'static str {
"StartupShellView"
}
/// Renders controls to change the default shell for new sessions.
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let theme = appearance.theme();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(ChildView::new(&self.shell_dropdown).finish());
if self.should_display_editor {
let border_color: Option<Fill> = if self.is_custom_path_valid {
None
} else {
Some(crate::themes::theme::Fill::error().into())
};
row.add_child(
Shrinkable::new(
1.,
ui_builder
.text_input(self.custom_path_editor.clone())
.with_style(UiComponentStyles {
border_color,
// Make sure the editor is the same height as the dropdown it's next to.
height: Some(TOP_MENU_BAR_HEIGHT),
padding: Some(Coords::uniform(7.)),
margin: Some(Coords::default().left(8.).right(8.)),
font_size: Some(appearance.ui_font_size()),
background: Some(theme.surface_2().into()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
)
}
row.finish()
}
}
impl TypedActionView for StartupShellView {
type Action = NewSessionShellAction;
/// Handles a `NewSessionShellAction`, either triggered by the shell dropdown
/// or by custom path editor events.
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NewSessionShellAction::ShowCustomPathInput => {
self.should_display_editor = true;
ctx.notify();
}
NewSessionShellAction::Set(shell) => {
if shell.get_custom_path().is_none() && self.should_display_editor {
self.should_display_editor = false;
ctx.notify();
}
AvailableShells::handle(ctx).update(ctx, |shells, ctx| {
report_if_error!(shells.set_user_preferred_shell(shell.clone(), ctx));
});
}
}
send_telemetry_from_ctx!(action.telemetry_event(), ctx);
}
}
@@ -0,0 +1,239 @@
use std::{cell::RefCell, collections::HashMap, time::Duration};
use settings::{Setting, ToggleableSetting};
use warpui::{
elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MouseStateHandle, ParentElement,
Text,
},
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{self, EditorView, SingleLineEditorOptions, TextOptions},
report_if_error,
settings_view::{
features_page::render_group,
settings_page::{render_body_item, LocalOnlyIconState, ToggleState},
},
undo_close::{settings::UndoCloseEnabled, UndoCloseSettings},
};
#[derive(Debug, Clone, Copy)]
pub enum Action {
ToggleUndoCloseEnabled,
UpdateGracePeriod,
}
/// A view containing settings relating to the undo close feature.
pub struct UndoCloseView {
/// State for the enable/disable toggle switch.
switch_state: SwitchStateHandle,
/// An editor for modifying the undo close grace period.
grace_period_editor: ViewHandle<EditorView>,
/// Whether or not the grace period value is valid.
is_grace_period_valid: bool,
/// State for the local only icon tooltip.
local_only_icon_states: RefCell<HashMap<String, MouseStateHandle>>,
}
impl UndoCloseView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let grace_period_editor = ctx.add_typed_action_view(|ctx| {
EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_font_size(Appearance::as_ref(ctx)),
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_model(&UndoCloseSettings::handle(ctx), |me, _, _, ctx| {
// Update the value of the grace period input to match the new setting
me.grace_period_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(
&format!(
"{}",
UndoCloseSettings::handle(ctx)
.as_ref(ctx)
.grace_period
.as_secs_f32()
),
ctx,
);
});
ctx.notify()
});
ctx.subscribe_to_view(&grace_period_editor, move |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let grace_period = UndoCloseSettings::as_ref(ctx).grace_period.as_secs();
grace_period_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&grace_period.to_string(), ctx);
});
Self {
switch_state: Default::default(),
local_only_icon_states: Default::default(),
grace_period_editor,
is_grace_period_valid: true,
}
}
/// This callback updates the undo close setting based on user input. If the
/// user hits Enter or the input loses focus, the new setting is saved (they
/// can also save it by clicking outside of the text field).
fn handle_editor_event(&mut self, event: &editor::Event, ctx: &mut ViewContext<Self>) {
use editor::Event;
match event {
Event::Edited(_) => {
let buffer_text = self.grace_period_editor.as_ref(ctx).buffer_text(ctx);
let new_validity = Self::parse_grace_period(&buffer_text).is_some();
if new_validity != self.is_grace_period_valid {
self.is_grace_period_valid = new_validity;
ctx.notify();
}
}
Event::Blurred | Event::Enter => {
self.handle_action(&Action::UpdateGracePeriod, ctx);
}
_ => (),
}
}
/// Parses user-entered text into a grace period duration, returning
/// None if the text isn't a valid grace period.
fn parse_grace_period(text: &str) -> Option<Duration> {
text.parse::<u64>().ok().map(Duration::from_secs)
}
/// Renders the editor for the grace period duration.
fn render_grace_period_editor(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let border_color = if self.is_grace_period_valid {
None
} else {
Some(crate::themes::theme::Fill::error().into())
};
let editor_style = UiComponentStyles {
border_color,
width: Some(40.),
padding: Some(Coords::uniform(5.)),
background: Some(theme.surface_2().into()),
..Default::default()
};
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Container::new(
Text::new_inline(
"Grace period (seconds)",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.with_padding_right(8.5)
.finish(),
)
.with_child(
appearance
.ui_builder()
.text_input(self.grace_period_editor.clone())
.with_style(editor_style)
.build()
.finish(),
)
.finish()
}
}
impl Entity for UndoCloseView {
type Event = ();
}
impl View for UndoCloseView {
fn ui_name() -> &'static str {
"UndoCloseView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let settings = UndoCloseSettings::as_ref(app);
let enabled = *settings.enabled;
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(render_body_item::<Action>(
"Enable reopening of closed sessions".into(),
None,
LocalOnlyIconState::for_setting(
UndoCloseEnabled::storage_key(),
UndoCloseEnabled::sync_to_cloud(),
&mut self.local_only_icon_states.borrow_mut(),
app,
),
ToggleState::Enabled,
appearance,
ui_builder
.switch(self.switch_state.clone())
.check(enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(Action::ToggleUndoCloseEnabled);
})
.finish(),
None,
));
if enabled {
column.add_child(render_group(
[self.render_grace_period_editor(appearance)],
appearance,
));
}
column.finish()
}
}
impl TypedActionView for UndoCloseView {
type Action = Action;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut warpui::ViewContext<Self>) {
match action {
Action::ToggleUndoCloseEnabled => {
UndoCloseSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.enabled.toggle_and_save_value(ctx));
})
}
Action::UpdateGracePeriod => {
let grace_period_secs = self
.grace_period_editor
.read(ctx, |editor, ctx| editor.buffer_text(ctx));
let Some(grace_period) = Self::parse_grace_period(&grace_period_secs) else {
self.is_grace_period_valid = false;
return;
};
self.is_grace_period_valid = true;
UndoCloseSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.grace_period.set_value(grace_period, ctx));
});
}
}
}
}
@@ -0,0 +1,404 @@
use itertools::Itertools;
use warpui::{
elements::{Container, CrossAxisAlignment, Flex, ParentElement, Shrinkable},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
report_if_error, send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
settings_view::features_page::render_group,
terminal::session_settings::*,
view_components::{dropdown::TOP_MENU_BAR_HEIGHT, Dropdown, DropdownItem},
};
#[derive(Clone, Debug)]
#[allow(clippy::enum_variant_names)]
pub enum WorkingDirectoryAction {
/// Sets the mode that should be used for all new sessions, independent of
/// source. A value of None indicates that the mode should be configured
/// per-source instead of globally (i.e.: "advanced" mode).
SetGlobalWorkingDirectoryMode(Option<WorkingDirectoryMode>),
/// Sets the mode that should be used for new sessions spawned from the
/// given source (e.g.: new tab/window/split pane).
SetPerSourceWorkingDirectoryMode(NewSessionSource, WorkingDirectoryMode),
/// Sets the path that will be used for [`WorkingDirectoryMode::CustomDir`]
/// for the given source (where None represents global configuration).
SetCustomWorkingDirectoryValue(Option<NewSessionSource>, String),
}
/// A view for configuring the initial working directory for new sessions,
/// either globally or on a per-source (new tab/window/split pane) basis.
pub struct WorkingDirectoryView {
working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
working_directory_editor: ViewHandle<EditorView>,
new_window_working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
new_window_working_directory_editor: ViewHandle<EditorView>,
new_tab_working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
new_tab_working_directory_editor: ViewHandle<EditorView>,
split_pane_working_directory_dropdown: ViewHandle<Dropdown<WorkingDirectoryAction>>,
split_pane_working_directory_editor: ViewHandle<EditorView>,
}
impl WorkingDirectoryView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_top_level_dropdown(&mut dropdown, ctx);
dropdown
});
let working_directory_editor = create_editor(None, ctx);
let new_window_working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_per_source_dropdown(&mut dropdown, NewSessionSource::Window, ctx);
dropdown
});
let new_window_working_directory_editor =
create_editor(Some(NewSessionSource::Window), ctx);
let new_tab_working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_per_source_dropdown(&mut dropdown, NewSessionSource::Tab, ctx);
dropdown
});
let new_tab_working_directory_editor = create_editor(Some(NewSessionSource::Tab), ctx);
let split_pane_working_directory_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = Dropdown::new(ctx);
init_per_source_dropdown(&mut dropdown, NewSessionSource::SplitPane, ctx);
dropdown
});
let split_pane_working_directory_editor =
create_editor(Some(NewSessionSource::SplitPane), ctx);
ctx.subscribe_to_model(&SessionSettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
SessionSettingsChangedEvent::WorkingDirectoryConfig { .. }
) {
me.working_directory_dropdown.update(ctx, |dropdown, ctx| {
init_top_level_dropdown(dropdown, ctx);
ctx.notify();
});
me.new_window_working_directory_dropdown
.update(ctx, |dropdown, ctx| {
init_per_source_dropdown(dropdown, NewSessionSource::Window, ctx);
ctx.notify();
});
me.new_tab_working_directory_dropdown
.update(ctx, |dropdown, ctx| {
init_per_source_dropdown(dropdown, NewSessionSource::Tab, ctx);
ctx.notify();
});
me.split_pane_working_directory_dropdown
.update(ctx, |dropdown, ctx| {
init_per_source_dropdown(dropdown, NewSessionSource::SplitPane, ctx);
ctx.notify();
});
ctx.notify();
}
});
Self {
working_directory_dropdown,
working_directory_editor,
new_window_working_directory_dropdown,
new_window_working_directory_editor,
new_tab_working_directory_dropdown,
new_tab_working_directory_editor,
split_pane_working_directory_dropdown,
split_pane_working_directory_editor,
}
}
}
impl Entity for WorkingDirectoryView {
type Event = ();
}
impl View for WorkingDirectoryView {
fn ui_name() -> &'static str {
"WorkingDirectoryView"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder();
let settings = SessionSettings::as_ref(app);
let config = &settings.working_directory_config;
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_row(
&self.working_directory_dropdown,
&self.working_directory_editor,
config.global.mode == WorkingDirectoryMode::CustomDir && !config.advanced_mode,
appearance,
));
if config.advanced_mode {
let items = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_children([
ui_builder.label("New window").build().finish(),
render_row(
&self.new_window_working_directory_dropdown,
&self.new_window_working_directory_editor,
config.new_window.mode == WorkingDirectoryMode::CustomDir,
appearance,
),
ui_builder.label("New tab").build().finish(),
render_row(
&self.new_tab_working_directory_dropdown,
&self.new_tab_working_directory_editor,
config.new_tab.mode == WorkingDirectoryMode::CustomDir,
appearance,
),
ui_builder.label("Split pane").build().finish(),
render_row(
&self.split_pane_working_directory_dropdown,
&self.split_pane_working_directory_editor,
config.split_pane.mode == WorkingDirectoryMode::CustomDir,
appearance,
),
])
.finish();
column.add_child(
Container::new(render_group(
[Container::new(items)
.with_margin_top(4.)
.with_margin_bottom(2.)
.finish()],
appearance,
))
.with_margin_top(8.)
.finish(),
);
}
column.finish()
}
}
impl TypedActionView for WorkingDirectoryView {
type Action = WorkingDirectoryAction;
fn handle_action(&mut self, action: &WorkingDirectoryAction, ctx: &mut ViewContext<Self>) {
use WorkingDirectoryAction::*;
match action {
SetGlobalWorkingDirectoryMode(mode) => {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.working_directory_config.update_and_save_value(
|config| {
if let Some(mode) = mode {
config.advanced_mode = false;
config.global.mode = *mode;
} else {
config.advanced_mode = true;
}
},
ctx,
));
});
send_telemetry_from_ctx!(
TelemetryEvent::InitialWorkingDirectoryConfigurationChanged {
advanced_mode_enabled: mode.is_none()
},
ctx
);
// Redraw settings in case we switched in or out of advanced mode.
ctx.notify();
}
SetPerSourceWorkingDirectoryMode(source, mode) => {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.working_directory_config.update_and_save_value(
|config| match source {
NewSessionSource::SplitPane => config.split_pane.mode = *mode,
NewSessionSource::Tab => config.new_tab.mode = *mode,
NewSessionSource::Window => config.new_window.mode = *mode,
},
ctx,
));
});
// Redraw settings in case we changed a mode to/from "custom directory".
ctx.notify();
}
SetCustomWorkingDirectoryValue(source, value) => {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.working_directory_config.update_and_save_value(
|config| match source {
Some(NewSessionSource::SplitPane) => {
config.split_pane.custom_dir.clone_from(value)
}
Some(NewSessionSource::Tab) => {
config.new_tab.custom_dir.clone_from(value)
}
Some(NewSessionSource::Window) => {
config.new_window.custom_dir.clone_from(value)
}
None => config.global.custom_dir.clone_from(value),
},
ctx,
));
});
}
}
}
}
/// Render a single row, containing a dropdown view and editor.
///
/// `show_editor` controls whether the editor is currently visible.
fn render_row(
dropdown: &ViewHandle<Dropdown<WorkingDirectoryAction>>,
editor: &ViewHandle<EditorView>,
show_editor: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(ChildView::new(dropdown).finish());
if show_editor {
row.add_child(
Shrinkable::new(
1.,
appearance
.ui_builder()
.text_input(editor.clone())
.with_style(UiComponentStyles {
height: Some(TOP_MENU_BAR_HEIGHT),
font_color: Some(pathfinder_color::ColorU::black()),
font_size: Some(appearance.ui_font_size()),
padding: Some(Coords::uniform(7.)),
margin: Some(Coords::default().left(8.).right(8.)),
background: Some(appearance.theme().surface_2().into()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
);
}
row.finish()
}
/// Initializes the top-level dropdown (global configuration vs. advanced mode).
fn init_top_level_dropdown(
dropdown: &mut Dropdown<WorkingDirectoryAction>,
ctx: &mut ViewContext<Dropdown<WorkingDirectoryAction>>,
) {
let mut items = [
WorkingDirectoryMode::HomeDir,
WorkingDirectoryMode::PreviousDir,
WorkingDirectoryMode::CustomDir,
]
.into_iter()
.map(|mode| {
DropdownItem::new(
mode.dropdown_item_label(),
WorkingDirectoryAction::SetGlobalWorkingDirectoryMode(Some(mode)),
)
})
.collect_vec();
items.push(DropdownItem::new(
"Advanced".to_string(),
WorkingDirectoryAction::SetGlobalWorkingDirectoryMode(None),
));
let advanced_item_index = items.len() - 1;
dropdown.set_items(items, ctx);
dropdown.set_top_bar_max_width(200.);
let config = &SessionSettings::as_ref(ctx).working_directory_config;
if config.advanced_mode {
dropdown.set_selected_by_index(advanced_item_index, ctx);
} else {
dropdown.set_selected_by_name(config.global.mode.dropdown_item_label(), ctx);
}
}
/// Initializes a dropdown that relates to a particular new session source.
fn init_per_source_dropdown(
dropdown: &mut Dropdown<WorkingDirectoryAction>,
source: NewSessionSource,
ctx: &mut ViewContext<Dropdown<WorkingDirectoryAction>>,
) {
let items = [
WorkingDirectoryMode::HomeDir,
WorkingDirectoryMode::PreviousDir,
WorkingDirectoryMode::CustomDir,
]
.into_iter()
.map(|mode| {
DropdownItem::new(
mode.dropdown_item_label(),
WorkingDirectoryAction::SetPerSourceWorkingDirectoryMode(source, mode),
)
})
.collect_vec();
dropdown.set_items(items, ctx);
dropdown.set_top_bar_max_width(200.);
let config = &SessionSettings::as_ref(ctx).working_directory_config;
let source_config = match source {
NewSessionSource::SplitPane => &config.split_pane,
NewSessionSource::Tab => &config.new_tab,
NewSessionSource::Window => &config.new_window,
};
dropdown.set_selected_by_name(source_config.mode.dropdown_item_label(), ctx);
}
/// Creates a new editor view for entering a custom initial directory path.
fn create_editor(
source: Option<NewSessionSource>,
ctx: &mut ViewContext<WorkingDirectoryView>,
) -> ViewHandle<EditorView> {
let editor = {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_font_size(appearance),
..Default::default()
};
ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("Directory path", ctx);
editor
})
};
let initial_value = {
let config = &SessionSettings::as_ref(ctx).working_directory_config;
let source_config = match source {
None => &config.global,
Some(NewSessionSource::SplitPane) => &config.split_pane,
Some(NewSessionSource::Tab) => &config.new_tab,
Some(NewSessionSource::Window) => &config.new_window,
};
source_config.custom_dir.clone()
};
editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&initial_value, ctx);
});
let editor_handle = editor.clone();
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| match event {
// If the user presses enter or focus moves out of the editor view,
// update our configuration to match the current value.
EditorEvent::Blurred | EditorEvent::Enter => {
let editor_contents = editor_handle.as_ref(ctx).buffer_text(ctx);
me.handle_action(
&WorkingDirectoryAction::SetCustomWorkingDirectoryValue(source, editor_contents),
ctx,
);
}
_ => {}
});
editor
}