Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
use crate::ai::facts::AIFactView;
|
||||
use crate::{
|
||||
pane_group::{AIFactPane, PaneContent},
|
||||
PaneViewLocator,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
|
||||
|
||||
/// Singleton model to manage state of AI fact panes across multiple windows
|
||||
/// (where only one AI fact pane can exist per window). Specifically:
|
||||
/// - Maintains AI fact view handles to preserve state when panes are hidden
|
||||
/// - Tracks currently open AI fact panes and their location
|
||||
#[derive(Default)]
|
||||
pub struct AIFactManager {
|
||||
panes: HashMap<WindowId, AIFactPaneData>,
|
||||
}
|
||||
|
||||
struct AIFactPaneData {
|
||||
locator: Option<PaneViewLocator>,
|
||||
view: ViewHandle<AIFactView>,
|
||||
}
|
||||
|
||||
impl AIFactManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn ai_fact_view(&self, window_id: WindowId) -> ViewHandle<AIFactView> {
|
||||
self.panes
|
||||
.get(&window_id)
|
||||
.expect("Window should have corresponding AI fact view")
|
||||
.view
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn register_view(&mut self, window_id: WindowId, view: ViewHandle<AIFactView>) {
|
||||
if let Some(data) = self.panes.get_mut(&window_id) {
|
||||
data.view = view;
|
||||
} else {
|
||||
self.panes.insert(
|
||||
window_id,
|
||||
AIFactPaneData {
|
||||
locator: None,
|
||||
view,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_pane(&self, window_id: WindowId) -> Option<PaneViewLocator> {
|
||||
self.panes.get(&window_id).and_then(|data| data.locator)
|
||||
}
|
||||
|
||||
pub fn register_pane(
|
||||
&mut self,
|
||||
pane: &AIFactPane,
|
||||
pane_group_id: EntityId,
|
||||
window_id: WindowId,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(data) = self.panes.get_mut(&window_id) {
|
||||
data.locator = Some(PaneViewLocator {
|
||||
pane_group_id,
|
||||
pane_id: pane.id(),
|
||||
});
|
||||
} else {
|
||||
log::warn!("AI fact view should already exist for AI fact pane");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deregister_pane(&mut self, window_id: &WindowId, _ctx: &mut ModelContext<Self>) {
|
||||
if let Some(data) = self.panes.get_mut(window_id) {
|
||||
data.locator = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AIFactManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AIFactManager {}
|
||||
@@ -0,0 +1,128 @@
|
||||
use crate::ai::agent::SuggestedLoggingId;
|
||||
use crate::drive::items::{ai_fact::WarpDriveAIFact, WarpDriveItem};
|
||||
use crate::server::{ids::SyncId, sync_queue::QueueItem};
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Revision, ServerCloudObject,
|
||||
},
|
||||
drive::CloudObjectTypeAndId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
|
||||
pub mod manager;
|
||||
pub mod view;
|
||||
pub use manager::AIFactManager;
|
||||
pub use view::{AIFactView, AIFactViewEvent};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AIFact {
|
||||
#[serde(rename = "memory")]
|
||||
Memory(AIMemory),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AIMemory {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub content: String,
|
||||
// Deprecated: This field is no longer used and will be removed in the future.
|
||||
#[serde(default)]
|
||||
pub is_autogenerated: bool,
|
||||
/// If this rule was created from a suggested rule, record the suggestion's logging_id
|
||||
/// so we can suppress re-surfacing the same suggestion in future responses.
|
||||
#[serde(default)]
|
||||
pub suggested_logging_id: Option<SuggestedLoggingId>,
|
||||
}
|
||||
|
||||
impl AIFact {
|
||||
pub fn is_memory(&self) -> bool {
|
||||
matches!(self, AIFact::Memory { .. })
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudAIFact = GenericCloudObject<GenericStringObjectId, CloudAIFactModel>;
|
||||
pub type CloudAIFactModel = GenericStringModel<AIFact, JsonSerializer>;
|
||||
|
||||
impl StringModel for AIFact {
|
||||
type CloudObjectType = CloudAIFact;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
"Rule"
|
||||
}
|
||||
|
||||
fn should_enforce_revisions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(JsonObjectType::AIFact)
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
match self {
|
||||
AIFact::Memory(memory) => memory.content.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &Self::CloudObjectType,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateAIFact {
|
||||
model: object.model().clone().into(),
|
||||
id: object.id,
|
||||
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::AIFact(server_ai_fact) = server_cloud_object {
|
||||
return Some(server_ai_fact.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
|
||||
None
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
id: SyncId,
|
||||
_appearance: &Appearance,
|
||||
ai_fact: &CloudAIFact,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
Some(Box::new(WarpDriveAIFact::new(
|
||||
CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
|
||||
id,
|
||||
},
|
||||
ai_fact.clone(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for AIFact {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::AIFact
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use crate::cloud_object::{
|
||||
CloudObject, CloudObjectSyncStatus, GenericStringObjectFormat, JsonObjectType,
|
||||
};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::{pane::view, BackingView, PaneConfiguration, PaneEvent};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use std::path::PathBuf;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CrossAxisAlignment, Expanded, Flex, MainAxisAlignment, MainAxisSize, ParentElement,
|
||||
ScrollbarWidth,
|
||||
},
|
||||
ui_components::components::UiComponent,
|
||||
AppContext, Element, Entity, FocusContext, ModelHandle, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::ui_components::icons::Icon;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{SingletonEntity, ViewHandle};
|
||||
|
||||
use super::{AIFact, CloudAIFact, CloudAIFactModel};
|
||||
|
||||
pub mod rule;
|
||||
pub mod rule_editor;
|
||||
mod style;
|
||||
use rule::*;
|
||||
use rule_editor::*;
|
||||
|
||||
const OFFLINE_TEXT: &str = "You are offline. Some rules will be read only.";
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum AIFactPage {
|
||||
#[default]
|
||||
Rules,
|
||||
RuleEditor {
|
||||
sync_id: Option<SyncId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AIFactPage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AIFactPage::Rules => write!(f, "Rules"),
|
||||
AIFactPage::RuleEditor { .. } => write!(f, "Rule Editor"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AIFactViewEvent {
|
||||
Pane(PaneEvent),
|
||||
OpenSettings,
|
||||
OpenFile(PathBuf),
|
||||
InitializeProject(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AIFactViewAction {
|
||||
AddRule,
|
||||
UpdatePage(AIFactPage),
|
||||
}
|
||||
|
||||
pub struct AIFactView {
|
||||
pane_configuration: ModelHandle<PaneConfiguration>,
|
||||
focus_handle: Option<PaneFocusHandle>,
|
||||
current_page: AIFactPage,
|
||||
rule_view: ViewHandle<RuleView>,
|
||||
rule_editor_view: ViewHandle<RuleEditorView>,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl AIFactView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new(HEADER_TEXT));
|
||||
|
||||
let rule_view = ctx.add_typed_action_view(RuleView::new);
|
||||
ctx.subscribe_to_view(&rule_view, |me, _, event, ctx| {
|
||||
me.handle_rule_view_event(event, ctx);
|
||||
});
|
||||
|
||||
let rule_editor_view = ctx.add_typed_action_view(RuleEditorView::new);
|
||||
ctx.subscribe_to_view(&rule_editor_view, |me, _, event, ctx| {
|
||||
me.handle_rule_editor_view_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
pane_configuration,
|
||||
focus_handle: None,
|
||||
rule_editor_view,
|
||||
rule_view,
|
||||
current_page: AIFactPage::default(),
|
||||
clipped_scroll_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
|
||||
self.pane_configuration.clone()
|
||||
}
|
||||
|
||||
pub fn current_page(&self) -> AIFactPage {
|
||||
self.current_page
|
||||
}
|
||||
|
||||
pub fn focus(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.current_page {
|
||||
AIFactPage::Rules => ctx.focus(&self.rule_view),
|
||||
AIFactPage::RuleEditor { .. } => ctx.focus(&self.rule_editor_view),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_rule_view_event(&mut self, event: &RuleViewEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
RuleViewEvent::AddRule => {
|
||||
self.update_page(AIFactPage::RuleEditor { sync_id: None }, ctx);
|
||||
}
|
||||
RuleViewEvent::Edit(sync_id) => {
|
||||
self.update_page(
|
||||
AIFactPage::RuleEditor {
|
||||
sync_id: Some(*sync_id),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
RuleViewEvent::OpenSettings => {
|
||||
ctx.emit(AIFactViewEvent::OpenSettings);
|
||||
}
|
||||
RuleViewEvent::OpenFile(path) => {
|
||||
ctx.emit(AIFactViewEvent::OpenFile(path.clone()));
|
||||
}
|
||||
RuleViewEvent::InitializeProject(path) => {
|
||||
ctx.emit(AIFactViewEvent::InitializeProject(path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_rule_editor_view_event(
|
||||
&mut self,
|
||||
event: &RuleEditorViewEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.update_page(AIFactPage::Rules, ctx);
|
||||
match event {
|
||||
RuleEditorViewEvent::Add { name, content } => {
|
||||
self.rule_view.update(ctx, |rule_view, ctx| {
|
||||
rule_view.add_ai_rule(name.clone(), content.clone(), ctx);
|
||||
});
|
||||
}
|
||||
RuleEditorViewEvent::Edit {
|
||||
name,
|
||||
content,
|
||||
sync_id,
|
||||
revision_ts,
|
||||
} => {
|
||||
self.rule_view.update(ctx, |rule_view, ctx| {
|
||||
rule_view.edit_ai_rule(
|
||||
name.clone(),
|
||||
content.clone(),
|
||||
*sync_id,
|
||||
revision_ts.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
RuleEditorViewEvent::Delete { sync_id } => {
|
||||
self.rule_view.update(ctx, |rule_view, ctx| {
|
||||
rule_view.delete_ai_rule(*sync_id, ctx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_page(&mut self, page: AIFactPage, ctx: &mut ViewContext<Self>) {
|
||||
self.current_page = page;
|
||||
if let AIFactPage::RuleEditor { sync_id } = page {
|
||||
self.rule_editor_view.update(ctx, |rule_editor_view, ctx| {
|
||||
rule_editor_view.set_ai_rule(sync_id, ctx);
|
||||
});
|
||||
}
|
||||
self.focus(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_offline_banner(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ConstrainedBox::new(
|
||||
Icon::CloudOffline
|
||||
.to_warpui_icon(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2()),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(style::ICON_SIZE)
|
||||
.with_height(style::ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(OFFLINE_TEXT, true)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(style::ICON_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(style::PANE_PADDING)
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AIFactView {
|
||||
type Event = AIFactViewEvent;
|
||||
}
|
||||
|
||||
impl View for AIFactView {
|
||||
fn ui_name() -> &'static str {
|
||||
"AIFactView"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
match self.current_page {
|
||||
AIFactPage::Rules => ctx.focus(&self.rule_view),
|
||||
AIFactPage::RuleEditor { .. } => ctx.focus(&self.rule_editor_view),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut col = Flex::column().with_main_axis_size(MainAxisSize::Min);
|
||||
if !is_online(app) {
|
||||
col.add_child(self.render_offline_banner(appearance));
|
||||
}
|
||||
match self.current_page {
|
||||
AIFactPage::Rules => col.add_child(ChildView::new(&self.rule_view).finish()),
|
||||
AIFactPage::RuleEditor { .. } => {
|
||||
col.add_child(ChildView::new(&self.rule_editor_view).finish())
|
||||
}
|
||||
}
|
||||
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
Align::new(
|
||||
Container::new(
|
||||
ConstrainedBox::new(col.finish())
|
||||
.with_max_width(style::PANE_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(style::PANE_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.top_center()
|
||||
.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
appearance.theme().nonactive_ui_detail().into(),
|
||||
appearance.theme().active_ui_detail().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AIFactView {
|
||||
type Action = AIFactViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &AIFactViewAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AIFactViewAction::AddRule => {
|
||||
self.rule_editor_view.update(ctx, |rule_editor_view, ctx| {
|
||||
rule_editor_view.set_ai_rule(None, ctx);
|
||||
});
|
||||
self.update_page(AIFactPage::RuleEditor { sync_id: None }, ctx);
|
||||
}
|
||||
AIFactViewAction::UpdatePage(page) => self.update_page(*page, ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackingView for AIFactView {
|
||||
type PaneHeaderOverflowMenuAction = AIFactViewAction;
|
||||
type CustomAction = ();
|
||||
type AssociatedData = ();
|
||||
|
||||
fn handle_pane_header_overflow_menu_action(
|
||||
&mut self,
|
||||
_action: &Self::PaneHeaderOverflowMenuAction,
|
||||
_ctx: &mut warpui::ViewContext<Self>,
|
||||
) {
|
||||
self.handle_action(_action, _ctx)
|
||||
}
|
||||
|
||||
fn close(&mut self, ctx: &mut warpui::ViewContext<Self>) {
|
||||
ctx.emit(AIFactViewEvent::Pane(PaneEvent::Close));
|
||||
}
|
||||
|
||||
fn focus_contents(&mut self, ctx: &mut warpui::ViewContext<Self>) {
|
||||
self.focus(ctx);
|
||||
}
|
||||
|
||||
fn render_header_content(
|
||||
&self,
|
||||
_ctx: &view::HeaderRenderContext<'_>,
|
||||
_app: &AppContext,
|
||||
) -> view::HeaderContent {
|
||||
view::HeaderContent::simple(HEADER_TEXT)
|
||||
}
|
||||
|
||||
fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext<Self>) {
|
||||
self.focus_handle = Some(focus_handle);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_online(app: &AppContext) -> bool {
|
||||
NetworkStatus::as_ref(app).is_online()
|
||||
}
|
||||
|
||||
pub fn is_delete_allowed(ai_fact: CloudAIFact, app: &AppContext) -> bool {
|
||||
let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
|
||||
id: ai_fact.sync_id(),
|
||||
};
|
||||
is_online(app)
|
||||
&& cloud_object_type_and_id.has_server_id()
|
||||
&& !ai_fact.metadata().has_pending_online_only_change()
|
||||
}
|
||||
|
||||
pub fn is_edit_allowed(ai_fact: CloudAIFact, app: &AppContext) -> bool {
|
||||
let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
|
||||
id: ai_fact.sync_id(),
|
||||
};
|
||||
is_online(app) || !cloud_object_type_and_id.has_server_id()
|
||||
}
|
||||
|
||||
pub fn is_syncing(ai_fact: CloudAIFact, app: &AppContext) -> bool {
|
||||
let sync_queue_is_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
let sync_status = &ai_fact.metadata().pending_changes_statuses;
|
||||
let has_in_flight_requests = matches!(
|
||||
&sync_status.content_sync_status,
|
||||
CloudObjectSyncStatus::InFlight(reqs) if reqs.0 > 0
|
||||
);
|
||||
(has_in_flight_requests && sync_queue_is_dequeueing)
|
||||
|| sync_status.has_pending_metadata_change
|
||||
|| sync_status.has_pending_permissions_change
|
||||
|| sync_status.pending_untrash
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
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::view_components::{
|
||||
action_button::{ActionButton, NakedTheme},
|
||||
DismissibleToast,
|
||||
};
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use ai::project_context::model::{ProjectContextModel, ProjectContextModelEvent};
|
||||
use markdown_parser::{
|
||||
weight::CustomWeight, FormattedText, FormattedTextFragment, FormattedTextLine,
|
||||
};
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use warp_core::ui::{
|
||||
appearance::{Appearance, AppearanceEvent},
|
||||
theme::color::internal_colors,
|
||||
};
|
||||
use warpui::elements::Shrinkable;
|
||||
use warpui::platform::FilePickerConfiguration;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use super::{is_edit_allowed, is_syncing, style, AIFact, CloudAIFact, CloudAIFactModel};
|
||||
use crate::ai::facts::AIMemory;
|
||||
|
||||
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 = "Once you add a rule, it will be shown here.";
|
||||
const ZERO_STATE_TEXT_PROJECT: &str =
|
||||
"Once you generate a WARP.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(PathBuf),
|
||||
InitializeProject(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RuleViewAction {
|
||||
AddRule,
|
||||
InitializeProject,
|
||||
Edit(SyncId),
|
||||
OpenSettings,
|
||||
SelectScope(RuleScope),
|
||||
OpenFile(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct MouseStateHandles {
|
||||
pub hover: MouseStateHandle,
|
||||
pub sync_status_hover: MouseStateHandle,
|
||||
pub sync_status_icon: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CloudRuleRow {
|
||||
fact: CloudAIFact,
|
||||
mouse_states: MouseStateHandles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProjectScopedRow {
|
||||
file_path: PathBuf,
|
||||
mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum RuleRow {
|
||||
Global(Box<CloudRuleRow>),
|
||||
ProjectScoped(ProjectScopedRow),
|
||||
}
|
||||
|
||||
impl RuleRow {
|
||||
fn matches_search_term(&self, search_term: &str) -> bool {
|
||||
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.to_lowercase().as_str())
|
||||
|| content
|
||||
.to_lowercase()
|
||||
.contains(search_term.to_lowercase().as_str())
|
||||
}
|
||||
RuleRow::ProjectScoped(row) => row
|
||||
.file_path
|
||||
.to_str()
|
||||
.map(|s| s.to_lowercase().contains(search_term))
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
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::ProjectScoped(a), RuleRow::ProjectScoped(b)) => a.file_path.cmp(&b.file_path),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RuleView {
|
||||
owner: Option<Owner>,
|
||||
global_rules: Vec<CloudRuleRow>,
|
||||
project_rules: Vec<ProjectScopedRow>,
|
||||
search_editor: ViewHandle<EditorView>,
|
||||
search_bar: ViewHandle<SearchBar>,
|
||||
add_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| ProjectScopedRow {
|
||||
file_path: p,
|
||||
mouse_state: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
ctx.subscribe_to_model(&project_context, |me, context_model, event, ctx| {
|
||||
if matches!(event, ProjectContextModelEvent::PathIndexed) {
|
||||
me.project_rules = context_model
|
||||
.as_ref(ctx)
|
||||
.indexed_rules()
|
||||
.map(|p| ProjectScopedRow {
|
||||
file_path: p,
|
||||
mouse_state: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
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 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))
|
||||
});
|
||||
|
||||
Self {
|
||||
owner,
|
||||
global_rules: ai_rules,
|
||||
project_rules,
|
||||
search_editor,
|
||||
search_bar,
|
||||
add_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()
|
||||
};
|
||||
self.global_rules = ai_rules
|
||||
.into_iter()
|
||||
.map(|ai_fact| CloudRuleRow {
|
||||
fact: ai_fact,
|
||||
mouse_states: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
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
|
||||
.global_rules
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|rule| RuleRow::Global(Box::new(rule)))
|
||||
.collect(),
|
||||
RuleScope::ProjectBased => self
|
||||
.project_rules
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(RuleRow::ProjectScoped)
|
||||
.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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
warpui::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(warpui::elements::Radius::Pixels(
|
||||
4.,
|
||||
)));
|
||||
} else if state.is_hovered() {
|
||||
container = container
|
||||
.with_background(appearance.theme().surface_1())
|
||||
.with_corner_radius(CornerRadius::with_all(warpui::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_warpui_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(warpui::elements::Radius::Pixels(4.)))
|
||||
.with_uniform_padding(style::BANNER_PADDING)
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_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 !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_project_based_row(
|
||||
&self,
|
||||
project_row: ProjectScopedRow,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let row_name = project_row.file_path.to_str().map(|s| s.to_string())?;
|
||||
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(warpui::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);
|
||||
|
||||
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());
|
||||
|
||||
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(warpui::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)
|
||||
.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::ProjectScoped(project_row) => {
|
||||
self.render_project_based_row(project_row, appearance)
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(text, true)
|
||||
.with_style(style::description_text(appearance))
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_add_button())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(style::ZERO_STATE_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.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::Edit(sync_id) => {
|
||||
ctx.emit(RuleViewEvent::Edit(*sync_id));
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{CloudObject, Revision};
|
||||
use crate::editor::{
|
||||
EditorOptions, EditorView, EnterAction, EnterSettings, Event as EditorEvent,
|
||||
PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::view_components::action_button::{ActionButton, DangerSecondaryTheme, PrimaryTheme};
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warp_editor::editor::NavigationKey;
|
||||
use warpui::elements::{Clipped, ConstrainedBox};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, Container, CornerRadius,
|
||||
CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
Radius, ScrollbarWidth,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponent,
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use super::{is_delete_allowed, style, AIFact, CloudAIFact, CloudAIFactModel};
|
||||
use crate::ai::facts::AIMemory;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const RULE_NAME_PLACEHOLDER_TEXT: &str = "e.g. Rust rules";
|
||||
const RULE_DESCRIPTION_PLACEHOLDER_TEXT: &str = "e.g. Never use unwrap in Rust";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum EditorType {
|
||||
Name,
|
||||
Content,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RuleEditorViewEvent {
|
||||
Back,
|
||||
Add {
|
||||
name: Option<String>,
|
||||
content: String,
|
||||
},
|
||||
Edit {
|
||||
name: Option<String>,
|
||||
content: String,
|
||||
sync_id: SyncId,
|
||||
revision_ts: Option<Revision>,
|
||||
},
|
||||
Delete {
|
||||
sync_id: SyncId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RuleEditorViewAction {
|
||||
Back,
|
||||
Save,
|
||||
Delete,
|
||||
}
|
||||
pub struct RuleEditorView {
|
||||
// Is None if we are adding a new rule, otherwise it is the existing rule we are editing.
|
||||
ai_fact: Option<CloudAIFact>,
|
||||
|
||||
current_editor: EditorType,
|
||||
name_editor: ViewHandle<EditorView>,
|
||||
content_editor: ViewHandle<EditorView>,
|
||||
|
||||
save_button: ViewHandle<ActionButton>,
|
||||
delete_button: ViewHandle<ActionButton>,
|
||||
back_button: MouseStateHandle,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl RuleEditorView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let network_status = NetworkStatus::handle(ctx);
|
||||
ctx.subscribe_to_model(&network_status, |_me, _, _event, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let font_family = appearance.ui_font_family();
|
||||
let text = TextOptions {
|
||||
font_size_override: Some(style::TEXT_FONT_SIZE),
|
||||
font_family_override: Some(font_family),
|
||||
..Default::default()
|
||||
};
|
||||
let name_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor = EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text: text.clone(),
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
editor.set_placeholder_text(RULE_NAME_PLACEHOLDER_TEXT, ctx);
|
||||
editor
|
||||
});
|
||||
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| {
|
||||
let mut editor = 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,
|
||||
);
|
||||
editor.set_placeholder_text(RULE_DESCRIPTION_PLACEHOLDER_TEXT, ctx);
|
||||
editor
|
||||
});
|
||||
ctx.subscribe_to_view(&content_editor, |me, _editor, event, ctx| {
|
||||
me.handle_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
let save_button = ctx.add_typed_action_view(|ctx| {
|
||||
let mut button = ActionButton::new("Save", PrimaryTheme)
|
||||
.with_icon(Icon::Check)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(RuleEditorViewAction::Save);
|
||||
});
|
||||
// Disable the button until the user has entered a description
|
||||
button.set_disabled(true, ctx);
|
||||
button
|
||||
});
|
||||
|
||||
let delete_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Delete rule", DangerSecondaryTheme)
|
||||
.with_icon(Icon::Trash)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(RuleEditorViewAction::Delete);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
ai_fact: None,
|
||||
current_editor: EditorType::Name,
|
||||
name_editor,
|
||||
content_editor,
|
||||
save_button,
|
||||
delete_button,
|
||||
back_button: Default::default(),
|
||||
clipped_scroll_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ai_rule(&mut self, sync_id: Option<SyncId>, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(sync_id) = sync_id {
|
||||
// Get the AIFact from the cloud model
|
||||
let Some(ai_fact) = CloudModel::as_ref(ctx)
|
||||
.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(&sync_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let AIFact::Memory(AIMemory { name, content, .. }) =
|
||||
ai_fact.model().string_model.clone();
|
||||
self.ai_fact = Some(ai_fact.clone());
|
||||
|
||||
// Update the UI with the AIFact
|
||||
self.name_editor.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text(name.unwrap_or_default().as_str(), ctx);
|
||||
});
|
||||
self.content_editor.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text(content.as_str(), ctx);
|
||||
});
|
||||
} else {
|
||||
self.ai_fact = None;
|
||||
self.name_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
});
|
||||
self.content_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
});
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
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::Focused => {
|
||||
self.current_editor = if self.name_editor.is_focused(ctx) {
|
||||
EditorType::Name
|
||||
} else {
|
||||
EditorType::Content
|
||||
};
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
EditorEvent::Edited(_) => {
|
||||
// Disable the save button if the description is empty
|
||||
let is_disabled = self.content_editor.as_ref(ctx).buffer_text(ctx).is_empty();
|
||||
self.save_button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(is_disabled, ctx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_back_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let button = icon_button(appearance, Icon::ArrowLeft, false, self.back_button.clone());
|
||||
Container::new(
|
||||
button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(RuleEditorViewAction::Back);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(style::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_save_button(&self, _appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(ChildView::new(&self.save_button).finish())
|
||||
.with_margin_left(style::SECTION_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = if self.ai_fact.is_none() {
|
||||
"Add Rule"
|
||||
} else {
|
||||
"Edit Rule"
|
||||
};
|
||||
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_child(self.render_back_button(appearance))
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(title, true)
|
||||
.with_style(style::header_text())
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_save_button(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_name_editor(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(Clipped::new(ChildView::new(&self.name_editor).finish()).finish())
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_border(
|
||||
Border::all(1.).with_border_color(internal_colors::neutral_4(appearance.theme())),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.with_horizontal_padding(style::EDITOR_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(style::EDITOR_VERTICAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_content_editor(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
ConstrainedBox::new(ChildView::new(&self.content_editor).finish())
|
||||
.with_min_height(style::EDITOR_MIN_HEIGHT)
|
||||
.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
appearance.theme().nonactive_ui_detail().into(),
|
||||
appearance.theme().active_ui_detail().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_border(
|
||||
Border::all(1.).with_border_color(internal_colors::neutral_4(appearance.theme())),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.with_padding_left(style::EDITOR_HORIZONTAL_PADDING)
|
||||
.with_vertical_padding(style::EDITOR_VERTICAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(style::EDITOR_MAX_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_form(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(appearance.ui_builder().span("Name").build().finish())
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_name_editor(appearance))
|
||||
.with_child(
|
||||
Container::new(appearance.ui_builder().span("Rule").build().finish())
|
||||
.with_margin_bottom(style::ITEM_BOTTOM_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_content_editor(appearance))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RuleEditorView {
|
||||
type Event = RuleEditorViewEvent;
|
||||
}
|
||||
|
||||
impl View for RuleEditorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"RuleEditorView"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
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 mut col = Flex::column()
|
||||
.with_child(self.render_header(appearance))
|
||||
.with_child(self.render_form(appearance));
|
||||
|
||||
if let Some(ai_fact) = &self.ai_fact {
|
||||
if is_delete_allowed(ai_fact.clone(), app) {
|
||||
col.add_child(ChildView::new(&self.delete_button).finish());
|
||||
}
|
||||
}
|
||||
col.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for RuleEditorView {
|
||||
type Action = RuleEditorViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &RuleEditorViewAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
RuleEditorViewAction::Back => {
|
||||
ctx.emit(RuleEditorViewEvent::Back);
|
||||
}
|
||||
RuleEditorViewAction::Save => {
|
||||
let name = self.name_editor.as_ref(ctx).buffer_text(ctx);
|
||||
let name = if name.is_empty() { None } else { Some(name) };
|
||||
let content = self.content_editor.as_ref(ctx).buffer_text(ctx);
|
||||
if let Some(ai_fact) = &self.ai_fact {
|
||||
ctx.emit(RuleEditorViewEvent::Edit {
|
||||
name,
|
||||
content,
|
||||
sync_id: ai_fact.sync_id(),
|
||||
revision_ts: ai_fact.metadata().revision.clone(),
|
||||
});
|
||||
} else {
|
||||
// Using AIMemory with is_autogenerated set to false to represent a manually created rule
|
||||
ctx.emit(RuleEditorViewEvent::Add { name, content });
|
||||
}
|
||||
}
|
||||
RuleEditorViewAction::Delete => {
|
||||
if let Some(ai_fact) = &self.ai_fact {
|
||||
ctx.emit(RuleEditorViewEvent::Delete {
|
||||
sync_id: ai_fact.sync_id(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
|
||||
use warpui::{
|
||||
elements::{CornerRadius, Radius},
|
||||
fonts::Weight,
|
||||
ui_components::components::{Coords, UiComponentStyles},
|
||||
};
|
||||
|
||||
pub const HEADER_FONT_SIZE: f32 = 16.;
|
||||
pub const TEXT_FONT_SIZE: f32 = 14.;
|
||||
pub const SUBTEXT_FONT_SIZE: f32 = 12.;
|
||||
pub const ICON_SIZE: f32 = 16.;
|
||||
|
||||
pub const BANNER_ICON_SIZE: f32 = 14.;
|
||||
pub const BANNER_PADDING: f32 = 12.;
|
||||
|
||||
pub const ICON_MARGIN: f32 = 8.;
|
||||
pub const ROW_ICON_MARGIN: f32 = 4.;
|
||||
|
||||
pub const RULE_VERTICAL_PADDING: f32 = 12.;
|
||||
pub const ROW_HORIZONTAL_PADDING: f32 = 12.;
|
||||
pub const ITEM_BOTTOM_MARGIN: f32 = 12.;
|
||||
|
||||
pub const EDITOR_HORIZONTAL_PADDING: f32 = 16.;
|
||||
pub const EDITOR_VERTICAL_PADDING: f32 = 10.;
|
||||
pub const EDITOR_MIN_HEIGHT: f32 = 240.;
|
||||
pub const EDITOR_MAX_HEIGHT: f32 = 320.;
|
||||
|
||||
pub const SECTION_MARGIN: f32 = 16.;
|
||||
pub const PANE_PADDING: f32 = 16.;
|
||||
pub const PANE_WIDTH: f32 = 800.;
|
||||
pub const ZERO_STATE_HEIGHT: f32 = 643.;
|
||||
|
||||
pub fn search_bar(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
background: Some(internal_colors::neutral_2(appearance.theme()).into()),
|
||||
border_color: Some(internal_colors::neutral_4(appearance.theme()).into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
|
||||
padding: Some(Coords {
|
||||
top: 8.,
|
||||
bottom: 8.,
|
||||
left: 12.,
|
||||
right: 12.,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header_text() -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(HEADER_FONT_SIZE),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description_text(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
margin: Some(Coords {
|
||||
bottom: 8.,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fact_row_subtext(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(SUBTEXT_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fact_row_text(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fact_project_based_row_text(appearance: &Appearance) -> UiComponentStyles {
|
||||
UiComponentStyles {
|
||||
font_size: Some(TEXT_FONT_SIZE),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user