Files
galaxy/app/src/tab_configs/params_modal.rs
T

740 lines
28 KiB
Rust

use std::{collections::HashMap, path::PathBuf};
use warp_core::ui::theme::color::internal_colors;
use warp_core::ui::Icon;
use warp_editor::editor::NavigationKey;
use warpui::{
elements::{
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Fill, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, Padding, ParentElement, Radius, SavePosition, ScrollTarget,
ScrollToPositionMode, ScrollbarWidth, Shrinkable, Text,
},
fonts::{Properties, Weight},
keymap::{macros::*, FixedBinding, Keystroke},
platform::Cursor,
ui_components::components::UiComponent,
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
},
modal::ModalAction,
tab_configs::{
branch_picker::BranchPicker,
repo_picker::{RepoPicker, RepoPickerEvent},
PickerStyle, TabConfig, TabConfigParam, TabConfigParamType,
},
view_components::action_button::{
ActionButton, DisabledTheme, KeystrokeSource, NakedTheme, PrimaryTheme,
},
};
pub fn init(app: &mut AppContext) {
app.register_fixed_bindings(vec![
FixedBinding::new(
"escape",
TabConfigParamsModalAction::Escape,
id!("TabConfigParamsModal"),
),
// Enter and Space only fire when no EditorView descendant is focused.
// When a text field or picker filter editor has focus, the editor
// consumes these keys and the modal handles them via event
// subscriptions instead (see handle_editor_event).
FixedBinding::new(
"enter",
TabConfigParamsModalAction::Submit,
id!("TabConfigParamsModal") & !id!("EditorView"),
),
FixedBinding::new(
"space",
TabConfigParamsModalAction::ToggleDropdown,
id!("TabConfigParamsModal") & !id!("EditorView"),
),
]);
}
fn param_field_position_id(index: usize) -> String {
format!("tab_config_param_field_{index}")
}
/// Resolves the effective value to submit for a parameter.
///
/// Returns `None` when the value is blank and the param has no default (i.e. it
/// is required and unsatisfied), causing submit to be blocked.
fn resolve_param_value(raw_value: String, param: &TabConfigParam) -> Option<String> {
if raw_value.trim().is_empty() {
param.default.clone()
} else {
Some(raw_value)
}
}
/// A single editable field in the modal — either a text editor or a smart picker.
enum ParamField {
/// Plain single-line text input.
Text(ViewHandle<EditorView>),
/// Git branch picker; stores the last selection so submit can read it.
Branch {
picker: ViewHandle<BranchPicker>,
selected: Option<String>,
},
/// Known-repo picker; stores the last selection.
Repo {
picker: ViewHandle<RepoPicker>,
selected: Option<String>,
},
}
impl ParamField {
fn current_value(&self, app: &AppContext) -> String {
match self {
ParamField::Text(editor) => editor.as_ref(app).buffer_text(app),
ParamField::Branch { picker, selected } => picker
.as_ref(app)
.selected_value(app)
.or_else(|| selected.clone())
.unwrap_or_default(),
// Check the stored `selected` first: it is always kept up-to-date by the
// `RepoPickerEvent::Selected` subscription and by `on_new_repo_selected`.
// `picker.selected_value()` is a fallback for the initial default-value
// case before any explicit selection has been made.
ParamField::Repo { picker, selected } => selected
.clone()
.or_else(|| picker.as_ref(app).selected_value(app))
.unwrap_or_default(),
}
}
}
/// Body view for the tab-config parameter fill modal.
///
/// Renders one labeled input row per parameter defined in the tab config's `[params]` section.
/// The workspace creates this as the inner body of a [`crate::modal::Modal`] and calls
/// [`Self::on_open`] / [`Self::on_close`] around the modal's visibility.
pub struct TabConfigParamsModal {
/// Ordered list of `(param_name, param_definition, field)`.
/// Rebuilt each time [`Self::on_open`] is called.
param_fields: Vec<(String, TabConfigParam, ParamField)>,
/// The config being launched; kept so [`Self::try_submit`] can include it in the event.
pending_config: Option<TabConfig>,
title: String,
cancel_button: ViewHandle<ActionButton>,
submit_button: ViewHandle<ActionButton>,
submit_button_disabled: ViewHandle<ActionButton>,
close_button_mouse_state: MouseStateHandle,
scroll_state: ClippedScrollStateHandle,
}
pub enum TabConfigParamsModalEvent {
Close,
Submit {
config: Box<TabConfig>,
params: HashMap<String, String>,
},
/// The user clicked "Add new repo..." in a repo picker; the workspace should
/// open a folder picker and call [`TabConfigParamsModal::on_new_repo_selected`].
PickNewRepo {
param_index: usize,
},
}
#[derive(Clone, Copy, Debug)]
pub enum TabConfigParamsModalAction {
Cancel,
Submit,
Escape,
ToggleDropdown,
}
impl TabConfigParamsModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(TabConfigParamsModalAction::Cancel);
})
});
let submit_button = ctx.add_typed_action_view(|ctx| {
ActionButton::new("Open Tab", PrimaryTheme)
.with_keybinding(
KeystrokeSource::Fixed(Keystroke::parse("enter").unwrap_or_default()),
ctx,
)
.on_click(|ctx| {
ctx.dispatch_typed_action(TabConfigParamsModalAction::Submit);
})
});
let submit_button_disabled =
ctx.add_typed_action_view(|_| ActionButton::new("Open Tab", DisabledTheme));
Self {
param_fields: Vec::new(),
pending_config: None,
title: String::new(),
cancel_button,
submit_button,
submit_button_disabled,
close_button_mouse_state: Default::default(),
scroll_state: Default::default(),
}
}
pub fn set_title(&mut self, title: String) {
self.title = title;
}
/// Called by the workspace before making the modal visible.
///
/// Builds one field per param in `config`. `cwd` is the active terminal's
/// working directory, used to seed the branch picker's git lookup.
pub fn on_open(
&mut self,
config: TabConfig,
cwd: Option<PathBuf>,
ctx: &mut ViewContext<Self>,
) {
self.param_fields.clear();
// Sort params by type priority (Repo first, Branch second, Text last),
// then alphabetically by name within each type for stable ordering.
let mut params: Vec<(String, TabConfigParam)> = config
.params
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let type_priority = |t: &TabConfigParamType| match t {
TabConfigParamType::Repo => 0,
TabConfigParamType::Branch => 1,
TabConfigParamType::Text => 2,
};
params.sort_by(|a, b| {
type_priority(&a.1.param_type)
.cmp(&type_priority(&b.1.param_type))
.then(a.0.cmp(&b.0))
});
// If there's a Repo param with a default value, seed branch pickers with that repo
// path so branches are populated on initial open. Without this, branch pickers would
// use the terminal cwd, which may differ from the configured repo.
let branch_initial_cwd = params
.iter()
.find(|(_, p)| matches!(p.param_type, TabConfigParamType::Repo))
.and_then(|(_, p)| p.default.as_deref())
.map(PathBuf::from)
.or_else(|| cwd.clone());
let picker_style = PickerStyle {
width: 412.,
background: Some(Appearance::as_ref(ctx).theme().background()),
};
for (i, (name, param)) in params.iter().enumerate() {
let field = match param.param_type {
TabConfigParamType::Branch => {
let default_value = param.default.clone();
let branch_cwd = branch_initial_cwd.clone();
let style = PickerStyle {
width: picker_style.width,
background: picker_style.background,
};
let picker = ctx.add_typed_action_view(move |ctx| {
BranchPicker::new_with_style(branch_cwd, default_value, Some(style), ctx)
});
ctx.subscribe_to_view(&picker, move |me, _, value, ctx| {
if let Some((_, _, ParamField::Branch { selected, .. })) =
me.param_fields.get_mut(i)
{
*selected = Some(value.clone());
}
me.reclaim_focus(ctx);
});
ParamField::Branch {
picker,
selected: param.default.clone(),
}
}
TabConfigParamType::Repo => {
let default_value = param.default.clone();
let style = PickerStyle {
width: picker_style.width,
background: picker_style.background,
};
let picker = ctx.add_typed_action_view(move |ctx| {
RepoPicker::new_with_style(default_value, Some(style), ctx)
});
ctx.subscribe_to_view(&picker, move |me, _, event, ctx| match event {
RepoPickerEvent::Selected(value) => {
if let Some((_, _, ParamField::Repo { selected, .. })) =
me.param_fields.get_mut(i)
{
*selected = Some(value.clone());
}
me.sync_branch_pickers_for_repo(PathBuf::from(value.as_str()), ctx);
me.reclaim_focus(ctx);
}
RepoPickerEvent::RequestAddRepo => {
ctx.emit(TabConfigParamsModalEvent::PickNewRepo { param_index: i });
}
});
ParamField::Repo {
picker,
selected: param.default.clone(),
}
}
TabConfigParamType::Text => {
let default_text = param.default.clone().unwrap_or_default();
let placeholder = if default_text.is_empty() {
format!("Enter {name}")
} else {
default_text.clone()
};
let text_options = TextOptions::ui_font_size(Appearance::as_ref(ctx));
let editor = ctx.add_typed_action_view(|ctx| {
let options = SingleLineEditorOptions {
text: text_options,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text(placeholder.as_str(), ctx);
editor
});
if !default_text.is_empty() {
editor.update(ctx, |e, ctx| {
e.system_reset_buffer_text(&default_text, ctx);
});
}
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| {
me.handle_editor_event(i, event, ctx);
});
ParamField::Text(editor)
}
};
self.param_fields.push((name.clone(), param.clone(), field));
}
self.pending_config = Some(config);
// When the only fields are dropdowns, focus the modal itself so
// Enter (submit) and Space (toggle dropdown) fixed bindings fire.
// When there are text fields, focus the first one so the user can
// start typing immediately.
if self.has_text_fields() {
self.focus_field(0, ctx);
} else {
ctx.focus_self();
}
ctx.notify();
}
/// Called by the workspace when the modal is dismissed. Clears all dynamic state.
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
self.param_fields.clear();
self.pending_config = None;
ctx.notify();
}
/// Called by the workspace after the user adds a new repo via the folder picker.
/// Refreshes the repo picker at `param_index` and pre-selects the new path.
pub fn on_new_repo_selected(
&mut self,
path: PathBuf,
param_index: usize,
ctx: &mut ViewContext<Self>,
) {
if let Some((_, _, ParamField::Repo { picker, selected })) =
self.param_fields.get_mut(param_index)
{
let path_str = path.to_string_lossy().to_string();
*selected = Some(path_str);
picker.update(ctx, |repo_picker, ctx| {
repo_picker.refresh_and_select(path.clone(), ctx);
});
}
self.sync_branch_pickers_for_repo(path, ctx);
}
fn sync_branch_pickers_for_repo(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
// Clear stale branch selections and collect picker handles.
// Collecting handles first avoids borrow conflicts when calling
// picker.update() below.
let branch_pickers: Vec<_> = self
.param_fields
.iter_mut()
.filter_map(|(_, _, field)| {
if let ParamField::Branch { picker, selected } = field {
*selected = None;
Some(picker.clone())
} else {
None
}
})
.collect();
for branch_picker in branch_pickers {
branch_picker.update(ctx, |picker, ctx| {
picker.refetch_branches(path.clone(), ctx);
});
}
ctx.notify();
}
/// Restores focus to the modal itself (dropdown-only) or the first text
/// field after a picker interaction closes its dropdown.
fn reclaim_focus(&self, ctx: &mut ViewContext<Self>) {
if self.has_text_fields() {
self.focus_field(0, ctx);
} else {
ctx.focus_self();
}
ctx.notify();
}
fn has_text_fields(&self) -> bool {
self.param_fields
.iter()
.any(|(_, _, field)| matches!(field, ParamField::Text(_)))
}
fn dropdown_count(&self) -> usize {
self.param_fields
.iter()
.filter(|(_, _, field)| !matches!(field, ParamField::Text(_)))
.count()
}
fn toggle_single_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let mut opened = false;
for (_, _, field) in &self.param_fields {
match field {
ParamField::Branch { picker, .. } => {
opened = picker.update(ctx, |p, ctx| p.toggle_dropdown(ctx));
break;
}
ParamField::Repo { picker, .. } => {
opened = picker.update(ctx, |p, ctx| p.toggle_dropdown(ctx));
break;
}
ParamField::Text(_) => {}
}
}
// When the dropdown just closed, reclaim focus so Enter/Space
// fixed bindings continue to work.
if !opened && !self.has_text_fields() {
ctx.focus_self();
}
}
fn focus_field(&self, index: usize, ctx: &mut ViewContext<Self>) {
if let Some((_, _, field)) = self.param_fields.get(index) {
match field {
ParamField::Text(editor) => ctx.focus(editor),
ParamField::Branch { picker, .. } => ctx.focus(picker),
ParamField::Repo { picker, .. } => ctx.focus(picker),
}
self.scroll_state.scroll_to_position(ScrollTarget {
position_id: param_field_position_id(index),
mode: ScrollToPositionMode::FullyIntoView,
});
}
}
fn handle_editor_event(
&mut self,
index: usize,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
let count = self.param_fields.len();
match event {
EditorEvent::Navigate(NavigationKey::Tab) if count > 0 => {
self.focus_field((index + 1) % count, ctx);
}
EditorEvent::Navigate(NavigationKey::ShiftTab) if count > 0 => {
let prev = if index == 0 { count - 1 } else { index - 1 };
self.focus_field(prev, ctx);
}
EditorEvent::Enter => self.try_submit(ctx),
EditorEvent::Escape => ctx.emit(TabConfigParamsModalEvent::Close),
EditorEvent::Edited(_) => ctx.notify(),
_ => {}
}
}
fn try_submit(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(config) = self.pending_config.clone() {
let params: Option<HashMap<String, String>> = self
.param_fields
.iter()
.map(|(name, param, field)| {
let value = field.current_value(ctx);
resolve_param_value(value, param).map(|v| (name.clone(), v))
})
.collect();
if let Some(params) = params {
ctx.emit(TabConfigParamsModalEvent::Submit {
config: Box::new(config),
params,
});
}
}
}
}
#[cfg(test)]
#[path = "params_modal_tests.rs"]
mod tests;
impl Entity for TabConfigParamsModal {
type Event = TabConfigParamsModalEvent;
}
impl View for TabConfigParamsModal {
fn ui_name() -> &'static str {
"TabConfigParamsModal"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
// When focus arrives directly at this view (not at a child), keep
// self-focus so the Enter/Space fixed bindings fire. This happens
// on initial open (via focus_self in on_open) and when the Modal
// wrapper re-focuses the body after a child (like a dropdown)
// releases focus.
if focus_ctx.is_self_focused() && !self.has_text_fields() {
ctx.focus_self();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let sub_text = theme.sub_text_color(theme.background());
let is_submit_enabled = self.param_fields.iter().all(|(_, param, field)| {
let value = field.current_value(app);
resolve_param_value(value, param).is_some()
});
// ── Header ───────────────────────────────────────────────────────
let header = {
let title = Text::new_inline(self.title.clone(), appearance.ui_font_family(), 16.)
.with_color(theme.active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish();
let esc_badge = Container::new(
ConstrainedBox::new(
Text::new_inline("ESC".to_string(), appearance.ui_font_family(), 10.)
.with_color(theme.foreground().into())
.finish(),
)
.with_height(14.)
.finish(),
)
.with_horizontal_padding(2.)
.with_background(internal_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.finish();
let close_icon = ConstrainedBox::new(Icon::X.to_warpui_icon(sub_text).finish())
.with_width(14.)
.with_height(14.)
.finish();
let close_button = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.with_child(close_icon)
.with_child(esc_badge)
.finish();
let close_hoverable =
Hoverable::new(self.close_button_mouse_state.clone(), move |_state| {
close_button
})
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ModalAction::Close);
})
.with_cursor(Cursor::PointingHand)
.finish();
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., title).finish())
.with_child(close_hoverable)
.finish(),
)
.with_padding(
Padding::uniform(0.)
.with_top(24.)
.with_bottom(12.)
.with_left(24.)
.with_right(24.),
)
.finish()
};
// ── Form body ────────────────────────────────────────────────────
let mut form = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let active_text = theme.active_ui_text_color();
for (i, (name, param, field)) in self.param_fields.iter().enumerate() {
let mut label = Container::new(
Text::new_inline(
name.clone(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(active_text.into())
.finish(),
)
.with_margin_bottom(4.);
if i > 0 {
label = label.with_margin_top(16.);
}
form.add_child(label.finish());
// Optional description sub-label.
if let Some(description) = &param.description {
form.add_child(
Container::new(
Text::new_inline(
description.clone(),
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_color(sub_text.into())
.finish(),
)
.with_margin_bottom(4.)
.finish(),
);
}
// Default value hint (text params only — pickers show the value in their top bar).
if matches!(param.param_type, TabConfigParamType::Text) {
if let Some(default_value) = &param.default {
form.add_child(
Container::new(
Text::new_inline(
format!("Default: {default_value}"),
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_color(sub_text.into())
.finish(),
)
.with_margin_bottom(4.)
.finish(),
);
}
}
// The input field itself.
// Text editors get the standard text_input border treatment;
// pickers (Dropdown-based) already render their own chrome.
let field_element: Box<dyn Element> = match field {
ParamField::Text(editor) => appearance
.ui_builder()
.text_input(editor.clone())
.build()
.finish(),
ParamField::Branch { picker, .. } => ChildView::new(picker).finish(),
ParamField::Repo { picker, .. } => ChildView::new(picker).finish(),
};
form.add_child(SavePosition::new(field_element, &param_field_position_id(i)).finish());
}
let scrollable = ClippedScrollable::vertical(
self.scroll_state.clone(),
form.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_text_color().into(),
theme.active_ui_text_color().into(),
Fill::None,
)
.with_overlayed_scrollbar()
.with_padding_start(0.)
.with_padding_end(0.)
.finish();
let body_container = Container::new(
ConstrainedBox::new(scrollable)
.with_max_height(340.)
.finish(),
)
.with_padding(
Padding::uniform(0.)
.with_left(24.)
.with_right(24.)
.with_bottom(16.),
)
.finish();
// ── Footer ───────────────────────────────────────────────────────
let button_row = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(ChildView::new(&self.cancel_button).finish())
.with_child(if is_submit_enabled {
ChildView::new(&self.submit_button).finish()
} else {
ChildView::new(&self.submit_button_disabled).finish()
})
.finish(),
)
.with_padding(Padding::uniform(12.).with_left(24.).with_right(24.))
.finish();
let footer = Container::new(button_row)
.with_border(Border::top(1.).with_border_fill(theme.outline()))
.finish();
// ── Assemble ─────────────────────────────────────────────────────
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(body_container)
.with_child(footer)
.finish()
}
}
impl TypedActionView for TabConfigParamsModal {
type Action = TabConfigParamsModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
TabConfigParamsModalAction::Cancel | TabConfigParamsModalAction::Escape => {
ctx.emit(TabConfigParamsModalEvent::Close);
}
TabConfigParamsModalAction::Submit => self.try_submit(ctx),
TabConfigParamsModalAction::ToggleDropdown => {
if self.dropdown_count() <= 1 {
self.toggle_single_dropdown(ctx);
}
}
}
}
}