Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+248
View File
@@ -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()
}
+296
View File
@@ -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());
}
}
+27
View File
@@ -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>,
}
+644
View File
@@ -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();
}
}
}
}
+739
View File
@@ -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) = &param.description {
form.add_child(
Container::new(
Text::new_inline(
description.clone(),
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_color(sub_text.into())
.finish(),
)
.with_margin_bottom(4.)
.finish(),
);
}
// Default value hint (text params only — pickers show the value in their top bar).
if matches!(param.param_type, TabConfigParamType::Text) {
if let Some(default_value) = &param.default {
form.add_child(
Container::new(
Text::new_inline(
format!("Default: {default_value}"),
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_color(sub_text.into())
.finish(),
)
.with_margin_bottom(4.)
.finish(),
);
}
}
// The input field itself.
// Text editors get the standard text_input border treatment;
// pickers (Dropdown-based) already render their own chrome.
let field_element: Box<dyn Element> = match field {
ParamField::Text(editor) => appearance
.ui_builder()
.text_input(editor.clone())
.build()
.finish(),
ParamField::Branch { picker, .. } => ChildView::new(picker).finish(),
ParamField::Repo { picker, .. } => ChildView::new(picker).finish(),
};
form.add_child(SavePosition::new(field_element, &param_field_position_id(i)).finish());
}
let scrollable = ClippedScrollable::vertical(
self.scroll_state.clone(),
form.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_text_color().into(),
theme.active_ui_text_color().into(),
Fill::None,
)
.with_overlayed_scrollbar()
.with_padding_start(0.)
.with_padding_end(0.)
.finish();
let body_container = Container::new(
ConstrainedBox::new(scrollable)
.with_max_height(340.)
.finish(),
)
.with_padding(
Padding::uniform(0.)
.with_left(24.)
.with_right(24.)
.with_bottom(16.),
)
.finish();
// ── Footer ───────────────────────────────────────────────────────
let button_row = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(ChildView::new(&self.cancel_button).finish())
.with_child(if is_submit_enabled {
ChildView::new(&self.submit_button).finish()
} else {
ChildView::new(&self.submit_button_disabled).finish()
})
.finish(),
)
.with_padding(Padding::uniform(12.).with_left(24.).with_right(24.))
.finish();
let footer = Container::new(button_row)
.with_border(Border::top(1.).with_border_fill(theme.outline()))
.finish();
// ── Assemble ─────────────────────────────────────────────────────
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(body_container)
.with_child(footer)
.finish()
}
}
impl TypedActionView for TabConfigParamsModal {
type Action = TabConfigParamsModalAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
TabConfigParamsModalAction::Cancel | TabConfigParamsModalAction::Escape => {
ctx.emit(TabConfigParamsModalEvent::Close);
}
TabConfigParamsModalAction::Submit => self.try_submit(ctx),
TabConfigParamsModalAction::ToggleDropdown => {
if self.dropdown_count() <= 1 {
self.toggle_single_dropdown(ctx);
}
}
}
}
}
+41
View File
@@ -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(), &param),
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(), &param), 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(), &param),
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);
}
}
}
}
+220
View File
@@ -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);
}
}
}
}
+315
View File
@@ -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;
+391
View File
@@ -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()
}
+934
View File
@@ -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, &param_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, &param_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, &param_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("/")));
}
+478
View File
@@ -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, &quoted_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;
+906
View File
@@ -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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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");
}
}
+160
View File
@@ -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);