Files
galaxy/app/src/ai/facts/view/rule.rs
T
Ryan Ward 3d2d90fd4e feat: inject global rules into AI system prompt and fix Rules UI seeding
- Load global rules (AIFact/AIMemory) from local CloudModel and inject
  them into the Bedrock/OpenAI system prompt as a '## Global Rules' section
  when memory is enabled.
- Fix rule seeding: always re-seed predefined rules when the CloudModel has
  none, regardless of the has_seeded_predefined_rules flag (handles case
  where flag was set but rules never persisted due to prior missing owner).
- Rename /context slash command to /copy-context: dumps the full context
  window (global rules, progressive summary, message history) to the
  clipboard for debugging.
2026-07-16 12:25:56 -05:00

1191 lines
43 KiB
Rust

use std::fmt::Debug;
use std::path::PathBuf;
use ai::project_context::model::{ProjectContextModel, ProjectContextModelEvent};
use galaxy_core::ui::appearance::{Appearance, AppearanceEvent};
use galaxy_core::ui::theme::color::internal_colors;
use markdown_parser::weight::CustomWeight;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Shrinkable,
};
use warpui::platform::{Cursor, FilePickerConfiguration};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use super::{
is_delete_allowed, is_edit_allowed, is_syncing, style, AIFact, CloudAIFact, CloudAIFactModel,
};
use crate::ai::facts::predefined_rules::{
is_predefined_rule, predefined_rule_index, PREDEFINED_RULES,
};
use crate::ai::facts::AIMemory;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{
CloudObject, GenericStringObjectFormat, JsonObjectType, Owner, Revision,
};
use crate::drive::CloudObjectTypeAndId;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::network::NetworkStatus;
use crate::search_bar::SearchBar;
use crate::server::cloud_objects::update_manager::{UpdateManager, UpdateManagerEvent};
use crate::server::ids::{ClientId, SyncId};
use crate::server::sync_queue::SyncQueue;
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::ui_components::icons::Icon;
use crate::util::path::display_path_with_host;
use crate::view_components::action_button::{ActionButton, NakedTheme};
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
pub const HEADER_TEXT: &str = "Rules";
const DESCRIPTION_TEXT: &str = "Rules enhance the agent by providing structured guidelines that help maintain consistency, enforce best practices, and adapt to specific workflows, including codebases or broader tasks.";
const SEARCH_PLACEHOLDER_TEXT: &str = "Search rules";
const ZERO_STATE_TEXT: &str =
"Add a rule above, or drop one at ~/.agents/AGENTS.md to apply it across every project.";
const ZERO_STATE_TEXT_PROJECT: &str =
"Once you generate a GALAXY.md rules file for a project, it will appear here.";
const DISABLED_BANNER_TEXT: &str =
"Your rules are disabled and won't be used as context in sessions. You can ";
const DISABLED_BANNER_LINK_TEXT: &str = "turn it back on";
const DISABLED_BANNER_TEXT_2: &str = " anytime.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleScope {
Global,
ProjectBased,
}
#[derive(Debug, Clone)]
pub enum RuleViewEvent {
AddRule,
Edit(SyncId),
OpenSettings,
OpenFile(LocalOrRemotePath),
InitializeProject(PathBuf),
}
#[derive(Debug, Clone)]
pub enum RuleViewAction {
AddRule,
AddPredefinedRules,
InitializeProject,
Edit(SyncId),
Delete(SyncId),
OpenSettings,
SelectScope(RuleScope),
OpenFile(LocalOrRemotePath),
}
#[derive(Default, Debug, Clone)]
pub struct MouseStateHandles {
pub hover: MouseStateHandle,
pub sync_status_hover: MouseStateHandle,
pub sync_status_icon: MouseStateHandle,
pub delete_hover: MouseStateHandle,
}
#[derive(Debug, Clone)]
struct CloudRuleRow {
fact: CloudAIFact,
mouse_states: MouseStateHandles,
}
/// A rule row backed by a file on disk — used for both project-scoped rules
/// (e.g. `<repo>/WARP.md`) and file-based global rules (e.g.
/// `~/.agents/AGENTS.md`). The render path is identical for both: a path label
/// plus an "Open file" button.
#[derive(Debug, Clone)]
struct FileBackedRow {
file_path: LocalOrRemotePath,
mouse_state: MouseStateHandle,
}
#[derive(Debug, Clone)]
enum RuleRow {
Global(Box<CloudRuleRow>),
FileBacked(FileBackedRow),
}
impl RuleRow {
fn matches_search_term(&self, search_term: &str) -> bool {
let search_term = search_term.to_lowercase();
let search_term = search_term.as_str();
match self {
RuleRow::Global(row) => {
let AIFact::Memory(AIMemory { name, content, .. }) =
row.fact.model().string_model.clone();
name.unwrap_or_default()
.to_lowercase()
.contains(search_term)
|| content.to_lowercase().contains(search_term)
}
RuleRow::FileBacked(row) => row
.file_path
.display_path()
.to_lowercase()
.contains(search_term),
}
}
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
(RuleRow::Global(a), RuleRow::Global(b)) => {
b.fact.metadata().revision.cmp(&a.fact.metadata().revision)
}
(RuleRow::FileBacked(a), RuleRow::FileBacked(b)) => {
a.file_path.display_path().cmp(&b.file_path.display_path())
}
_ => std::cmp::Ordering::Equal,
}
}
}
pub struct RuleView {
owner: Option<Owner>,
cloud_global_rules: Vec<CloudRuleRow>,
/// File-based global rules (e.g. `~/.agents/AGENTS.md`). Surfaced in the
/// Global tab alongside cloud rules. Sourced from
/// `ProjectContextModel::global_rule_paths()`.
file_backed_global_rules: Vec<FileBackedRow>,
project_rules: Vec<FileBackedRow>,
search_editor: ViewHandle<EditorView>,
search_bar: ViewHandle<SearchBar>,
add_button: ViewHandle<ActionButton>,
add_predefined_rules_button: ViewHandle<ActionButton>,
initialize_button: ViewHandle<ActionButton>,
disabled_banner_highlight_index: HighlightedHyperlink,
current_scope: RuleScope,
global_tab_mouse_state: MouseStateHandle,
project_tab_mouse_state: MouseStateHandle,
}
impl RuleView {
pub 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 network_status = NetworkStatus::handle(ctx);
ctx.subscribe_to_model(&network_status, |_me, _, _event, ctx| {
ctx.notify();
});
let owner = UserWorkspaces::as_ref(ctx).personal_drive(ctx);
ctx.subscribe_to_model(&AISettings::handle(ctx), |_, _, event, ctx| {
if matches!(
event,
AISettingsChangedEvent::MemoryEnabled { .. }
| AISettingsChangedEvent::IsAnyAIEnabled { .. }
) {
ctx.notify();
}
});
let ai_rules: Vec<CloudAIFact> = {
let cloud_model = CloudModel::handle(ctx);
cloud_model
.as_ref(ctx)
.get_all_objects_of_type::<GenericStringObjectId, CloudAIFactModel>()
.cloned()
.collect()
};
let ai_rules: Vec<CloudRuleRow> = ai_rules
.into_iter()
.map(|fact| CloudRuleRow {
fact,
mouse_states: Default::default(),
})
.collect();
let project_context = ProjectContextModel::handle(ctx);
let project_rules = project_context
.as_ref(ctx)
.indexed_rules()
.map(|p| FileBackedRow {
file_path: p,
mouse_state: Default::default(),
})
.collect();
let file_backed_global_rules = project_context
.as_ref(ctx)
.global_rule_paths()
.map(|p| FileBackedRow {
file_path: p,
mouse_state: Default::default(),
})
.collect();
ctx.subscribe_to_model(
&project_context,
|me, context_model, event, ctx| match event {
// Upon indexing a new path, update project rules.
ProjectContextModelEvent::PathIndexed => {
me.project_rules = context_model
.as_ref(ctx)
.indexed_rules()
.map(|p| FileBackedRow {
file_path: p,
mouse_state: Default::default(),
})
.collect();
ctx.notify();
}
// On detecting a change to global rule files, update file-backed global rules.
ProjectContextModelEvent::GlobalRulesChanged(_) => {
me.file_backed_global_rules = context_model
.as_ref(ctx)
.global_rule_paths()
.map(|p| FileBackedRow {
file_path: p,
mouse_state: Default::default(),
})
.collect();
ctx.notify();
}
// No action needed for other ProjectContextModelEvent variants.
// PathIndexed is emitted last and indicates it's time to refresh the UI.
ProjectContextModelEvent::KnownRulesChanged(_) => {}
},
);
let appearance = Appearance::handle(ctx);
ctx.subscribe_to_model(&appearance, move |me, _, event, ctx| {
if let AppearanceEvent::ThemeChanged = event {
let appearance = Appearance::as_ref(ctx);
let search_bar_styles = style::search_bar(appearance);
me.search_bar.update(ctx, |search_bar, _| {
search_bar.with_style(search_bar_styles)
});
}
});
let search_editor_text = TextOptions::ui_text(None, appearance.as_ref(ctx));
let search_editor = {
let options = SingleLineEditorOptions {
text: search_editor_text,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
};
ctx.add_typed_action_view(|ctx| EditorView::single_line(options, ctx))
};
ctx.subscribe_to_view(&search_editor, move |me, _, event, ctx| {
me.handle_search_editor_event(event, ctx);
});
search_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.set_placeholder_text(SEARCH_PLACEHOLDER_TEXT, ctx);
});
let search_bar = ctx.add_typed_action_view(|_| SearchBar::new(search_editor.clone()));
let add_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add", NakedTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::AddRule))
});
let add_predefined_rules_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add Predefined Rules", NakedTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::AddPredefinedRules))
});
let initialize_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Initialize Project", NakedTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::InitializeProject))
});
// Seed predefined rules on first launch if no global rules exist.
// Also re-seed if the flag was set but rules are empty (e.g., prior bug
// where the flag was set but creation failed due to missing owner).
if ai_rules.is_empty() {
if let Some(owner) = owner {
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
for rule in PREDEFINED_RULES {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name: Some(rule.name.to_string()),
content: rule.content.to_string(),
suggested_logging_id: None,
});
update_manager.create_ai_fact(ai_fact, ClientId::default(), owner, ctx);
}
});
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
settings.mark_predefined_rules_seeded(ctx);
});
}
Self {
owner,
cloud_global_rules: ai_rules,
file_backed_global_rules,
project_rules,
search_editor,
search_bar,
add_button,
add_predefined_rules_button,
initialize_button,
disabled_banner_highlight_index: Default::default(),
current_scope: RuleScope::Global,
global_tab_mouse_state: Default::default(),
project_tab_mouse_state: Default::default(),
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ViewContext<Self>,
) {
if let UpdateManagerEvent::ObjectOperationComplete { .. } = event {
self.fetch_ai_rules(ctx);
}
}
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext<Self>) {
match event {
CloudModelEvent::ObjectUpdated { .. }
| CloudModelEvent::ObjectTrashed { .. }
| CloudModelEvent::ObjectUntrashed { .. }
| CloudModelEvent::ObjectCreated { .. }
| CloudModelEvent::ObjectDeleted { .. } => {
self.fetch_ai_rules(ctx);
}
_ => {}
}
}
fn handle_search_editor_event(&mut self, _event: &EditorEvent, ctx: &mut ViewContext<Self>) {
ctx.notify();
}
fn fetch_ai_rules(&mut self, ctx: &mut ViewContext<Self>) {
let ai_rules: Vec<CloudAIFact> = {
let cloud_model = CloudModel::handle(ctx);
cloud_model
.as_ref(ctx)
.get_all_objects_of_type::<GenericStringObjectId, CloudAIFactModel>()
.cloned()
.collect()
};
let mut rows: Vec<CloudRuleRow> = ai_rules
.into_iter()
.map(|ai_fact| CloudRuleRow {
fact: ai_fact,
mouse_states: Default::default(),
})
.collect();
rows.sort_by(|a, b| {
let name_a = match &a.fact.model().string_model {
AIFact::Memory(AIMemory { name, .. }) => name.clone().unwrap_or_default(),
};
let name_b = match &b.fact.model().string_model {
AIFact::Memory(AIMemory { name, .. }) => name.clone().unwrap_or_default(),
};
let is_predefined_a = is_predefined_rule(&name_a);
let is_predefined_b = is_predefined_rule(&name_b);
match (is_predefined_a, is_predefined_b) {
(true, true) => {
let idx_a = predefined_rule_index(&name_a).unwrap_or(usize::MAX);
let idx_b = predefined_rule_index(&name_b).unwrap_or(usize::MAX);
idx_a.cmp(&idx_b)
}
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(false, false) => std::cmp::Ordering::Equal,
}
});
self.cloud_global_rules = rows;
ctx.notify();
}
fn select_scope(&mut self, scope: RuleScope, ctx: &mut ViewContext<Self>) {
self.current_scope = scope;
ctx.notify();
}
fn get_filtered_rules(&self) -> Vec<RuleRow> {
match self.current_scope {
RuleScope::Global => self
.cloud_global_rules
.iter()
.cloned()
.map(|rule| RuleRow::Global(Box::new(rule)))
.chain(
self.file_backed_global_rules
.iter()
.cloned()
.map(RuleRow::FileBacked),
)
.collect(),
RuleScope::ProjectBased => self
.project_rules
.iter()
.cloned()
.map(RuleRow::FileBacked)
.collect(),
}
}
pub fn add_ai_rule(
&mut self,
name: Option<String>,
content: String,
ctx: &mut ViewContext<Self>,
) {
let update_manager = UpdateManager::handle(ctx);
if let Some(owner) = self.owner {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name,
content,
suggested_logging_id: None,
});
update_manager.update(ctx, |update_manager, ctx| {
update_manager.create_ai_fact(ai_fact, ClientId::default(), owner, ctx);
});
}
}
pub fn edit_ai_rule(
&mut self,
name: Option<String>,
content: String,
sync_id: SyncId,
revision_ts: Option<Revision>,
ctx: &mut ViewContext<Self>,
) {
let update_manager = UpdateManager::handle(ctx);
let (is_autogenerated, suggested_logging_id) = CloudModel::as_ref(ctx)
.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(&sync_id)
.map(|ai_fact| {
let AIFact::Memory(AIMemory {
is_autogenerated,
suggested_logging_id,
..
}) = ai_fact.model().string_model.clone();
(is_autogenerated, suggested_logging_id)
})
.unwrap_or((false, None));
update_manager.update(ctx, |update_manager, ctx| {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated,
name,
content,
suggested_logging_id,
});
update_manager.update_ai_fact(ai_fact, sync_id, revision_ts, ctx);
});
}
pub fn delete_ai_rule(&mut self, id: SyncId, ctx: &mut ViewContext<Self>) {
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
update_manager.delete_object_by_user(
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
id,
},
ctx,
);
});
}
pub fn add_predefined_rules(&mut self, ctx: &mut ViewContext<Self>) {
let Some(owner) = self.owner else {
return;
};
// Build a map of existing system-defined rules by name for update detection
let existing_system_rules: std::collections::HashMap<String, (SyncId, Option<Revision>)> =
self.cloud_global_rules
.iter()
.filter_map(|row| {
let AIFact::Memory(AIMemory { ref name, .. }) = row.fact.model().string_model;
let name = name.as_deref().unwrap_or_default();
if is_predefined_rule(name) {
Some((
name.to_string(),
(row.fact.sync_id(), row.fact.metadata().revision.clone()),
))
} else {
None
}
})
.collect();
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
for rule in PREDEFINED_RULES {
let ai_fact = AIFact::Memory(AIMemory {
is_autogenerated: false,
name: Some(rule.name.to_string()),
content: rule.content.to_string(),
suggested_logging_id: None,
});
if let Some((sync_id, revision)) = existing_system_rules.get(rule.name) {
update_manager.update_ai_fact(ai_fact, *sync_id, revision.clone(), ctx);
} else {
update_manager.create_ai_fact(ai_fact, ClientId::default(), owner, ctx);
}
}
});
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
galaxyui::elements::Icon::new(
Icon::BookOpen.into(),
appearance
.theme()
.main_text_color(appearance.theme().background()),
)
.finish(),
)
.with_width(style::ICON_SIZE)
.with_height(style::ICON_SIZE)
.finish(),
)
.with_margin_right(style::ICON_MARGIN)
.finish(),
)
.with_child(
appearance
.ui_builder()
.wrappable_text(HEADER_TEXT, true)
.with_style(style::header_text())
.build()
.finish(),
)
.finish()
}
fn render_description(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
appearance
.ui_builder()
.wrappable_text(DESCRIPTION_TEXT, true)
.with_style(style::description_text(appearance))
.build()
.finish(),
)
.with_vertical_margin(style::ITEM_BOTTOM_MARGIN)
.finish()
}
fn render_scope_tabs(&self, appearance: &Appearance) -> Box<dyn Element> {
let global_tab = Container::new(self.render_scope_tab(
"Global",
RuleScope::Global,
appearance,
self.global_tab_mouse_state.clone(),
))
.with_padding_right(4.)
.finish();
let project_tab = self.render_scope_tab(
"Project based",
RuleScope::ProjectBased,
appearance,
self.project_tab_mouse_state.clone(),
);
Container::new(
Flex::row()
.with_child(global_tab)
.with_child(project_tab)
.finish(),
)
.with_margin_bottom(style::SECTION_MARGIN)
.finish()
}
fn render_scope_tab(
&self,
title: &str,
scope: RuleScope,
appearance: &Appearance,
mouse_state: MouseStateHandle,
) -> Box<dyn Element> {
let is_selected = self.current_scope == scope;
let text_color = if is_selected {
appearance
.theme()
.main_text_color(appearance.theme().background())
} else {
appearance
.theme()
.sub_text_color(appearance.theme().background())
};
let title_owned = title.to_string();
Hoverable::new(mouse_state, move |state| {
let mut container = Container::new(
appearance
.ui_builder()
.wrappable_text(title_owned.clone(), true)
.with_style(UiComponentStyles {
font_size: Some(style::TEXT_FONT_SIZE),
font_color: Some(text_color.into()),
..Default::default()
})
.build()
.finish(),
)
.with_horizontal_padding(style::ROW_HORIZONTAL_PADDING)
.with_vertical_padding(8.);
if is_selected {
container = container
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
));
} else if state.is_hovered() {
container = container
.with_background(appearance.theme().surface_1())
.with_corner_radius(CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
));
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::SelectScope(scope));
})
.finish()
}
fn render_add_button(&self) -> Box<dyn Element> {
Container::new(
ChildView::new(if self.current_scope == RuleScope::ProjectBased {
&self.initialize_button
} else {
&self.add_button
})
.finish(),
)
.with_margin_left(style::SECTION_MARGIN)
.finish()
}
fn render_disabled_banner(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut link = FormattedTextFragment::hyperlink(DISABLED_BANNER_LINK_TEXT, "Settings > AI");
link.styles.weight = Some(CustomWeight::Bold);
let formatted_text = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::bold(DISABLED_BANNER_TEXT),
link,
FormattedTextFragment::bold(DISABLED_BANNER_TEXT_2),
])]),
style::SUBTEXT_FONT_SIZE,
appearance.ui_font_family(),
appearance.ui_font_family(),
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
self.disabled_banner_highlight_index.clone(),
)
.with_hyperlink_font_color(internal_colors::accent_fg_strong(appearance.theme()).into())
.register_default_click_handlers(|_, ctx, _| {
ctx.dispatch_typed_action(RuleViewAction::OpenSettings);
});
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::Info
.to_galaxyui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish(),
)
.with_width(style::BANNER_ICON_SIZE)
.with_height(style::BANNER_ICON_SIZE)
.finish(),
)
.with_margin_right(style::ROW_ICON_MARGIN)
.finish(),
)
.with_child(Expanded::new(1., formatted_text.finish()).finish())
.finish(),
)
.with_background(appearance.theme().accent_overlay())
.with_corner_radius(CornerRadius::with_all(galaxyui::elements::Radius::Pixels(
4.,
)))
.with_uniform_padding(style::BANNER_PADDING)
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
.finish()
}
fn render_add_predefined_rules_button(&self) -> Box<dyn Element> {
Container::new(ChildView::new(&self.add_predefined_rules_button).finish())
.with_margin_left(style::SECTION_MARGIN)
.finish()
}
fn render_search_bar_row(&self, filtered_rules: &[RuleRow]) -> Box<dyn Element> {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., ChildView::new(&self.search_bar).finish()).finish());
if self.current_scope == RuleScope::Global {
row.add_child(self.render_add_predefined_rules_button());
}
if !filtered_rules.is_empty() {
row.add_child(self.render_add_button());
}
Container::new(row.finish())
.with_margin_bottom(style::SECTION_MARGIN)
.finish()
}
fn render_sync_status_icon(
&self,
ai_row: CloudRuleRow,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
// Don't show icon if the syncing is in progress.
if is_syncing(ai_row.fact.clone(), app) {
return None;
}
let item = ai_row.fact.to_warp_drive_item(appearance)?;
let icon = item.sync_status_icon(
SyncQueue::as_ref(app).is_dequeueing(),
ai_row.mouse_states.sync_status_icon.clone(),
appearance,
)?;
Some(
Hoverable::new(ai_row.mouse_states.sync_status_hover.clone(), |state| {
let mut container = Container::new(icon)
.with_border(Border::all(1.))
.with_uniform_padding(4.);
if state.is_hovered() {
container = container
.with_background(appearance.theme().surface_2())
.with_border(
Border::all(1.).with_border_fill(appearance.theme().surface_3()),
);
}
container.with_margin_right(style::ROW_ICON_MARGIN).finish()
})
.finish(),
)
}
fn render_file_backed_row(
&self,
project_row: FileBackedRow,
appearance: &Appearance,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let row_name = display_path_with_host(&project_row.file_path, false, app);
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(
Shrinkable::new(
1.,
appearance
.ui_builder()
.wrappable_text(row_name, true)
.with_style(style::fact_project_based_row_text(appearance))
.build()
.finish(),
)
.finish(),
);
let file_path = project_row.file_path.clone();
row.add_child(
appearance
.ui_builder()
.button(ButtonVariant::Outlined, project_row.mouse_state.clone())
.with_text_label("Open file".to_string())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::OpenFile(file_path.clone()));
})
.finish(),
);
Some(
Container::new(row.finish())
.with_background(internal_colors::neutral_1(appearance.theme()))
.with_corner_radius(CornerRadius::with_all(galaxyui::elements::Radius::Pixels(
4.,
)))
.with_border(
Border::all(1.)
.with_border_color(internal_colors::neutral_2(appearance.theme())),
)
.with_horizontal_padding(style::ROW_HORIZONTAL_PADDING)
.with_vertical_padding(style::RULE_VERTICAL_PADDING)
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
.finish(),
)
}
fn render_global_rule_row(
&self,
ai_row: CloudRuleRow,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let AIFact::Memory(AIMemory { name, content, .. }) =
ai_row.fact.model().string_model.clone();
let formatted_name = match name {
Some(name) => {
if name.is_empty() {
"Untitled".to_string()
} else {
name
}
}
None => "Untitled".to_string(),
};
// Truncate content to 3 lines
let formatted_content = if content.split("\n").count() > 3 {
content
.split("\n")
.take(3)
.collect::<Vec<&str>>()
.join("\n")
+ "..."
} else {
content
};
let fact_text = Flex::column()
.with_child(
appearance
.ui_builder()
.wrappable_text(formatted_name, true)
.with_style(style::fact_row_text(appearance))
.build()
.finish(),
)
.with_child(
appearance
.ui_builder()
.wrappable_text(formatted_content, true)
.with_style(style::fact_row_subtext(appearance))
.build()
.finish(),
)
.finish();
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(sync_status_icon) =
self.render_sync_status_icon(ai_row.clone(), appearance, app)
{
row.add_child(sync_status_icon);
}
row.add_child(Expanded::new(1., fact_text).finish());
if is_delete_allowed(ai_row.fact.clone(), app) {
let delete_sync_id = ai_row.fact.sync_id();
let delete_button = Hoverable::new(ai_row.mouse_states.delete_hover.clone(), |state| {
let mut container = Container::new(
ConstrainedBox::new(
Icon::Trash
.to_galaxyui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
));
if state.is_hovered() {
container = container.with_background(appearance.theme().surface_2());
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::Delete(delete_sync_id));
})
.finish();
row.add_child(delete_button);
}
let mut hoverable = Hoverable::new(ai_row.mouse_states.hover.clone(), |state| {
let mut bg_color = internal_colors::neutral_1(appearance.theme());
if state.is_hovered() {
bg_color = internal_colors::neutral_4(appearance.theme());
}
Container::new(row.finish())
.with_background(bg_color)
.with_corner_radius(CornerRadius::with_all(galaxyui::elements::Radius::Pixels(
4.,
)))
.with_border(
Border::all(1.)
.with_border_color(internal_colors::neutral_2(appearance.theme())),
)
.with_horizontal_padding(style::ROW_HORIZONTAL_PADDING)
.with_vertical_padding(style::RULE_VERTICAL_PADDING)
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
.finish()
});
if is_edit_allowed(ai_row.fact.clone(), app) {
hoverable = hoverable
.with_cursor(Cursor::PointingHand)
.with_defer_events_to_children()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::Edit(ai_row.fact.sync_id()));
});
}
hoverable.finish()
}
fn render_items(
&self,
appearance: &Appearance,
mut filtered_rules: Vec<RuleRow>,
app: &AppContext,
) -> Box<dyn Element> {
let mut col = Flex::column();
// Filter the rows based on the search query
let search_term = self.search_editor.as_ref(app).buffer_text(app);
if !search_term.is_empty() {
filtered_rules = filtered_rules
.iter()
.filter(|row| row.matches_search_term(search_term.as_str()))
.cloned()
.collect();
}
// Sort the rows by the last modified timestamp
filtered_rules.sort_by(|a, b| a.cmp(b));
for row in filtered_rules {
let row = match row {
RuleRow::Global(global_row) => {
Some(self.render_global_rule_row(*global_row, appearance, app))
}
RuleRow::FileBacked(file_row) => {
self.render_file_backed_row(file_row, appearance, app)
}
};
if let Some(row) = row {
col.add_child(row);
}
}
col.finish()
}
fn render_zero_state(&self, appearance: &Appearance) -> Box<dyn Element> {
let text = match self.current_scope {
RuleScope::Global => ZERO_STATE_TEXT,
RuleScope::ProjectBased => ZERO_STATE_TEXT_PROJECT,
};
let centered_text = appearance
.ui_builder()
.wrappable_text(text, true)
.with_style(style::description_text(appearance))
.build()
.finish();
Container::new(
ConstrainedBox::new(
Align::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(Align::new(centered_text).top_center().finish())
.with_horizontal_padding(style::ROW_HORIZONTAL_PADDING)
.finish(),
)
.with_child(self.render_add_button())
.finish(),
)
.finish(),
)
.with_height(style::ZERO_STATE_HEIGHT)
.finish(),
)
.with_horizontal_padding(style::ROW_HORIZONTAL_PADDING)
.with_border(
Border::all(1.).with_border_color(internal_colors::neutral_2(appearance.theme())),
)
.with_margin_bottom(style::SECTION_MARGIN)
.finish()
}
fn render_body(
&self,
appearance: &Appearance,
filtered_rules: Vec<RuleRow>,
app: &AppContext,
) -> Box<dyn Element> {
Flex::column()
.with_child(self.render_search_bar_row(&filtered_rules))
.with_child(self.render_items(appearance, filtered_rules, app))
.finish()
}
}
impl Entity for RuleView {
type Event = RuleViewEvent;
}
impl View for RuleView {
fn ui_name() -> &'static str {
"RuleView"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.search_editor);
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut col = Flex::column()
.with_child(self.render_header(appearance))
.with_child(self.render_description(appearance));
col.add_child(self.render_scope_tabs(appearance));
let ai_settings = AISettings::as_ref(app);
if !ai_settings.is_memory_enabled(app) {
col.add_child(self.render_disabled_banner(appearance));
}
let filtered_rules = self.get_filtered_rules();
if filtered_rules.is_empty() {
col.add_child(self.render_zero_state(appearance));
} else {
col.add_child(self.render_body(appearance, filtered_rules, app));
};
col.finish()
}
}
impl TypedActionView for RuleView {
type Action = RuleViewAction;
fn handle_action(&mut self, action: &RuleViewAction, ctx: &mut ViewContext<Self>) {
match action {
RuleViewAction::AddRule => {
ctx.emit(RuleViewEvent::AddRule);
}
RuleViewAction::AddPredefinedRules => {
self.add_predefined_rules(ctx);
}
RuleViewAction::Edit(sync_id) => {
ctx.emit(RuleViewEvent::Edit(*sync_id));
}
RuleViewAction::Delete(sync_id) => {
self.delete_ai_rule(*sync_id, ctx);
}
RuleViewAction::OpenSettings => {
ctx.emit(RuleViewEvent::OpenSettings);
}
RuleViewAction::SelectScope(scope) => {
self.select_scope(*scope, ctx);
}
RuleViewAction::OpenFile(path) => {
ctx.emit(RuleViewEvent::OpenFile(path.clone()));
}
RuleViewAction::InitializeProject => {
let file_picker_config = FilePickerConfiguration::new().folders_only();
let window_id = ctx.window_id();
ctx.open_file_picker(
move |result, ctx| match result {
Ok(paths) => {
if let Some(directory_path) = paths.first() {
let path = PathBuf::from(directory_path);
ctx.emit(RuleViewEvent::InitializeProject(path));
}
}
Err(err) => {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("{err}")),
window_id,
ctx,
);
});
}
},
file_picker_config,
);
}
}
}
}