3546 lines
131 KiB
Rust
3546 lines
131 KiB
Rust
use super::{
|
|
editor_text_colors,
|
|
settings_page::{render_input_list, InputListItem},
|
|
};
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::{
|
|
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
|
|
ai::{
|
|
ambient_agents::github_auth_notifier::{GitHubAuthEvent, GitHubAuthNotifier},
|
|
cloud_environments::{AmbientAgentEnvironment, GithubRepo},
|
|
},
|
|
appearance::Appearance,
|
|
editor::{
|
|
EditorOptions, EditorView, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
|
TextOptions,
|
|
},
|
|
root_view::CreateEnvironmentArg,
|
|
server::ids::SyncId,
|
|
ui_components::{buttons::icon_button, icons::Icon},
|
|
view_components::{
|
|
action_button::{ActionButton, DangerSecondaryTheme, PrimaryTheme},
|
|
render_warning_box, SubmittableTextInput, SubmittableTextInputEvent,
|
|
WarningBoxButtonConfig, WarningBoxConfig,
|
|
},
|
|
workspaces::user_workspaces::UserWorkspaces,
|
|
ChannelState,
|
|
};
|
|
use instant::{Duration, Instant};
|
|
use log::debug;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use std::collections::HashMap;
|
|
use url::Url;
|
|
use warp_core::send_telemetry_from_ctx;
|
|
use warp_editor::editor::NavigationKey;
|
|
use warp_graphql::queries::user_github_info::UserGithubInfoResult;
|
|
use warpui::{
|
|
elements::{
|
|
Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable,
|
|
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element, Empty,
|
|
Expanded, Fill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
|
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
|
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, ScrollTarget,
|
|
ScrollToPositionMode, ScrollbarWidth, SizeConstraintCondition, SizeConstraintSwitch, Stack,
|
|
Text,
|
|
},
|
|
fonts::{Properties, Weight},
|
|
geometry::vector::vec2f,
|
|
keymap::FixedBinding,
|
|
platform::Cursor,
|
|
prelude::Coords,
|
|
ui_components::components::{UiComponent, UiComponentStyles},
|
|
AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
|
ViewHandle,
|
|
};
|
|
|
|
const SUBMIT_BUTTON_FOCUSED: &str = "SubmitButtonFocused";
|
|
|
|
pub fn init(app: &mut AppContext) {
|
|
use warpui::keymap::macros::*;
|
|
|
|
app.register_fixed_bindings([
|
|
FixedBinding::new(
|
|
"enter",
|
|
UpdateEnvironmentFormAction::Submit,
|
|
id!(UpdateEnvironmentForm::ui_name()) & id!(SUBMIT_BUTTON_FOCUSED),
|
|
),
|
|
FixedBinding::new(
|
|
"numpadenter",
|
|
UpdateEnvironmentFormAction::Submit,
|
|
id!(UpdateEnvironmentForm::ui_name()) & id!(SUBMIT_BUTTON_FOCUSED),
|
|
),
|
|
FixedBinding::new(
|
|
"shift-tab",
|
|
UpdateEnvironmentFormAction::FocusSetupCommandsInput,
|
|
id!(UpdateEnvironmentForm::ui_name()) & id!(SUBMIT_BUTTON_FOCUSED),
|
|
),
|
|
// Escape behaves like:
|
|
// - close dropdown when open
|
|
// - back/cancel when dropdown is closed
|
|
FixedBinding::new(
|
|
"escape",
|
|
UpdateEnvironmentFormAction::Escape,
|
|
id!(UpdateEnvironmentForm::ui_name()),
|
|
),
|
|
]);
|
|
}
|
|
|
|
/// Form data model representing environment values independent of UI state.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct EnvironmentFormValues {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub selected_repos: Vec<GithubRepo>,
|
|
pub docker_image: String,
|
|
pub setup_commands: Vec<String>,
|
|
}
|
|
|
|
impl EnvironmentFormValues {
|
|
/// Converts form values to an AmbientAgentEnvironment for submission.
|
|
pub fn to_ambient_agent_environment(&self) -> AmbientAgentEnvironment {
|
|
let setup_commands: Vec<String> = self
|
|
.setup_commands
|
|
.iter()
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
|
|
let description = {
|
|
let trimmed = self.description.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
};
|
|
|
|
AmbientAgentEnvironment::new(
|
|
self.name.trim().to_string(),
|
|
description,
|
|
self.selected_repos.clone(),
|
|
self.docker_image.trim().to_string(),
|
|
setup_commands,
|
|
)
|
|
}
|
|
|
|
/// Validates the form values.
|
|
pub fn is_valid(&self) -> bool {
|
|
!self.name.trim().is_empty() && !self.docker_image.trim().is_empty()
|
|
}
|
|
}
|
|
|
|
/// Arguments for initializing the UpdateEnvironmentForm.
|
|
/// Contains all data needed for initialization, including initial values for Edit mode.
|
|
#[derive(Clone, Debug)]
|
|
pub enum EnvironmentFormInitArgs {
|
|
Create,
|
|
Edit {
|
|
env_id: SyncId,
|
|
initial_values: Box<EnvironmentFormValues>,
|
|
},
|
|
}
|
|
|
|
/// Persisted mode for the UpdateEnvironmentForm.
|
|
/// Contains only what's needed after initialization (just the env_id for Edit mode).
|
|
#[derive(Clone, Debug)]
|
|
pub enum EnvironmentFormMode {
|
|
Create,
|
|
Edit { env_id: SyncId },
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum GithubAuthRedirectTarget {
|
|
SettingsEnvironments,
|
|
FocusCloudMode,
|
|
}
|
|
|
|
impl GithubAuthRedirectTarget {
|
|
fn next_path(self) -> &'static str {
|
|
match self {
|
|
Self::SettingsEnvironments => "settings/environments",
|
|
Self::FocusCloudMode => "action/focus_cloud_mode",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Events emitted by UpdateEnvironmentForm.
|
|
#[derive(Debug, Clone)]
|
|
pub enum UpdateEnvironmentFormEvent {
|
|
Created {
|
|
environment: AmbientAgentEnvironment,
|
|
share_with_team: bool,
|
|
},
|
|
Updated {
|
|
env_id: SyncId,
|
|
environment: AmbientAgentEnvironment,
|
|
},
|
|
DeleteRequested {
|
|
env_id: SyncId,
|
|
},
|
|
Cancelled,
|
|
}
|
|
|
|
/// Actions handled by UpdateEnvironmentForm.
|
|
#[derive(Debug, Clone)]
|
|
pub enum UpdateEnvironmentFormAction {
|
|
Submit,
|
|
Delete,
|
|
Cancel,
|
|
Escape,
|
|
FocusSetupCommandsInput,
|
|
|
|
ToggleShareWithTeam,
|
|
|
|
AddRepo,
|
|
RemoveRepo(usize),
|
|
ToggleReposDropdown,
|
|
CloseReposDropdown,
|
|
ToggleRepoSelection(usize),
|
|
|
|
RemoveSetupCommand(usize),
|
|
|
|
SuggestImage,
|
|
LaunchAgentForSelectedRepos,
|
|
RetryFetchGithubRepos,
|
|
StartGithubAuth,
|
|
OpenUrl(String),
|
|
}
|
|
|
|
/// State for the GitHub repos dropdown.
|
|
#[derive(Clone, Default)]
|
|
pub struct GithubReposDropdownState {
|
|
pub available_repos: Vec<GithubRepo>,
|
|
pub is_loading: bool,
|
|
pub is_expanded: bool,
|
|
pub auth_url: Option<String>,
|
|
pub auth_fetched_at: Option<Instant>,
|
|
pub load_error_message: Option<String>,
|
|
pub selected_index: Option<usize>,
|
|
app_install_link: Option<String>,
|
|
repo_row_mouse_states: Vec<MouseStateHandle>,
|
|
scroll_state: ClippedScrollStateHandle,
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
#[derive(Clone, Debug)]
|
|
enum CachedSuggestImageResult {
|
|
Success {
|
|
image: String,
|
|
needs_custom_image: bool,
|
|
reason: String,
|
|
},
|
|
AuthRequired {
|
|
auth_url: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
enum SuggestImageState {
|
|
Idle,
|
|
Loading {
|
|
key: String,
|
|
},
|
|
Success {
|
|
key: String,
|
|
needs_custom_image: bool,
|
|
reason: String,
|
|
},
|
|
AuthRequired {
|
|
key: String,
|
|
auth_url: String,
|
|
},
|
|
Error {
|
|
key: String,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// Indicates where the GitHub authorization flow was initiated from.
|
|
/// This affects the redirect URL used after auth completes.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum AuthSource {
|
|
/// Auth initiated from the settings page (default behavior: redirect to settings)
|
|
#[default]
|
|
Settings,
|
|
/// Auth initiated from cloud agent setup (skip redirect, just refresh in place)
|
|
CloudSetup,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum OAuthNextPlatform {
|
|
Native,
|
|
Web,
|
|
}
|
|
|
|
pub struct UpdateEnvironmentForm {
|
|
mode: EnvironmentFormMode,
|
|
form_state: EnvironmentFormValues,
|
|
repos_input: String,
|
|
github_auth_redirect_target: GithubAuthRedirectTarget,
|
|
|
|
// Editor views
|
|
name_editor: ViewHandle<EditorView>,
|
|
description_editor: ViewHandle<EditorView>,
|
|
docker_image_editor: ViewHandle<EditorView>,
|
|
repos_input_editor: ViewHandle<EditorView>,
|
|
|
|
// Setup commands (multi-input)
|
|
setup_commands_input: ViewHandle<SubmittableTextInput>,
|
|
remove_setup_command_mouse_states: Vec<MouseStateHandle>,
|
|
|
|
// Action buttons
|
|
submit_button: ViewHandle<ActionButton>,
|
|
delete_button: ViewHandle<ActionButton>,
|
|
back_button_mouse_state: MouseStateHandle,
|
|
|
|
// Share-with-team checkbox (Create mode only, when user is on a team)
|
|
share_with_team: bool,
|
|
share_with_team_checkbox_mouse_state: MouseStateHandle,
|
|
share_with_team_label_mouse_state: MouseStateHandle,
|
|
|
|
// GitHub repos dropdown
|
|
github_dropdown_state: GithubReposDropdownState,
|
|
dropdown_input_mouse_state: MouseStateHandle,
|
|
|
|
// Repo management
|
|
remove_repo_mouse_states: Vec<MouseStateHandle>,
|
|
add_repo_button_mouse_state: MouseStateHandle,
|
|
auth_button_mouse_state: MouseStateHandle,
|
|
retry_fetch_github_repos_mouse_state: MouseStateHandle,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
configure_access_link_mouse_state: MouseStateHandle,
|
|
refresh_repos_button_mouse_state: MouseStateHandle,
|
|
|
|
// Suggest image state
|
|
suggest_image_state: SuggestImageState,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
suggest_image_cache: HashMap<String, CachedSuggestImageResult>,
|
|
suggest_image_last_attempt_key: Option<String>,
|
|
suggest_image_request_seq: u64,
|
|
suggest_image_button_mouse_state: MouseStateHandle,
|
|
suggest_image_auth_button_mouse_state: MouseStateHandle,
|
|
suggest_image_launch_agent_button_mouse_state: MouseStateHandle,
|
|
image_link_button_mouse_state: MouseStateHandle,
|
|
|
|
/// On the edit page, we keep the suggest-image button disabled until repos have been modified
|
|
/// at least once during the current edit session. Once enabled, it stays enabled even if the
|
|
/// user reverts the repo selection.
|
|
edit_repos_modified: bool,
|
|
|
|
/// When true (default), renders the header with back button, title, and submit button.
|
|
/// When false, skips the header and renders the submit button at the bottom-right of the form.
|
|
show_header: bool,
|
|
|
|
/// When true, pressing Escape in any editor will emit a Cancelled event.
|
|
/// This should only be enabled for contexts where the form is used as a modal (e.g., first-time setup).
|
|
should_handle_escape_from_editor: bool,
|
|
|
|
/// Indicates where the GitHub authorization flow was initiated from.
|
|
/// Affects the redirect URL used after auth completes.
|
|
auth_source: AuthSource,
|
|
}
|
|
|
|
const DESCRIPTION_MAX_CHARS: usize = 240;
|
|
const REPOS_PLACEHOLDER_AUTHED: &str = "Enter repos (owner/repo format)";
|
|
const REPOS_PLACEHOLDER_UNAUTHED: &str = "Paste repo URL(s)";
|
|
const FORM_FIELD_SPACING: f32 = 20.;
|
|
const FORM_LABEL_SPACING: f32 = 6.;
|
|
const FORM_INPUT_HEIGHT: f32 = 36.;
|
|
const FORM_INPUT_HORIZONTAL_PADDING: f32 = 10.;
|
|
const AUTH_URL_REFRESH_THRESHOLD: Duration = Duration::from_secs(10 * 60);
|
|
const FORM_DESCRIPTION_HEIGHT: f32 = 72.;
|
|
const FORM_DESCRIPTION_VERTICAL_PADDING: f32 = 6.;
|
|
const CARD_BORDER_WIDTH: f32 = 1.;
|
|
const REPO_CHIP_MAX_WIDTH: f32 = 200.;
|
|
const DROPDOWN_MAX_WIDTH: f32 = 800.;
|
|
const DROPDOWN_MAX_HEIGHT: f32 = 300.;
|
|
const REPOS_DROPDOWN_ANCHOR: &str = "repos_dropdown_anchor";
|
|
const HEADER_VERTICAL_LAYOUT_THRESHOLD: f32 = 520.;
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum RepoDropdownSelectionDirection {
|
|
Up,
|
|
Down,
|
|
}
|
|
|
|
impl UpdateEnvironmentForm {
|
|
pub fn new(init_args: EnvironmentFormInitArgs, ctx: &mut ViewContext<Self>) -> Self {
|
|
Self::new_impl(init_args, true, ctx)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn new_for_test(
|
|
init_args: EnvironmentFormInitArgs,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
Self::new_impl(init_args, false, ctx)
|
|
}
|
|
|
|
fn new_impl(
|
|
init_args: EnvironmentFormInitArgs,
|
|
fetch_github_repos_on_init: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
ctx.subscribe_to_model(&Appearance::handle(ctx), |form, _, _, ctx| {
|
|
form.update_editor_text_colors(ctx);
|
|
});
|
|
// Create editors
|
|
let name_editor = Self::create_single_line_editor("Environment name", ctx);
|
|
let description_editor = Self::create_description_editor(ctx);
|
|
let docker_image_editor =
|
|
Self::create_single_line_editor("e.g. python:3.11, node:20-alpine", ctx);
|
|
let repos_input_editor = Self::create_single_line_editor(REPOS_PLACEHOLDER_AUTHED, ctx);
|
|
|
|
let setup_commands_input = ctx.add_typed_action_view(|ctx| {
|
|
let mut input = SubmittableTextInput::new(ctx);
|
|
input.set_placeholder_text("e.g. cd my-repo && pip install -r requirements.txt", ctx);
|
|
// Keep this consistent with other form inputs (e.g. repos): caller controls spacing.
|
|
input.set_outer_margins(0., 0., ctx);
|
|
input
|
|
});
|
|
|
|
ctx.subscribe_to_view(&setup_commands_input, |me, _, event, ctx| match event {
|
|
SubmittableTextInputEvent::Submit(command) => {
|
|
me.form_state.setup_commands.push(command.clone());
|
|
me.remove_setup_command_mouse_states
|
|
.push(MouseStateHandle::default());
|
|
ctx.notify();
|
|
}
|
|
SubmittableTextInputEvent::Escape => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::Escape, ctx);
|
|
}
|
|
});
|
|
|
|
let setup_commands_input_editor = setup_commands_input.as_ref(ctx).editor().clone();
|
|
setup_commands_input_editor.update(ctx, |editor, _ctx| {
|
|
editor.set_propagate_vertical_navigation_keys(PropagateAndNoOpNavigationKeys::Always);
|
|
});
|
|
|
|
ctx.subscribe_to_view(
|
|
&setup_commands_input_editor,
|
|
|me, _, event, ctx| match event {
|
|
crate::editor::Event::Navigate(NavigationKey::Tab) => {
|
|
if me.form_state.is_valid() {
|
|
ctx.focus(&me.submit_button);
|
|
} else {
|
|
// Wrap around to name (first field)
|
|
ctx.focus(&me.name_editor);
|
|
}
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::ShiftTab) => {
|
|
ctx.focus(&me.docker_image_editor);
|
|
}
|
|
crate::editor::Event::Escape => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::Escape, ctx);
|
|
}
|
|
_ => {}
|
|
},
|
|
);
|
|
|
|
// Create buttons
|
|
let submit_button = ctx.add_typed_action_view(|_| {
|
|
ActionButton::new("Create", PrimaryTheme)
|
|
.with_icon(Icon::Check)
|
|
.on_click(|ctx| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::Submit);
|
|
})
|
|
});
|
|
|
|
let delete_button = ctx.add_typed_action_view(|_| {
|
|
ActionButton::new("Delete environment", DangerSecondaryTheme)
|
|
.with_icon(Icon::Trash)
|
|
.on_click(|ctx| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::Delete);
|
|
})
|
|
});
|
|
|
|
// Set up editor subscriptions
|
|
ctx.subscribe_to_view(&name_editor, |me, _, event, ctx| match event {
|
|
crate::editor::Event::Edited(_) => {
|
|
me.form_state.name = me.name_editor.as_ref(ctx).buffer_text(ctx);
|
|
me.update_button_state(ctx);
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::Tab) => {
|
|
ctx.focus(&me.description_editor);
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::ShiftTab) => {
|
|
// Wrap around to setup commands (last field)
|
|
ctx.focus(&me.setup_commands_input);
|
|
}
|
|
crate::editor::Event::Escape => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::Escape, ctx);
|
|
}
|
|
_ => {}
|
|
});
|
|
|
|
ctx.subscribe_to_view(&description_editor, |me, _, event, ctx| match event {
|
|
crate::editor::Event::Edited(_) => {
|
|
me.form_state.description = me.description_editor.as_ref(ctx).buffer_text(ctx);
|
|
ctx.notify();
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::Tab) => {
|
|
ctx.focus(&me.repos_input_editor);
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::ShiftTab) => {
|
|
ctx.focus(&me.name_editor);
|
|
}
|
|
crate::editor::Event::Escape => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::Escape, ctx);
|
|
}
|
|
_ => {}
|
|
});
|
|
|
|
ctx.subscribe_to_view(&docker_image_editor, |me, _, event, ctx| match event {
|
|
crate::editor::Event::Edited(_) => {
|
|
me.form_state.docker_image = me.docker_image_editor.as_ref(ctx).buffer_text(ctx);
|
|
me.update_button_state(ctx);
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::Tab) => {
|
|
ctx.focus(&me.setup_commands_input);
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::ShiftTab) => {
|
|
ctx.focus(&me.repos_input_editor);
|
|
}
|
|
crate::editor::Event::Escape => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::Escape, ctx);
|
|
}
|
|
_ => {}
|
|
});
|
|
|
|
ctx.subscribe_to_view(&repos_input_editor, |me, _, event, ctx| match event {
|
|
crate::editor::Event::Edited(origin) => {
|
|
me.repos_input = me.repos_input_editor.as_ref(ctx).buffer_text(ctx);
|
|
|
|
if origin.is_user() {
|
|
let was_expanded = me.github_dropdown_state.is_expanded;
|
|
me.github_dropdown_state.is_expanded = true;
|
|
if !was_expanded {
|
|
me.github_dropdown_state.scroll_state = ClippedScrollStateHandle::default();
|
|
}
|
|
}
|
|
|
|
if me.github_dropdown_state.is_expanded {
|
|
me.ensure_repo_dropdown_selection();
|
|
me.scroll_repo_dropdown_selection_into_view();
|
|
}
|
|
ctx.notify();
|
|
}
|
|
crate::editor::Event::Blurred => {
|
|
me.clear_invalid_repos_input_on_blur(ctx);
|
|
}
|
|
crate::editor::Event::Enter
|
|
| crate::editor::Event::CmdEnter
|
|
| crate::editor::Event::ShiftEnter
|
|
| crate::editor::Event::AltEnter => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::AddRepo, ctx);
|
|
}
|
|
crate::editor::Event::BackspaceOnEmptyBuffer => {
|
|
if !me.form_state.selected_repos.is_empty() {
|
|
let last_index = me.form_state.selected_repos.len() - 1;
|
|
me.form_state.selected_repos.pop();
|
|
if last_index < me.remove_repo_mouse_states.len() {
|
|
me.remove_repo_mouse_states.pop();
|
|
}
|
|
if me.is_edit_mode() {
|
|
me.edit_repos_modified = true;
|
|
}
|
|
ctx.notify();
|
|
}
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::Down) => {
|
|
if me.github_dropdown_state.is_expanded {
|
|
me.move_repo_dropdown_selection(RepoDropdownSelectionDirection::Down, ctx);
|
|
}
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::Up) => {
|
|
if me.github_dropdown_state.is_expanded {
|
|
me.move_repo_dropdown_selection(RepoDropdownSelectionDirection::Up, ctx);
|
|
}
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::Tab) => {
|
|
ctx.focus(&me.docker_image_editor);
|
|
}
|
|
crate::editor::Event::Navigate(NavigationKey::ShiftTab) => {
|
|
ctx.focus(&me.description_editor);
|
|
}
|
|
crate::editor::Event::Escape => {
|
|
me.handle_action(&UpdateEnvironmentFormAction::Escape, ctx);
|
|
}
|
|
_ => {}
|
|
});
|
|
|
|
let mode = match &init_args {
|
|
EnvironmentFormInitArgs::Create => EnvironmentFormMode::Create,
|
|
EnvironmentFormInitArgs::Edit { env_id, .. } => {
|
|
EnvironmentFormMode::Edit { env_id: *env_id }
|
|
}
|
|
};
|
|
|
|
// Subscribe to GitHubAuthNotifier to refetch repos when auth completes
|
|
ctx.subscribe_to_model(&GitHubAuthNotifier::handle(ctx), |me, _, event, ctx| {
|
|
if matches!(event, GitHubAuthEvent::AuthCompleted) {
|
|
me.fetch_github_repos(ctx);
|
|
}
|
|
});
|
|
|
|
let mut form = Self {
|
|
mode,
|
|
form_state: EnvironmentFormValues::default(),
|
|
repos_input: String::new(),
|
|
github_auth_redirect_target: GithubAuthRedirectTarget::SettingsEnvironments,
|
|
name_editor,
|
|
description_editor,
|
|
docker_image_editor,
|
|
repos_input_editor,
|
|
setup_commands_input,
|
|
remove_setup_command_mouse_states: Vec::new(),
|
|
submit_button,
|
|
delete_button,
|
|
back_button_mouse_state: MouseStateHandle::default(),
|
|
share_with_team: false,
|
|
share_with_team_checkbox_mouse_state: MouseStateHandle::default(),
|
|
share_with_team_label_mouse_state: MouseStateHandle::default(),
|
|
github_dropdown_state: GithubReposDropdownState::default(),
|
|
dropdown_input_mouse_state: MouseStateHandle::default(),
|
|
remove_repo_mouse_states: Vec::new(),
|
|
add_repo_button_mouse_state: MouseStateHandle::default(),
|
|
auth_button_mouse_state: MouseStateHandle::default(),
|
|
retry_fetch_github_repos_mouse_state: MouseStateHandle::default(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
configure_access_link_mouse_state: MouseStateHandle::default(),
|
|
refresh_repos_button_mouse_state: MouseStateHandle::default(),
|
|
suggest_image_state: SuggestImageState::Idle,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
suggest_image_cache: HashMap::new(),
|
|
suggest_image_last_attempt_key: None,
|
|
suggest_image_request_seq: 0,
|
|
suggest_image_button_mouse_state: MouseStateHandle::default(),
|
|
suggest_image_auth_button_mouse_state: MouseStateHandle::default(),
|
|
suggest_image_launch_agent_button_mouse_state: MouseStateHandle::default(),
|
|
image_link_button_mouse_state: MouseStateHandle::default(),
|
|
edit_repos_modified: false,
|
|
show_header: true,
|
|
should_handle_escape_from_editor: false,
|
|
auth_source: AuthSource::default(),
|
|
};
|
|
|
|
// Initialize based on init args
|
|
form.apply_mode(&init_args, ctx);
|
|
form.update_button_state(ctx);
|
|
|
|
if fetch_github_repos_on_init {
|
|
// Fetch GitHub repos for dropdown
|
|
form.fetch_github_repos(ctx);
|
|
}
|
|
form.update_editor_text_colors(ctx);
|
|
|
|
form
|
|
}
|
|
|
|
pub fn github_dropdown_state(&self) -> &GithubReposDropdownState {
|
|
&self.github_dropdown_state
|
|
}
|
|
|
|
pub fn set_github_auth_redirect_target(&mut self, target: GithubAuthRedirectTarget) {
|
|
self.github_auth_redirect_target = target;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn github_auth_redirect_target_for_test(&self) -> GithubAuthRedirectTarget {
|
|
self.github_auth_redirect_target
|
|
}
|
|
|
|
fn try_close_repos_dropdown(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
|
if !self.github_dropdown_state.is_expanded {
|
|
return false;
|
|
}
|
|
|
|
self.github_dropdown_state.is_expanded = false;
|
|
self.github_dropdown_state.selected_index = None;
|
|
ctx.notify();
|
|
true
|
|
}
|
|
|
|
fn repo_dropdown_row_position_id(index: usize) -> String {
|
|
format!("repos_dropdown_row_{index}")
|
|
}
|
|
|
|
fn scroll_repo_dropdown_selection_into_view(&mut self) {
|
|
if !self.github_dropdown_state.is_expanded {
|
|
return;
|
|
}
|
|
|
|
let Some(index) = self.github_dropdown_state.selected_index else {
|
|
return;
|
|
};
|
|
|
|
self.github_dropdown_state
|
|
.scroll_state
|
|
.scroll_to_position(ScrollTarget {
|
|
position_id: Self::repo_dropdown_row_position_id(index),
|
|
mode: ScrollToPositionMode::FullyIntoView,
|
|
});
|
|
}
|
|
|
|
pub fn set_mode(&mut self, init_args: EnvironmentFormInitArgs, ctx: &mut ViewContext<Self>) {
|
|
self.mode = match &init_args {
|
|
EnvironmentFormInitArgs::Create => EnvironmentFormMode::Create,
|
|
EnvironmentFormInitArgs::Edit { env_id, .. } => {
|
|
EnvironmentFormMode::Edit { env_id: *env_id }
|
|
}
|
|
};
|
|
self.apply_mode(&init_args, ctx);
|
|
self.update_button_state(ctx);
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Sets whether the header (back button, title, submit button) should be shown.
|
|
/// When `false`, the submit button is rendered at the bottom-right of the form instead.
|
|
pub fn set_show_header(&mut self, show_header: bool, ctx: &mut ViewContext<Self>) {
|
|
self.show_header = show_header;
|
|
|
|
// Update button text based on mode when header is hidden
|
|
if !show_header {
|
|
let button_text = match &self.mode {
|
|
EnvironmentFormMode::Create => "Create environment",
|
|
EnvironmentFormMode::Edit { .. } => "Save environment",
|
|
};
|
|
self.submit_button.update(ctx, |button, ctx| {
|
|
button.set_label(button_text, ctx);
|
|
});
|
|
}
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Sets whether Escape keypresses in editors should trigger the Cancel action.
|
|
/// This should only be enabled for modal contexts (e.g., first-time setup).
|
|
pub fn set_should_handle_escape_from_editor(&mut self, should_handle: bool) {
|
|
self.should_handle_escape_from_editor = should_handle;
|
|
}
|
|
|
|
/// Sets the auth source, which affects the redirect URL used after GitHub auth completes.
|
|
/// When set to `CloudSetup`, the redirect URL will include a source parameter that tells
|
|
/// the URI handler to skip opening the settings page.
|
|
pub fn set_auth_source(&mut self, source: AuthSource) {
|
|
self.auth_source = source;
|
|
}
|
|
|
|
/// Focus the Name editor (the first field in the form).
|
|
pub fn focus(&self, ctx: &mut ViewContext<Self>) {
|
|
ctx.focus(&self.name_editor);
|
|
}
|
|
|
|
fn apply_mode(&mut self, init_args: &EnvironmentFormInitArgs, ctx: &mut ViewContext<Self>) {
|
|
match init_args {
|
|
EnvironmentFormInitArgs::Create => {
|
|
// Clear form
|
|
self.form_state = EnvironmentFormValues::default();
|
|
self.share_with_team = UserWorkspaces::as_ref(ctx).current_team_uid().is_some();
|
|
self.name_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
self.description_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
self.docker_image_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
self.repos_input_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
let editor = { self.setup_commands_input.as_ref(ctx).editor().clone() };
|
|
editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
self.remove_repo_mouse_states.clear();
|
|
self.remove_setup_command_mouse_states.clear();
|
|
// Update button text for Create mode
|
|
self.submit_button.update(ctx, |button, ctx| {
|
|
button.set_label("Create", ctx);
|
|
});
|
|
}
|
|
EnvironmentFormInitArgs::Edit {
|
|
env_id: _,
|
|
initial_values,
|
|
} => {
|
|
self.share_with_team = false;
|
|
|
|
// Populate form with initial values
|
|
self.form_state = initial_values.as_ref().clone();
|
|
self.name_editor.update(ctx, |editor, ctx| {
|
|
editor.set_buffer_text(&initial_values.name, ctx);
|
|
});
|
|
self.description_editor.update(ctx, |editor, ctx| {
|
|
editor.set_buffer_text(&initial_values.description, ctx);
|
|
});
|
|
self.docker_image_editor.update(ctx, |editor, ctx| {
|
|
editor.set_buffer_text(&initial_values.docker_image, ctx);
|
|
});
|
|
self.repos_input_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
let editor = { self.setup_commands_input.as_ref(ctx).editor().clone() };
|
|
editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
self.remove_repo_mouse_states = self
|
|
.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.map(|_| MouseStateHandle::default())
|
|
.collect();
|
|
self.remove_setup_command_mouse_states = self
|
|
.form_state
|
|
.setup_commands
|
|
.iter()
|
|
.map(|_| MouseStateHandle::default())
|
|
.collect();
|
|
// Update button text for Edit mode
|
|
self.submit_button.update(ctx, |button, ctx| {
|
|
button.set_label("Save", ctx);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Reset suggest image state for this session.
|
|
//
|
|
// Note: We intentionally do not set `suggest_image_last_attempt_key` here.
|
|
// That field tracks the last *attempted* suggest-image key, and is used to enforce
|
|
// “repos must change to retry”. Edit-mode gating is handled via `edit_repos_modified`.
|
|
self.suggest_image_state = SuggestImageState::Idle;
|
|
self.suggest_image_last_attempt_key = None;
|
|
self.suggest_image_request_seq = 0;
|
|
|
|
// Track that repos haven't been modified yet in this edit session.
|
|
// The suggest button will be disabled until the user modifies repos.
|
|
self.edit_repos_modified = false;
|
|
}
|
|
|
|
fn update_button_state(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let is_valid = self.form_state.is_valid();
|
|
self.submit_button.update(ctx, |button, ctx| {
|
|
button.set_disabled(!is_valid, ctx);
|
|
});
|
|
}
|
|
|
|
fn is_edit_mode(&self) -> bool {
|
|
matches!(self.mode, EnvironmentFormMode::Edit { .. })
|
|
}
|
|
|
|
fn update_repos_input_placeholder(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let placeholder = if self.github_dropdown_state.auth_url.is_some() {
|
|
REPOS_PLACEHOLDER_UNAUTHED
|
|
} else {
|
|
REPOS_PLACEHOLDER_AUTHED
|
|
};
|
|
self.repos_input_editor.update(ctx, |editor, ctx| {
|
|
editor.set_placeholder_text(placeholder, ctx);
|
|
});
|
|
}
|
|
|
|
fn selected_repos_as_remote_repo_args(&self) -> Vec<String> {
|
|
self.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.map(|repo| format!("{}/{}", repo.owner.trim(), repo.repo.trim()))
|
|
.collect()
|
|
}
|
|
|
|
fn create_single_line_editor(
|
|
placeholder: &'static str,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> ViewHandle<EditorView> {
|
|
ctx.add_typed_action_view(|ctx| {
|
|
let appearance = Appearance::as_ref(ctx);
|
|
let options = SingleLineEditorOptions {
|
|
text: TextOptions {
|
|
font_size_override: Some(appearance.ui_font_size()),
|
|
font_family_override: Some(appearance.ui_font_family()),
|
|
text_colors_override: Some(editor_text_colors(appearance)),
|
|
..Default::default()
|
|
},
|
|
propagate_and_no_op_vertical_navigation_keys:
|
|
PropagateAndNoOpNavigationKeys::Always,
|
|
..Default::default()
|
|
};
|
|
let mut editor = EditorView::single_line(options, ctx);
|
|
editor.set_placeholder_text(placeholder, ctx);
|
|
editor
|
|
})
|
|
}
|
|
|
|
fn update_editor_text_colors(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let appearance = Appearance::as_ref(ctx);
|
|
let text_colors = editor_text_colors(appearance);
|
|
|
|
for editor in [
|
|
&self.name_editor,
|
|
&self.description_editor,
|
|
&self.docker_image_editor,
|
|
&self.repos_input_editor,
|
|
] {
|
|
let colors = text_colors.clone();
|
|
editor.update(ctx, move |editor, ctx| {
|
|
editor.set_text_colors(colors, ctx);
|
|
});
|
|
}
|
|
|
|
let colors = text_colors.clone();
|
|
let editor = self.setup_commands_input.as_ref(ctx).editor().clone();
|
|
editor.update(ctx, move |editor, ctx| {
|
|
editor.set_text_colors(colors, ctx);
|
|
});
|
|
}
|
|
|
|
fn create_description_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
|
|
ctx.add_typed_action_view(|ctx| {
|
|
let appearance = Appearance::as_ref(ctx);
|
|
let options = EditorOptions {
|
|
text: TextOptions {
|
|
font_size_override: Some(appearance.ui_font_size()),
|
|
font_family_override: Some(appearance.ui_font_family()),
|
|
text_colors_override: Some(editor_text_colors(appearance)),
|
|
..Default::default()
|
|
},
|
|
autogrow: true,
|
|
soft_wrap: true,
|
|
single_line: false,
|
|
max_buffer_len: Some(DESCRIPTION_MAX_CHARS),
|
|
propagate_and_no_op_vertical_navigation_keys:
|
|
PropagateAndNoOpNavigationKeys::Always,
|
|
..Default::default()
|
|
};
|
|
let mut editor = EditorView::new(options, ctx);
|
|
editor.set_placeholder_text(
|
|
"e.g., this environment is for all front end focused agents",
|
|
ctx,
|
|
);
|
|
editor
|
|
})
|
|
}
|
|
|
|
fn parse_repo_input(input: &str) -> Option<(String, String)> {
|
|
use url::Url;
|
|
let trimmed = input.trim().trim_end_matches('/');
|
|
|
|
fn parse_owner_repo<'a, I>(mut segments: I) -> Option<(String, String)>
|
|
where
|
|
I: Iterator<Item = &'a str>,
|
|
{
|
|
let owner = segments.next()?.trim();
|
|
let repo = segments.next()?.trim();
|
|
let repo = repo.trim_end_matches(".git");
|
|
if owner.is_empty() || repo.is_empty() {
|
|
return None;
|
|
}
|
|
Some((owner.to_string(), repo.to_string()))
|
|
}
|
|
|
|
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
|
|
return parse_owner_repo(rest.split('/').filter(|p| !p.is_empty()));
|
|
}
|
|
|
|
let parsed_url = Url::parse(trimmed).or_else(|_| {
|
|
if trimmed.starts_with("github.com/") || trimmed.starts_with("www.github.com/") {
|
|
Url::parse(&format!("https://{trimmed}"))
|
|
} else {
|
|
Err(url::ParseError::RelativeUrlWithoutBase)
|
|
}
|
|
});
|
|
|
|
if let Ok(url) = parsed_url {
|
|
if matches!(url.host_str(), Some("github.com" | "www.github.com")) {
|
|
return parse_owner_repo(url.path_segments()?.filter(|p| !p.is_empty()));
|
|
}
|
|
}
|
|
|
|
parse_owner_repo(trimmed.split('/').filter(|p| !p.is_empty()))
|
|
}
|
|
|
|
fn parse_repo_inputs(input: &str) -> Vec<(String, String)> {
|
|
// Accept comma- and whitespace-separated entries to support pasting multiple repos at once.
|
|
input
|
|
.split(|c: char| c == ',' || c.is_whitespace())
|
|
.filter_map(|part| {
|
|
let trimmed = part.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Self::parse_repo_input(trimmed)
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Clears the repos input editor and the mirrored `repos_input` string.
|
|
///
|
|
/// This is used after successfully adding/toggling a repo, and also for cleanup cases
|
|
/// where we intentionally discard unsubmitted text.
|
|
fn clear_repos_input(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.repos_input.clear();
|
|
self.repos_input_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
});
|
|
}
|
|
|
|
/// On blur, clear any *unsubmitted* text that doesn't parse as `owner/repo` (or supported URL forms).
|
|
///
|
|
/// Important: this must not remove already-added repo chips; it only clears the text the user typed.
|
|
fn clear_invalid_repos_input_on_blur(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let input = self.repos_input_editor.as_ref(ctx).buffer_text(ctx);
|
|
if input.trim().is_empty() {
|
|
return;
|
|
}
|
|
|
|
if Self::parse_repo_inputs(&input).is_empty() {
|
|
self.clear_repos_input(ctx);
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn filtered_repo_indices(&self) -> Vec<usize> {
|
|
// Return indices of available repos that match the current input filter.
|
|
let search_text = self.repos_input.to_lowercase();
|
|
self.github_dropdown_state
|
|
.available_repos
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, repo)| {
|
|
if search_text.is_empty() {
|
|
true
|
|
} else {
|
|
let repo_str = format!("{}/{}", repo.owner, repo.repo).to_lowercase();
|
|
repo_str.contains(&search_text)
|
|
}
|
|
})
|
|
.map(|(index, _)| index)
|
|
.collect()
|
|
}
|
|
|
|
fn ensure_repo_dropdown_selection(&mut self) {
|
|
// Ensure the dropdown selection points at a visible (filtered) repo.
|
|
let filtered_indices = self.filtered_repo_indices();
|
|
if filtered_indices.is_empty() {
|
|
self.github_dropdown_state.selected_index = None;
|
|
return;
|
|
}
|
|
|
|
let selected_index = self.github_dropdown_state.selected_index;
|
|
let is_selected_visible = selected_index
|
|
.and_then(|index| filtered_indices.iter().position(|value| *value == index))
|
|
.is_some();
|
|
|
|
if !is_selected_visible {
|
|
self.github_dropdown_state.selected_index = Some(filtered_indices[0]);
|
|
}
|
|
}
|
|
|
|
fn move_repo_dropdown_selection(
|
|
&mut self,
|
|
direction: RepoDropdownSelectionDirection,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Move the dropdown highlight up/down, wrapping within the filtered list.
|
|
let filtered_indices = self.filtered_repo_indices();
|
|
if filtered_indices.is_empty() {
|
|
self.github_dropdown_state.selected_index = None;
|
|
ctx.notify();
|
|
return;
|
|
}
|
|
|
|
let next_index = match (self.github_dropdown_state.selected_index, direction) {
|
|
(None, RepoDropdownSelectionDirection::Down) => filtered_indices[0],
|
|
(None, RepoDropdownSelectionDirection::Up) => {
|
|
*filtered_indices.last().expect("filtered indices non-empty")
|
|
}
|
|
(Some(current), RepoDropdownSelectionDirection::Down) => {
|
|
let current_pos = filtered_indices
|
|
.iter()
|
|
.position(|index| *index == current)
|
|
.unwrap_or(0);
|
|
let next_pos = if current_pos + 1 >= filtered_indices.len() {
|
|
0
|
|
} else {
|
|
current_pos + 1
|
|
};
|
|
filtered_indices[next_pos]
|
|
}
|
|
(Some(current), RepoDropdownSelectionDirection::Up) => {
|
|
let current_pos = filtered_indices
|
|
.iter()
|
|
.position(|index| *index == current)
|
|
.unwrap_or(0);
|
|
let next_pos = if current_pos == 0 {
|
|
filtered_indices.len() - 1
|
|
} else {
|
|
current_pos - 1
|
|
};
|
|
filtered_indices[next_pos]
|
|
}
|
|
};
|
|
|
|
self.github_dropdown_state.selected_index = Some(next_index);
|
|
self.scroll_repo_dropdown_selection_into_view();
|
|
ctx.notify();
|
|
}
|
|
|
|
fn toggle_repo_selection_at_index(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
// Toggle the repo chip for the given available repo index.
|
|
let Some(available_repo) = self.github_dropdown_state.available_repos.get(index) else {
|
|
return;
|
|
};
|
|
let owner = available_repo.owner.clone();
|
|
let repo = available_repo.repo.clone();
|
|
|
|
let existing_index = self
|
|
.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.position(|r| r.owner == owner && r.repo == repo);
|
|
|
|
if let Some(idx) = existing_index {
|
|
self.form_state.selected_repos.remove(idx);
|
|
self.remove_repo_mouse_states.remove(idx);
|
|
} else {
|
|
self.form_state
|
|
.selected_repos
|
|
.push(GithubRepo::new(owner, repo));
|
|
self.remove_repo_mouse_states
|
|
.push(MouseStateHandle::default());
|
|
self.clear_repos_input(ctx);
|
|
}
|
|
|
|
if self.is_edit_mode() {
|
|
self.edit_repos_modified = true;
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
pub fn start_github_auth(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if self.github_dropdown_state.is_loading {
|
|
return;
|
|
}
|
|
if self.should_refresh_auth_url() {
|
|
if let Some(elapsed) = self
|
|
.github_dropdown_state
|
|
.auth_fetched_at
|
|
.map(|fetched_at| fetched_at.elapsed())
|
|
{
|
|
debug!(
|
|
"Refreshing GitHub auth URL after {:.0}s (threshold {:.0}s)",
|
|
elapsed.as_secs_f64(),
|
|
AUTH_URL_REFRESH_THRESHOLD.as_secs_f64()
|
|
);
|
|
} else {
|
|
debug!("Refreshing GitHub auth URL (no previous fetch timestamp)");
|
|
}
|
|
self.fetch_github_repos_for_auth(ctx);
|
|
} else {
|
|
self.open_github_auth_url_or_fallback(ctx);
|
|
}
|
|
}
|
|
|
|
fn should_refresh_auth_url(&self) -> bool {
|
|
match self.github_dropdown_state.auth_fetched_at {
|
|
Some(fetched_at) => fetched_at.elapsed() >= AUTH_URL_REFRESH_THRESHOLD,
|
|
// No timestamp means the age is unknown — treat as stale to be safe.
|
|
None => self.github_dropdown_state.auth_url.is_some(),
|
|
}
|
|
}
|
|
|
|
fn open_github_auth_url_or_fallback(&self, ctx: &mut ViewContext<Self>) {
|
|
let url = self
|
|
.github_dropdown_state
|
|
.auth_url
|
|
.as_deref()
|
|
.map(|auth_url| self.auth_url_with_next(auth_url))
|
|
.unwrap_or_else(|| self.github_connect_fallback_url());
|
|
ctx.open_url(&url);
|
|
}
|
|
|
|
fn github_connect_fallback_url(&self) -> String {
|
|
let base_url = format!("{}/oauth/connect/github", ChannelState::server_root_url());
|
|
self.auth_url_with_next(&base_url)
|
|
}
|
|
|
|
fn extract_tx_id(auth_url: &str) -> Option<String> {
|
|
let parsed = Url::parse(auth_url).ok()?;
|
|
parsed
|
|
.query_pairs()
|
|
.find_map(|(key, value)| (key == "txId").then(|| value.to_string()))
|
|
}
|
|
|
|
fn fetch_github_repos_for_auth(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.fetch_github_repos_internal(ctx, true);
|
|
}
|
|
|
|
/// Fetch GitHub repos for the dropdown.
|
|
pub fn fetch_github_repos(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.fetch_github_repos_internal(ctx, false);
|
|
}
|
|
|
|
fn fetch_github_repos_internal(
|
|
&mut self,
|
|
ctx: &mut ViewContext<Self>,
|
|
open_auth_after_fetch: bool,
|
|
) {
|
|
self.github_dropdown_state.is_loading = true;
|
|
self.github_dropdown_state.load_error_message = None;
|
|
self.github_dropdown_state.auth_url = None;
|
|
self.github_dropdown_state.auth_fetched_at = None;
|
|
ctx.notify();
|
|
|
|
let integrations_client = ServerApiProvider::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_integrations_client();
|
|
|
|
ctx.spawn(
|
|
async move { integrations_client.get_user_github_info().await },
|
|
move |me, result, ctx| {
|
|
me.github_dropdown_state.is_loading = false;
|
|
let mut should_open_auth = open_auth_after_fetch;
|
|
|
|
match result {
|
|
Ok(UserGithubInfoResult::GithubConnectedOutput(info)) => {
|
|
me.github_dropdown_state.available_repos = info
|
|
.installed_repos
|
|
.into_iter()
|
|
.map(|r| GithubRepo::new(r.owner, r.repo))
|
|
.collect();
|
|
me.github_dropdown_state.repo_row_mouse_states = me
|
|
.github_dropdown_state
|
|
.available_repos
|
|
.iter()
|
|
.map(|_| MouseStateHandle::default())
|
|
.collect();
|
|
me.github_dropdown_state.scroll_state = ClippedScrollStateHandle::default();
|
|
me.github_dropdown_state.selected_index = None;
|
|
me.ensure_repo_dropdown_selection();
|
|
me.scroll_repo_dropdown_selection_into_view();
|
|
// Store appInstallLink even when authenticated - it's used for "Configure access" link
|
|
me.github_dropdown_state.app_install_link = Some(info.app_install_link);
|
|
me.github_dropdown_state.auth_url = None;
|
|
me.github_dropdown_state.auth_fetched_at = None;
|
|
should_open_auth = false;
|
|
me.update_repos_input_placeholder(ctx);
|
|
}
|
|
Ok(UserGithubInfoResult::GithubAuthRequiredOutput(auth_info)) => {
|
|
me.github_dropdown_state.auth_url = Some(auth_info.auth_url);
|
|
me.github_dropdown_state.auth_fetched_at = Some(Instant::now());
|
|
me.github_dropdown_state.app_install_link =
|
|
Some(auth_info.app_install_link);
|
|
if open_auth_after_fetch {
|
|
if let Some(auth_url) = me.github_dropdown_state.auth_url.as_deref() {
|
|
if let Some(tx_id) = Self::extract_tx_id(auth_url) {
|
|
debug!("Refetched GitHub auth URL with tx_id={tx_id}");
|
|
} else {
|
|
debug!("Refetched GitHub auth URL (tx_id missing)");
|
|
}
|
|
}
|
|
}
|
|
me.update_repos_input_placeholder(ctx);
|
|
}
|
|
Ok(UserGithubInfoResult::Unknown) => {
|
|
me.github_dropdown_state.load_error_message =
|
|
Some("Failed to load GitHub repos".to_string());
|
|
}
|
|
Err(e) => {
|
|
me.github_dropdown_state.load_error_message =
|
|
Some(format!("Failed to load GitHub repos: {}", e));
|
|
}
|
|
}
|
|
|
|
if should_open_auth {
|
|
if let Some(auth_url) = me.github_dropdown_state.auth_url.as_deref() {
|
|
let auth_url = me.auth_url_with_next(auth_url);
|
|
ctx.open_url(&auth_url);
|
|
} else if me.github_dropdown_state.available_repos.is_empty() {
|
|
let fallback_url = me.github_connect_fallback_url();
|
|
ctx.open_url(&fallback_url);
|
|
}
|
|
}
|
|
ctx.notify();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Generate a cache key from selected repos for suggest image.
|
|
fn selected_repos_key(&self) -> Option<String> {
|
|
if self.form_state.selected_repos.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut repos = self
|
|
.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.map(|r| {
|
|
format!(
|
|
"{}/{}",
|
|
r.owner.trim().to_lowercase(),
|
|
r.repo.trim().to_lowercase()
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
repos.sort();
|
|
Some(repos.join("\n"))
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn apply_cached_suggest_image_result(
|
|
&mut self,
|
|
key: &str,
|
|
cached: CachedSuggestImageResult,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match cached {
|
|
CachedSuggestImageResult::Success {
|
|
image,
|
|
needs_custom_image,
|
|
reason,
|
|
} => {
|
|
self.apply_suggest_image_success(
|
|
key.to_string(),
|
|
image,
|
|
needs_custom_image,
|
|
reason,
|
|
ctx,
|
|
);
|
|
}
|
|
CachedSuggestImageResult::AuthRequired { auth_url } => {
|
|
self.suggest_image_state = SuggestImageState::AuthRequired {
|
|
key: key.to_string(),
|
|
auth_url,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn apply_suggest_image_success(
|
|
&mut self,
|
|
key: String,
|
|
image: String,
|
|
needs_custom_image: bool,
|
|
reason: String,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Cache for later
|
|
self.suggest_image_cache.insert(
|
|
key.clone(),
|
|
CachedSuggestImageResult::Success {
|
|
image: image.clone(),
|
|
needs_custom_image,
|
|
reason: reason.clone(),
|
|
},
|
|
);
|
|
|
|
// Only update the input if the request key still matches the current repo selection
|
|
if self.selected_repos_key().as_deref() == Some(&key) {
|
|
self.form_state.docker_image = image.clone();
|
|
self.docker_image_editor.update(ctx, |editor, ctx| {
|
|
editor.set_buffer_text(&image, ctx);
|
|
});
|
|
self.update_button_state(ctx);
|
|
}
|
|
|
|
self.suggest_image_state = SuggestImageState::Success {
|
|
key,
|
|
needs_custom_image,
|
|
reason,
|
|
};
|
|
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::ImageSuggested {
|
|
image,
|
|
needs_custom_image,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn suggest_image(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let Some(key) = self.selected_repos_key() else {
|
|
return;
|
|
};
|
|
|
|
// Don't start a new request if we're already loading for this key
|
|
let is_generating = matches!(&self.suggest_image_state, SuggestImageState::Loading { key: loading_key } if loading_key == &key);
|
|
if is_generating {
|
|
return;
|
|
}
|
|
|
|
if self.suggest_image_last_attempt_key.as_deref() == Some(&key) {
|
|
return;
|
|
}
|
|
|
|
// Record the attempt immediately to enforce the "repos must change to retry" rule
|
|
self.suggest_image_last_attempt_key = Some(key.clone());
|
|
|
|
// If we have a cached result, apply it immediately
|
|
if let Some(cached) = self.suggest_image_cache.get(&key).cloned() {
|
|
self.apply_cached_suggest_image_result(&key, cached, ctx);
|
|
ctx.notify();
|
|
return;
|
|
}
|
|
|
|
self.suggest_image_request_seq = self.suggest_image_request_seq.saturating_add(1);
|
|
let request_seq = self.suggest_image_request_seq;
|
|
self.suggest_image_state = SuggestImageState::Loading { key: key.clone() };
|
|
ctx.notify();
|
|
|
|
let repos = self
|
|
.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.map(|r| (r.owner.clone(), r.repo.clone()))
|
|
.collect::<Vec<_>>();
|
|
|
|
let integrations_client = ServerApiProvider::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_integrations_client();
|
|
|
|
ctx.spawn(
|
|
async move { integrations_client.suggest_cloud_environment_image(repos).await },
|
|
move |me, result, ctx| {
|
|
if me.suggest_image_request_seq != request_seq {
|
|
return;
|
|
}
|
|
|
|
match result {
|
|
Ok(result) => match result {
|
|
warp_graphql::queries::suggest_cloud_environment_image::SuggestCloudEnvironmentImageResult::SuggestCloudEnvironmentImageOutput(output) => {
|
|
let image = output.image;
|
|
let needs_custom_image = output.needs_custom_image;
|
|
let reason = output.reason;
|
|
me.apply_suggest_image_success(
|
|
key.clone(),
|
|
image,
|
|
needs_custom_image,
|
|
reason,
|
|
ctx,
|
|
);
|
|
}
|
|
warp_graphql::queries::suggest_cloud_environment_image::SuggestCloudEnvironmentImageResult::SuggestCloudEnvironmentImageAuthRequiredOutput(output) => {
|
|
me.suggest_image_cache.insert(
|
|
key.clone(),
|
|
CachedSuggestImageResult::AuthRequired {
|
|
auth_url: output.auth_url.clone(),
|
|
},
|
|
);
|
|
me.suggest_image_state = SuggestImageState::AuthRequired {
|
|
key: key.clone(),
|
|
auth_url: output.auth_url,
|
|
};
|
|
}
|
|
warp_graphql::queries::suggest_cloud_environment_image::SuggestCloudEnvironmentImageResult::UserFacingError(_) => {
|
|
let error_message = "Failed to suggest a Docker image".to_string();
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::ImageSuggestionFailed {
|
|
error: error_message.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
me.suggest_image_state = SuggestImageState::Error {
|
|
key: key.clone(),
|
|
message: error_message,
|
|
};
|
|
}
|
|
warp_graphql::queries::suggest_cloud_environment_image::SuggestCloudEnvironmentImageResult::Unknown => {
|
|
let error_message = "Unknown response from suggestCloudEnvironmentImage".to_string();
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::ImageSuggestionFailed {
|
|
error: error_message.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
me.suggest_image_state = SuggestImageState::Error {
|
|
key: key.clone(),
|
|
message: error_message,
|
|
};
|
|
}
|
|
},
|
|
Err(e) => {
|
|
let error_message = format!("Failed to suggest a Docker image: {}", e);
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::ImageSuggestionFailed {
|
|
error: error_message.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
me.suggest_image_state = SuggestImageState::Error {
|
|
key: key.clone(),
|
|
message: error_message,
|
|
};
|
|
}
|
|
}
|
|
ctx.notify();
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
fn suggest_image(&mut self, _ctx: &mut ViewContext<Self>) {
|
|
// Not supported on WASM
|
|
}
|
|
|
|
fn should_show_share_with_team_checkbox(&self, app: &AppContext) -> bool {
|
|
matches!(self.mode, EnvironmentFormMode::Create)
|
|
&& UserWorkspaces::as_ref(app).current_team_uid().is_some()
|
|
}
|
|
|
|
fn render_submit_actions(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
button_handle: &ViewHandle<ActionButton>,
|
|
) -> Box<dyn Element> {
|
|
let mut row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(12.);
|
|
|
|
if self.should_show_share_with_team_checkbox(app) {
|
|
let theme = appearance.theme();
|
|
let font_family = appearance.ui_font_family();
|
|
let font_size = appearance.ui_font_size();
|
|
|
|
let checkbox = appearance
|
|
.ui_builder()
|
|
.checkbox(self.share_with_team_checkbox_mouse_state.clone(), None)
|
|
.check(self.share_with_team)
|
|
.with_style(UiComponentStyles {
|
|
margin: Some(Coords::uniform(0.)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::ToggleShareWithTeam);
|
|
})
|
|
.finish();
|
|
|
|
let label = Hoverable::new(
|
|
self.share_with_team_label_mouse_state.clone(),
|
|
move |state| {
|
|
let color = if state.is_mouse_over_element() {
|
|
theme.active_ui_text_color().with_opacity(200)
|
|
} else {
|
|
theme.active_ui_text_color()
|
|
};
|
|
|
|
Text::new_inline("Share with team", font_family, font_size)
|
|
.with_color(color.into())
|
|
.finish()
|
|
},
|
|
)
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::ToggleShareWithTeam);
|
|
})
|
|
.finish();
|
|
|
|
row.add_child(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(4.)
|
|
.with_child(checkbox)
|
|
.with_child(label)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
row.add_child(ChildView::new(button_handle).finish());
|
|
|
|
row.finish()
|
|
}
|
|
|
|
fn render_share_with_team_warning(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
if !self.should_show_share_with_team_checkbox(app) || self.share_with_team {
|
|
return None;
|
|
}
|
|
|
|
Some(render_warning_box(
|
|
WarningBoxConfig::new(
|
|
"Personal environments cannot be used with external integrations or team API keys. For the best experience, use shared environments.",
|
|
)
|
|
.with_width(DROPDOWN_MAX_WIDTH),
|
|
appearance,
|
|
))
|
|
}
|
|
|
|
fn render_back_button_and_title(
|
|
&self,
|
|
title: &str,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let back_button = icon_button(
|
|
appearance,
|
|
Icon::ArrowLeft,
|
|
false,
|
|
self.back_button_mouse_state.clone(),
|
|
)
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::Cancel);
|
|
})
|
|
.finish();
|
|
|
|
let title_text = Text::new(
|
|
title.to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 1.5,
|
|
)
|
|
.with_style(Properties::default().weight(Weight::Bold))
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish();
|
|
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(8.)
|
|
.with_child(back_button)
|
|
.with_child(title_text)
|
|
.finish()
|
|
}
|
|
|
|
fn render_header(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
|
let (title, button_handle) = match &self.mode {
|
|
EnvironmentFormMode::Create => ("Create environment", &self.submit_button),
|
|
EnvironmentFormMode::Edit { .. } => ("Edit environment", &self.submit_button),
|
|
};
|
|
|
|
let submit_actions = || self.render_submit_actions(appearance, app, button_handle);
|
|
|
|
let horizontal_header = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(self.render_back_button_and_title(title, appearance))
|
|
.with_child(submit_actions())
|
|
.finish();
|
|
|
|
let compact_header = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_spacing(8.)
|
|
.with_child(self.render_back_button_and_title(title, appearance))
|
|
.with_child(submit_actions())
|
|
.finish();
|
|
|
|
SizeConstraintSwitch::new(
|
|
horizontal_header,
|
|
vec![(
|
|
SizeConstraintCondition::WidthLessThan(HEADER_VERTICAL_LAYOUT_THRESHOLD),
|
|
compact_header,
|
|
)],
|
|
)
|
|
.finish()
|
|
}
|
|
|
|
fn render_form_label(
|
|
label: &'static str,
|
|
required: bool,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let mut row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(4.);
|
|
|
|
row.add_child(
|
|
Text::new(
|
|
label,
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_style(Properties::default().weight(Weight::Semibold))
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish(),
|
|
);
|
|
|
|
if required {
|
|
row.add_child(
|
|
Text::new("*", appearance.ui_font_family(), appearance.ui_font_size())
|
|
.with_style(Properties::default().weight(Weight::Semibold))
|
|
.with_color(theme.accent().into())
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
row.finish()
|
|
}
|
|
|
|
fn render_form_field(
|
|
label: &'static str,
|
|
required: bool,
|
|
helper_text: Option<&'static str>,
|
|
editor: &ViewHandle<EditorView>,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
field.add_child(Self::render_form_label(label, required, appearance));
|
|
|
|
let editor_container = Container::new(
|
|
ConstrainedBox::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_child(
|
|
Clipped::new(
|
|
Container::new(ChildView::new(editor).finish())
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
field.add_child(editor_container);
|
|
|
|
if let Some(helper) = helper_text {
|
|
field.add_child(
|
|
Text::new(
|
|
helper,
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.85,
|
|
)
|
|
.with_color(theme.nonactive_ui_text_color().into())
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
field.finish()
|
|
}
|
|
|
|
fn render_setup_commands_field(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
field.add_child(Self::render_form_label(
|
|
"Setup command(s)",
|
|
false,
|
|
appearance,
|
|
));
|
|
|
|
let items = self
|
|
.form_state
|
|
.setup_commands
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, command)| InputListItem {
|
|
item: command.clone(),
|
|
mouse_state_handle: self
|
|
.remove_setup_command_mouse_states
|
|
.get(index)
|
|
.cloned()
|
|
.unwrap_or_default(),
|
|
on_remove_action: UpdateEnvironmentFormAction::RemoveSetupCommand(index),
|
|
});
|
|
|
|
let helper_text = Text::new(
|
|
"Setup commands run independently. Each command runs from the workspace root (/workspace). If a command depends on the previous one, combine them with &&.",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.85,
|
|
)
|
|
.soft_wrap(true)
|
|
.with_color(theme.nonactive_ui_text_color().into())
|
|
.finish();
|
|
|
|
// We want the helper text close to the input itself (like the helper under the repo input),
|
|
// not separated by the existing command list.
|
|
let input_and_helper = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(4.)
|
|
.with_child(ChildView::new(&self.setup_commands_input).finish())
|
|
.with_child(helper_text)
|
|
.finish();
|
|
|
|
let list_items = render_input_list(None, items, None, false, appearance);
|
|
|
|
let list = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(6.)
|
|
.with_child(input_and_helper)
|
|
.with_child(list_items)
|
|
.finish();
|
|
|
|
field.add_child(
|
|
ConstrainedBox::new(Container::new(list).finish())
|
|
.with_max_width(DROPDOWN_MAX_WIDTH)
|
|
.finish(),
|
|
);
|
|
|
|
field.finish()
|
|
}
|
|
|
|
fn render_description_field(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
field.add_child(
|
|
Text::new(
|
|
"Description",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_style(Properties::default().weight(Weight::Semibold))
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish(),
|
|
);
|
|
|
|
let editor_container = ConstrainedBox::new(
|
|
Container::new(
|
|
Clipped::new(ChildView::new(&self.description_editor).finish()).finish(),
|
|
)
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.with_vertical_padding(FORM_DESCRIPTION_VERTICAL_PADDING)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish(),
|
|
)
|
|
.with_min_height(FORM_DESCRIPTION_HEIGHT)
|
|
.finish();
|
|
|
|
field.add_child(editor_container);
|
|
|
|
// Character count display
|
|
let char_count = self
|
|
.description_editor
|
|
.as_ref(app)
|
|
.buffer_text(app)
|
|
.chars()
|
|
.count();
|
|
let count_text = format!("{} / {} characters", char_count, DESCRIPTION_MAX_CHARS);
|
|
field.add_child(
|
|
Text::new(
|
|
count_text,
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.85,
|
|
)
|
|
.with_color(theme.nonactive_ui_text_color().into())
|
|
.finish(),
|
|
);
|
|
|
|
field.finish()
|
|
}
|
|
|
|
fn render_repos_field(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
// Route to appropriate rendering based on dropdown state
|
|
if self.github_dropdown_state.is_loading {
|
|
self.render_repos_field_loading(appearance)
|
|
} else if self.github_dropdown_state.auth_url.is_some() {
|
|
self.render_repos_field_unauthed(appearance)
|
|
} else if self.github_dropdown_state.load_error_message.is_some() {
|
|
self.render_repos_field_error(appearance)
|
|
} else {
|
|
self.render_repos_field_authed(appearance)
|
|
}
|
|
}
|
|
|
|
fn render_repos_field_label(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
Text::new(
|
|
"Repo(s)",
|
|
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_repos_field_loading(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
field.add_child(self.render_repos_field_label(appearance));
|
|
|
|
// Selected repo chips (if any)
|
|
if !self.form_state.selected_repos.is_empty() {
|
|
field.add_child(self.render_selected_repo_chips(appearance));
|
|
}
|
|
|
|
// Disabled input with loading placeholder
|
|
let loading_input = Container::new(
|
|
ConstrainedBox::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_child(
|
|
Container::new(
|
|
Text::new(
|
|
"Loading...",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.disabled_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
field.add_child(loading_input);
|
|
field.finish()
|
|
}
|
|
|
|
fn render_repos_field_unauthed(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
// Label
|
|
field.add_child(self.render_repos_field_label(appearance));
|
|
|
|
// Selected repo chips (if any)
|
|
if !self.form_state.selected_repos.is_empty() {
|
|
field.add_child(self.render_selected_repo_chips(appearance));
|
|
}
|
|
|
|
// Input for pasting repo URLs manually
|
|
let editor = Clipped::new(ChildView::new(&self.repos_input_editor).finish()).finish();
|
|
|
|
let input_container = Container::new(
|
|
ConstrainedBox::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_child(
|
|
Clipped::new(
|
|
Container::new(editor)
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
let auth_button = Hoverable::new(self.auth_button_mouse_state.clone(), move |state| {
|
|
let bg = if state.is_mouse_over_element() {
|
|
theme.surface_3()
|
|
} else {
|
|
theme.surface_2().with_opacity(45)
|
|
};
|
|
let icon_size = appearance.ui_font_size();
|
|
|
|
ConstrainedBox::new(
|
|
Container::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_spacing(6.)
|
|
.with_child(
|
|
ConstrainedBox::new(
|
|
Icon::Github
|
|
.to_warpui_icon(theme.active_ui_text_color())
|
|
.finish(),
|
|
)
|
|
.with_width(icon_size)
|
|
.with_height(icon_size)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Text::new(
|
|
"Auth with GitHub",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_horizontal_padding(12.)
|
|
.with_vertical_padding(8.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(bg)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish()
|
|
})
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::StartGithubAuth);
|
|
})
|
|
.finish();
|
|
|
|
let row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(8.)
|
|
.with_child(Expanded::new(1., input_container).finish())
|
|
.with_child(auth_button)
|
|
.finish();
|
|
field.add_child(
|
|
ConstrainedBox::new(row)
|
|
.with_max_width(DROPDOWN_MAX_WIDTH)
|
|
.finish(),
|
|
);
|
|
field.finish()
|
|
}
|
|
|
|
fn render_repos_field_error(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let message = self
|
|
.github_dropdown_state
|
|
.load_error_message
|
|
.clone()
|
|
.unwrap_or_else(|| "Failed to load GitHub repositories".to_string());
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
// Label
|
|
field.add_child(self.render_repos_field_label(appearance));
|
|
|
|
// Selected repo chips (if any)
|
|
if !self.form_state.selected_repos.is_empty() {
|
|
field.add_child(self.render_selected_repo_chips(appearance));
|
|
}
|
|
|
|
let error_input = Container::new(
|
|
ConstrainedBox::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_child(
|
|
Container::new(
|
|
Text::new(
|
|
message,
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.ui_error_color())
|
|
.finish(),
|
|
)
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
let retry_button = Hoverable::new(
|
|
self.retry_fetch_github_repos_mouse_state.clone(),
|
|
move |state| {
|
|
let bg = if state.is_mouse_over_element() {
|
|
theme.surface_3()
|
|
} else {
|
|
theme.surface_2()
|
|
};
|
|
let icon_size = appearance.ui_font_size();
|
|
|
|
ConstrainedBox::new(
|
|
Container::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_spacing(6.)
|
|
.with_child(
|
|
ConstrainedBox::new(
|
|
Icon::Refresh
|
|
.to_warpui_icon(theme.active_ui_text_color())
|
|
.finish(),
|
|
)
|
|
.with_width(icon_size)
|
|
.with_height(icon_size)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Text::new(
|
|
"Retry",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_horizontal_padding(12.)
|
|
.with_vertical_padding(8.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(bg)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish()
|
|
},
|
|
)
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::RetryFetchGithubRepos);
|
|
})
|
|
.finish();
|
|
|
|
let row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(8.)
|
|
.with_child(Expanded::new(1., error_input).finish())
|
|
.with_child(retry_button)
|
|
.finish();
|
|
|
|
field.add_child(
|
|
ConstrainedBox::new(row)
|
|
.with_max_width(DROPDOWN_MAX_WIDTH)
|
|
.finish(),
|
|
);
|
|
|
|
field.finish()
|
|
}
|
|
|
|
fn render_repos_field_authed(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
field.add_child(self.render_repos_field_label(appearance));
|
|
|
|
// Build chevron button for toggling dropdown
|
|
// Note: Don't add a click handler here since the entire container is clickable
|
|
let chevron_icon = if self.github_dropdown_state.is_expanded {
|
|
Icon::ChevronUp
|
|
} else {
|
|
Icon::ChevronDown
|
|
};
|
|
let chevron_button = icon_button(
|
|
appearance,
|
|
chevron_icon,
|
|
false,
|
|
self.add_repo_button_mouse_state.clone(),
|
|
)
|
|
.build()
|
|
.finish();
|
|
|
|
// Build chips row with editor inline
|
|
let mut chips_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(6.);
|
|
|
|
// Add selected repo chips inline
|
|
for (index, repo) in self.form_state.selected_repos.iter().enumerate() {
|
|
let mouse_state = self
|
|
.remove_repo_mouse_states
|
|
.get(index)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let action = UpdateEnvironmentFormAction::RemoveRepo(index);
|
|
let remove_button = icon_button(appearance, Icon::X, false, mouse_state)
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
})
|
|
.finish();
|
|
|
|
let chip = Container::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(4.)
|
|
.with_child(
|
|
ConstrainedBox::new(
|
|
Text::new_inline(
|
|
repo.to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.9,
|
|
)
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.with_max_width(REPO_CHIP_MAX_WIDTH)
|
|
.finish(),
|
|
)
|
|
.with_child(remove_button)
|
|
.finish(),
|
|
)
|
|
.with_padding_left(8.)
|
|
.with_vertical_padding(4.)
|
|
.with_padding_right(4.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
|
.with_background(theme.surface_3())
|
|
.finish();
|
|
|
|
chips_row.add_child(chip);
|
|
}
|
|
|
|
// Add editor after chips (expands to fill remaining space)
|
|
let editor = ChildView::new(&self.repos_input_editor).finish();
|
|
chips_row.add_child(Expanded::new(1., editor).finish());
|
|
|
|
let chips_content = chips_row.finish();
|
|
|
|
// Main row: [clipped chips + editor area] [chevron]
|
|
let input_content = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(6.)
|
|
.with_child(Expanded::new(1., Clipped::new(chips_content).finish()).finish())
|
|
.with_child(chevron_button)
|
|
.finish();
|
|
|
|
// Wrap in container with border and make the entire area clickable to toggle dropdown
|
|
let input_container = SavePosition::new(
|
|
Hoverable::new(self.dropdown_input_mouse_state.clone(), |_| {
|
|
Container::new(
|
|
ConstrainedBox::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_child(
|
|
Clipped::new(
|
|
Container::new(input_content)
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish()
|
|
})
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::ToggleReposDropdown);
|
|
})
|
|
.finish(),
|
|
REPOS_DROPDOWN_ANCHOR,
|
|
)
|
|
.finish();
|
|
|
|
// Build the stack with dropdown if expanded
|
|
let mut stack = Stack::new().with_child(input_container);
|
|
|
|
if self.github_dropdown_state.is_expanded {
|
|
let dropdown = self.render_repos_dropdown(appearance);
|
|
let dismissible_dropdown = Dismiss::new(dropdown)
|
|
.on_dismiss(|ctx, _app| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::CloseReposDropdown);
|
|
})
|
|
.finish();
|
|
stack.add_positioned_overlay_child(
|
|
dismissible_dropdown,
|
|
OffsetPositioning::offset_from_save_position_element(
|
|
REPOS_DROPDOWN_ANCHOR.to_string(),
|
|
vec2f(0., 4.),
|
|
PositionedElementOffsetBounds::WindowBySize,
|
|
PositionedElementAnchor::BottomLeft,
|
|
ChildAnchor::TopLeft,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Build refresh button (icon only) - placed outside the dropdown in the same column
|
|
let refresh_button = Container::new(
|
|
icon_button(
|
|
appearance,
|
|
Icon::Refresh,
|
|
false,
|
|
self.refresh_repos_button_mouse_state.clone(),
|
|
)
|
|
.with_style(UiComponentStyles {
|
|
height: Some(FORM_INPUT_HEIGHT),
|
|
width: Some(FORM_INPUT_HEIGHT),
|
|
padding: Some(Coords::uniform(10.)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::RetryFetchGithubRepos);
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
// Row with dropdown stack and refresh button
|
|
let input_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_spacing(4.)
|
|
.with_child(Expanded::new(1., stack.finish()).finish())
|
|
.with_child(refresh_button)
|
|
.finish();
|
|
|
|
field.add_child(input_row);
|
|
|
|
// Helper text
|
|
field.add_child(self.render_repo_helper_text_row(appearance));
|
|
field.finish()
|
|
}
|
|
|
|
fn render_repo_helper_text_row(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let helper = Text::new(
|
|
"Type owner/repo and press Enter to add, or select from dropdown.",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.85,
|
|
)
|
|
.with_color(theme.nonactive_ui_text_color().into())
|
|
.finish();
|
|
|
|
// Configure access link is only available on non-WASM platforms
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
let Some(app_install_link) = &self.github_dropdown_state.app_install_link else {
|
|
return helper;
|
|
};
|
|
|
|
// "Missing a repo? Configure access on GitHub" text with link
|
|
let install_link = app_install_link.clone();
|
|
|
|
// Build as a row with plain text + link
|
|
let mut text_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(4.);
|
|
|
|
text_row.add_child(helper);
|
|
// Plain text part
|
|
text_row.add_child(
|
|
Text::new(
|
|
"Missing a repo?",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.85,
|
|
)
|
|
.with_color(theme.nonactive_ui_text_color().into())
|
|
.finish(),
|
|
);
|
|
|
|
// Link part
|
|
let link = Hoverable::new(
|
|
self.configure_access_link_mouse_state.clone(),
|
|
move |state| {
|
|
let color = if state.is_mouse_over_element() {
|
|
theme.accent().with_opacity(180)
|
|
} else {
|
|
theme.accent()
|
|
};
|
|
Text::new(
|
|
"Configure access on GitHub",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.85,
|
|
)
|
|
.with_color(color.into())
|
|
.finish()
|
|
},
|
|
)
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(move |ctx, _, _| {
|
|
let url = install_link.clone();
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::OpenUrl(url));
|
|
})
|
|
.finish();
|
|
|
|
text_row.add_child(link);
|
|
|
|
text_row.finish()
|
|
}
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
{
|
|
helper
|
|
}
|
|
}
|
|
|
|
/// Split `haystack` into a sequence of fragments tagged as (text, is_match) for occurrences of `needle`.
|
|
///
|
|
/// Matches are non-overlapping (as returned by `str::match_indices`). This is used to render dropdown
|
|
/// labels where matching fragments are bolded.
|
|
///
|
|
/// Note on case-insensitive matching:
|
|
/// We do ASCII-only case-insensitive matching so that the match indices from the searched string are safe
|
|
/// to apply to the original `haystack` (ASCII lowercasing preserves byte lengths and indices).
|
|
fn split_non_overlapping_substring_matches<'a>(
|
|
haystack: &'a str,
|
|
needle: &'a str,
|
|
) -> Vec<(&'a str, bool)> {
|
|
let needle = needle.trim();
|
|
if needle.is_empty() {
|
|
return vec![(haystack, false)];
|
|
}
|
|
|
|
// Only do case-insensitive matching when both strings are ASCII, otherwise slicing `haystack` by
|
|
// indices from a transformed string could panic.
|
|
let (search_haystack, search_needle) = if haystack.is_ascii() && needle.is_ascii() {
|
|
(haystack.to_ascii_lowercase(), needle.to_ascii_lowercase())
|
|
} else {
|
|
(haystack.to_string(), needle.to_string())
|
|
};
|
|
|
|
let mut parts = Vec::new();
|
|
let mut cursor = 0usize;
|
|
|
|
for (start, _) in search_haystack.match_indices(&search_needle) {
|
|
let end = start + search_needle.len();
|
|
if start > cursor {
|
|
parts.push((&haystack[cursor..start], false));
|
|
}
|
|
parts.push((&haystack[start..end], true));
|
|
cursor = end;
|
|
}
|
|
|
|
if cursor < haystack.len() {
|
|
parts.push((&haystack[cursor..], false));
|
|
}
|
|
|
|
if parts.is_empty() {
|
|
vec![(haystack, false)]
|
|
} else {
|
|
parts
|
|
}
|
|
}
|
|
|
|
/// Render a repo label for the dropdown, bolding any non-overlapping substring matches against the
|
|
/// current query (`self.repos_input`).
|
|
///
|
|
/// For performance and simplicity, we avoid regexes and only bold literal substring matches.
|
|
fn render_repo_dropdown_label(
|
|
&self,
|
|
repo_label: &str,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let query = self.repos_input.trim();
|
|
|
|
if query.is_empty() {
|
|
return Text::new(
|
|
repo_label.to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish();
|
|
}
|
|
|
|
let fragments = Self::split_non_overlapping_substring_matches(repo_label, query);
|
|
if fragments.len() == 1 {
|
|
let (text, is_match) = fragments[0];
|
|
let mut label = Text::new(
|
|
text.to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.active_ui_text_color().into());
|
|
|
|
if is_match {
|
|
label = label.with_style(Properties::default().weight(Weight::Bold));
|
|
}
|
|
|
|
return label.finish();
|
|
}
|
|
|
|
let mut row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(0.);
|
|
|
|
for (text, is_match) in fragments {
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let mut fragment = Text::new_inline(
|
|
text.to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.active_ui_text_color().into());
|
|
|
|
if is_match {
|
|
fragment = fragment.with_style(Properties::default().weight(Weight::Bold));
|
|
}
|
|
|
|
row.add_child(fragment.finish());
|
|
}
|
|
|
|
row.finish()
|
|
}
|
|
|
|
fn render_repos_dropdown(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
// Filter repos based on input text
|
|
let search_text = self.repos_input.to_lowercase();
|
|
let filtered_repos: Vec<_> = self
|
|
.github_dropdown_state
|
|
.available_repos
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, repo)| {
|
|
if search_text.is_empty() {
|
|
true
|
|
} else {
|
|
let repo_str = format!("{}/{}", repo.owner, repo.repo).to_lowercase();
|
|
repo_str.contains(&search_text)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let mut content = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(4.);
|
|
|
|
// If no filtered repos, show message
|
|
if filtered_repos.is_empty() {
|
|
content.add_child(
|
|
Container::new(
|
|
Text::new(
|
|
"No repositories found",
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.nonactive_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.with_uniform_padding(12.)
|
|
.finish(),
|
|
);
|
|
} else {
|
|
// Render repo list with checkboxes
|
|
for (index, repo) in filtered_repos {
|
|
let is_selected = self
|
|
.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.any(|r| r.owner == repo.owner && r.repo == repo.repo);
|
|
|
|
let mouse_state = self
|
|
.github_dropdown_state
|
|
.repo_row_mouse_states
|
|
.get(index)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let repo_label = format!("{}/{}", repo.owner, repo.repo);
|
|
|
|
// Build checkbox
|
|
let checkbox = appearance
|
|
.ui_builder()
|
|
.checkbox(MouseStateHandle::default(), None)
|
|
.check(is_selected)
|
|
.build()
|
|
.disable()
|
|
.finish();
|
|
|
|
// Render label (bold the matched substring fragments)
|
|
let label = self.render_repo_dropdown_label(&repo_label, appearance);
|
|
|
|
// Build row content with checkbox and label
|
|
let row_content = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(8.)
|
|
.with_child(checkbox)
|
|
.with_child(label)
|
|
.finish();
|
|
|
|
let is_selected_row = self.github_dropdown_state.selected_index == Some(index);
|
|
let row = Hoverable::new(mouse_state, move |state| {
|
|
let bg = if is_selected_row || state.is_mouse_over_element() {
|
|
theme.surface_3()
|
|
} else {
|
|
theme.surface_2()
|
|
};
|
|
Container::new(row_content)
|
|
.with_horizontal_padding(12.)
|
|
.with_vertical_padding(8.)
|
|
.with_background(bg)
|
|
.finish()
|
|
})
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::ToggleRepoSelection(
|
|
index,
|
|
));
|
|
})
|
|
.finish();
|
|
|
|
let position_id = Self::repo_dropdown_row_position_id(index);
|
|
content.add_child(SavePosition::new(row, &position_id).finish());
|
|
}
|
|
}
|
|
|
|
// Use ClippedScrollable for proper scrolling
|
|
let scrollable = ClippedScrollable::vertical(
|
|
self.github_dropdown_state.scroll_state.clone(),
|
|
content.finish(),
|
|
ScrollbarWidth::Auto,
|
|
theme.nonactive_ui_text_color().into(), // scrollbar thumb
|
|
theme.active_ui_text_color().into(), // scrollbar thumb active
|
|
Fill::None, // scrollbar track
|
|
)
|
|
.finish();
|
|
|
|
// Constrain height and width
|
|
let dropdown_content = ConstrainedBox::new(scrollable)
|
|
.with_max_height(DROPDOWN_MAX_HEIGHT)
|
|
.with_max_width(DROPDOWN_MAX_WIDTH)
|
|
.finish();
|
|
|
|
// Wrap in container with border and background
|
|
Container::new(dropdown_content)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish()
|
|
}
|
|
|
|
fn render_selected_repo_chips(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut chips_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(6.);
|
|
|
|
for (index, repo) in self.form_state.selected_repos.iter().enumerate() {
|
|
let mouse_state = self
|
|
.remove_repo_mouse_states
|
|
.get(index)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let action = UpdateEnvironmentFormAction::RemoveRepo(index);
|
|
let remove_button = icon_button(appearance, Icon::X, false, mouse_state)
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
})
|
|
.finish();
|
|
|
|
let chip = Container::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(4.)
|
|
.with_child(
|
|
ConstrainedBox::new(
|
|
Text::new_inline(
|
|
repo.to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size() * 0.9,
|
|
)
|
|
.with_color(theme.active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.with_max_width(REPO_CHIP_MAX_WIDTH)
|
|
.finish(),
|
|
)
|
|
.with_child(remove_button)
|
|
.finish(),
|
|
)
|
|
.with_padding_left(8.)
|
|
.with_vertical_padding(4.)
|
|
.with_padding_right(4.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
|
.with_background(theme.surface_3())
|
|
.finish();
|
|
|
|
chips_row.add_child(chip);
|
|
}
|
|
|
|
chips_row.finish()
|
|
}
|
|
|
|
fn auth_url_with_next(&self, base_auth_url: &str) -> String {
|
|
let scheme = Self::oauth_next_scheme();
|
|
Self::build_auth_url_with_next_internal(
|
|
base_auth_url,
|
|
self.github_auth_redirect_target,
|
|
&scheme,
|
|
self.auth_source,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn build_auth_url_with_next(
|
|
base_auth_url: &str,
|
|
target: GithubAuthRedirectTarget,
|
|
scheme: &str,
|
|
) -> String {
|
|
Self::build_auth_url_with_next_internal(base_auth_url, target, scheme, AuthSource::Settings)
|
|
}
|
|
|
|
fn build_auth_url_with_next_internal(
|
|
base_auth_url: &str,
|
|
target: GithubAuthRedirectTarget,
|
|
scheme: &str,
|
|
auth_source: AuthSource,
|
|
) -> String {
|
|
let Ok(mut url) = Url::parse(base_auth_url) else {
|
|
return base_auth_url.to_string();
|
|
};
|
|
|
|
let scheme_for_next = std::env::var("WARP_OAUTH_NEXT_SCHEME")
|
|
.ok()
|
|
.filter(|value| !value.is_empty())
|
|
.or_else(|| {
|
|
url.query_pairs()
|
|
.find(|(key, _)| key == "scheme")
|
|
.map(|(_, value)| value.into_owned())
|
|
})
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| scheme.to_string());
|
|
|
|
let platform = if cfg!(target_family = "wasm") {
|
|
OAuthNextPlatform::Web
|
|
} else {
|
|
OAuthNextPlatform::Native
|
|
};
|
|
|
|
let next_url = Self::build_next_url(target, &scheme_for_next, auth_source, platform)
|
|
.unwrap_or_else(|| format!("{scheme_for_next}://{}", target.next_path()));
|
|
|
|
let existing_pairs = url
|
|
.query_pairs()
|
|
.filter(|(key, _)| key != "next")
|
|
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
.collect::<Vec<_>>();
|
|
|
|
{
|
|
let mut query_pairs = url.query_pairs_mut();
|
|
query_pairs.clear();
|
|
for (key, value) in existing_pairs {
|
|
query_pairs.append_pair(&key, &value);
|
|
}
|
|
query_pairs.append_pair("next", &next_url);
|
|
}
|
|
|
|
url.to_string()
|
|
}
|
|
|
|
fn build_next_url(
|
|
target: GithubAuthRedirectTarget,
|
|
scheme_for_next: &str,
|
|
auth_source: AuthSource,
|
|
platform: OAuthNextPlatform,
|
|
) -> Option<String> {
|
|
match platform {
|
|
OAuthNextPlatform::Native => {
|
|
let base = format!("{scheme_for_next}://{}", target.next_path());
|
|
let mut url = Url::parse(&base).ok()?;
|
|
|
|
if matches!(auth_source, AuthSource::CloudSetup) {
|
|
url.query_pairs_mut()
|
|
.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
|
|
}
|
|
|
|
Some(url.to_string())
|
|
}
|
|
OAuthNextPlatform::Web => {
|
|
let mut url = Url::parse(&ChannelState::server_root_url()).ok()?;
|
|
url.set_query(None);
|
|
|
|
match target {
|
|
GithubAuthRedirectTarget::SettingsEnvironments => {
|
|
url.set_path("/settings/environments");
|
|
{
|
|
let mut pairs = url.query_pairs_mut();
|
|
pairs.append_pair("oauth", "github");
|
|
if matches!(auth_source, AuthSource::CloudSetup) {
|
|
pairs.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
|
|
}
|
|
}
|
|
}
|
|
GithubAuthRedirectTarget::FocusCloudMode => {
|
|
url.set_path("/action/focus_cloud_mode");
|
|
}
|
|
}
|
|
|
|
Some(url.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn oauth_next_scheme() -> String {
|
|
if let Ok(override_value) = std::env::var("WARP_OAUTH_NEXT_SCHEME") {
|
|
if !override_value.is_empty() {
|
|
return override_value;
|
|
}
|
|
}
|
|
ChannelState::url_scheme().to_string()
|
|
}
|
|
|
|
/// Parses a Docker image reference and returns the Docker Hub URL if it looks like a Docker Hub image.
|
|
///
|
|
/// Recognizes:
|
|
/// - Official images like `python:3.11` (no owner, single segment) → `https://hub.docker.com/_/python`
|
|
/// - `owner/repo` or `owner/repo:tag` → `https://hub.docker.com/r/owner/repo`
|
|
/// - `docker.io/owner/repo` or `docker.io/owner/repo:tag`
|
|
/// - `docker.io/library/python` (explicit library prefix for official images)
|
|
/// - `index.docker.io/owner/repo` or `index.docker.io/owner/repo:tag`
|
|
///
|
|
/// Does NOT recognize (returns None):
|
|
/// - Other registries like `ghcr.io/owner/repo`
|
|
fn parse_docker_hub_url(image_ref: &str) -> Option<String> {
|
|
let trimmed = image_ref.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Remove tag or digest suffix for URL construction.
|
|
let image_without_tag = trimmed
|
|
.split('@')
|
|
.next()
|
|
.unwrap_or(trimmed)
|
|
.split(':')
|
|
.next()
|
|
.unwrap_or(trimmed);
|
|
|
|
// Check for explicit docker.io or index.docker.io prefix
|
|
let stripped = image_without_tag
|
|
.strip_prefix("docker.io/")
|
|
.or_else(|| image_without_tag.strip_prefix("index.docker.io/"));
|
|
|
|
let path = if let Some(path) = stripped {
|
|
// Explicit Docker Hub reference
|
|
path
|
|
} else {
|
|
// Check if it looks like a bare image reference (no registry prefix)
|
|
let parts: Vec<&str> = image_without_tag.split('/').collect();
|
|
match parts.len() {
|
|
// Single segment = official image (e.g. "python", "node")
|
|
1 => {
|
|
let name = parts[0];
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
return Some(format!("https://hub.docker.com/_/{name}"));
|
|
}
|
|
// Two segments = owner/repo; if the first part contains a dot, it's a registry hostname
|
|
2 => {
|
|
if parts[0].contains('.') {
|
|
return None;
|
|
}
|
|
image_without_tag
|
|
}
|
|
_ => return None,
|
|
}
|
|
};
|
|
|
|
// Handle explicit "library/" prefix for official images (e.g. docker.io/library/python)
|
|
if let Some(official_name) = path.strip_prefix("library/") {
|
|
if !official_name.is_empty() && !official_name.contains('/') {
|
|
return Some(format!("https://hub.docker.com/_/{official_name}"));
|
|
}
|
|
}
|
|
|
|
// Validate the path has owner/repo format
|
|
let path_parts: Vec<&str> = path.split('/').collect();
|
|
if path_parts.len() != 2 {
|
|
return None;
|
|
}
|
|
|
|
let owner = path_parts[0];
|
|
let repo = path_parts[1];
|
|
|
|
if owner.is_empty() || repo.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(format!("https://hub.docker.com/r/{owner}/{repo}"))
|
|
}
|
|
|
|
fn render_image_link_button(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
let docker_hub_url = Self::parse_docker_hub_url(&self.form_state.docker_image)?;
|
|
let theme = appearance.theme();
|
|
|
|
let action = UpdateEnvironmentFormAction::OpenUrl(docker_hub_url.clone());
|
|
|
|
let button = Container::new(
|
|
icon_button(
|
|
appearance,
|
|
Icon::Docker,
|
|
false,
|
|
self.image_link_button_mouse_state.clone(),
|
|
)
|
|
.with_style(UiComponentStyles {
|
|
height: Some(FORM_INPUT_HEIGHT),
|
|
width: Some(FORM_INPUT_HEIGHT),
|
|
padding: Some(Coords::uniform(10.)),
|
|
..Default::default()
|
|
})
|
|
.with_tooltip({
|
|
let ui_builder = appearance.ui_builder().clone();
|
|
move || {
|
|
ui_builder
|
|
.tool_tip(format!("Open image at {docker_hub_url}"))
|
|
.build()
|
|
.finish()
|
|
}
|
|
})
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
Some(button)
|
|
}
|
|
fn render_docker_image_field(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let mut field = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_spacing(FORM_LABEL_SPACING);
|
|
|
|
// Label (without suggest button)
|
|
field.add_child(Self::render_form_label(
|
|
"Docker image reference",
|
|
true,
|
|
appearance,
|
|
));
|
|
|
|
// Docker image input
|
|
let editor_container = Container::new(
|
|
ConstrainedBox::new(
|
|
Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_child(
|
|
Clipped::new(
|
|
Container::new(ChildView::new(&self.docker_image_editor).finish())
|
|
.with_horizontal_padding(FORM_INPUT_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(theme.surface_2())
|
|
.finish();
|
|
|
|
// Row with editor, optional Docker Hub link button, and suggest button.
|
|
let mut row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_spacing(8.);
|
|
|
|
row.add_child(Expanded::new(1., editor_container).finish());
|
|
|
|
// Add image link button if the image looks like a Docker Hub image.
|
|
if let Some(link_button) = self.render_image_link_button(appearance) {
|
|
row.add_child(link_button);
|
|
}
|
|
|
|
row.add_child(self.render_docker_image_suggest_button(appearance));
|
|
|
|
let row = row.finish();
|
|
|
|
field.add_child(
|
|
ConstrainedBox::new(row)
|
|
.with_width(DROPDOWN_MAX_WIDTH)
|
|
.finish(),
|
|
);
|
|
|
|
// Suggest image callout (if applicable) - shown below the input
|
|
if let Some(callout) = self.render_suggest_image_callout(appearance) {
|
|
field.add_child(callout);
|
|
}
|
|
|
|
field.finish()
|
|
}
|
|
|
|
fn can_suggest_image_for_current_repos(&self) -> bool {
|
|
if self.form_state.selected_repos.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
if matches!(&self.suggest_image_state, SuggestImageState::Loading { .. }) {
|
|
return false;
|
|
}
|
|
|
|
// In Edit mode, disable suggest-image until repos have been modified at least once.
|
|
if self.is_edit_mode() && !self.edit_repos_modified {
|
|
return false;
|
|
}
|
|
|
|
// Enforce “repos must change to retry” after a suggest-image attempt.
|
|
let repos_unchanged = self
|
|
.selected_repos_key()
|
|
.and_then(|key| {
|
|
self.suggest_image_last_attempt_key
|
|
.as_ref()
|
|
.map(|last_key| key == *last_key)
|
|
})
|
|
.unwrap_or(false);
|
|
if repos_unchanged {
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn render_docker_image_suggest_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let is_loading = matches!(&self.suggest_image_state, SuggestImageState::Loading { .. });
|
|
let is_disabled = !self.can_suggest_image_for_current_repos();
|
|
|
|
let button_text = if is_loading {
|
|
"Generating…"
|
|
} else {
|
|
"Suggest image"
|
|
};
|
|
|
|
let tooltip_text = "Warp will suggest a Docker image based on your selected repositories.";
|
|
|
|
let button = Hoverable::new(
|
|
self.suggest_image_button_mouse_state.clone(),
|
|
move |state| {
|
|
let bg = if !is_disabled {
|
|
if state.is_mouse_over_element() {
|
|
theme.surface_3()
|
|
} else {
|
|
theme.surface_2()
|
|
}
|
|
} else {
|
|
theme.surface_2()
|
|
};
|
|
|
|
let text_fill = if is_disabled {
|
|
theme.disabled_ui_text_color()
|
|
} else {
|
|
theme.active_ui_text_color()
|
|
};
|
|
|
|
let icon_size = appearance.ui_font_size();
|
|
let icon = ConstrainedBox::new(Icon::Lightbulb.to_warpui_icon(text_fill).finish())
|
|
.with_width(icon_size)
|
|
.with_height(icon_size)
|
|
.finish();
|
|
|
|
let text = Text::new(
|
|
button_text,
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(text_fill.into())
|
|
.finish();
|
|
|
|
let button_content = Container::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_spacing(6.)
|
|
.with_child(icon)
|
|
.with_child(text)
|
|
.finish(),
|
|
)
|
|
.with_horizontal_padding(12.)
|
|
.with_vertical_padding(8.)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
|
.with_border(Border::all(CARD_BORDER_WIDTH).with_border_fill(theme.outline()))
|
|
.with_background(bg)
|
|
.finish();
|
|
|
|
let button = ConstrainedBox::new(button_content)
|
|
.with_height(FORM_INPUT_HEIGHT)
|
|
.finish();
|
|
|
|
let mut stack = Stack::new().with_child(button);
|
|
|
|
if state.is_hovered() {
|
|
let tooltip = ConstrainedBox::new(
|
|
appearance
|
|
.ui_builder()
|
|
.tool_tip(tooltip_text.to_string())
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_max_width(420.)
|
|
.finish();
|
|
|
|
stack.add_positioned_overlay_child(
|
|
tooltip,
|
|
OffsetPositioning::offset_from_parent(
|
|
vec2f(0., 6.),
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::BottomLeft,
|
|
ChildAnchor::TopLeft,
|
|
),
|
|
);
|
|
}
|
|
|
|
stack.finish()
|
|
},
|
|
);
|
|
|
|
if is_disabled {
|
|
return button.finish();
|
|
}
|
|
|
|
button
|
|
.with_cursor(Cursor::PointingHand)
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(UpdateEnvironmentFormAction::SuggestImage);
|
|
})
|
|
.finish()
|
|
}
|
|
|
|
fn render_suggest_image_callout(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
let current_key = self.selected_repos_key();
|
|
|
|
// Only show callouts if they match the current repo selection
|
|
match (&self.suggest_image_state, current_key.as_ref()) {
|
|
(SuggestImageState::Idle, _) => None,
|
|
(SuggestImageState::Loading { .. }, _) => None,
|
|
(
|
|
SuggestImageState::Success {
|
|
key,
|
|
needs_custom_image: true,
|
|
reason,
|
|
},
|
|
Some(current_key),
|
|
) if key == current_key => {
|
|
Some(self.render_suggest_image_callout_with_action(reason, appearance))
|
|
}
|
|
(SuggestImageState::AuthRequired { key, auth_url }, Some(current_key))
|
|
if key == current_key =>
|
|
{
|
|
let auth_url_with_next = self.auth_url_with_next(auth_url);
|
|
let action = UpdateEnvironmentFormAction::OpenUrl(auth_url_with_next);
|
|
let button = WarningBoxButtonConfig::new(
|
|
"Authenticate",
|
|
self.suggest_image_auth_button_mouse_state.clone(),
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
},
|
|
);
|
|
Some(render_warning_box(
|
|
WarningBoxConfig::new(
|
|
"You need to grant access to your GitHub repos to suggest a Docker image",
|
|
)
|
|
.with_width(DROPDOWN_MAX_WIDTH)
|
|
.with_button(button),
|
|
appearance,
|
|
))
|
|
}
|
|
(SuggestImageState::Error { key, message }, Some(current_key))
|
|
if key == current_key =>
|
|
{
|
|
Some(render_warning_box(
|
|
WarningBoxConfig::new(message).with_width(DROPDOWN_MAX_WIDTH),
|
|
appearance,
|
|
))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn render_suggest_image_callout_with_action(
|
|
&self,
|
|
reason: &str,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let action = UpdateEnvironmentFormAction::LaunchAgentForSelectedRepos;
|
|
let button = WarningBoxButtonConfig::new(
|
|
"Launch agent",
|
|
self.suggest_image_launch_agent_button_mouse_state.clone(),
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
},
|
|
);
|
|
|
|
render_warning_box(
|
|
WarningBoxConfig::new(
|
|
"We couldn't find a good match. We recommend using a custom Docker image for these repos.",
|
|
)
|
|
.with_description(reason)
|
|
.with_icon(Icon::AlertTriangle)
|
|
.with_width(DROPDOWN_MAX_WIDTH)
|
|
.with_button(button),
|
|
appearance,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Entity for UpdateEnvironmentForm {
|
|
type Event = UpdateEnvironmentFormEvent;
|
|
}
|
|
|
|
impl TypedActionView for UpdateEnvironmentForm {
|
|
type Action = UpdateEnvironmentFormAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
UpdateEnvironmentFormAction::Submit => {
|
|
if !self.form_state.is_valid() {
|
|
return;
|
|
}
|
|
|
|
let environment = self.form_state.to_ambient_agent_environment();
|
|
match &self.mode {
|
|
EnvironmentFormMode::Create => {
|
|
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentCreated, ctx);
|
|
ctx.emit(UpdateEnvironmentFormEvent::Created {
|
|
environment,
|
|
share_with_team: self.share_with_team,
|
|
});
|
|
}
|
|
EnvironmentFormMode::Edit { env_id } => {
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::EnvironmentUpdated {
|
|
environment_id: env_id.into_server(),
|
|
},
|
|
ctx
|
|
);
|
|
ctx.emit(UpdateEnvironmentFormEvent::Updated {
|
|
env_id: *env_id,
|
|
environment,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
UpdateEnvironmentFormAction::Delete => {
|
|
if let EnvironmentFormMode::Edit { env_id } = &self.mode {
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::EnvironmentDeleted {
|
|
environment_id: env_id.into_server(),
|
|
},
|
|
ctx
|
|
);
|
|
ctx.emit(UpdateEnvironmentFormEvent::DeleteRequested { env_id: *env_id });
|
|
}
|
|
}
|
|
UpdateEnvironmentFormAction::Cancel => {
|
|
ctx.emit(UpdateEnvironmentFormEvent::Cancelled);
|
|
}
|
|
UpdateEnvironmentFormAction::ToggleShareWithTeam => {
|
|
if matches!(self.mode, EnvironmentFormMode::Create)
|
|
&& UserWorkspaces::as_ref(ctx).current_team_uid().is_some()
|
|
{
|
|
self.share_with_team = !self.share_with_team;
|
|
ctx.notify();
|
|
}
|
|
}
|
|
UpdateEnvironmentFormAction::Escape => {
|
|
if !self.try_close_repos_dropdown(ctx) {
|
|
ctx.emit(UpdateEnvironmentFormEvent::Cancelled);
|
|
}
|
|
}
|
|
UpdateEnvironmentFormAction::FocusSetupCommandsInput => {
|
|
ctx.focus(&self.setup_commands_input);
|
|
}
|
|
UpdateEnvironmentFormAction::AddRepo => {
|
|
let repo_input = self.repos_input_editor.as_ref(ctx).buffer_text(ctx);
|
|
let repo_input = if repo_input.trim().is_empty() {
|
|
self.repos_input.clone()
|
|
} else {
|
|
repo_input
|
|
};
|
|
let parsed_repos = Self::parse_repo_inputs(&repo_input);
|
|
let has_custom_repo = parsed_repos.iter().any(|(owner, repo)| {
|
|
!self
|
|
.github_dropdown_state
|
|
.available_repos
|
|
.iter()
|
|
.any(|available| available.owner == *owner && available.repo == *repo)
|
|
});
|
|
|
|
if self.github_dropdown_state.is_expanded {
|
|
if let Some(selected_index) = self.github_dropdown_state.selected_index {
|
|
if parsed_repos.is_empty() || !has_custom_repo {
|
|
self.toggle_repo_selection_at_index(selected_index, ctx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if parsed_repos.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for (owner, repo) in parsed_repos {
|
|
let already_selected = self
|
|
.form_state
|
|
.selected_repos
|
|
.iter()
|
|
.any(|r| r.owner == owner && r.repo == repo);
|
|
if !already_selected {
|
|
self.form_state
|
|
.selected_repos
|
|
.push(GithubRepo::new(owner, repo));
|
|
self.remove_repo_mouse_states
|
|
.push(MouseStateHandle::default());
|
|
}
|
|
}
|
|
|
|
// Mark repos as modified in Edit mode to enable suggest button
|
|
if self.is_edit_mode() {
|
|
self.edit_repos_modified = true;
|
|
}
|
|
|
|
// Clear the input
|
|
self.clear_repos_input(ctx);
|
|
ctx.notify();
|
|
}
|
|
UpdateEnvironmentFormAction::RemoveRepo(index) => {
|
|
if *index < self.form_state.selected_repos.len() {
|
|
self.form_state.selected_repos.remove(*index);
|
|
self.remove_repo_mouse_states.remove(*index);
|
|
|
|
// Mark repos as modified in Edit mode to enable suggest button
|
|
if self.is_edit_mode() {
|
|
self.edit_repos_modified = true;
|
|
}
|
|
ctx.notify();
|
|
}
|
|
}
|
|
UpdateEnvironmentFormAction::ToggleReposDropdown => {
|
|
self.github_dropdown_state.is_expanded = !self.github_dropdown_state.is_expanded;
|
|
ctx.focus(&self.repos_input_editor);
|
|
|
|
if self.github_dropdown_state.is_expanded {
|
|
self.github_dropdown_state.scroll_state = ClippedScrollStateHandle::default();
|
|
self.ensure_repo_dropdown_selection();
|
|
self.scroll_repo_dropdown_selection_into_view();
|
|
} else {
|
|
self.github_dropdown_state.selected_index = None;
|
|
}
|
|
|
|
ctx.notify();
|
|
}
|
|
UpdateEnvironmentFormAction::CloseReposDropdown => {
|
|
self.github_dropdown_state.is_expanded = false;
|
|
self.github_dropdown_state.selected_index = None;
|
|
ctx.notify();
|
|
}
|
|
UpdateEnvironmentFormAction::ToggleRepoSelection(index) => {
|
|
self.toggle_repo_selection_at_index(*index, ctx);
|
|
}
|
|
UpdateEnvironmentFormAction::RemoveSetupCommand(index) => {
|
|
if *index < self.form_state.setup_commands.len() {
|
|
self.form_state.setup_commands.remove(*index);
|
|
}
|
|
if *index < self.remove_setup_command_mouse_states.len() {
|
|
self.remove_setup_command_mouse_states.remove(*index);
|
|
}
|
|
ctx.notify();
|
|
}
|
|
UpdateEnvironmentFormAction::SuggestImage => {
|
|
self.suggest_image(ctx);
|
|
}
|
|
UpdateEnvironmentFormAction::LaunchAgentForSelectedRepos => {
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::LaunchedAgentFromEnvironmentForm,
|
|
ctx
|
|
);
|
|
|
|
let repos = self.selected_repos_as_remote_repo_args();
|
|
if repos.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let arg = CreateEnvironmentArg { repos };
|
|
|
|
let window_id = ctx.window_id();
|
|
let primary_window_and_view = ctx
|
|
.root_view_id(window_id)
|
|
.map(|view_id| (window_id, view_id));
|
|
|
|
if let Some((primary_window_id, root_view_id)) = primary_window_and_view {
|
|
ctx.dispatch_action(
|
|
primary_window_id,
|
|
&[root_view_id],
|
|
"root_view:create_environment_in_existing_window_and_run",
|
|
&arg,
|
|
log::Level::Info,
|
|
);
|
|
} else {
|
|
ctx.dispatch_global_action("root_view:create_environment_and_run", arg);
|
|
}
|
|
|
|
ctx.notify();
|
|
}
|
|
UpdateEnvironmentFormAction::RetryFetchGithubRepos => {
|
|
self.fetch_github_repos(ctx);
|
|
}
|
|
UpdateEnvironmentFormAction::StartGithubAuth => {
|
|
send_telemetry_from_ctx!(
|
|
CloudAgentTelemetryEvent::GitHubAuthFromEnvironmentForm,
|
|
ctx
|
|
);
|
|
self.start_github_auth(ctx);
|
|
}
|
|
UpdateEnvironmentFormAction::OpenUrl(url) => {
|
|
ctx.open_url(url);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn action_accessibility_contents(
|
|
&mut self,
|
|
_action: &Self::Action,
|
|
_ctx: &mut ViewContext<Self>,
|
|
) -> warpui::accessibility::ActionAccessibilityContent {
|
|
warpui::accessibility::ActionAccessibilityContent::default()
|
|
}
|
|
}
|
|
|
|
impl View for UpdateEnvironmentForm {
|
|
fn ui_name() -> &'static str {
|
|
"UpdateEnvironmentForm"
|
|
}
|
|
|
|
fn keymap_context(&self, app: &AppContext) -> warpui::keymap::Context {
|
|
let mut context = Self::default_keymap_context();
|
|
if self.submit_button.is_focused(app) {
|
|
context.set.insert(SUBMIT_BUTTON_FOCUSED);
|
|
}
|
|
context
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
|
|
let mut page = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_spacing(FORM_FIELD_SPACING);
|
|
|
|
// Header row with back button, title, and action button (only when show_header is true)
|
|
if self.show_header {
|
|
page.add_child(self.render_header(appearance, app));
|
|
}
|
|
if let Some(warning) = self.render_share_with_team_warning(appearance, app) {
|
|
page.add_child(warning);
|
|
}
|
|
|
|
// Form fields
|
|
page.add_child(Self::render_form_field(
|
|
"Name",
|
|
true,
|
|
None,
|
|
&self.name_editor,
|
|
appearance,
|
|
));
|
|
|
|
page.add_child(self.render_description_field(appearance, app));
|
|
page.add_child(self.render_repos_field(appearance));
|
|
page.add_child(self.render_docker_image_field(appearance));
|
|
page.add_child(self.render_setup_commands_field(appearance));
|
|
|
|
// Footer row with buttons (only when header is hidden)
|
|
if !self.show_header {
|
|
let mut footer_row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
|
|
|
|
// Delete button on the left for edit mode
|
|
if matches!(&self.mode, EnvironmentFormMode::Edit { .. }) {
|
|
footer_row.add_child(ChildView::new(&self.delete_button).finish());
|
|
} else {
|
|
// Empty spacer for create mode to push submit button to the right
|
|
footer_row.add_child(Empty::new().finish());
|
|
}
|
|
|
|
// Submit actions on the right
|
|
footer_row.add_child(self.render_submit_actions(appearance, app, &self.submit_button));
|
|
|
|
page.add_child(footer_row.finish());
|
|
} else if matches!(&self.mode, EnvironmentFormMode::Edit { .. }) {
|
|
// Delete button row when header is shown
|
|
page.add_child(
|
|
Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_child(ChildView::new(&self.delete_button).finish())
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
page.finish()
|
|
}
|
|
|
|
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
|
if focus_ctx.is_self_focused() {
|
|
self.focus(ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "update_environment_form_tests.rs"]
|
|
mod tests;
|