#[cfg_attr(target_family = "wasm", allow(unused_imports))] use std::{ path::{Path, PathBuf}, time::Duration, }; use pathfinder_color::ColorU; use warp_core::{ features::FeatureFlag, paths::home_relative_path, ui::theme::color::internal_colors, }; use warpui::{ elements::{ Align, Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty, Expanded, Flex, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text, }, fonts::{Properties, Weight}, platform::{file_picker::FilePickerError, FilePickerConfiguration}, r#async::{SpawnedFutureHandle, Timer}, ui_components::components::UiComponent, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use crate::{ appearance::Appearance, themes::theme::Blend, ui_components::{ buttons::icon_button, dialog::{dialog_styles, Dialog}, icons::Icon, }, view_components::{ action_button::{ActionButton, ButtonSize, PrimaryTheme, SecondaryTheme}, DismissibleToast, }, workspace::ToastStack, }; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use git2::Repository as GitRepository; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; #[cfg(all( feature = "local_fs", not(target_family = "wasm"), not(any(test, feature = "integration_tests")) ))] use ai::index::full_source_code_embedding::manager::CodebaseIndexManagerEvent; const DIALOG_WIDTH: f32 = 600.; const AVAILABLE_LIST_MAX_HEIGHT: f32 = 260.; const REPO_ROW_HORIZONTAL_PADDING: f32 = 10.; const REPO_ROW_VERTICAL_PADDING: f32 = 8.; const REPO_ROW_CORNER_RADIUS: f32 = 6.; #[derive(Debug, Clone)] pub enum AgentAssistedEnvironmentModalEvent { Cancelled, Confirmed { repo_paths: Vec }, } #[derive(Debug, Clone)] pub enum AgentAssistedEnvironmentModalAction { Cancel, Confirm, AddRepo(usize), RemoveRepo(usize), OpenDirectoryPicker, DirectoryPicked(Result), } #[derive(Clone, Debug)] struct RepoEntry { name: String, path: PathBuf, } pub struct AgentAssistedEnvironmentModal { visible: bool, available_repos: Vec, selected_repo_paths: Vec, available_row_mouse_states: Vec, selected_row_mouse_states: Vec, close_button_mouse_state: MouseStateHandle, available_scroll_state: ClippedScrollStateHandle, available_repos_loading: bool, available_repos_loading_timeout: Option, add_repo_button: ViewHandle, cancel_button: ViewHandle, create_button: ViewHandle, } impl AgentAssistedEnvironmentModal { pub fn new(ctx: &mut ViewContext) -> Self { let add_repo_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Add repo", SecondaryTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action( AgentAssistedEnvironmentModalAction::OpenDirectoryPicker, ); }) }); let cancel_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Cancel", SecondaryTheme).on_click(|ctx| { ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Cancel); }) }); let create_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Create environment", PrimaryTheme).on_click(|ctx| { ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Confirm); }) }); let me = Self { visible: false, available_repos: Vec::new(), selected_repo_paths: Vec::new(), available_row_mouse_states: Vec::new(), selected_row_mouse_states: Vec::new(), close_button_mouse_state: MouseStateHandle::default(), available_scroll_state: ClippedScrollStateHandle::default(), available_repos_loading: false, available_repos_loading_timeout: None, add_repo_button, cancel_button, create_button, }; #[cfg(all( feature = "local_fs", not(target_family = "wasm"), not(any(test, feature = "integration_tests")) ))] { let index_manager = CodebaseIndexManager::handle(ctx); ctx.subscribe_to_model(&index_manager, |me, _, event, ctx| { if !me.visible { return; } match event { CodebaseIndexManagerEvent::SyncStateUpdated | CodebaseIndexManagerEvent::NewIndexCreated | CodebaseIndexManagerEvent::RemoveExpiredIndexMetadata { .. } | CodebaseIndexManagerEvent::IndexMetadataUpdated { .. } => { me.refresh_available_repos(ctx); if me.available_repos.is_empty() { me.maybe_start_available_repos_loading(ctx); } else { me.stop_available_repos_loading(); } ctx.notify(); } _ => {} } }); } me.update_create_button_disabled_state(ctx); me } pub fn is_visible(&self) -> bool { self.visible } pub fn show(&mut self, ctx: &mut ViewContext) { self.visible = true; self.selected_repo_paths.clear(); self.selected_row_mouse_states.clear(); self.stop_available_repos_loading(); self.refresh_available_repos(ctx); self.maybe_start_available_repos_loading(ctx); self.update_create_button_disabled_state(ctx); ctx.notify(); } pub fn hide(&mut self, ctx: &mut ViewContext) { self.visible = false; self.stop_available_repos_loading(); ctx.notify(); } fn update_create_button_disabled_state(&self, ctx: &mut ViewContext) { let disabled = self.selected_repo_paths.is_empty(); self.create_button.update(ctx, |button, ctx| { button.set_disabled(disabled, ctx); }); } fn refresh_available_repos(&mut self, ctx: &mut ViewContext) { self.available_repos = available_indexed_repos(ctx); self.available_row_mouse_states = self .available_repos .iter() .map(|_| MouseStateHandle::default()) .collect(); } fn maybe_start_available_repos_loading(&mut self, ctx: &mut ViewContext) { if !cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { return; } if !self.visible || !self.available_repos.is_empty() || self.available_repos_loading { return; } self.available_repos_loading = true; if let Some(handle) = self.available_repos_loading_timeout.take() { handle.abort(); } self.available_repos_loading_timeout = Some(ctx.spawn_abortable( Timer::after(Duration::from_millis(750)), |me, _, ctx| { me.available_repos_loading_timeout = None; if !me.visible { return; } me.refresh_available_repos(ctx); // If repos are still empty after a brief wait, stop showing the loading state so we // can surface the empty-state message. if me.available_repos.is_empty() { me.available_repos_loading = false; } else { me.stop_available_repos_loading(); } ctx.notify(); }, |_, _| {}, )); } fn stop_available_repos_loading(&mut self) { self.available_repos_loading = false; if let Some(handle) = self.available_repos_loading_timeout.take() { handle.abort(); } } fn is_selected(&self, path: &PathBuf) -> bool { self.selected_repo_paths.iter().any(|p| p == path) } fn add_repo_path(&mut self, path: PathBuf, ctx: &mut ViewContext) { if self.is_selected(&path) { return; } self.selected_repo_paths.push(path); self.selected_row_mouse_states .push(MouseStateHandle::default()); self.update_create_button_disabled_state(ctx); ctx.notify(); } fn add_repo(&mut self, index: usize, ctx: &mut ViewContext) { let Some(entry) = self.available_repos.get(index) else { return; }; self.add_repo_path(entry.path.clone(), ctx); } fn remove_repo(&mut self, index: usize, ctx: &mut ViewContext) { if index >= self.selected_repo_paths.len() { return; } self.selected_repo_paths.remove(index); self.selected_row_mouse_states.remove(index); self.update_create_button_disabled_state(ctx); ctx.notify(); } fn render_section_title(&self, title: &str, appearance: &Appearance) -> Box { let theme = appearance.theme(); Text::new( title.to_string(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.active_ui_text_color().into()) .finish() } fn render_repo_info(name: String, path: String, appearance: &Appearance) -> Box { let theme = appearance.theme(); Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_spacing(2.) .with_child( Text::new(name, appearance.ui_font_family(), appearance.ui_font_size()) .with_color(theme.active_ui_text_color().into()) .finish(), ) .with_child( Text::new( path, appearance.ui_font_family(), appearance.ui_font_size() * 0.9, ) .with_color(theme.nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .finish() } fn render_selected_section(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let mut col = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(8.); col.add_child(self.render_section_title("Selected repos", appearance)); if self.selected_repo_paths.is_empty() { col.add_child( Text::new( "No repos selected yet", appearance.ui_font_family(), appearance.ui_font_size() * 0.95, ) .with_color(theme.nonactive_ui_text_color().into()) .finish(), ); return col.finish(); } for (idx, repo_path) in self.selected_repo_paths.iter().enumerate() { let mouse_state = self .selected_row_mouse_states .get(idx) .cloned() .unwrap_or_default(); let name = repo_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("(unknown)") .to_string(); let path_text = home_relative_path(repo_path); let remove_action = AgentAssistedEnvironmentModalAction::RemoveRepo(idx); let remove_button = icon_button(appearance, Icon::X, false, mouse_state) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(remove_action.clone()); }) .finish(); let row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Expanded::new(1., Self::render_repo_info(name, path_text, appearance)).finish(), ) .with_child(remove_button) .finish(); col.add_child( Container::new(row) .with_horizontal_padding(REPO_ROW_HORIZONTAL_PADDING) .with_vertical_padding(REPO_ROW_VERTICAL_PADDING) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( REPO_ROW_CORNER_RADIUS, ))) .with_background( theme .surface_2() .blend(&internal_colors::accent_overlay_1(theme)), ) .with_border(Border::all(1.).with_border_fill(theme.outline())) .finish(), ); } col.finish() } fn render_available_section(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let mut col = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(8.); let header = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Expanded::new( 1., self.render_section_title("Available indexed repos", appearance), ) .finish(), ) .with_child( if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { Container::new(ChildView::new(&self.add_repo_button).finish()) .with_margin_left(8.) .finish() } else { Empty::new().finish() }, ) .finish(); col.add_child(header); if self.available_repos.is_empty() { let text = if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { if self.available_repos_loading { "Loading locally indexed repos…" } else { "No locally indexed repos found yet. Index a repo, then try again." } } else { "Local repo selection is unavailable in this build." }; col.add_child( Text::new( text, appearance.ui_font_family(), appearance.ui_font_size() * 0.95, ) .with_color(theme.nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); return col.finish(); } let mut list = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(4.); let mut has_any_available = false; for (idx, entry) in self.available_repos.iter().enumerate() { if self.is_selected(&entry.path) { continue; } has_any_available = true; let mouse_state = self .available_row_mouse_states .get(idx) .cloned() .unwrap_or_default(); let add_action = AgentAssistedEnvironmentModalAction::AddRepo(idx); let add_button = icon_button(appearance, Icon::Plus, false, mouse_state) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(add_action.clone()); }) .finish(); let name = entry.name.clone(); let path_text = home_relative_path(&entry.path); let row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Expanded::new(1., Self::render_repo_info(name, path_text, appearance)).finish(), ) .with_child(add_button) .finish(); list.add_child( Container::new(row) .with_horizontal_padding(REPO_ROW_HORIZONTAL_PADDING) .with_vertical_padding(REPO_ROW_VERTICAL_PADDING) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( REPO_ROW_CORNER_RADIUS, ))) .with_background(theme.surface_2()) .with_border(Border::all(1.).with_border_fill(theme.outline())) .finish(), ); } if !has_any_available { col.add_child( Text::new( "All locally indexed repos are already selected.", appearance.ui_font_family(), appearance.ui_font_size() * 0.95, ) .with_color(theme.nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); return col.finish(); } // NOTE: `Scrollable` reserves horizontal space for its scrollbar gutter by default, // which makes the list content slightly narrower than non-scrollable rows above. // Overlay the scrollbar so it doesn't affect layout width. let scrollable = ClippedScrollable::vertical( self.available_scroll_state.clone(), list.finish(), ScrollbarWidth::Auto, theme.nonactive_ui_text_color().into(), theme.active_ui_text_color().into(), warpui::elements::Fill::None, ) .with_overlayed_scrollbar() .with_padding_start(0.) .with_padding_end(0.) .finish(); col.add_child( ConstrainedBox::new(scrollable) .with_max_height(AVAILABLE_LIST_MAX_HEIGHT) .finish(), ); col.finish() } #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] fn show_not_a_repo_toast(&self, selected_path: &Path, ctx: &mut ViewContext) { let window_id = ctx.window_id(); let path = home_relative_path(selected_path); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::error(format!("Selected folder is not a Git repository: {path}")) .with_object_id("agent_assisted_env_add_repo_not_git_repo".to_string()); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } fn show_file_picker_error_toast(&self, error: &FilePickerError, ctx: &mut ViewContext) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::error(format!("{error}")); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] fn handle_directory_picked(&mut self, selected_path: PathBuf, ctx: &mut ViewContext) { let selected_path = dunce::canonicalize(&selected_path).unwrap_or(selected_path); // `discover` accepts subdirectories; we normalize to the repo working tree root. match GitRepository::discover(&selected_path) .ok() .and_then(|repo| repo.workdir().map(|workdir| workdir.to_path_buf())) { Some(repo_root) => { self.add_repo_path(repo_root, ctx); } None => { self.show_not_a_repo_toast(&selected_path, ctx); } } } fn open_directory_picker(&mut self, ctx: &mut ViewContext) { if !cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { return; } let window_id = ctx.window_id(); let view_id = ctx.view_id(); ctx.open_file_picker( move |paths_result, ctx| { let result = paths_result.and_then(|paths| { paths.into_iter().next().map(PathBuf::from).ok_or_else(|| { FilePickerError::DialogFailed("No directory selected".to_string()) }) }); ctx.dispatch_typed_action_for_view( window_id, view_id, &AgentAssistedEnvironmentModalAction::DirectoryPicked(result), ); }, FilePickerConfiguration::new().folders_only(), ); ctx.notify(); } fn render_dialog(&self, appearance: &Appearance, app: &AppContext) -> Box { let description = if FeatureFlag::FullSourceCodeEmbedding.is_enabled() { "Select locally indexed repos to provide context for the environment creation agent." } else { "Select repos to provide context for the environment creation agent." } .to_string(); let close_button = icon_button( appearance, Icon::X, false, self.close_button_mouse_state.clone(), ) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Cancel); }) .finish(); let content = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(16.) .with_child(self.render_selected_section(appearance)) .with_child(self.render_available_section(appearance)) .finish(); let dialog = Dialog::new( "Select repos for your environment".to_string(), Some(description), dialog_styles(appearance), ) .with_close_button(close_button) .with_child(content) .with_separator() .with_bottom_row_child(ChildView::new(&self.cancel_button).finish()) .with_bottom_row_child( Container::new(ChildView::new(&self.create_button).finish()) .with_margin_left(12.) .finish(), ) .with_width(DIALOG_WIDTH) .build(); let dialog = Dismiss::new(dialog.finish()) .prevent_interaction_with_other_elements() .on_dismiss(|ctx, _app| { ctx.dispatch_typed_action(AgentAssistedEnvironmentModalAction::Cancel); }) .finish(); Container::new(Align::new(dialog).finish()) .with_background_color(ColorU::new(0, 0, 0, 179)) .with_corner_radius(app.windows().window_corner_radius()) .finish() } } impl Entity for AgentAssistedEnvironmentModal { type Event = AgentAssistedEnvironmentModalEvent; } impl TypedActionView for AgentAssistedEnvironmentModal { type Action = AgentAssistedEnvironmentModalAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { AgentAssistedEnvironmentModalAction::Cancel => { ctx.emit(AgentAssistedEnvironmentModalEvent::Cancelled); } AgentAssistedEnvironmentModalAction::Confirm => { if self.selected_repo_paths.is_empty() { return; } let repo_paths = self .selected_repo_paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); ctx.emit(AgentAssistedEnvironmentModalEvent::Confirmed { repo_paths }); } AgentAssistedEnvironmentModalAction::AddRepo(index) => { self.add_repo(*index, ctx); } AgentAssistedEnvironmentModalAction::RemoveRepo(index) => { self.remove_repo(*index, ctx); } AgentAssistedEnvironmentModalAction::OpenDirectoryPicker => { self.open_directory_picker(ctx); } AgentAssistedEnvironmentModalAction::DirectoryPicked(result) => match result { Ok(path) => { #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] { self.handle_directory_picked(path.clone(), ctx); } #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] { self.add_repo_path(path.clone(), ctx); } } Err(error) => { self.show_file_picker_error_toast(error, ctx); } }, } } } impl View for AgentAssistedEnvironmentModal { fn ui_name() -> &'static str { "AgentAssistedEnvironmentModal" } fn render(&self, app: &AppContext) -> Box { if !self.visible { return Empty::new().finish(); } let appearance = Appearance::as_ref(app); self.render_dialog(appearance, app) } } fn available_indexed_repos(app: &AppContext) -> Vec { #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] { let mut repos: Vec = CodebaseIndexManager::as_ref(app) .get_codebase_index_statuses(app) .filter_map(|(root, status)| { status.has_synced_version().then(|| { let name = root .file_name() .and_then(|s| s.to_str()) .map(ToOwned::to_owned) .unwrap_or_else(|| root.to_string_lossy().into_owned()); RepoEntry { name, path: root.clone(), } }) }) .collect(); repos.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); repos } #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] { let _ = app; Vec::new() } } #[cfg(test)] #[path = "agent_assisted_environment_modal_tests.rs"] mod tests;