Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
use warp_util::path::user_friendly_path;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::{ButtonTooltipPosition, ButtonVariant},
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance, settings::ai::DefaultSessionMode, tab_configs::TabConfig,
|
||||
terminal::available_shells::AvailableShell, workspace::WorkspaceAction,
|
||||
};
|
||||
|
||||
pub(crate) const SIDECAR_WIDTH: f32 = 260.;
|
||||
const SIDECAR_PADDING: f32 = 12.;
|
||||
|
||||
/// Describes what the sidecar is showing, which determines which buttons appear.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum SidecarItemKind {
|
||||
/// A built-in item (Terminal, a specific shell, Agent, Cloud Oz).
|
||||
BuiltIn {
|
||||
name: String,
|
||||
default_mode: DefaultSessionMode,
|
||||
shell: Option<AvailableShell>,
|
||||
},
|
||||
/// A user-created tab config loaded from disk.
|
||||
UserTabConfig { config: TabConfig },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SidecarMouseStates {
|
||||
pub(crate) make_default: MouseStateHandle,
|
||||
pub(crate) edit_config: MouseStateHandle,
|
||||
pub(crate) remove_config: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Renders the action sidecar panel as a raw element tree.
|
||||
/// Called directly from the Workspace render method (not via ChildView).
|
||||
pub(crate) fn render_action_sidecar(
|
||||
item: &SidecarItemKind,
|
||||
mouse_states: &SidecarMouseStates,
|
||||
is_already_default: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
// Title
|
||||
let title = match item {
|
||||
SidecarItemKind::BuiltIn { name, .. } => name.clone(),
|
||||
SidecarItemKind::UserTabConfig { config } => config.name.clone(),
|
||||
};
|
||||
column.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(title, font_family, font_size + 1.)
|
||||
.with_color(theme.main_text_color(theme.surface_2()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Subtitle (file path for user configs)
|
||||
if let SidecarItemKind::UserTabConfig { config } = item {
|
||||
if let Some(path) = &config.source_path {
|
||||
let raw_path = path.to_string_lossy().into_owned();
|
||||
let home_dir = dirs::home_dir();
|
||||
let path_str =
|
||||
user_friendly_path(&raw_path, home_dir.as_ref().and_then(|h| h.to_str()))
|
||||
.into_owned();
|
||||
column.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(path_str, font_family, font_size - 1.)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let primary_text_color = theme.main_text_color(theme.surface_2());
|
||||
let button_style = UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
font_weight: Some(warpui::fonts::Weight::Bold),
|
||||
font_color: Some(primary_text_color.into()),
|
||||
padding: Some(warpui::ui_components::components::Coords {
|
||||
top: 4.,
|
||||
bottom: 4.,
|
||||
left: 8.,
|
||||
right: 8.,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let make_default_action = match item {
|
||||
SidecarItemKind::BuiltIn {
|
||||
default_mode,
|
||||
shell,
|
||||
..
|
||||
} => WorkspaceAction::TabConfigSidecarMakeDefault {
|
||||
mode: *default_mode,
|
||||
tab_config_path: None,
|
||||
shell: shell.clone(),
|
||||
},
|
||||
SidecarItemKind::UserTabConfig { config } => WorkspaceAction::TabConfigSidecarMakeDefault {
|
||||
mode: DefaultSessionMode::TabConfig,
|
||||
tab_config_path: config.source_path.clone(),
|
||||
shell: None,
|
||||
},
|
||||
};
|
||||
|
||||
// "Make default" button (always shown; visually disabled with tooltip when already the default)
|
||||
let make_default_button = if is_already_default {
|
||||
let disabled_style = UiComponentStyles {
|
||||
font_color: Some(theme.disabled_text_color(theme.surface_2()).into()),
|
||||
border_color: Some(theme.outline().into()),
|
||||
..button_style
|
||||
};
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Outlined, mouse_states.make_default.clone())
|
||||
.with_centered_text_label("Make default".into())
|
||||
.with_style(disabled_style)
|
||||
.with_tooltip({
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
move || {
|
||||
ui_builder
|
||||
.tool_tip("Already the default".into())
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
})
|
||||
.with_tooltip_position(ButtonTooltipPosition::Above)
|
||||
.set_clicked_styles(None)
|
||||
.build()
|
||||
.finish()
|
||||
} else {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Outlined, mouse_states.make_default.clone())
|
||||
.with_centered_text_label("Make default".into())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx: &mut warpui::elements::EventContext, _, _| {
|
||||
ctx.dispatch_typed_action(make_default_action.clone())
|
||||
})
|
||||
.finish()
|
||||
};
|
||||
column.add_child(
|
||||
ConstrainedBox::new(make_default_button)
|
||||
.with_max_width(SIDECAR_WIDTH - SIDECAR_PADDING * 2.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// "Edit config" and "Remove" buttons (only for user tab configs)
|
||||
if let SidecarItemKind::UserTabConfig { config } = item {
|
||||
if let Some(config_path) = &config.source_path {
|
||||
let edit_path = config_path.clone();
|
||||
let edit_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Outlined, mouse_states.edit_config.clone())
|
||||
.with_centered_text_label("Edit config".into())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx: &mut warpui::elements::EventContext, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::TabConfigSidecarEditConfig {
|
||||
path: edit_path.clone(),
|
||||
})
|
||||
})
|
||||
.finish();
|
||||
column.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(edit_button)
|
||||
.with_max_width(SIDECAR_WIDTH - SIDECAR_PADDING * 2.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let remove_name = config.name.clone();
|
||||
let remove_path = config_path.clone();
|
||||
let red_color = theme.ansi_fg_red();
|
||||
let remove_style = UiComponentStyles {
|
||||
font_color: Some(red_color),
|
||||
border_color: Some(red_color.into()),
|
||||
..button_style
|
||||
};
|
||||
let remove_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Outlined, mouse_states.remove_config.clone())
|
||||
.with_centered_text_label("Remove".into())
|
||||
.with_style(remove_style)
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
border_color: Some(theme.accent().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx: &mut warpui::elements::EventContext, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::TabConfigSidecarRemoveConfig {
|
||||
name: remove_name.clone(),
|
||||
path: remove_path.clone(),
|
||||
})
|
||||
})
|
||||
.finish();
|
||||
column.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(remove_button)
|
||||
.with_max_width(SIDECAR_WIDTH - SIDECAR_PADDING * 2.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(column.finish())
|
||||
.with_padding_left(SIDECAR_PADDING)
|
||||
.with_padding_right(SIDECAR_PADDING)
|
||||
.with_padding_top(SIDECAR_PADDING)
|
||||
.with_padding_bottom(SIDECAR_PADDING)
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(SIDECAR_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::{
|
||||
elements::ChildView, ui_components::components::UiComponentStyles, AppContext, Element, Entity,
|
||||
TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
code_review::diff_state::DiffStateModel,
|
||||
tab_configs::PickerStyle,
|
||||
util::git::detect_current_branch,
|
||||
view_components::{DropdownItem, FilterableDropdown},
|
||||
};
|
||||
|
||||
const DEFAULT_DROPDOWN_WIDTH: f32 = 380.;
|
||||
/// Placeholder text shown in the dropdown top bar while branches are loading.
|
||||
const LOADING_PLACEHOLDER: &str = "Fetching branches\u{2026}";
|
||||
|
||||
/// A filterable dropdown that lists local git branches for the given repo path.
|
||||
///
|
||||
/// Created with an optional `cwd` — if `None`, the picker starts with the
|
||||
/// default value pre-populated while the async fetch runs.
|
||||
/// When branches are available, main branches are sorted to the top.
|
||||
///
|
||||
/// Emits the selected branch name (a `String`) as its event.
|
||||
pub struct BranchPicker {
|
||||
dropdown: ViewHandle<FilterableDropdown<String>>,
|
||||
/// Pre-selected default value from the TOML `default =` field, used to
|
||||
/// restore a selection after the async branch list arrives.
|
||||
default_value: Option<String>,
|
||||
/// Monotonically increasing counter incremented on every `fetch_branches` call.
|
||||
/// The async callback compares against the epoch captured at spawn time and
|
||||
/// discards stale results, preventing a slow earlier fetch from overwriting a
|
||||
/// faster later one when the repo changes mid-flight.
|
||||
fetch_epoch: usize,
|
||||
/// Main branch name cached after the first successful fetch for this repo.
|
||||
/// Passed to `get_all_branches_with_known_main` on subsequent fetches to skip
|
||||
/// the `detect_main_branch` step (which can make up to 6 sequential subprocess
|
||||
/// calls). Cleared in `refetch_branches` because a different repo may have a
|
||||
/// different main branch.
|
||||
cached_main_branch: Option<String>,
|
||||
/// True while an async branch fetch is in-flight. While loading, the
|
||||
/// dropdown is disabled so the user cannot interact with an empty list.
|
||||
is_loading: bool,
|
||||
}
|
||||
|
||||
impl BranchPicker {
|
||||
/// Creates a new picker and immediately spawns an async fetch for the
|
||||
/// branches of the repo at `cwd`. `default_value` is pre-selected once
|
||||
/// the list arrives (if it appears in the list).
|
||||
pub fn new(
|
||||
cwd: Option<PathBuf>,
|
||||
default_value: Option<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
Self::new_with_style(cwd, default_value, None, ctx)
|
||||
}
|
||||
|
||||
pub fn new_with_style(
|
||||
cwd: Option<PathBuf>,
|
||||
default_value: Option<String>,
|
||||
style: Option<PickerStyle>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let width = style.as_ref().map_or(DEFAULT_DROPDOWN_WIDTH, |s| s.width);
|
||||
let bg = style.and_then(|s| s.background);
|
||||
let dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = FilterableDropdown::new(ctx);
|
||||
dropdown.set_top_bar_max_width(width);
|
||||
dropdown.set_menu_width(width, ctx);
|
||||
if let Some(bg) = bg {
|
||||
dropdown.set_style(UiComponentStyles {
|
||||
background: Some(bg.into()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
dropdown
|
||||
});
|
||||
|
||||
let mut picker = Self {
|
||||
dropdown,
|
||||
default_value: default_value.clone(),
|
||||
fetch_epoch: 0,
|
||||
cached_main_branch: None,
|
||||
is_loading: false,
|
||||
};
|
||||
|
||||
// Synchronously show the default value immediately so the dropdown is
|
||||
// never empty while the async branch fetch is in flight (or if the
|
||||
// repo has no commits / is not a git repo).
|
||||
if let Some(ref default) = default_value {
|
||||
let default = default.clone();
|
||||
picker.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(
|
||||
vec![DropdownItem::new(default.clone(), default.clone())],
|
||||
ctx,
|
||||
);
|
||||
dropdown.set_selected_by_name(default.as_str(), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Kick off the async branch fetch to replace the placeholder with
|
||||
// the full list of branches from the repo.
|
||||
if let Some(cwd) = cwd {
|
||||
picker.fetch_branches(cwd, ctx);
|
||||
}
|
||||
|
||||
picker
|
||||
}
|
||||
|
||||
/// Fetches branches for `cwd` asynchronously and populates the dropdown.
|
||||
///
|
||||
/// On the first call for a given repo this runs `get_all_branches`, which
|
||||
/// internally calls `detect_main_branch` (up to 6 sequential subprocess calls).
|
||||
/// The detected main branch is cached and reused on all subsequent calls via
|
||||
/// `get_all_branches_with_known_main`, reducing each refetch to a single
|
||||
/// `git for-each-ref` invocation.
|
||||
///
|
||||
/// A `fetch_epoch` counter guards against stale results: if a second fetch
|
||||
/// completes before a slower first one, the first result is silently discarded.
|
||||
fn fetch_branches(&mut self, cwd: PathBuf, ctx: &mut ViewContext<Self>) {
|
||||
self.is_loading = true;
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_disabled(ctx);
|
||||
// Show loading text in the dropdown top bar so the modal
|
||||
// doesn't shift layout while the fetch is in-flight.
|
||||
let placeholder = DropdownItem::new(LOADING_PLACEHOLDER.to_string(), String::new());
|
||||
dropdown.set_items(vec![placeholder], ctx);
|
||||
dropdown.set_selected_by_name(LOADING_PLACEHOLDER, ctx);
|
||||
});
|
||||
|
||||
self.fetch_epoch += 1;
|
||||
let epoch = self.fetch_epoch;
|
||||
let known_main = self.cached_main_branch.clone();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let branches = match known_main {
|
||||
Some(ref main) => {
|
||||
DiffStateModel::get_all_branches_with_known_main(&cwd, main, None, false)
|
||||
.await
|
||||
}
|
||||
None => DiffStateModel::get_all_branches(&cwd, None, false).await,
|
||||
};
|
||||
|
||||
// git for-each-ref only lists refs backed by actual commits,
|
||||
// so a freshly initialised repo (`git init`, no commits yet)
|
||||
// returns an empty list even though HEAD points to a valid
|
||||
// branch name (e.g. "main"). Fall back to the current branch
|
||||
// so the picker still has a usable entry.
|
||||
match branches {
|
||||
Ok(ref list) if list.is_empty() => {
|
||||
if let Ok(current) = detect_current_branch(&cwd).await {
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(vec![(trimmed, true)]);
|
||||
}
|
||||
}
|
||||
branches
|
||||
}
|
||||
_ => branches,
|
||||
}
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
// Discard results from a superseded fetch.
|
||||
if me.fetch_epoch != epoch {
|
||||
return;
|
||||
}
|
||||
|
||||
me.is_loading = false;
|
||||
me.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_enabled(ctx);
|
||||
});
|
||||
|
||||
let branches = match result {
|
||||
Ok(branches) => branches,
|
||||
Err(err) => {
|
||||
log::warn!("BranchPicker: failed to fetch branches: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Cache the detected main branch for subsequent refetches so
|
||||
// detect_main_branch is only run once per repo.
|
||||
if me.cached_main_branch.is_none() {
|
||||
me.cached_main_branch = branches
|
||||
.iter()
|
||||
.find(|(_, is_main)| *is_main)
|
||||
.map(|(name, _)| name.clone());
|
||||
}
|
||||
|
||||
// Main branches first, then the rest in recency order.
|
||||
let mut items: Vec<DropdownItem<String>> =
|
||||
DiffStateModel::sort_branches_main_first(&branches)
|
||||
.map(|(name, _)| DropdownItem::new(name.clone(), name.clone()))
|
||||
.collect();
|
||||
|
||||
// Add the default as the first item if it isn't already in the list
|
||||
// (e.g. the user typed a branch name that doesn't exist locally yet).
|
||||
if let Some(ref default) = me.default_value {
|
||||
if !branches.iter().any(|(name, _)| name == default) {
|
||||
items.insert(0, DropdownItem::new(default.clone(), default.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which branch to auto-select: explicit default
|
||||
// first, then the detected main branch — but only if the
|
||||
// branch actually exists in the fetched list.
|
||||
let auto_select = me
|
||||
.default_value
|
||||
.clone()
|
||||
.or_else(|| me.cached_main_branch.clone())
|
||||
.filter(|name| items.iter().any(|item| item.display_text == *name));
|
||||
|
||||
me.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(items, ctx);
|
||||
|
||||
if let Some(ref name) = auto_select {
|
||||
dropdown.set_selected_by_name(name.as_str(), ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Emit the auto-selected value so parent views (e.g. the
|
||||
// worktree modal) update their state without requiring
|
||||
// an explicit user click.
|
||||
if let Some(ref name) = auto_select {
|
||||
ctx.emit(name.clone());
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Clears the current branch list and re-fetches for a new repo path.
|
||||
///
|
||||
/// Called when the user changes the repo selection in the params modal.
|
||||
/// Clears stale items immediately so the dropdown shows empty while loading.
|
||||
/// Also clears `cached_main_branch` because the new repo may have a different
|
||||
/// main branch name.
|
||||
pub fn refetch_branches(&mut self, new_cwd: PathBuf, ctx: &mut ViewContext<Self>) {
|
||||
self.default_value = None;
|
||||
self.cached_main_branch = None;
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(vec![], ctx);
|
||||
// Clear the cached selected_item so the closed dropdown no longer
|
||||
// shows a stale branch name from the previous repo.
|
||||
dropdown.set_selected_by_name("", ctx);
|
||||
});
|
||||
self.fetch_branches(new_cwd, ctx);
|
||||
}
|
||||
|
||||
pub fn toggle_dropdown(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.toggle_expanded(ctx);
|
||||
});
|
||||
self.dropdown.as_ref(ctx).is_expanded()
|
||||
}
|
||||
|
||||
pub fn selected_value(&self, app: &AppContext) -> Option<String> {
|
||||
// While loading, the dropdown shows a placeholder label
|
||||
// ("Fetching branches…") that must not be treated as a real
|
||||
// branch selection.
|
||||
if self.is_loading {
|
||||
return None;
|
||||
}
|
||||
self.dropdown.as_ref(app).selected_item_label()
|
||||
}
|
||||
|
||||
/// Returns `true` while an async branch fetch is in-flight.
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for BranchPicker {
|
||||
type Event = String;
|
||||
}
|
||||
|
||||
impl View for BranchPicker {
|
||||
fn ui_name() -> &'static str {
|
||||
"BranchPicker"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.dropdown).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for BranchPicker {
|
||||
type Action = String;
|
||||
|
||||
fn handle_action(&mut self, action: &String, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(action.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
pub(crate) mod action_sidecar;
|
||||
pub mod branch_picker;
|
||||
pub mod new_worktree_modal;
|
||||
pub mod params_modal;
|
||||
pub(crate) mod remove_confirmation_dialog;
|
||||
pub mod repo_picker;
|
||||
pub mod session_config;
|
||||
pub mod session_config_modal;
|
||||
pub mod session_config_rendering;
|
||||
pub mod tab_config;
|
||||
pub mod telemetry;
|
||||
|
||||
use warp_core::ui::theme::Fill;
|
||||
|
||||
pub use new_worktree_modal::{NewWorktreeModal, NewWorktreeModalEvent};
|
||||
pub use params_modal::{TabConfigParamsModal, TabConfigParamsModalEvent};
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) use tab_config::build_worktree_config_toml;
|
||||
pub use tab_config::{
|
||||
render_tab_config, TabConfig, TabConfigError, TabConfigParam, TabConfigParamType,
|
||||
};
|
||||
|
||||
/// Optional visual overrides for BranchPicker / RepoPicker dropdowns.
|
||||
pub struct PickerStyle {
|
||||
pub width: f32,
|
||||
pub background: Option<Fill>,
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element,
|
||||
Fill as ElementFill, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, Padding,
|
||||
ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
keymap::FixedBinding,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
checkbox::Checkbox,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
/// Registers keybindings for the new-worktree modal (ESC to close).
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
app.register_fixed_bindings(vec![FixedBinding::new(
|
||||
"escape",
|
||||
NewWorktreeModalAction::Escape,
|
||||
id!("NewWorktreeModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
|
||||
use crate::{
|
||||
ai::persisted_workspace::PersistedWorkspace,
|
||||
appearance::Appearance,
|
||||
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions},
|
||||
modal::ModalAction,
|
||||
tab_configs::{
|
||||
branch_picker::BranchPicker,
|
||||
repo_picker::{RepoPicker, RepoPickerEvent},
|
||||
},
|
||||
};
|
||||
|
||||
/// Gap between sections in the modal body (repo picker, branch picker, checkbox).
|
||||
const SECTION_GAP: f32 = 16.;
|
||||
/// Gap between a section label and its picker below.
|
||||
const LABEL_BOTTOM_MARGIN: f32 = 4.;
|
||||
/// Horizontal padding for the modal body and footer (matches Figma px-24).
|
||||
const CONTENT_HORIZONTAL_PADDING: f32 = 24.;
|
||||
/// Header top padding (Figma: pt-24).
|
||||
const HEADER_PADDING_TOP: f32 = 24.;
|
||||
/// Header bottom padding (Figma: pb-12).
|
||||
const HEADER_PADDING_BOTTOM: f32 = 12.;
|
||||
/// Header title font size (Figma: 16px bold).
|
||||
const HEADER_TITLE_FONT_SIZE: f32 = 16.;
|
||||
/// Bottom padding of the form area above the footer.
|
||||
const BODY_BOTTOM_PADDING: f32 = 16.;
|
||||
/// Vertical padding of the footer bar.
|
||||
const FOOTER_VERTICAL_PADDING: f32 = 12.;
|
||||
/// Checkbox outer size (Figma: 16px with 12px inner container).
|
||||
const CHECKBOX_SIZE: f32 = 16.;
|
||||
/// Height of footer buttons (Figma: h-32).
|
||||
const FOOTER_BUTTON_HEIGHT: f32 = 32.;
|
||||
/// Horizontal padding inside footer buttons (Figma: px-12).
|
||||
const FOOTER_BUTTON_HORIZONTAL_PADDING: f32 = 12.;
|
||||
/// Gap between Cancel and Open buttons (Figma: gap-8).
|
||||
const FOOTER_BUTTON_GAP: f32 = 8.;
|
||||
/// Corner radius for footer buttons (Figma: rounded-4).
|
||||
const FOOTER_BUTTON_RADIUS: Radius = Radius::Pixels(4.);
|
||||
/// Size of the ESC keyboard shortcut badge (Figma: 14px tall, 10px font).
|
||||
const ESC_BADGE_HEIGHT: f32 = 14.;
|
||||
const ESC_BADGE_FONT_SIZE: f32 = 10.;
|
||||
const ESC_BADGE_CORNER_RADIUS: Radius = Radius::Pixels(3.);
|
||||
/// Size of the close (X) icon in the header.
|
||||
const CLOSE_ICON_SIZE: f32 = 14.;
|
||||
/// Font size for inline validation error messages.
|
||||
const ERROR_FONT_SIZE: f32 = 12.;
|
||||
/// Error shown when the user-entered worktree branch name contains invalid characters.
|
||||
const INVALID_BRANCH_NAME_ERROR: &str =
|
||||
"Name can only contain letters, numbers, hyphens, and underscores";
|
||||
|
||||
/// Returns `true` if `name` is a valid worktree branch name.
|
||||
///
|
||||
/// Valid names contain only ASCII letters, digits, hyphens, and underscores.
|
||||
/// The name must also be non-empty after trimming whitespace.
|
||||
fn is_valid_worktree_branch_name(name: &str) -> bool {
|
||||
let trimmed = name.trim();
|
||||
!trimmed.is_empty()
|
||||
&& trimmed
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Body view for the "New worktree" modal.
|
||||
///
|
||||
/// Renders a repo picker, branch picker, auto-generate checkbox, and
|
||||
/// Cancel / Open footer. The workspace wraps this in a `Modal<NewWorktreeModal>`.
|
||||
pub struct NewWorktreeModal {
|
||||
repo_picker: ViewHandle<RepoPicker>,
|
||||
branch_picker: ViewHandle<BranchPicker>,
|
||||
worktree_name_editor: ViewHandle<EditorView>,
|
||||
autogenerate_branch_name: bool,
|
||||
selected_repo: Option<String>,
|
||||
selected_branch: Option<String>,
|
||||
cancel_button_mouse_state: MouseStateHandle,
|
||||
open_button_mouse_state: MouseStateHandle,
|
||||
checkbox_mouse_state: MouseStateHandle,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub enum NewWorktreeModalEvent {
|
||||
Close,
|
||||
Submit {
|
||||
repo: String,
|
||||
/// The base branch to create the worktree from.
|
||||
branch: String,
|
||||
/// `None` when autogenerate is enabled (the workspace handler
|
||||
/// will generate a name); `Some(name)` when the user typed a
|
||||
/// name manually.
|
||||
worktree_branch_name: Option<String>,
|
||||
},
|
||||
/// The user clicked "+ Add new repo..." in the repo picker; the workspace
|
||||
/// should open a folder picker and call [`NewWorktreeModal::on_new_repo_selected`].
|
||||
PickNewRepo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum NewWorktreeModalAction {
|
||||
Cancel,
|
||||
Open,
|
||||
ToggleAutogenerate,
|
||||
Escape,
|
||||
}
|
||||
|
||||
impl NewWorktreeModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let repo_picker = Self::build_repo_picker(None, ctx);
|
||||
let branch_picker = Self::build_branch_picker(None, ctx);
|
||||
let worktree_name_editor = Self::build_worktree_name_editor(ctx);
|
||||
|
||||
Self {
|
||||
repo_picker,
|
||||
branch_picker,
|
||||
worktree_name_editor,
|
||||
autogenerate_branch_name: true,
|
||||
selected_repo: None,
|
||||
selected_branch: None,
|
||||
cancel_button_mouse_state: Default::default(),
|
||||
open_button_mouse_state: Default::default(),
|
||||
checkbox_mouse_state: Default::default(),
|
||||
close_button_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_repo_picker(
|
||||
default: Option<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<RepoPicker> {
|
||||
let picker = ctx.add_typed_action_view(|ctx| RepoPicker::new(default, ctx));
|
||||
ctx.subscribe_to_view(&picker, |me, _, event, ctx| match event {
|
||||
RepoPickerEvent::Selected(value) => {
|
||||
me.selected_repo = Some(value.clone());
|
||||
me.selected_branch = None;
|
||||
me.branch_picker.update(ctx, |picker, ctx| {
|
||||
picker.refetch_branches(PathBuf::from(value.as_str()), ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
RepoPickerEvent::RequestAddRepo => {
|
||||
ctx.emit(NewWorktreeModalEvent::PickNewRepo);
|
||||
}
|
||||
});
|
||||
picker
|
||||
}
|
||||
|
||||
fn build_branch_picker(
|
||||
cwd: Option<PathBuf>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<BranchPicker> {
|
||||
let picker = ctx.add_typed_action_view(move |ctx| BranchPicker::new(cwd, None, ctx));
|
||||
ctx.subscribe_to_view(&picker, |me, _, value, ctx| {
|
||||
me.selected_branch = Some(value.clone());
|
||||
ctx.notify();
|
||||
});
|
||||
picker
|
||||
}
|
||||
|
||||
fn build_worktree_name_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
let options = SingleLineEditorOptions::default();
|
||||
let mut editor = EditorView::single_line(options, ctx);
|
||||
editor.set_placeholder_text("my-feature-branch", ctx);
|
||||
editor
|
||||
});
|
||||
ctx.subscribe_to_view(&editor, |me, _, event, ctx| match event {
|
||||
EditorEvent::Enter => me.try_submit(ctx),
|
||||
EditorEvent::Escape => ctx.emit(NewWorktreeModalEvent::Close),
|
||||
EditorEvent::Edited(_) => ctx.notify(),
|
||||
_ => {}
|
||||
});
|
||||
editor
|
||||
}
|
||||
|
||||
/// Called by the workspace before making the modal visible.
|
||||
pub fn on_open(&mut self, cwd: Option<PathBuf>, ctx: &mut ViewContext<Self>) {
|
||||
self.autogenerate_branch_name = true;
|
||||
self.selected_repo = None;
|
||||
self.selected_branch = None;
|
||||
self.worktree_name_editor.update(ctx, |e, ctx| {
|
||||
e.clear_buffer_and_reset_undo_stack(ctx);
|
||||
});
|
||||
|
||||
// Prefer the active session's cwd; fall back to the first known
|
||||
// workspace so that both pickers start populated even when no
|
||||
// terminal session is active yet.
|
||||
let effective_cwd = cwd.or_else(|| {
|
||||
PersistedWorkspace::as_ref(ctx)
|
||||
.workspaces()
|
||||
.next()
|
||||
.map(|ws| ws.path.clone())
|
||||
});
|
||||
|
||||
let default_repo = effective_cwd
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
self.repo_picker = Self::build_repo_picker(default_repo, ctx);
|
||||
self.branch_picker = Self::build_branch_picker(effective_cwd, ctx);
|
||||
|
||||
ctx.focus(&self.repo_picker);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Called by the workspace when the modal is dismissed.
|
||||
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.selected_repo = None;
|
||||
self.selected_branch = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Called by the workspace after the user adds a new repo via the folder picker.
|
||||
pub fn on_new_repo_selected(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
self.selected_repo = Some(path_str);
|
||||
self.repo_picker.update(ctx, |repo_picker, ctx| {
|
||||
repo_picker.refresh_and_select(path.clone(), ctx);
|
||||
});
|
||||
// Clear stale branch; refetch will auto-select the new repo's main
|
||||
// branch and emit it back via the subscription.
|
||||
self.selected_branch = None;
|
||||
self.branch_picker.update(ctx, |picker, ctx| {
|
||||
picker.refetch_branches(path, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn try_submit(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let repo = self
|
||||
.selected_repo
|
||||
.clone()
|
||||
.or_else(|| self.repo_picker.as_ref(ctx).selected_value(ctx));
|
||||
|
||||
let Some(repo) = repo else {
|
||||
return;
|
||||
};
|
||||
|
||||
let branch = self
|
||||
.selected_branch
|
||||
.clone()
|
||||
.or_else(|| self.branch_picker.as_ref(ctx).selected_value(ctx));
|
||||
|
||||
let Some(branch) = branch else {
|
||||
return;
|
||||
};
|
||||
|
||||
let worktree_branch_name = if self.autogenerate_branch_name {
|
||||
None
|
||||
} else {
|
||||
let text = self.worktree_name_editor.as_ref(ctx).buffer_text(ctx);
|
||||
if !is_valid_worktree_branch_name(&text) {
|
||||
return;
|
||||
}
|
||||
Some(text.trim().to_string())
|
||||
};
|
||||
|
||||
ctx.emit(NewWorktreeModalEvent::Submit {
|
||||
repo,
|
||||
branch,
|
||||
worktree_branch_name,
|
||||
});
|
||||
}
|
||||
|
||||
fn render_section_label(text: &str, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
text.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(LABEL_BOTTOM_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NewWorktreeModal {
|
||||
type Event = NewWorktreeModalEvent;
|
||||
}
|
||||
|
||||
impl View for NewWorktreeModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"NewWorktreeModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let has_repo = self.selected_repo.is_some()
|
||||
|| self.repo_picker.as_ref(app).selected_value(app).is_some();
|
||||
let has_branch = self.selected_branch.is_some()
|
||||
|| self.branch_picker.as_ref(app).selected_value(app).is_some();
|
||||
let worktree_name_text = self.worktree_name_editor.as_ref(app).buffer_text(app);
|
||||
let worktree_name_valid =
|
||||
self.autogenerate_branch_name || is_valid_worktree_branch_name(&worktree_name_text);
|
||||
let worktree_name_has_error = !self.autogenerate_branch_name
|
||||
&& !worktree_name_text.trim().is_empty()
|
||||
&& !is_valid_worktree_branch_name(&worktree_name_text);
|
||||
let can_submit = has_repo && has_branch && worktree_name_valid;
|
||||
|
||||
// ── Header (custom — Modal wrapper has no title) ────────────────
|
||||
let header = {
|
||||
let title = Text::new_inline(
|
||||
"New worktree".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
HEADER_TITLE_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// ESC keyboard shortcut badge (matches Figma keyboardBase component)
|
||||
let esc_badge = {
|
||||
let badge_bg = internal_colors::neutral_2(theme);
|
||||
let badge_text = Text::new_inline(
|
||||
"ESC".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
ESC_BADGE_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.foreground().into())
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(badge_text)
|
||||
.with_height(ESC_BADGE_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(2.)
|
||||
.with_background(badge_bg)
|
||||
.with_corner_radius(CornerRadius::with_all(ESC_BADGE_CORNER_RADIUS))
|
||||
.finish()
|
||||
};
|
||||
|
||||
// X close icon
|
||||
let close_icon = ConstrainedBox::new(
|
||||
warp_core::ui::Icon::X
|
||||
.to_warpui_icon(theme.sub_text_color(theme.background()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(CLOSE_ICON_SIZE)
|
||||
.with_height(CLOSE_ICON_SIZE)
|
||||
.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 = warpui::elements::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(HEADER_PADDING_TOP)
|
||||
.with_bottom(HEADER_PADDING_BOTTOM)
|
||||
.with_left(CONTENT_HORIZONTAL_PADDING)
|
||||
.with_right(CONTENT_HORIZONTAL_PADDING),
|
||||
)
|
||||
.finish()
|
||||
};
|
||||
|
||||
// ── Form body ───────────────────────────────────────────────────
|
||||
let mut body = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
// Repo picker
|
||||
body.add_child(Self::render_section_label("Select repository", appearance));
|
||||
body.add_child(ChildView::new(&self.repo_picker).finish());
|
||||
|
||||
// Branch picker (with gap)
|
||||
body.add_child(
|
||||
Container::new(Self::render_section_label("Select branch", appearance))
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
);
|
||||
body.add_child(ChildView::new(&self.branch_picker).finish());
|
||||
|
||||
// Checkbox
|
||||
let checkbox_label_color = theme.sub_text_color(theme.background());
|
||||
// Figma: 16px outer, 12px inner container, rounded 1.333px,
|
||||
// checked state: accent background, white checkmark.
|
||||
// Checkbox default has margin = font_size/2 on all sides; override
|
||||
// to zero so the checkbox aligns with the left edge of the labels.
|
||||
let zero_margin = Coords::uniform(0.);
|
||||
let checkbox_default = UiComponentStyles {
|
||||
font_size: Some(CHECKBOX_SIZE),
|
||||
border_width: Some(1.),
|
||||
border_color: Some(checkbox_label_color.into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(1.))),
|
||||
margin: Some(zero_margin),
|
||||
..Default::default()
|
||||
};
|
||||
let checkbox_checked = UiComponentStyles {
|
||||
font_size: Some(CHECKBOX_SIZE),
|
||||
background: Some(theme.accent_button_color().into()),
|
||||
font_color: Some(theme.main_text_color(theme.accent_button_color()).into()),
|
||||
border_width: Some(1.),
|
||||
border_color: Some(theme.accent_button_color().into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(1.))),
|
||||
margin: Some(zero_margin),
|
||||
..Default::default()
|
||||
};
|
||||
let checkbox_element = Checkbox::new(
|
||||
self.checkbox_mouse_state.clone(),
|
||||
checkbox_default,
|
||||
None,
|
||||
Some(checkbox_checked),
|
||||
None,
|
||||
)
|
||||
.check(self.autogenerate_branch_name)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(NewWorktreeModalAction::ToggleAutogenerate);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let checkbox_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(4.)
|
||||
.with_child(checkbox_element)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
"Autogenerate worktree branch name".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(checkbox_label_color.into())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
body.add_child(
|
||||
Container::new(checkbox_row)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Worktree branch name text field — shown when autogenerate is unchecked.
|
||||
if !self.autogenerate_branch_name {
|
||||
body.add_child(
|
||||
Container::new(Self::render_section_label(
|
||||
"Worktree branch name",
|
||||
appearance,
|
||||
))
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
);
|
||||
body.add_child(ChildView::new(&self.worktree_name_editor).finish());
|
||||
|
||||
if worktree_name_has_error {
|
||||
body.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
INVALID_BRANCH_NAME_ERROR.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
ERROR_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.ui_error_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(LABEL_BOTTOM_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let body_container = Container::new(body.finish())
|
||||
.with_padding(
|
||||
Padding::uniform(0.)
|
||||
.with_left(CONTENT_HORIZONTAL_PADDING)
|
||||
.with_right(CONTENT_HORIZONTAL_PADDING)
|
||||
.with_bottom(BODY_BOTTOM_PADDING),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────
|
||||
// Figma: text-only buttons, semibold 14px, h-32, px-12, no background.
|
||||
// Cancel uses main text color; Open uses disabled text color when no repo.
|
||||
let text_button_base = UiComponentStyles {
|
||||
font_size: Some(appearance.ui_font_size() + 2.),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
height: Some(FOOTER_BUTTON_HEIGHT),
|
||||
padding: Some(
|
||||
Coords::uniform(0.)
|
||||
.left(FOOTER_BUTTON_HORIZONTAL_PADDING)
|
||||
.right(FOOTER_BUTTON_HORIZONTAL_PADDING),
|
||||
),
|
||||
background: Some(ElementFill::None),
|
||||
border_width: Some(0.),
|
||||
border_radius: Some(CornerRadius::with_all(FOOTER_BUTTON_RADIUS)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let main_text = theme.main_text_color(theme.background());
|
||||
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, self.cancel_button_mouse_state.clone())
|
||||
.with_text_label("Cancel".to_string())
|
||||
.with_style(text_button_base)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(main_text.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(NewWorktreeModalAction::Cancel);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let open_button = {
|
||||
let font_color = if can_submit {
|
||||
main_text
|
||||
} else {
|
||||
theme.disabled_text_color(theme.background())
|
||||
};
|
||||
|
||||
let mut builder = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, self.open_button_mouse_state.clone())
|
||||
.with_text_label("Open".to_string())
|
||||
.with_style(text_button_base)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(font_color.into()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if !can_submit {
|
||||
builder = builder.with_cursor(None);
|
||||
}
|
||||
|
||||
if can_submit {
|
||||
builder
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(NewWorktreeModalAction::Open);
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
builder.build().disable().finish()
|
||||
}
|
||||
};
|
||||
|
||||
// The border-top spans the full modal width; horizontal padding
|
||||
// is only on the button row inside.
|
||||
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(FOOTER_BUTTON_GAP)
|
||||
.with_child(cancel_button)
|
||||
.with_child(open_button)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding(
|
||||
Padding::uniform(FOOTER_VERTICAL_PADDING)
|
||||
.with_left(CONTENT_HORIZONTAL_PADDING)
|
||||
.with_right(CONTENT_HORIZONTAL_PADDING),
|
||||
)
|
||||
.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 NewWorktreeModal {
|
||||
type Action = NewWorktreeModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
NewWorktreeModalAction::Cancel | NewWorktreeModalAction::Escape => {
|
||||
ctx.emit(NewWorktreeModalEvent::Close);
|
||||
}
|
||||
NewWorktreeModalAction::Open => self.try_submit(ctx),
|
||||
NewWorktreeModalAction::ToggleAutogenerate => {
|
||||
self.autogenerate_branch_name = !self.autogenerate_branch_name;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
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) = ¶m.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) = ¶m.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, ¶m_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use super::resolve_param_value;
|
||||
use crate::tab_configs::{TabConfigParam, TabConfigParamType};
|
||||
|
||||
#[test]
|
||||
fn resolve_param_value_returns_default_for_blank_input() {
|
||||
let param = TabConfigParam {
|
||||
description: None,
|
||||
default: Some("main".to_string()),
|
||||
param_type: TabConfigParamType::Text,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_param_value(" ".to_string(), ¶m),
|
||||
Some("main".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_param_value_returns_none_for_blank_required_input() {
|
||||
let param = TabConfigParam {
|
||||
description: None,
|
||||
default: None,
|
||||
param_type: TabConfigParamType::Text,
|
||||
};
|
||||
|
||||
assert_eq!(resolve_param_value("".to_string(), ¶m), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_param_value_preserves_non_blank_input() {
|
||||
let param = TabConfigParam {
|
||||
description: None,
|
||||
default: Some("main".to_string()),
|
||||
param_type: TabConfigParamType::Text,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_param_value("feature-branch".to_string(), ¶m),
|
||||
Some("feature-branch".to_string())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ChildAnchor, ChildView, Container, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, Stack,
|
||||
},
|
||||
keymap::{FixedBinding, Keystroke},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
view_components::action_button::{
|
||||
ActionButton, DangerPrimaryTheme, KeystrokeSource, NakedTheme,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
RemoveTabConfigConfirmationAction::Cancel,
|
||||
id!(RemoveTabConfigConfirmationDialog::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
RemoveTabConfigConfirmationAction::Confirm,
|
||||
id!(RemoveTabConfigConfirmationDialog::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const DIALOG_WIDTH: f32 = 460.;
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub(crate) enum RemoveTabConfigConfirmationEvent {
|
||||
Confirm { path: PathBuf },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RemoveTabConfigConfirmationAction {
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub(crate) struct RemoveTabConfigConfirmationDialog {
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
confirm_button: ViewHandle<ActionButton>,
|
||||
config_name: String,
|
||||
config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl RemoveTabConfigConfirmationDialog {
|
||||
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(RemoveTabConfigConfirmationAction::Cancel);
|
||||
})
|
||||
});
|
||||
|
||||
let enter_keystroke = Keystroke::parse("enter").expect("Valid keystroke");
|
||||
let confirm_button = ctx.add_typed_action_view(|ctx| {
|
||||
ActionButton::new("Remove", DangerPrimaryTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter_keystroke), ctx)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(RemoveTabConfigConfirmationAction::Confirm);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
cancel_button,
|
||||
confirm_button,
|
||||
config_name: String::new(),
|
||||
config_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_config(&mut self, name: String, path: PathBuf) {
|
||||
self.config_name = name;
|
||||
self.config_path = Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RemoveTabConfigConfirmationDialog {
|
||||
type Event = RemoveTabConfigConfirmationEvent;
|
||||
}
|
||||
|
||||
impl View for RemoveTabConfigConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"RemoveTabConfigConfirmationDialog"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let cancel_button = Container::new(ChildView::new(&self.cancel_button).finish())
|
||||
.with_margin_right(12.)
|
||||
.finish();
|
||||
|
||||
let title = format!("Remove '{}'?", self.config_name);
|
||||
|
||||
let dialog = Dialog::new(
|
||||
title,
|
||||
Some(
|
||||
"This tab config will be permanently deleted. This action cannot be undone.".into(),
|
||||
),
|
||||
UiComponentStyles {
|
||||
width: Some(DIALOG_WIDTH),
|
||||
..dialog_styles(appearance)
|
||||
},
|
||||
)
|
||||
.with_bottom_row_child(cancel_button)
|
||||
.with_bottom_row_child(ChildView::new(&self.confirm_button).finish())
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
dialog,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for RemoveTabConfigConfirmationDialog {
|
||||
type Action = RemoveTabConfigConfirmationAction;
|
||||
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: &RemoveTabConfigConfirmationAction,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match action {
|
||||
RemoveTabConfigConfirmationAction::Confirm => {
|
||||
let Some(path) = self.config_path.clone() else {
|
||||
log::error!("Remove confirm button pressed with no config path");
|
||||
return;
|
||||
};
|
||||
ctx.emit(RemoveTabConfigConfirmationEvent::Confirm { path });
|
||||
}
|
||||
RemoveTabConfigConfirmationAction::Cancel => {
|
||||
ctx.emit(RemoveTabConfigConfirmationEvent::Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::{
|
||||
elements::{Border, ChildView, Container, Hoverable, MouseStateHandle, Text},
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponentStyles,
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::persisted_workspace::{PersistedWorkspace, PersistedWorkspaceEvent},
|
||||
appearance::Appearance,
|
||||
tab_configs::PickerStyle,
|
||||
view_components::{DropdownItem, FilterableDropdown},
|
||||
};
|
||||
|
||||
const DEFAULT_DROPDOWN_WIDTH: f32 = 380.;
|
||||
|
||||
/// Label for the sticky "Add new repo..." footer at the bottom of the picker.
|
||||
const ADD_NEW_REPO_LABEL: &str = "+ Add new repo...";
|
||||
|
||||
/// A filterable dropdown listing known repos (from `PersistedWorkspace`), with a
|
||||
/// sticky "+ Add new repo..." footer that is always visible even when scrolling.
|
||||
///
|
||||
/// Emits:
|
||||
/// - [`RepoPickerEvent::Selected`] when the user picks a repo path.
|
||||
/// - [`RepoPickerEvent::RequestAddRepo`] when the user clicks "+ Add new repo...".
|
||||
pub struct RepoPicker {
|
||||
dropdown: ViewHandle<FilterableDropdown<RepoPickerAction>>,
|
||||
/// The currently selected repo path (updated by `handle_action`).
|
||||
selected: Option<String>,
|
||||
/// Mouse state for the sticky "Add new repo..." footer row.
|
||||
add_repo_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RepoPickerAction {
|
||||
Select(String),
|
||||
AddNewRepo,
|
||||
}
|
||||
|
||||
pub enum RepoPickerEvent {
|
||||
Selected(String),
|
||||
RequestAddRepo,
|
||||
}
|
||||
|
||||
impl RepoPicker {
|
||||
/// Creates a new picker pre-populated with all known projects.
|
||||
///
|
||||
/// `default_value` is pre-selected if it appears in the project list (or is
|
||||
/// added as an extra entry if it doesn't).
|
||||
pub fn new(default_value: Option<String>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self::new_with_style(default_value, None, ctx)
|
||||
}
|
||||
|
||||
pub fn new_with_style(
|
||||
default_value: Option<String>,
|
||||
style: Option<PickerStyle>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Subscribe to PersistedWorkspace so the list refreshes when the user
|
||||
// adds a repo via the folder picker.
|
||||
ctx.subscribe_to_model(&PersistedWorkspace::handle(ctx), |me, _, event, ctx| {
|
||||
if let PersistedWorkspaceEvent::WorkspaceAdded { path } = event {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
me.refresh_items(Some(&path_str), ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let width = style.as_ref().map_or(DEFAULT_DROPDOWN_WIDTH, |s| s.width);
|
||||
let bg = style.and_then(|s| s.background);
|
||||
let dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = FilterableDropdown::new(ctx);
|
||||
dropdown.set_top_bar_max_width(width);
|
||||
dropdown.set_menu_width(width, ctx);
|
||||
if let Some(bg) = bg {
|
||||
dropdown.set_style(UiComponentStyles {
|
||||
background: Some(bg.into()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
dropdown
|
||||
});
|
||||
|
||||
let mut picker = Self {
|
||||
dropdown,
|
||||
selected: None,
|
||||
add_repo_mouse_state: Default::default(),
|
||||
};
|
||||
|
||||
// Attach the sticky footer. It stays visible while scrolling because it is
|
||||
// rendered below the scrollable items but inside the Menu's Dismiss
|
||||
// (via FilterableDropdown::set_footer → Menu::set_pinned_footer_builder).
|
||||
// Being inside the Dismiss means clicks on it do not trigger the dismiss
|
||||
// handler, so the standard on_click / LeftMouseUp path works correctly.
|
||||
let mouse_state = picker.add_repo_mouse_state.clone();
|
||||
picker.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_footer(
|
||||
move |app| {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let is_hovered = mouse_state.lock().unwrap().is_hovered();
|
||||
let bg = if is_hovered {
|
||||
theme.accent_button_color()
|
||||
} else {
|
||||
theme.surface_2()
|
||||
};
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
let text_color = theme.main_text_color(bg);
|
||||
let border_fill = theme.outline();
|
||||
let mouse_state_clone = mouse_state.clone();
|
||||
Hoverable::new(mouse_state_clone, move |_| {
|
||||
Container::new(
|
||||
Text::new_inline(ADD_NEW_REPO_LABEL, font_family, font_size)
|
||||
.with_color(text_color.into())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_background(bg)
|
||||
.with_border(Border::top(1.).with_border_fill(border_fill))
|
||||
.finish()
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(RepoPickerAction::AddNewRepo);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
picker.refresh_items(default_value.as_deref(), ctx);
|
||||
picker
|
||||
}
|
||||
|
||||
/// Refreshes the dropdown list from `PersistedWorkspace` and optionally
|
||||
/// pre-selects a specific path.
|
||||
pub fn refresh_and_select(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
self.refresh_items(Some(&path_str), ctx);
|
||||
}
|
||||
|
||||
fn refresh_items(&mut self, select_path: Option<&str>, ctx: &mut ViewContext<Self>) {
|
||||
// workspaces() already returns entries sorted by most-recently-touched.
|
||||
// "+ Add new repo..." is a sticky footer (not a list item) so it is
|
||||
// not included here.
|
||||
let items: Vec<DropdownItem<RepoPickerAction>> = PersistedWorkspace::as_ref(ctx)
|
||||
.workspaces()
|
||||
.filter(|ws| ws.path.exists())
|
||||
.map(|ws| {
|
||||
let path_str = ws.path.to_string_lossy().into_owned();
|
||||
DropdownItem::new(path_str.clone(), RepoPickerAction::Select(path_str))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let path_to_select = select_path
|
||||
.or(self.selected.as_deref())
|
||||
.map(|s| s.to_owned());
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(items, ctx);
|
||||
if let Some(ref path) = path_to_select {
|
||||
dropdown.set_selected_by_name(path.as_str(), ctx);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_dropdown(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.toggle_expanded(ctx);
|
||||
});
|
||||
self.dropdown.as_ref(ctx).is_expanded()
|
||||
}
|
||||
|
||||
/// Returns the currently shown selected repo path.
|
||||
pub fn selected_value(&self, app: &AppContext) -> Option<String> {
|
||||
self.selected
|
||||
.clone()
|
||||
.or_else(|| self.dropdown.as_ref(app).selected_item_label())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RepoPicker {
|
||||
type Event = RepoPickerEvent;
|
||||
}
|
||||
|
||||
impl View for RepoPicker {
|
||||
fn ui_name() -> &'static str {
|
||||
"RepoPicker"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.dropdown).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for RepoPicker {
|
||||
type Action = RepoPickerAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
RepoPickerAction::Select(value) => {
|
||||
self.selected = Some(value.clone());
|
||||
ctx.emit(RepoPickerEvent::Selected(value.clone()));
|
||||
}
|
||||
RepoPickerAction::AddNewRepo => {
|
||||
// Close the dropdown before the folder picker opens so the two
|
||||
// don't compete for focus.
|
||||
self.dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.close(ctx);
|
||||
});
|
||||
ctx.emit(RepoPickerEvent::RequestAddRepo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::app_state::{BranchSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot};
|
||||
use crate::launch_configs::launch_config::SplitDirection;
|
||||
use crate::terminal::cli_agent::CLIAgent;
|
||||
use crate::themes::theme::AnsiColorIdentifier;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
use super::tab_config::{
|
||||
generated_worktree_path_string, TabConfig, TabConfigPaneNode, TabConfigPaneType,
|
||||
TabConfigParam, TabConfigParamType, AUTOGENERATED_BRANCH_NAME_PARAM,
|
||||
};
|
||||
|
||||
/// The type of session the user wants to start.
|
||||
///
|
||||
/// Wraps the existing `CLIAgent` for third-party agents and adds
|
||||
/// Terminal and Oz as first-class variants.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SessionType {
|
||||
Terminal,
|
||||
Oz,
|
||||
CliAgent(CLIAgent),
|
||||
}
|
||||
|
||||
impl SessionType {
|
||||
/// The CLI command to auto-run for this session type, if any.
|
||||
/// Returns `None` for Terminal and Oz (Oz uses agent view, not a CLI command).
|
||||
fn command_prefix(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
SessionType::Terminal | SessionType::Oz => None,
|
||||
SessionType::CliAgent(agent) => Some(agent.command_prefix()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon to display for this session type.
|
||||
pub(crate) fn icon(&self) -> Icon {
|
||||
match self {
|
||||
SessionType::Terminal => Icon::Terminal,
|
||||
SessionType::Oz => Icon::Oz,
|
||||
SessionType::CliAgent(agent) => agent.icon().unwrap_or(Icon::Terminal),
|
||||
}
|
||||
}
|
||||
|
||||
/// Short label for the session type pill in the modal.
|
||||
pub(crate) fn pill_label(&self) -> &'static str {
|
||||
match self {
|
||||
SessionType::Terminal => "Terminal",
|
||||
SessionType::Oz => "Built in agent",
|
||||
SessionType::CliAgent(CLIAgent::Claude) => "Claude",
|
||||
SessionType::CliAgent(CLIAgent::Codex) => "Codex",
|
||||
SessionType::CliAgent(CLIAgent::Gemini) => "Gemini",
|
||||
SessionType::CliAgent(agent) => agent.display_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's selections from the session config modal.
|
||||
///
|
||||
/// This is the modal's output — the caller decides what to do with it.
|
||||
pub struct SessionConfigSelection {
|
||||
pub session_type: SessionType,
|
||||
pub directory: PathBuf,
|
||||
pub enable_worktree: bool,
|
||||
pub autogenerate_worktree_branch_name: bool,
|
||||
}
|
||||
|
||||
const WORKTREE_BRANCH_PARAM: &str = "worktree_branch_name";
|
||||
const WORKTREE_BRANCH_DEFAULT: &str = "my-feature-branch";
|
||||
|
||||
// Rust `format!` treats `{...}` as interpolation syntax, so we escape braces
|
||||
// to emit the literal Handlebars placeholder `{{name}}` for the later
|
||||
// tab-config render pass. In `format!("{{{{{name}}}}}")`, the outer doubled
|
||||
// braces become literal `{` / `}`, and `{name}` interpolates the helper arg.
|
||||
fn handlebars_placeholder(name: &str) -> String {
|
||||
format!("{{{{{name}}}}}")
|
||||
}
|
||||
|
||||
/// Derives a human-readable config name from the directory and worktree setting.
|
||||
/// e.g. "Worktree: my-repo" or "New tab: my-repo".
|
||||
fn config_name(directory: &Path, enable_worktree: bool) -> String {
|
||||
let repo = directory
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.or_else(|| directory.to_str())
|
||||
.unwrap_or("untitled");
|
||||
let prefix = if enable_worktree {
|
||||
"Worktree"
|
||||
} else {
|
||||
"New tab"
|
||||
};
|
||||
format!("{prefix}: {repo}")
|
||||
}
|
||||
|
||||
/// Builds a `TabConfig` from the given session parameters.
|
||||
///
|
||||
/// This is a pure function — it produces the canonical `TabConfig`
|
||||
/// that can be either rendered directly (via `render_tab_config`) or
|
||||
/// serialized to disk (via `write_tab_config`).
|
||||
pub fn build_tab_config(
|
||||
session_type: &SessionType,
|
||||
directory: &Path,
|
||||
enable_worktree: bool,
|
||||
autogenerate_worktree_branch_name: bool,
|
||||
) -> TabConfig {
|
||||
let mut commands: Vec<String> = Vec::new();
|
||||
let mut params = HashMap::new();
|
||||
let mut title = None;
|
||||
|
||||
if enable_worktree {
|
||||
if autogenerate_worktree_branch_name {
|
||||
let autogenerated_branch_name = handlebars_placeholder(AUTOGENERATED_BRANCH_NAME_PARAM);
|
||||
let worktree_path =
|
||||
generated_worktree_path_string(directory, &autogenerated_branch_name);
|
||||
commands.push(format!(
|
||||
"git worktree add -b {autogenerated_branch_name} {worktree_path}"
|
||||
));
|
||||
commands.push(format!("cd {worktree_path}"));
|
||||
} else {
|
||||
let worktree_branch_name = handlebars_placeholder(WORKTREE_BRANCH_PARAM);
|
||||
let worktree_path = generated_worktree_path_string(directory, &worktree_branch_name);
|
||||
commands.push(format!(
|
||||
"git worktree add -b {worktree_branch_name} {worktree_path}"
|
||||
));
|
||||
commands.push(format!("cd {worktree_path}"));
|
||||
|
||||
params.insert(
|
||||
WORKTREE_BRANCH_PARAM.to_string(),
|
||||
TabConfigParam {
|
||||
description: Some("New worktree branch name".to_string()),
|
||||
default: Some(WORKTREE_BRANCH_DEFAULT.to_string()),
|
||||
param_type: TabConfigParamType::Text,
|
||||
},
|
||||
);
|
||||
title = Some(worktree_branch_name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prefix) = session_type.command_prefix() {
|
||||
commands.push(prefix.to_string());
|
||||
}
|
||||
|
||||
let pane_type = match session_type {
|
||||
SessionType::Oz => TabConfigPaneType::Agent,
|
||||
SessionType::Terminal | SessionType::CliAgent(_) => TabConfigPaneType::Terminal,
|
||||
};
|
||||
|
||||
TabConfig {
|
||||
name: config_name(directory, enable_worktree),
|
||||
title,
|
||||
color: None,
|
||||
panes: vec![TabConfigPaneNode {
|
||||
id: "main".to_string(),
|
||||
pane_type: Some(pane_type),
|
||||
split: None,
|
||||
children: None,
|
||||
is_focused: None,
|
||||
directory: Some(directory.to_string_lossy().into_owned()),
|
||||
commands: if commands.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(commands)
|
||||
},
|
||||
shell: None,
|
||||
}],
|
||||
params,
|
||||
source_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes a `TabConfig` to TOML and writes it to an unused path in `dir`.
|
||||
///
|
||||
/// Creates `dir` if it doesn't exist. Returns the path of the written file.
|
||||
/// The filesystem watcher will automatically pick up the new file.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn write_tab_config(config: &TabConfig, dir: &Path, base_name: &str) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = crate::user_config::find_unused_toml_path(dir, base_name);
|
||||
let toml_string = toml::to_string_pretty(config)?;
|
||||
std::fs::write(&path, toml_string)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Returns whether the given directory is inside a git repository.
|
||||
///
|
||||
/// Checks for a `.git` directory at the path itself and walks up
|
||||
/// parent directories.
|
||||
pub fn is_git_repo(path: &Path) -> bool {
|
||||
let mut current = path;
|
||||
loop {
|
||||
if current.join(".git").is_dir() {
|
||||
return true;
|
||||
}
|
||||
match current.parent() {
|
||||
Some(parent) if parent != current => current = parent,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `TabConfig` from a live tab's pane tree snapshot.
|
||||
///
|
||||
/// Walks the `PaneNodeSnapshot` recursively and produces a flat `[[panes]]`
|
||||
/// array. Non-terminal leaves (notebook, code, settings, etc.) are replaced
|
||||
/// with empty terminal panes to preserve the spatial layout.
|
||||
pub fn tab_config_from_pane_snapshot(
|
||||
snapshot: &PaneNodeSnapshot,
|
||||
custom_title: Option<String>,
|
||||
color: Option<AnsiColorIdentifier>,
|
||||
) -> TabConfig {
|
||||
let mut panes = Vec::new();
|
||||
let mut counter: usize = 0;
|
||||
snapshot_to_flat_panes(snapshot, &mut panes, &mut counter);
|
||||
|
||||
TabConfig {
|
||||
name: "My Tab Config".to_string(),
|
||||
title: custom_title,
|
||||
color,
|
||||
panes,
|
||||
params: HashMap::new(),
|
||||
source_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively converts a `PaneNodeSnapshot` into flat `TabConfigPaneNode` entries.
|
||||
/// Returns the ID assigned to the root of this subtree.
|
||||
fn snapshot_to_flat_panes(
|
||||
snapshot: &PaneNodeSnapshot,
|
||||
panes: &mut Vec<TabConfigPaneNode>,
|
||||
counter: &mut usize,
|
||||
) -> String {
|
||||
match snapshot {
|
||||
PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction,
|
||||
children,
|
||||
}) => {
|
||||
*counter += 1;
|
||||
let my_id = format!("p{counter}");
|
||||
|
||||
// Record where this subtree starts so we can insert the split node
|
||||
// before all of its descendants (root-first ordering).
|
||||
let insert_pos = panes.len();
|
||||
|
||||
// Recurse into children first to collect their IDs.
|
||||
let child_ids: Vec<String> = children
|
||||
.iter()
|
||||
.map(|(_, child)| snapshot_to_flat_panes(child, panes, counter))
|
||||
.collect();
|
||||
|
||||
let split_direction = match direction {
|
||||
crate::app_state::SplitDirection::Horizontal => SplitDirection::Horizontal,
|
||||
crate::app_state::SplitDirection::Vertical => SplitDirection::Vertical,
|
||||
};
|
||||
panes.insert(
|
||||
insert_pos,
|
||||
TabConfigPaneNode {
|
||||
id: my_id.clone(),
|
||||
pane_type: None,
|
||||
split: Some(split_direction),
|
||||
children: Some(child_ids),
|
||||
is_focused: None,
|
||||
directory: None,
|
||||
commands: None,
|
||||
shell: None,
|
||||
},
|
||||
);
|
||||
|
||||
my_id
|
||||
}
|
||||
PaneNodeSnapshot::Leaf(LeafSnapshot {
|
||||
is_focused,
|
||||
custom_vertical_tabs_title: _,
|
||||
contents,
|
||||
}) => {
|
||||
*counter += 1;
|
||||
let my_id = format!("p{counter}");
|
||||
|
||||
let (directory, pane_type) = match contents {
|
||||
LeafContents::Terminal(terminal) => {
|
||||
// If the agent view was open in fullscreen, treat as an Agent pane.
|
||||
let pane_type = if terminal.active_conversation_id.is_some() {
|
||||
TabConfigPaneType::Agent
|
||||
} else {
|
||||
TabConfigPaneType::Terminal
|
||||
};
|
||||
(terminal.cwd.clone(), pane_type)
|
||||
}
|
||||
LeafContents::AmbientAgent(_) => (None, TabConfigPaneType::Cloud),
|
||||
// Non-terminal panes become empty terminal panes to preserve layout.
|
||||
_ => (None, TabConfigPaneType::Terminal),
|
||||
};
|
||||
|
||||
panes.push(TabConfigPaneNode {
|
||||
id: my_id.clone(),
|
||||
pane_type: Some(pane_type),
|
||||
split: None,
|
||||
children: None,
|
||||
is_focused: if *is_focused { Some(true) } else { None },
|
||||
directory,
|
||||
commands: None,
|
||||
shell: None,
|
||||
});
|
||||
|
||||
my_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_config_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,391 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex,
|
||||
FormattedTextElement, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::macros::id;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::platform::file_picker::FilePickerConfiguration;
|
||||
use warpui::FocusContext;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
|
||||
};
|
||||
|
||||
use super::session_config::{is_git_repo, SessionConfigSelection, SessionType};
|
||||
use super::session_config_rendering;
|
||||
|
||||
pub fn init(app: &mut warpui::AppContext) {
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"enter",
|
||||
SessionConfigModalAction::Submit,
|
||||
id!(SessionConfigModal::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
const SECTION_GAP: f32 = 16.;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SessionConfigModalAction {
|
||||
SelectSessionType(usize),
|
||||
OpenDirectoryPicker,
|
||||
DirectorySelected(Result<String, warpui::platform::file_picker::FilePickerError>),
|
||||
ToggleWorktree,
|
||||
ToggleAutogenerateWorktreeBranchName,
|
||||
Submit,
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
pub enum SessionConfigModalEvent {
|
||||
Completed(SessionConfigSelection),
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct SessionConfigModal {
|
||||
session_types: Vec<SessionType>,
|
||||
selected_session_type_index: usize,
|
||||
selected_directory: PathBuf,
|
||||
is_git_repo: bool,
|
||||
enable_worktree: bool,
|
||||
autogenerate_worktree_branch_name: bool,
|
||||
/// When `false`, the session type pill row is hidden and the session type
|
||||
/// defaults to Terminal behind the scenes (used when Oz is disabled).
|
||||
show_session_type_row: bool,
|
||||
session_pill_mouse_states: Vec<MouseStateHandle>,
|
||||
directory_button_mouse_state: MouseStateHandle,
|
||||
worktree_checkbox_mouse_state: MouseStateHandle,
|
||||
autogenerate_worktree_branch_name_checkbox_mouse_state: MouseStateHandle,
|
||||
autogenerate_tooltip_mouse_state: MouseStateHandle,
|
||||
worktree_tooltip_mouse_state: MouseStateHandle,
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
submit_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl SessionConfigModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||
let session_types = session_config_rendering::visible_session_types(true);
|
||||
|
||||
let close_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("", NakedTheme)
|
||||
.with_icon(crate::ui_components::icons::Icon::X)
|
||||
.with_size(ButtonSize::Small)
|
||||
.with_keybinding(
|
||||
KeystrokeSource::Fixed(Keystroke::parse("escape").unwrap_or_default()),
|
||||
ctx,
|
||||
)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(SessionConfigModalAction::Dismiss))
|
||||
});
|
||||
|
||||
let submit_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Get Warping", PrimaryTheme)
|
||||
.with_full_width(true)
|
||||
.with_keybinding(
|
||||
KeystrokeSource::Fixed(Keystroke::parse("enter").unwrap_or_default()),
|
||||
ctx,
|
||||
)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(SessionConfigModalAction::Submit))
|
||||
});
|
||||
|
||||
let pill_mouse_states = session_types
|
||||
.iter()
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
session_types,
|
||||
selected_session_type_index: 0,
|
||||
selected_directory: home,
|
||||
// Filled in by `configure()` before the modal is shown.
|
||||
is_git_repo: false,
|
||||
enable_worktree: false,
|
||||
autogenerate_worktree_branch_name: false,
|
||||
show_session_type_row: true,
|
||||
session_pill_mouse_states: pill_mouse_states,
|
||||
directory_button_mouse_state: MouseStateHandle::default(),
|
||||
worktree_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
autogenerate_worktree_branch_name_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
autogenerate_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
worktree_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
close_button,
|
||||
submit_button,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconfigures the visible session types based on whether Oz is available.
|
||||
/// Resets the selection to index 0 (the first available type).
|
||||
/// When Oz is disabled, hides the session type row entirely and defaults
|
||||
/// to Terminal behind the scenes.
|
||||
pub fn configure(&mut self, show_oz: bool) {
|
||||
self.show_session_type_row = show_oz;
|
||||
self.session_types = session_config_rendering::visible_session_types(show_oz);
|
||||
self.selected_session_type_index = 0;
|
||||
self.session_pill_mouse_states = self
|
||||
.session_types
|
||||
.iter()
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
self.is_git_repo = is_git_repo(&self.selected_directory);
|
||||
}
|
||||
|
||||
fn selected_session_type(&self) -> SessionType {
|
||||
self.session_types[self.selected_session_type_index]
|
||||
}
|
||||
|
||||
fn update_directory(&mut self, path: PathBuf) {
|
||||
self.is_git_repo = is_git_repo(&path);
|
||||
if !self.is_git_repo {
|
||||
self.enable_worktree = false;
|
||||
self.autogenerate_worktree_branch_name = false;
|
||||
}
|
||||
self.selected_directory = path;
|
||||
}
|
||||
|
||||
fn submit(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(SessionConfigModalEvent::Completed(SessionConfigSelection {
|
||||
session_type: self.selected_session_type(),
|
||||
directory: self.selected_directory.clone(),
|
||||
enable_worktree: self.enable_worktree,
|
||||
autogenerate_worktree_branch_name: self.autogenerate_worktree_branch_name,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let title = FormattedTextElement::from_str(
|
||||
"Create your first tab config",
|
||||
appearance.ui_font_family(),
|
||||
24.,
|
||||
)
|
||||
.with_color(blended_colors::text_main(theme, theme.background()))
|
||||
.with_weight(Weight::Semibold)
|
||||
.finish();
|
||||
|
||||
let subtitle_text = if self.show_session_type_row {
|
||||
"Set up a reusable starting point for your tabs. \
|
||||
Pick a repo, choose a session type, and optionally attach a worktree. \
|
||||
Use it whenever you want to open a new tab with this setup."
|
||||
} else {
|
||||
"Set up a reusable starting point for your tabs. \
|
||||
Pick a repo, optionally attach a worktree, and \
|
||||
use it whenever you want to open a new tab with this setup."
|
||||
};
|
||||
let subtitle =
|
||||
FormattedTextElement::from_str(subtitle_text, appearance.ui_font_family(), 14.)
|
||||
.with_color(blended_colors::text_sub(theme, theme.background()))
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(subtitle).with_margin_top(4.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_session_type_section(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
session_config_rendering::render_session_type_pills(
|
||||
&self.session_types,
|
||||
self.selected_session_type_index,
|
||||
&self.session_pill_mouse_states,
|
||||
|i, ctx, _| {
|
||||
ctx.dispatch_typed_action(SessionConfigModalAction::SelectSessionType(i));
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_directory_section(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
session_config_rendering::render_directory_picker(
|
||||
&self.selected_directory,
|
||||
self.directory_button_mouse_state.clone(),
|
||||
|ctx, _| {
|
||||
ctx.dispatch_typed_action(SessionConfigModalAction::OpenDirectoryPicker);
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_checkboxes(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
session_config_rendering::render_worktree_checkbox(
|
||||
self.enable_worktree,
|
||||
self.is_git_repo,
|
||||
self.worktree_checkbox_mouse_state.clone(),
|
||||
self.worktree_tooltip_mouse_state.clone(),
|
||||
|ctx, _| {
|
||||
ctx.dispatch_typed_action(SessionConfigModalAction::ToggleWorktree);
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_autogenerate_worktree_branch_name_checkbox(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
session_config_rendering::render_autogenerate_worktree_branch_name_checkbox(
|
||||
self.autogenerate_worktree_branch_name,
|
||||
self.enable_worktree,
|
||||
self.autogenerate_worktree_branch_name_checkbox_mouse_state
|
||||
.clone(),
|
||||
self.autogenerate_tooltip_mouse_state.clone(),
|
||||
|ctx, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
SessionConfigModalAction::ToggleAutogenerateWorktreeBranchName,
|
||||
);
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SessionConfigModal {
|
||||
type Event = SessionConfigModalEvent;
|
||||
}
|
||||
|
||||
impl View for SessionConfigModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"SessionConfigModal"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let mut form = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(self.render_header(appearance));
|
||||
|
||||
if self.show_session_type_row {
|
||||
form.add_child(
|
||||
Container::new(self.render_session_type_section(appearance))
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
form.add_child(
|
||||
Container::new(self.render_directory_section(appearance))
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
form.add_child(
|
||||
Container::new(self.render_checkboxes(appearance))
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
);
|
||||
form.add_child(
|
||||
Container::new(self.render_autogenerate_worktree_branch_name_checkbox(appearance))
|
||||
.with_margin_top(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(form.finish())
|
||||
.with_child(
|
||||
Container::new(ChildView::new(&self.submit_button).finish())
|
||||
.with_margin_top(32.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let body = Container::new(content)
|
||||
.with_horizontal_padding(32.)
|
||||
.with_vertical_padding(40.)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(body);
|
||||
stack.add_positioned_overlay_child(
|
||||
Container::new(ChildView::new(&self.close_button).finish())
|
||||
.with_margin_top(12.)
|
||||
.with_margin_right(12.)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
ConstrainedBox::new(stack.finish())
|
||||
.with_width(420.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SessionConfigModal {
|
||||
type Action = SessionConfigModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SessionConfigModalAction::SelectSessionType(index) => {
|
||||
self.selected_session_type_index = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
SessionConfigModalAction::OpenDirectoryPicker => {
|
||||
ctx.open_file_picker(
|
||||
|result, ctx| {
|
||||
if let Some(path_result) =
|
||||
result.map(|paths| paths.into_iter().next()).transpose()
|
||||
{
|
||||
ctx.dispatch_typed_action(
|
||||
&SessionConfigModalAction::DirectorySelected(path_result),
|
||||
);
|
||||
}
|
||||
},
|
||||
FilePickerConfiguration::new().folders_only(),
|
||||
);
|
||||
}
|
||||
SessionConfigModalAction::DirectorySelected(result) => match result {
|
||||
Ok(path) => {
|
||||
self.update_directory(PathBuf::from(path));
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("File picker error in session config modal: {err}");
|
||||
}
|
||||
},
|
||||
SessionConfigModalAction::ToggleWorktree => {
|
||||
if self.is_git_repo {
|
||||
self.enable_worktree = !self.enable_worktree;
|
||||
if !self.enable_worktree {
|
||||
self.autogenerate_worktree_branch_name = false;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
SessionConfigModalAction::ToggleAutogenerateWorktreeBranchName => {
|
||||
if self.enable_worktree {
|
||||
self.autogenerate_worktree_branch_name =
|
||||
!self.autogenerate_worktree_branch_name;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
SessionConfigModalAction::Submit => {
|
||||
self.submit(ctx);
|
||||
}
|
||||
SessionConfigModalAction::Dismiss => {
|
||||
ctx.emit(SessionConfigModalEvent::Dismissed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded,
|
||||
Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::geometry::vector::Vector2F;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::Element;
|
||||
use warpui::EventContext;
|
||||
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::tab_configs::session_config::SessionType;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::view_components::callout_bubble::{
|
||||
callout_checkbox, callout_label_color, phenomenon_accent_color, phenomenon_background_color,
|
||||
phenomenon_body_text_color, phenomenon_disabled_label_text_color, phenomenon_foreground_color,
|
||||
phenomenon_subtle_border_color,
|
||||
};
|
||||
|
||||
const PILL_GAP: f32 = 8.;
|
||||
|
||||
fn session_type_item_color(
|
||||
is_selected: bool,
|
||||
on_accent_bg: bool,
|
||||
theme: &WarpTheme,
|
||||
bg_fill: Fill,
|
||||
) -> ColorU {
|
||||
if on_accent_bg {
|
||||
if is_selected {
|
||||
phenomenon_background_color()
|
||||
} else {
|
||||
phenomenon_body_text_color()
|
||||
}
|
||||
} else if is_selected {
|
||||
blended_colors::text_main(theme, bg_fill)
|
||||
} else {
|
||||
blended_colors::text_sub(theme, bg_fill)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the session type pill selector.
|
||||
///
|
||||
/// Each pill dispatches `on_select(index)` when clicked.
|
||||
pub fn render_session_type_pills<F>(
|
||||
session_types: &[SessionType],
|
||||
selected_index: usize,
|
||||
pill_mouse_states: &[MouseStateHandle],
|
||||
on_select: F,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(usize, &mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
render_session_type_pills_with_background(
|
||||
session_types,
|
||||
selected_index,
|
||||
pill_mouse_states,
|
||||
on_select,
|
||||
None,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders session type pills with an optional background color override.
|
||||
/// When `bg` is `Some`, text and border colors are computed against that background
|
||||
/// (used for the accent-tinted onboarding callout).
|
||||
pub fn render_session_type_pills_with_background<F>(
|
||||
session_types: &[SessionType],
|
||||
selected_index: usize,
|
||||
pill_mouse_states: &[MouseStateHandle],
|
||||
on_select: F,
|
||||
bg: Option<ColorU>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(usize, &mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
let theme = appearance.theme();
|
||||
let bg_fill = bg.map(Fill::Solid).unwrap_or(theme.background());
|
||||
let on_accent_bg = bg.is_some();
|
||||
let on_select = Arc::new(on_select);
|
||||
|
||||
let label = Text::new_inline("Session type".to_string(), appearance.ui_font_family(), 12.)
|
||||
.with_color(if on_accent_bg {
|
||||
callout_label_color(appearance)
|
||||
} else {
|
||||
blended_colors::text_disabled(theme, bg_fill)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut pills_row = Flex::row().with_spacing(PILL_GAP);
|
||||
|
||||
for (i, session_type) in session_types.iter().enumerate() {
|
||||
let is_selected = i == selected_index;
|
||||
let mouse_state = pill_mouse_states[i].clone();
|
||||
|
||||
let item_color = session_type_item_color(is_selected, on_accent_bg, theme, bg_fill);
|
||||
|
||||
let icon = ConstrainedBox::new(
|
||||
session_type
|
||||
.icon()
|
||||
.to_warpui_icon(item_color.into())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish();
|
||||
|
||||
let name = Text::new_inline(
|
||||
session_type.pill_label().to_string(),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(item_color)
|
||||
.finish();
|
||||
|
||||
let pill_content = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(icon)
|
||||
.with_child(Container::new(name).with_margin_left(8.).finish())
|
||||
.finish();
|
||||
|
||||
let border_color = if is_selected {
|
||||
if on_accent_bg {
|
||||
phenomenon_accent_color()
|
||||
} else {
|
||||
theme.accent().into_solid()
|
||||
}
|
||||
} else if on_accent_bg {
|
||||
phenomenon_subtle_border_color()
|
||||
} else {
|
||||
blended_colors::neutral_4(theme)
|
||||
};
|
||||
|
||||
let background = if is_selected {
|
||||
if on_accent_bg {
|
||||
Some(Fill::Solid(phenomenon_foreground_color()))
|
||||
} else {
|
||||
Some(blended_colors::accent_overlay_1(theme))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut pill = Container::new(pill_content)
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_color(border_color));
|
||||
|
||||
if let Some(bg) = background {
|
||||
pill = pill.with_background(bg);
|
||||
}
|
||||
|
||||
let on_select = on_select.clone();
|
||||
let pill_element = Expanded::new(
|
||||
1.0,
|
||||
Hoverable::new(mouse_state, move |_| pill.finish())
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, position| {
|
||||
on_select(i, ctx, position);
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
|
||||
pills_row.extend([pill_element.finish()]);
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(label)
|
||||
.with_child(
|
||||
Container::new(pills_row.finish())
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the directory picker button.
|
||||
///
|
||||
/// Displays the selected directory in a bordered button. Calls `on_click` when pressed.
|
||||
pub fn render_directory_picker<F>(
|
||||
selected_directory: &Path,
|
||||
mouse_state: MouseStateHandle,
|
||||
on_click: F,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
render_directory_picker_with_background(
|
||||
selected_directory,
|
||||
mouse_state,
|
||||
on_click,
|
||||
None,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a directory picker with an optional background color override.
|
||||
pub fn render_directory_picker_with_background<F>(
|
||||
selected_directory: &Path,
|
||||
mouse_state: MouseStateHandle,
|
||||
on_click: F,
|
||||
bg: Option<ColorU>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
let theme = appearance.theme();
|
||||
let bg_fill = bg.map(Fill::Solid).unwrap_or(theme.background());
|
||||
|
||||
let on_accent_bg = bg.is_some();
|
||||
|
||||
let label = Text::new_inline(
|
||||
"Select directory".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(if on_accent_bg {
|
||||
callout_label_color(appearance)
|
||||
} else {
|
||||
blended_colors::text_disabled(theme, bg_fill)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let home_dir = dirs::home_dir();
|
||||
let raw_path = selected_directory.to_string_lossy();
|
||||
let dir_display =
|
||||
warp_util::path::user_friendly_path(&raw_path, home_dir.as_ref().and_then(|h| h.to_str()))
|
||||
.into_owned();
|
||||
|
||||
let dir_text = Text::new_inline(dir_display, appearance.ui_font_family(), 14.)
|
||||
.with_color(if on_accent_bg {
|
||||
phenomenon_body_text_color()
|
||||
} else {
|
||||
blended_colors::text_main(theme, bg_fill)
|
||||
})
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
let border_color = if on_accent_bg {
|
||||
phenomenon_subtle_border_color()
|
||||
} else {
|
||||
blended_colors::neutral_4(theme)
|
||||
};
|
||||
|
||||
let button = Hoverable::new(mouse_state, move |_| {
|
||||
let content_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(dir_text)
|
||||
.finish();
|
||||
|
||||
Container::new(ConstrainedBox::new(content_row).with_height(30.).finish())
|
||||
.with_horizontal_padding(12.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, position| {
|
||||
on_click(ctx, position);
|
||||
})
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(label)
|
||||
.with_child(Container::new(button).with_margin_top(4.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the worktree checkbox with label and tooltip.
|
||||
///
|
||||
/// `on_toggle` is called when the checkbox is clicked (only when enabled).
|
||||
pub fn render_worktree_checkbox<F>(
|
||||
enabled: bool,
|
||||
is_git_repo: bool,
|
||||
checkbox_mouse_state: MouseStateHandle,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
on_toggle: F,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
render_worktree_checkbox_with_background(
|
||||
enabled,
|
||||
is_git_repo,
|
||||
checkbox_mouse_state,
|
||||
tooltip_mouse_state,
|
||||
on_toggle,
|
||||
None,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a worktree checkbox with an optional background color override.
|
||||
pub fn render_worktree_checkbox_with_background<F>(
|
||||
enabled: bool,
|
||||
is_git_repo: bool,
|
||||
checkbox_mouse_state: MouseStateHandle,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
on_toggle: F,
|
||||
bg: Option<ColorU>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut warpui::EventContext, warpui::geometry::vector::Vector2F) + 'static,
|
||||
{
|
||||
let disabled = !is_git_repo;
|
||||
let on_accent_bg = bg.is_some();
|
||||
|
||||
let mut checkbox = if on_accent_bg {
|
||||
callout_checkbox(checkbox_mouse_state, Some(10.5), appearance).check(enabled)
|
||||
} else {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.checkbox(checkbox_mouse_state, Some(10.5))
|
||||
.check(enabled)
|
||||
};
|
||||
|
||||
if disabled {
|
||||
checkbox = checkbox.disabled();
|
||||
}
|
||||
|
||||
let checkbox_el = if disabled {
|
||||
checkbox.build().finish()
|
||||
} else {
|
||||
checkbox
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, position| {
|
||||
on_toggle(ctx, position);
|
||||
})
|
||||
.finish()
|
||||
};
|
||||
|
||||
let checkbox_el = if disabled {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
Hoverable::new(tooltip_mouse_state, move |state| {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(checkbox_el);
|
||||
if state.is_hovered() {
|
||||
let tooltip = Container::new(
|
||||
Text::new_inline(
|
||||
"Select a git repository to enable worktree support".to_string(),
|
||||
font_family,
|
||||
12.,
|
||||
)
|
||||
.with_color(theme.background().into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(14.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_background_color(theme.tooltip_background())
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
checkbox_el
|
||||
};
|
||||
|
||||
let theme = appearance.theme();
|
||||
let label_color = if on_accent_bg {
|
||||
if disabled {
|
||||
phenomenon_disabled_label_text_color()
|
||||
} else {
|
||||
callout_label_color(appearance)
|
||||
}
|
||||
} else if disabled {
|
||||
blended_colors::text_disabled(theme, theme.background())
|
||||
} else {
|
||||
blended_colors::text_sub(theme, theme.background())
|
||||
};
|
||||
let label = Text::new(
|
||||
"Automatically create a worktree when opening a new tab",
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(label_color)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(checkbox_el)
|
||||
.with_child(Container::new(label).with_margin_left(8.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the "Autogenerate worktree branch name" checkbox with label and tooltip.
|
||||
pub fn render_autogenerate_worktree_branch_name_checkbox<F>(
|
||||
checked: bool,
|
||||
enable_worktree: bool,
|
||||
checkbox_mouse_state: MouseStateHandle,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
on_toggle: F,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
render_autogenerate_worktree_branch_name_checkbox_with_background(
|
||||
checked,
|
||||
enable_worktree,
|
||||
checkbox_mouse_state,
|
||||
tooltip_mouse_state,
|
||||
on_toggle,
|
||||
None,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the autogenerate checkbox with an optional background color override.
|
||||
pub fn render_autogenerate_worktree_branch_name_checkbox_with_background<F>(
|
||||
checked: bool,
|
||||
enable_worktree: bool,
|
||||
checkbox_mouse_state: MouseStateHandle,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
on_toggle: F,
|
||||
bg: Option<ColorU>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
let disabled = !enable_worktree;
|
||||
let on_accent_bg = bg.is_some();
|
||||
|
||||
let mut checkbox = if on_accent_bg {
|
||||
callout_checkbox(checkbox_mouse_state, Some(10.5), appearance).check(checked)
|
||||
} else {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.checkbox(checkbox_mouse_state, Some(10.5))
|
||||
.check(checked)
|
||||
};
|
||||
|
||||
if disabled {
|
||||
checkbox = checkbox.disabled();
|
||||
}
|
||||
|
||||
let checkbox_el = if disabled {
|
||||
checkbox.build().finish()
|
||||
} else {
|
||||
checkbox
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, position| {
|
||||
on_toggle(ctx, position);
|
||||
})
|
||||
.finish()
|
||||
};
|
||||
|
||||
let checkbox_el = if disabled {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
Hoverable::new(tooltip_mouse_state, move |state| {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(checkbox_el);
|
||||
if state.is_hovered() {
|
||||
let tooltip = Container::new(
|
||||
Text::new_inline(
|
||||
"You must select that you want to automatically create a \
|
||||
worktree in order to select this"
|
||||
.to_string(),
|
||||
font_family,
|
||||
12.,
|
||||
)
|
||||
.with_color(theme.background().into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(14.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_background_color(theme.tooltip_background())
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
checkbox_el
|
||||
};
|
||||
|
||||
let theme = appearance.theme();
|
||||
let label_color = if on_accent_bg {
|
||||
if disabled {
|
||||
phenomenon_disabled_label_text_color()
|
||||
} else {
|
||||
callout_label_color(appearance)
|
||||
}
|
||||
} else if disabled {
|
||||
blended_colors::text_disabled(theme, theme.background())
|
||||
} else {
|
||||
blended_colors::text_sub(theme, theme.background())
|
||||
};
|
||||
|
||||
let label = Text::new(
|
||||
"Auto-generate worktree branch name",
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(label_color)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(checkbox_el)
|
||||
.with_child(Container::new(label).with_margin_left(8.).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// All possible session types, in display order.
|
||||
const ALL_SESSION_TYPES: &[SessionType] = &[SessionType::Oz, SessionType::Terminal];
|
||||
|
||||
/// Returns the session types to display, filtering out Oz when AI is disabled.
|
||||
pub fn visible_session_types(show_oz: bool) -> Vec<SessionType> {
|
||||
ALL_SESSION_TYPES
|
||||
.iter()
|
||||
.filter(|st| show_oz || !matches!(st, SessionType::Oz))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::terminal::cli_agent::CLIAgent;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn generated_worktree_path_string(repo: &str, worktree_name: &str) -> String {
|
||||
super::super::tab_config::generated_worktree_path(Path::new(repo), worktree_name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ── SessionType helpers ──
|
||||
|
||||
#[test]
|
||||
fn terminal_command_prefix_is_none() {
|
||||
assert_eq!(SessionType::Terminal.command_prefix(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_command_prefix_is_none() {
|
||||
assert_eq!(SessionType::Oz.command_prefix(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_agent_command_prefix_delegates() {
|
||||
assert_eq!(
|
||||
SessionType::CliAgent(CLIAgent::Claude).command_prefix(),
|
||||
Some("claude")
|
||||
);
|
||||
assert_eq!(
|
||||
SessionType::CliAgent(CLIAgent::Codex).command_prefix(),
|
||||
Some("codex")
|
||||
);
|
||||
assert_eq!(
|
||||
SessionType::CliAgent(CLIAgent::Gemini).command_prefix(),
|
||||
Some("gemini")
|
||||
);
|
||||
}
|
||||
|
||||
// ── build_tab_config ──
|
||||
|
||||
#[test]
|
||||
fn terminal_no_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(config.name, "New tab: project");
|
||||
assert!(config.title.is_none());
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
assert_eq!(
|
||||
config.panes[0].directory.as_deref(),
|
||||
Some("/home/user/project")
|
||||
);
|
||||
assert!(config.panes[0].commands.is_none());
|
||||
assert!(config.params.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_agent_no_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::CliAgent(CLIAgent::Claude),
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(config.panes[0].commands.as_deref().unwrap(), &["claude"]);
|
||||
assert!(config.params.is_empty());
|
||||
assert!(config.title.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_with_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/repo"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(config.title.as_deref(), Some("{{worktree_branch_name}}"));
|
||||
let expected_worktree_path =
|
||||
generated_worktree_path_string("/home/user/repo", "{{worktree_branch_name}}");
|
||||
assert_eq!(
|
||||
config.panes[0].commands.as_deref().unwrap(),
|
||||
[
|
||||
format!("git worktree add -b {{{{worktree_branch_name}}}} {expected_worktree_path}"),
|
||||
format!("cd {expected_worktree_path}"),
|
||||
]
|
||||
.as_ref()
|
||||
);
|
||||
assert!(!config.uses_autogenerated_branch_name());
|
||||
assert!(config.params.contains_key("worktree_branch_name"));
|
||||
let param = &config.params["worktree_branch_name"];
|
||||
assert_eq!(param.param_type, TabConfigParamType::Text);
|
||||
assert_eq!(param.default.as_deref(), Some("my-feature-branch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_agent_with_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::CliAgent(CLIAgent::Gemini),
|
||||
Path::new("/home/user/repo"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
// Worktree commands come first, then agent command.
|
||||
let expected_worktree_path =
|
||||
generated_worktree_path_string("/home/user/repo", "{{worktree_branch_name}}");
|
||||
assert_eq!(
|
||||
config.panes[0].commands.as_deref().unwrap(),
|
||||
[
|
||||
format!("git worktree add -b {{{{worktree_branch_name}}}} {expected_worktree_path}"),
|
||||
format!("cd {expected_worktree_path}"),
|
||||
"gemini".to_string(),
|
||||
]
|
||||
.as_ref()
|
||||
);
|
||||
assert!(config.params.contains_key("worktree_branch_name"));
|
||||
assert_eq!(config.title.as_deref(), Some("{{worktree_branch_name}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_no_worktree_same_as_terminal() {
|
||||
let oz = build_tab_config(
|
||||
&SessionType::Oz,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let terminal = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(oz.panes[0].directory, terminal.panes[0].directory);
|
||||
assert_eq!(oz.panes[0].commands, terminal.panes[0].commands);
|
||||
assert_eq!(oz.params.len(), terminal.params.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_with_worktree_has_worktree_commands_but_no_agent_command() {
|
||||
let config = build_tab_config(&SessionType::Oz, Path::new("/home/user/repo"), true, false);
|
||||
let expected_worktree_path =
|
||||
generated_worktree_path_string("/home/user/repo", "{{worktree_branch_name}}");
|
||||
|
||||
assert_eq!(
|
||||
config.panes[0].commands.as_deref().unwrap(),
|
||||
[
|
||||
format!("git worktree add -b {{{{worktree_branch_name}}}} {expected_worktree_path}"),
|
||||
format!("cd {expected_worktree_path}"),
|
||||
]
|
||||
.as_ref()
|
||||
);
|
||||
assert!(config.params.contains_key("worktree_branch_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_path_is_absolute_in_directory() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/absolute/path/here"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
let directory = config.panes[0].directory.as_deref().unwrap();
|
||||
assert!(
|
||||
directory.starts_with('/'),
|
||||
"directory should be absolute, got: {directory}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── TOML round-trip ──
|
||||
|
||||
#[test]
|
||||
fn round_trip_terminal_no_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let toml_str = toml::to_string_pretty(&config).expect("Should serialize");
|
||||
let parsed: TabConfig = toml::from_str(&toml_str).expect("Should deserialize");
|
||||
|
||||
assert_eq!(parsed.name, config.name);
|
||||
assert_eq!(parsed.panes[0].directory, config.panes[0].directory);
|
||||
assert_eq!(parsed.panes[0].commands, config.panes[0].commands);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_cli_agent_with_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::CliAgent(CLIAgent::Claude),
|
||||
Path::new("/home/user/repo"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let toml_str = toml::to_string_pretty(&config).expect("Should serialize");
|
||||
let parsed: TabConfig = toml::from_str(&toml_str).expect("Should deserialize");
|
||||
|
||||
assert_eq!(parsed.name, config.name);
|
||||
assert_eq!(parsed.title, config.title);
|
||||
assert_eq!(parsed.panes[0].commands, config.panes[0].commands);
|
||||
assert!(parsed.params.contains_key("worktree_branch_name"));
|
||||
assert_eq!(
|
||||
parsed.params["worktree_branch_name"].default.as_deref(),
|
||||
Some("my-feature-branch")
|
||||
);
|
||||
}
|
||||
|
||||
// ── render_tab_config integration ──
|
||||
|
||||
#[test]
|
||||
fn render_terminal_produces_correct_pane_template() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let param_values = config.default_param_values();
|
||||
let (title, pane_template) = super::super::render_tab_config(&config, ¶m_values, None);
|
||||
|
||||
assert!(title.is_none());
|
||||
if let crate::launch_configs::launch_config::PaneTemplateType::PaneTemplate {
|
||||
cwd,
|
||||
commands,
|
||||
..
|
||||
} = pane_template
|
||||
{
|
||||
assert_eq!(cwd, std::path::PathBuf::from("/home/user/project"));
|
||||
assert!(commands.is_empty());
|
||||
} else {
|
||||
panic!("Expected PaneTemplate variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_cli_agent_produces_correct_commands() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::CliAgent(CLIAgent::Claude),
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let param_values = config.default_param_values();
|
||||
let (_, pane_template) = super::super::render_tab_config(&config, ¶m_values, None);
|
||||
|
||||
if let crate::launch_configs::launch_config::PaneTemplateType::PaneTemplate {
|
||||
commands, ..
|
||||
} = pane_template
|
||||
{
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].exec, "claude");
|
||||
} else {
|
||||
panic!("Expected PaneTemplate variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_worktree_substitutes_default_branch_name() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/repo"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let param_values = config.default_param_values();
|
||||
let (title, pane_template) = super::super::render_tab_config(&config, ¶m_values, None);
|
||||
|
||||
assert_eq!(title.as_deref(), Some("my-feature-branch"));
|
||||
if let crate::launch_configs::launch_config::PaneTemplateType::PaneTemplate {
|
||||
commands, ..
|
||||
} = pane_template
|
||||
{
|
||||
assert!(commands[0].exec.contains("my-feature-branch"));
|
||||
assert!(commands[1].exec.contains("my-feature-branch"));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate variant");
|
||||
}
|
||||
}
|
||||
|
||||
// ── write_tab_config (file naming) ──
|
||||
|
||||
#[test]
|
||||
fn write_tab_config_creates_file_with_correct_naming() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
let path1 = write_tab_config(&config, dir.path(), "startup_config")
|
||||
.expect("First write should succeed");
|
||||
assert_eq!(path1.file_name().unwrap(), "startup_config.toml");
|
||||
|
||||
let path2 = write_tab_config(&config, dir.path(), "startup_config")
|
||||
.expect("Second write should succeed");
|
||||
assert_eq!(path2.file_name().unwrap(), "startup_config_1.toml");
|
||||
|
||||
let path3 = write_tab_config(&config, dir.path(), "startup_config")
|
||||
.expect("Third write should succeed");
|
||||
assert_eq!(path3.file_name().unwrap(), "startup_config_2.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tab_config_content_is_valid_toml() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
let config = build_tab_config(
|
||||
&SessionType::CliAgent(CLIAgent::Claude),
|
||||
Path::new("/home/user/repo"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
let path =
|
||||
write_tab_config(&config, dir.path(), "startup_config").expect("Write should succeed");
|
||||
let contents = std::fs::read_to_string(&path).expect("Should read file");
|
||||
let parsed: TabConfig = toml::from_str(&contents).expect("Should parse as TabConfig");
|
||||
|
||||
assert_eq!(parsed.name, "Worktree: repo");
|
||||
assert_eq!(parsed.panes[0].commands.as_ref().unwrap().len(), 3);
|
||||
assert!(parsed.params.contains_key("worktree_branch_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tab_config_creates_directory_if_missing() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
let nested = dir.path().join("nested").join("tab_configs");
|
||||
let config = build_tab_config(&SessionType::Terminal, Path::new("/tmp"), false, true);
|
||||
|
||||
let path = write_tab_config(&config, &nested, "startup_config").expect("Write should succeed");
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tab_config_custom_base_name() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/project"),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
let path =
|
||||
write_tab_config(&config, dir.path(), "my_tab_config").expect("Write should succeed");
|
||||
assert_eq!(path.file_name().unwrap(), "my_tab_config.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_with_autogenerated_worktree() {
|
||||
let config = build_tab_config(
|
||||
&SessionType::Terminal,
|
||||
Path::new("/home/user/repo"),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(config.title.is_none());
|
||||
let expected_worktree_path =
|
||||
generated_worktree_path_string("/home/user/repo", "{{autogenerated_branch_name}}");
|
||||
assert_eq!(
|
||||
config.panes[0].commands.as_deref().unwrap(),
|
||||
[
|
||||
format!(
|
||||
"git worktree add -b {{{{autogenerated_branch_name}}}} {expected_worktree_path}"
|
||||
),
|
||||
format!("cd {expected_worktree_path}"),
|
||||
]
|
||||
.as_ref()
|
||||
);
|
||||
assert!(config.uses_autogenerated_branch_name());
|
||||
assert!(config.params.is_empty());
|
||||
}
|
||||
|
||||
// ── tab_config_from_pane_snapshot ──
|
||||
|
||||
use crate::app_state::{
|
||||
BranchSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot, TerminalPaneSnapshot,
|
||||
};
|
||||
use crate::tab_configs::tab_config::TabConfigPaneType;
|
||||
|
||||
fn make_terminal_leaf(cwd: Option<&str>, is_focused: bool) -> PaneNodeSnapshot {
|
||||
PaneNodeSnapshot::Leaf(LeafSnapshot {
|
||||
is_focused,
|
||||
custom_vertical_tabs_title: None,
|
||||
contents: LeafContents::Terminal(TerminalPaneSnapshot {
|
||||
uuid: vec![],
|
||||
cwd: cwd.map(|s| s.to_string()),
|
||||
shell_launch_data: None,
|
||||
is_active: false,
|
||||
is_read_only: false,
|
||||
input_config: None,
|
||||
llm_model_override: None,
|
||||
active_profile_id: None,
|
||||
conversation_ids_to_restore: vec![],
|
||||
active_conversation_id: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_single_terminal_pane() {
|
||||
let snapshot = make_terminal_leaf(Some("/home/user/project"), true);
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
assert_eq!(config.name, "My Tab Config");
|
||||
assert!(config.title.is_none());
|
||||
assert!(config.color.is_none());
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
assert_eq!(config.panes[0].id, "p1");
|
||||
assert_eq!(config.panes[0].pane_type, Some(TabConfigPaneType::Terminal));
|
||||
assert_eq!(
|
||||
config.panes[0].directory.as_deref(),
|
||||
Some("/home/user/project")
|
||||
);
|
||||
assert_eq!(config.panes[0].is_focused, Some(true));
|
||||
assert!(config.panes[0].split.is_none());
|
||||
assert!(config.panes[0].children.is_none());
|
||||
assert!(config.params.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_two_pane_horizontal_split() {
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/home/user/a"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/home/user/b"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
assert_eq!(config.panes.len(), 3);
|
||||
// Root should be the split node.
|
||||
assert_eq!(config.panes[0].id, "p1");
|
||||
assert_eq!(
|
||||
config.panes[0].split,
|
||||
Some(crate::launch_configs::launch_config::SplitDirection::Horizontal)
|
||||
);
|
||||
assert_eq!(
|
||||
config.panes[0].children,
|
||||
Some(vec!["p2".to_string(), "p3".to_string()])
|
||||
);
|
||||
// Children.
|
||||
assert_eq!(config.panes[1].directory.as_deref(), Some("/home/user/a"));
|
||||
assert_eq!(config.panes[1].is_focused, Some(true));
|
||||
assert_eq!(config.panes[2].directory.as_deref(), Some("/home/user/b"));
|
||||
assert!(config.panes[2].is_focused.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_2x2_grid() {
|
||||
let left = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Vertical,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/a"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/b"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
let right = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Vertical,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/c"), false),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/d"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(crate::app_state::PaneFlex(0.5), left),
|
||||
(crate::app_state::PaneFlex(0.5), right),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
// 3 split nodes + 4 leaf nodes = 7 panes.
|
||||
assert_eq!(config.panes.len(), 7);
|
||||
// Root is p1 (horizontal split).
|
||||
assert_eq!(config.panes[0].id, "p1");
|
||||
assert!(config.panes[0].split.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_non_terminal_leaf_replaced_with_terminal() {
|
||||
use crate::app_state::NotebookPaneSnapshot;
|
||||
use crate::drive::OpenWarpDriveObjectSettings;
|
||||
|
||||
let notebook_leaf = PaneNodeSnapshot::Leaf(LeafSnapshot {
|
||||
is_focused: false,
|
||||
custom_vertical_tabs_title: None,
|
||||
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
|
||||
notebook_id: None,
|
||||
settings: OpenWarpDriveObjectSettings::default(),
|
||||
}),
|
||||
});
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/home/user"), true),
|
||||
),
|
||||
(crate::app_state::PaneFlex(0.5), notebook_leaf),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
assert_eq!(config.panes.len(), 3);
|
||||
// The notebook pane should be replaced with a terminal (no cwd).
|
||||
assert_eq!(config.panes[2].pane_type, Some(TabConfigPaneType::Terminal));
|
||||
assert!(config.panes[2].directory.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_custom_title_and_color() {
|
||||
let snapshot = make_terminal_leaf(Some("/home/user"), true);
|
||||
let config = tab_config_from_pane_snapshot(
|
||||
&snapshot,
|
||||
Some("My Project".to_string()),
|
||||
Some(crate::themes::theme::AnsiColorIdentifier::Blue),
|
||||
);
|
||||
|
||||
assert_eq!(config.title.as_deref(), Some("My Project"));
|
||||
assert_eq!(
|
||||
config.color,
|
||||
Some(crate::themes::theme::AnsiColorIdentifier::Blue)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trip_toml() {
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/home/user/a"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/home/user/b"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, Some("Test".to_string()), None);
|
||||
let toml_str = toml::to_string_pretty(&config).expect("Should serialize");
|
||||
let parsed: TabConfig = toml::from_str(&toml_str).expect("Should deserialize");
|
||||
|
||||
assert_eq!(parsed.name, config.name);
|
||||
assert_eq!(parsed.title, config.title);
|
||||
assert_eq!(parsed.panes.len(), config.panes.len());
|
||||
assert_eq!(parsed.panes[0].id, config.panes[0].id);
|
||||
assert_eq!(parsed.panes[1].directory, config.panes[1].directory);
|
||||
assert_eq!(parsed.panes[2].directory, config.panes[2].directory);
|
||||
}
|
||||
|
||||
// ── snapshot pane_type derivation ──
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::app_state::AmbientAgentPaneSnapshot;
|
||||
|
||||
fn make_agent_leaf(cwd: Option<&str>, is_focused: bool) -> PaneNodeSnapshot {
|
||||
PaneNodeSnapshot::Leaf(LeafSnapshot {
|
||||
is_focused,
|
||||
custom_vertical_tabs_title: None,
|
||||
contents: LeafContents::Terminal(TerminalPaneSnapshot {
|
||||
uuid: vec![],
|
||||
cwd: cwd.map(|s| s.to_string()),
|
||||
shell_launch_data: None,
|
||||
is_active: false,
|
||||
is_read_only: false,
|
||||
input_config: None,
|
||||
llm_model_override: None,
|
||||
active_profile_id: None,
|
||||
conversation_ids_to_restore: vec![],
|
||||
active_conversation_id: Some(AIConversationId::new()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn make_cloud_leaf(is_focused: bool) -> PaneNodeSnapshot {
|
||||
PaneNodeSnapshot::Leaf(LeafSnapshot {
|
||||
is_focused,
|
||||
custom_vertical_tabs_title: None,
|
||||
contents: LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
|
||||
uuid: vec![],
|
||||
task_id: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_agent_pane_gets_agent_type() {
|
||||
let snapshot = make_agent_leaf(Some("/home/user/project"), true);
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
assert_eq!(config.panes[0].pane_type, Some(TabConfigPaneType::Agent));
|
||||
assert_eq!(
|
||||
config.panes[0].directory.as_deref(),
|
||||
Some("/home/user/project")
|
||||
);
|
||||
assert_eq!(config.panes[0].is_focused, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_cloud_pane_gets_cloud_type() {
|
||||
let snapshot = make_cloud_leaf(true);
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
assert_eq!(config.panes[0].pane_type, Some(TabConfigPaneType::Cloud));
|
||||
assert!(config.panes[0].directory.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_mixed_terminal_agent_cloud_split() {
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_terminal_leaf(Some("/home/user/a"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_agent_leaf(Some("/home/user/b"), false),
|
||||
),
|
||||
(crate::app_state::PaneFlex(0.33), make_cloud_leaf(false)),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
// 1 split + 3 leaves = 4 panes.
|
||||
assert_eq!(config.panes.len(), 4);
|
||||
assert_eq!(config.panes[1].pane_type, Some(TabConfigPaneType::Terminal));
|
||||
assert_eq!(config.panes[2].pane_type, Some(TabConfigPaneType::Agent));
|
||||
assert_eq!(config.panes[3].pane_type, Some(TabConfigPaneType::Cloud));
|
||||
}
|
||||
|
||||
// ── nested / multi-level pane layout edge cases ──
|
||||
|
||||
#[test]
|
||||
fn snapshot_3_deep_nesting() {
|
||||
// Root(H) -> left(V) -> inner(H) -> [leaf_c, leaf_d]
|
||||
// -> leaf_b
|
||||
// -> right_leaf
|
||||
let inner = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/c"), false),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/d"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
let left = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Vertical,
|
||||
children: vec![
|
||||
(crate::app_state::PaneFlex(0.5), inner),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/b"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(crate::app_state::PaneFlex(0.5), left),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/a"), true),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
// 3 splits + 4 leaves = 7 panes.
|
||||
assert_eq!(config.panes.len(), 7);
|
||||
|
||||
// Root-first ordering: each split appears before its subtree.
|
||||
assert_eq!(config.panes[0].id, "p1"); // root H-split
|
||||
assert!(config.panes[0].split.is_some());
|
||||
assert_eq!(
|
||||
config.panes[0].children,
|
||||
Some(vec!["p2".to_string(), "p7".to_string()])
|
||||
);
|
||||
|
||||
assert_eq!(config.panes[1].id, "p2"); // left V-split
|
||||
assert!(config.panes[1].split.is_some());
|
||||
assert_eq!(
|
||||
config.panes[1].children,
|
||||
Some(vec!["p3".to_string(), "p6".to_string()])
|
||||
);
|
||||
|
||||
assert_eq!(config.panes[2].id, "p3"); // inner H-split
|
||||
assert!(config.panes[2].split.is_some());
|
||||
assert_eq!(
|
||||
config.panes[2].children,
|
||||
Some(vec!["p4".to_string(), "p5".to_string()])
|
||||
);
|
||||
|
||||
// Leaves
|
||||
assert_eq!(config.panes[3].directory.as_deref(), Some("/c"));
|
||||
assert_eq!(config.panes[4].directory.as_deref(), Some("/d"));
|
||||
assert_eq!(config.panes[5].directory.as_deref(), Some("/b"));
|
||||
assert_eq!(config.panes[6].directory.as_deref(), Some("/a"));
|
||||
assert_eq!(config.panes[6].is_focused, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_asymmetric_tree() {
|
||||
// Root(H) -> deep_left(V) -> [leaf_a, leaf_b]
|
||||
// -> right_leaf
|
||||
// Left subtree has 3 panes; right has 1.
|
||||
let deep_left = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Vertical,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/a"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/b"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(crate::app_state::PaneFlex(0.7), deep_left),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.3),
|
||||
make_agent_leaf(Some("/c"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
// 2 splits + 3 leaves = 5 panes.
|
||||
assert_eq!(config.panes.len(), 5);
|
||||
assert_eq!(config.panes[0].id, "p1"); // root
|
||||
assert_eq!(
|
||||
config.panes[0].children,
|
||||
Some(vec!["p2".to_string(), "p5".to_string()])
|
||||
);
|
||||
assert_eq!(config.panes[1].id, "p2"); // deep_left
|
||||
assert_eq!(config.panes[4].pane_type, Some(TabConfigPaneType::Agent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_3_way_split() {
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_terminal_leaf(Some("/a"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_terminal_leaf(Some("/b"), false),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_terminal_leaf(Some("/c"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
|
||||
assert_eq!(config.panes.len(), 4);
|
||||
assert_eq!(config.panes[0].id, "p1");
|
||||
assert_eq!(
|
||||
config.panes[0].children,
|
||||
Some(vec!["p2".to_string(), "p3".to_string(), "p4".to_string()])
|
||||
);
|
||||
assert_eq!(config.panes[1].directory.as_deref(), Some("/a"));
|
||||
assert_eq!(config.panes[2].directory.as_deref(), Some("/b"));
|
||||
assert_eq!(config.panes[3].directory.as_deref(), Some("/c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trip_agent_and_cloud_pane_types() {
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_terminal_leaf(Some("/term"), true),
|
||||
),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.33),
|
||||
make_agent_leaf(Some("/agent"), false),
|
||||
),
|
||||
(crate::app_state::PaneFlex(0.33), make_cloud_leaf(false)),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, Some("Mixed".to_string()), None);
|
||||
let toml_str = toml::to_string_pretty(&config).expect("Should serialize");
|
||||
let parsed: TabConfig = toml::from_str(&toml_str).expect("Should deserialize");
|
||||
|
||||
assert_eq!(parsed.panes.len(), 4);
|
||||
assert_eq!(parsed.panes[1].pane_type, Some(TabConfigPaneType::Terminal));
|
||||
assert_eq!(parsed.panes[1].directory.as_deref(), Some("/term"));
|
||||
assert_eq!(parsed.panes[2].pane_type, Some(TabConfigPaneType::Agent));
|
||||
assert_eq!(parsed.panes[2].directory.as_deref(), Some("/agent"));
|
||||
assert_eq!(parsed.panes[3].pane_type, Some(TabConfigPaneType::Cloud));
|
||||
assert!(parsed.panes[3].directory.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trip_3_deep_nesting() {
|
||||
let inner = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Vertical,
|
||||
children: vec![
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_agent_leaf(Some("/x"), false),
|
||||
),
|
||||
(crate::app_state::PaneFlex(0.5), make_cloud_leaf(true)),
|
||||
],
|
||||
});
|
||||
let snapshot = PaneNodeSnapshot::Branch(BranchSnapshot {
|
||||
direction: crate::app_state::SplitDirection::Horizontal,
|
||||
children: vec![
|
||||
(crate::app_state::PaneFlex(0.5), inner),
|
||||
(
|
||||
crate::app_state::PaneFlex(0.5),
|
||||
make_terminal_leaf(Some("/y"), false),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
let config = tab_config_from_pane_snapshot(&snapshot, None, None);
|
||||
let toml_str = toml::to_string_pretty(&config).expect("Should serialize");
|
||||
let parsed: TabConfig = toml::from_str(&toml_str).expect("Should deserialize");
|
||||
|
||||
// Verify structural integrity after round-trip.
|
||||
assert_eq!(parsed.panes.len(), 5);
|
||||
// Root split references inner split and leaf.
|
||||
assert_eq!(
|
||||
parsed.panes[0].children,
|
||||
Some(vec!["p2".to_string(), "p5".to_string()])
|
||||
);
|
||||
// Inner split references its two leaves.
|
||||
assert_eq!(
|
||||
parsed.panes[1].children,
|
||||
Some(vec!["p3".to_string(), "p4".to_string()])
|
||||
);
|
||||
assert_eq!(parsed.panes[2].pane_type, Some(TabConfigPaneType::Agent));
|
||||
assert_eq!(parsed.panes[3].pane_type, Some(TabConfigPaneType::Cloud));
|
||||
assert_eq!(parsed.panes[4].pane_type, Some(TabConfigPaneType::Terminal));
|
||||
}
|
||||
|
||||
// ── is_git_repo ──
|
||||
|
||||
#[test]
|
||||
fn detects_git_repo_with_dot_git_dir() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
std::fs::create_dir(dir.path().join(".git")).expect("Should create .git dir");
|
||||
|
||||
assert!(is_git_repo(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_git_directory() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
|
||||
assert!(!is_git_repo(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_git_repo_in_parent() {
|
||||
let dir = tempfile::tempdir().expect("Should create temp dir");
|
||||
std::fs::create_dir(dir.path().join(".git")).expect("Should create .git dir");
|
||||
let subdir = dir.path().join("src");
|
||||
std::fs::create_dir(&subdir).expect("Should create subdir");
|
||||
|
||||
assert!(is_git_repo(&subdir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_directory_does_not_loop() {
|
||||
// Ensures the walk terminates at the filesystem root without panicking.
|
||||
assert!(!is_git_repo(Path::new("/")));
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use toml::Value;
|
||||
|
||||
/// Describes a tab config file that failed to parse.
|
||||
///
|
||||
/// Carries enough context to surface a helpful, persistent error toast
|
||||
/// so the user can locate and fix the broken TOML.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TabConfigError {
|
||||
/// The file name shown in the toast (e.g. `"my_config.toml"`).
|
||||
pub file_name: String,
|
||||
/// Full path used by the "Open file" action.
|
||||
pub file_path: PathBuf,
|
||||
/// The full, untruncated error from `toml` deserialization.
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
fn contains_autogenerated_branch_name(value: &str) -> bool {
|
||||
value.contains(AUTOGENERATED_BRANCH_NAME_PARAM)
|
||||
}
|
||||
|
||||
use crate::launch_configs::launch_config::{
|
||||
CommandTemplate, PaneMode, PaneTemplateType, SplitDirection,
|
||||
};
|
||||
use crate::themes::theme::AnsiColorIdentifier;
|
||||
|
||||
pub(crate) const AUTOGENERATED_BRANCH_NAME_PARAM: &str = "autogenerated_branch_name";
|
||||
|
||||
// Rust `format!` treats `{...}` as interpolation syntax, so we escape braces
|
||||
// to emit the literal Handlebars placeholder `{{name}}` for the later
|
||||
// tab-config render pass. In `format!("{{{{{name}}}}}")`, the outer doubled
|
||||
// braces become literal `{` / `}`, and `{name}` interpolates the helper arg.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn handlebars_placeholder(name: &str) -> String {
|
||||
format!("{{{{{name}}}}}")
|
||||
}
|
||||
|
||||
pub(crate) fn generated_worktree_repo_dir(repo_path: &Path) -> PathBuf {
|
||||
let repo_name = repo_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("untitled");
|
||||
warp_core::paths::data_dir()
|
||||
.join("worktrees")
|
||||
.join(repo_name)
|
||||
}
|
||||
pub(crate) fn generated_worktree_path(repo_path: &Path, worktree_name: &str) -> PathBuf {
|
||||
generated_worktree_repo_dir(repo_path).join(worktree_name)
|
||||
}
|
||||
pub(crate) fn generated_worktree_path_string(repo_path: &Path, worktree_name: &str) -> String {
|
||||
generated_worktree_path(repo_path, worktree_name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The kind of smart picker to show for a parameter.
|
||||
///
|
||||
/// Authors opt in via `type = "branch"` or `type = "repo"` in the TOML.
|
||||
/// Omitting the field (or using `type = "text"`) gives a plain text editor.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TabConfigParamType {
|
||||
/// A plain single-line text editor (the default).
|
||||
#[default]
|
||||
Text,
|
||||
/// A dropdown populated with local git branches from the active terminal's repo.
|
||||
Branch,
|
||||
/// A dropdown populated with known repos (from the project list), with an option to add a new one.
|
||||
Repo,
|
||||
}
|
||||
|
||||
/// A single parameter declared in a tab config file.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TabConfigParam {
|
||||
/// Human-readable description shown in the param-fill UI.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Default value used when the user provides no input.
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
/// Controls which smart picker to render for this param.
|
||||
///
|
||||
/// Defaults to `TabConfigParamType::Text` (a plain text input).
|
||||
#[serde(default, rename = "type")]
|
||||
pub param_type: TabConfigParamType,
|
||||
}
|
||||
|
||||
// ── Flat pane schema ────────────────────────────────────────────────
|
||||
|
||||
/// The pane type declared on leaf nodes via `type = "..."` in TOML.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TabConfigPaneType {
|
||||
/// A standard terminal shell session.
|
||||
Terminal,
|
||||
/// A terminal that immediately enters Agent Mode.
|
||||
Agent,
|
||||
/// A cloud-mode (ambient agent) pane with no local shell.
|
||||
Cloud,
|
||||
}
|
||||
|
||||
/// A single node in the flat `[[panes]]` array. Distinguished as a split vs.
|
||||
/// leaf by the presence of `split` + `children`.
|
||||
///
|
||||
/// Leaf panes must specify `type` to indicate whether they open as a terminal,
|
||||
/// agent, or cloud pane.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TabConfigPaneNode {
|
||||
pub id: String,
|
||||
|
||||
/// The pane type. Required on leaf nodes; ignored on split nodes.
|
||||
#[serde(rename = "type")]
|
||||
pub pane_type: Option<TabConfigPaneType>,
|
||||
|
||||
// -- Split fields (present for branches) --
|
||||
pub split: Option<SplitDirection>,
|
||||
pub children: Option<Vec<String>>,
|
||||
|
||||
// -- Leaf fields --
|
||||
pub is_focused: Option<bool>,
|
||||
pub directory: Option<String>,
|
||||
pub commands: Option<Vec<String>>,
|
||||
/// Optional shell to use for this pane (e.g. `"pwsh"`, `"zsh"`, `"bash"`, `"fish"`).
|
||||
/// Only applies to `terminal` and `agent` pane types.
|
||||
/// If omitted or the shell is not found, the user's default shell is used.
|
||||
pub shell: Option<String>,
|
||||
}
|
||||
|
||||
// ── TabConfig ───────────────────────────────────────────────────────
|
||||
|
||||
/// A tab config loaded from a `.toml` file in `~/.warp/tab_configs/`.
|
||||
///
|
||||
/// Pane layout is defined with a flat `[[panes]]` array where the first entry
|
||||
/// is the root and splits reference children by ID.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TabConfig {
|
||||
/// Display name shown in the + menu.
|
||||
pub name: String,
|
||||
/// Optional tab title template. Supports `{{ }}` template variables.
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
/// Optional tab color.
|
||||
#[serde(default)]
|
||||
pub color: Option<AnsiColorIdentifier>,
|
||||
/// Flat pane list. The first entry is the root of the pane tree.
|
||||
#[serde(default)]
|
||||
pub panes: Vec<TabConfigPaneNode>,
|
||||
/// Named parameters that the user fills in before the tab opens.
|
||||
/// Keys are parameter names (used in `{{ }}` placeholders).
|
||||
#[serde(default)]
|
||||
pub params: HashMap<String, TabConfigParam>,
|
||||
/// The on-disk path this config was loaded from.
|
||||
/// Populated during parsing; not serialized into or from the TOML.
|
||||
#[serde(skip)]
|
||||
pub source_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TabConfig {
|
||||
/// Returns param values using defaults where available, and empty strings for
|
||||
/// params with no default. This is the fallback used when no param-fill UI is shown.
|
||||
pub fn default_param_values(&self) -> HashMap<String, String> {
|
||||
self.params
|
||||
.iter()
|
||||
.map(|(name, param)| {
|
||||
let value = param.default.clone().unwrap_or_default();
|
||||
(name.clone(), value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_worktree(&self) -> bool {
|
||||
self.panes.iter().any(|pane| {
|
||||
pane.commands.as_ref().is_some_and(|commands| {
|
||||
commands
|
||||
.iter()
|
||||
.any(|command| command.contains("git worktree"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn uses_autogenerated_branch_name(&self) -> bool {
|
||||
self.title
|
||||
.as_deref()
|
||||
.is_some_and(contains_autogenerated_branch_name)
|
||||
|| self.panes.iter().any(|pane| {
|
||||
pane.directory
|
||||
.as_deref()
|
||||
.is_some_and(contains_autogenerated_branch_name)
|
||||
|| pane.commands.as_ref().is_some_and(|commands| {
|
||||
commands
|
||||
.iter()
|
||||
.any(|command| contains_autogenerated_branch_name(command))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a [`TabConfig`] with the given param values into a [`PaneTemplateType`]
|
||||
/// and an optional rendered title.
|
||||
///
|
||||
/// - `title` and `directory` receive unquoted param values (quoting would break paths).
|
||||
/// - `commands` receive shell-quoted param values to prevent injection.
|
||||
/// - `worktree_branch_name`: when `Some`, injected into the template context as the
|
||||
/// special-cased `autogenerated_branch_name` var so commands can use
|
||||
/// `{{autogenerated_branch_name}}`.
|
||||
pub fn render_tab_config(
|
||||
config: &TabConfig,
|
||||
param_values: &HashMap<String, String>,
|
||||
worktree_branch_name: Option<&str>,
|
||||
) -> (Option<String>, PaneTemplateType) {
|
||||
let (unquoted_context, quoted_context) =
|
||||
build_template_contexts(param_values, worktree_branch_name);
|
||||
|
||||
let rendered_title = config
|
||||
.title
|
||||
.as_deref()
|
||||
.map(|t| handlebars::render_template(t, &unquoted_context));
|
||||
|
||||
let pane_template = match resolve_pane_tree(&config.panes, &unquoted_context, "ed_context) {
|
||||
Ok(template) => template,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to resolve pane tree for '{}': {err}", config.name);
|
||||
PaneTemplateType::PaneTemplate {
|
||||
cwd: PathBuf::new(),
|
||||
commands: Vec::new(),
|
||||
is_focused: Some(true),
|
||||
pane_mode: PaneMode::Terminal,
|
||||
shell: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(rendered_title, pane_template)
|
||||
}
|
||||
|
||||
fn build_template_contexts(
|
||||
param_values: &HashMap<String, String>,
|
||||
worktree_branch_name: Option<&str>,
|
||||
) -> (HashMap<String, String>, HashMap<String, String>) {
|
||||
let mut unquoted_context = param_values.clone();
|
||||
let mut quoted_context: HashMap<String, String> = param_values
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), shell_words::quote(v).into_owned()))
|
||||
.collect();
|
||||
|
||||
if let Some(name) = worktree_branch_name {
|
||||
unquoted_context.insert(
|
||||
AUTOGENERATED_BRANCH_NAME_PARAM.to_string(),
|
||||
name.to_string(),
|
||||
);
|
||||
quoted_context.insert(
|
||||
AUTOGENERATED_BRANCH_NAME_PARAM.to_string(),
|
||||
shell_words::quote(name).into_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
(unquoted_context, quoted_context)
|
||||
}
|
||||
|
||||
// ── Flat pane tree resolution ───────────────────────────────────────
|
||||
|
||||
/// Builds a [`PaneTemplateType`] tree from a flat `[[panes]]` list.
|
||||
///
|
||||
/// The first entry is the root. Split nodes reference children by ID;
|
||||
/// leaf nodes produce terminal panes.
|
||||
fn resolve_pane_tree(
|
||||
panes: &[TabConfigPaneNode],
|
||||
unquoted: &HashMap<String, String>,
|
||||
quoted: &HashMap<String, String>,
|
||||
) -> Result<PaneTemplateType, String> {
|
||||
if panes.is_empty() {
|
||||
return Err("panes array is empty".to_string());
|
||||
}
|
||||
|
||||
let pane_map: HashMap<&str, &TabConfigPaneNode> =
|
||||
panes.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||
|
||||
if pane_map.len() != panes.len() {
|
||||
return Err("duplicate pane IDs detected".to_string());
|
||||
}
|
||||
|
||||
let root = &panes[0];
|
||||
let has_explicit_focus = panes
|
||||
.iter()
|
||||
.any(|p| p.split.is_none() && p.is_focused == Some(true));
|
||||
|
||||
let (template, _) = resolve_pane_node(
|
||||
root,
|
||||
&pane_map,
|
||||
unquoted,
|
||||
quoted,
|
||||
!has_explicit_focus, // auto-focus first leaf when nothing is explicitly focused
|
||||
)?;
|
||||
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// Recursively resolves a single pane node.
|
||||
///
|
||||
/// Returns `(PaneTemplateType, did_consume_auto_focus)` — the second element
|
||||
/// is `true` when a leaf consumed the auto-focus so subsequent leaves are not
|
||||
/// also focused.
|
||||
fn resolve_pane_node(
|
||||
node: &TabConfigPaneNode,
|
||||
pane_map: &HashMap<&str, &TabConfigPaneNode>,
|
||||
unquoted: &HashMap<String, String>,
|
||||
quoted: &HashMap<String, String>,
|
||||
auto_focus_first_leaf: bool,
|
||||
) -> Result<(PaneTemplateType, bool), String> {
|
||||
if let Some(split_direction) = &node.split {
|
||||
// ── Split node ──────────────────────────────────────────────
|
||||
let children = node
|
||||
.children
|
||||
.as_ref()
|
||||
.ok_or_else(|| format!("split node '{}' is missing 'children'", node.id))?;
|
||||
|
||||
if children.len() < 2 {
|
||||
return Err(format!(
|
||||
"split node '{}' must have at least 2 children, got {}",
|
||||
node.id,
|
||||
children.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mut child_templates = Vec::with_capacity(children.len());
|
||||
let mut auto_focus_remaining = auto_focus_first_leaf;
|
||||
|
||||
for child_id in children {
|
||||
let child = pane_map.get(child_id.as_str()).ok_or_else(|| {
|
||||
format!(
|
||||
"split node '{}' references unknown child '{}'",
|
||||
node.id, child_id
|
||||
)
|
||||
})?;
|
||||
let (child_template, did_focus) =
|
||||
resolve_pane_node(child, pane_map, unquoted, quoted, auto_focus_remaining)?;
|
||||
if did_focus {
|
||||
auto_focus_remaining = false;
|
||||
}
|
||||
child_templates.push(child_template);
|
||||
}
|
||||
|
||||
Ok((
|
||||
PaneTemplateType::PaneBranchTemplate {
|
||||
split_direction: split_direction.clone(),
|
||||
panes: child_templates,
|
||||
},
|
||||
auto_focus_first_leaf && !auto_focus_remaining,
|
||||
))
|
||||
} else {
|
||||
// ── Leaf node ────────────────────────────────────────────────
|
||||
let pane_type = node
|
||||
.pane_type
|
||||
.as_ref()
|
||||
.ok_or_else(|| format!("leaf pane '{}' is missing required 'type' field", node.id))?;
|
||||
|
||||
let pane_mode = match pane_type {
|
||||
TabConfigPaneType::Terminal => PaneMode::Terminal,
|
||||
TabConfigPaneType::Agent => PaneMode::Agent,
|
||||
TabConfigPaneType::Cloud => PaneMode::Cloud,
|
||||
};
|
||||
|
||||
let cwd = node
|
||||
.directory
|
||||
.as_deref()
|
||||
.map(|c| {
|
||||
let rendered = handlebars::render_template(c, unquoted);
|
||||
PathBuf::from(shellexpand::tilde(&rendered).into_owned())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let commands: Vec<CommandTemplate> = node
|
||||
.commands
|
||||
.as_ref()
|
||||
.map(|cmds| {
|
||||
cmds.iter()
|
||||
.map(|cmd| CommandTemplate {
|
||||
exec: handlebars::render_template(cmd, quoted),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let explicitly_focused = node.is_focused == Some(true);
|
||||
let is_focused = explicitly_focused || auto_focus_first_leaf;
|
||||
let did_consume = is_focused && !explicitly_focused;
|
||||
|
||||
Ok((
|
||||
PaneTemplateType::PaneTemplate {
|
||||
cwd,
|
||||
commands,
|
||||
is_focused: Some(is_focused),
|
||||
pane_mode,
|
||||
shell: node.shell.clone(),
|
||||
},
|
||||
explicitly_focused || did_consume,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a worktree tab config TOML string.
|
||||
///
|
||||
/// When `worktree_branch_name` is `Some(name)` (manual naming), the returned
|
||||
/// TOML bakes commands with Handlebars template variables and exposes a
|
||||
/// `worktree_branch_name` param so re-opens show the params modal.
|
||||
///
|
||||
/// When `None` (autogenerate), the TOML stores commands with
|
||||
/// `{{autogenerated_branch_name}}` Handlebars template variables that get
|
||||
/// substituted with a fresh name on every open.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn build_worktree_config_toml(
|
||||
config_name: &str,
|
||||
repo: &str,
|
||||
base_branch: &str,
|
||||
worktree_branch_name: Option<&str>,
|
||||
) -> String {
|
||||
let mut doc = toml::map::Map::new();
|
||||
doc.insert("name".into(), Value::String(config_name.to_string()));
|
||||
|
||||
let mut pane = toml::map::Map::new();
|
||||
pane.insert("id".into(), Value::String("main".into()));
|
||||
pane.insert("type".into(), Value::String("terminal".into()));
|
||||
pane.insert("directory".into(), Value::String(repo.to_string()));
|
||||
|
||||
if worktree_branch_name.is_some() {
|
||||
let worktree_branch_name = handlebars_placeholder("worktree_branch_name");
|
||||
let worktree_path = generated_worktree_path_string(Path::new(repo), &worktree_branch_name);
|
||||
doc.insert("title".into(), Value::String(worktree_branch_name.clone()));
|
||||
pane.insert(
|
||||
"commands".into(),
|
||||
Value::Array(vec![
|
||||
Value::String(format!(
|
||||
"git worktree add -b {worktree_branch_name} {worktree_path} {base_branch}"
|
||||
)),
|
||||
Value::String(format!("cd {worktree_path}")),
|
||||
]),
|
||||
);
|
||||
doc.insert("panes".into(), Value::Array(vec![Value::Table(pane)]));
|
||||
|
||||
let mut param = toml::map::Map::new();
|
||||
param.insert("type".into(), Value::String("text".into()));
|
||||
param.insert(
|
||||
"description".into(),
|
||||
Value::String("Worktree branch name".to_string()),
|
||||
);
|
||||
let mut params = toml::map::Map::new();
|
||||
params.insert("worktree_branch_name".into(), Value::Table(param));
|
||||
doc.insert("params".into(), Value::Table(params));
|
||||
} else {
|
||||
let autogenerated_branch_name = handlebars_placeholder(AUTOGENERATED_BRANCH_NAME_PARAM);
|
||||
let worktree_path =
|
||||
generated_worktree_path_string(Path::new(repo), &autogenerated_branch_name);
|
||||
pane.insert(
|
||||
"commands".into(),
|
||||
Value::Array(vec![
|
||||
Value::String(format!(
|
||||
"git worktree add -b {autogenerated_branch_name} {worktree_path} {base_branch}"
|
||||
)),
|
||||
Value::String(format!("cd {worktree_path}")),
|
||||
]),
|
||||
);
|
||||
doc.insert("panes".into(), Value::Array(vec![Value::Table(pane)]));
|
||||
}
|
||||
|
||||
toml::to_string_pretty(&Value::Table(doc)).expect("generated TOML should always serialize")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tab_config_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,906 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::launch_configs::launch_config::{PaneTemplateType, SplitDirection};
|
||||
|
||||
use super::*;
|
||||
|
||||
const WORKTREE_TOML: &str = r#"
|
||||
name = "New Worktree"
|
||||
title = "{{worktree_branch_name}}"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
directory = "{{repo}}"
|
||||
commands = [
|
||||
"git worktree add -b {{worktree_branch_name}} $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}} {{branch}}",
|
||||
"cd $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}}",
|
||||
]
|
||||
|
||||
[params.repo]
|
||||
type = "repo"
|
||||
description = "Absolute path to repository"
|
||||
|
||||
[params.branch]
|
||||
type = "branch"
|
||||
description = "Base branch to branch from"
|
||||
|
||||
[params.worktree_branch_name]
|
||||
type = "text"
|
||||
description = "New worktree branch name"
|
||||
default = "my-feature-branch"
|
||||
"#;
|
||||
|
||||
fn generated_worktree_path_string(repo: &str, worktree_name: &str) -> String {
|
||||
generated_worktree_path(Path::new(repo), worktree_name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_test_tab_config_toml(name: &str, commands: Vec<String>) -> String {
|
||||
let config = TabConfig {
|
||||
name: name.to_string(),
|
||||
title: None,
|
||||
color: None,
|
||||
panes: vec![TabConfigPaneNode {
|
||||
id: "main".to_string(),
|
||||
pane_type: Some(TabConfigPaneType::Terminal),
|
||||
split: None,
|
||||
children: None,
|
||||
is_focused: None,
|
||||
directory: Some("/Users/me/repo".to_string()),
|
||||
commands: Some(commands),
|
||||
shell: None,
|
||||
}],
|
||||
params: HashMap::new(),
|
||||
source_path: None,
|
||||
};
|
||||
|
||||
toml::to_string_pretty(&config).expect("Test config should serialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_worktree_path_uses_repo_name_directory() {
|
||||
let repo_dir = generated_worktree_repo_dir(Path::new("/Users/me/backend"));
|
||||
|
||||
assert_eq!(
|
||||
repo_dir,
|
||||
warp_core::paths::data_dir()
|
||||
.join("worktrees")
|
||||
.join("backend")
|
||||
);
|
||||
assert_eq!(
|
||||
generated_worktree_path(Path::new("/Users/me/backend"), "mesa-coyote"),
|
||||
repo_dir.join("mesa-coyote")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_worktree_toml() {
|
||||
let config: TabConfig = toml::from_str(WORKTREE_TOML).expect("Should parse worktree TOML");
|
||||
|
||||
assert_eq!(config.name, "New Worktree");
|
||||
assert_eq!(config.title.as_deref(), Some("{{worktree_branch_name}}"));
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
assert_eq!(config.panes[0].id, "main");
|
||||
assert_eq!(config.panes[0].directory.as_deref(), Some("{{repo}}"));
|
||||
assert_eq!(
|
||||
config.panes[0].commands.as_deref().unwrap(),
|
||||
&[
|
||||
"git worktree add -b {{worktree_branch_name}} $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}} {{branch}}",
|
||||
"cd $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}}"
|
||||
]
|
||||
);
|
||||
assert_eq!(config.params.len(), 3);
|
||||
assert_eq!(config.params["repo"].param_type, TabConfigParamType::Repo);
|
||||
assert_eq!(
|
||||
config.params["branch"].param_type,
|
||||
TabConfigParamType::Branch
|
||||
);
|
||||
assert_eq!(
|
||||
config.params["worktree_branch_name"].param_type,
|
||||
TabConfigParamType::Text
|
||||
);
|
||||
assert!(config.params["repo"].default.is_none());
|
||||
assert!(config.params["branch"].default.is_none());
|
||||
assert_eq!(
|
||||
config.params["worktree_branch_name"].default.as_deref(),
|
||||
Some("my-feature-branch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_toml() {
|
||||
let toml = r#"name = "Plain Tab""#;
|
||||
let config: TabConfig = toml::from_str(toml).expect("Should parse minimal TOML");
|
||||
|
||||
assert_eq!(config.name, "Plain Tab");
|
||||
assert!(config.title.is_none());
|
||||
assert!(config.panes.is_empty());
|
||||
assert!(config.params.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_toml_with_on_close_fails() {
|
||||
let toml = r#"
|
||||
name = "Legacy Tab"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
|
||||
[on_close]
|
||||
commands = ["echo cleanup"]
|
||||
"#;
|
||||
|
||||
let error = toml::from_str::<TabConfig>(toml).expect_err("on_close should be rejected");
|
||||
assert!(error.to_string().contains("unknown field `on_close`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_param_values() {
|
||||
let config: TabConfig = toml::from_str(WORKTREE_TOML).unwrap();
|
||||
let defaults = config.default_param_values();
|
||||
assert_eq!(defaults["branch"], "");
|
||||
assert_eq!(defaults["repo"], "");
|
||||
assert_eq!(defaults["worktree_branch_name"], "my-feature-branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_worktree() {
|
||||
let worktree_config: TabConfig = toml::from_str(WORKTREE_TOML).unwrap();
|
||||
assert!(worktree_config.is_worktree());
|
||||
|
||||
let plain_config: TabConfig = toml::from_str(
|
||||
r#"
|
||||
name = "Plain Tab"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
commands = ["pwd", "ls"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!plain_config.is_worktree());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tab_config_substitutes_values() {
|
||||
let config: TabConfig = toml::from_str(WORKTREE_TOML).unwrap();
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("repo".to_string(), "/Users/me/repo".to_string());
|
||||
params.insert("branch".to_string(), "main".to_string());
|
||||
params.insert("worktree_branch_name".to_string(), "my-feature".to_string());
|
||||
|
||||
let (title, pane_template) = render_tab_config(&config, ¶ms, None);
|
||||
|
||||
assert_eq!(title.as_deref(), Some("my-feature"));
|
||||
|
||||
if let crate::launch_configs::launch_config::PaneTemplateType::PaneTemplate {
|
||||
cwd,
|
||||
commands,
|
||||
..
|
||||
} = pane_template
|
||||
{
|
||||
assert_eq!(cwd, std::path::PathBuf::from("/Users/me/repo"));
|
||||
// Commands should have shell-quoted values.
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
"git worktree add -b my-feature $HOME/.warp/worktrees/$(basename /Users/me/repo)/my-feature main"
|
||||
);
|
||||
assert_eq!(
|
||||
commands[1].exec,
|
||||
"cd $HOME/.warp/worktrees/$(basename /Users/me/repo)/my-feature"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PaneTemplate variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tab_config_shell_quotes_commands_with_spaces() {
|
||||
let config: TabConfig = toml::from_str(WORKTREE_TOML).unwrap();
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("repo".to_string(), "/Users/me/my project".to_string());
|
||||
params.insert("branch".to_string(), "release-candidate".to_string());
|
||||
params.insert("worktree_branch_name".to_string(), "my feature".to_string());
|
||||
|
||||
let (_, pane_template) = render_tab_config(&config, ¶ms, None);
|
||||
|
||||
if let crate::launch_configs::launch_config::PaneTemplateType::PaneTemplate {
|
||||
commands, ..
|
||||
} = pane_template
|
||||
{
|
||||
// Values with spaces should be quoted in commands.
|
||||
assert!(
|
||||
commands[0].exec.contains("'my feature'"),
|
||||
"Expected shell-quoted worktree branch name in command: {}",
|
||||
commands[0].exec
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PaneTemplate variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tab_config_multi_pane() {
|
||||
let toml = r#"
|
||||
name = "Split Tab"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["left", "right"]
|
||||
|
||||
[[panes]]
|
||||
id = "left"
|
||||
type = "terminal"
|
||||
directory = "{{repo}}"
|
||||
commands = [
|
||||
"git worktree add -b {{worktree_branch_name}} $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}} {{branch}}",
|
||||
"cd $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}}",
|
||||
]
|
||||
|
||||
[[panes]]
|
||||
id = "right"
|
||||
type = "terminal"
|
||||
directory = "{{repo}}"
|
||||
commands = ["nvim"]
|
||||
|
||||
[params.repo]
|
||||
type = "repo"
|
||||
description = "Repo path"
|
||||
|
||||
[params.branch]
|
||||
type = "branch"
|
||||
description = "Base branch to branch from"
|
||||
|
||||
[params.worktree_branch_name]
|
||||
type = "text"
|
||||
description = "New worktree branch name"
|
||||
"#;
|
||||
|
||||
let config: TabConfig = toml::from_str(toml).expect("Should parse multi-pane TOML");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("repo".to_string(), "/Users/me/repo".to_string());
|
||||
params.insert("branch".to_string(), "main".to_string());
|
||||
params.insert("worktree_branch_name".to_string(), "my-feature".to_string());
|
||||
|
||||
let (title, pane_template) = render_tab_config(&config, ¶ms, None);
|
||||
assert!(title.is_none());
|
||||
|
||||
if let PaneTemplateType::PaneBranchTemplate {
|
||||
split_direction,
|
||||
panes,
|
||||
} = pane_template
|
||||
{
|
||||
assert_eq!(split_direction, SplitDirection::Horizontal);
|
||||
assert_eq!(panes.len(), 2);
|
||||
|
||||
// First pane should be focused and have two commands.
|
||||
if let PaneTemplateType::PaneTemplate {
|
||||
cwd,
|
||||
commands,
|
||||
is_focused,
|
||||
..
|
||||
} = &panes[0]
|
||||
{
|
||||
assert_eq!(*cwd, std::path::PathBuf::from("/Users/me/repo"));
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
"git worktree add -b my-feature $HOME/.warp/worktrees/$(basename /Users/me/repo)/my-feature main"
|
||||
);
|
||||
assert_eq!(
|
||||
commands[1].exec,
|
||||
"cd $HOME/.warp/worktrees/$(basename /Users/me/repo)/my-feature"
|
||||
);
|
||||
assert_eq!(*is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for first child");
|
||||
}
|
||||
|
||||
// Second pane should not be focused.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &panes[1] {
|
||||
assert_eq!(*is_focused, Some(false));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for second child");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected PaneBranchTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tab_config_cwd_is_not_quoted() {
|
||||
let config: TabConfig = toml::from_str(WORKTREE_TOML).unwrap();
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("repo".to_string(), "/Users/me/my project".to_string());
|
||||
params.insert("branch".to_string(), "main".to_string());
|
||||
params.insert("worktree_branch_name".to_string(), "my-feature".to_string());
|
||||
|
||||
let (_, pane_template) = render_tab_config(&config, ¶ms, None);
|
||||
|
||||
if let crate::launch_configs::launch_config::PaneTemplateType::PaneTemplate { cwd, .. } =
|
||||
pane_template
|
||||
{
|
||||
// cwd should be unquoted (raw path).
|
||||
assert_eq!(cwd, std::path::PathBuf::from("/Users/me/my project"));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate variant");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flat pane format tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_flat_single_pane() {
|
||||
let toml_str = r#"
|
||||
name = "Single"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
directory = "~/code/project"
|
||||
commands = ["npm run dev"]
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse flat single pane");
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
assert_eq!(config.panes[0].id, "main");
|
||||
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
if let PaneTemplateType::PaneTemplate {
|
||||
commands,
|
||||
is_focused,
|
||||
..
|
||||
} = template
|
||||
{
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].exec, "npm run dev");
|
||||
// Single pane should be auto-focused.
|
||||
assert_eq!(is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for single flat pane");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_two_pane_split() {
|
||||
let toml_str = r#"
|
||||
name = "Split"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["left", "right"]
|
||||
|
||||
[[panes]]
|
||||
id = "left"
|
||||
type = "terminal"
|
||||
directory = "~/code/frontend"
|
||||
commands = ["npm start"]
|
||||
|
||||
[[panes]]
|
||||
id = "right"
|
||||
type = "terminal"
|
||||
directory = "~/code/backend"
|
||||
commands = ["cargo run"]
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse flat split");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
|
||||
if let PaneTemplateType::PaneBranchTemplate {
|
||||
split_direction,
|
||||
panes,
|
||||
} = template
|
||||
{
|
||||
assert_eq!(split_direction, SplitDirection::Horizontal);
|
||||
assert_eq!(panes.len(), 2);
|
||||
|
||||
// First leaf should be auto-focused.
|
||||
if let PaneTemplateType::PaneTemplate {
|
||||
commands,
|
||||
is_focused,
|
||||
..
|
||||
} = &panes[0]
|
||||
{
|
||||
assert_eq!(commands[0].exec, "npm start");
|
||||
assert_eq!(*is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for left child");
|
||||
}
|
||||
|
||||
// Second leaf should not be focused.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &panes[1] {
|
||||
assert_eq!(*is_focused, Some(false));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for right child");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected PaneBranchTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_2x2_grid() {
|
||||
let toml_str = r#"
|
||||
name = "Grid"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["left_col", "right_col"]
|
||||
|
||||
[[panes]]
|
||||
id = "left_col"
|
||||
split = "vertical"
|
||||
children = ["tl", "bl"]
|
||||
|
||||
[[panes]]
|
||||
id = "tl"
|
||||
type = "terminal"
|
||||
directory = "~/a"
|
||||
|
||||
[[panes]]
|
||||
id = "bl"
|
||||
type = "terminal"
|
||||
directory = "~/b"
|
||||
|
||||
[[panes]]
|
||||
id = "right_col"
|
||||
split = "vertical"
|
||||
children = ["tr", "br"]
|
||||
|
||||
[[panes]]
|
||||
id = "tr"
|
||||
type = "terminal"
|
||||
directory = "~/c"
|
||||
|
||||
[[panes]]
|
||||
id = "br"
|
||||
type = "terminal"
|
||||
directory = "~/d"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse flat 2x2");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
|
||||
// Root should be a horizontal split.
|
||||
let PaneTemplateType::PaneBranchTemplate {
|
||||
split_direction: root_dir,
|
||||
panes: root_children,
|
||||
} = template
|
||||
else {
|
||||
panic!("Expected root PaneBranchTemplate");
|
||||
};
|
||||
assert_eq!(root_dir, SplitDirection::Horizontal);
|
||||
assert_eq!(root_children.len(), 2);
|
||||
|
||||
// Left column should be a vertical split with 2 children.
|
||||
let PaneTemplateType::PaneBranchTemplate {
|
||||
split_direction: left_dir,
|
||||
panes: left_children,
|
||||
} = &root_children[0]
|
||||
else {
|
||||
panic!("Expected left_col PaneBranchTemplate");
|
||||
};
|
||||
assert_eq!(*left_dir, SplitDirection::Vertical);
|
||||
assert_eq!(left_children.len(), 2);
|
||||
|
||||
// Top-left should be focused (first leaf in tree, auto-focus).
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &left_children[0] {
|
||||
assert_eq!(*is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for tl");
|
||||
}
|
||||
|
||||
// Bottom-left should not be focused.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &left_children[1] {
|
||||
assert_eq!(*is_focused, Some(false), "Expected bl to not be focused");
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for bl");
|
||||
}
|
||||
|
||||
// Right column should be a vertical split with 2 children.
|
||||
let PaneTemplateType::PaneBranchTemplate {
|
||||
panes: right_children,
|
||||
..
|
||||
} = &root_children[1]
|
||||
else {
|
||||
panic!("Expected right_col PaneBranchTemplate");
|
||||
};
|
||||
for (label, pane) in [("tr", &right_children[0]), ("br", &right_children[1])] {
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = pane {
|
||||
assert_eq!(
|
||||
*is_focused,
|
||||
Some(false),
|
||||
"Expected {label} to not be focused"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PaneTemplate for {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_explicit_focus() {
|
||||
let toml_str = r#"
|
||||
name = "Focus Test"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["left", "right"]
|
||||
|
||||
[[panes]]
|
||||
id = "left"
|
||||
type = "terminal"
|
||||
directory = "~/a"
|
||||
|
||||
[[panes]]
|
||||
id = "right"
|
||||
type = "terminal"
|
||||
directory = "~/b"
|
||||
is_focused = true
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
|
||||
if let PaneTemplateType::PaneBranchTemplate { panes, .. } = template {
|
||||
// Left should NOT be focused (explicit focus is on right).
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &panes[0] {
|
||||
assert_eq!(*is_focused, Some(false));
|
||||
}
|
||||
// Right should be focused.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &panes[1] {
|
||||
assert_eq!(*is_focused, Some(true));
|
||||
}
|
||||
} else {
|
||||
panic!("Expected PaneBranchTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_auto_focus_first_leaf() {
|
||||
let toml_str = r#"
|
||||
name = "Auto Focus"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["left", "right"]
|
||||
|
||||
[[panes]]
|
||||
id = "left"
|
||||
type = "terminal"
|
||||
directory = "~/a"
|
||||
|
||||
[[panes]]
|
||||
id = "right"
|
||||
type = "terminal"
|
||||
directory = "~/b"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
|
||||
if let PaneTemplateType::PaneBranchTemplate { panes, .. } = template {
|
||||
// First leaf (left) should be auto-focused.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &panes[0] {
|
||||
assert_eq!(*is_focused, Some(true));
|
||||
}
|
||||
// Second leaf should not be focused.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = &panes[1] {
|
||||
assert_eq!(*is_focused, Some(false));
|
||||
}
|
||||
} else {
|
||||
panic!("Expected PaneBranchTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_color_deserialized() {
|
||||
let toml_str = r#"
|
||||
name = "Colored Tab"
|
||||
color = "blue"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
directory = "~/code"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse with color");
|
||||
assert_eq!(
|
||||
config.color,
|
||||
Some(crate::themes::theme::AnsiColorIdentifier::Blue)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_missing_child_ref_falls_back() {
|
||||
let toml_str = r#"
|
||||
name = "Bad Ref"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["left", "nonexistent"]
|
||||
|
||||
[[panes]]
|
||||
id = "left"
|
||||
type = "terminal"
|
||||
directory = "~/a"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse");
|
||||
// render_tab_config should fall back to a single empty terminal.
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
if let PaneTemplateType::PaneTemplate {
|
||||
commands,
|
||||
is_focused,
|
||||
..
|
||||
} = template
|
||||
{
|
||||
assert!(commands.is_empty());
|
||||
assert_eq!(is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected fallback PaneTemplate on error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_duplicate_ids_falls_back() {
|
||||
let toml_str = r#"
|
||||
name = "Dup IDs"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
directory = "~/a"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
directory = "~/b"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
// Should fall back because of duplicate IDs.
|
||||
if let PaneTemplateType::PaneTemplate {
|
||||
commands,
|
||||
is_focused,
|
||||
..
|
||||
} = template
|
||||
{
|
||||
assert!(commands.is_empty());
|
||||
assert_eq!(is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected fallback PaneTemplate on duplicate ID error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_with_params() {
|
||||
let toml_str = r#"
|
||||
name = "Param Test"
|
||||
title = "{{branch}}"
|
||||
|
||||
[[panes]]
|
||||
id = "main"
|
||||
type = "terminal"
|
||||
directory = "{{repo}}"
|
||||
commands = ["git checkout {{branch}}"]
|
||||
|
||||
[params.repo]
|
||||
type = "repo"
|
||||
description = "Repo path"
|
||||
|
||||
[params.branch]
|
||||
type = "branch"
|
||||
description = "Branch"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse with params");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("repo".to_string(), "/Users/me/code".to_string());
|
||||
params.insert("branch".to_string(), "main".to_string());
|
||||
|
||||
let (title, template) = render_tab_config(&config, ¶ms, None);
|
||||
assert_eq!(title.as_deref(), Some("main"));
|
||||
|
||||
if let PaneTemplateType::PaneTemplate { cwd, commands, .. } = template {
|
||||
assert_eq!(cwd, std::path::PathBuf::from("/Users/me/code"));
|
||||
assert_eq!(commands[0].exec, "git checkout main");
|
||||
} else {
|
||||
panic!("Expected PaneTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worktree_template_substitution() {
|
||||
let worktree_path =
|
||||
generated_worktree_path_string("/Users/me/repo", "{{autogenerated_branch_name}}");
|
||||
let toml_str = build_test_tab_config_toml(
|
||||
"Template Worktree",
|
||||
vec![
|
||||
format!("git worktree add -b {{{{autogenerated_branch_name}}}} {worktree_path} main"),
|
||||
format!("cd {worktree_path}"),
|
||||
],
|
||||
);
|
||||
let config: TabConfig = toml::from_str(&toml_str).expect("Should parse");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), Some("mesa-coyote"));
|
||||
|
||||
if let PaneTemplateType::PaneTemplate { commands, .. } = template {
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
format!(
|
||||
"git worktree add -b mesa-coyote {} main",
|
||||
generated_worktree_path_string("/Users/me/repo", "mesa-coyote")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
commands[1].exec,
|
||||
format!(
|
||||
"cd {}",
|
||||
generated_worktree_path_string("/Users/me/repo", "mesa-coyote")
|
||||
)
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PaneTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worktree_custom_commands_with_template() {
|
||||
let worktree_path =
|
||||
generated_worktree_path_string("/Users/me/repo", "{{autogenerated_branch_name}}");
|
||||
let toml_str = build_test_tab_config_toml(
|
||||
"Custom Worktree",
|
||||
vec![
|
||||
format!("git worktree add -b {{{{autogenerated_branch_name}}}} {worktree_path} main"),
|
||||
format!("cd {worktree_path}"),
|
||||
"gt branch create".to_string(),
|
||||
"npm install".to_string(),
|
||||
],
|
||||
);
|
||||
let config: TabConfig = toml::from_str(&toml_str).expect("Should parse");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), Some("mesa-coyote"));
|
||||
|
||||
if let PaneTemplateType::PaneTemplate { commands, .. } = template {
|
||||
assert_eq!(commands.len(), 4);
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
format!(
|
||||
"git worktree add -b mesa-coyote {} main",
|
||||
generated_worktree_path_string("/Users/me/repo", "mesa-coyote")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
commands[1].exec,
|
||||
format!(
|
||||
"cd {}",
|
||||
generated_worktree_path_string("/Users/me/repo", "mesa-coyote")
|
||||
)
|
||||
);
|
||||
assert_eq!(commands[2].exec, "gt branch create");
|
||||
assert_eq!(commands[3].exec, "npm install");
|
||||
} else {
|
||||
panic!("Expected PaneTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_worktree_toml_autogenerate_round_trips() {
|
||||
let toml_str =
|
||||
build_worktree_config_toml("Worktree: my-project", "/Users/me/repo", "main", None);
|
||||
let config: TabConfig = toml::from_str(&toml_str).expect("Generated TOML should parse");
|
||||
|
||||
assert_eq!(config.name, "Worktree: my-project");
|
||||
assert!(config.title.is_none());
|
||||
assert!(config.params.is_empty());
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
|
||||
let pane = &config.panes[0];
|
||||
assert!(config.uses_autogenerated_branch_name());
|
||||
assert!(pane.commands.is_some());
|
||||
assert_eq!(pane.directory.as_deref(), Some("/Users/me/repo"));
|
||||
|
||||
// Verify rendering produces the correct commands.
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), Some("obsidian-hawk"));
|
||||
if let PaneTemplateType::PaneTemplate { commands, .. } = template {
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
format!(
|
||||
"git worktree add -b obsidian-hawk {} main",
|
||||
generated_worktree_path_string("/Users/me/repo", "obsidian-hawk")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
commands[1].exec,
|
||||
format!(
|
||||
"cd {}",
|
||||
generated_worktree_path_string("/Users/me/repo", "obsidian-hawk")
|
||||
)
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PaneTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_worktree_toml_manual_round_trips() {
|
||||
let toml_str = build_worktree_config_toml(
|
||||
"Worktree: my-project",
|
||||
"/Users/me/repo",
|
||||
"main",
|
||||
Some("my-feature"),
|
||||
);
|
||||
let config: TabConfig = toml::from_str(&toml_str).expect("Generated TOML should parse");
|
||||
|
||||
assert_eq!(config.name, "Worktree: my-project");
|
||||
assert_eq!(config.title.as_deref(), Some("{{worktree_branch_name}}"));
|
||||
assert!(config.params.contains_key("worktree_branch_name"));
|
||||
assert_eq!(
|
||||
config.params["worktree_branch_name"].param_type,
|
||||
TabConfigParamType::Text
|
||||
);
|
||||
assert_eq!(config.panes.len(), 1);
|
||||
|
||||
let pane = &config.panes[0];
|
||||
assert!(!config.uses_autogenerated_branch_name());
|
||||
assert!(pane.commands.is_some());
|
||||
|
||||
// Verify rendering substitutes the branch name into commands and title.
|
||||
let mut params = HashMap::new();
|
||||
params.insert("worktree_branch_name".to_string(), "my-feature".to_string());
|
||||
let (title, template) = render_tab_config(&config, ¶ms, None);
|
||||
assert_eq!(title.as_deref(), Some("my-feature"));
|
||||
|
||||
if let PaneTemplateType::PaneTemplate { commands, .. } = template {
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(
|
||||
commands[0].exec,
|
||||
format!(
|
||||
"git worktree add -b my-feature {} main",
|
||||
generated_worktree_path_string("/Users/me/repo", "my-feature")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
commands[1].exec,
|
||||
format!(
|
||||
"cd {}",
|
||||
generated_worktree_path_string("/Users/me/repo", "my-feature")
|
||||
)
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PaneTemplate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_split_with_fewer_than_two_children_falls_back() {
|
||||
let toml_str = r#"
|
||||
name = "Bad Split"
|
||||
|
||||
[[panes]]
|
||||
id = "root"
|
||||
split = "horizontal"
|
||||
children = ["only"]
|
||||
|
||||
[[panes]]
|
||||
id = "only"
|
||||
type = "terminal"
|
||||
directory = "~/a"
|
||||
"#;
|
||||
let config: TabConfig = toml::from_str(toml_str).expect("Should parse");
|
||||
let (_, template) = render_tab_config(&config, &HashMap::new(), None);
|
||||
// Should fall back because split needs >= 2 children.
|
||||
if let PaneTemplateType::PaneTemplate { is_focused, .. } = template {
|
||||
assert_eq!(is_focused, Some(true));
|
||||
} else {
|
||||
panic!("Expected fallback PaneTemplate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
use crate::tab_configs::session_config::SessionType;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExistingTabConfigOpenMode {
|
||||
Direct,
|
||||
ParamsModal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NewWorktreeConfigOpenSource {
|
||||
Submenu,
|
||||
NewWorktreeModal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorktreeBranchNamingMode {
|
||||
Auto,
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GuidedModalSessionType {
|
||||
Terminal,
|
||||
Oz,
|
||||
CliAgent,
|
||||
}
|
||||
|
||||
impl From<&SessionType> for GuidedModalSessionType {
|
||||
fn from(value: &SessionType) -> Self {
|
||||
match value {
|
||||
SessionType::Terminal => Self::Terminal,
|
||||
SessionType::Oz => Self::Oz,
|
||||
SessionType::CliAgent(_) => Self::CliAgent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub enum TabConfigsTelemetryEvent {
|
||||
MenuCreateNewTabConfigClicked,
|
||||
ExistingConfigOpened {
|
||||
open_mode: ExistingTabConfigOpenMode,
|
||||
is_worktree_config: bool,
|
||||
},
|
||||
NewWorktreeConfigOpened {
|
||||
source: NewWorktreeConfigOpenSource,
|
||||
naming_mode: WorktreeBranchNamingMode,
|
||||
},
|
||||
GuidedModalOpened,
|
||||
GuidedModalSubmitted {
|
||||
session_type: GuidedModalSessionType,
|
||||
enable_worktree: bool,
|
||||
autogenerate_worktree_branch_name: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for TabConfigsTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
TabConfigsTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::MenuCreateNewTabConfigClicked | Self::GuidedModalOpened => None,
|
||||
Self::ExistingConfigOpened {
|
||||
open_mode,
|
||||
is_worktree_config,
|
||||
} => Some(json!({
|
||||
"open_mode": open_mode,
|
||||
"is_worktree_config": is_worktree_config,
|
||||
})),
|
||||
Self::NewWorktreeConfigOpened {
|
||||
source,
|
||||
naming_mode,
|
||||
} => Some(json!({
|
||||
"source": source,
|
||||
"naming_mode": naming_mode,
|
||||
})),
|
||||
Self::GuidedModalSubmitted {
|
||||
session_type,
|
||||
enable_worktree,
|
||||
autogenerate_worktree_branch_name,
|
||||
} => Some(json!({
|
||||
"session_type": session_type,
|
||||
"enable_worktree": enable_worktree,
|
||||
"autogenerate_worktree_branch_name": autogenerate_worktree_branch_name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
TabConfigsTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
TabConfigsTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
match self {
|
||||
Self::MenuCreateNewTabConfigClicked => false,
|
||||
Self::ExistingConfigOpened { .. } => false,
|
||||
Self::NewWorktreeConfigOpened { .. } => false,
|
||||
Self::GuidedModalOpened => false,
|
||||
Self::GuidedModalSubmitted { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for TabConfigsTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MenuCreateNewTabConfigClicked => "TabConfigs.MenuCreateNewTabConfigClicked",
|
||||
Self::ExistingConfigOpened => "TabConfigs.ExistingConfigOpened",
|
||||
Self::NewWorktreeConfigOpened => "TabConfigs.NewWorktreeConfigOpened",
|
||||
Self::GuidedModalOpened => "TabConfigs.GuidedModalOpened",
|
||||
Self::GuidedModalSubmitted => "TabConfigs.GuidedModalSubmitted",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MenuCreateNewTabConfigClicked => {
|
||||
"User clicked the New tab config entry from the tab configs menu"
|
||||
}
|
||||
Self::ExistingConfigOpened => "User opened an existing saved tab config",
|
||||
Self::NewWorktreeConfigOpened => {
|
||||
"User opened a new worktree config from the submenu or new worktree modal"
|
||||
}
|
||||
Self::GuidedModalOpened => "User opened the guided Create a tab config modal",
|
||||
Self::GuidedModalSubmitted => "User submitted the guided Create a tab config modal",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
Self::MenuCreateNewTabConfigClicked
|
||||
| Self::ExistingConfigOpened
|
||||
| Self::NewWorktreeConfigOpened
|
||||
| Self::GuidedModalOpened
|
||||
| Self::GuidedModalSubmitted => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(TabConfigsTelemetryEvent);
|
||||
Reference in New Issue
Block a user