Files
galaxy/app/src/settings_view/agent_assisted_environment_modal.rs
T

775 lines
26 KiB
Rust

#[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<String> },
}
#[derive(Debug, Clone)]
pub enum AgentAssistedEnvironmentModalAction {
Cancel,
Confirm,
AddRepo(usize),
RemoveRepo(usize),
OpenDirectoryPicker,
DirectoryPicked(Result<PathBuf, FilePickerError>),
}
#[derive(Clone, Debug)]
struct RepoEntry {
name: String,
path: PathBuf,
}
pub struct AgentAssistedEnvironmentModal {
visible: bool,
available_repos: Vec<RepoEntry>,
selected_repo_paths: Vec<PathBuf>,
available_row_mouse_states: Vec<MouseStateHandle>,
selected_row_mouse_states: Vec<MouseStateHandle>,
close_button_mouse_state: MouseStateHandle,
available_scroll_state: ClippedScrollStateHandle,
available_repos_loading: bool,
available_repos_loading_timeout: Option<SpawnedFutureHandle>,
add_repo_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
create_button: ViewHandle<ActionButton>,
}
impl AgentAssistedEnvironmentModal {
pub fn new(ctx: &mut ViewContext<Self>) -> 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>) {
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>) {
self.visible = false;
self.stop_available_repos_loading();
ctx.notify();
}
fn update_create_button_disabled_state(&self, ctx: &mut ViewContext<Self>) {
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>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<dyn Element> {
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<Self>) {
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<dyn Element> {
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<RepoEntry> {
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
{
let mut repos: Vec<RepoEntry> = 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;