Files
galaxy/app/src/ai/blocklist/suggested_rule_modal.rs
T

679 lines
24 KiB
Rust

use crate::ai::agent::SuggestedRule;
use crate::ai::facts::CloudAIFactModel;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::Owner;
use crate::drive::CloudObjectTypeAndId;
use crate::editor::{
EditorOptions, EditorView, EnterAction, EnterSettings, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::modal::{Modal, ModalEvent};
use crate::network::NetworkStatus;
use crate::send_telemetry_from_ctx;
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManagerEvent,
};
use crate::server::ids::SyncId;
use crate::server::telemetry::TelemetryEvent;
use crate::view_components::action_button::{ActionButton, PrimaryTheme};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
ai::facts::{AIFact, AIMemory},
server::cloud_objects::update_manager::UpdateManager,
ui_components::blended_colors,
};
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::editor::NavigationKey;
use galaxyui::elements::{
ChildAnchor, OffsetPositioning, PositionedElementAnchor, PositionedElementOffsetBounds,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::FixedBinding;
use galaxyui::{
elements::ClippedScrollStateHandle,
ui_components::components::{Coords, UiComponentStyles},
};
use galaxyui::{
elements::{
Align, Border, ChildView, ClippedScrollable, ConstrainedBox, Container, CornerRadius, Flex,
ParentElement, Radius, ScrollbarWidth,
},
ui_components::components::UiComponent,
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
const HEADER_TEXT: &str = "Suggested rule";
const MAX_EDITOR_HEIGHT: f32 = 240.;
pub fn init(app: &mut AppContext) {
use galaxyui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
SuggestedRuleDialogAction::Close,
id!(SuggestedRuleView::ui_name()),
)]);
}
#[derive(Debug, Clone, Copy)]
enum EditorType {
Name,
Content,
}
#[derive(Debug, Clone)]
pub enum SuggestedRuleModalEvent {
AddNewRule { rule: SuggestedRule },
OpenRuleForEditing { rule: SuggestedRule },
Close,
}
/// A modal component for displaying and managing suggested rules.
/// This component wraps a SuggestedRuleView in a modal dialog with proper styling
/// and event handling.
///
/// # Focus Management
/// - The modal automatically focuses the rule editor when opened
/// - Key events (like Escape) are handled for dismissing the modal
/// - Focus returns to the terminal/AI block when the modal is closed
/// - Tab navigation works between name and content editors
///
/// # Positioning
/// - The modal positions itself relative to the chip that triggered it
/// - Uses `OffsetPositioning` with the chip's saved position as the anchor
/// - The position is calculated based on the rule's unique identifier to maintain consistency
///
pub struct SuggestedRuleModal {
modal: ViewHandle<Modal<SuggestedRuleView>>,
view: ViewHandle<SuggestedRuleView>,
}
impl SuggestedRuleModal {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let appearance = Appearance::as_ref(ctx);
let background = blended_colors::neutral_2(appearance.theme());
let view = ctx.add_typed_action_view(SuggestedRuleView::new);
ctx.subscribe_to_view(&view, |me, _, event, ctx| {
me.handle_view_event(event, ctx);
});
let view_handle = view.clone();
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(Some(HEADER_TEXT.to_string()), view, ctx)
.with_modal_style(UiComponentStyles {
width: Some(510.),
background: Some(background.into()),
..Default::default()
})
.with_header_style(UiComponentStyles {
padding: Some(Coords {
top: 8.,
bottom: 0.,
left: 24.,
right: 24.,
}),
font_size: Some(16.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords {
top: 0.,
bottom: 24.,
left: 24.,
right: 24.,
}),
..Default::default()
})
.with_background_opacity(100)
.with_dismiss_on_click()
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_modal_event(event, ctx);
});
Self {
modal,
view: view_handle,
}
}
pub fn set_rule_and_id(
&mut self,
rule_and_id: &SuggestedRuleAndId,
ctx: &mut ViewContext<Self>,
) {
self.view.update(ctx, |view, ctx| {
view.set_rule_and_id(rule_and_id, ctx);
});
self.modal.update(ctx, |modal, ctx| {
modal.set_offset_positioning(OffsetPositioning::offset_from_save_position_element(
format!("rule_position_{}", rule_and_id.rule.logging_id),
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopLeft,
ChildAnchor::BottomLeft,
));
ctx.notify();
});
ctx.notify();
}
fn handle_view_event(&mut self, event: &SuggestedRuleDialogEvent, ctx: &mut ViewContext<Self>) {
match event {
SuggestedRuleDialogEvent::AddNewRule { rule } => {
ctx.emit(SuggestedRuleModalEvent::AddNewRule { rule: rule.clone() })
}
SuggestedRuleDialogEvent::OpenRuleForEditing { rule } => {
ctx.emit(SuggestedRuleModalEvent::OpenRuleForEditing { rule: rule.clone() })
}
SuggestedRuleDialogEvent::Close => ctx.emit(SuggestedRuleModalEvent::Close),
}
}
fn handle_modal_event(&mut self, event: &ModalEvent, ctx: &mut ViewContext<Self>) {
match event {
ModalEvent::Close => {
ctx.emit(SuggestedRuleModalEvent::Close);
}
}
}
}
impl Entity for SuggestedRuleModal {
type Event = SuggestedRuleModalEvent;
}
impl View for SuggestedRuleModal {
fn ui_name() -> &'static str {
"SuggestedRuleModal"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
ChildView::new(&self.modal).finish()
}
}
impl TypedActionView for SuggestedRuleModal {
type Action = ();
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
}
#[derive(Debug, Clone)]
enum SuggestedRuleDialogAction {
Add,
Edit,
Close,
}
#[derive(Debug, Clone)]
pub enum SuggestedRuleDialogEvent {
AddNewRule { rule: SuggestedRule },
OpenRuleForEditing { rule: SuggestedRule },
Close,
}
#[derive(Debug, Clone)]
pub struct SuggestedRuleAndId {
pub rule: SuggestedRule,
pub sync_id: SyncId,
}
struct SuggestedRuleView {
rule_and_id: Option<SuggestedRuleAndId>,
owner: Option<Owner>,
is_saved: bool,
current_editor: EditorType,
name_editor: ViewHandle<EditorView>,
content_editor: ViewHandle<EditorView>,
add_button: ViewHandle<ActionButton>,
edit_button: ViewHandle<ActionButton>,
clipped_scroll_state: ClippedScrollStateHandle,
}
impl SuggestedRuleView {
fn new(ctx: &mut ViewContext<Self>) -> Self {
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
me.handle_update_manager_event(event, ctx);
});
let cloud_model = CloudModel::handle(ctx);
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
me.handle_cloud_model_event(event, ctx);
});
let owner = UserWorkspaces::as_ref(ctx).personal_drive(ctx);
let network_status = NetworkStatus::handle(ctx);
ctx.subscribe_to_model(&network_status, |me, _, _event, ctx| {
let is_edit_allowed = me.is_edit_allowed(ctx);
let tooltip = if !is_edit_allowed {
Some("Editing is disabled while offline.".to_string())
} else {
None
};
me.edit_button.update(ctx, |edit_button, ctx| {
edit_button.set_disabled(!is_edit_allowed, ctx);
edit_button.set_tooltip(tooltip, ctx);
});
ctx.notify();
});
let appearance = Appearance::as_ref(ctx);
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let text = TextOptions {
font_size_override: Some(font_size),
font_family_override: Some(font_family),
..Default::default()
};
let name_editor = ctx.add_typed_action_view(|ctx| {
EditorView::single_line(
SingleLineEditorOptions {
text: text.clone(),
soft_wrap: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_view(&name_editor, |me, _editor, event, ctx| {
me.handle_editor_event(event, ctx);
});
let content_editor = ctx.add_typed_action_view(|ctx| {
EditorView::new(
EditorOptions {
text,
soft_wrap: true,
autogrow: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
supports_vim_mode: false,
single_line: false,
enter_settings: EnterSettings {
shift_enter: EnterAction::InsertNewLineIfMultiLine,
enter: EnterAction::InsertNewLineIfMultiLine,
alt_enter: EnterAction::InsertNewLineIfMultiLine,
..Default::default()
},
..Default::default()
},
ctx,
)
});
ctx.subscribe_to_view(&content_editor, |me, _editor, event, ctx| {
me.handle_editor_event(event, ctx);
});
let add_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add rule", PrimaryTheme)
.on_click(|ctx| ctx.dispatch_typed_action(SuggestedRuleDialogAction::Add))
});
let edit_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Edit rule", PrimaryTheme)
.on_click(|ctx| ctx.dispatch_typed_action(SuggestedRuleDialogAction::Edit))
});
Self {
rule_and_id: None,
owner,
is_saved: false,
current_editor: EditorType::Name,
name_editor,
content_editor,
add_button,
edit_button,
clipped_scroll_state: Default::default(),
}
}
pub fn set_rule_and_id(
&mut self,
rule_and_id: &SuggestedRuleAndId,
ctx: &mut ViewContext<Self>,
) {
self.rule_and_id = Some(rule_and_id.clone());
self.reset_rule(ctx);
ctx.focus_self();
ctx.notify();
}
pub fn is_edit_allowed(&self, ctx: &mut ViewContext<Self>) -> bool {
let Some(SuggestedRuleAndId { sync_id, .. }) = &self.rule_and_id else {
return false;
};
let is_online = NetworkStatus::as_ref(ctx).is_online();
is_online || sync_id.into_server().is_none()
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
let (current_editor, next_editor, next_editor_type) = match self.current_editor {
EditorType::Name => (&self.name_editor, &self.content_editor, EditorType::Content),
EditorType::Content => (&self.content_editor, &self.name_editor, EditorType::Name),
};
match event {
EditorEvent::Escape => {
ctx.emit(SuggestedRuleDialogEvent::Close);
}
EditorEvent::Focused => {
self.current_editor = if self.name_editor.is_focused(ctx) {
EditorType::Name
} else {
EditorType::Content
};
}
EditorEvent::Edited(_) => {
// todo this seems noisy?
if let Some(SuggestedRuleAndId { rule, .. }) = &self.rule_and_id {
send_telemetry_from_ctx!(
TelemetryEvent::AISuggestedRuleContentChanged {
rule_id: rule.logging_id.clone(),
is_saved: self.is_saved
},
ctx
);
}
}
EditorEvent::Navigate(NavigationKey::Tab)
| EditorEvent::Navigate(NavigationKey::ShiftTab) => {
self.current_editor = next_editor_type;
ctx.focus(next_editor);
}
EditorEvent::Navigate(NavigationKey::Up) => {
current_editor.update(ctx, |editor, ctx| {
editor.move_up(ctx);
});
}
EditorEvent::Navigate(NavigationKey::Down) => {
current_editor.update(ctx, |editor, ctx| {
editor.move_down(ctx);
});
}
_ => {}
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ViewContext<Self>,
) {
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
return;
};
if let (ObjectOperation::Create { .. }, OperationSuccessType::Success) =
(&result.operation, &result.success_type)
{
if let Some(rule_and_id) = &self.rule_and_id {
if rule_and_id.sync_id.into_client() == result.client_id {
if let Some(server_id) = result.server_id {
self.rule_and_id = Some(SuggestedRuleAndId {
rule: rule_and_id.rule.clone(),
sync_id: SyncId::ServerId(server_id),
});
// Reload the rule from the cloud model.
self.load_rule(ctx);
}
}
}
}
}
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext<Self>) {
match event {
CloudModelEvent::ObjectUpdated {
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
..
} => {
if let Some(rule_and_id) = &self.rule_and_id {
if rule_and_id.sync_id.into_client() == id.into_client() {
self.load_rule(ctx);
}
}
}
CloudModelEvent::ObjectTrashed {
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
..
}
| CloudModelEvent::ObjectDeleted {
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
..
} => {
// If the rule has been deleted, then we should reset the rule such that
// the suggestion can be added again.
if let Some(rule_and_id) = &self.rule_and_id {
if rule_and_id.sync_id == *id {
self.reset_rule(ctx);
}
}
}
_ => {}
}
}
/// Resets the rule to its initial state.
fn reset_rule(&mut self, ctx: &mut ViewContext<Self>) {
self.is_saved = false;
let name = self
.rule_and_id
.as_ref()
.map(|rule_and_id| rule_and_id.rule.name.clone())
.unwrap_or("".to_string());
self.name_editor.update(ctx, |name_editor, ctx| {
name_editor.set_buffer_text(&name, ctx);
name_editor.set_interaction_state(InteractionState::Editable, ctx);
});
let content = self
.rule_and_id
.as_ref()
.map(|rule_and_id| rule_and_id.rule.content.clone())
.unwrap_or("".to_string());
self.content_editor.update(ctx, |content_editor, ctx| {
content_editor.set_buffer_text(&content, ctx);
content_editor.set_interaction_state(InteractionState::Editable, ctx);
});
ctx.notify();
}
/// Fetches the rule from the cloud model, and updates the UI to reflect that.
fn load_rule(&mut self, ctx: &mut ViewContext<Self>) {
let Some(SuggestedRuleAndId { sync_id, .. }) = &self.rule_and_id else {
return;
};
let cloud_model = CloudModel::handle(ctx);
if let Some(rule) = cloud_model
.as_ref(ctx)
.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(sync_id)
{
let AIFact::Memory(AIMemory { name, content, .. }) = rule.model().string_model.clone();
self.name_editor.update(ctx, |name_editor, ctx| {
name_editor.set_buffer_text(&name.unwrap_or("Untitled".to_string()), ctx);
});
self.content_editor.update(ctx, |content_editor, ctx| {
content_editor.set_buffer_text(&content, ctx);
});
ctx.notify();
}
}
pub fn add_rule(&mut self, ctx: &mut ViewContext<Self>) {
let Some(SuggestedRuleAndId { rule, sync_id }) = self.rule_and_id.clone() else {
log::warn!("No rule to add in suggested rule dialog");
return;
};
// Add rule as a WD object.
let update_manager = UpdateManager::handle(ctx);
let name = if self.name_editor.as_ref(ctx).buffer_text(ctx).is_empty() {
None
} else {
Some(self.name_editor.as_ref(ctx).buffer_text(ctx).clone())
};
let content = self.content_editor.as_ref(ctx).buffer_text(ctx);
if let Some(owner) = self.owner {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name,
content,
suggested_logging_id: Some(rule.logging_id.clone()),
});
update_manager.update(ctx, |update_manager, ctx| {
if let Some(client_id) = sync_id.into_client() {
update_manager.create_ai_fact(ai_fact, client_id, owner, ctx);
}
});
}
self.on_add_rule(ctx);
ctx.emit(SuggestedRuleDialogEvent::AddNewRule { rule });
}
/// Updates the UI state to reflect that a rule has been added.
fn on_add_rule(&mut self, ctx: &mut ViewContext<Self>) {
self.is_saved = true;
self.name_editor.update(ctx, |name_editor, ctx| {
name_editor.set_interaction_state(InteractionState::Disabled, ctx);
});
self.content_editor.update(ctx, |content_editor, ctx| {
content_editor.set_interaction_state(InteractionState::Disabled, ctx);
});
ctx.notify();
}
fn render_label(&self, text: String, appearance: &Appearance) -> Box<dyn Element> {
Container::new(appearance.ui_builder().span(text).build().finish())
.with_margin_bottom(8.)
.finish()
}
fn render_rule_form(&self, appearance: &Appearance) -> Box<dyn Element> {
let editor_bg = blended_colors::neutral_4(appearance.theme());
let editor_border =
Border::all(1.).with_border_fill(blended_colors::neutral_2(appearance.theme()));
let editor_corner_radius = CornerRadius::with_all(Radius::Pixels(4.));
let editor_horizontal_padding = 16.;
let editor_vertical_padding = 12.;
let editor_margin = 16.;
Flex::column()
.with_child(self.render_label("Name".to_string(), appearance))
.with_child(
Container::new(ChildView::new(&self.name_editor).finish())
.with_background(editor_bg)
.with_border(editor_border)
.with_corner_radius(editor_corner_radius)
.with_vertical_padding(editor_vertical_padding)
.with_horizontal_padding(editor_horizontal_padding)
.with_margin_bottom(editor_margin)
.finish(),
)
.with_child(self.render_label("Rule".to_string(), appearance))
.with_child(
ConstrainedBox::new(
Container::new(
ClippedScrollable::vertical(
self.clipped_scroll_state.clone(),
ChildView::new(&self.content_editor).finish(),
ScrollbarWidth::Auto,
appearance.theme().nonactive_ui_detail().into(),
appearance.theme().active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish(),
)
.with_background(editor_bg)
.with_border(editor_border)
.with_corner_radius(editor_corner_radius)
.with_padding_left(editor_horizontal_padding)
.with_vertical_padding(editor_vertical_padding)
.with_margin_bottom(editor_margin)
.finish(),
)
.with_max_height(MAX_EDITOR_HEIGHT)
.finish(),
)
.finish()
}
}
impl Entity for SuggestedRuleView {
type Event = SuggestedRuleDialogEvent;
}
impl View for SuggestedRuleView {
fn ui_name() -> &'static str {
"SuggestedDialog"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
if self.is_saved {
ctx.focus_self();
} else {
match self.current_editor {
EditorType::Name => ctx.focus(&self.name_editor),
EditorType::Content => ctx.focus(&self.content_editor),
}
}
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let add_edit_button = if self.is_saved {
&self.edit_button
} else {
&self.add_button
};
Flex::column()
.with_child(self.render_rule_form(appearance))
.with_child(
Container::new(
Align::new(ChildView::new(add_edit_button).finish())
.right()
.finish(),
)
.finish(),
)
.finish()
}
}
impl TypedActionView for SuggestedRuleView {
type Action = SuggestedRuleDialogAction;
fn handle_action(&mut self, action: &SuggestedRuleDialogAction, ctx: &mut ViewContext<Self>) {
match action {
SuggestedRuleDialogAction::Add => {
self.add_rule(ctx);
}
SuggestedRuleDialogAction::Edit => {
if let Some(SuggestedRuleAndId { rule, .. }) = &self.rule_and_id {
ctx.emit(SuggestedRuleDialogEvent::OpenRuleForEditing { rule: rule.clone() });
} else {
log::warn!("No rule to edit in suggested rule dialog");
}
}
SuggestedRuleDialogAction::Close => {
ctx.emit(SuggestedRuleDialogEvent::Close);
}
}
}
}