Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,498 @@
|
||||
use super::{
|
||||
ask_user_question_view_state, AskUserQuestionAction, AskUserQuestionEffect,
|
||||
AskUserQuestionPhase, AskUserQuestionSession, AskUserQuestionViewState,
|
||||
};
|
||||
use ai::agent::{
|
||||
action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType},
|
||||
action_result::AskUserQuestionAnswerItem,
|
||||
};
|
||||
|
||||
fn build_question(
|
||||
question_id: &str,
|
||||
question: &str,
|
||||
is_multiselect: bool,
|
||||
supports_other: bool,
|
||||
options: &[&str],
|
||||
) -> AskUserQuestionItem {
|
||||
AskUserQuestionItem {
|
||||
question_id: question_id.to_string(),
|
||||
question: question.to_string(),
|
||||
question_type: AskUserQuestionType::MultipleChoice {
|
||||
is_multiselect,
|
||||
options: options
|
||||
.iter()
|
||||
.map(|label| AskUserQuestionOption {
|
||||
label: (*label).to_string(),
|
||||
recommended: false,
|
||||
})
|
||||
.collect(),
|
||||
supports_other,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session(questions: Vec<AskUserQuestionItem>) -> AskUserQuestionSession {
|
||||
AskUserQuestionSession::new(questions)
|
||||
}
|
||||
|
||||
fn view_state_for(session: &AskUserQuestionSession) -> AskUserQuestionViewState {
|
||||
ask_user_question_view_state(session.current())
|
||||
}
|
||||
|
||||
fn current_draft(session: &AskUserQuestionSession) -> Option<&super::QuestionDraft> {
|
||||
session.current().and_then(|current| current.draft)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_other_row_focuses_the_other_input() {
|
||||
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: Some(1),
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_without_an_answer_submits_the_last_question_immediately() {
|
||||
let mut session = build_session(vec![build_question(
|
||||
"q1",
|
||||
"Only",
|
||||
false,
|
||||
true,
|
||||
&["Stable", "Nightly"],
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: None,
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped {
|
||||
question_id: "q1".to_string(),
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_after_a_selected_answer_schedules_auto_advance() {
|
||||
let mut session = build_session(vec![build_question(
|
||||
"q1",
|
||||
"Only",
|
||||
false,
|
||||
false,
|
||||
&["Stable", "Nightly"],
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: None,
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_blank_other_input_submits_the_last_question_immediately() {
|
||||
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: None,
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped {
|
||||
question_id: "q1".to_string(),
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_active_other_text_schedules_auto_advance() {
|
||||
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: None,
|
||||
active_other_text: Some("nightly".to_string()),
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert_eq!(
|
||||
current_draft(&session).and_then(|draft| draft.other_text.as_deref()),
|
||||
Some("nightly")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_single_select_option_toggles_it_and_schedules_auto_advance() {
|
||||
let mut session = build_session(vec![build_question(
|
||||
"q1",
|
||||
"Only",
|
||||
false,
|
||||
true,
|
||||
&["Stable", "Nightly"],
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: Some(1),
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_non_last_multi_select_option_toggles_it_and_schedules_auto_advance() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", true, true, &["Stable", "Nightly"]),
|
||||
build_question("q2", "Second", false, false, &["CLI"]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: Some(1),
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_answered_non_last_multi_select_option_keeps_existing_selection_and_advances() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", true, true, &["Stable", "Nightly"]),
|
||||
build_question("q2", "Second", false, false, &["CLI"]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
|
||||
AskUserQuestionEffect::RefreshCurrent
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::PressEnter {
|
||||
highlighted_index: Some(1),
|
||||
active_other_text: None,
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert!(current_draft(&session).is_some_and(|draft| {
|
||||
draft.selected_option_indices.contains(&0) && draft.selected_option_indices.contains(&1)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_select_non_last_toggle_schedules_auto_advance() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", false, false, &["Rust", "Go"]),
|
||||
build_question("q2", "Second", false, false, &["CLI", "GUI"]),
|
||||
]);
|
||||
|
||||
let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 });
|
||||
|
||||
assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance);
|
||||
assert_eq!(session.current_question_index(), 0);
|
||||
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
|
||||
assert!(matches!(session.phase(), AskUserQuestionPhase::Editing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_select_non_last_toggle_does_not_auto_advance() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", true, false, &["Rust", "Go"]),
|
||||
build_question("q2", "Second", false, false, &["CLI", "GUI"]),
|
||||
]);
|
||||
|
||||
let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 });
|
||||
|
||||
assert_eq!(effect, AskUserQuestionEffect::RefreshCurrent);
|
||||
assert_eq!(session.current_question_index(), 0);
|
||||
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
|
||||
assert!(matches!(session.phase(), AskUserQuestionPhase::Editing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_multi_select_toggle_schedules_auto_advance() {
|
||||
let mut session = build_session(vec![build_question(
|
||||
"q1",
|
||||
"Only",
|
||||
true,
|
||||
false,
|
||||
&["Rust", "Go"],
|
||||
)]);
|
||||
|
||||
let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 });
|
||||
|
||||
assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance);
|
||||
assert_eq!(session.current_question_index(), 0);
|
||||
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_select_clicking_selected_option_clears_the_draft() {
|
||||
let mut session = build_session(vec![build_question(
|
||||
"q1",
|
||||
"Only",
|
||||
false,
|
||||
false,
|
||||
&["Rust", "Go"],
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
|
||||
AskUserQuestionEffect::RefreshCurrent
|
||||
);
|
||||
assert!(current_draft(&session).is_none());
|
||||
assert_eq!(session.current_question_index(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafts_survive_navigation_and_submit_skips_only_unanswered_questions() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", true, false, &["Rust", "Go"]),
|
||||
build_question("q2", "Second", true, false, &["CLI", "GUI"]),
|
||||
build_question("q3", "Third", false, true, &["Stable"]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
|
||||
AskUserQuestionEffect::RefreshCurrent
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::NavigateNext),
|
||||
AskUserQuestionEffect::ShowQuestion
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::NavigatePrev),
|
||||
AskUserQuestionEffect::ShowQuestion
|
||||
);
|
||||
assert_eq!(
|
||||
current_draft(&session).map(|draft| draft.selected_option_indices.len()),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::NavigateNext),
|
||||
AskUserQuestionEffect::ShowQuestion
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::NavigateNext),
|
||||
AskUserQuestionEffect::ShowQuestion
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::SaveOtherText {
|
||||
text: Some("nightly toolchain".to_string()),
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
|
||||
let effect = session.apply(AskUserQuestionAction::Confirm);
|
||||
|
||||
assert_eq!(
|
||||
effect,
|
||||
AskUserQuestionEffect::Submit(vec![
|
||||
AskUserQuestionAnswerItem::Answered {
|
||||
question_id: "q1".to_string(),
|
||||
selected_options: vec!["Rust".to_string()],
|
||||
other_text: String::new(),
|
||||
},
|
||||
AskUserQuestionAnswerItem::Skipped {
|
||||
question_id: "q2".to_string(),
|
||||
},
|
||||
AskUserQuestionAnswerItem::Answered {
|
||||
question_id: "q3".to_string(),
|
||||
selected_options: vec![],
|
||||
other_text: "nightly toolchain".to_string(),
|
||||
},
|
||||
])
|
||||
);
|
||||
assert!(matches!(
|
||||
session.phase(),
|
||||
AskUserQuestionPhase::Completed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_select_other_text_does_not_auto_advance_before_last_question() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", true, true, &["Rust"]),
|
||||
build_question("q2", "Second", false, false, &["CLI"]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::SaveOtherText {
|
||||
text: Some("nightly".to_string()),
|
||||
}),
|
||||
AskUserQuestionEffect::RefreshCurrent
|
||||
);
|
||||
assert_eq!(session.current_question_index(), 0);
|
||||
assert_eq!(
|
||||
current_draft(&session).and_then(|draft| draft.other_text.as_deref()),
|
||||
Some("nightly")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_all_moves_session_to_completed_with_skipped_answers() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", true, false, &["Rust"]),
|
||||
build_question("q2", "Second", false, true, &["Stable"]),
|
||||
]);
|
||||
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 });
|
||||
session.apply(AskUserQuestionAction::NavigateNext);
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput);
|
||||
session.apply(AskUserQuestionAction::SaveOtherText {
|
||||
text: Some("nightly".to_string()),
|
||||
});
|
||||
|
||||
let effect = session.apply(AskUserQuestionAction::SkipAll);
|
||||
|
||||
assert_eq!(
|
||||
effect,
|
||||
AskUserQuestionEffect::Submit(vec![
|
||||
AskUserQuestionAnswerItem::Skipped {
|
||||
question_id: "q1".to_string(),
|
||||
},
|
||||
AskUserQuestionAnswerItem::Skipped {
|
||||
question_id: "q2".to_string(),
|
||||
},
|
||||
])
|
||||
);
|
||||
assert!(matches!(
|
||||
session.phase(),
|
||||
AskUserQuestionPhase::Completed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_text_submission_exits_input_and_submits_last_question() {
|
||||
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
assert!(view_state_for(&session).show_other_input);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::SaveOtherText {
|
||||
text: Some("nightly".to_string()),
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
|
||||
let draft = current_draft(&session).expect("draft should exist");
|
||||
assert_eq!(draft.other_text.as_deref(), Some("nightly"));
|
||||
assert!(!draft.is_other_input_active);
|
||||
assert!(!view_state_for(&session).show_other_input);
|
||||
|
||||
let effect = session.apply(AskUserQuestionAction::Confirm);
|
||||
|
||||
assert_eq!(
|
||||
effect,
|
||||
AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Answered {
|
||||
question_id: "q1".to_string(),
|
||||
selected_options: vec![],
|
||||
other_text: "nightly".to_string(),
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigating_next_on_last_question_is_a_noop() {
|
||||
let mut session = build_session(vec![build_question(
|
||||
"q1",
|
||||
"Only",
|
||||
false,
|
||||
false,
|
||||
&["Rust", "Go"],
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::NavigateNext),
|
||||
AskUserQuestionEffect::Noop
|
||||
);
|
||||
assert!(matches!(session.phase(), AskUserQuestionPhase::Editing));
|
||||
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_shows_other_input() {
|
||||
let mut session = build_session(vec![
|
||||
build_question("q1", "First", false, true, &["Stable"]),
|
||||
build_question("q2", "Second", false, false, &["CLI"]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
view_state_for(&session),
|
||||
AskUserQuestionViewState {
|
||||
show_other_input: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::OpenOtherInput),
|
||||
AskUserQuestionEffect::FocusOtherInput
|
||||
);
|
||||
assert_eq!(
|
||||
view_state_for(&session),
|
||||
AskUserQuestionViewState {
|
||||
show_other_input: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::SaveOtherText {
|
||||
text: Some("nightly".to_string()),
|
||||
}),
|
||||
AskUserQuestionEffect::ScheduleAutoAdvance
|
||||
);
|
||||
assert_eq!(
|
||||
session.apply(AskUserQuestionAction::NavigateNext),
|
||||
AskUserQuestionEffect::ShowQuestion
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
view_state_for(&session),
|
||||
AskUserQuestionViewState {
|
||||
show_other_input: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use settings::Setting as _;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{
|
||||
ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Shrinkable, SizeConstraintCondition,
|
||||
SizeConstraintSwitch, Text,
|
||||
};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EventContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::report_if_error;
|
||||
use crate::Appearance;
|
||||
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, PrimaryTheme};
|
||||
|
||||
use super::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::view_util::error_color;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AwsBedrockCredentialsErrorAction {
|
||||
RunLoginCommand,
|
||||
Configure,
|
||||
ToggleAutoLogin,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AwsBedrockCredentialsErrorEvent {
|
||||
RunLoginCommand,
|
||||
ConfigureLoginCommand,
|
||||
}
|
||||
|
||||
pub struct AwsBedrockCredentialsErrorView {
|
||||
model_name: String,
|
||||
login_command: String,
|
||||
|
||||
/// Whether auto-login was triggered, which shows a simpler "running command" message.
|
||||
auto_login_triggered: bool,
|
||||
|
||||
// Run button
|
||||
run_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Configure button
|
||||
configure_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Auto-login checkbox
|
||||
auto_login_checkbox_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl AwsBedrockCredentialsErrorView {
|
||||
pub fn new(
|
||||
model_name: String,
|
||||
login_command: String,
|
||||
auto_login_triggered: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Run button
|
||||
let run_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Refresh AWS Credentials", PrimaryTheme)
|
||||
.with_size(ButtonSize::InlineActionHeader)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(AwsBedrockCredentialsErrorAction::RunLoginCommand)
|
||||
})
|
||||
});
|
||||
|
||||
// Configure button
|
||||
let configure_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Configure", NakedTheme)
|
||||
.with_size(ButtonSize::InlineActionHeader)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(AwsBedrockCredentialsErrorAction::Configure)
|
||||
})
|
||||
});
|
||||
|
||||
// Subscribe to AISettings changes to update checkbox state
|
||||
ctx.subscribe_to_model(&AISettings::handle(ctx), |_me, _, event, ctx| {
|
||||
if matches!(event, AISettingsChangedEvent::AwsBedrockAutoLogin { .. }) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
model_name,
|
||||
login_command,
|
||||
auto_login_triggered,
|
||||
run_button,
|
||||
configure_button,
|
||||
auto_login_checkbox_handle: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AwsBedrockCredentialsErrorView {
|
||||
type Event = AwsBedrockCredentialsErrorEvent;
|
||||
}
|
||||
|
||||
impl View for AwsBedrockCredentialsErrorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"AwsBedrockCredentialsErrorView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// If auto-login was triggered, show a simple "running command" message
|
||||
if self.auto_login_triggered {
|
||||
return Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new(
|
||||
format!("Running `{}`...", self.login_command),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
||||
.with_selectable(false)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
}
|
||||
|
||||
let auto_login_enabled = *AISettings::as_ref(app).aws_bedrock_auto_login.value();
|
||||
|
||||
// Helper closures to create elements (since Box<dyn Element> can't be cloned)
|
||||
let make_alert_icon = || {
|
||||
ConstrainedBox::new(
|
||||
Icon::AlertTriangle
|
||||
.to_warpui_icon(error_color(theme).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_alert_text = || {
|
||||
Text::new(
|
||||
"AWS credentials expired or missing",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(error_color(theme))
|
||||
.with_selectable(false)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_detail_text = || {
|
||||
Text::new(
|
||||
format!(
|
||||
"Failed to authenticate with AWS Bedrock when using {}. \
|
||||
Run `{}` to refresh credentials.",
|
||||
self.model_name, self.login_command
|
||||
),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
||||
.with_selectable(false)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_buttons_row = || {
|
||||
Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(ChildView::new(&self.configure_button).finish())
|
||||
.with_child(ChildView::new(&self.run_button).finish())
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_checkbox_row = || {
|
||||
let checkbox = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.auto_login_checkbox_handle.clone(), None)
|
||||
.check(auto_login_enabled)
|
||||
.build()
|
||||
.on_click(|ctx: &mut EventContext, _, _| {
|
||||
ctx.dispatch_typed_action(AwsBedrockCredentialsErrorAction::ToggleAutoLogin)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(-4.)
|
||||
.finish();
|
||||
|
||||
let checkbox_label = Text::new(
|
||||
"Always run automatically",
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
||||
.with_selectable(false)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(checkbox)
|
||||
.with_child(Container::new(checkbox_label).with_margin_left(4.).finish())
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_header_row = || {
|
||||
Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(make_alert_icon())
|
||||
.with_child(make_alert_text())
|
||||
.finish()
|
||||
};
|
||||
|
||||
// Wide layout: detail text on left, checkbox + buttons on right (same row)
|
||||
let wide_layout = Flex::column()
|
||||
.with_spacing(12.)
|
||||
.with_child(make_header_row())
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., make_detail_text()).finish())
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_spacing(8.)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(make_checkbox_row())
|
||||
.with_child(make_buttons_row())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Narrow layout: detail text, then checkbox + buttons row
|
||||
let narrow_layout = Flex::column()
|
||||
.with_spacing(12.)
|
||||
.with_child(make_header_row())
|
||||
.with_child(make_detail_text())
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(make_checkbox_row())
|
||||
.with_child(make_buttons_row())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Switch threshold - when width is less than this, use narrow layout
|
||||
let threshold = 600.0 * appearance.monospace_ui_scalar();
|
||||
|
||||
SizeConstraintSwitch::new(
|
||||
wide_layout,
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(threshold),
|
||||
narrow_layout,
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AwsBedrockCredentialsErrorView {
|
||||
type Action = AwsBedrockCredentialsErrorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AwsBedrockCredentialsErrorAction::RunLoginCommand => {
|
||||
ctx.emit(AwsBedrockCredentialsErrorEvent::RunLoginCommand);
|
||||
}
|
||||
AwsBedrockCredentialsErrorAction::Configure => {
|
||||
ctx.emit(AwsBedrockCredentialsErrorEvent::ConfigureLoginCommand);
|
||||
}
|
||||
AwsBedrockCredentialsErrorAction::ToggleAutoLogin => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let current = *settings.aws_bedrock_auto_login.value();
|
||||
report_if_error!(settings.aws_bedrock_auto_login.set_value(!current, ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
|
||||
Hoverable, Icon, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
AppContext, Element,
|
||||
};
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::icons::Icon as CoreIcon;
|
||||
use warp_core::ui::theme::color::internal_colors::neutral_2;
|
||||
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
self, INLINE_ACTION_HORIZONTAL_PADDING, INLINE_ACTION_VERTICAL_PADDING,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion};
|
||||
|
||||
pub struct CreateOrEditDocumentAction {
|
||||
document_id: AIDocumentId,
|
||||
document_title: String,
|
||||
document_version: AIDocumentVersion,
|
||||
mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl CreateOrEditDocumentAction {
|
||||
pub fn new(
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
mouse_state: MouseStateHandle,
|
||||
app: &AppContext,
|
||||
) -> Option<Self> {
|
||||
let document = AIDocumentModel::as_ref(app).get_document(&document_id, document_version)?;
|
||||
Some(Self {
|
||||
document_id,
|
||||
document_title: document.get_title(),
|
||||
document_version: document.get_version(),
|
||||
mouse_state,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render(self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let subtext_color = theme.sub_text_color(theme.background()).into_solid();
|
||||
let main_text_color = theme.main_text_color(theme.background()).into_solid();
|
||||
|
||||
let file_icon = Container::new(
|
||||
ConstrainedBox::new(Icon::new(CoreIcon::Compass.into(), main_text_color).finish())
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_action_header::ICON_MARGIN)
|
||||
.finish();
|
||||
|
||||
let title_text = Clipped::new(
|
||||
Text::new_inline(
|
||||
self.document_title.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(main_text_color)
|
||||
.with_selectable(false)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let version_text = Container::new(
|
||||
Text::new_inline(
|
||||
self.document_version.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(subtext_color)
|
||||
.with_selectable(false)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_action_header::ICON_MARGIN)
|
||||
.finish();
|
||||
|
||||
let view_document_icon =
|
||||
ConstrainedBox::new(Icon::new(CoreIcon::Share3.into(), main_text_color).finish())
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
|
||||
let view_document_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(0.)
|
||||
.with_child(version_text)
|
||||
.with_child(view_document_icon)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(file_icon)
|
||||
.with_child(Expanded::new(1., title_text).finish())
|
||||
.with_child(view_document_row);
|
||||
|
||||
let content = Container::new(row.finish())
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_VERTICAL_PADDING)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background_color(neutral_2(theme))
|
||||
.with_border(warpui::elements::Border::all(1.).with_border_fill(theme.surface_2()))
|
||||
.finish();
|
||||
|
||||
Hoverable::new(self.mouse_state, move |_mouse_state| content)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _app, _| {
|
||||
ctx.dispatch_typed_action(crate::WorkspaceAction::OpenAIDocumentPane {
|
||||
document_id: self.document_id,
|
||||
document_version: self.document_version,
|
||||
});
|
||||
})
|
||||
.finish()
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use std::borrow::Cow;
|
||||
use std::rc::Rc;
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
|
||||
EventHandler, Expanded, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, SizeConstraintCondition,
|
||||
SizeConstraintSwitch, Text,
|
||||
},
|
||||
fonts::FamilyId,
|
||||
platform::Cursor,
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::inline_action::inline_action_icons::icon_size,
|
||||
ui_components::blended_colors,
|
||||
view_components::compactible_action_button::{
|
||||
render_compact_and_regular_button_rows, render_expansion_icon,
|
||||
RenderCompactibleActionButton,
|
||||
},
|
||||
};
|
||||
|
||||
/// Same padding constants as the original for consistency
|
||||
pub const INLINE_ACTION_HORIZONTAL_PADDING: f32 = 16.;
|
||||
/// The vertical padding applied to the requested action row's content body (usually a command).
|
||||
pub const INLINE_ACTION_VERTICAL_PADDING: f32 = 12.;
|
||||
pub const INLINE_ACTION_HEADER_VERTICAL_PADDING: f32 = 10.;
|
||||
pub const ICON_MARGIN: f32 = 8.;
|
||||
|
||||
pub type OnToggleExpandedCallback = Rc<dyn Fn(&mut EventContext) + 'static>;
|
||||
pub type OnRightClickCallback = Rc<dyn Fn(&mut EventContext) + 'static>;
|
||||
|
||||
/// Configuration for manual expansion behavior
|
||||
#[derive(Clone)]
|
||||
pub struct ExpandedConfig {
|
||||
pub is_expanded: bool,
|
||||
/// Optional callback for when expansion toggle is clicked
|
||||
pub on_toggle_expanded: Option<OnToggleExpandedCallback>,
|
||||
/// Optional callback for when right-click is triggered
|
||||
pub on_right_click: Option<OnRightClickCallback>,
|
||||
/// Mouse state handle for expansion toggle
|
||||
pub toggle_mouse_state: MouseStateHandle,
|
||||
pub expands_upwards: bool,
|
||||
}
|
||||
|
||||
impl ExpandedConfig {
|
||||
pub fn new(is_expanded: bool, toggle_mouse_state: MouseStateHandle) -> Self {
|
||||
Self {
|
||||
is_expanded,
|
||||
on_toggle_expanded: None,
|
||||
on_right_click: None,
|
||||
toggle_mouse_state,
|
||||
expands_upwards: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_expands_upwards(mut self) -> Self {
|
||||
self.expands_upwards = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_toggle_callback<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&mut EventContext) + 'static,
|
||||
{
|
||||
self.on_toggle_expanded = Some(Rc::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_right_click_callback<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&mut EventContext) + 'static,
|
||||
{
|
||||
self.on_right_click = Some(Rc::new(callback));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for when we want a right clickable element,
|
||||
/// but that element isn't necessarily expandable.
|
||||
#[derive(Clone)]
|
||||
pub struct RightClickConfig {
|
||||
pub on_right_click: OnRightClickCallback,
|
||||
pub header_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl RightClickConfig {
|
||||
pub fn new(on_right_click: OnRightClickCallback, header_mouse_state: MouseStateHandle) -> Self {
|
||||
Self {
|
||||
on_right_click,
|
||||
header_mouse_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum InteractionMode {
|
||||
/// Renders action buttons.
|
||||
ActionButtons {
|
||||
action_buttons: Vec<Rc<dyn RenderCompactibleActionButton>>,
|
||||
size_switch_threshold: f32,
|
||||
},
|
||||
/// Renders expansion chevron, with caller-specified click handler.
|
||||
ManuallyExpandable(ExpandedConfig),
|
||||
/// Renders a right-clickable element, with caller-specified right-click handler.
|
||||
RightClickable(RightClickConfig),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HeaderConfig {
|
||||
pub title: Cow<'static, str>,
|
||||
pub font_family: FamilyId,
|
||||
/// Whether to parse the title as markdown when rendering.
|
||||
pub use_markdown: bool,
|
||||
pub icon: Option<warpui::elements::Icon>,
|
||||
pub badge: Option<String>,
|
||||
pub interaction_mode: Option<InteractionMode>,
|
||||
pub is_text_selectable: bool,
|
||||
pub font_color_override: Option<ColorU>,
|
||||
pub corner_radius_override: Option<CornerRadius>,
|
||||
pub soft_wrap_title: bool,
|
||||
}
|
||||
|
||||
impl HeaderConfig {
|
||||
pub fn new(title: impl Into<Cow<'static, str>>, app: &AppContext) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
font_family: Appearance::as_ref(app).ui_font_family(),
|
||||
use_markdown: false,
|
||||
icon: None,
|
||||
badge: None,
|
||||
interaction_mode: None,
|
||||
is_text_selectable: false,
|
||||
font_color_override: None,
|
||||
corner_radius_override: None,
|
||||
soft_wrap_title: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_soft_wrap_title(mut self) -> Self {
|
||||
self.soft_wrap_title = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_font_family(mut self, font: FamilyId) -> Self {
|
||||
self.font_family = font;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_icon(mut self, icon: warpui::elements::Icon) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_badge(mut self, badge: String) -> Self {
|
||||
self.badge = Some(badge);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_interaction_mode(mut self, interaction_mode: InteractionMode) -> Self {
|
||||
self.interaction_mode = Some(interaction_mode);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_selectable_text(mut self) -> Self {
|
||||
self.is_text_selectable = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_font_color(mut self, font_color: ColorU) -> Self {
|
||||
self.font_color_override = Some(font_color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_corner_radius_override(mut self, corner_radius: CornerRadius) -> Self {
|
||||
self.corner_radius_override = Some(corner_radius);
|
||||
self
|
||||
}
|
||||
|
||||
/// Parses the title as markdown when rendering.
|
||||
pub fn with_markdown(mut self) -> Self {
|
||||
self.use_markdown = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render_header(
|
||||
self,
|
||||
app: &AppContext,
|
||||
interaction_mode_content: Option<Box<dyn Element>>,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header_background = theme.surface_2();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let mut left_content_container = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(icon) = self.icon {
|
||||
left_content_container.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(ICON_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
let text_color = self
|
||||
.font_color_override
|
||||
.unwrap_or_else(|| blended_colors::text_main(appearance.theme(), header_background));
|
||||
|
||||
let mut title_element = Text::new_inline(
|
||||
self.title.clone(),
|
||||
self.font_family,
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.soft_wrap(self.soft_wrap_title)
|
||||
.with_selectable(self.is_text_selectable)
|
||||
.with_color(text_color)
|
||||
.finish();
|
||||
|
||||
if self.use_markdown {
|
||||
if let Ok(formatted_text) = markdown_parser::parse_markdown(&self.title) {
|
||||
let mut element = FormattedTextElement::new(
|
||||
formatted_text,
|
||||
appearance.monospace_font_size(),
|
||||
self.font_family,
|
||||
appearance.monospace_font_family(),
|
||||
text_color,
|
||||
Default::default(),
|
||||
)
|
||||
.set_selectable(self.is_text_selectable);
|
||||
if !self.soft_wrap_title {
|
||||
element = element.with_no_text_wrapping();
|
||||
}
|
||||
title_element = element.finish();
|
||||
}
|
||||
}
|
||||
|
||||
left_content_container.add_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
Container::new(title_element).with_margin_right(8.).finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(badge) = self.badge {
|
||||
left_content_container.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
badge,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(internal_colors::fg_overlay_5(theme).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(4.)
|
||||
.with_margin_right(8.)
|
||||
.with_border(Border::all(1.0).with_border_fill(theme.outline()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
|
||||
.with_background(theme.surface_1())
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
header_row.add_child(Shrinkable::new(1., left_content_container.finish()).finish());
|
||||
|
||||
if let Some(interaction_mode_content) = interaction_mode_content {
|
||||
header_row.add_child(interaction_mode_content);
|
||||
}
|
||||
|
||||
let looks_expanded_downwards =
|
||||
self.interaction_mode
|
||||
.as_ref()
|
||||
.is_some_and(|mode| match mode {
|
||||
InteractionMode::ActionButtons { .. } => true,
|
||||
InteractionMode::ManuallyExpandable(expansion_config) => {
|
||||
expansion_config.is_expanded && !expansion_config.expands_upwards
|
||||
}
|
||||
InteractionMode::RightClickable(..) => false,
|
||||
});
|
||||
let container = Container::new(header_row.finish())
|
||||
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_background(header_background)
|
||||
.with_corner_radius(
|
||||
if let Some(corner_radius_override) = self.corner_radius_override {
|
||||
corner_radius_override
|
||||
} else if looks_expanded_downwards {
|
||||
CornerRadius::with_top(Radius::Pixels(8.))
|
||||
} else {
|
||||
CornerRadius::with_all(Radius::Pixels(8.))
|
||||
},
|
||||
)
|
||||
.finish();
|
||||
|
||||
if let Some(InteractionMode::ManuallyExpandable(expansion_config)) = &self.interaction_mode
|
||||
{
|
||||
let element = if let Some(callback) = &expansion_config.on_toggle_expanded {
|
||||
let callback = Rc::clone(callback);
|
||||
let mouse_state = expansion_config.toggle_mouse_state.clone();
|
||||
|
||||
Hoverable::new(mouse_state, |_| container)
|
||||
.on_click(move |ctx, _, _| {
|
||||
callback(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
container
|
||||
};
|
||||
|
||||
// Wrap in EventHandler to allow right-click event propagation
|
||||
if let Some(right_click_callback) = expansion_config.on_right_click.clone() {
|
||||
return EventHandler::new(element)
|
||||
.on_right_mouse_down(move |ctx, _, _| {
|
||||
right_click_callback(ctx);
|
||||
DispatchEventResult::PropagateToParent
|
||||
})
|
||||
.finish();
|
||||
} else {
|
||||
return element;
|
||||
}
|
||||
} else if let Some(InteractionMode::RightClickable(right_click_config)) =
|
||||
&self.interaction_mode
|
||||
{
|
||||
let right_click_callback = right_click_config.on_right_click.clone();
|
||||
let header_mouse_state = right_click_config.header_mouse_state.clone();
|
||||
|
||||
let hoverable = Hoverable::new(header_mouse_state, |_| container).finish();
|
||||
return EventHandler::new(hoverable)
|
||||
.on_right_mouse_down(move |ctx, _, _| {
|
||||
right_click_callback(ctx);
|
||||
DispatchEventResult::PropagateToParent
|
||||
})
|
||||
.finish();
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
pub fn render(self, app: &AppContext) -> Box<dyn Element> {
|
||||
if let Some(interaction_mode) = self.interaction_mode.clone() {
|
||||
let appearance: &Appearance = Appearance::as_ref(app);
|
||||
match interaction_mode {
|
||||
InteractionMode::ActionButtons {
|
||||
action_buttons,
|
||||
size_switch_threshold,
|
||||
} => {
|
||||
// Convert boxed trait objects into trait object references expected by the renderer
|
||||
let button_refs: Vec<&dyn RenderCompactibleActionButton> =
|
||||
action_buttons.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let (regular_row, compact_row) =
|
||||
render_compact_and_regular_button_rows(button_refs, None, appearance, app);
|
||||
|
||||
let regular_header = self.clone().render_header(app, Some(regular_row));
|
||||
let compact_header = self.render_header(app, Some(compact_row));
|
||||
|
||||
let size_switch_threshold =
|
||||
size_switch_threshold * appearance.monospace_ui_scalar();
|
||||
SizeConstraintSwitch::new(
|
||||
regular_header,
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(size_switch_threshold),
|
||||
compact_header,
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
InteractionMode::ManuallyExpandable(expansion_config) => {
|
||||
let expanded_icon = ConstrainedBox::new(render_expansion_icon(
|
||||
expansion_config.is_expanded,
|
||||
expansion_config.expands_upwards,
|
||||
appearance,
|
||||
app,
|
||||
))
|
||||
.with_height(icon_size(app))
|
||||
.with_width(icon_size(app))
|
||||
.finish();
|
||||
|
||||
self.render_header(app, Some(expanded_icon))
|
||||
}
|
||||
InteractionMode::RightClickable(_) => self.render_header(app, None),
|
||||
}
|
||||
} else {
|
||||
self.render_header(app, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
/// Returns the size for icons in the AI block, scaled to the user's current font size.
|
||||
pub fn icon_size(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn green_check_icon(appearance: &Appearance) -> warpui::elements::Icon {
|
||||
warpui::elements::Icon::new(
|
||||
Icon::Check.into(),
|
||||
AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn red_x_icon(appearance: &Appearance) -> warpui::elements::Icon {
|
||||
warpui::elements::Icon::new(
|
||||
Icon::X.into(),
|
||||
AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cancelled_icon(appearance: &Appearance) -> warpui::elements::Icon {
|
||||
warpui::elements::Icon::new(
|
||||
Icon::Cancelled.into(),
|
||||
blended_colors::neutral_6(appearance.theme()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reverted_icon(appearance: &Appearance) -> warpui::elements::Icon {
|
||||
warpui::elements::Icon::new(
|
||||
Icon::ReverseLeft.into(),
|
||||
blended_colors::neutral_6(appearance.theme()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use ai::diff_validation::{DiffDelta, DiffType};
|
||||
|
||||
pub(super) fn proposed_terminal_line_range(diff_type: &DiffType) -> Option<std::ops::Range<usize>> {
|
||||
let (terminal_delta, cumulative_line_shift) = terminal_update_delta_with_shift(diff_type)?;
|
||||
let terminal_start = terminal_delta
|
||||
.replacement_line_range
|
||||
.start
|
||||
.saturating_sub(1);
|
||||
let terminal_start = terminal_start.saturating_add_signed(cumulative_line_shift);
|
||||
let inserted_lines = inserted_line_count(&terminal_delta.insertion);
|
||||
let terminal_end = terminal_start.saturating_add(inserted_lines);
|
||||
Some(terminal_start..terminal_end.max(terminal_start))
|
||||
}
|
||||
|
||||
pub(super) fn proposed_terminal_line_text(diff_type: &DiffType) -> Option<String> {
|
||||
let terminal_delta = terminal_update_delta(diff_type)?;
|
||||
terminal_delta
|
||||
.insertion
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn looks_malformed_terminal_line(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
has_odd_unescaped_quote(trimmed, '"')
|
||||
|| has_odd_unescaped_quote(trimmed, '\'')
|
||||
|| has_odd_unescaped_quote(trimmed, '`')
|
||||
|| trimmed.ends_with('\\')
|
||||
|| has_trailing_incomplete_token(trimmed)
|
||||
|| looks_like_function_declaration_without_body(trimmed)
|
||||
}
|
||||
|
||||
pub(super) fn changed_lines_intersect_terminal_range(
|
||||
changed_lines: &[std::ops::Range<usize>],
|
||||
terminal_line_range: &std::ops::Range<usize>,
|
||||
) -> bool {
|
||||
changed_lines
|
||||
.iter()
|
||||
.any(|changed| range_intersects_or_contains_point(changed, terminal_line_range))
|
||||
}
|
||||
|
||||
pub(super) fn has_malformed_terminal_correction_signal(
|
||||
diff_type: &DiffType,
|
||||
changed_lines: &[std::ops::Range<usize>],
|
||||
) -> bool {
|
||||
let Some(terminal_line_range) = proposed_terminal_line_range(diff_type) else {
|
||||
return false;
|
||||
};
|
||||
let Some(terminal_line_text) = proposed_terminal_line_text(diff_type) else {
|
||||
return false;
|
||||
};
|
||||
looks_malformed_terminal_line(&terminal_line_text)
|
||||
&& changed_lines_intersect_terminal_range(changed_lines, &terminal_line_range)
|
||||
}
|
||||
|
||||
fn terminal_update_delta(diff_type: &DiffType) -> Option<&DiffDelta> {
|
||||
match diff_type {
|
||||
DiffType::Update { deltas, .. } => deltas.iter().max_by_key(|delta| {
|
||||
(
|
||||
delta.replacement_line_range.end,
|
||||
delta.replacement_line_range.start,
|
||||
)
|
||||
}),
|
||||
DiffType::Create { .. } | DiffType::Delete { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_update_delta_with_shift(diff_type: &DiffType) -> Option<(&DiffDelta, isize)> {
|
||||
let DiffType::Update { deltas, .. } = diff_type else {
|
||||
return None;
|
||||
};
|
||||
let terminal_delta = terminal_update_delta(diff_type)?;
|
||||
let mut sorted_deltas: Vec<&DiffDelta> = deltas.iter().collect();
|
||||
sorted_deltas.sort_by_key(|delta| {
|
||||
(
|
||||
delta.replacement_line_range.start,
|
||||
delta.replacement_line_range.end,
|
||||
)
|
||||
});
|
||||
let mut cumulative_line_shift = 0isize;
|
||||
for delta in sorted_deltas {
|
||||
if std::ptr::eq(delta, terminal_delta) {
|
||||
return Some((terminal_delta, cumulative_line_shift));
|
||||
}
|
||||
let replaced_lines = delta
|
||||
.replacement_line_range
|
||||
.end
|
||||
.saturating_sub(delta.replacement_line_range.start);
|
||||
let inserted_lines = inserted_line_count(&delta.insertion);
|
||||
cumulative_line_shift += inserted_lines as isize - replaced_lines as isize;
|
||||
}
|
||||
unreachable!(
|
||||
"terminal_update_delta_with_shift: terminal delta not found in sorted update deltas"
|
||||
)
|
||||
}
|
||||
|
||||
fn inserted_line_count(insertion: &str) -> usize {
|
||||
if insertion.is_empty() {
|
||||
0
|
||||
} else {
|
||||
insertion.lines().count()
|
||||
}
|
||||
}
|
||||
|
||||
fn has_odd_unescaped_quote(line: &str, quote: char) -> bool {
|
||||
let mut escaped = false;
|
||||
let mut count = 0usize;
|
||||
for ch in line.chars() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ch == quote {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count % 2 == 1
|
||||
}
|
||||
|
||||
fn has_trailing_incomplete_token(line: &str) -> bool {
|
||||
if line.starts_with("//") || line.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
// Keep this list biased toward operators that are strongly code-like at EOL.
|
||||
// Deliberately exclude ambiguous single-character tokens like ".", "-", "*", and "/"
|
||||
// because they frequently appear in prose/markdown/path-like text and can inflate false positives.
|
||||
[
|
||||
"&&", "||", "??", "=>", "->", "::", "==", "!=", "<=", ">=", "+", "%", "=", "<", ">",
|
||||
]
|
||||
.iter()
|
||||
.any(|token| line.ends_with(token))
|
||||
}
|
||||
|
||||
fn starts_like_function_declaration(line: &str) -> bool {
|
||||
[
|
||||
"fn ",
|
||||
"pub fn ",
|
||||
"pub(crate) fn ",
|
||||
"pub(super) fn ",
|
||||
"pub async fn ",
|
||||
"async fn ",
|
||||
"def ",
|
||||
"async def ",
|
||||
"function ",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| line.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn looks_like_function_declaration_without_body(line: &str) -> bool {
|
||||
if !starts_like_function_declaration(line) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let has_params = line.contains('(') && line.contains(')');
|
||||
let has_body_delimiter = line.contains('{') || line.ends_with(':');
|
||||
let ends_like_declaration = line.ends_with(')') || line.contains("->");
|
||||
let terminates_signature = line.ends_with(';');
|
||||
|
||||
has_params && ends_like_declaration && !has_body_delimiter && !terminates_signature
|
||||
}
|
||||
|
||||
fn range_intersects_or_contains_point(
|
||||
changed: &std::ops::Range<usize>,
|
||||
terminal: &std::ops::Range<usize>,
|
||||
) -> bool {
|
||||
let changed_is_point = changed.start == changed.end;
|
||||
let terminal_is_point = terminal.start == terminal.end;
|
||||
|
||||
if changed_is_point && terminal_is_point {
|
||||
return changed.start == terminal.start;
|
||||
}
|
||||
if changed_is_point {
|
||||
return terminal.start <= changed.start && changed.start < terminal.end;
|
||||
}
|
||||
if terminal_is_point {
|
||||
return changed.start <= terminal.start && terminal.start < changed.end;
|
||||
}
|
||||
|
||||
changed.start < terminal.end && terminal.start < changed.end
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "malformed_line_heuristics_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,97 @@
|
||||
use std::slice;
|
||||
|
||||
use super::{
|
||||
changed_lines_intersect_terminal_range, has_malformed_terminal_correction_signal,
|
||||
proposed_terminal_line_range,
|
||||
};
|
||||
use ai::diff_validation::{DiffDelta, DiffType};
|
||||
|
||||
#[test]
|
||||
fn terminal_range_accounts_for_prior_insertions() {
|
||||
let diff = DiffType::update(
|
||||
vec![
|
||||
DiffDelta {
|
||||
replacement_line_range: 2..3,
|
||||
insertion: "alpha\nbeta\ngamma\n".to_string(),
|
||||
},
|
||||
DiffDelta {
|
||||
replacement_line_range: 5..6,
|
||||
insertion: "let value = \"unterminated".to_string(),
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
let terminal_range =
|
||||
proposed_terminal_line_range(&diff).expect("terminal hunk should produce range");
|
||||
assert_eq!(terminal_range, 6..7);
|
||||
assert!(changed_lines_intersect_terminal_range(
|
||||
slice::from_ref(&(6..7)),
|
||||
&terminal_range
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_range_accounts_for_prior_deletions() {
|
||||
let diff = DiffType::update(
|
||||
vec![
|
||||
DiffDelta {
|
||||
replacement_line_range: 2..5,
|
||||
insertion: "one-line\n".to_string(),
|
||||
},
|
||||
DiffDelta {
|
||||
replacement_line_range: 8..9,
|
||||
insertion: "let value = \"unterminated".to_string(),
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
let terminal_range =
|
||||
proposed_terminal_line_range(&diff).expect("terminal hunk should produce range");
|
||||
assert_eq!(terminal_range, 5..6);
|
||||
assert!(changed_lines_intersect_terminal_range(
|
||||
slice::from_ref(&(5..6)),
|
||||
&terminal_range
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_metric_signal_true_for_shifted_multi_hunk_diff() {
|
||||
let diff = DiffType::update(
|
||||
vec![
|
||||
DiffDelta {
|
||||
replacement_line_range: 2..3,
|
||||
insertion: "alpha\nbeta\ngamma\n".to_string(),
|
||||
},
|
||||
DiffDelta {
|
||||
replacement_line_range: 5..6,
|
||||
insertion: "let value = \"unterminated".to_string(),
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(has_malformed_terminal_correction_signal(
|
||||
&diff,
|
||||
slice::from_ref(&(6..7)),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_metric_signal_false_when_terminal_hunk_not_changed() {
|
||||
let diff = DiffType::update(
|
||||
vec![
|
||||
DiffDelta {
|
||||
replacement_line_range: 2..3,
|
||||
insertion: "alpha\nbeta\ngamma\n".to_string(),
|
||||
},
|
||||
DiffDelta {
|
||||
replacement_line_range: 5..6,
|
||||
insertion: "let value = \"unterminated".to_string(),
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(!has_malformed_terminal_correction_signal(
|
||||
&diff,
|
||||
slice::from_ref(&(2..3)),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub(crate) mod ask_user_question_view;
|
||||
pub(super) mod aws_bedrock_credentials_error;
|
||||
pub(crate) mod code_diff_view;
|
||||
pub(crate) mod create_or_edit_document;
|
||||
pub(crate) mod inline_action_header;
|
||||
pub(crate) mod inline_action_icons;
|
||||
mod malformed_line_heuristics;
|
||||
pub(crate) mod requested_action;
|
||||
pub(crate) mod requested_command;
|
||||
pub(crate) mod requested_command_attribution;
|
||||
pub(crate) mod requested_script;
|
||||
pub(super) mod search_codebase;
|
||||
pub(crate) mod search_results_common;
|
||||
pub(crate) mod suggested_unit_tests;
|
||||
pub(super) mod web_fetch;
|
||||
pub(super) mod web_search;
|
||||
@@ -0,0 +1,608 @@
|
||||
//! This module contains rendering functions for various requested inline actions that have not yet
|
||||
//! been transformed into a [`View`] component. This currently encompasses UI for file retrieval,
|
||||
//! environmental variable collection, and SSH Warpification, to name a few.
|
||||
//!
|
||||
//! There's quite a bit of duplication between function-based inline actions and view-based inline
|
||||
//! actions. Moreover, the header rendering functions here don't make use of the HeaderConfig.
|
||||
//!
|
||||
//! Ideally, the modules that currently use the functions herein should be transformed
|
||||
//! into [`View`] components as well. If that's ever deemed necessary, see [`RequestedCommandView`]
|
||||
//! for an example on how that transformation could be made.
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_parser::FormattedText;
|
||||
use markdown_parser::FormattedTextFragment;
|
||||
use markdown_parser::FormattedTextLine;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::borrow::Cow;
|
||||
use std::rc::Rc;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors::neutral_2;
|
||||
use warpui::elements::Align;
|
||||
use warpui::elements::Clipped;
|
||||
use warpui::elements::FormattedTextElement;
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
Hoverable, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Text, Wrap, WrapFill,
|
||||
},
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use super::inline_action_header::HeaderConfig;
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::INLINE_ACTION_VERTICAL_PADDING;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
const REQUESTED_ACTION_CANCEL_LABEL: &str = "Cancel";
|
||||
const REQUESTED_ACTION_RUN_LABEL: &str = "Run";
|
||||
|
||||
const KEYBOARD_SHORTCUT_MARGIN_RIGHT: f32 = 8.;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref ENTER_KEYSTROKE: Keystroke = Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
pub static ref CMD_ENTER_KEYSTROKE: Keystroke =
|
||||
Keystroke::parse("cmdorctrl-enter").expect("RUN_REQUESTED_ACTION_KEYSTROKE is invalid");
|
||||
pub static ref CTRL_C_KEYSTROKE: Keystroke = Keystroke {
|
||||
ctrl: true,
|
||||
key: "c".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
pub static ref ESCAPE_KEYSTROKE: Keystroke = Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) enum FormattedTextOrElement {
|
||||
FormattedText(Box<FormattedTextElement>),
|
||||
Element(Box<dyn Element>),
|
||||
}
|
||||
|
||||
impl From<FormattedTextElement> for FormattedTextOrElement {
|
||||
fn from(value: FormattedTextElement) -> Self {
|
||||
Self::FormattedText(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for rendering a requested action component using the builder pattern.
|
||||
pub struct RenderableAction {
|
||||
body: FormattedTextOrElement,
|
||||
action_button: Option<Box<dyn Element>>,
|
||||
pub icon: Option<Box<dyn Element>>,
|
||||
pub header: Option<HeaderConfig>,
|
||||
pub footer: Option<Box<dyn Element>>,
|
||||
pub background_color: ColorU,
|
||||
pub should_highlight_border: bool,
|
||||
should_override_with_content_item_spacing: bool,
|
||||
}
|
||||
|
||||
impl RenderableAction {
|
||||
pub fn new(text: &str, app: &AppContext) -> Self {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let formatted_text =
|
||||
render_requested_action_body_text(text.into(), appearance.ui_font_family(), app);
|
||||
Self {
|
||||
body: FormattedTextOrElement::FormattedText(Box::new(formatted_text)),
|
||||
icon: None,
|
||||
header: None,
|
||||
footer: None,
|
||||
action_button: None,
|
||||
background_color: neutral_2(theme),
|
||||
should_highlight_border: false,
|
||||
should_override_with_content_item_spacing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_formatted_text(formatted_text: FormattedTextElement, app: &AppContext) -> Self {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
Self {
|
||||
body: FormattedTextOrElement::FormattedText(Box::new(formatted_text)),
|
||||
icon: None,
|
||||
header: None,
|
||||
footer: None,
|
||||
action_button: None,
|
||||
background_color: neutral_2(theme),
|
||||
should_highlight_border: false,
|
||||
should_override_with_content_item_spacing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_element(element: Box<dyn Element>, app: &AppContext) -> Self {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
Self {
|
||||
body: FormattedTextOrElement::Element(element),
|
||||
icon: None,
|
||||
header: None,
|
||||
footer: None,
|
||||
action_button: None,
|
||||
background_color: neutral_2(theme),
|
||||
should_highlight_border: false,
|
||||
should_override_with_content_item_spacing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_icon(mut self, icon: Box<dyn Element>) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_header(mut self, header: HeaderConfig) -> Self {
|
||||
self.header = Some(header);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_footer(mut self, footer: Box<dyn Element>) -> Self {
|
||||
self.footer = Some(footer);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_font_color(mut self, color: ColorU) -> Self {
|
||||
if let FormattedTextOrElement::FormattedText(formatted_text) = self.body {
|
||||
self.body =
|
||||
FormattedTextOrElement::FormattedText(Box::new(formatted_text.with_color(color)));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background_color(mut self, color: ColorU) -> Self {
|
||||
self.background_color = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_highlighted_border(mut self) -> Self {
|
||||
self.should_highlight_border = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_action_button(mut self, button: Box<dyn Element>) -> Self {
|
||||
self.action_button = Some(button);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_content_item_spacing(mut self) -> Self {
|
||||
self.should_override_with_content_item_spacing = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Renders the requested action with the current configuration.
|
||||
pub fn render(self, app: &AppContext) -> Container {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut has_header = false;
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
if let Some(header) = self.header {
|
||||
content.add_child(Clipped::new(header.render(app)).finish());
|
||||
has_header = true;
|
||||
}
|
||||
|
||||
content.add_child(render_requested_action_row(
|
||||
self.body,
|
||||
self.icon,
|
||||
self.action_button,
|
||||
true,
|
||||
has_header,
|
||||
app,
|
||||
));
|
||||
|
||||
if let Some(footer) = self.footer {
|
||||
content.add_child(
|
||||
Container::new(footer)
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(4.)
|
||||
.with_background(theme.surface_1())
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let container = Container::new(content.finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background_color(self.background_color)
|
||||
.with_border(
|
||||
Border::all(1.).with_border_fill(if self.should_highlight_border {
|
||||
theme.accent()
|
||||
} else {
|
||||
theme.surface_2()
|
||||
}),
|
||||
);
|
||||
|
||||
if has_header || self.should_override_with_content_item_spacing {
|
||||
container.finish().with_content_item_spacing()
|
||||
} else {
|
||||
container.finish().with_agent_output_item_spacing(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the buttons representing Run and Cancel on the requested action.
|
||||
/// Note that keyboard events aren't automatically propagated to the buttons.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render_header_buttons(
|
||||
on_accept: impl Fn(&mut EventContext) + 'static,
|
||||
on_cancel: impl Fn(&mut EventContext) + 'static,
|
||||
run_keystroke: &Keystroke,
|
||||
cancel_keystroke: &Keystroke,
|
||||
run_button: &MouseStateHandle,
|
||||
cancel_button: &MouseStateHandle,
|
||||
should_show_accept_button: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
const BUTTON_MARGIN_RIGHT: f32 = 16.;
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let width_required_for_full_size_layout = approx_keystroke_button_width(
|
||||
REQUESTED_ACTION_CANCEL_LABEL,
|
||||
appearance.monospace_font_size(),
|
||||
cancel_keystroke,
|
||||
None,
|
||||
app,
|
||||
) + BUTTON_MARGIN_RIGHT
|
||||
+ approx_keystroke_button_width(
|
||||
REQUESTED_ACTION_RUN_LABEL,
|
||||
appearance.monospace_font_size(),
|
||||
run_keystroke,
|
||||
None,
|
||||
app,
|
||||
)
|
||||
+ INLINE_ACTION_HORIZONTAL_PADDING;
|
||||
|
||||
let compact_button_font_size = (appearance.monospace_font_size() - 2.).max(4.);
|
||||
let compact_button_styles = UiComponentStyles {
|
||||
font_size: Some(compact_button_font_size),
|
||||
..Default::default()
|
||||
};
|
||||
let width_required_for_compact_layout = approx_keystroke_button_width(
|
||||
REQUESTED_ACTION_CANCEL_LABEL,
|
||||
compact_button_font_size,
|
||||
cancel_keystroke,
|
||||
Some(compact_button_styles),
|
||||
app,
|
||||
)
|
||||
.max(approx_keystroke_button_width(
|
||||
REQUESTED_ACTION_RUN_LABEL,
|
||||
compact_button_font_size,
|
||||
run_keystroke,
|
||||
Some(compact_button_styles),
|
||||
app,
|
||||
));
|
||||
|
||||
let cancel_callback = Rc::new(on_cancel);
|
||||
let cancel_clone = Rc::clone(&cancel_callback);
|
||||
let accept_callback = Rc::new(on_accept);
|
||||
let accept_clone = Rc::clone(&accept_callback);
|
||||
|
||||
let mut default_row = Flex::row().with_child(
|
||||
Container::new(render_keyboard_shortcut_button(
|
||||
REQUESTED_ACTION_CANCEL_LABEL,
|
||||
Some(cancel_keystroke.clone()),
|
||||
cancel_button.clone(),
|
||||
cancel_callback,
|
||||
None,
|
||||
app,
|
||||
))
|
||||
.with_margin_right(BUTTON_MARGIN_RIGHT)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut size_constrained_column = Flex::column().with_child(render_keyboard_shortcut_button(
|
||||
REQUESTED_ACTION_CANCEL_LABEL,
|
||||
Some(cancel_keystroke.clone()),
|
||||
cancel_button.clone(),
|
||||
cancel_clone,
|
||||
Some(compact_button_styles),
|
||||
app,
|
||||
));
|
||||
|
||||
if should_show_accept_button {
|
||||
default_row.add_child(render_keyboard_shortcut_button(
|
||||
REQUESTED_ACTION_RUN_LABEL,
|
||||
Some(run_keystroke.clone()),
|
||||
run_button.clone(),
|
||||
accept_callback,
|
||||
None,
|
||||
app,
|
||||
));
|
||||
|
||||
size_constrained_column.add_child(
|
||||
Container::new(render_keyboard_shortcut_button(
|
||||
REQUESTED_ACTION_RUN_LABEL,
|
||||
Some(run_keystroke.clone()),
|
||||
run_button.clone(),
|
||||
accept_clone,
|
||||
Some(compact_button_styles),
|
||||
app,
|
||||
))
|
||||
.with_margin_top(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
SizeConstraintSwitch::new(
|
||||
default_row.finish(),
|
||||
vec![
|
||||
(
|
||||
SizeConstraintCondition::WidthLessThan(width_required_for_compact_layout),
|
||||
Empty::new().finish(),
|
||||
),
|
||||
(
|
||||
SizeConstraintCondition::WidthLessThan(width_required_for_full_size_layout),
|
||||
size_constrained_column.finish(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_requested_action_body_text(
|
||||
text: Cow<str>,
|
||||
font_family: FamilyId,
|
||||
app: &AppContext,
|
||||
) -> FormattedTextElement {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Split text into lines and create FormattedTextLine for each
|
||||
let lines = text
|
||||
.lines()
|
||||
.map(|line| {
|
||||
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(line.to_owned())])
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let formatted_text = FormattedText::new(lines);
|
||||
FormattedTextElement::new(
|
||||
formatted_text.clone(),
|
||||
appearance.monospace_font_size(),
|
||||
font_family,
|
||||
font_family,
|
||||
blended_colors::text_main(theme, theme.background()),
|
||||
Default::default(),
|
||||
)
|
||||
.with_color(blended_colors::text_main(theme, theme.background()))
|
||||
.set_selectable(true)
|
||||
}
|
||||
|
||||
/// Note that [`is_text_selectable`] is used to determine whether text selections are rendered.
|
||||
/// A [`SelectableArea`] ancestor element is required to maintain functional text selection logic.
|
||||
pub fn render_requested_action_row_for_text(
|
||||
text: Cow<str>,
|
||||
font_family: FamilyId,
|
||||
icon: Option<Box<dyn Element>>,
|
||||
acton_button: Option<Box<dyn Element>>,
|
||||
is_text_selectable: bool,
|
||||
has_header_above: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_requested_action_row(
|
||||
render_requested_action_body_text(text, font_family, app).into(),
|
||||
icon,
|
||||
acton_button,
|
||||
is_text_selectable,
|
||||
has_header_above,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a full-width, rounded rectangular row with the specified text and a custom icon.
|
||||
/// Note that [`is_text_selectable`] is used to determine whether text selections are rendered.
|
||||
/// A [`SelectableArea`] ancestor element is required to maintain functional text selection logic.
|
||||
pub(crate) fn render_requested_action_row(
|
||||
text: FormattedTextOrElement,
|
||||
icon: Option<Box<dyn Element>>,
|
||||
action_button: Option<Box<dyn Element>>,
|
||||
is_text_selectable: bool,
|
||||
has_header_above: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let element = match text {
|
||||
FormattedTextOrElement::FormattedText(formatted_text) => {
|
||||
formatted_text.set_selectable(is_text_selectable).finish()
|
||||
}
|
||||
FormattedTextOrElement::Element(element) => element,
|
||||
};
|
||||
render_requested_action_row_for_element(element, icon, action_button, has_header_above, app)
|
||||
}
|
||||
|
||||
/// Renders a full-width, rounded rectangular row with the specified text and a custom icon.
|
||||
/// Note that [`is_text_selectable`] is used to determine whether text selections are rendered.
|
||||
/// A [`SelectableArea`] ancestor element is required to maintain functional text selection logic.
|
||||
fn render_requested_action_row_for_element(
|
||||
element: Box<dyn Element>,
|
||||
icon: Option<Box<dyn Element>>,
|
||||
action_button: Option<Box<dyn Element>>,
|
||||
has_header_above: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let has_action_button = action_button.is_some();
|
||||
|
||||
let mut text_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(icon) = icon {
|
||||
text_row.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_action_header::ICON_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
// When an action button is present we use a Wrap layout (below) so the button
|
||||
// flows to a second row on narrow panes. In that case we must NOT wrap the text
|
||||
// in Align, because Align always reports the full constraint width, which would
|
||||
// inflate the text row and force the button to a new line unconditionally.
|
||||
if has_action_button {
|
||||
text_row.add_child(Shrinkable::new(1., element).finish());
|
||||
} else {
|
||||
text_row.add_child(Shrinkable::new(1., Align::new(element).left().finish()).finish());
|
||||
}
|
||||
|
||||
let content = if let Some(action_button) = action_button {
|
||||
let button_element = Container::new(action_button)
|
||||
.with_margin_right(inline_action_header::ICON_MARGIN)
|
||||
.finish();
|
||||
let mut wrap = Wrap::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_run_spacing(8.);
|
||||
wrap.extend([
|
||||
WrapFill::new(0., text_row.finish()).finish(),
|
||||
button_element,
|
||||
]);
|
||||
wrap.finish()
|
||||
} else {
|
||||
text_row.finish()
|
||||
};
|
||||
|
||||
Container::new(content)
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
// The requested action row is currently overloaded, being used in two distinct ways:
|
||||
// 1) To display the body content of an inline requested action (with a header rendered above)
|
||||
// 2) To display the header of a non-expandable inline requested action
|
||||
.with_vertical_padding(if has_header_above {
|
||||
INLINE_ACTION_VERTICAL_PADDING
|
||||
} else {
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_keyboard_shortcut_button(
|
||||
label: &'static str,
|
||||
keystroke: Option<Keystroke>,
|
||||
mouse_state: MouseStateHandle,
|
||||
on_click: Rc<impl Fn(&mut EventContext) + 'static>,
|
||||
style_overrides: Option<UiComponentStyles>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
Hoverable::new(mouse_state, |mouse_state| {
|
||||
let text_color = if mouse_state.is_hovered() {
|
||||
blended_colors::accent(theme).into_solid()
|
||||
} else {
|
||||
blended_colors::text_main(theme, theme.surface_1())
|
||||
};
|
||||
|
||||
let mut shortcut_styles = UiComponentStyles {
|
||||
font_color: Some(text_color),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
background: Some(neutral_2(theme).into()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(style_overrides) = style_overrides {
|
||||
shortcut_styles = shortcut_styles.merge(style_overrides)
|
||||
}
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
if let Some(keystroke) = keystroke {
|
||||
row.add_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.keyboard_shortcut(&keystroke)
|
||||
.with_style(shortcut_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(KEYBOARD_SHORTCUT_MARGIN_RIGHT)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
row.with_child(
|
||||
Text::new_inline(
|
||||
label,
|
||||
appearance.ui_font_family(),
|
||||
shortcut_styles
|
||||
.font_size
|
||||
.unwrap_or(appearance.monospace_font_size()),
|
||||
)
|
||||
.with_color(text_color)
|
||||
.with_selectable(false)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |ctx, _, _| on_click(ctx))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn approx_keystroke_button_width(
|
||||
label: &str,
|
||||
label_font_size: f32,
|
||||
keystroke: &Keystroke,
|
||||
keystroke_style_overrides: Option<UiComponentStyles>,
|
||||
app: &AppContext,
|
||||
) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let approx_text_width = (label.len() as f32)
|
||||
* app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), label_font_size);
|
||||
let keystroke_len_ems = {
|
||||
let mut len = 0;
|
||||
if keystroke.ctrl {
|
||||
len += 2
|
||||
}
|
||||
if keystroke.alt {
|
||||
len += 2
|
||||
}
|
||||
if keystroke.shift {
|
||||
len += 2
|
||||
}
|
||||
if keystroke.cmd {
|
||||
len += 2
|
||||
}
|
||||
if keystroke.meta {
|
||||
len += 2
|
||||
}
|
||||
len += keystroke.key.len();
|
||||
len
|
||||
};
|
||||
|
||||
let mut keystroke_styles = appearance.ui_builder().default_keyboard_shortcut_styles();
|
||||
if let Some(style_overrides) = keystroke_style_overrides {
|
||||
keystroke_styles = keystroke_styles.merge(style_overrides);
|
||||
}
|
||||
|
||||
let approx_keystroke_width = (keystroke_len_ems as f32)
|
||||
* app.font_cache().em_width(
|
||||
appearance.ui_font_family(),
|
||||
keystroke_styles
|
||||
.font_size
|
||||
.unwrap_or(appearance.ui_font_size()),
|
||||
)
|
||||
+ keystroke_styles
|
||||
.padding
|
||||
.map(|padding| padding.right + padding.left)
|
||||
.unwrap_or(0.)
|
||||
+ keystroke_styles
|
||||
.margin
|
||||
.map(|margin| margin.right + margin.left)
|
||||
.unwrap_or(0.);
|
||||
|
||||
approx_text_width + KEYBOARD_SHORTCUT_MARGIN_RIGHT + approx_keystroke_width
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
//! Module to attribute AI-generated requested commands
|
||||
//! to known documents (e.g. Warp Drive objects).
|
||||
|
||||
use warpui::AppContext;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
use crate::env_vars::EnvVarCollection;
|
||||
use crate::env_vars::EnvVarValue;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::{
|
||||
ai::agent::AIAgentCitation, cloud_object::model::persistence::CloudModel,
|
||||
workflows::command_parser::command_matches_workflow,
|
||||
};
|
||||
use markdown_parser::{parse_markdown, FormattedTextLine};
|
||||
|
||||
/// Returns true iff the `command` is directly copied from the `document`.
|
||||
pub(crate) fn is_command_copied_from_document(
|
||||
command: &str,
|
||||
document: &AIAgentCitation,
|
||||
shell_type: Option<ShellType>,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
let command = command.trim();
|
||||
|
||||
match document {
|
||||
AIAgentCitation::WarpDriveObject { uid } => {
|
||||
is_command_copied_from_warp_drive_object(command, uid, shell_type, ctx)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff the `command` is directly copied from the
|
||||
/// Warp Drive object identified by `object_uid`.
|
||||
fn is_command_copied_from_warp_drive_object(
|
||||
command: &str,
|
||||
object_uid: &str,
|
||||
shell_type: Option<ShellType>,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
if let Some(workflow) = CloudModel::as_ref(ctx).get_workflow_by_uid(object_uid) {
|
||||
command_matches_workflow(command, &workflow.model().data)
|
||||
} else if let Some(notebook) = CloudModel::as_ref(ctx).get_notebook_by_uid(object_uid) {
|
||||
is_command_copied_from_notebook(command, notebook.model())
|
||||
} else if let Some((env_var_collection, shell_type)) = CloudModel::as_ref(ctx)
|
||||
.get_env_var_collection_by_uid(object_uid)
|
||||
.zip(shell_type)
|
||||
{
|
||||
is_command_copied_from_env_var_collection(
|
||||
command,
|
||||
&env_var_collection.model().string_model,
|
||||
shell_type,
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff the `command` was copied directly from one of the
|
||||
/// notebook's code blocks.
|
||||
fn is_command_copied_from_notebook(command: &str, notebook: &CloudNotebookModel) -> bool {
|
||||
let Ok(md) = parse_markdown(notebook.data.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for line in md.lines {
|
||||
if let FormattedTextLine::CodeBlock(code) = line {
|
||||
if command == code.code.trim() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true iff the `command` was copied from one of the env-vars
|
||||
/// in the `collection`.
|
||||
///
|
||||
/// TODO: it'd be ideal to attribute the command to the env-var (collection)
|
||||
/// if the name of the var didn't necessarily match but the value still did.
|
||||
fn is_command_copied_from_env_var_collection(
|
||||
command: &str,
|
||||
collection: &EnvVarCollection,
|
||||
shell_type: ShellType,
|
||||
) -> bool {
|
||||
// Check if the command is an instantiation of all the env-vars in the collection.
|
||||
if collection.export_variables_for_shell(shell_type) == command {
|
||||
return true;
|
||||
}
|
||||
|
||||
for var in &collection.vars {
|
||||
// Check if the env-var is defined as a command and matches the given command exactly.
|
||||
if let EnvVarValue::Command(secret_command) = &var.value {
|
||||
if secret_command.command == command {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the command is an initialization of the specific env-var.
|
||||
let init_string = var.get_initialization_string(shell_type);
|
||||
if init_string == command || init_string.trim_end_matches(";") == command {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "requested_command_attribution_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::env_vars::{view::command_dialog::EnvVarSecretCommand, EnvVar};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_command_copied_from_notebook() {
|
||||
let notebook = CloudNotebookModel {
|
||||
title: String::from(""),
|
||||
data: String::from("hello world\n```\n foobar \n```\n"),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
assert!(!is_command_copied_from_notebook("hello world", ¬ebook));
|
||||
assert!(!is_command_copied_from_notebook("foo", ¬ebook));
|
||||
assert!(is_command_copied_from_notebook("foobar", ¬ebook));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_command_copied_from_env_var_collection() {
|
||||
let shell_type = ShellType::Zsh;
|
||||
let collection = EnvVarCollection {
|
||||
title: None,
|
||||
description: None,
|
||||
vars: vec![
|
||||
EnvVar {
|
||||
name: String::from("GCP_TOKEN"),
|
||||
description: None,
|
||||
value: EnvVarValue::Command(EnvVarSecretCommand {
|
||||
name: String::from("gcloud cmd"),
|
||||
command: String::from("gcloud print auth token"),
|
||||
}),
|
||||
},
|
||||
EnvVar {
|
||||
name: String::from("OPENAI_KEY"),
|
||||
description: None,
|
||||
value: EnvVarValue::Command(EnvVarSecretCommand {
|
||||
name: String::from("openai"),
|
||||
command: String::from("openai --token"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(!is_command_copied_from_env_var_collection(
|
||||
"gcloud",
|
||||
&collection,
|
||||
shell_type
|
||||
));
|
||||
assert!(is_command_copied_from_env_var_collection(
|
||||
"gcloud print auth token",
|
||||
&collection,
|
||||
shell_type
|
||||
));
|
||||
assert!(is_command_copied_from_env_var_collection(
|
||||
"export GCP_TOKEN=$(gcloud print auth token)",
|
||||
&collection,
|
||||
shell_type
|
||||
));
|
||||
|
||||
// TODO(suraj): enable this after fixing how we export env-vars
|
||||
// assert!(is_command_copied_from_env_var_collection(
|
||||
// "export GCP_TOKEN=$(gcloud print auth token); export OPENAI_KEY=$(openai --token);",
|
||||
// &collection,
|
||||
// shell_type
|
||||
// ));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Unit tests for format_command_text in requested_command.rs
|
||||
|
||||
use super::format_command_text;
|
||||
|
||||
#[test]
|
||||
fn single_line_without_newline_is_unchanged_ascii() {
|
||||
let input = "echo hello world";
|
||||
let output = format_command_text(input);
|
||||
assert_eq!(output, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_without_newline_preserves_multibyte_characters() {
|
||||
let input = "echo 🚀✨";
|
||||
let output = format_command_text(input);
|
||||
assert_eq!(output, input);
|
||||
|
||||
// Additional sanity check: string is valid UTF-8 and can be iterated by chars without panic
|
||||
let collected: String = output.chars().collect();
|
||||
assert_eq!(collected, output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_at_first_newline_and_appends_ellipsis_when_more_content_exists() {
|
||||
let input = "cargo build\n--release";
|
||||
let output = format_command_text(input);
|
||||
assert_eq!(output, "cargo build…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_at_first_newline_without_ellipsis_when_rest_is_whitespace() {
|
||||
let input = "git status\n \t ";
|
||||
let output = format_command_text(input);
|
||||
assert_eq!(output, "git status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_split_multibyte_char_across_utf8_boundaries_when_newline_follows() {
|
||||
// The emoji is a multi-byte sequence; ensure truncation at the newline does not split it.
|
||||
let input = "echo 🧪\nthen do something";
|
||||
let output = format_command_text(input);
|
||||
assert_eq!(output, "echo 🧪…");
|
||||
|
||||
// Validate resulting string is valid UTF-8 by iterating graphemes via chars
|
||||
let reconstructed: String = output.chars().collect();
|
||||
assert_eq!(reconstructed, output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_combining_characters_when_newline_is_after_cluster() {
|
||||
// "e" + combining acute accent
|
||||
// Sanity checks that the formatter doesn't split this unicode sequence
|
||||
let composed = format!("{}{}", 'e', '\u{0301}');
|
||||
let input = format!("echo {composed}\nnext");
|
||||
let output = format_command_text(&input);
|
||||
assert_eq!(output, format!("echo {composed}…"));
|
||||
|
||||
// Still valid UTF-8 and same when re-collected from chars
|
||||
let reconstructed: String = output.chars().collect();
|
||||
assert_eq!(reconstructed, output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_then_multibyte_results_in_ellipsis_only() {
|
||||
let input = "\n🚀";
|
||||
let output = format_command_text(input);
|
||||
assert_eq!(output, "…");
|
||||
|
||||
// Sanity: output remains valid UTF-8
|
||||
let reconstructed: String = output.chars().collect();
|
||||
assert_eq!(reconstructed, output);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! This module is similar to `requested_actions.rs` but specifically for scripts instead of commands.
|
||||
//! Eventually, we'll probably want to refactor the functions here to lean on Views instead.
|
||||
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::rc::Rc;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
Wrap,
|
||||
};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::toggle_menu::{
|
||||
ToggleMenuCallback, ToggleMenuItem, ToggleMenuStateHandle,
|
||||
};
|
||||
use warpui::{AppContext, Element, EventContext, SingletonEntity};
|
||||
|
||||
use super::inline_action_header::{
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
use super::requested_action::render_header_buttons;
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::inline_action::requested_action::render_requested_action_row_for_text;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
pub struct TitledScript {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RequestedScriptStatus {
|
||||
/// The action is loaded and is waiting to be acted upon by the user.
|
||||
WaitingForUser,
|
||||
/// The action is currently running, after being accepted by the user.
|
||||
Running,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct RequestedScriptMouseStates {
|
||||
pub show_hide_button: MouseStateHandle,
|
||||
pub cancel_button: MouseStateHandle,
|
||||
pub run_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Creates a requested script element that gives the option to hide or show the script.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_requested_script(
|
||||
header: &str,
|
||||
script: &str,
|
||||
status: RequestedScriptStatus,
|
||||
is_collapsed: bool,
|
||||
is_viewing_detail: bool,
|
||||
on_toggle_expanded: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
|
||||
on_accept: impl Fn(&mut EventContext) + 'static,
|
||||
on_cancel: impl Fn(&mut EventContext) + 'static,
|
||||
run_keystroke: &Keystroke,
|
||||
cancel_keystroke: &Keystroke,
|
||||
button_states: &RequestedScriptMouseStates,
|
||||
should_highlight_border: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
let is_executing = matches!(status, RequestedScriptStatus::Running);
|
||||
let should_show_accept_button = matches!(status, RequestedScriptStatus::WaitingForUser);
|
||||
|
||||
// The current status of the script: whether it is executing, expanded, or collapsed.
|
||||
let (action_text, icon) = script_status(is_executing, is_collapsed, is_viewing_detail, app);
|
||||
|
||||
content.add_child(render_header(
|
||||
Text::new(
|
||||
header.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.main_text_color(theme.surface_1()).into())
|
||||
.finish(),
|
||||
on_accept,
|
||||
on_cancel,
|
||||
run_keystroke,
|
||||
cancel_keystroke,
|
||||
button_states,
|
||||
should_show_accept_button,
|
||||
!is_executing,
|
||||
app,
|
||||
));
|
||||
|
||||
content.add_child(
|
||||
Hoverable::new(button_states.show_hide_button.clone(), |_| {
|
||||
render_requested_action_row_for_text(
|
||||
action_text.into(),
|
||||
appearance.ui_font_family(),
|
||||
Some(icon),
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(on_toggle_expanded)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// If expanded, the contents of the script.
|
||||
if !is_collapsed {
|
||||
content.add_child(render_requested_action_row_for_text(
|
||||
script.into(),
|
||||
appearance.ui_font_family(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
let border_color =
|
||||
if should_highlight_border && matches!(status, RequestedScriptStatus::WaitingForUser) {
|
||||
theme.accent().into_solid()
|
||||
} else {
|
||||
theme.surface_2().into_solid()
|
||||
};
|
||||
|
||||
content
|
||||
.finish()
|
||||
.with_content_item_spacing()
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background_color(theme.background().into_solid())
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Creates a requested script element that gives the option to hide or show the scripts.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_requested_scripts(
|
||||
first_script: TitledScript,
|
||||
second_script: TitledScript,
|
||||
is_first_script_active: bool,
|
||||
status: RequestedScriptStatus,
|
||||
is_collapsed: bool,
|
||||
is_viewing_detail: bool,
|
||||
on_toggle_expanded: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
|
||||
on_accept: impl Fn(&mut EventContext) + 'static,
|
||||
on_cancel: impl Fn(&mut EventContext) + 'static,
|
||||
run_keystroke: &Keystroke,
|
||||
cancel_keystroke: &Keystroke,
|
||||
button_states: &RequestedScriptMouseStates,
|
||||
toggle_menu_mouse_states: Vec<MouseStateHandle>,
|
||||
toggle_menu_state_handle: ToggleMenuStateHandle,
|
||||
on_toggle_change: Rc<ToggleMenuCallback>,
|
||||
should_highlight_border: bool,
|
||||
toggle_menu_width: f32,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
let is_executing = matches!(status, RequestedScriptStatus::Running);
|
||||
let should_show_accept_button = matches!(status, RequestedScriptStatus::WaitingForUser);
|
||||
|
||||
// The current status of the script: whether it is executing, expanded, or collapsed.
|
||||
let (action_text, icon) = script_status(is_executing, is_collapsed, is_viewing_detail, app);
|
||||
|
||||
let selected_color = theme.surface_3();
|
||||
let toggle_menu: Box<dyn Element> = ConstrainedBox::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.toggle_menu(
|
||||
toggle_menu_mouse_states,
|
||||
vec![
|
||||
ToggleMenuItem::new(first_script.title.clone()),
|
||||
ToggleMenuItem::new(second_script.title.clone()),
|
||||
],
|
||||
toggle_menu_state_handle,
|
||||
Some((!is_first_script_active).into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
appearance.ui_font_size(),
|
||||
on_toggle_change,
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
top: 10.,
|
||||
right: 12.,
|
||||
bottom: 10.,
|
||||
left: 12.,
|
||||
}),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(0.))),
|
||||
background: Some(theme.background().into()),
|
||||
border_color: Some(selected_color.into()),
|
||||
margin: Some(Coords {
|
||||
top: 0.,
|
||||
right: 0.,
|
||||
bottom: 0.,
|
||||
left: 0.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_disabled(is_executing)
|
||||
.build()
|
||||
.with_uniform_padding(0.)
|
||||
.with_border(Border::all(2.).with_border_fill(selected_color.into_solid()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(toggle_menu_width)
|
||||
.finish();
|
||||
|
||||
content.add_child(render_header(
|
||||
toggle_menu,
|
||||
on_accept,
|
||||
on_cancel,
|
||||
run_keystroke,
|
||||
cancel_keystroke,
|
||||
button_states,
|
||||
should_show_accept_button,
|
||||
!is_executing,
|
||||
app,
|
||||
));
|
||||
|
||||
content.add_child(
|
||||
Hoverable::new(button_states.show_hide_button.clone(), |_| {
|
||||
render_requested_action_row_for_text(
|
||||
action_text.into(),
|
||||
appearance.ui_font_family(),
|
||||
Some(icon),
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(on_toggle_expanded)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// If expanded, the contents of the script.
|
||||
if !is_collapsed {
|
||||
let script = if is_first_script_active {
|
||||
first_script.content
|
||||
} else {
|
||||
second_script.content
|
||||
};
|
||||
content.add_child(render_requested_action_row_for_text(
|
||||
script.into(),
|
||||
appearance.ui_font_family(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
let border_color =
|
||||
if should_highlight_border && matches!(status, RequestedScriptStatus::WaitingForUser) {
|
||||
theme.accent().into_solid()
|
||||
} else {
|
||||
theme.surface_2().into_solid()
|
||||
};
|
||||
|
||||
content
|
||||
.finish()
|
||||
.with_content_item_spacing()
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background_color(theme.background().into_solid())
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the header for the requested script, with optional action buttons.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_header(
|
||||
header: Box<dyn Element>,
|
||||
on_accept: impl Fn(&mut EventContext) + 'static,
|
||||
on_cancel: impl Fn(&mut EventContext) + 'static,
|
||||
run_keystroke: &Keystroke,
|
||||
cancel_keystroke: &Keystroke,
|
||||
mouse_states: &RequestedScriptMouseStates,
|
||||
should_show_accept_button: bool,
|
||||
is_interactive: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut row = Wrap::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_run_spacing(8.)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(3., header).finish());
|
||||
|
||||
if is_interactive {
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Align::new(render_header_buttons(
|
||||
on_accept,
|
||||
on_cancel,
|
||||
run_keystroke,
|
||||
cancel_keystroke,
|
||||
&mouse_states.run_button,
|
||||
&mouse_states.cancel_button,
|
||||
should_show_accept_button,
|
||||
app,
|
||||
))
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_background(theme.surface_1())
|
||||
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Helper function to determine the correct contents for the first row in the requested script
|
||||
/// element. The contents consist of a label and an icon.
|
||||
fn script_status(
|
||||
is_executing: bool,
|
||||
is_collapsed: bool,
|
||||
is_viewing_detail: bool,
|
||||
app: &AppContext,
|
||||
) -> (&str, Box<dyn Element>) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let label = match (is_executing, is_collapsed) {
|
||||
(true, _) => "Running...",
|
||||
(false, true) => "Expand to show script",
|
||||
(false, false) => "Hide",
|
||||
};
|
||||
let is_expanded = (is_executing && is_viewing_detail) || (!is_executing && !is_collapsed);
|
||||
let icon = ConstrainedBox::new(
|
||||
warpui::elements::Icon::new(
|
||||
if is_expanded {
|
||||
Icon::ChevronDown.into()
|
||||
} else {
|
||||
Icon::ChevronRight.into()
|
||||
},
|
||||
appearance.theme().foreground(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
(label, icon)
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::ui_components::components::UiComponentStyles;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Container, CornerRadius, CrossAxisAlignment, Element, Flex, FormattedTextElement,
|
||||
MainAxisAlignment, ParentElement, Radius, SelectableArea, SelectionHandle, Shrinkable,
|
||||
},
|
||||
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::search_results_common::{
|
||||
render_collapsible_search_results, CollapsibleSearchResultsState,
|
||||
};
|
||||
use crate::ai::blocklist::TextLocation;
|
||||
use crate::ai::{
|
||||
agent::icons::yellow_running_icon,
|
||||
blocklist::inline_action::{
|
||||
inline_action_header::{
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
},
|
||||
inline_action_icons::cancelled_icon,
|
||||
},
|
||||
};
|
||||
use crate::ai::{
|
||||
agent::FileContext,
|
||||
blocklist::{
|
||||
action_model::AIActionStatus,
|
||||
block::{
|
||||
find::FindState,
|
||||
secret_redaction::SecretRedactionState,
|
||||
view_impl::{
|
||||
output::{
|
||||
render_read_files_text, LinkActionConstructors, RenderContext,
|
||||
RenderReadFileArg,
|
||||
},
|
||||
FindContext, WithContentItemSpacing,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
use crate::terminal::view::RichContentLink;
|
||||
use crate::terminal::{find::TerminalFindModel, ShellLaunchData};
|
||||
use crate::util::link_detection::{
|
||||
detect_links, DetectedLinkType, DetectedLinksState, LinkLocation,
|
||||
};
|
||||
|
||||
pub enum SearchCodebaseViewEvent {
|
||||
OpenLinkTooltip {
|
||||
rich_content_link: RichContentLink,
|
||||
},
|
||||
#[cfg(feature = "local_fs")]
|
||||
OpenDetectedFilePath {
|
||||
absolute_path: std::path::PathBuf,
|
||||
line_and_column_num: Option<warp_util::path::LineAndColumnArg>,
|
||||
},
|
||||
TextSelected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SearchCodebaseViewAction {
|
||||
ToggleExpanded,
|
||||
OpenLink {
|
||||
link_range: Range<usize>,
|
||||
location: TextLocation,
|
||||
},
|
||||
ChangedHoverOnLink {
|
||||
link_range: Range<usize>,
|
||||
location: TextLocation,
|
||||
is_hovering: bool,
|
||||
},
|
||||
OpenLinkTooltip {
|
||||
location: TextLocation,
|
||||
link_range: Range<usize>,
|
||||
},
|
||||
SelectText,
|
||||
}
|
||||
pub struct SearchCodebaseView {
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
current_working_directory: Option<String>,
|
||||
detected_links_state: DetectedLinksState,
|
||||
secret_redaction_state: SecretRedactionState,
|
||||
find_model: ModelHandle<TerminalFindModel>,
|
||||
find_state: FindState,
|
||||
file_contexts: Vec<FileContext>,
|
||||
search_query: String,
|
||||
repo_name: Option<String>,
|
||||
collapsible: CollapsibleSearchResultsState,
|
||||
selection_handle: SelectionHandle,
|
||||
selected_text: Arc<RwLock<Option<String>>>,
|
||||
action_index: usize,
|
||||
status: Option<AIActionStatus>,
|
||||
}
|
||||
|
||||
impl SearchCodebaseView {
|
||||
pub fn new(
|
||||
find_model: ModelHandle<TerminalFindModel>,
|
||||
file_contexts: Vec<FileContext>,
|
||||
search_query: String,
|
||||
repo_path: Option<String>,
|
||||
shell_launch_data: &Option<ShellLaunchData>,
|
||||
current_working_directory: &Option<String>,
|
||||
action_index: usize,
|
||||
) -> Self {
|
||||
let repo_name = repo_path.as_ref().and_then(|path| {
|
||||
std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
Self {
|
||||
detected_links_state: DetectedLinksState::default(),
|
||||
secret_redaction_state: Default::default(),
|
||||
find_model,
|
||||
find_state: FindState::default(),
|
||||
file_contexts,
|
||||
search_query,
|
||||
repo_name,
|
||||
collapsible: CollapsibleSearchResultsState::new(),
|
||||
selection_handle: SelectionHandle::default(),
|
||||
selected_text: Arc::new(RwLock::new(None)),
|
||||
action_index,
|
||||
status: None,
|
||||
shell_launch_data: shell_launch_data.clone(),
|
||||
current_working_directory: current_working_directory.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_status(&mut self, status: Option<AIActionStatus>) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
pub fn update_render_read_file_args(
|
||||
&mut self,
|
||||
find_state: &FindState,
|
||||
file_contexts: Vec<FileContext>,
|
||||
status: Option<AIActionStatus>,
|
||||
) {
|
||||
self.find_state = find_state.clone();
|
||||
self.file_contexts = file_contexts;
|
||||
self.status = status;
|
||||
|
||||
self.detected_links_state.detected_links_by_location.clear();
|
||||
for (line_index, file_context) in self.file_contexts.iter().enumerate() {
|
||||
let text_location = TextLocation::Action {
|
||||
action_index: self.action_index,
|
||||
line_index,
|
||||
};
|
||||
let file_display = file_context.to_string();
|
||||
detect_links(
|
||||
&mut self.detected_links_state,
|
||||
&file_display,
|
||||
text_location,
|
||||
self.current_working_directory.as_ref(),
|
||||
self.shell_launch_data.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_finished(
|
||||
&self,
|
||||
_appearance: &Appearance,
|
||||
file_contexts: &[FileContext],
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let title_text = if let Some(repo_name) = &self.repo_name {
|
||||
format!("Searched for \"{}\" in {}", self.search_query, repo_name)
|
||||
} else {
|
||||
format!("Searched for \"{}\"", self.search_query)
|
||||
};
|
||||
|
||||
let body = if self.collapsible.is_expanded {
|
||||
Some(self.render_results_body(file_contexts, app))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
render_collapsible_search_results(
|
||||
title_text,
|
||||
file_contexts.len(),
|
||||
"results",
|
||||
&self.collapsible,
|
||||
body,
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(SearchCodebaseViewAction::ToggleExpanded);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_results_body(
|
||||
&self,
|
||||
file_contexts: &[FileContext],
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let render_read_file_args = RenderReadFileArg::new(
|
||||
RenderContext {
|
||||
shell_launch_data: self.shell_launch_data.as_ref(),
|
||||
current_working_directory: self.current_working_directory.as_ref(),
|
||||
detected_links_state: &self.detected_links_state,
|
||||
secret_redaction_state: &self.secret_redaction_state,
|
||||
},
|
||||
self.find_model
|
||||
.as_ref(app)
|
||||
.is_find_bar_open()
|
||||
.then_some(FindContext {
|
||||
model: self.find_model.as_ref(app),
|
||||
state: &self.find_state,
|
||||
}),
|
||||
self.selection_handle.is_selecting(),
|
||||
LinkActionConstructors {
|
||||
construct_open_link_action: |link_range, location| {
|
||||
SearchCodebaseViewAction::OpenLink {
|
||||
link_range,
|
||||
location,
|
||||
}
|
||||
},
|
||||
construct_open_link_tooltip_action: |link_range, location| {
|
||||
SearchCodebaseViewAction::OpenLinkTooltip {
|
||||
link_range,
|
||||
location,
|
||||
}
|
||||
},
|
||||
construct_changed_hover_on_link_action: |link_range, location, is_hovering| {
|
||||
SearchCodebaseViewAction::ChangedHoverOnLink {
|
||||
link_range,
|
||||
location,
|
||||
is_hovering,
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let files: Box<dyn Element> = if file_contexts.is_empty() {
|
||||
let no_results_style = UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
..Default::default()
|
||||
};
|
||||
self.render_formatted_text("No results found".to_string(), no_results_style, appearance)
|
||||
} else {
|
||||
render_read_files_text(
|
||||
render_read_file_args,
|
||||
file_contexts.iter().map(|fc| fc.to_string()),
|
||||
app,
|
||||
appearance,
|
||||
self.action_index,
|
||||
)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let selected_text = self.selected_text.clone();
|
||||
SelectableArea::new(
|
||||
self.selection_handle.clone(),
|
||||
#[allow(clippy::unwrap_used)]
|
||||
move |selection_args, _, _| {
|
||||
*selected_text.write().unwrap() = selection_args.selection;
|
||||
},
|
||||
files,
|
||||
)
|
||||
.on_selection_updated(|ctx, _| {
|
||||
ctx.dispatch_typed_action(SearchCodebaseViewAction::SelectText);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn create_header_row(&self) -> Flex {
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
}
|
||||
|
||||
fn create_header_text_style(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
header_background: warp_core::ui::theme::Fill,
|
||||
) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(header_background)
|
||||
.into_solid(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_header_container(
|
||||
&self,
|
||||
header_row: Box<dyn Element>,
|
||||
header_background: warp_core::ui::theme::Fill,
|
||||
corner_radius: CornerRadius,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
Container::new(header_row)
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_background(header_background)
|
||||
.with_corner_radius(corner_radius)
|
||||
.finish()
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_simple_header(&self, text: String, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header_background = theme.surface_2();
|
||||
let text_style = self.create_header_text_style(appearance, header_background);
|
||||
|
||||
let mut header_row = self.create_header_row();
|
||||
|
||||
let title_element = self.render_formatted_text(text, text_style, appearance);
|
||||
|
||||
header_row.add_child(Shrinkable::new(1.0, title_element).finish());
|
||||
|
||||
self.create_header_container(
|
||||
header_row.finish(),
|
||||
header_background,
|
||||
CornerRadius::with_all(Radius::Pixels(8.)),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_header(
|
||||
&self,
|
||||
_appearance: &Appearance,
|
||||
text: String,
|
||||
icon: warpui::elements::Icon,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
super::search_results_common::render_loading_header(text, icon, app)
|
||||
}
|
||||
|
||||
pub fn clear_link_tooltip(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.detected_links_state.link_location_open_tooltip = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self, _ctx: &mut ViewContext<Self>) {
|
||||
self.selection_handle.clear();
|
||||
}
|
||||
|
||||
pub fn selected_text(&self, _ctx: &AppContext) -> Option<String> {
|
||||
self.selected_text.read().unwrap().clone()
|
||||
}
|
||||
|
||||
fn render_formatted_text(
|
||||
&self,
|
||||
text: String,
|
||||
style: UiComponentStyles,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let font_family = style
|
||||
.font_family_id
|
||||
.unwrap_or_else(|| appearance.ui_font_family());
|
||||
let font_size = style.font_size.unwrap_or_else(|| appearance.ui_font_size());
|
||||
let text_color = style.font_color.unwrap_or_else(|| {
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_1())
|
||||
.into_solid()
|
||||
});
|
||||
|
||||
FormattedTextElement::from_str(text, font_family, font_size)
|
||||
.with_color(text_color)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SearchCodebaseView {
|
||||
type Event = SearchCodebaseViewEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for SearchCodebaseView {
|
||||
type Action = SearchCodebaseViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SearchCodebaseViewAction::ToggleExpanded => {
|
||||
self.collapsible.toggle_expanded();
|
||||
ctx.notify();
|
||||
}
|
||||
SearchCodebaseViewAction::OpenLink {
|
||||
link_range,
|
||||
location,
|
||||
} => {
|
||||
if self
|
||||
.detected_links_state
|
||||
.link_at(location, link_range)
|
||||
.is_some()
|
||||
{
|
||||
match self.detected_links_state.link_at(location, link_range) {
|
||||
Some(DetectedLinkType::Url(link)) => {
|
||||
ctx.open_url(link);
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
Some(DetectedLinkType::FilePath {
|
||||
absolute_path,
|
||||
line_and_column_num,
|
||||
}) => ctx.emit(SearchCodebaseViewEvent::OpenDetectedFilePath {
|
||||
absolute_path: absolute_path.clone(),
|
||||
line_and_column_num: *line_and_column_num,
|
||||
}),
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchCodebaseViewAction::ChangedHoverOnLink {
|
||||
link_range,
|
||||
location,
|
||||
is_hovering,
|
||||
} => {
|
||||
self.detected_links_state.update_hovered_link(
|
||||
*is_hovering,
|
||||
self.selection_handle.is_selecting(),
|
||||
link_range,
|
||||
location,
|
||||
);
|
||||
}
|
||||
SearchCodebaseViewAction::OpenLinkTooltip {
|
||||
link_range,
|
||||
location,
|
||||
} => {
|
||||
if let Some(link_type) = self.detected_links_state.link_at(location, link_range) {
|
||||
let rich_content_link: RichContentLink = match link_type {
|
||||
DetectedLinkType::Url(link) => RichContentLink::Url(link.clone()),
|
||||
#[cfg(feature = "local_fs")]
|
||||
DetectedLinkType::FilePath {
|
||||
absolute_path,
|
||||
line_and_column_num,
|
||||
} => RichContentLink::FilePath {
|
||||
absolute_path: absolute_path.to_owned(),
|
||||
line_and_column_num: *line_and_column_num,
|
||||
target_override: None,
|
||||
},
|
||||
};
|
||||
|
||||
self.detected_links_state.link_location_open_tooltip = Some(LinkLocation {
|
||||
link_range: link_range.clone(),
|
||||
location: *location,
|
||||
});
|
||||
|
||||
ctx.emit(SearchCodebaseViewEvent::OpenLinkTooltip { rich_content_link });
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
SearchCodebaseViewAction::SelectText => {
|
||||
ctx.emit(SearchCodebaseViewEvent::TextSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for SearchCodebaseView {
|
||||
fn ui_name() -> &'static str {
|
||||
"SearchCodebaseView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
match &self.status {
|
||||
Some(
|
||||
AIActionStatus::Preprocessing
|
||||
| AIActionStatus::Queued
|
||||
| AIActionStatus::RunningAsync,
|
||||
) => {
|
||||
let loading_text = if let Some(repo_name) = &self.repo_name {
|
||||
format!("Searching for \"{}\" in {}", self.search_query, repo_name)
|
||||
} else {
|
||||
format!("Searching codebase for \"{}\"", self.search_query)
|
||||
};
|
||||
let loading_icon = yellow_running_icon(appearance);
|
||||
self.render_header(appearance, loading_text, loading_icon, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
Some(AIActionStatus::Finished(result)) if result.result.is_cancelled() => {
|
||||
let cancelled_text = if let Some(repo_name) = &self.repo_name {
|
||||
format!(
|
||||
"Search for \"{}\" in {} cancelled",
|
||||
self.search_query, repo_name
|
||||
)
|
||||
} else {
|
||||
format!("Search for \"{}\" cancelled", self.search_query)
|
||||
};
|
||||
let cancelled_icon = cancelled_icon(appearance);
|
||||
self.render_header(appearance, cancelled_text, cancelled_icon, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
Some(AIActionStatus::Finished(_)) => self
|
||||
.render_finished(appearance, &self.file_contexts, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish(),
|
||||
_ => {
|
||||
let text = if let Some(repo_name) = &self.repo_name {
|
||||
format!(
|
||||
"Searched codebase for \"{}\" in {}",
|
||||
self.search_query, repo_name
|
||||
)
|
||||
} else {
|
||||
format!("Searched codebase for \"{}\"", self.search_query)
|
||||
};
|
||||
self.render_simple_header(text, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{AppContext, EventContext, SingletonEntity};
|
||||
|
||||
use super::inline_action_header::{
|
||||
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
use super::inline_action_icons::icon_size;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
pub fn render_search_results_header(
|
||||
title_text: String,
|
||||
right_label_text: String,
|
||||
is_expanded: bool,
|
||||
mouse_state: warpui::elements::MouseStateHandle,
|
||||
on_toggle: impl Fn(&mut EventContext) + 'static,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header_background = theme.surface_2();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
// Left side: icon + title
|
||||
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
let search_icon = ConstrainedBox::new(
|
||||
warpui::elements::Icon::new(
|
||||
Icon::SearchSmall.into(),
|
||||
appearance.theme().main_text_color(header_background),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size(app) - 4.)
|
||||
.with_height(icon_size(app) - 4.)
|
||||
.finish();
|
||||
left_side.add_child(
|
||||
Container::new(search_icon)
|
||||
.with_margin_right(ICON_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let title = Text::new_inline(
|
||||
title_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().main_text_color(header_background).into())
|
||||
.finish();
|
||||
left_side.add_child(Shrinkable::new(1.0, title).finish());
|
||||
header_row.add_child(Shrinkable::new(1.0, left_side.finish()).finish());
|
||||
|
||||
// Right side: results label + chevron
|
||||
let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
let right_label = Text::new_inline(
|
||||
right_label_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().sub_text_color(header_background).into())
|
||||
.finish();
|
||||
right_side.add_child(Container::new(right_label).with_margin_right(8.).finish());
|
||||
|
||||
let chevron_icon = ConstrainedBox::new(
|
||||
warpui::elements::Icon::new(
|
||||
if is_expanded {
|
||||
Icon::ChevronDown.into()
|
||||
} else {
|
||||
Icon::ChevronRight.into()
|
||||
},
|
||||
appearance.theme().sub_text_color(header_background),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
right_side.add_child(chevron_icon);
|
||||
|
||||
let inner = Container::new(header_row.with_child(right_side.finish()).finish())
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_background(header_background)
|
||||
.with_corner_radius(if is_expanded {
|
||||
CornerRadius::with_top(Radius::Pixels(8.))
|
||||
} else {
|
||||
CornerRadius::with_all(Radius::Pixels(8.))
|
||||
})
|
||||
.finish();
|
||||
|
||||
warpui::elements::Hoverable::new(mouse_state, |_| inner)
|
||||
.on_click(move |ctx, _, _| {
|
||||
on_toggle(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_results_body_container(body: Box<dyn Element>, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Container::new(body)
|
||||
.with_background(appearance.theme().surface_1())
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_loading_header(
|
||||
text: String,
|
||||
icon: warpui::elements::Icon,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let header_background = theme.surface_2();
|
||||
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let icon_box = ConstrainedBox::new(icon.finish())
|
||||
.with_width(icon_size(app))
|
||||
.with_height(icon_size(app))
|
||||
.finish();
|
||||
header_row.add_child(
|
||||
Container::new(icon_box)
|
||||
.with_margin_right(ICON_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let title = Text::new_inline(
|
||||
text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().main_text_color(header_background).into())
|
||||
.finish();
|
||||
header_row.add_child(Shrinkable::new(1.0, title).finish());
|
||||
|
||||
Container::new(header_row.finish())
|
||||
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
|
||||
.with_background(header_background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_expanded_layout(
|
||||
header: Box<dyn Element>,
|
||||
body_container: Box<dyn Element>,
|
||||
) -> Box<dyn Element> {
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(header)
|
||||
.with_child(body_container)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// A composable helper for managing collapsible search results state.
|
||||
/// Parent views should store this and call `toggle_expanded()` on user interaction.
|
||||
pub struct CollapsibleSearchResultsState {
|
||||
pub is_expanded: bool,
|
||||
pub mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl CollapsibleSearchResultsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
is_expanded: false,
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_expanded(&mut self) {
|
||||
self.is_expanded = !self.is_expanded;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CollapsibleSearchResultsState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a complete collapsible search results view with header and optional body.
|
||||
/// This is a convenience wrapper around the individual rendering functions.
|
||||
/// The on_toggle callback will be called when the user clicks the header.
|
||||
pub fn render_collapsible_search_results<F>(
|
||||
title_text: String,
|
||||
results_count: usize,
|
||||
results_label: &str,
|
||||
state: &CollapsibleSearchResultsState,
|
||||
body: Option<Box<dyn Element>>,
|
||||
on_toggle: F,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: Fn(&mut EventContext) + 'static,
|
||||
{
|
||||
let right_label = format!("{results_count} {results_label}");
|
||||
|
||||
let header = render_search_results_header(
|
||||
title_text,
|
||||
right_label,
|
||||
state.is_expanded,
|
||||
state.mouse_state.clone(),
|
||||
on_toggle,
|
||||
app,
|
||||
);
|
||||
|
||||
if state.is_expanded {
|
||||
if let Some(body_content) = body {
|
||||
let body_container = render_results_body_container(body_content, app);
|
||||
render_expanded_layout(header, body_container)
|
||||
} else {
|
||||
header
|
||||
}
|
||||
} else {
|
||||
header
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng as _};
|
||||
use warp_core::{settings::ToggleableSetting, ui::appearance::Appearance};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ConstrainedBox, Container, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
SavePosition, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{AIAgentActionId, AIIdentifiers},
|
||||
predict::prompt_suggestions::{
|
||||
ACCEPT_PROMPT_SUGGESTION_KEYBINDING, REJECT_PROMPT_SUGGESTION_KEYSTROKE,
|
||||
},
|
||||
},
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::ToggleCodeSuggestionsSettingSource,
|
||||
settings::AISettings,
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
view_components::{
|
||||
action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
|
||||
compactible_action_button::{
|
||||
render_compact_and_regular_button_rows, CompactibleActionButton,
|
||||
MEDIUM_SIZE_SWITCH_THRESHOLD,
|
||||
},
|
||||
},
|
||||
TelemetryEvent,
|
||||
};
|
||||
|
||||
const ACCEPT_LABEL: &str = "Generate tests";
|
||||
const CANCEL_LABEL: &str = "Dismiss";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SuggestedUnitTestsEvent {
|
||||
Accept,
|
||||
Cancel,
|
||||
Blur,
|
||||
OpenSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SuggestedUnitTestsAction {
|
||||
Accept,
|
||||
Cancel,
|
||||
ToggleSetting,
|
||||
OpenSettings,
|
||||
}
|
||||
|
||||
pub struct SuggestedUnitTestsView {
|
||||
/// Client and server identifiers for the AI output associated with the suggested prompt.
|
||||
identifiers: AIIdentifiers,
|
||||
action_id: AIAgentActionId,
|
||||
|
||||
is_hidden: bool,
|
||||
is_keybindings_hidden: bool,
|
||||
should_show_speedbump: bool,
|
||||
title: String,
|
||||
description: String,
|
||||
query: String,
|
||||
accept_button: CompactibleActionButton,
|
||||
cancel_button: CompactibleActionButton,
|
||||
speedbump_mouse_state: MouseStateHandle,
|
||||
ai_settings_link_highlight_index: HighlightedHyperlink,
|
||||
|
||||
/// A randomly-generated string prefix to ensure the [`SavePosition`]s in this view are unique.
|
||||
position_id_prefix: String,
|
||||
}
|
||||
|
||||
impl SuggestedUnitTestsView {
|
||||
pub fn new(
|
||||
identifiers: AIIdentifiers,
|
||||
action_id: AIAgentActionId,
|
||||
query: String,
|
||||
title: String,
|
||||
description: String,
|
||||
should_show_speedbump: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let accept_button = CompactibleActionButton::new(
|
||||
ACCEPT_LABEL.to_string(),
|
||||
Some(KeystrokeSource::Binding(
|
||||
ACCEPT_PROMPT_SUGGESTION_KEYBINDING,
|
||||
)),
|
||||
ButtonSize::Small,
|
||||
SuggestedUnitTestsAction::Accept,
|
||||
Icon::Check,
|
||||
Arc::new(PrimaryTheme),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let cancel_button = CompactibleActionButton::new(
|
||||
CANCEL_LABEL.to_string(),
|
||||
Some(KeystrokeSource::Fixed(
|
||||
REJECT_PROMPT_SUGGESTION_KEYSTROKE.clone(),
|
||||
)),
|
||||
ButtonSize::Small,
|
||||
SuggestedUnitTestsAction::Cancel,
|
||||
Icon::X,
|
||||
Arc::new(NakedTheme),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let random_str = thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(8)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
identifiers,
|
||||
action_id,
|
||||
is_hidden: false,
|
||||
is_keybindings_hidden: false,
|
||||
should_show_speedbump,
|
||||
title,
|
||||
description,
|
||||
query,
|
||||
accept_button,
|
||||
cancel_button,
|
||||
speedbump_mouse_state: Default::default(),
|
||||
ai_settings_link_highlight_index: Default::default(),
|
||||
position_id_prefix: random_str,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn identifiers(&self) -> &AIIdentifiers {
|
||||
&self.identifiers
|
||||
}
|
||||
|
||||
pub fn action_id(&self) -> &AIAgentActionId {
|
||||
&self.action_id
|
||||
}
|
||||
|
||||
fn position_id_for_speedbump(&self) -> String {
|
||||
format!(
|
||||
"SuggestedUnitTestsView-speedbump-{}",
|
||||
&self.position_id_prefix
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.is_hidden
|
||||
}
|
||||
|
||||
pub fn is_keybindings_hidden(&self) -> bool {
|
||||
self.is_keybindings_hidden
|
||||
}
|
||||
|
||||
pub fn query(&self) -> Option<String> {
|
||||
(!self.query.is_empty()).then(|| self.query.to_string())
|
||||
}
|
||||
|
||||
pub fn set_is_hidden(&mut self, is_hidden: bool) {
|
||||
self.is_hidden = is_hidden;
|
||||
}
|
||||
|
||||
pub fn hide_keybindings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_keybindings_hidden = true;
|
||||
self.accept_button.set_keybinding(None, ctx);
|
||||
self.cancel_button.set_keybinding(None, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
warpui::elements::Icon::new(
|
||||
Icon::Code2.into(),
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_buttons(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> (Box<dyn Element>, Box<dyn Element>) {
|
||||
{
|
||||
render_compact_and_regular_button_rows(
|
||||
vec![&self.cancel_button, &self.accept_button],
|
||||
None,
|
||||
appearance,
|
||||
app,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header_contents(
|
||||
&self,
|
||||
buttons: Box<dyn Element>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let background = appearance.theme().background();
|
||||
let mut col = Flex::column();
|
||||
|
||||
if !self.title.is_empty() {
|
||||
let title = Text::new_inline(
|
||||
self.title.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() + 4.,
|
||||
)
|
||||
.with_color(appearance.theme().main_text_color(background).into_solid())
|
||||
.with_selectable(false)
|
||||
.finish();
|
||||
col.add_child(title);
|
||||
}
|
||||
|
||||
if !self.description.is_empty() {
|
||||
let description = Text::new_inline(
|
||||
self.description.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().sub_text_color(background).into_solid())
|
||||
.with_selectable(false)
|
||||
.finish();
|
||||
col.add_child(Container::new(description).with_margin_top(2.).finish());
|
||||
}
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Shrinkable::new(1., Expanded::new(1., col.finish()).finish()).finish())
|
||||
.with_child(Align::new(buttons).right().finish())
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let (regular_buttons, compact_buttons) = self.render_buttons(appearance, app);
|
||||
let regular_header = self.render_header_contents(regular_buttons, appearance);
|
||||
let compact_header = self.render_header_contents(compact_buttons, appearance);
|
||||
|
||||
let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar();
|
||||
SizeConstraintSwitch::new(
|
||||
regular_header,
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(size_switch_threshold),
|
||||
compact_header,
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let header = self.render_header(appearance, app);
|
||||
let mut col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(header);
|
||||
if !self.query.is_empty() {
|
||||
let query: Box<dyn Element> = Text::new(
|
||||
self.query.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
)
|
||||
.with_selectable(true)
|
||||
.finish();
|
||||
|
||||
col.add_child(Container::new(query).with_margin_top(6.).finish());
|
||||
}
|
||||
|
||||
col.finish()
|
||||
}
|
||||
|
||||
fn render_speedbump(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_color = theme.sub_text_color(theme.background()).into_solid();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = 12.;
|
||||
|
||||
let checked = AISettings::as_ref(app).is_code_suggestions_enabled(app);
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.speedbump_mouse_state.clone(), Some(font_size))
|
||||
.check(!checked)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(font_color),
|
||||
font_size: Some(font_size),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(SuggestedUnitTestsAction::ToggleSetting);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let checkbox_text = appearance
|
||||
.ui_builder()
|
||||
.span("Don't show me suggested code banners again")
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(font_color),
|
||||
font_size: Some(font_size),
|
||||
padding: Some(Coords::default().left(4.)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let formatted_text = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::hyperlink(
|
||||
"Manage suggested code banner settings",
|
||||
"Settings > AI",
|
||||
),
|
||||
])]),
|
||||
font_size,
|
||||
font_family,
|
||||
font_family,
|
||||
font_color,
|
||||
self.ai_settings_link_highlight_index.clone(),
|
||||
)
|
||||
.with_hyperlink_font_color(blended_colors::accent_fg_strong(theme).into())
|
||||
.register_default_click_handlers(|_, ctx, _| {
|
||||
ctx.dispatch_typed_action(SuggestedUnitTestsAction::OpenSettings);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let container = Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([checkbox, checkbox_text])
|
||||
.finish(),
|
||||
)
|
||||
.with_child(formatted_text)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(4.)
|
||||
.finish();
|
||||
|
||||
SavePosition::new(container, &self.position_id_for_speedbump()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for SuggestedUnitTestsView {
|
||||
fn ui_name() -> &'static str {
|
||||
"SuggestedUnitTestsView"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
// We always want the focus to be in the input, not the view.
|
||||
ctx.emit(SuggestedUnitTestsEvent::Blur);
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(self.render_icon(appearance))
|
||||
.with_child(Expanded::new(1., self.render_body(appearance, app)).finish())
|
||||
.finish();
|
||||
|
||||
if self.should_show_speedbump {
|
||||
let speedbump = self.render_speedbump(appearance, app);
|
||||
Flex::column()
|
||||
.with_child(row)
|
||||
.with_child(speedbump)
|
||||
.finish()
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SuggestedUnitTestsView {
|
||||
type Event = SuggestedUnitTestsEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for SuggestedUnitTestsView {
|
||||
type Action = SuggestedUnitTestsAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SuggestedUnitTestsAction::Accept => ctx.emit(SuggestedUnitTestsEvent::Accept),
|
||||
SuggestedUnitTestsAction::Cancel => ctx.emit(SuggestedUnitTestsEvent::Cancel),
|
||||
SuggestedUnitTestsAction::ToggleSetting => {
|
||||
let checked = AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
settings
|
||||
.code_suggestions_enabled_internal
|
||||
.toggle_and_save_value(ctx)
|
||||
});
|
||||
ctx.notify();
|
||||
|
||||
if let Ok(checked) = checked {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ToggleCodeSuggestionsSetting {
|
||||
source: ToggleCodeSuggestionsSettingSource::Speedbump,
|
||||
is_code_suggestions_enabled: checked,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
SuggestedUnitTestsAction::OpenSettings => {
|
||||
ctx.emit(SuggestedUnitTestsEvent::OpenSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::elements::{Container, CrossAxisAlignment, Element, Flex, ParentElement, Text};
|
||||
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::search_results_common::{
|
||||
render_collapsible_search_results, CollapsibleSearchResultsState,
|
||||
};
|
||||
use crate::ai::agent::icons::yellow_running_icon;
|
||||
use crate::ai::agent::WebFetchStatus;
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
|
||||
pub enum WebFetchViewEvent {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WebFetchViewAction {
|
||||
ToggleExpanded,
|
||||
}
|
||||
|
||||
pub struct WebFetchView {
|
||||
pub status: WebFetchStatus,
|
||||
pub collapsible: CollapsibleSearchResultsState,
|
||||
}
|
||||
|
||||
impl WebFetchView {
|
||||
pub fn new(urls: Vec<String>) -> Self {
|
||||
Self {
|
||||
status: WebFetchStatus::Fetching { urls },
|
||||
collapsible: CollapsibleSearchResultsState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, status: &WebFetchStatus) {
|
||||
self.status = status.clone();
|
||||
}
|
||||
|
||||
fn render_loading(&self, urls: &[String], app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let loading_icon = yellow_running_icon(appearance);
|
||||
|
||||
let text = format!("Fetching {} web pages...", urls.len());
|
||||
|
||||
super::search_results_common::render_loading_header(text, loading_icon, app)
|
||||
}
|
||||
|
||||
fn render_success(
|
||||
&self,
|
||||
pages: &[(String, String, bool)],
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let successful_count = pages.iter().filter(|(_, _, success)| *success).count();
|
||||
let title_text = if successful_count == pages.len() {
|
||||
format!("Fetched {} web pages", pages.len())
|
||||
} else {
|
||||
format!("Fetched {} of {} web pages", successful_count, pages.len())
|
||||
};
|
||||
|
||||
let body = if self.collapsible.is_expanded {
|
||||
Some(self.render_urls_list(pages, app))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
render_collapsible_search_results(
|
||||
title_text,
|
||||
pages.len(),
|
||||
"URLs",
|
||||
&self.collapsible,
|
||||
body,
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(WebFetchViewAction::ToggleExpanded);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_urls_list(
|
||||
&self,
|
||||
pages: &[(String, String, bool)],
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for (url, title, success) in pages {
|
||||
let display_text = if title.is_empty() {
|
||||
url.clone()
|
||||
} else {
|
||||
format!("{title} ({url})")
|
||||
};
|
||||
|
||||
// Show failed URLs with a different indicator
|
||||
let display_text = if *success {
|
||||
display_text
|
||||
} else {
|
||||
format!("✗ {display_text}")
|
||||
};
|
||||
|
||||
let text_color = if *success {
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_1())
|
||||
} else {
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
};
|
||||
|
||||
let url_text = Text::new_inline(
|
||||
display_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(text_color.into())
|
||||
.finish();
|
||||
|
||||
column.add_child(Container::new(url_text).with_vertical_padding(2.).finish());
|
||||
}
|
||||
|
||||
if pages.is_empty() {
|
||||
let no_results = Text::new_inline(
|
||||
"No URLs fetched".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
column.add_child(no_results);
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WebFetchView {
|
||||
type Event = WebFetchViewEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for WebFetchView {
|
||||
type Action = WebFetchViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
WebFetchViewAction::ToggleExpanded => {
|
||||
self.collapsible.toggle_expanded();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for WebFetchView {
|
||||
fn ui_name() -> &'static str {
|
||||
"WebFetchView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
match &self.status {
|
||||
WebFetchStatus::Fetching { urls } => self
|
||||
.render_loading(urls, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish(),
|
||||
WebFetchStatus::Success { pages } => self
|
||||
.render_success(pages, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish(),
|
||||
WebFetchStatus::Error => {
|
||||
// Render as if fetch completed with no results
|
||||
self.render_success(&[], app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::elements::{Container, CrossAxisAlignment, Element, Flex, ParentElement, Text};
|
||||
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::search_results_common::{
|
||||
render_collapsible_search_results, CollapsibleSearchResultsState,
|
||||
};
|
||||
use crate::ai::agent::icons::yellow_running_icon;
|
||||
use crate::ai::agent::WebSearchStatus;
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
|
||||
pub enum WebSearchViewEvent {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WebSearchViewAction {
|
||||
ToggleExpanded,
|
||||
}
|
||||
|
||||
pub struct WebSearchView {
|
||||
pub status: WebSearchStatus,
|
||||
pub collapsible: CollapsibleSearchResultsState,
|
||||
}
|
||||
|
||||
impl WebSearchView {
|
||||
pub fn new(query: String) -> Self {
|
||||
Self {
|
||||
status: WebSearchStatus::Searching {
|
||||
query: if query.is_empty() { None } else { Some(query) },
|
||||
},
|
||||
collapsible: CollapsibleSearchResultsState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, status: &WebSearchStatus) {
|
||||
self.status = status.clone();
|
||||
}
|
||||
|
||||
fn render_loading(&self, query: &Option<String>, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let loading_icon = yellow_running_icon(appearance);
|
||||
|
||||
let text = if let Some(q) = query {
|
||||
format!("Searching the web for \"{q}\"")
|
||||
} else {
|
||||
"Searching the web".to_string()
|
||||
};
|
||||
|
||||
super::search_results_common::render_loading_header(text, loading_icon, app)
|
||||
}
|
||||
|
||||
fn render_success(
|
||||
&self,
|
||||
query: &str,
|
||||
pages: &[(String, String)],
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let title_text = if query.is_empty() {
|
||||
"Searched the web".to_string()
|
||||
} else {
|
||||
format!("Searched the web for \"{query}\"")
|
||||
};
|
||||
|
||||
let body = if self.collapsible.is_expanded {
|
||||
Some(self.render_urls_list(pages, app))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
render_collapsible_search_results(
|
||||
title_text,
|
||||
pages.len(),
|
||||
"URLs",
|
||||
&self.collapsible,
|
||||
body,
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(WebSearchViewAction::ToggleExpanded);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_urls_list(&self, pages: &[(String, String)], app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for (url, title) in pages {
|
||||
let display_text = if title.is_empty() {
|
||||
url.clone()
|
||||
} else {
|
||||
format!("{title} ({url})")
|
||||
};
|
||||
|
||||
let url_text = Text::new_inline(
|
||||
display_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.0,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
column.add_child(Container::new(url_text).with_vertical_padding(2.).finish());
|
||||
}
|
||||
|
||||
if pages.is_empty() {
|
||||
let no_results = Text::new_inline(
|
||||
"No URLs found".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
column.add_child(no_results);
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WebSearchView {
|
||||
type Event = WebSearchViewEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for WebSearchView {
|
||||
type Action = WebSearchViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
WebSearchViewAction::ToggleExpanded => {
|
||||
self.collapsible.toggle_expanded();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for WebSearchView {
|
||||
fn ui_name() -> &'static str {
|
||||
"WebSearchView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
match &self.status {
|
||||
WebSearchStatus::Searching { query } => self
|
||||
.render_loading(query, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish(),
|
||||
WebSearchStatus::Success { query, pages } => self
|
||||
.render_success(query, pages, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish(),
|
||||
WebSearchStatus::Error { query } => {
|
||||
// For now, render as if search completed with no results
|
||||
// TODO(advait): Add proper error rendering
|
||||
self.render_success(query, &[], app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user