Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::document::ai_document_model::AIDocumentId,
|
||||
cloud_object::{
|
||||
breadcrumbs::ContainingObject,
|
||||
model::{
|
||||
persistence::{CloudModel, CloudModelEvent},
|
||||
view::{CloudViewModel, Editor, EditorState},
|
||||
},
|
||||
CloudObject, Owner, Space,
|
||||
},
|
||||
drive::sharing::{ContentEditability, SharingAccessLevel},
|
||||
notebooks::CloudNotebook,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ClientId, SyncId},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{CloudNotebookModel, NotebookId};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub enum ActiveNotebook {
|
||||
#[default]
|
||||
None,
|
||||
// A notebook already stored in CloudModel, all relevant data should be queried
|
||||
// from CloudModel directly
|
||||
CommittedNotebook(SyncId),
|
||||
// A notebook that has been created and displayed in the view, but is not yet
|
||||
// committed to CloudModel
|
||||
NewNotebook(Box<CloudNotebook>),
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Default, Clone, Copy, Debug)]
|
||||
pub enum Mode {
|
||||
#[default]
|
||||
Editing,
|
||||
View,
|
||||
}
|
||||
|
||||
/// True if the object is currently being saved. We don't allow editing workflows
|
||||
/// yet so this is only used for notebooks, but we will want it to apply for
|
||||
/// workflows also.
|
||||
#[derive(Default)]
|
||||
pub enum SavingStatus {
|
||||
#[default]
|
||||
Saved,
|
||||
Saving,
|
||||
}
|
||||
|
||||
/// Data displayed in the status bar that is also relevant for workflows and notebooks.
|
||||
/// We share this data between views by making it a model.
|
||||
#[derive(Default)]
|
||||
pub struct ActiveNotebookData {
|
||||
/// Whether we're in editing, readonly or viewing mode.
|
||||
pub mode: Mode,
|
||||
pub saving_status: SavingStatus,
|
||||
pub active_notebook: ActiveNotebook,
|
||||
|
||||
pub show_grab_edit_access_modal: bool,
|
||||
pub feature_not_available: bool,
|
||||
}
|
||||
|
||||
impl ActiveNotebookData {
|
||||
pub fn new(ctx: &mut ModelContext<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);
|
||||
});
|
||||
|
||||
Self {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
||||
match event {
|
||||
CloudModelEvent::NotebookEditorChangedFromServer { notebook_id } => {
|
||||
if self.is_active_notebook(*notebook_id) {
|
||||
if let Some(new_editor) =
|
||||
CloudViewModel::as_ref(ctx).object_current_editor(¬ebook_id.uid(), ctx)
|
||||
{
|
||||
if self.mode == Mode::Editing
|
||||
&& matches!(new_editor.state, EditorState::OtherUserActive)
|
||||
{
|
||||
self.mode = Mode::View;
|
||||
ctx.emit(ActiveNotebookDataEvent::ModeChangedFromServer);
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
CloudModelEvent::ObjectMoved { type_and_id, .. } => {
|
||||
if let Some(notebook_id) = type_and_id.as_notebook_id() {
|
||||
// Update breadcrumb when a notebook is moved, whether by the user or a
|
||||
// teammate.
|
||||
if self.is_active_notebook(notebook_id) {
|
||||
ctx.emit(ActiveNotebookDataEvent::BreadcrumbsChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
event: &UpdateManagerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
match (&result.operation, &result.success_type) {
|
||||
(ObjectOperation::Create { .. }, OperationSuccessType::Success) => {
|
||||
if let Some(current_id) = self.id() {
|
||||
if current_id.into_client() == result.client_id {
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
let notebook_id: NotebookId = server_id.into();
|
||||
self.feature_not_available = false;
|
||||
self.saving_status = SavingStatus::Saved;
|
||||
self.active_notebook =
|
||||
ActiveNotebook::CommittedNotebook(SyncId::ServerId(notebook_id.into()));
|
||||
ctx.emit(ActiveNotebookDataEvent::BreadcrumbsChanged);
|
||||
ctx.emit(ActiveNotebookDataEvent::CreatedOnServer);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
(ObjectOperation::Update, OperationSuccessType::Success) => {
|
||||
if let Some(current_id) = self.id() {
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
if current_id.into_server() == Some(server_id) {
|
||||
self.feature_not_available = false;
|
||||
self.saving_status = SavingStatus::Saved;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
(ObjectOperation::Update, OperationSuccessType::Rejection) => {
|
||||
let current_id = self.id();
|
||||
if let Some(id) = current_id {
|
||||
let server_id = result
|
||||
.server_id
|
||||
.expect("Expect server id on update rejection");
|
||||
if id.into_server() == Some(server_id) {
|
||||
self.feature_not_available = false;
|
||||
ctx.emit(ActiveNotebookDataEvent::EditRejected);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
(ObjectOperation::Update, OperationSuccessType::FeatureNotAvailable) => {
|
||||
let current_id = self.id();
|
||||
if let Some(id) = current_id {
|
||||
let server_id = result
|
||||
.server_id
|
||||
.expect("Expect server id on update failure");
|
||||
if id.into_server() == Some(server_id) {
|
||||
self.feature_not_available = true;
|
||||
ctx.emit(ActiveNotebookDataEvent::EditRejected);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
(ObjectOperation::TakeEditAccess, OperationSuccessType::Success) => {
|
||||
let current_id = self.id();
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
if let Some(id) = current_id {
|
||||
if id.into_server() == Some(server_id) {
|
||||
self.feature_not_available = false;
|
||||
self.mode = Mode::Editing;
|
||||
ctx.emit(ActiveNotebookDataEvent::SwitchedToEditMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
(ObjectOperation::Trash, OperationSuccessType::Success)
|
||||
| (ObjectOperation::Untrash, OperationSuccessType::Success) => {
|
||||
let current_id = self.id();
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
if let Some(id) = current_id {
|
||||
if id.into_server() == Some(server_id) {
|
||||
ctx.emit(ActiveNotebookDataEvent::TrashStatusChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
(ObjectOperation::MoveToDrive, OperationSuccessType::Success) => {
|
||||
let current_id = self.id();
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
if let Some(id) = current_id {
|
||||
if id.into_server() == Some(server_id) {
|
||||
ctx.emit(ActiveNotebookDataEvent::MovedToSpace);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.mode = Mode::View;
|
||||
self.saving_status = SavingStatus::default();
|
||||
self.show_grab_edit_access_modal = false;
|
||||
self.active_notebook = ActiveNotebook::None;
|
||||
self.feature_not_available = false;
|
||||
}
|
||||
|
||||
pub fn open_new(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
initial_folder_id: Option<SyncId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.reset();
|
||||
|
||||
// create a new client id
|
||||
let new_id = ClientId::default();
|
||||
|
||||
// Set the active notebook to be an uncommited notebook
|
||||
self.active_notebook = ActiveNotebook::NewNotebook(Box::new(CloudNotebook::new_local(
|
||||
CloudNotebookModel::default(),
|
||||
owner,
|
||||
initial_folder_id,
|
||||
new_id,
|
||||
)));
|
||||
ctx.emit(ActiveNotebookDataEvent::BreadcrumbsChanged);
|
||||
}
|
||||
|
||||
pub fn open_existing(&mut self, notebook_id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
self.reset();
|
||||
self.active_notebook = ActiveNotebook::CommittedNotebook(notebook_id);
|
||||
ctx.emit(ActiveNotebookDataEvent::BreadcrumbsChanged);
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Option<SyncId> {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::None => None,
|
||||
ActiveNotebook::CommittedNotebook(id) => Some(*id),
|
||||
ActiveNotebook::NewNotebook(notebook) => Some(notebook.id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ai_document_id(&self, ctx: &AppContext) -> Option<AIDocumentId> {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::None => None,
|
||||
ActiveNotebook::CommittedNotebook(id) => CloudModel::as_ref(ctx)
|
||||
.get_notebook(id)
|
||||
.and_then(|n| n.model().ai_document_id),
|
||||
ActiveNotebook::NewNotebook(notebook) => notebook.model().ai_document_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_notebook(&self) -> ActiveNotebook {
|
||||
self.active_notebook.clone()
|
||||
}
|
||||
|
||||
/// Whether or not the notebook has been synced to the server.
|
||||
pub fn is_on_server(&self) -> bool {
|
||||
matches!(
|
||||
&self.active_notebook,
|
||||
ActiveNotebook::CommittedNotebook(SyncId::ServerId(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculate the breadcrumbs for this object.
|
||||
pub fn breadcrumbs(&self, ctx: &AppContext) -> Option<Vec<ContainingObject>> {
|
||||
let cloud_notebook = match &self.active_notebook {
|
||||
ActiveNotebook::None => None,
|
||||
ActiveNotebook::CommittedNotebook(id) => CloudModel::as_ref(ctx).get_notebook(id),
|
||||
ActiveNotebook::NewNotebook(notebook) => Some(notebook.as_ref()),
|
||||
};
|
||||
|
||||
cloud_notebook.map(|notebook| notebook.containing_objects_path(ctx))
|
||||
}
|
||||
|
||||
/// The space that the active notebook is shown in for this user.
|
||||
pub fn space(&self, app: &AppContext) -> Option<Space> {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::None => None,
|
||||
ActiveNotebook::CommittedNotebook(id) => CloudModel::as_ref(app)
|
||||
.get_notebook(id)
|
||||
.map(|notebook| notebook.space(app)),
|
||||
ActiveNotebook::NewNotebook(notebook) => Some(notebook.space(app)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The drive that owns the active notebook.
|
||||
pub fn owner(&self, app: &AppContext) -> Option<Owner> {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::None => None,
|
||||
ActiveNotebook::CommittedNotebook(id) => CloudModel::as_ref(app)
|
||||
.get_notebook(id)
|
||||
.map(|notebook| notebook.permissions.owner),
|
||||
ActiveNotebook::NewNotebook(notebook) => Some(notebook.permissions.owner),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active_notebook(&self, notebook_id: SyncId) -> bool {
|
||||
self.id() == Some(notebook_id)
|
||||
}
|
||||
|
||||
/// Checks whether or not this notebook has edit conflicts that would
|
||||
/// results in the conflict resolution banner being shown. We check both
|
||||
/// if a conflicting object has been recieved from the server, and that there
|
||||
/// are no pending content changes on the notebook.
|
||||
///
|
||||
/// We need to check the pending content changes because of a race condition where
|
||||
/// echo'd back RTC messages can come in before a server response and incorrectly apply
|
||||
/// a conflict to the notebook. To ensure we don't incorrectly show the dialog, we wait until
|
||||
/// all pending requests have returned.
|
||||
pub fn has_conflicts(&self, ctx: &AppContext) -> bool {
|
||||
self.id()
|
||||
.and_then(|id| CloudModel::as_ref(ctx).get_by_uid(&id.uid()))
|
||||
.is_some_and(|object| {
|
||||
object.has_conflicting_changes() && !object.metadata().has_pending_content_changes()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn feature_not_available(&self) -> bool {
|
||||
self.feature_not_available
|
||||
}
|
||||
|
||||
/// Returns the current editor of the active object. Returns None
|
||||
/// if there is not currently an active notebook
|
||||
pub fn current_editor(&self, ctx: &AppContext) -> Option<Editor> {
|
||||
let id = self.id()?;
|
||||
CloudViewModel::as_ref(ctx).object_current_editor(&id.uid(), ctx)
|
||||
}
|
||||
|
||||
/// Checks if this notebook is trashed or deleted.
|
||||
pub fn trash_status(&self, ctx: &AppContext) -> TrashStatus {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::None | ActiveNotebook::NewNotebook(_) => TrashStatus::Active,
|
||||
ActiveNotebook::CommittedNotebook(id) => {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
match cloud_model.get_notebook(id) {
|
||||
Some(notebook) => {
|
||||
if notebook.is_trashed(cloud_model) {
|
||||
TrashStatus::Trashed
|
||||
} else {
|
||||
TrashStatus::Active
|
||||
}
|
||||
}
|
||||
None => TrashStatus::Deleted,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current user's access level on the notebook.
|
||||
pub fn access_level(&self, app: &AppContext) -> SharingAccessLevel {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::CommittedNotebook(object_id) => {
|
||||
CloudViewModel::as_ref(app).access_level(&object_id.uid(), app)
|
||||
}
|
||||
ActiveNotebook::None | ActiveNotebook::NewNotebook(_) => SharingAccessLevel::Full,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the current user can edit the notebook.
|
||||
pub fn editability(&self, app: &AppContext) -> ContentEditability {
|
||||
match &self.active_notebook {
|
||||
ActiveNotebook::CommittedNotebook(object_id) => {
|
||||
CloudViewModel::as_ref(app).object_editability(&object_id.uid(), app)
|
||||
}
|
||||
ActiveNotebook::None | ActiveNotebook::NewNotebook(_) => ContentEditability::Editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ActiveNotebookDataEvent {
|
||||
/// Another user stole the baton for the current object.
|
||||
ModeChangedFromServer,
|
||||
/// The editing baton for the current object was successfully grabbed server-side.
|
||||
SwitchedToEditMode,
|
||||
/// An edit to the current object was rejected.
|
||||
EditRejected,
|
||||
/// The notebook's breadcrumbs were updated.
|
||||
BreadcrumbsChanged,
|
||||
/// This notebook was created on the server.
|
||||
CreatedOnServer,
|
||||
/// This notebook was trashed or untrashed (used for refreshing pane overflow items)
|
||||
TrashStatusChanged,
|
||||
// This notebook was moved to a shared space.
|
||||
MovedToSpace,
|
||||
}
|
||||
|
||||
/// Whether or not a notebook is trashed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum TrashStatus {
|
||||
Active,
|
||||
Trashed,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl TrashStatus {
|
||||
/// Whether or not the notebook can be edited in this state.
|
||||
pub fn is_editable(self) -> bool {
|
||||
match self {
|
||||
TrashStatus::Active => true,
|
||||
TrashStatus::Trashed | TrashStatus::Deleted => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ActiveNotebookData {
|
||||
type Event = ActiveNotebookDataEvent;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Shared context menu implementation for notebooks.
|
||||
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use warp_core::context_flag::ContextFlag;
|
||||
use warpui::{
|
||||
elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack},
|
||||
keymap::Trigger,
|
||||
presenter::ChildView,
|
||||
Action, Element, EventContext, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
editor::EditorView,
|
||||
menu::{self, Menu, MenuItem, MenuItemFields},
|
||||
pane_group::{focus_state::PaneFocusHandle, PaneEvent, SplitPaneState},
|
||||
util::bindings::{keybinding_name_to_display_string, trigger_to_keystroke, CustomAction},
|
||||
};
|
||||
|
||||
use super::{
|
||||
editor::{keys::custom_action_to_display, view::RichTextEditorView},
|
||||
telemetry::ActionEntrypoint,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "context_menu_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const CONTEXT_MENU_WIDTH: f32 = 200.;
|
||||
|
||||
pub struct ContextMenuState<V: TypedActionView + View>
|
||||
where
|
||||
V::Action: Clone + From<ContextMenuAction>,
|
||||
{
|
||||
/// The kind of menu that's open. If `None`, the menu is closed.
|
||||
source: Option<MenuSource>,
|
||||
menu: ViewHandle<Menu<V::Action>>,
|
||||
/// Focus state of the pane containing this context menu.
|
||||
focus_handle: Option<PaneFocusHandle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MenuSource {
|
||||
RichTextEditor {
|
||||
parent_offset: Vector2F,
|
||||
editor: ViewHandle<RichTextEditorView>,
|
||||
},
|
||||
TextEditor {
|
||||
parent_offset: Vector2F,
|
||||
editor: ViewHandle<EditorView>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<V: TypedActionView + View> ContextMenuState<V>
|
||||
where
|
||||
V::Event: From<PaneEvent>,
|
||||
V::Action: Clone + From<ContextMenuAction>,
|
||||
{
|
||||
pub fn new(ctx: &mut ViewContext<V>) -> Self {
|
||||
let menu = ctx.add_typed_action_view(|_| Menu::new().with_width(CONTEXT_MENU_WIDTH));
|
||||
|
||||
ctx.subscribe_to_view(&menu, |view, _, event, ctx| match event {
|
||||
menu::Event::ItemSelected | menu::Event::ItemHovered => (),
|
||||
menu::Event::Close { via_select_item } => {
|
||||
view.handle_action(
|
||||
&V::Action::from(ContextMenuAction::Close {
|
||||
via_select_item: *via_select_item,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
source: None,
|
||||
menu,
|
||||
focus_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle) {
|
||||
self.focus_handle = Some(focus_handle);
|
||||
}
|
||||
|
||||
/// Renders the context menu, if it's open.
|
||||
pub fn render(&self, stack: &mut Stack) {
|
||||
let offset = match self.source {
|
||||
Some(MenuSource::RichTextEditor { parent_offset, .. }) => parent_offset,
|
||||
Some(MenuSource::TextEditor { parent_offset, .. }) => parent_offset,
|
||||
None => return,
|
||||
};
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.menu).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
offset,
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show the context menu.
|
||||
pub fn show_context_menu(&mut self, source: MenuSource, ctx: &mut ViewContext<V>) {
|
||||
let mut items = vec![];
|
||||
|
||||
// Section 1: text selection actions.
|
||||
let (has_selection, can_edit) = match &source {
|
||||
MenuSource::RichTextEditor { editor, .. } => {
|
||||
let editor = editor.as_ref(ctx);
|
||||
(
|
||||
!editor.selection_is_single_cursor(ctx) || editor.has_command_selection(ctx),
|
||||
editor.is_editable(ctx),
|
||||
)
|
||||
}
|
||||
MenuSource::TextEditor { editor, .. } => editor.read(ctx, |editor, ctx| {
|
||||
(!editor.selected_text(ctx).is_empty(), editor.can_edit(ctx))
|
||||
}),
|
||||
};
|
||||
|
||||
if has_selection && can_edit {
|
||||
let item = MenuItemFields::new("Cut")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::CutSelectedText))
|
||||
.with_key_shortcut_label(custom_action_to_display(CustomAction::Cut));
|
||||
items.push(item.into_item());
|
||||
}
|
||||
if has_selection {
|
||||
let item = MenuItemFields::new("Copy")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::CopySelectedText))
|
||||
.with_key_shortcut_label(custom_action_to_display(CustomAction::Copy));
|
||||
items.push(item.into_item());
|
||||
}
|
||||
if can_edit {
|
||||
let item = MenuItemFields::new("Paste")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::Paste))
|
||||
.with_key_shortcut_label(custom_action_to_display(CustomAction::Paste));
|
||||
items.push(item.into_item());
|
||||
}
|
||||
|
||||
// Section 2: Split-pane actions
|
||||
let split_pane_menu_items = self.split_pane_menu_items(ctx);
|
||||
if !items.is_empty() && !split_pane_menu_items.is_empty() {
|
||||
items.push(MenuItem::Separator);
|
||||
}
|
||||
if !split_pane_menu_items.is_empty() {
|
||||
items.extend(split_pane_menu_items);
|
||||
}
|
||||
|
||||
self.menu.update(ctx, move |menu, ctx| {
|
||||
menu.set_items(items, ctx); // This also resets the selection.
|
||||
});
|
||||
self.source = Some(source);
|
||||
ctx.focus(&self.menu);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn split_pane_menu_items(&self, ctx: &mut ViewContext<V>) -> Vec<MenuItem<V::Action>> {
|
||||
let mut items = vec![];
|
||||
if ContextFlag::CreateNewSession.is_enabled() {
|
||||
items.extend([
|
||||
MenuItemFields::new("Split pane right")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
||||
PaneEvent::SplitRight(None),
|
||||
)))
|
||||
.with_key_shortcut_label(keybinding_name_to_display_string(
|
||||
"pane_group:add_right",
|
||||
ctx,
|
||||
))
|
||||
.into_item(),
|
||||
MenuItemFields::new("Split pane left")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
||||
PaneEvent::SplitLeft(None),
|
||||
)))
|
||||
.with_key_shortcut_label(keybinding_name_to_display_string(
|
||||
"pane_group:add_left",
|
||||
ctx,
|
||||
))
|
||||
.into_item(),
|
||||
MenuItemFields::new("Split pane down")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
||||
PaneEvent::SplitDown(None),
|
||||
)))
|
||||
.with_key_shortcut_label(keybinding_name_to_display_string(
|
||||
"pane_group:add_down",
|
||||
ctx,
|
||||
))
|
||||
.into_item(),
|
||||
MenuItemFields::new("Split pane up")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
||||
PaneEvent::SplitUp(None),
|
||||
)))
|
||||
.with_key_shortcut_label(keybinding_name_to_display_string(
|
||||
"pane_group:add_up",
|
||||
ctx,
|
||||
))
|
||||
.into_item(),
|
||||
]);
|
||||
}
|
||||
|
||||
let split_pane_state = self
|
||||
.focus_handle
|
||||
.as_ref()
|
||||
.map_or(SplitPaneState::NotInSplitPane, |h| h.split_pane_state(ctx));
|
||||
if split_pane_state.is_in_split_pane() {
|
||||
let is_maximized = split_pane_state.is_maximized();
|
||||
items.push(
|
||||
MenuItemFields::toggle_pane_action(is_maximized)
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
||||
PaneEvent::ToggleMaximized,
|
||||
)))
|
||||
.with_key_shortcut_label(keybinding_name_to_display_string(
|
||||
"pane_group:toggle_maximize_pane",
|
||||
ctx,
|
||||
))
|
||||
.into_item(),
|
||||
);
|
||||
|
||||
items.push(
|
||||
MenuItemFields::new("Close pane")
|
||||
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
||||
PaneEvent::Close,
|
||||
)))
|
||||
.with_key_shortcut_label(
|
||||
trigger_to_keystroke(&Trigger::Custom(
|
||||
CustomAction::CloseCurrentSession.into(),
|
||||
))
|
||||
.map(|keystroke| keystroke.displayed()),
|
||||
)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Close the context menu. If `focus_parent` is true, the parent view (either the editor that
|
||||
/// triggered the context menu or the notebook view) will be focused.
|
||||
pub fn close_context_menu(&mut self, focus_parent: bool, ctx: &mut ViewContext<V>) {
|
||||
if focus_parent {
|
||||
match &self.source {
|
||||
Some(MenuSource::RichTextEditor { editor, .. }) => ctx.focus(editor),
|
||||
Some(MenuSource::TextEditor { editor, .. }) => ctx.focus(editor),
|
||||
None => ctx.focus_self(),
|
||||
}
|
||||
}
|
||||
self.source = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// List out the context menu items by name.
|
||||
pub fn item_names<'a>(&self, ctx: &'a impl warpui::ViewAsRef) -> Vec<&'a str> {
|
||||
self.menu
|
||||
.as_ref(ctx)
|
||||
.items()
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
MenuItem::Item(item) => item.label(),
|
||||
MenuItem::Separator => "----",
|
||||
MenuItem::ItemsRow { .. } => panic!("ItemsRow not supported"),
|
||||
MenuItem::Submenu { fields, .. } => fields.label(),
|
||||
MenuItem::Header { fields, .. } => fields.label(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_action(&mut self, action: &ContextMenuAction, ctx: &mut ViewContext<V>) {
|
||||
match action {
|
||||
ContextMenuAction::Open(source) => self.show_context_menu(source.clone(), ctx),
|
||||
ContextMenuAction::Close { via_select_item } => {
|
||||
self.close_context_menu(!*via_select_item, ctx)
|
||||
}
|
||||
ContextMenuAction::CopySelectedText => match &self.source {
|
||||
Some(MenuSource::RichTextEditor { editor, .. }) => {
|
||||
editor.update(ctx, |editor, ctx| editor.copy(ActionEntrypoint::Menu, ctx))
|
||||
}
|
||||
Some(MenuSource::TextEditor { editor, .. }) => {
|
||||
editor.update(ctx, |editor, ctx| editor.copy(ctx))
|
||||
}
|
||||
None => (),
|
||||
},
|
||||
ContextMenuAction::CutSelectedText => match &self.source {
|
||||
Some(MenuSource::RichTextEditor { editor, .. }) => {
|
||||
ctx.focus(editor);
|
||||
editor.update(ctx, |editor, ctx| editor.cut(ActionEntrypoint::Menu, ctx));
|
||||
}
|
||||
Some(MenuSource::TextEditor { editor, .. }) => {
|
||||
ctx.focus(editor);
|
||||
editor.update(ctx, |editor, ctx| editor.cut(ctx))
|
||||
}
|
||||
None => (),
|
||||
},
|
||||
ContextMenuAction::Paste => match &self.source {
|
||||
Some(MenuSource::RichTextEditor { editor, .. }) => {
|
||||
ctx.focus(editor);
|
||||
editor.update(ctx, |editor, ctx| editor.paste(ctx))
|
||||
}
|
||||
Some(MenuSource::TextEditor { editor, .. }) => {
|
||||
ctx.focus(editor);
|
||||
editor.update(ctx, |editor, ctx| editor.paste(ctx))
|
||||
}
|
||||
None => (),
|
||||
},
|
||||
ContextMenuAction::EmitPaneEvent(event) => ctx.emit(V::Event::from(event.clone())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch an action to show the notebook context menu for a rich text editor view.
|
||||
pub fn show_rich_editor_context_menu<A>(
|
||||
ctx: &mut EventContext,
|
||||
position: Vector2F,
|
||||
parent_position_id: &str,
|
||||
editor: &ViewHandle<RichTextEditorView>,
|
||||
) where
|
||||
A: Action + From<ContextMenuAction>,
|
||||
{
|
||||
if let Some(parent_bounds) = ctx.element_position_by_id(parent_position_id) {
|
||||
let offset = position - parent_bounds.origin();
|
||||
ctx.dispatch_typed_action(A::from(ContextMenuAction::Open(
|
||||
MenuSource::RichTextEditor {
|
||||
parent_offset: offset,
|
||||
editor: editor.clone(),
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch an action to show the notebook context menu for a plain text editor view.
|
||||
pub fn show_text_editor_context_menu<A>(
|
||||
ctx: &mut EventContext,
|
||||
position: Vector2F,
|
||||
parent_position_id: &str,
|
||||
editor: &ViewHandle<EditorView>,
|
||||
) where
|
||||
A: Action + From<ContextMenuAction>,
|
||||
{
|
||||
if let Some(parent_bounds) = ctx.element_position_by_id(parent_position_id) {
|
||||
let offset = position - parent_bounds.origin();
|
||||
ctx.dispatch_typed_action(A::from(ContextMenuAction::Open(MenuSource::TextEditor {
|
||||
parent_offset: offset,
|
||||
editor: editor.clone(),
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContextMenuAction {
|
||||
Open(MenuSource),
|
||||
Close { via_select_item: bool },
|
||||
CopySelectedText,
|
||||
CutSelectedText,
|
||||
Paste,
|
||||
EmitPaneEvent(PaneEvent),
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use string_offset::ByteOffset;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::model::CoreEditorModel;
|
||||
use warpui::{platform::WindowStyle, App};
|
||||
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
|
||||
use super::MenuSource;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusState};
|
||||
use crate::pane_group::{BackingView as _, PaneId};
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::{
|
||||
cloud_object::model::{persistence::CloudModel, view::CloudViewModel},
|
||||
editor::InteractionState,
|
||||
network::NetworkStatus,
|
||||
notebooks::{editor::keys::NotebookKeybindings, notebook::NotebookView},
|
||||
server::{
|
||||
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
|
||||
sync_queue::SyncQueue,
|
||||
},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resources = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(CloudViewModel::mock);
|
||||
app.add_singleton_model(|_| UserProfiles::new(vec![]));
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default());
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(repo_metadata::RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
#[cfg(feature = "voice_input")]
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
}
|
||||
|
||||
/// Builds a list of the standard notebook context-menu items by appending the set of split-pane
|
||||
/// items to the given state-specific ones.
|
||||
fn standard_menu_items<'a>(items: impl IntoIterator<Item = &'a str>) -> Vec<&'a str> {
|
||||
let mut items: Vec<_> = items.into_iter().collect();
|
||||
items.extend([
|
||||
"----",
|
||||
"Split pane right",
|
||||
"Split pane left",
|
||||
"Split pane down",
|
||||
"Split pane up",
|
||||
]);
|
||||
items
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rich_text_actions() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let (_window_id, notebook) = app.add_window(WindowStyle::NotStealFocus, NotebookView::new);
|
||||
|
||||
// With no selection, the only text action should be to paste.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
let source = MenuSource::RichTextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.input_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
standard_menu_items(["Paste"])
|
||||
);
|
||||
});
|
||||
|
||||
// Once text is selected, cut/copy become available.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.input_editor().update(ctx, |editor, ctx| {
|
||||
editor.reset_with_markdown("Hello, World!", ctx);
|
||||
editor
|
||||
.model()
|
||||
.update(ctx, |model, ctx| model.select_all(ctx));
|
||||
});
|
||||
|
||||
let source = MenuSource::RichTextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.input_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
standard_menu_items(["Cut", "Copy", "Paste"])
|
||||
);
|
||||
});
|
||||
|
||||
// If the editor is read-only, cut and paste are disabled.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.input_editor().update(ctx, |editor, ctx| {
|
||||
editor.set_interaction_state(InteractionState::Selectable, ctx)
|
||||
});
|
||||
let source = MenuSource::RichTextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.input_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
standard_menu_items(["Copy"])
|
||||
);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plain_text_actions() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let (_window_id, notebook) = app.add_window(WindowStyle::NotStealFocus, NotebookView::new);
|
||||
|
||||
// With no selection, the only text action should be to paste.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
let source = MenuSource::TextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.title_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
standard_menu_items(["Paste"]),
|
||||
);
|
||||
});
|
||||
|
||||
// Once text is selected, cut/copy become available.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.title_editor().update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text("The Title", ctx);
|
||||
editor.select_ranges_by_byte_offset([ByteOffset::zero()..ByteOffset::from(4)], ctx)
|
||||
});
|
||||
|
||||
let source = MenuSource::TextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.title_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
standard_menu_items(["Cut", "Copy", "Paste"]),
|
||||
);
|
||||
});
|
||||
|
||||
// If the editor is read-only, cut and paste are disabled.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.title_editor().update(ctx, |editor, ctx| {
|
||||
editor.set_interaction_state(InteractionState::Selectable, ctx)
|
||||
});
|
||||
let source = MenuSource::TextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.title_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
standard_menu_items(["Copy"])
|
||||
);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_pane_actions() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let (_window_id, notebook) = app.add_window(WindowStyle::NotStealFocus, NotebookView::new);
|
||||
|
||||
// Set up focus state to simulate being in a split pane.
|
||||
let pane_id = PaneId::dummy_pane_id();
|
||||
let focus_state = app.add_model(|_| {
|
||||
PaneGroupFocusState::new(
|
||||
pane_id, None, // active_session_id
|
||||
true, // in_split_pane
|
||||
)
|
||||
});
|
||||
let focus_handle = PaneFocusHandle::new(pane_id, focus_state.clone());
|
||||
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.set_focus_handle(focus_handle, ctx);
|
||||
});
|
||||
|
||||
// In a split pane, all the management actions are available.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
let source = MenuSource::TextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.title_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
vec![
|
||||
"Paste",
|
||||
"----",
|
||||
"Split pane right",
|
||||
"Split pane left",
|
||||
"Split pane down",
|
||||
"Split pane up",
|
||||
"Maximize pane",
|
||||
"Close pane"
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Modify the focus state to simulate not being in a split pane.
|
||||
focus_state.update(&mut app, |state, ctx| {
|
||||
state.set_in_split_pane_for_test(false, ctx);
|
||||
});
|
||||
|
||||
// If not in a split pane, maximize and close actions are hidden.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
let source = MenuSource::TextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: notebook.title_editor(),
|
||||
};
|
||||
notebook.context_menu().show_context_menu(source, ctx);
|
||||
assert_eq!(
|
||||
notebook.context_menu().item_names(ctx),
|
||||
vec![
|
||||
"Paste",
|
||||
"----",
|
||||
"Split pane right",
|
||||
"Split pane left",
|
||||
"Split pane down",
|
||||
"Split pane up",
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_editor::content::text::BufferBlockItem;
|
||||
use warpui::{
|
||||
elements::{
|
||||
AnchorPair, Border, Container, CornerRadius, MouseStateHandle, OffsetPositioning,
|
||||
OffsetType, PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Stack,
|
||||
XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonTooltipPosition,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, ObjectIdType, Space},
|
||||
drive::CloudObjectTypeAndId,
|
||||
menu::{self, Menu, MenuItemFields},
|
||||
notebooks::telemetry::EmbeddedObjectInfo,
|
||||
search::notebook_embedding::{
|
||||
searcher::EmbeddingSearchItemAction,
|
||||
view::{EmbeddingSearchEvent, EmbeddingSearchMenu},
|
||||
},
|
||||
server::ids::SyncId,
|
||||
themes::theme::Fill,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
};
|
||||
|
||||
use super::{
|
||||
embedded_item::EmbeddedWorkflow,
|
||||
view::{EditorViewAction, EditorViewEvent, RichTextEditorView},
|
||||
BlockType,
|
||||
};
|
||||
|
||||
/// The saved position ID for the block insertion button.
|
||||
const BLOCK_INSERT_BUTTON_ID: &str = "notebook_block_insertion_button";
|
||||
|
||||
/// Where the block insertion menu was triggered from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BlockInsertionSource {
|
||||
AtCursor,
|
||||
BlockInsertionButton,
|
||||
}
|
||||
|
||||
/// Editor view state related to the block insertion menu.
|
||||
pub struct BlockInsertionMenuState {
|
||||
// If the menu is closed, this will be None.
|
||||
pub open_at_source: Option<BlockInsertionSource>,
|
||||
button_state: MouseStateHandle,
|
||||
// Whether the embedded object search menu is open.
|
||||
pub embedded_object_search_open: bool,
|
||||
/// The embedded object search menu, lazily created when embedded objects are enabled.
|
||||
embedded_object_search: Option<ViewHandle<EmbeddingSearchMenu>>,
|
||||
pub menu: ViewHandle<Menu<EditorViewAction>>,
|
||||
}
|
||||
|
||||
impl BlockInsertionMenuState {
|
||||
pub fn new(ctx: &mut ViewContext<RichTextEditorView>, embedded_objects_enabled: bool) -> Self {
|
||||
let menu =
|
||||
ctx.add_typed_action_view(|ctx| Self::create_menu(embedded_objects_enabled, ctx));
|
||||
|
||||
ctx.subscribe_to_view(&menu, RichTextEditorView::handle_block_insertion_menu_event);
|
||||
|
||||
let embedded_object_search = if embedded_objects_enabled {
|
||||
let embedded_object_search = ctx.add_typed_action_view(EmbeddingSearchMenu::new);
|
||||
ctx.subscribe_to_view(
|
||||
&embedded_object_search,
|
||||
RichTextEditorView::handle_embedded_object_search_menu_event,
|
||||
);
|
||||
Some(embedded_object_search)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
open_at_source: None,
|
||||
button_state: Default::default(),
|
||||
embedded_object_search_open: false,
|
||||
embedded_object_search,
|
||||
menu,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_menu(
|
||||
embedded_objects_enabled: bool,
|
||||
ctx: &mut ViewContext<Menu<EditorViewAction>>,
|
||||
) -> Menu<EditorViewAction> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut menu = Menu::new().prevent_interaction_with_other_elements();
|
||||
|
||||
for block_type in BlockType::code_block_types() {
|
||||
menu.add_item(
|
||||
MenuItemFields::new(block_type.label())
|
||||
.with_icon(block_type.icon())
|
||||
.with_on_select_action(EditorViewAction::InsertBlock(
|
||||
warp_editor::content::text::BlockType::Text(block_type.into()),
|
||||
))
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
if embedded_objects_enabled {
|
||||
menu.add_item(
|
||||
MenuItemFields::new("Embed")
|
||||
.with_icon(Icon::EmbedBlock)
|
||||
.with_on_select_action(EditorViewAction::OpenEmbeddedObjectSearch)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
for block_type in BlockType::text_block_types() {
|
||||
let mut item_fields = MenuItemFields::new(block_type.label())
|
||||
.with_icon(block_type.icon())
|
||||
.with_on_select_action(EditorViewAction::InsertBlock(
|
||||
warp_editor::content::text::BlockType::Text(block_type.into()),
|
||||
));
|
||||
if let Some(icon_fill) = block_type.icon_color(appearance) {
|
||||
item_fields = item_fields.with_override_icon_color(icon_fill);
|
||||
}
|
||||
menu.add_item(item_fields.into_item());
|
||||
}
|
||||
|
||||
menu.add_item(
|
||||
MenuItemFields::new("Divider")
|
||||
.with_icon(Icon::HorizontalRuleBlock)
|
||||
.with_on_select_action(EditorViewAction::InsertBlock(
|
||||
warp_editor::content::text::BlockType::Item(BufferBlockItem::HorizontalRule),
|
||||
))
|
||||
.with_override_icon_color(Fill::Solid(appearance.theme().ui_warning_color()))
|
||||
.into_item(),
|
||||
);
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
pub fn reset_selection(&mut self, ctx: &mut AppContext) {
|
||||
self.menu.update(ctx, |menu, ctx| {
|
||||
menu.reset_selection(ctx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RichTextEditorView {
|
||||
/// Open the block insertion menu.
|
||||
pub(super) fn open_block_insertion_menu(
|
||||
&mut self,
|
||||
source: BlockInsertionSource,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Reset selection if we are opening a new block insertion menu or opening
|
||||
// the menu from a different source.
|
||||
if self.insertion_menu_state.open_at_source != Some(source) {
|
||||
self.insertion_menu_state.reset_selection(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
self.insertion_menu_state.open_at_source = Some(source);
|
||||
// By default we should show the block insertion menu.
|
||||
self.insertion_menu_state.embedded_object_search_open = false;
|
||||
ctx.focus(&self.insertion_menu_state.menu);
|
||||
ctx.emit(EditorViewEvent::OpenedBlockInsertionMenu(source));
|
||||
}
|
||||
|
||||
pub(super) fn open_embedded_object_search(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(embedded_object_search) = &self.insertion_menu_state.embedded_object_search else {
|
||||
return;
|
||||
};
|
||||
self.insertion_menu_state.embedded_object_search_open = true;
|
||||
// Reset the filter state.
|
||||
embedded_object_search.update(ctx, |menu, ctx| {
|
||||
menu.reset_state(ctx);
|
||||
});
|
||||
ctx.focus(embedded_object_search);
|
||||
ctx.emit(EditorViewEvent::OpenedEmbeddedObjectSearch);
|
||||
}
|
||||
|
||||
/// Set the space containing this notebook.
|
||||
pub fn set_space(&mut self, space: Space, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(embedded_object_search) = &self.insertion_menu_state.embedded_object_search {
|
||||
embedded_object_search.update(ctx, |menu, ctx| menu.set_embedding_space(space, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the block insertion menu.
|
||||
pub(super) fn close_block_insertion_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.is_block_insertion_menu_open() {
|
||||
ctx.notify();
|
||||
}
|
||||
self.insertion_menu_state.open_at_source = None;
|
||||
self.insertion_menu_state.embedded_object_search_open = false;
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
/// Whether the block insertion menu is open.
|
||||
pub(super) fn is_block_insertion_menu_open(&self) -> bool {
|
||||
self.insertion_menu_state.open_at_source.is_some()
|
||||
}
|
||||
|
||||
fn handle_embedded_object_search_menu_event(
|
||||
&mut self,
|
||||
_handle: ViewHandle<EmbeddingSearchMenu>,
|
||||
event: &EmbeddingSearchEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EmbeddingSearchEvent::Close => self.close_block_insertion_menu(ctx),
|
||||
EmbeddingSearchEvent::ItemSelected { payload } => match payload.as_ref() {
|
||||
EmbeddingSearchItemAction::AcceptWorkflow(id) => {
|
||||
self.insert_embedded_workflow(id, ctx)
|
||||
}
|
||||
EmbeddingSearchItemAction::AcceptNotebook(id) => {
|
||||
self.insert_embedded_notebook(id, ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert an embedded workflow block at the current insertion menu source.
|
||||
fn insert_embedded_workflow(&mut self, id: &SyncId, ctx: &mut ViewContext<Self>) {
|
||||
self.insert_block(
|
||||
warp_editor::content::text::BlockType::Item(BufferBlockItem::Embedded {
|
||||
item: Arc::new(EmbeddedWorkflow::new(
|
||||
id.sqlite_uid_hash(ObjectIdType::Workflow),
|
||||
)),
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
let team_uid = CloudModel::as_ref(ctx)
|
||||
.get_workflow(id)
|
||||
.and_then(|workflow| workflow.permissions.owner.into());
|
||||
ctx.emit(EditorViewEvent::InsertedEmbeddedObject(
|
||||
EmbeddedObjectInfo::Workflow {
|
||||
workflow_id: id.into_server().map(Into::into),
|
||||
team_uid,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Insert an embedded notebook inline view at the current insertion menu source.
|
||||
fn insert_embedded_notebook(&mut self, id: &SyncId, ctx: &mut ViewContext<Self>) {
|
||||
let (title, link) = CloudModel::handle(ctx).read(ctx, |model, _| {
|
||||
let title = model
|
||||
.get_notebook(id)
|
||||
.map(|notebook| notebook.model().title.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let link = model
|
||||
.get_by_uid(&CloudObjectTypeAndId::Notebook(*id).uid())
|
||||
.and_then(|object| object.object_link());
|
||||
(title, link)
|
||||
});
|
||||
|
||||
if let Some(link) = link {
|
||||
self.insert_embedded_notebook_view(title, link, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback for events on the block insertion menu.
|
||||
fn handle_block_insertion_menu_event(
|
||||
&mut self,
|
||||
_menu: ViewHandle<Menu<EditorViewAction>>,
|
||||
event: &menu::Event,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
menu::Event::ItemSelected | menu::Event::ItemHovered => (),
|
||||
menu::Event::Close { via_select_item } => {
|
||||
// Don't close the block insertion menu if the embedded object
|
||||
// search menu is open. Handle the close event emitted from
|
||||
// embedded object search menu instead.
|
||||
if self.insertion_menu_state.embedded_object_search_open {
|
||||
return;
|
||||
}
|
||||
self.close_block_insertion_menu(ctx);
|
||||
if !*via_select_item {
|
||||
ctx.focus_self()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders controls for the block insertion menu.
|
||||
pub(super) fn render_block_insertion_menu(&self, stack: &mut Stack, app: &AppContext) {
|
||||
if self.disable_block_insertion_menu() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.can_edit_app(app) {
|
||||
self.render_button(stack, app);
|
||||
}
|
||||
|
||||
if let Some(source) = self.insertion_menu_state.open_at_source {
|
||||
self.render_menu(source, stack, app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a button that opens the block insertion menu when clicked.
|
||||
fn render_button(&self, stack: &mut Stack, app: &AppContext) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
let button = icon_button(
|
||||
appearance,
|
||||
Icon::Plus,
|
||||
self.insertion_menu_state.open_at_source
|
||||
== Some(BlockInsertionSource::BlockInsertionButton),
|
||||
self.insertion_menu_state.button_state.clone(),
|
||||
)
|
||||
.with_active_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().surface_2().into()),
|
||||
border_color: Some(appearance.theme().surface_3().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Insert block".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
// Position the tooltip above the insertion button to ensure they don't overlap if the
|
||||
// button is towards the bottom of the screen.
|
||||
.with_tooltip_position(ButtonTooltipPosition::Above)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(EditorViewAction::OpenBlockInsertionMenu))
|
||||
.finish();
|
||||
|
||||
let render_state = self.model.as_ref(app).render_state();
|
||||
let hovered_block_id = render_state
|
||||
.as_ref(app)
|
||||
.saved_positions()
|
||||
.hovered_block_start();
|
||||
|
||||
stack.add_positioned_child(
|
||||
SavePosition::new(button, BLOCK_INSERT_BUTTON_ID).finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&hovered_block_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(-4.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Right),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
hovered_block_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a menu for inserting new kinds of blocks.
|
||||
fn render_menu(&self, source: BlockInsertionSource, stack: &mut Stack, app: &AppContext) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let render_state = self.model.as_ref(app).render_state.as_ref(app);
|
||||
|
||||
let (container, bounds) = if !self.insertion_menu_state.embedded_object_search_open {
|
||||
let menu = ChildView::new(&self.insertion_menu_state.menu).finish();
|
||||
(
|
||||
Container::new(menu)
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish(),
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
)
|
||||
} else if let Some(embedded_object_search) =
|
||||
&self.insertion_menu_state.embedded_object_search
|
||||
{
|
||||
(
|
||||
ChildView::new(embedded_object_search).finish(),
|
||||
// Embedded object search menu is not bounded by the editor.
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
)
|
||||
} else {
|
||||
// Embedded object search is open but no menu exists - shouldn't happen.
|
||||
return;
|
||||
};
|
||||
|
||||
let positioning = match source {
|
||||
BlockInsertionSource::BlockInsertionButton => OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
BLOCK_INSERT_BUTTON_ID,
|
||||
bounds,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
BLOCK_INSERT_BUTTON_ID,
|
||||
bounds,
|
||||
OffsetType::Pixel(4.),
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
BlockInsertionSource::AtCursor => {
|
||||
let cursor_position = render_state.saved_positions().cursor_id();
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&cursor_position,
|
||||
bounds,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&cursor_position,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(4.),
|
||||
// TODO: Decide if this should be above or below the cursor based
|
||||
// on its location within the viewport.
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
stack.add_positioned_overlay_child(container, positioning);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
use std::{collections::HashMap, ops::Range, sync::Arc};
|
||||
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::html_parser::WARP_EMBED_ATTRIBUTE_NAME;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use serde_yaml::Mapping;
|
||||
use string_offset::ByteOffset;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::{
|
||||
content::{markdown::MarkdownStyle, text::TextStylesWithMetadata},
|
||||
editor::EmbeddedItemModel,
|
||||
extract_block,
|
||||
render::{
|
||||
element::{CursorData, CursorDisplayType, RenderContext, RenderableBlock},
|
||||
layout::TextLayout,
|
||||
model::{
|
||||
viewport::ViewportItem, BlockItem, BlockSpacing, BrokenBlockEmbedding, EmbeddedItem,
|
||||
EmbeddedItemHTMLRepresentation, EmbeddedItemRichFormat, LaidOutEmbeddedItem,
|
||||
ParagraphStyles, RenderState, EMBEDDED_ITEM_FIRST_LINE_HEIGHT,
|
||||
},
|
||||
BLOCK_FOOTER_HEIGHT,
|
||||
},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{Border, Empty},
|
||||
SingletonEntity,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{ConstrainedBox, CornerRadius, Margin, Padding, Radius},
|
||||
text_layout::TextFrame,
|
||||
units::{IntoPixels, Pixels},
|
||||
AppContext, Element, LayoutContext, SizeConstraint,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType},
|
||||
server::ids::{HashableId, ToServerId},
|
||||
ui_components::icons::Icon,
|
||||
workflows::{workflow::Workflow, CloudWorkflow, WorkflowId},
|
||||
};
|
||||
|
||||
// Spacing for the embedded workflow card.
|
||||
const EMBED_WORKFLOW_SPACING: BlockSpacing = BlockSpacing {
|
||||
margin: Margin::uniform(0.)
|
||||
.with_top(8.)
|
||||
.with_left(4.)
|
||||
.with_bottom(8.)
|
||||
.with_right(16.),
|
||||
padding: Padding::uniform(8.)
|
||||
.with_left(16.)
|
||||
.with_top(16.)
|
||||
// Reserve space for the buttons.
|
||||
.with_bottom(BLOCK_FOOTER_HEIGHT),
|
||||
};
|
||||
|
||||
// Spacing for the text sections (e.g. title, command) within the workflow card.
|
||||
const EMBED_WORKFLOW_TEXT_SPACING: BlockSpacing = BlockSpacing {
|
||||
margin: Margin::uniform(0.)
|
||||
.with_top(8.)
|
||||
.with_left(4.)
|
||||
.with_bottom(8.)
|
||||
.with_right(16.),
|
||||
padding: Padding::uniform(8.)
|
||||
.with_left(40.)
|
||||
.with_top(16.)
|
||||
// Reserve space for the buttons.
|
||||
.with_bottom(BLOCK_FOOTER_HEIGHT),
|
||||
};
|
||||
const TITLE_TO_DESCRIPTION_PADDING: f32 = 4.;
|
||||
const DESCRIPTION_TO_COMMAND_PADDING: f32 = 8.;
|
||||
|
||||
const WORKFLOW_ICON_SIZE: f32 = 16.;
|
||||
const WORKFLOW_TEXT_PADDING: f32 = 24.;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EmbeddedWorkflow {
|
||||
hashed_id: String,
|
||||
syntax_highlights: Vec<(Range<ByteOffset>, ColorU)>,
|
||||
}
|
||||
|
||||
impl EmbeddedWorkflow {
|
||||
pub fn new(hashed_id: String) -> Self {
|
||||
Self {
|
||||
hashed_id,
|
||||
syntax_highlights: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_syntax_highlighting(
|
||||
mut self,
|
||||
syntax_highlights: Vec<(Range<ByteOffset>, ColorU)>,
|
||||
) -> Self {
|
||||
self.syntax_highlights = syntax_highlights;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn command_text_frames(
|
||||
&self,
|
||||
command: String,
|
||||
command_text_style: &ParagraphStyles,
|
||||
text_layout: &TextLayout,
|
||||
) -> Vec<Arc<TextFrame>> {
|
||||
// Index of the active syntax styling.
|
||||
let mut syntax_style_index = 0;
|
||||
|
||||
// ByteOffset before the current line.
|
||||
let mut byteoffset_before_line = ByteOffset::zero();
|
||||
let default_command_style =
|
||||
text_layout.style_and_font(command_text_style, &TextStylesWithMetadata::default());
|
||||
let mut text_frames = vec![];
|
||||
|
||||
for line in command.lines() {
|
||||
let mut style_runs = Vec::new();
|
||||
|
||||
let total_line_byteoffset = ByteOffset::from(line.len());
|
||||
let mut byteoffset_from_line_start = ByteOffset::zero();
|
||||
|
||||
// Mapping from byte to character offset.
|
||||
let byte_to_charoffset_mapping =
|
||||
line.char_indices().map(|(index, _)| index).collect_vec();
|
||||
|
||||
while let Some((styling_range, color)) = self.syntax_highlights.get(syntax_style_index)
|
||||
{
|
||||
// Break out of the loop if either
|
||||
// 1) the current byte offset is already past the max of the line.
|
||||
// 2) the start of the active styling range is past the max of the line.
|
||||
if byteoffset_from_line_start >= total_line_byteoffset
|
||||
|| styling_range.start >= byteoffset_before_line + total_line_byteoffset
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Total byte offset from the start of text frame.
|
||||
let byteoffset_from_frame_start =
|
||||
byteoffset_from_line_start + byteoffset_before_line;
|
||||
|
||||
// Three scenarios:
|
||||
// 1) If byte offset is before the start of the styling range, push a style run with default styling until the start of styling range.
|
||||
// 2) If byte offset is after the start and before the end of the styling range, push the style run with the active styling.
|
||||
// 3) If byte offset is after the end of the styling range, increment the active styling range index.
|
||||
byteoffset_from_line_start = if styling_range.start > byteoffset_from_frame_start {
|
||||
let new_byteoffset = styling_range.start - byteoffset_before_line;
|
||||
style_runs.push((
|
||||
byteoffset_from_line_start..new_byteoffset,
|
||||
default_command_style,
|
||||
));
|
||||
new_byteoffset
|
||||
} else if styling_range.start <= byteoffset_from_frame_start
|
||||
&& byteoffset_from_frame_start < styling_range.end
|
||||
{
|
||||
let new_byteoffset =
|
||||
(styling_range.end - byteoffset_before_line).min(total_line_byteoffset);
|
||||
let command_style = text_layout.style_and_font(
|
||||
command_text_style,
|
||||
&TextStylesWithMetadata::default().with_color(*color),
|
||||
);
|
||||
style_runs.push((byteoffset_from_line_start..new_byteoffset, command_style));
|
||||
|
||||
// Only increment the active style range index if we have consumed the entire styling range.
|
||||
if styling_range.end <= total_line_byteoffset + byteoffset_before_line {
|
||||
syntax_style_index += 1;
|
||||
}
|
||||
|
||||
new_byteoffset
|
||||
} else {
|
||||
syntax_style_index += 1;
|
||||
continue;
|
||||
};
|
||||
}
|
||||
|
||||
// If the byte offset is not past the line max, push a default style run for the remaining part
|
||||
// of the line.
|
||||
if byteoffset_from_line_start < total_line_byteoffset {
|
||||
style_runs.push((
|
||||
byteoffset_from_line_start..total_line_byteoffset,
|
||||
default_command_style,
|
||||
));
|
||||
}
|
||||
|
||||
// Translate from byte offsets to character offsets.
|
||||
let mut char_style_runs = vec![];
|
||||
for (style_range, style) in style_runs {
|
||||
let starting_char =
|
||||
match byte_to_charoffset_mapping.binary_search(&style_range.start.as_usize()) {
|
||||
Ok(num) => num,
|
||||
Err(num) => num,
|
||||
};
|
||||
|
||||
let ending_char =
|
||||
match byte_to_charoffset_mapping.binary_search(&style_range.end.as_usize()) {
|
||||
Ok(num) => num,
|
||||
Err(num) => num,
|
||||
};
|
||||
|
||||
char_style_runs.push((starting_char..ending_char, style));
|
||||
}
|
||||
|
||||
text_frames.push(text_layout.layout_text(
|
||||
line,
|
||||
command_text_style,
|
||||
&EMBED_WORKFLOW_TEXT_SPACING,
|
||||
&char_style_runs,
|
||||
));
|
||||
|
||||
// Include linebreaks into the byte offset.
|
||||
byteoffset_before_line += total_line_byteoffset + 1;
|
||||
}
|
||||
text_frames
|
||||
}
|
||||
|
||||
/// Get the backing [`CloudWorkflow`] for this embed.
|
||||
fn get_workflow<'a>(&self, app: &'a AppContext) -> Option<&'a CloudWorkflow> {
|
||||
// TODO: @ianhodge - replace the `from_hash` when we create a new API for going from
|
||||
// sqlite hash id -> uid
|
||||
let uid = WorkflowId::from_hash(&self.hashed_id).map(|id| id.to_server_id().uid())?;
|
||||
CloudModel::as_ref(app)
|
||||
.get_by_uid(&uid)
|
||||
.and_then(|object| object.as_any().downcast_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl EmbeddedItem for EmbeddedWorkflow {
|
||||
fn layout(&self, text_layout: &TextLayout, app: &AppContext) -> Box<dyn LaidOutEmbeddedItem> {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let cloud_workflow = self.get_workflow(app);
|
||||
|
||||
let base_text_style = &text_layout.rich_text_styles().base_text;
|
||||
let width = text_layout.max_width() - EMBED_WORKFLOW_TEXT_SPACING.x_axis_offset();
|
||||
|
||||
let Some(workflow) = cloud_workflow.and_then(|workflow| {
|
||||
if !workflow.is_trashed(cloud_model) {
|
||||
Some(Into::<Workflow>::into(workflow))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) else {
|
||||
return Box::new(BrokenBlockEmbedding::new(width, base_text_style.font_size));
|
||||
};
|
||||
|
||||
let command_text_style = &text_layout.rich_text_styles().embedding_text;
|
||||
|
||||
let title_style =
|
||||
text_layout.style_and_font(base_text_style, &TextStylesWithMetadata::default());
|
||||
|
||||
let title_frame = text_layout.layout_text(
|
||||
workflow.name(),
|
||||
base_text_style,
|
||||
&EMBED_WORKFLOW_TEXT_SPACING,
|
||||
&[(0..workflow.name().chars().count(), title_style)],
|
||||
);
|
||||
|
||||
// Use placeholder style for description text.
|
||||
let description_style = text_layout.style_and_font(
|
||||
base_text_style,
|
||||
&TextStylesWithMetadata::default().for_placeholder(),
|
||||
);
|
||||
let description_frame = workflow.description().map(|description| {
|
||||
text_layout.layout_text(
|
||||
description,
|
||||
base_text_style,
|
||||
&EMBED_WORKFLOW_TEXT_SPACING,
|
||||
&[(0..description.chars().count(), description_style)],
|
||||
)
|
||||
});
|
||||
|
||||
let content_frames = self.command_text_frames(
|
||||
workflow.content().to_owned(),
|
||||
command_text_style,
|
||||
text_layout,
|
||||
);
|
||||
|
||||
let is_agent_mode_prompt =
|
||||
cloud_workflow.is_some_and(|w| w.model().data.is_agent_mode_workflow());
|
||||
|
||||
Box::new(LaidOutEmbeddedWorkflow::new(
|
||||
title_frame,
|
||||
description_frame,
|
||||
content_frames,
|
||||
width,
|
||||
is_agent_mode_prompt,
|
||||
))
|
||||
}
|
||||
|
||||
fn hashed_id(&self) -> &str {
|
||||
self.hashed_id.as_str()
|
||||
}
|
||||
|
||||
fn to_mapping(&self, style: MarkdownStyle) -> Mapping {
|
||||
let mut base = match style {
|
||||
MarkdownStyle::Internal => Default::default(),
|
||||
MarkdownStyle::Export { app_context, .. } => app_context
|
||||
.and_then(|ctx| self.get_workflow(ctx))
|
||||
.and_then(|workflow| serde_yaml::to_value(&workflow.model().data).ok())
|
||||
.and_then(|value| match value {
|
||||
serde_yaml::Value::Mapping(mapping) => Some(mapping),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
base.insert("id".into(), self.hashed_id().into());
|
||||
base
|
||||
}
|
||||
|
||||
fn to_rich_format(&self, app: &AppContext) -> EmbeddedItemRichFormat<'_> {
|
||||
let cloud_model = CloudModel::as_ref(app);
|
||||
let workflow = self.get_workflow(app);
|
||||
|
||||
// If the workflow is no longer accessible or is trashed, set the content to
|
||||
// an empty string. But we should still keep the HTML element formatting and
|
||||
// attributes so we could re-parse the ID and metadata when pasted into Warp.
|
||||
let workflow_content = workflow
|
||||
.and_then(|workflow| {
|
||||
if !workflow.is_trashed(cloud_model) {
|
||||
Some(workflow.model().data.content().to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or("".to_owned());
|
||||
|
||||
EmbeddedItemRichFormat {
|
||||
plain_text: workflow_content.clone(),
|
||||
html: EmbeddedItemHTMLRepresentation {
|
||||
element_name: "pre",
|
||||
content: workflow_content,
|
||||
attributes: HashMap::from([(WARP_EMBED_ATTRIBUTE_NAME, self.hashed_id())]),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaidOutEmbeddedWorkflow {
|
||||
pub title: Arc<TextFrame>,
|
||||
pub description: Option<Arc<TextFrame>>,
|
||||
pub command: Vec<Arc<TextFrame>>,
|
||||
|
||||
pub title_height: Pixels,
|
||||
pub description_height: Option<Pixels>,
|
||||
pub command_height: Pixels,
|
||||
|
||||
width: Pixels,
|
||||
is_agent_mode_prompt: bool,
|
||||
}
|
||||
|
||||
impl LaidOutEmbeddedWorkflow {
|
||||
pub fn new(
|
||||
title: Arc<TextFrame>,
|
||||
description: Option<Arc<TextFrame>>,
|
||||
command: Vec<Arc<TextFrame>>,
|
||||
width: Pixels,
|
||||
is_agent_mode_prompt: bool,
|
||||
) -> Self {
|
||||
let title_height = title
|
||||
.lines()
|
||||
.iter()
|
||||
.fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
})
|
||||
.into_pixels();
|
||||
|
||||
let description_height = description.as_ref().map(|description| {
|
||||
description
|
||||
.lines()
|
||||
.iter()
|
||||
.fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
})
|
||||
.into_pixels()
|
||||
});
|
||||
|
||||
let command_height = command
|
||||
.iter()
|
||||
.fold(0f32, |acc, frame| {
|
||||
acc + frame.lines().iter().fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
})
|
||||
})
|
||||
.into_pixels();
|
||||
|
||||
Self {
|
||||
title,
|
||||
description,
|
||||
command,
|
||||
title_height,
|
||||
description_height,
|
||||
command_height,
|
||||
width,
|
||||
is_agent_mode_prompt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LaidOutEmbeddedItem for LaidOutEmbeddedWorkflow {
|
||||
fn height(&self) -> Pixels {
|
||||
let mut total_height = self.title_height;
|
||||
|
||||
if let Some(height) = self.description_height {
|
||||
total_height += TITLE_TO_DESCRIPTION_PADDING.into_pixels() + height;
|
||||
}
|
||||
|
||||
total_height += DESCRIPTION_TO_COMMAND_PADDING.into_pixels() + self.command_height;
|
||||
total_height
|
||||
}
|
||||
|
||||
fn size(&self) -> Vector2F {
|
||||
vec2f(self.width.as_f32(), self.height().as_f32())
|
||||
}
|
||||
|
||||
fn first_line_bound(&self) -> Vector2F {
|
||||
// Use a constant here so we are consistently aligning the block insertion menu.
|
||||
vec2f(self.width.as_f32(), EMBEDDED_ITEM_FIRST_LINE_HEIGHT)
|
||||
}
|
||||
|
||||
fn element(
|
||||
&self,
|
||||
_state: &RenderState,
|
||||
viewport_item: ViewportItem,
|
||||
model: Option<&dyn EmbeddedItemModel>,
|
||||
ctx: &AppContext,
|
||||
) -> Box<dyn RenderableBlock> {
|
||||
Box::new(RenderableEmbeddedWorkflow::new(
|
||||
viewport_item,
|
||||
model,
|
||||
ctx,
|
||||
self.is_agent_mode_prompt,
|
||||
))
|
||||
}
|
||||
|
||||
fn spacing(&self) -> BlockSpacing {
|
||||
EMBED_WORKFLOW_SPACING
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RenderableEmbeddedWorkflow {
|
||||
viewport_item: ViewportItem,
|
||||
workflow_icon: Box<dyn Element>,
|
||||
border: Option<Border>,
|
||||
footer: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl RenderableEmbeddedWorkflow {
|
||||
pub fn new(
|
||||
viewport_item: ViewportItem,
|
||||
model: Option<&dyn EmbeddedItemModel>,
|
||||
ctx: &AppContext,
|
||||
is_agent_mode_prompt: bool,
|
||||
) -> Self {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let (icon, icon_color) = if is_agent_mode_prompt {
|
||||
(
|
||||
Icon::Prompt,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::AgentModeWorkflow),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Icon::Workflow,
|
||||
warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
)
|
||||
};
|
||||
let workflow_icon = ConstrainedBox::new(
|
||||
icon.to_warpui_icon(icon_color.into())
|
||||
.with_opacity(1.0)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(WORKFLOW_ICON_SIZE)
|
||||
.with_width(WORKFLOW_ICON_SIZE)
|
||||
.finish();
|
||||
|
||||
let footer = match model.and_then(|model| model.render_item_footer(ctx)) {
|
||||
Some(element) => element,
|
||||
None => Empty::new().finish(),
|
||||
};
|
||||
|
||||
Self {
|
||||
viewport_item,
|
||||
workflow_icon,
|
||||
border: model.and_then(|model| model.border(ctx)),
|
||||
footer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderableBlock for RenderableEmbeddedWorkflow {
|
||||
fn viewport_item(&self) -> &ViewportItem {
|
||||
&self.viewport_item
|
||||
}
|
||||
|
||||
fn layout(&mut self, _model: &RenderState, ctx: &mut LayoutContext, app: &AppContext) {
|
||||
self.workflow_icon.layout(
|
||||
SizeConstraint::strict(vec2f(WORKFLOW_ICON_SIZE, WORKFLOW_ICON_SIZE)),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
|
||||
self.footer.layout(
|
||||
SizeConstraint::strict(vec2f(
|
||||
self.viewport_item.content_size.x(),
|
||||
BLOCK_FOOTER_HEIGHT,
|
||||
)),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
|
||||
let content = model.content();
|
||||
let embedded_workflow = extract_block!(self.viewport_item, content, (block, BlockItem::Embedded(workflow)) => block.embedded(workflow));
|
||||
|
||||
let workflow: &LaidOutEmbeddedWorkflow = embedded_workflow
|
||||
.item
|
||||
.as_any()
|
||||
.downcast_ref()
|
||||
.expect("Should be a workflow");
|
||||
|
||||
// Check if any of the active selections overlap with the embedded workflow.
|
||||
let selected = model.offset_in_active_selection(embedded_workflow.start_char_offset);
|
||||
// Check if any of the cursors are at the start of the embedded workflow.
|
||||
let draw_cursor = model.is_selection_head(embedded_workflow.start_char_offset);
|
||||
|
||||
let styles = model.styles();
|
||||
let base_style = &styles.base_text;
|
||||
let code_style = &styles.embedding_text;
|
||||
|
||||
let border = self.border.unwrap_or(styles.code_border);
|
||||
|
||||
let background_rect = self.viewport_item.visible_bounds(ctx);
|
||||
|
||||
ctx.paint
|
||||
.scene
|
||||
.draw_rect_without_hit_recording(background_rect)
|
||||
.with_border(border)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background(model.styles().embedding_background);
|
||||
|
||||
let mut content_origin = embedded_workflow.content_origin();
|
||||
|
||||
// Vertically center the icon relative to the first line of the title text.
|
||||
let title_line_height = workflow
|
||||
.title
|
||||
.lines()
|
||||
.first()
|
||||
.map_or(workflow.title_height.as_f32(), |line| line.height());
|
||||
let workflow_icon_origin =
|
||||
content_origin + vec2f(0., (title_line_height - WORKFLOW_ICON_SIZE) / 2.);
|
||||
self.workflow_icon
|
||||
.paint(ctx.content_to_screen(workflow_icon_origin), ctx.paint, app);
|
||||
|
||||
content_origin += vec2f(WORKFLOW_TEXT_PADDING, 0.);
|
||||
ctx.draw_text(
|
||||
content_origin,
|
||||
Default::default(),
|
||||
&workflow.title,
|
||||
base_style,
|
||||
);
|
||||
content_origin += vec2f(0., workflow.title_height.as_f32());
|
||||
|
||||
if let Some(description_frame) = &workflow.description {
|
||||
content_origin += vec2f(0., TITLE_TO_DESCRIPTION_PADDING);
|
||||
|
||||
ctx.draw_text(
|
||||
content_origin,
|
||||
Default::default(),
|
||||
description_frame,
|
||||
base_style,
|
||||
);
|
||||
|
||||
content_origin += vec2f(
|
||||
0.,
|
||||
workflow.description_height.expect("Should exist").as_f32(),
|
||||
)
|
||||
}
|
||||
|
||||
content_origin += vec2f(0., DESCRIPTION_TO_COMMAND_PADDING);
|
||||
|
||||
for frame in &workflow.command {
|
||||
ctx.draw_text(content_origin, Default::default(), frame, code_style);
|
||||
|
||||
content_origin += vec2f(
|
||||
0.,
|
||||
frame.lines().iter().fold(0f32, |acc, line| {
|
||||
acc + line.font_size * line.line_height_ratio
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if selected {
|
||||
ctx.paint
|
||||
.scene
|
||||
.draw_rect_with_hit_recording(background_rect)
|
||||
.with_background(styles.selection_fill);
|
||||
}
|
||||
|
||||
if draw_cursor {
|
||||
let line_height = styles.base_text.line_height().as_f32();
|
||||
// The lower right corner of the background rect is at reserved_origin + background_rect.size()
|
||||
// Add some horizontal padding and minus line height vertically so it's visible and aligned to
|
||||
// the bottom of the background rect.
|
||||
let end_of_line_position = embedded_workflow.reserved_origin()
|
||||
+ background_rect.size()
|
||||
+ vec2f(5., -line_height);
|
||||
ctx.draw_and_save_cursor(
|
||||
CursorDisplayType::Bar,
|
||||
end_of_line_position,
|
||||
vec2f(styles.cursor_width, line_height),
|
||||
CursorData::default(),
|
||||
styles,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.paint.scene.start_layer(warpui::ClipBounds::ActiveLayer);
|
||||
|
||||
// Position the block footer right below the content area, flush with its right-hand edge.
|
||||
// This gives the footer some padding relative to the visible area with a background.
|
||||
let content_rect = self.viewport_item.content_bounds(ctx);
|
||||
let button_origin = content_rect.lower_right()
|
||||
- vec2f(
|
||||
self.footer.size().expect("Footer should be laid out").x(),
|
||||
0.,
|
||||
);
|
||||
self.footer.paint(button_origin, ctx.paint, app);
|
||||
|
||||
ctx.paint.scene.stop_layer();
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
|
||||
self.footer.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_model: &warp_editor::render::model::RenderState,
|
||||
event: &warpui::event::DispatchedEvent,
|
||||
ctx: &mut warpui::EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.footer.dispatch_event(event, ctx, app)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
|
||||
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_editor::{
|
||||
content::{anchor::Anchor, buffer::Buffer, selection_model::BufferSelectionModel},
|
||||
editor::EmbeddedItemModel,
|
||||
};
|
||||
use warp_util::user_input::UserInput;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::{button::ButtonVariant, components::UiComponent},
|
||||
AppContext, Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
completer::SessionAgnosticContext,
|
||||
notebooks::{
|
||||
styles::block_footer_action_button,
|
||||
telemetry::{ActionEntrypoint, BlockInfo},
|
||||
},
|
||||
server::ids::{HashableId, ToServerId},
|
||||
settings::FontSettings,
|
||||
terminal::input::decorations::{parse_current_commands_and_tokens, ParsedTokensSnapshot},
|
||||
themes::theme::AnsiColorIdentifier,
|
||||
ui_components::icons::Icon,
|
||||
util::bindings::CustomAction,
|
||||
workflows::{CloudWorkflow, WorkflowId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
embedded_item::EmbeddedWorkflow,
|
||||
keys::{custom_action_to_display, NotebookKeybindings},
|
||||
model::ChildModelHandle,
|
||||
notebook_command::{parsed_token_to_color_style_ranges, transform_ansi_color_to_solid_color},
|
||||
rich_text_styles,
|
||||
view::EditorViewAction,
|
||||
NotebookWorkflow,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
insert_button_state: MouseStateHandle,
|
||||
copy_button_state: MouseStateHandle,
|
||||
edit_button_state: MouseStateHandle,
|
||||
remove_embedding_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct NotebookEmbed {
|
||||
start: Anchor,
|
||||
hashed_id: String,
|
||||
is_selected: bool,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
cached_syntax_color: Option<Vec<(Range<ByteOffset>, AnsiColorIdentifier)>>,
|
||||
}
|
||||
|
||||
impl NotebookEmbed {
|
||||
pub fn new(
|
||||
start: CharOffset,
|
||||
hashed_id: String,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let start = selection_model.update(ctx, |selection_model, ctx| {
|
||||
selection_model.anchor(start, ctx)
|
||||
});
|
||||
|
||||
let embedding = Self {
|
||||
start,
|
||||
hashed_id,
|
||||
content,
|
||||
selection_model,
|
||||
is_selected: false,
|
||||
mouse_state_handles: Default::default(),
|
||||
cached_syntax_color: None,
|
||||
};
|
||||
|
||||
embedding.highlight_syntax(ctx);
|
||||
embedding
|
||||
}
|
||||
|
||||
pub fn highlight_syntax(&self, ctx: &mut ModelContext<Self>) {
|
||||
let completion_context = SessionAgnosticContext::new(CommandRegistry::global_instance());
|
||||
if let Some(command) = self
|
||||
.maybe_get_workflow(ctx)
|
||||
.and_then(|workflow| workflow.model().data.command())
|
||||
{
|
||||
let command = command.to_string();
|
||||
let _ = ctx.spawn(
|
||||
async move { parse_current_commands_and_tokens(command, &completion_context).await },
|
||||
|notebook_embed, parsed_tokens, ctx| {
|
||||
notebook_embed.update_buffer_with_parsed_tokens(parsed_tokens, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_buffer_with_parsed_tokens(
|
||||
&mut self,
|
||||
parsed_tokens: ParsedTokensSnapshot,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let colors = parsed_token_to_color_style_ranges(parsed_tokens.parsed_tokens);
|
||||
self.cached_syntax_color = Some(colors.clone());
|
||||
|
||||
self.update_buffer_with_syntax_color(&colors, ctx);
|
||||
}
|
||||
|
||||
pub fn try_apply_cached_highlighting(&self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(colors) = &self.cached_syntax_color {
|
||||
self.update_buffer_with_syntax_color(colors, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_buffer_with_syntax_color(
|
||||
&self,
|
||||
colors: &[(Range<ByteOffset>, AnsiColorIdentifier)],
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(offset) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let font_settings = FontSettings::as_ref(ctx);
|
||||
let terminal_colors_normal = appearance.theme().terminal_colors().normal.to_owned();
|
||||
let background_color = rich_text_styles(appearance, font_settings)
|
||||
.embedding_background
|
||||
.start_color();
|
||||
|
||||
self.content.update(ctx, |buffer, ctx| {
|
||||
buffer.replace_embedding_at_offset(
|
||||
offset,
|
||||
Arc::new(
|
||||
EmbeddedWorkflow::new(self.hashed_id.clone()).with_syntax_highlighting(
|
||||
transform_ansi_color_to_solid_color(
|
||||
colors,
|
||||
&terminal_colors_normal,
|
||||
background_color,
|
||||
),
|
||||
),
|
||||
),
|
||||
self.selection_model.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn hashed_id(&self) -> &str {
|
||||
self.hashed_id.as_str()
|
||||
}
|
||||
|
||||
pub fn refresh_item_state(&self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(offset) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.content.update(ctx, |buffer, ctx| {
|
||||
buffer.replace_embedding_at_offset(
|
||||
offset,
|
||||
Arc::new(EmbeddedWorkflow::new(self.hashed_id.clone())),
|
||||
self.selection_model.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Re-highlight syntax since the command might have changed.
|
||||
self.highlight_syntax(ctx);
|
||||
}
|
||||
|
||||
fn maybe_get_workflow<'a>(&self, ctx: &'a AppContext) -> Option<&'a CloudWorkflow> {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
|
||||
// Currently we are only supporting embedded workflows. We could support
|
||||
// more drive objects in the future.
|
||||
let id = WorkflowId::from_hash(&self.hashed_id)?;
|
||||
cloud_model
|
||||
.get_by_uid(&id.to_server_id().uid())
|
||||
.and_then(|object| object.as_any().downcast_ref::<CloudWorkflow>())
|
||||
.and_then(|workflow| {
|
||||
if workflow.is_trashed(cloud_model) {
|
||||
None
|
||||
} else {
|
||||
Some(workflow)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_offset(&self, ctx: &impl ModelAsRef) -> Option<CharOffset> {
|
||||
self.selection_model.as_ref(ctx).resolve_anchor(&self.start)
|
||||
}
|
||||
|
||||
fn selectable(&self, ctx: &AppContext) -> bool {
|
||||
self.maybe_get_workflow(ctx).is_some()
|
||||
}
|
||||
|
||||
fn render_footer_for_workflow(
|
||||
&self,
|
||||
workflow: &CloudWorkflow,
|
||||
appearance: &Appearance,
|
||||
ctx: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
|
||||
let workflow_id = workflow.id;
|
||||
let workflow_info = NotebookWorkflow::from_cloud_workflow(Box::new(workflow.clone()));
|
||||
let block_info = BlockInfo::EmbeddedWorkflow {
|
||||
workflow_id: workflow_id.into_server().map(Into::into),
|
||||
team_uid: workflow.permissions.owner.into(),
|
||||
};
|
||||
|
||||
let workflow_content = workflow.model().data.content().to_owned();
|
||||
footer.add_child(Shrinkable::new(1.0, Empty::new().finish()).finish());
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Pencil,
|
||||
self.mouse_state_handles.edit_button_state.clone(),
|
||||
"Edit",
|
||||
None,
|
||||
)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::EditWorkflow(workflow_id));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Copy,
|
||||
self.mouse_state_handles.copy_button_state.clone(),
|
||||
"Copy",
|
||||
custom_action_to_display(CustomAction::Copy),
|
||||
)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::CopyTextToClipboard {
|
||||
text: UserInput::new(workflow_content.clone()),
|
||||
block: block_info,
|
||||
entrypoint: ActionEntrypoint::Button,
|
||||
});
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::TerminalInput,
|
||||
self.mouse_state_handles.insert_button_state.clone(),
|
||||
"Run in terminal",
|
||||
NotebookKeybindings::as_ref(ctx).run_commands_keybinding(),
|
||||
)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::RunWorkflow(workflow_info.clone()));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
footer.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotebookEmbed {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl EmbeddedItemModel for NotebookEmbed {
|
||||
fn render_item_footer(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
// Currently we are only supporting embedded workflows. We could support
|
||||
// more drive objects in the future.
|
||||
let workflow = self.maybe_get_workflow(ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
workflow.map(|workflow| self.render_footer_for_workflow(workflow, appearance, ctx))
|
||||
}
|
||||
|
||||
fn border(&self, app: &AppContext) -> Option<Border> {
|
||||
if self.is_selected {
|
||||
let border_fill = Appearance::as_ref(app).theme().accent();
|
||||
Some(Border::all(3.).with_border_fill(border_fill))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_remove_embedding_button(&self, ctx: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let offset = self.start_offset(ctx)?;
|
||||
Some(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Text,
|
||||
self.mouse_state_handles
|
||||
.remove_embedding_button_state
|
||||
.clone(),
|
||||
)
|
||||
.with_text_label("Remove".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::Arrow)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::RemoveEmbeddingAt(offset));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(12.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildModelHandle for ModelHandle<NotebookEmbed> {
|
||||
fn start_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
self.as_ref(app).start_offset(app)
|
||||
}
|
||||
|
||||
fn end_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
// Embedding should always take one character offset.
|
||||
self.as_ref(app).start_offset(app).map(|offset| offset + 1)
|
||||
}
|
||||
|
||||
fn selectable(&self, app: &AppContext) -> bool {
|
||||
self.as_ref(app).selectable(app)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn executable_workflow(&self, app: &AppContext) -> Option<NotebookWorkflow> {
|
||||
// Currently we are only supporting embedded workflows. We could support
|
||||
// more drive objects in the future.
|
||||
self.as_ref(app)
|
||||
.maybe_get_workflow(app)
|
||||
.map(|workflow| NotebookWorkflow::from_cloud_workflow(Box::new(workflow.clone())))
|
||||
}
|
||||
|
||||
fn executable_command<'a>(&'a self, app: &'a AppContext) -> Option<Cow<'a, str>> {
|
||||
self.as_ref(app)
|
||||
.maybe_get_workflow(app)
|
||||
.map(|workflow| workflow.model().data.content().into())
|
||||
}
|
||||
|
||||
fn selected(&self, app: &AppContext) -> bool {
|
||||
self.as_ref(app).is_selected
|
||||
}
|
||||
|
||||
fn set_selected(&self, selected: bool, ctx: &mut AppContext) -> bool {
|
||||
self.update(ctx, |model, _ctx| {
|
||||
mem::replace(&mut model.is_selected, selected)
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn ChildModelHandle> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
use std::{fmt::Write, time::Duration};
|
||||
|
||||
use async_channel::Sender;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_editor::{
|
||||
render::model::{AutoScrollMode, Decoration},
|
||||
search::{SearchEvent, Searcher},
|
||||
};
|
||||
use warpui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole},
|
||||
elements::{
|
||||
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
|
||||
PositionedElementOffsetBounds, Radius, Rect, Shrinkable, Stack,
|
||||
},
|
||||
platform::Cursor,
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
toggle_button::ToggleButton,
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
debounce::debounce,
|
||||
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
|
||||
ui_components::icons::Icon,
|
||||
view_components::find::{
|
||||
CASE_SENSITIVE_LABEL, CASE_SENSITIVE_TOOLTIP, FIND_BAR_WIDTH, REGEX_TOGGLE_LABEL,
|
||||
REGEX_TOGGLE_TOOLTIP,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::NotebooksEditorModel,
|
||||
view::{EditorViewEvent, RichTextEditorView},
|
||||
};
|
||||
|
||||
/// View for the find bar within a notebook.
|
||||
pub struct FindBar {
|
||||
searcher: ModelHandle<Searcher>,
|
||||
editor_model: ModelHandle<NotebooksEditorModel>,
|
||||
query_editor: ViewHandle<EditorView>,
|
||||
query_change_tx: Sender<()>,
|
||||
button_handles: ButtonHandles,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ButtonHandles {
|
||||
regex_toggle: MouseStateHandle,
|
||||
case_sensitive_toggle: MouseStateHandle,
|
||||
next_match: MouseStateHandle,
|
||||
previous_match: MouseStateHandle,
|
||||
close: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FindBarEvent {
|
||||
Close,
|
||||
SearchDecorationsChanged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FindBarAction {
|
||||
ToggleRegex,
|
||||
ToggleCaseSensitive,
|
||||
FocusNextMatch,
|
||||
FocusPreviousMatch,
|
||||
Close,
|
||||
}
|
||||
|
||||
const QUERY_DEBOUNCE_PERIOD: Duration = Duration::from_millis(20);
|
||||
|
||||
impl FindBar {
|
||||
pub fn new(
|
||||
editor_model: ModelHandle<NotebooksEditorModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let searcher = editor_model.update(ctx, |model, ctx| model.new_search(ctx));
|
||||
let query_editor = ctx.add_typed_action_view(|ctx| {
|
||||
EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
// Ensure the search input font size is consistent with the button labels.
|
||||
text: TextOptions::ui_font_size(Appearance::as_ref(ctx)),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_view(&query_editor, Self::handle_query_editor_event);
|
||||
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
ctx.spawn_stream_local(
|
||||
debounce(QUERY_DEBOUNCE_PERIOD, rx),
|
||||
Self::handle_debounced_query_change,
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&searcher, Self::handle_search_event);
|
||||
|
||||
Self {
|
||||
searcher,
|
||||
editor_model,
|
||||
query_editor,
|
||||
query_change_tx: tx,
|
||||
button_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the query editor is focused.
|
||||
pub fn query_editor_focused(&self, app: &AppContext) -> bool {
|
||||
self.query_editor.is_focused(app)
|
||||
}
|
||||
|
||||
/// Decorations for the current find-bar search results.
|
||||
pub fn decorations(&self, ctx: &AppContext) -> Vec<Decoration> {
|
||||
self.searcher.as_ref(ctx).result_decorations()
|
||||
}
|
||||
|
||||
fn handle_query_editor_event(
|
||||
&mut self,
|
||||
_editor: ViewHandle<EditorView>,
|
||||
event: &EditorEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => {
|
||||
let _ = self.query_change_tx.try_send(());
|
||||
}
|
||||
EditorEvent::Enter => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_next_result(ctx));
|
||||
}
|
||||
EditorEvent::ShiftEnter | EditorEvent::AltEnter => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_previous_result(ctx));
|
||||
}
|
||||
EditorEvent::Escape => ctx.emit(FindBarEvent::Close),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_debounced_query_change(&mut self, _event: (), ctx: &mut ViewContext<Self>) {
|
||||
let query = self.query_editor.as_ref(ctx).buffer_text(ctx);
|
||||
self.searcher
|
||||
.update(ctx, |searcher, ctx| searcher.set_query(query, ctx));
|
||||
}
|
||||
|
||||
fn handle_search_event(
|
||||
&mut self,
|
||||
_model: ModelHandle<Searcher>,
|
||||
event: &SearchEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SearchEvent::Updated => {
|
||||
// We ask the parent view to update decorations instead of doing it ourselves. This
|
||||
// way, it can merge together decorations from multiple sources.
|
||||
ctx.emit(FindBarEvent::SearchDecorationsChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
SearchEvent::SelectedResultChanged => {
|
||||
if let Some(autoscroll_match) = self.searcher.as_ref(ctx).selected_match_range() {
|
||||
self.editor_model.as_ref(ctx).render_state().clone().update(
|
||||
ctx,
|
||||
|render_state, _ctx| {
|
||||
render_state.request_autoscroll_to(
|
||||
AutoScrollMode::ScrollOffsetsIntoViewport(autoscroll_match),
|
||||
);
|
||||
},
|
||||
)
|
||||
}
|
||||
ctx.emit(FindBarEvent::SearchDecorationsChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
SearchEvent::InvalidQuery => {
|
||||
// TODO: Show an error border?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line height for the query editor.
|
||||
fn editor_height(&self, appearance: &Appearance, app: &AppContext) -> f32 {
|
||||
self.query_editor
|
||||
.as_ref(app)
|
||||
.line_height(app.font_cache(), appearance)
|
||||
}
|
||||
|
||||
fn render_match_index(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let searcher = self.searcher.as_ref(app);
|
||||
if searcher.has_query() {
|
||||
let match_count = searcher.match_count();
|
||||
let text = if match_count == 0 {
|
||||
"No matches".to_string()
|
||||
} else {
|
||||
let mut text = String::new();
|
||||
match searcher.selected_match() {
|
||||
Some(idx) => {
|
||||
let _ = write!(&mut text, "{}", idx + 1);
|
||||
}
|
||||
None => text.push('?'),
|
||||
}
|
||||
text.push('/');
|
||||
let _ = write!(&mut text, "{match_count}");
|
||||
text
|
||||
};
|
||||
|
||||
appearance.ui_builder().span(text).build().finish()
|
||||
} else {
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the separator between the query and search options.
|
||||
fn render_separator_line(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_background(
|
||||
appearance
|
||||
.theme()
|
||||
.foreground_button_color()
|
||||
.with_opacity(20),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(1.)
|
||||
.with_height(self.editor_height(appearance, app) + 16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_top(7.)
|
||||
.with_padding_bottom(7.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_action_button(
|
||||
&self,
|
||||
icon: Icon,
|
||||
action: FindBarAction,
|
||||
enabled: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let size = self.editor_height(appearance, app);
|
||||
|
||||
let base_styles = self
|
||||
.button_styles(appearance, app)
|
||||
// We have to add back in space for the padding, because Button applies its size
|
||||
// constraint around the padding and border.
|
||||
.set_width(size + 16.)
|
||||
.set_height(size + 16.);
|
||||
let mut button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Text, mouse_state_handle)
|
||||
// The fill here doesn't matter, since it's overridden by the button text color.
|
||||
.with_icon_label(icon.to_warpui_icon(crate::themes::theme::Fill::white()))
|
||||
.with_style(base_styles)
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().foreground_button_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_disabled_styles(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if !enabled {
|
||||
button = button.disabled();
|
||||
}
|
||||
|
||||
let button = button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(action);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Container::new(button)
|
||||
.with_vertical_padding(8.)
|
||||
.with_padding_left(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Render a toggle button for one of the search options.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_toggle_button(
|
||||
&self,
|
||||
text: &str,
|
||||
tooltip: &str,
|
||||
action: FindBarAction,
|
||||
toggled_on: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let button = ToggleButton::new(mouse_state, self.button_styles(appearance, app))
|
||||
.with_label(text)
|
||||
.with_toggled_on(toggled_on)
|
||||
.with_hovered_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().foreground_button_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_toggled_on_styles(UiComponentStyles {
|
||||
background: Some(appearance.theme().find_bar_button_selection_color().into()),
|
||||
border_color: Some(appearance.theme().accent().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_tooltip(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.tool_tip(tooltip.to_string())
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Container::new(button)
|
||||
.with_vertical_padding(8.)
|
||||
.with_padding_left(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Shared styles for find-bar buttons.
|
||||
fn button_styles(&self, appearance: &Appearance, app: &AppContext) -> UiComponentStyles {
|
||||
let size = self.editor_height(appearance, app);
|
||||
UiComponentStyles {
|
||||
width: Some(size),
|
||||
height: Some(size),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
|
||||
border_width: Some(1.),
|
||||
padding: Some(Coords::uniform(7.)),
|
||||
font_size: Some(appearance.ui_font_size()),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(appearance.theme().active_ui_text_color().into_solid()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FindBar {
|
||||
type Event = FindBarEvent;
|
||||
}
|
||||
|
||||
impl View for FindBar {
|
||||
fn ui_name() -> &'static str {
|
||||
"FindBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let searcher = self.searcher.as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let editor_height = self.editor_height(appearance, app);
|
||||
let has_matches = searcher.match_count() > 0;
|
||||
|
||||
let find_icon = Container::new(
|
||||
ConstrainedBox::new(Icon::Find.to_warpui_icon(theme.active_ui_detail()).finish())
|
||||
.with_height(editor_height)
|
||||
.with_width(editor_height)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(16.)
|
||||
.finish();
|
||||
|
||||
let find_editor = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Clipped::new(ChildView::new(&self.query_editor).finish()).finish(),
|
||||
)
|
||||
.finish(),
|
||||
self.render_match_index(appearance, app),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_height(editor_height)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(8.)
|
||||
.with_vertical_padding(16.)
|
||||
.finish();
|
||||
|
||||
let find_box = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
find_icon,
|
||||
Shrinkable::new(1., find_editor).finish(),
|
||||
self.render_separator_line(appearance, app),
|
||||
self.render_action_button(
|
||||
Icon::ChevronUp,
|
||||
FindBarAction::FocusPreviousMatch,
|
||||
has_matches,
|
||||
self.button_handles.previous_match.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_action_button(
|
||||
Icon::ChevronDown,
|
||||
FindBarAction::FocusNextMatch,
|
||||
has_matches,
|
||||
self.button_handles.next_match.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_toggle_button(
|
||||
REGEX_TOGGLE_LABEL,
|
||||
REGEX_TOGGLE_TOOLTIP,
|
||||
FindBarAction::ToggleRegex,
|
||||
searcher.is_regex(),
|
||||
self.button_handles.regex_toggle.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_toggle_button(
|
||||
CASE_SENSITIVE_LABEL,
|
||||
CASE_SENSITIVE_TOOLTIP,
|
||||
FindBarAction::ToggleCaseSensitive,
|
||||
searcher.is_case_sensitive(),
|
||||
self.button_handles.case_sensitive_toggle.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
self.render_action_button(
|
||||
Icon::X,
|
||||
FindBarAction::Close,
|
||||
true,
|
||||
self.button_handles.close.clone(),
|
||||
appearance,
|
||||
app,
|
||||
),
|
||||
]);
|
||||
|
||||
let container = Container::new(
|
||||
ConstrainedBox::new(find_box.finish())
|
||||
.with_width(FIND_BAR_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(14.)
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.finish();
|
||||
|
||||
Container::new(container)
|
||||
.with_padding_top(10.)
|
||||
.with_padding_right(20.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
// Enable auto-selection so that new search results automatically select the
|
||||
// nearest match from the cursor, avoiding a "?" in the result counter.
|
||||
self.searcher.update(ctx, |searcher, _ctx| {
|
||||
searcher.set_auto_select(true);
|
||||
});
|
||||
|
||||
if focus_ctx.is_self_focused() {
|
||||
self.query_editor
|
||||
.update(ctx, |editor, ctx| editor.select_all(ctx));
|
||||
ctx.focus(&self.query_editor);
|
||||
|
||||
// If reopening with cached results but no selection, select the nearest match.
|
||||
let should_select = {
|
||||
let searcher = self.searcher.as_ref(ctx);
|
||||
searcher.match_count() > 0 && searcher.selected_match().is_none()
|
||||
};
|
||||
if should_select {
|
||||
self.searcher
|
||||
.update(ctx, |searcher, ctx| searcher.select_next_from_cursor(ctx));
|
||||
}
|
||||
|
||||
// If there's a cached previous search, show the results.
|
||||
ctx.emit(FindBarEvent::SearchDecorationsChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, _blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
// Check if focus moved to the query editor (a child of this view).
|
||||
let focused_view_id = ctx.focused_view_id(ctx.window_id());
|
||||
let is_focus_within = focused_view_id == Some(self.query_editor.id());
|
||||
|
||||
if !is_focus_within {
|
||||
self.searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.clear_selected_result(ctx);
|
||||
searcher.set_auto_select(false);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for FindBar {
|
||||
type Action = FindBarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
FindBarAction::ToggleRegex => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.set_regex(!search.is_regex(), ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
FindBarAction::ToggleCaseSensitive => {
|
||||
self.searcher.update(ctx, |search, ctx| {
|
||||
search.set_case_sensitive(!search.is_case_sensitive(), ctx)
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
FindBarAction::FocusNextMatch => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_next_result(ctx));
|
||||
}
|
||||
FindBarAction::FocusPreviousMatch => {
|
||||
self.searcher
|
||||
.update(ctx, |search, ctx| search.select_previous_result(ctx));
|
||||
}
|
||||
FindBarAction::Close => {
|
||||
ctx.emit(FindBarEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_accessibility_contents(
|
||||
&mut self,
|
||||
action: &Self::Action,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ActionAccessibilityContent {
|
||||
let text = match action {
|
||||
FindBarAction::ToggleRegex => {
|
||||
if self.searcher.as_ref(ctx).is_regex() {
|
||||
"Enable regex search"
|
||||
} else {
|
||||
"Disable regex search"
|
||||
}
|
||||
}
|
||||
FindBarAction::ToggleCaseSensitive => {
|
||||
if self.searcher.as_ref(ctx).is_case_sensitive() {
|
||||
"Enable case-sensitive search"
|
||||
} else {
|
||||
"Disable case-sensitive search"
|
||||
}
|
||||
}
|
||||
FindBarAction::FocusNextMatch => "Focus next match",
|
||||
FindBarAction::FocusPreviousMatch => "Focus previous match",
|
||||
FindBarAction::Close => "Close find bar",
|
||||
};
|
||||
Some(AccessibilityContent::new_without_help(
|
||||
text,
|
||||
WarpA11yRole::UserAction,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// State for embedding a find bar in a rich-text editor.
|
||||
pub struct FindBarState {
|
||||
bar_view: ViewHandle<FindBar>,
|
||||
is_open: bool,
|
||||
parent_position: String,
|
||||
}
|
||||
|
||||
impl FindBarState {
|
||||
pub fn new(
|
||||
parent_position: String,
|
||||
model: ModelHandle<NotebooksEditorModel>,
|
||||
ctx: &mut ViewContext<RichTextEditorView>,
|
||||
) -> Self {
|
||||
let bar_view = ctx.add_typed_action_view(|ctx| FindBar::new(model, ctx));
|
||||
Self {
|
||||
parent_position,
|
||||
bar_view,
|
||||
is_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self) -> &ViewHandle<FindBar> {
|
||||
&self.bar_view
|
||||
}
|
||||
|
||||
/// Whether or not the find bar is focused.
|
||||
pub fn is_focused(&self, app: &AppContext) -> bool {
|
||||
self.bar_view.is_focused(app) || self.bar_view.as_ref(app).query_editor_focused(app)
|
||||
}
|
||||
|
||||
/// Decorations to highlight find-bar matches.
|
||||
pub fn decorations(&self, app: &AppContext) -> Vec<Decoration> {
|
||||
if self.is_open {
|
||||
self.bar_view.as_ref(app).decorations(app)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the find bar, if open.
|
||||
pub fn render(&self, stack: &mut Stack) {
|
||||
if self.is_open {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(&self.bar_view).finish(),
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
self.parent_position.clone(),
|
||||
vec2f(-4., -4.),
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
PositionedElementAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open and focus the find bar.
|
||||
pub fn show(&mut self, ctx: &mut ViewContext<RichTextEditorView>) {
|
||||
self.is_open = true;
|
||||
ctx.focus(&self.bar_view);
|
||||
ctx.emit(EditorViewEvent::OpenedFindBar);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Hide the find bar. If search matches were highlighted, the parent view should clear them.
|
||||
pub fn hide(&mut self, ctx: &mut ViewContext<RichTextEditorView>) {
|
||||
self.is_open = false;
|
||||
ctx.focus_self();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
pub struct InteractionStateModel {
|
||||
state: InteractionState,
|
||||
is_block_selected: bool,
|
||||
}
|
||||
|
||||
impl InteractionStateModel {
|
||||
pub fn new(initial_state: InteractionState) -> Self {
|
||||
Self {
|
||||
state: initial_state,
|
||||
is_block_selected: false, // refers to whether any block in the given notebook is selected
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_interaction_state(
|
||||
&mut self,
|
||||
new_state: InteractionState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.state = new_state;
|
||||
ctx.emit(InteractionStateModelEvent::InteractionStateChanged { new_state });
|
||||
}
|
||||
|
||||
pub fn interaction_state(&self) -> InteractionState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn is_block_selected(&self) -> bool {
|
||||
self.is_block_selected
|
||||
}
|
||||
|
||||
pub fn set_is_block_selected(&mut self, is_selected: bool, ctx: &mut ModelContext<Self>) {
|
||||
self.is_block_selected = is_selected;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InteractionStateModelEvent {
|
||||
InteractionStateChanged { new_state: InteractionState },
|
||||
}
|
||||
|
||||
impl Entity for InteractionStateModel {
|
||||
type Event = InteractionStateModelEvent;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Utilities for notebook keybindings.
|
||||
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier},
|
||||
util::bindings::{custom_tag_to_keystroke, keybinding_name_to_display_string, CustomAction},
|
||||
};
|
||||
|
||||
pub const RUN_COMMANDS_KEYBINDING_NAME: &str = "editor_view:run_commands";
|
||||
|
||||
/// Cache of keybindings used in notebooks.
|
||||
pub struct NotebookKeybindings {
|
||||
// Cache of editable keybinding names, to render in tooltips. This cache is necessary because
|
||||
// looking up a keybinding requires a [`AppContext`], so it can't be done when
|
||||
// rendering.
|
||||
//
|
||||
// Inspired by https://github.com/warpdotdev/warp-internal/pull/5676 (see the `Workspace` view)
|
||||
run_commands_keybinding: Option<String>,
|
||||
}
|
||||
|
||||
impl NotebookKeybindings {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&KeybindingChangedNotifier::handle(ctx),
|
||||
Self::handle_keybinding_change,
|
||||
);
|
||||
Self {
|
||||
run_commands_keybinding: keybinding_name_to_display_string(
|
||||
RUN_COMMANDS_KEYBINDING_NAME,
|
||||
ctx,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display label for the keybinding to run commands in a notebook.
|
||||
pub fn run_commands_keybinding(&self) -> Option<String> {
|
||||
self.run_commands_keybinding.clone()
|
||||
}
|
||||
|
||||
fn handle_keybinding_change(
|
||||
&mut self,
|
||||
event: &KeybindingChangedEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let KeybindingChangedEvent::BindingChanged {
|
||||
binding_name,
|
||||
new_trigger,
|
||||
} = event;
|
||||
if binding_name == RUN_COMMANDS_KEYBINDING_NAME {
|
||||
self.run_commands_keybinding = new_trigger.as_ref().map(|key| key.displayed());
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotebookKeybindings {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for NotebookKeybindings {}
|
||||
|
||||
/// The keybinding label to display for a [`CustomAction`].
|
||||
pub fn custom_action_to_display(action: CustomAction) -> Option<String> {
|
||||
custom_tag_to_keystroke(action.into()).map(|keystroke| keystroke.displayed())
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use warp_editor::{editor::NavigationKey, model::RichTextEditorModel, render::model::RenderState};
|
||||
use warpui::{
|
||||
elements::{
|
||||
AnchorPair, Container, Flex, MouseStateHandle, OffsetPositioning, OffsetType,
|
||||
ParentElement, PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
fonts::Weight,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
||||
TextOptions,
|
||||
},
|
||||
};
|
||||
|
||||
use super::model::NotebooksEditorModel;
|
||||
|
||||
const EDITOR_WIDTH: f32 = 368.;
|
||||
const EDITOR_VERTICAL_PADDING: f32 = 12.;
|
||||
const EDITOR_MARGIN: f32 = 16.;
|
||||
const BETWEEN_EDITOR_MARGIN: f32 = 8.;
|
||||
|
||||
pub enum LinkEditorEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LinkEditorAction {
|
||||
ApplyLink,
|
||||
}
|
||||
|
||||
pub struct LinkEditor {
|
||||
model: ModelHandle<NotebooksEditorModel>,
|
||||
tag_editor: ViewHandle<EditorView>,
|
||||
url_editor: ViewHandle<EditorView>,
|
||||
apply_link_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl LinkEditor {
|
||||
pub fn new(model: ModelHandle<NotebooksEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let editor_options = SingleLineEditorOptions {
|
||||
text: TextOptions::ui_text(None, appearance),
|
||||
propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tag_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor = EditorView::single_line(editor_options.clone(), ctx);
|
||||
editor.set_placeholder_text("Text", ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&tag_editor, |notebook, _, event, ctx| {
|
||||
notebook.handle_tag_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
let url_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor = EditorView::single_line(editor_options.clone(), ctx);
|
||||
editor.set_placeholder_text("Link (web or file)", ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&url_editor, |notebook, _, event, ctx| {
|
||||
notebook.handle_url_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
LinkEditor {
|
||||
model,
|
||||
tag_editor,
|
||||
url_editor,
|
||||
apply_link_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editors_focused(&self, app: &AppContext) -> bool {
|
||||
self.tag_editor.is_focused(app) || self.url_editor.is_focused(app)
|
||||
}
|
||||
|
||||
/// Focus the URL editor.
|
||||
pub fn focus_url_editor(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus(&self.url_editor);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn url_editor(&self) -> &ViewHandle<EditorView> {
|
||||
&self.url_editor
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn tag_editor(&self) -> &ViewHandle<EditorView> {
|
||||
&self.tag_editor
|
||||
}
|
||||
|
||||
/// Populate the link editor with the state of the active selection.
|
||||
pub fn populate(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let buffer_model = self.model.as_ref(ctx);
|
||||
let selected_content = buffer_model.selected_text(ctx);
|
||||
let url_at_selection = buffer_model.link_at_selection_head(ctx);
|
||||
self.tag_editor.update(ctx, |view, ctx| {
|
||||
view.clear_buffer_and_reset_undo_stack(ctx);
|
||||
view.set_buffer_text(&selected_content, ctx);
|
||||
});
|
||||
|
||||
self.url_editor.update(ctx, |view, ctx| {
|
||||
view.clear_buffer_and_reset_undo_stack(ctx);
|
||||
|
||||
if let Some(url) = &url_at_selection {
|
||||
view.set_buffer_text(url, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_tag_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => ctx.notify(),
|
||||
EditorEvent::Enter
|
||||
| EditorEvent::Navigate(NavigationKey::Tab | NavigationKey::ShiftTab) => {
|
||||
ctx.focus(&self.url_editor)
|
||||
}
|
||||
EditorEvent::Escape => ctx.emit(LinkEditorEvent::Close),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_url_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => ctx.notify(),
|
||||
EditorEvent::Enter => self.apply_link(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Tab | NavigationKey::ShiftTab) => {
|
||||
ctx.focus(&self.tag_editor)
|
||||
}
|
||||
EditorEvent::Escape => ctx.emit(LinkEditorEvent::Close),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the link editor is in a valid state that can be applied.
|
||||
fn is_valid(&self, ctx: &AppContext) -> bool {
|
||||
!self.tag_editor.as_ref(ctx).is_empty(ctx) && !self.url_editor.as_ref(ctx).is_empty(ctx)
|
||||
}
|
||||
|
||||
/// Apply the current link tag and url to the selected text and close the link editor.
|
||||
fn apply_link(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_valid(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tag = self.tag_editor.as_ref(ctx).buffer_text(ctx);
|
||||
let url = self.url_editor.as_ref(ctx).buffer_text(ctx);
|
||||
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.set_link(tag, url, ctx);
|
||||
});
|
||||
|
||||
ctx.emit(LinkEditorEvent::Close);
|
||||
}
|
||||
|
||||
pub fn positioning(render_state: &RenderState) -> OffsetPositioning {
|
||||
let selection_position = render_state.saved_positions().text_selection_id();
|
||||
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Middle, XAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(4.),
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for LinkEditor {
|
||||
type Event = LinkEditorEvent;
|
||||
}
|
||||
|
||||
impl View for LinkEditor {
|
||||
fn ui_name() -> &'static str {
|
||||
"LinkEditor"
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
ctx.emit(LinkEditorEvent::Close);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut editors = Flex::column();
|
||||
|
||||
editors.add_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(self.tag_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(EDITOR_WIDTH),
|
||||
padding: Some(Coords {
|
||||
top: EDITOR_VERTICAL_PADDING,
|
||||
bottom: EDITOR_VERTICAL_PADDING,
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
}),
|
||||
margin: Some(Coords {
|
||||
top: EDITOR_MARGIN,
|
||||
bottom: BETWEEN_EDITOR_MARGIN,
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
editors.add_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(self.url_editor.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(EDITOR_WIDTH),
|
||||
padding: Some(Coords {
|
||||
top: EDITOR_VERTICAL_PADDING,
|
||||
bottom: EDITOR_VERTICAL_PADDING,
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
}),
|
||||
margin: Some(Coords {
|
||||
left: EDITOR_MARGIN,
|
||||
right: EDITOR_MARGIN,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut link_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.apply_link_mouse_state.clone())
|
||||
.with_centered_text_label("Apply link".to_string());
|
||||
|
||||
// Disable the link button if either of the editors are empty.
|
||||
if !self.is_valid(app) {
|
||||
link_button = link_button.disabled();
|
||||
};
|
||||
|
||||
editors.add_child(
|
||||
link_button
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(EDITOR_WIDTH),
|
||||
margin: Some(Coords::uniform(EDITOR_MARGIN)),
|
||||
font_weight: Some(Weight::Bold),
|
||||
padding: Some(Coords {
|
||||
left: EDITOR_VERTICAL_PADDING,
|
||||
right: EDITOR_VERTICAL_PADDING,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(LinkEditorAction::ApplyLink))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Container::new(editors.finish())
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for LinkEditor {
|
||||
type Action = LinkEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
if matches!(action, LinkEditorAction::ApplyLink) {
|
||||
self.apply_link(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Rich-text notebooks editor.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::{builder::CHECK_SVG_PATH, theme::color::internal_colors};
|
||||
use warp_editor::{
|
||||
content::text::{
|
||||
BlockHeaderSize, BlockType as ContentBlockType, BufferBlockStyle, CodeBlockType,
|
||||
},
|
||||
render::model::{
|
||||
BrokenLinkStyle, CheckBoxStyle, EmbeddedItem, HorizontalRuleStyle, InlineCodeStyle,
|
||||
ParagraphStyles, RichTextStyles, TableStyle, PARAGRAPH_MIN_HEIGHT,
|
||||
},
|
||||
};
|
||||
use warp_util::user_input::UserInput;
|
||||
use warpui::{elements::Border, fonts::FamilyId, ui_components::checkbox::HOVER_BACKGROUND_COLOR};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
notebooks::editor::embedded_item::EmbeddedWorkflow,
|
||||
settings::{derived_notebook_font_size, FontSettings},
|
||||
themes::theme::Fill,
|
||||
ui_components::icons::Icon,
|
||||
util::color::{ContrastingColor, MinimumAllowedContrast},
|
||||
workflows::{CloudWorkflow, WorkflowSource, WorkflowType},
|
||||
};
|
||||
|
||||
mod block_insertion_menu;
|
||||
mod embedded_item;
|
||||
mod embedding_model;
|
||||
mod find_bar;
|
||||
mod interaction_state_model;
|
||||
pub mod keys;
|
||||
mod link_editor;
|
||||
pub mod model;
|
||||
pub mod notebook_command;
|
||||
mod omnibar;
|
||||
pub mod view;
|
||||
|
||||
pub use block_insertion_menu::BlockInsertionSource;
|
||||
use warpui::elements::ListIndentLevel;
|
||||
|
||||
const NOTEBOOK_LINE_HEIGHT_RATIO: f32 = 1.6;
|
||||
const NOTEBOOK_BASELINE_RATIO: f32 = 0.7;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct MarkdownTableAppearance {
|
||||
pub border_color: ColorU,
|
||||
pub header_background: ColorU,
|
||||
pub cell_background: ColorU,
|
||||
pub alternate_row_background: Option<ColorU>,
|
||||
pub text_color: ColorU,
|
||||
pub header_text_color: ColorU,
|
||||
pub scrollbar_nonactive_thumb_color: ColorU,
|
||||
pub scrollbar_active_thumb_color: ColorU,
|
||||
pub cell_padding: f32,
|
||||
pub outer_border: bool,
|
||||
pub column_dividers: bool,
|
||||
pub row_dividers: bool,
|
||||
}
|
||||
|
||||
/// A kind of block that can be added to a notebook.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockType {
|
||||
RunnableCommand,
|
||||
Code,
|
||||
Header(BlockHeaderSize),
|
||||
Text,
|
||||
UnorderedList,
|
||||
OrderedList,
|
||||
TaskList,
|
||||
}
|
||||
|
||||
impl BlockType {
|
||||
const ALL: [BlockType; 12] = [
|
||||
BlockType::RunnableCommand,
|
||||
BlockType::Code,
|
||||
BlockType::Header(BlockHeaderSize::Header1),
|
||||
BlockType::Header(BlockHeaderSize::Header2),
|
||||
BlockType::Header(BlockHeaderSize::Header3),
|
||||
BlockType::Header(BlockHeaderSize::Header4),
|
||||
BlockType::Header(BlockHeaderSize::Header5),
|
||||
BlockType::Header(BlockHeaderSize::Header6),
|
||||
BlockType::Text,
|
||||
BlockType::UnorderedList,
|
||||
BlockType::OrderedList,
|
||||
BlockType::TaskList,
|
||||
];
|
||||
|
||||
fn all() -> impl Iterator<Item = Self> {
|
||||
Self::ALL.into_iter()
|
||||
}
|
||||
|
||||
/// Block types that behave as code:
|
||||
/// * [`BlockType::RunnableCommand`]
|
||||
/// * [`BlockType::Code`]
|
||||
///
|
||||
/// These types support multiple paragraphs and syntax highlighting, but not user-defined
|
||||
/// formatting. In the block insertion menu, these types are grouped together.
|
||||
fn code_block_types() -> impl Iterator<Item = Self> {
|
||||
[BlockType::RunnableCommand, BlockType::Code].into_iter()
|
||||
}
|
||||
|
||||
/// Block types that behave as text (plain text, headings, and lists). These types support
|
||||
/// user-defined formatting. In the block insertion menu, these types are grouped together.
|
||||
fn text_block_types() -> impl Iterator<Item = Self> {
|
||||
Self::all().filter(|block_type| {
|
||||
*block_type != BlockType::Code && *block_type != BlockType::RunnableCommand
|
||||
})
|
||||
}
|
||||
|
||||
fn icon(self) -> Icon {
|
||||
match self {
|
||||
BlockType::Text => Icon::TextBlock,
|
||||
BlockType::Header(_) => Icon::HeaderBlock,
|
||||
BlockType::RunnableCommand => Icon::RunnableCommandBlock,
|
||||
BlockType::Code => Icon::Code1,
|
||||
BlockType::UnorderedList => Icon::BulletedListBlock,
|
||||
BlockType::OrderedList => Icon::OrderedListBlock,
|
||||
BlockType::TaskList => Icon::TaskListBlock,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_color(self, appearance: &Appearance) -> Option<Fill> {
|
||||
match self {
|
||||
BlockType::Text
|
||||
| BlockType::Header(_)
|
||||
| BlockType::UnorderedList
|
||||
| BlockType::OrderedList
|
||||
| BlockType::TaskList => Some(Fill::Solid(appearance.theme().ui_warning_color())),
|
||||
BlockType::RunnableCommand | BlockType::Code => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
BlockType::Text => "Text",
|
||||
BlockType::Header(size) => size.label(),
|
||||
BlockType::RunnableCommand => "Command",
|
||||
BlockType::UnorderedList => "Bulleted list",
|
||||
BlockType::OrderedList => "Numbered list",
|
||||
BlockType::Code => "Code",
|
||||
BlockType::TaskList => "To-do list",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded item transformation for notebooks.
|
||||
pub(super) fn notebook_embedded_item_conversion(
|
||||
mut mapping: serde_yaml::Mapping,
|
||||
) -> Option<Arc<dyn EmbeddedItem>> {
|
||||
use serde_yaml::Value;
|
||||
match mapping.remove(&Value::String("id".to_string())) {
|
||||
Some(Value::String(hashed_id)) => Some(Arc::new(EmbeddedWorkflow::new(hashed_id))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn markdown_table_appearance(appearance: &Appearance) -> MarkdownTableAppearance {
|
||||
let theme = appearance.theme();
|
||||
MarkdownTableAppearance {
|
||||
border_color: internal_colors::neutral_4(theme),
|
||||
header_background: ColorU::transparent_black(),
|
||||
cell_background: ColorU::transparent_black(),
|
||||
alternate_row_background: None,
|
||||
text_color: internal_colors::text_sub(theme, theme.background()),
|
||||
header_text_color: internal_colors::text_main(theme, theme.background()),
|
||||
scrollbar_nonactive_thumb_color: theme.nonactive_ui_detail().into_solid(),
|
||||
scrollbar_active_thumb_color: theme.active_ui_detail().into_solid(),
|
||||
cell_padding: 12.,
|
||||
outer_border: false,
|
||||
column_dividers: false,
|
||||
row_dividers: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn markdown_table_style(
|
||||
appearance: &Appearance,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
) -> TableStyle {
|
||||
let table_appearance = markdown_table_appearance(appearance);
|
||||
TableStyle {
|
||||
border_color: table_appearance.border_color,
|
||||
header_background: table_appearance.header_background,
|
||||
cell_background: table_appearance.cell_background,
|
||||
alternate_row_background: table_appearance.alternate_row_background,
|
||||
text_color: table_appearance.text_color,
|
||||
header_text_color: table_appearance.header_text_color,
|
||||
scrollbar_nonactive_thumb_color: table_appearance.scrollbar_nonactive_thumb_color,
|
||||
scrollbar_active_thumb_color: table_appearance.scrollbar_active_thumb_color,
|
||||
font_family,
|
||||
font_size,
|
||||
cell_padding: table_appearance.cell_padding,
|
||||
outer_border: table_appearance.outer_border,
|
||||
column_dividers: table_appearance.column_dividers,
|
||||
row_dividers: table_appearance.row_dividers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build [`RichTextStyles`] based on the current [`Appearance`].
|
||||
pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -> RichTextStyles {
|
||||
let theme = appearance.theme();
|
||||
let inline_font_color: ColorU = theme.terminal_colors().normal.red.into();
|
||||
let font_size = derived_notebook_font_size(font_settings);
|
||||
RichTextStyles {
|
||||
base_text: ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
font_family: appearance.ui_font_family(),
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
code_text: ParagraphStyles {
|
||||
font_family: appearance.monospace_font_family(),
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_background: theme.background().into(),
|
||||
embedding_background: theme.surface_2().into(),
|
||||
embedding_text: ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
font_family: appearance.monospace_font_family(),
|
||||
text_color: theme.main_text_color(theme.surface_2()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_border: Border::all(1.).with_border_fill(theme.surface_3()),
|
||||
placeholder_color: appearance
|
||||
.theme()
|
||||
.hint_text_color(theme.background())
|
||||
.into_solid(),
|
||||
selection_fill: appearance.theme().text_selection_color().into(),
|
||||
cursor_fill: theme
|
||||
.cursor()
|
||||
.on_background(theme.background(), MinimumAllowedContrast::Text)
|
||||
.into(),
|
||||
inline_code_style: InlineCodeStyle {
|
||||
font_family: appearance.monospace_font_family(),
|
||||
background: theme.surface_3().into(),
|
||||
font_color: inline_font_color
|
||||
.on_background(theme.surface_3().into(), MinimumAllowedContrast::Text),
|
||||
},
|
||||
check_box_style: CheckBoxStyle {
|
||||
border_color: theme.foreground().into(),
|
||||
border_width: 2.,
|
||||
icon_path: CHECK_SVG_PATH,
|
||||
background: theme.accent().into(),
|
||||
hover_background: *HOVER_BACKGROUND_COLOR,
|
||||
},
|
||||
horizontal_rule_style: HorizontalRuleStyle {
|
||||
color: theme.surface_3().into(),
|
||||
rule_height: 3.,
|
||||
},
|
||||
broken_link_style: BrokenLinkStyle {
|
||||
icon_path: "bundled/svg/link-broken-02.svg",
|
||||
icon_color: theme.terminal_colors().normal.red.into(),
|
||||
},
|
||||
block_spacings: Default::default(),
|
||||
show_placeholder_text_on_empty_block: true,
|
||||
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
|
||||
cursor_width: 1.,
|
||||
highlight_urls: true,
|
||||
table_style: markdown_table_style(appearance, appearance.ui_font_family(), font_size),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockType> for BufferBlockStyle {
|
||||
fn from(block_type: BlockType) -> Self {
|
||||
match block_type {
|
||||
BlockType::RunnableCommand => Self::CodeBlock {
|
||||
code_block_type: CodeBlockType::Shell,
|
||||
},
|
||||
BlockType::Text => Self::PlainText,
|
||||
BlockType::Header(header_size) => Self::Header { header_size },
|
||||
BlockType::UnorderedList => Self::UnorderedList {
|
||||
indent_level: ListIndentLevel::One,
|
||||
},
|
||||
BlockType::OrderedList => Self::ordered_list(ListIndentLevel::One),
|
||||
BlockType::Code => Self::CodeBlock {
|
||||
code_block_type: CodeBlockType::Code {
|
||||
lang: CODE_BLOCK_DEFAULT_MARKDOWN_LANG.into(),
|
||||
},
|
||||
},
|
||||
BlockType::TaskList => Self::TaskList {
|
||||
indent_level: ListIndentLevel::One,
|
||||
complete: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ContentBlockType> for BlockType {
|
||||
fn from(block_type: &'a ContentBlockType) -> Self {
|
||||
match block_type {
|
||||
// TODO: Add support for block item here.
|
||||
ContentBlockType::Item(_) => BlockType::Text,
|
||||
ContentBlockType::Text(block_style) => Self::from(block_style),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a BufferBlockStyle> for BlockType {
|
||||
fn from(block_style: &'a BufferBlockStyle) -> Self {
|
||||
match block_style {
|
||||
BufferBlockStyle::CodeBlock { code_block_type } => match code_block_type {
|
||||
CodeBlockType::Shell => BlockType::RunnableCommand,
|
||||
CodeBlockType::Mermaid | CodeBlockType::Code { .. } => BlockType::Code,
|
||||
},
|
||||
BufferBlockStyle::PlainText => BlockType::Text,
|
||||
BufferBlockStyle::Header { header_size } => BlockType::Header(*header_size),
|
||||
BufferBlockStyle::UnorderedList { .. } => BlockType::UnorderedList,
|
||||
BufferBlockStyle::OrderedList { .. } => BlockType::OrderedList,
|
||||
BufferBlockStyle::TaskList { .. } => BlockType::TaskList,
|
||||
BufferBlockStyle::Table { .. } => BlockType::Text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper around the shared [`Workflow`] type with additional context for workflows contained
|
||||
/// within a notebook.
|
||||
///
|
||||
/// This may be a command block that's part of the notebook text, or an embedded Warp Drive workflow.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NotebookWorkflow {
|
||||
/// Definition of the workflow itself.
|
||||
pub workflow: UserInput<Arc<WorkflowType>>,
|
||||
/// The source of the workflow, for attribution. If `None`, the workflow should be attributed
|
||||
/// to the parent notebook.
|
||||
pub source: Option<WorkflowSource>,
|
||||
}
|
||||
|
||||
impl NotebookWorkflow {
|
||||
pub fn from_cloud_workflow(cloud_workflow: Box<CloudWorkflow>) -> Self {
|
||||
Self {
|
||||
source: Some(cloud_workflow.permissions.owner.into()),
|
||||
workflow: UserInput::new(Arc::new(WorkflowType::Cloud(cloud_workflow))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the [`WorkflowType`], assigning a name using the given callback if needed.
|
||||
pub fn named_workflow<F: FnOnce() -> Option<String>>(&self, name: F) -> Arc<WorkflowType> {
|
||||
match &**self.workflow {
|
||||
WorkflowType::Notebook(workflow) if workflow.name().is_empty() => match name() {
|
||||
Some(name) => {
|
||||
let mut workflow = workflow.clone();
|
||||
workflow.set_name(name.as_str());
|
||||
Arc::new(WorkflowType::Notebook(workflow))
|
||||
}
|
||||
None => (*self.workflow).clone(),
|
||||
},
|
||||
_ => (*self.workflow).clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
||||
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
|
||||
|
||||
use async_channel::Sender;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_color::ColorU;
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use syntect::{
|
||||
easy::HighlightLines,
|
||||
highlighting::{self, Theme, ThemeSet},
|
||||
parsing::SyntaxSet,
|
||||
util::LinesWithEndings,
|
||||
};
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_editor::{
|
||||
content::{
|
||||
anchor::Anchor,
|
||||
buffer::{Buffer, BufferEvent, EditOrigin},
|
||||
selection_model::BufferSelectionModel,
|
||||
text::{
|
||||
BlockType, BufferBlockStyle, CodeBlockType, CODE_BLOCK_DEFAULT_DISPLAY_LANG,
|
||||
CODE_BLOCK_SHELL_DISPLAY_LANG,
|
||||
},
|
||||
},
|
||||
editor::RunnableCommandModel,
|
||||
};
|
||||
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
use warp_util::user_input::UserInput;
|
||||
use warpui::{elements::Align, r#async::SpawnedFutureHandle, AppContext};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::Properties,
|
||||
presenter::ChildView,
|
||||
Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle,
|
||||
WeakModelHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
completer::SessionAgnosticContext,
|
||||
debounce::debounce,
|
||||
drive::workflows::arguments::ArgumentsState,
|
||||
editor::InteractionState,
|
||||
notebooks::{
|
||||
styles::block_footer_action_button,
|
||||
telemetry::{ActionEntrypoint, BlockInfo},
|
||||
},
|
||||
settings::FontSettings,
|
||||
terminal::input::{
|
||||
decorations::{parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot},
|
||||
DEBOUNCE_INPUT_DECORATION_PERIOD,
|
||||
},
|
||||
themes::theme::{AnsiColorIdentifier, AnsiColors},
|
||||
ui_components::icons::Icon,
|
||||
util::{
|
||||
bindings::CustomAction,
|
||||
color::{ContrastingColor, MinimumAllowedContrast},
|
||||
},
|
||||
view_components::{Dropdown, DropdownItem},
|
||||
workflows::{workflow::Workflow, WorkflowType},
|
||||
Assets,
|
||||
};
|
||||
|
||||
use super::{
|
||||
interaction_state_model::InteractionStateModel,
|
||||
keys::{custom_action_to_display, NotebookKeybindings},
|
||||
model::ChildModelHandle,
|
||||
rich_text_styles,
|
||||
view::EditorViewAction,
|
||||
NotebookWorkflow,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref SUPPORTED_LANGUAGES: &'static [&'static str] = &[
|
||||
"Go",
|
||||
"Java",
|
||||
"C++",
|
||||
"C#",
|
||||
"JavaScript",
|
||||
"Python",
|
||||
"Ruby on Rails",
|
||||
"Rust",
|
||||
"SQL",
|
||||
"YAML",
|
||||
"JSON",
|
||||
"PHP",
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
insert_button_state: MouseStateHandle,
|
||||
copy_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
struct CachedHighlightKey {
|
||||
buffer_content: String,
|
||||
style: CodeBlockType,
|
||||
}
|
||||
|
||||
struct CachedHighlightColors {
|
||||
key: CachedHighlightKey,
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
}
|
||||
|
||||
impl CachedHighlightColors {
|
||||
fn matches_key(&self, buffer_content: &str, style: CodeBlockType) -> bool {
|
||||
self.key.buffer_content == buffer_content && self.key.style == style
|
||||
}
|
||||
}
|
||||
|
||||
struct CodeHighlightResult {
|
||||
origin_text: String,
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
}
|
||||
|
||||
/// Runnable command behavior for notebooks.
|
||||
pub struct NotebookCommand {
|
||||
start: Anchor,
|
||||
end: Anchor,
|
||||
interaction_state: ModelHandle<InteractionStateModel>,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
is_selected: bool,
|
||||
block_type_dropdown: ViewHandle<Dropdown<EditorViewAction>>,
|
||||
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
debounce_highlighting_tx: Sender<()>,
|
||||
syntax_highlighting_handle: Option<SpawnedFutureHandle>,
|
||||
cached_highlight_delta: Option<CachedHighlightColors>,
|
||||
|
||||
syntax_config: Option<(SyntaxSet, Theme)>,
|
||||
|
||||
handle: WeakModelHandle<Self>,
|
||||
}
|
||||
|
||||
impl NotebookCommand {
|
||||
/// Create a new `NotebookCommand` model to back the runnable command between `start` and `end`.
|
||||
pub fn new(
|
||||
start: CharOffset,
|
||||
end: CharOffset,
|
||||
interaction_state: ModelHandle<InteractionStateModel>,
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
rte_window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let current_block_style =
|
||||
NotebookCommand::block_type_to_code_type(content.as_ref(ctx).block_type_at_point(end));
|
||||
let (start, end) = selection_model.update(ctx, |selection_model, ctx| {
|
||||
(
|
||||
selection_model.anchor(start, ctx),
|
||||
selection_model.anchor(end, ctx),
|
||||
)
|
||||
});
|
||||
|
||||
let block_type_dropdown = ctx.add_typed_action_view(rte_window_id, |ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
|
||||
dropdown.set_top_bar_max_width(68.);
|
||||
dropdown.set_menu_width(68., ctx);
|
||||
|
||||
dropdown.add_items(
|
||||
CodeBlockType::all()
|
||||
.map(|code_block_type| {
|
||||
DropdownItem::new(
|
||||
code_block_type.to_string().as_str(),
|
||||
EditorViewAction::CodeBlockTypeSelectedAtOffset {
|
||||
code_block_type,
|
||||
start_anchor: start.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
let current_dropdown_selection = match ¤t_block_style {
|
||||
CodeBlockType::Shell => CODE_BLOCK_SHELL_DISPLAY_LANG,
|
||||
CodeBlockType::Mermaid => "Mermaid",
|
||||
CodeBlockType::Code { lang } if lang == "text" => CODE_BLOCK_DEFAULT_DISPLAY_LANG,
|
||||
CodeBlockType::Code { lang } => lang,
|
||||
};
|
||||
dropdown.set_selected_by_name(current_dropdown_selection, ctx);
|
||||
|
||||
dropdown
|
||||
});
|
||||
|
||||
let syntax_config = {
|
||||
let ps = SyntaxSet::load_defaults_newlines();
|
||||
if let Some(asset) = Assets::get("bundled/syntax_theme/base16.tmTheme") {
|
||||
let binary = asset.data;
|
||||
let mut cursor = std::io::Cursor::new(binary);
|
||||
match ThemeSet::load_from_reader(&mut cursor) {
|
||||
Ok(theme) => Some((ps, theme)),
|
||||
Err(e) => {
|
||||
log::debug!("Failed to load theme set from asset: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
ctx.subscribe_to_model(&content, Self::on_buffer_content_updated);
|
||||
|
||||
let (debounce_highlighting_tx, debounce_highlighting_rx) = async_channel::unbounded();
|
||||
let _ = ctx.spawn_stream_local(
|
||||
debounce(DEBOUNCE_INPUT_DECORATION_PERIOD, debounce_highlighting_rx),
|
||||
|me, _, ctx| me.highlight_syntax(ctx),
|
||||
|_me, _ctx| {},
|
||||
);
|
||||
|
||||
let mut command = Self {
|
||||
start,
|
||||
end,
|
||||
interaction_state,
|
||||
content,
|
||||
selection_model,
|
||||
mouse_state_handles: Default::default(),
|
||||
is_selected: false,
|
||||
block_type_dropdown,
|
||||
syntax_highlighting_handle: None,
|
||||
cached_highlight_delta: None,
|
||||
debounce_highlighting_tx,
|
||||
syntax_config,
|
||||
handle: ctx.handle(),
|
||||
};
|
||||
|
||||
command.highlight_syntax(ctx);
|
||||
command
|
||||
}
|
||||
|
||||
fn block_type_to_code_type(block_type: BlockType) -> CodeBlockType {
|
||||
match block_type {
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Shell,
|
||||
}) => CodeBlockType::Shell,
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Mermaid,
|
||||
}) => CodeBlockType::Mermaid,
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Code { lang },
|
||||
}) if SUPPORTED_LANGUAGES.contains(&lang.as_str()) => CodeBlockType::Code { lang },
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock { .. }) => CodeBlockType::Code {
|
||||
lang: CODE_BLOCK_DEFAULT_MARKDOWN_LANG.to_string(),
|
||||
},
|
||||
_ => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn start_anchor(&self) -> Anchor {
|
||||
self.start.clone()
|
||||
}
|
||||
|
||||
// Returns the CodeBlockType of this command
|
||||
fn code_block_type(&self, ctx: &AppContext) -> CodeBlockType {
|
||||
if let Some(offset) = self.end_offset(ctx) {
|
||||
NotebookCommand::block_type_to_code_type(
|
||||
self.content.as_ref(ctx).block_type_at_point(offset),
|
||||
)
|
||||
} else {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn syntax_highlighting_handle(&self) -> Option<SpawnedFutureHandle> {
|
||||
self.syntax_highlighting_handle.clone()
|
||||
}
|
||||
|
||||
pub fn highlight_syntax(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(handle) = self.syntax_highlighting_handle.take() {
|
||||
handle.abort_handle().abort();
|
||||
}
|
||||
|
||||
let success = self.try_apply_cached_highlighting(ctx);
|
||||
if success {
|
||||
return;
|
||||
}
|
||||
|
||||
let code_block_type = self.code_block_type(ctx);
|
||||
let Some(buffer_text) = self.command(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match code_block_type {
|
||||
CodeBlockType::Shell => {
|
||||
let completion_context =
|
||||
SessionAgnosticContext::new(CommandRegistry::global_instance());
|
||||
self.syntax_highlighting_handle = Some(ctx.spawn(
|
||||
async move {
|
||||
parse_current_commands_and_tokens(buffer_text, &completion_context).await
|
||||
},
|
||||
|notebook_command, parsed_tokens, ctx| {
|
||||
notebook_command.update_buffer_with_parsed_tokens(parsed_tokens, ctx);
|
||||
},
|
||||
));
|
||||
}
|
||||
CodeBlockType::Mermaid => (),
|
||||
// Skip highlighting for default code.
|
||||
CodeBlockType::Code { lang } if lang == "text" => (),
|
||||
CodeBlockType::Code { lang } => {
|
||||
let Some((syntax_set, syntax_theme)) = self.syntax_config.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.syntax_highlighting_handle = Some(ctx.spawn(
|
||||
parse_code_into_style_ranges(buffer_text, lang, syntax_set, syntax_theme),
|
||||
|notebook_command, result, ctx| {
|
||||
notebook_command.update_buffer_with_parsed_code_syntax(result, ctx);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_buffer_with_parsed_code_syntax(
|
||||
&mut self,
|
||||
highlight_result: Option<CodeHighlightResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(highlight_result) = highlight_result else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.maybe_apply_highlighting(
|
||||
CachedHighlightKey {
|
||||
buffer_content: highlight_result.origin_text,
|
||||
style: self.code_block_type(ctx),
|
||||
},
|
||||
highlight_result.colors,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
fn update_buffer_with_parsed_tokens(
|
||||
&mut self,
|
||||
parsed_tokens: ParsedTokensSnapshot,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let colors = parsed_token_to_color_style_ranges(parsed_tokens.parsed_tokens);
|
||||
|
||||
self.maybe_apply_highlighting(
|
||||
CachedHighlightKey {
|
||||
buffer_content: parsed_tokens.buffer_text,
|
||||
style: CodeBlockType::Shell,
|
||||
},
|
||||
colors,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn try_apply_cached_highlighting(&self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
let code_block_type = self.code_block_type(ctx);
|
||||
let Some(buffer_text) = self.command(ctx) else {
|
||||
return false;
|
||||
};
|
||||
match &self.cached_highlight_delta {
|
||||
// If the command block content matches our cache, simply update with the cache.
|
||||
Some(cache) if cache.matches_key(&buffer_text, code_block_type) => {
|
||||
if let Some(block_start) = self.start_offset(ctx) {
|
||||
self.apply_highlighting_to_buffer(&cache.colors, block_start, ctx)
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write syntax highlighting colors into the buffer and cache them with the given key. If the
|
||||
/// key does not match the buffer state, or the backing content range has been unstyled, the
|
||||
/// highlighting is discarded.
|
||||
fn maybe_apply_highlighting(
|
||||
&mut self,
|
||||
key: CachedHighlightKey,
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(buffer_text) = self.command(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If the command text has changed from when we parsed it, discard the parsing result.
|
||||
if buffer_text != key.buffer_content {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(block_start) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If the text range is no longer a code block, do not try to highlight it.
|
||||
if !matches!(
|
||||
self.content
|
||||
.as_ref(ctx)
|
||||
.block_type_at_point(block_start + 1),
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock { .. })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.apply_highlighting_to_buffer(&colors, block_start, ctx);
|
||||
self.cached_highlight_delta = Some(CachedHighlightColors { key, colors });
|
||||
}
|
||||
|
||||
fn apply_highlighting_to_buffer(
|
||||
&self,
|
||||
colors: &[(Range<ByteOffset>, AnsiColorIdentifier)],
|
||||
block_start: CharOffset,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let font_settings = FontSettings::as_ref(ctx);
|
||||
let terminal_colors_normal = appearance.theme().terminal_colors().normal.to_owned();
|
||||
let background_color = rich_text_styles(appearance, font_settings)
|
||||
.code_background
|
||||
.start_color();
|
||||
|
||||
let transformed_colors =
|
||||
transform_ansi_color_to_solid_color(colors, &terminal_colors_normal, background_color);
|
||||
|
||||
self.content.update(ctx, |buffer, ctx| {
|
||||
buffer.color_code_block_ranges(
|
||||
block_start + 1,
|
||||
&transformed_colors,
|
||||
self.selection_model.clone(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_buffer_content_updated(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
|
||||
// If the buffer changes, check to see if we should update the dropdown
|
||||
match event {
|
||||
BufferEvent::ContentChanged { origin, delta, .. }
|
||||
if *origin != EditOrigin::SystemEdit =>
|
||||
{
|
||||
let code_block_type = self.code_block_type(ctx);
|
||||
self.block_type_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_name(code_block_type.to_string(), ctx)
|
||||
});
|
||||
let replacement_offset = &delta.old_offset;
|
||||
|
||||
let Some(start_offset) = self.start_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !matches!(
|
||||
self.content
|
||||
.as_ref(ctx)
|
||||
.block_type_at_point(start_offset + 1),
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock { .. })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(end_offset) = self.end_offset(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If the replacement range overlaps with command block range, regenerate the highlight.
|
||||
if start_offset <= replacement_offset.end && end_offset >= replacement_offset.start
|
||||
{
|
||||
// In tests, run syntax highlighting immediately.
|
||||
// TODO(ben): This is another case where mock timers in tests would be
|
||||
// helpful.
|
||||
#[cfg(test)]
|
||||
self.highlight_syntax(ctx);
|
||||
#[cfg(not(test))]
|
||||
let _ = self.debounce_highlighting_tx.try_send(());
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// The offset of this command's start marker.
|
||||
pub fn start_offset(&self, ctx: &impl ModelAsRef) -> Option<CharOffset> {
|
||||
self.selection_model.as_ref(ctx).resolve_anchor(&self.start)
|
||||
}
|
||||
|
||||
/// The offset of this command's end marker.
|
||||
pub fn end_offset(&self, ctx: &impl ModelAsRef) -> Option<CharOffset> {
|
||||
self.selection_model.as_ref(ctx).resolve_anchor(&self.end)
|
||||
}
|
||||
|
||||
/// The current text of this command.
|
||||
pub fn command(&self, ctx: &impl ModelAsRef) -> Option<String> {
|
||||
let start = self.start_offset(ctx)?;
|
||||
let end = self.end_offset(ctx)?;
|
||||
// Add 1 to start because it refers to the start marker offset.
|
||||
Some(
|
||||
self.content
|
||||
.as_ref(ctx)
|
||||
.text_in_range(start + 1..end)
|
||||
.into_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_dropdown_focused(&self, ctx: &AppContext) -> bool {
|
||||
self.block_type_dropdown.as_ref(ctx).is_focused(ctx)
|
||||
}
|
||||
|
||||
/// Whether or not this block contains the text cursor
|
||||
pub fn contains_cursor(&self, ctx: &impl ModelAsRef) -> bool {
|
||||
let cursor = self.selection_model.as_ref(ctx).first_selection_head();
|
||||
// Subtract one to get to the start marker of the block
|
||||
let block_start = self.content.as_ref(ctx).block_or_line_start(cursor) - 1;
|
||||
if let Some(start_offset) = self.start_offset(ctx) {
|
||||
start_offset == block_start
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether or not we should display the dropdown selector for this block. Essentially, we want to display
|
||||
/// it if the editor if the user has the command selected, or they are typing in it.
|
||||
fn should_display_block_type_dropdown(
|
||||
&self,
|
||||
editor_is_focused: bool,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
// If we are in view mode or the editor is not focused, return false
|
||||
if !matches!(
|
||||
self.interaction_state.as_ref(ctx).interaction_state(),
|
||||
InteractionState::Editable
|
||||
) || !editor_is_focused
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this block is selected, return true
|
||||
if self.is_selected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If this block contains the cursor, and another block is not selected, return true
|
||||
if self.contains_cursor(ctx) && !self.interaction_state.as_ref(ctx).is_block_selected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this block is selected.
|
||||
pub fn is_selected(&self) -> bool {
|
||||
self.is_selected
|
||||
}
|
||||
|
||||
/// Set whether or not this block is selected.
|
||||
pub fn set_selected(&mut self, selected: bool) -> bool {
|
||||
mem::replace(&mut self.is_selected, selected)
|
||||
}
|
||||
|
||||
/// Promotes this notebook command into a [`Workflow`]. If the workflow is anonymous, the
|
||||
/// containing `NotebookView` fills in its title.
|
||||
pub fn to_workflow(&self, ctx: &AppContext) -> Option<NotebookWorkflow> {
|
||||
let command = self.command(ctx)?;
|
||||
let args_state = ArgumentsState::for_command_workflow(&Default::default(), command.clone());
|
||||
// TODO: Once notebook workflows have their own metadata, we can populate the title here.
|
||||
let workflow = Workflow::new(String::new(), command).with_arguments(args_state.arguments);
|
||||
Some(NotebookWorkflow {
|
||||
workflow: UserInput::new(Arc::new(WorkflowType::Notebook(workflow))),
|
||||
source: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NotebookCommand {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl RunnableCommandModel for NotebookCommand {
|
||||
fn render_block_footer(&self, editor_is_focused: bool, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut model = self.handle.clone();
|
||||
|
||||
// Get the CodeBlockType at the end offset for the NotebookCommand
|
||||
// We would expect the BlockStyle at the offset to be a CodeBlock
|
||||
let block_style = self.code_block_type(ctx);
|
||||
|
||||
let mut footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
|
||||
if self.should_display_block_type_dropdown(editor_is_focused, ctx) {
|
||||
footer.add_child(ChildView::new(&self.block_type_dropdown).finish());
|
||||
} else {
|
||||
footer.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
self.code_block_type(ctx).to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_style(Properties {
|
||||
weight: warpui::fonts::Weight::Light,
|
||||
..Default::default()
|
||||
})
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(11.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
footer.add_child(Shrinkable::new(1.0, Empty::new().finish()).finish());
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Copy,
|
||||
self.mouse_state_handles.copy_button_state.clone(),
|
||||
"Copy",
|
||||
custom_action_to_display(CustomAction::Copy),
|
||||
)
|
||||
.on_click(move |ctx, app, _| {
|
||||
if let Some(command_model) = model.upgrade(app) {
|
||||
if let Some(block_content) = command_model.as_ref(app).command(app) {
|
||||
ctx.dispatch_typed_action(EditorViewAction::CopyTextToClipboard {
|
||||
text: UserInput::new(block_content.trim()),
|
||||
block: BlockInfo::CodeBlock,
|
||||
entrypoint: ActionEntrypoint::Button,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
if matches!(block_style, CodeBlockType::Shell) {
|
||||
model = self.handle.clone();
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::TerminalInput,
|
||||
self.mouse_state_handles.insert_button_state.clone(),
|
||||
"Run in terminal",
|
||||
NotebookKeybindings::as_ref(ctx).run_commands_keybinding(),
|
||||
)
|
||||
.on_click(move |ctx, app, _| {
|
||||
if let Some(command_model) = model.upgrade(app) {
|
||||
if let Some(workflow) = command_model.as_ref(app).to_workflow(app) {
|
||||
ctx.dispatch_typed_action(EditorViewAction::RunWorkflow(workflow));
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
footer.finish()
|
||||
}
|
||||
|
||||
fn border(&self, app: &AppContext) -> Option<Border> {
|
||||
if self.is_selected {
|
||||
let border_fill = Appearance::as_ref(app).theme().accent();
|
||||
Some(Border::all(3.).with_border_fill(border_fill))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildModelHandle for ModelHandle<NotebookCommand> {
|
||||
fn start_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
self.as_ref(app).start_offset(app)
|
||||
}
|
||||
|
||||
fn end_offset(&self, app: &AppContext) -> Option<CharOffset> {
|
||||
self.as_ref(app).end_offset(app)
|
||||
}
|
||||
|
||||
fn selectable(&self, _: &AppContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn executable_workflow(&self, app: &AppContext) -> Option<NotebookWorkflow> {
|
||||
self.as_ref(app).to_workflow(app)
|
||||
}
|
||||
|
||||
fn executable_command<'a>(&'a self, app: &'a AppContext) -> Option<Cow<'a, str>> {
|
||||
self.as_ref(app).command(app).map(Into::into)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn selected(&self, app: &AppContext) -> bool {
|
||||
self.as_ref(app).is_selected
|
||||
}
|
||||
|
||||
fn set_selected(&self, selected: bool, ctx: &mut AppContext) -> bool {
|
||||
self.update(ctx, |model, _ctx| model.set_selected(selected))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn ChildModelHandle> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// Parse code into style ranges based on the current ANSI color and language.
|
||||
async fn parse_code_into_style_ranges(
|
||||
buffer_text: String,
|
||||
language: String,
|
||||
syntax_set: SyntaxSet,
|
||||
theme: Theme,
|
||||
) -> Option<CodeHighlightResult> {
|
||||
// Find the syntax corresponding to the input language.
|
||||
let syntax = syntax_set.find_syntax_by_name(&language)?;
|
||||
|
||||
let mut h = HighlightLines::new(syntax, &theme);
|
||||
let mut runs = Vec::new();
|
||||
|
||||
let mut byte_offset = 0;
|
||||
for line in LinesWithEndings::from(&buffer_text) {
|
||||
let ranges = h.highlight_line(line, &syntax_set).ok()?;
|
||||
|
||||
for (text_style, content) in ranges {
|
||||
let text_color = text_style.foreground;
|
||||
let text_len = content.len();
|
||||
|
||||
if let Some(ansi_color) = to_ansi_color(text_color) {
|
||||
runs.push((
|
||||
ByteOffset::from(byte_offset)..ByteOffset::from(byte_offset + text_len),
|
||||
ansi_color,
|
||||
));
|
||||
}
|
||||
|
||||
byte_offset += text_len;
|
||||
}
|
||||
}
|
||||
|
||||
Some(CodeHighlightResult {
|
||||
origin_text: buffer_text,
|
||||
colors: runs,
|
||||
})
|
||||
}
|
||||
|
||||
// We use base16 theme here so the colors could translate fully to terminal ANSI color.
|
||||
pub fn to_ansi_color(color: highlighting::Color) -> Option<AnsiColorIdentifier> {
|
||||
match color.r {
|
||||
0x00 => Some(AnsiColorIdentifier::Black),
|
||||
0x01 => Some(AnsiColorIdentifier::Red),
|
||||
0x02 => Some(AnsiColorIdentifier::Green),
|
||||
0x03 => Some(AnsiColorIdentifier::Yellow),
|
||||
0x04 => Some(AnsiColorIdentifier::Blue),
|
||||
0x05 => Some(AnsiColorIdentifier::Magenta),
|
||||
0x06 => Some(AnsiColorIdentifier::Cyan),
|
||||
0x07 => Some(AnsiColorIdentifier::White),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parsed_token_to_color_style_ranges(
|
||||
parsed_tokens: Vec<ParsedTokenData>,
|
||||
) -> Vec<(Range<ByteOffset>, AnsiColorIdentifier)> {
|
||||
let mut colors = Vec::new();
|
||||
|
||||
for token_data in parsed_tokens {
|
||||
let token_description = token_data.token_description.clone();
|
||||
if let Some(description) = token_description {
|
||||
let token_syntax_color: AnsiColorIdentifier =
|
||||
description.suggestion_type.to_name().into();
|
||||
|
||||
let style_byte_offset_start = ByteOffset::from(token_data.token.span.start());
|
||||
let style_byte_offset_end = ByteOffset::from(token_data.token.span.end());
|
||||
|
||||
colors.push((
|
||||
style_byte_offset_start..style_byte_offset_end,
|
||||
token_syntax_color,
|
||||
))
|
||||
}
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
pub fn transform_ansi_color_to_solid_color(
|
||||
colors: &[(Range<ByteOffset>, AnsiColorIdentifier)],
|
||||
terminal_colors_normal: &AnsiColors,
|
||||
background_color: ColorU,
|
||||
) -> Vec<(Range<ByteOffset>, ColorU)> {
|
||||
colors
|
||||
.iter()
|
||||
.map(|(range, identifier)| {
|
||||
let foreground_color: ColorU =
|
||||
(*identifier).to_ansi_color(terminal_colors_normal).into();
|
||||
(
|
||||
range.clone(),
|
||||
foreground_color.on_background(background_color, MinimumAllowedContrast::Text),
|
||||
)
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Implementation for the omnibar - a floating menu for editor interactions
|
||||
//! like formatting and changing block types.
|
||||
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
use warp_editor::{
|
||||
content::text::{
|
||||
BlockType as ContentBlockType, BufferBlockStyle, BufferTextStyle, TextStyles,
|
||||
TextStylesWithMetadata,
|
||||
},
|
||||
model::RichTextEditorModel,
|
||||
render::model::RenderState,
|
||||
};
|
||||
use warpui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole},
|
||||
elements::{
|
||||
AnchorPair, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
|
||||
MainAxisSize, MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, Point,
|
||||
PositionedElementOffsetBounds, PositioningAxis, Radius, Rect, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, SizeConstraint, TypedActionView,
|
||||
View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
menu::MenuVariant,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
view_components::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem},
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::{NotebooksEditorModel, RichTextEditorModelEvent},
|
||||
view::EditorViewAction,
|
||||
BlockType,
|
||||
};
|
||||
|
||||
const OMNIBAR_HEIGHT: f32 = 32.;
|
||||
const OMNIBAR_PADDING: f32 = 4.;
|
||||
|
||||
const ACTION_BUTTON_SIZE: f32 = 24.;
|
||||
|
||||
pub enum OmnibarEvent {
|
||||
OpenLinkEditor,
|
||||
}
|
||||
|
||||
/// View to render the omnibar.
|
||||
pub struct Omnibar {
|
||||
model: ModelHandle<NotebooksEditorModel>,
|
||||
|
||||
block_conversion_dropdown: ViewHandle<CompactDropdown<OmnibarAction>>,
|
||||
|
||||
bold_button_state: MouseStateHandle,
|
||||
italicize_button_state: MouseStateHandle,
|
||||
underline_button_state: MouseStateHandle,
|
||||
strikethrough_button_state: MouseStateHandle,
|
||||
link_button_state: MouseStateHandle,
|
||||
inline_code_button_state: MouseStateHandle,
|
||||
|
||||
active_text_styles: Option<TextStylesWithMetadata>,
|
||||
active_block_type: Option<ContentBlockType>,
|
||||
}
|
||||
|
||||
impl Omnibar {
|
||||
pub fn new(model: ModelHandle<NotebooksEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let block_conversion_dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = CompactDropdown::new(MenuVariant::Fixed, ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
dropdown.set_items(
|
||||
BlockType::all()
|
||||
.map(|block_type| conversion_item(block_type, appearance))
|
||||
.collect_vec(),
|
||||
ctx,
|
||||
);
|
||||
dropdown.set_icon_size(ACTION_BUTTON_SIZE - 2. * OMNIBAR_PADDING);
|
||||
dropdown
|
||||
});
|
||||
ctx.subscribe_to_view(&block_conversion_dropdown, Self::handle_dropdown_event);
|
||||
|
||||
ctx.subscribe_to_model(&model, Self::handle_model_event);
|
||||
|
||||
Self {
|
||||
model,
|
||||
block_conversion_dropdown,
|
||||
link_button_state: Default::default(),
|
||||
bold_button_state: Default::default(),
|
||||
strikethrough_button_state: Default::default(),
|
||||
italicize_button_state: Default::default(),
|
||||
underline_button_state: Default::default(),
|
||||
inline_code_button_state: Default::default(),
|
||||
active_text_styles: None,
|
||||
active_block_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The relative positioning of the omnibar.
|
||||
///
|
||||
/// The omnibar is positioned above the current text selection, clamped to the viewport. If
|
||||
/// no portion of the text selection is visible, the omnibar is not shown.
|
||||
pub fn positioning(render_state: &RenderState) -> OffsetPositioning {
|
||||
let selection_position = render_state.saved_positions().text_selection_id();
|
||||
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Middle, XAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&selection_position,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(-4.),
|
||||
// TODO(ben): Decide if this should be above or below the cursor based
|
||||
// on its location within the viewport.
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_style(&mut self, style: TextStyles, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.toggle_style(style, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn unset_link(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.unset_link(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn convert_block(&mut self, style: BufferBlockStyle, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.convert_block(style, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_action_button(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
action: OmnibarAction,
|
||||
active: bool,
|
||||
mouse_state: &MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let active_background = appearance.theme().surface_3().into();
|
||||
let button = icon_button(appearance, icon, active, mouse_state.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(ACTION_BUTTON_SIZE),
|
||||
height: Some(ACTION_BUTTON_SIZE),
|
||||
// Explicitly override the default icon button padding of 4px.
|
||||
// With a button size of 24px, 1px of border, and 1px of padding, each icon should
|
||||
// be 20px.
|
||||
padding: Some(Coords::uniform(1.)),
|
||||
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_active_styles(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.active_ui_text_color()
|
||||
// .with_opacity(100)
|
||||
.into_solid(),
|
||||
),
|
||||
background: Some(active_background),
|
||||
border_color: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let renderable_button = button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
|
||||
.finish();
|
||||
|
||||
Container::new(renderable_button)
|
||||
.with_margin_left(OMNIBAR_PADDING / 2.)
|
||||
.with_margin_right(OMNIBAR_PADDING / 2.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_separator(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_background(appearance.theme().disabled_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(1.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(OMNIBAR_PADDING)
|
||||
.with_margin_right(OMNIBAR_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Updates the omnibar state in response to rich text model changes.
|
||||
fn handle_model_event(
|
||||
&mut self,
|
||||
_handle: ModelHandle<NotebooksEditorModel>,
|
||||
event: &RichTextEditorModelEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let RichTextEditorModelEvent::ActiveStylesChanged {
|
||||
selection_text_styles,
|
||||
block_type,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// The omnibar only applies to selections, so we only care about
|
||||
// the selected text styles.
|
||||
self.active_text_styles = Some(selection_text_styles.clone());
|
||||
self.active_block_type = Some(block_type.clone());
|
||||
self.reset_conversion_menu(BlockType::from(block_type), ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the conversion dropdown to the selected block type.
|
||||
fn reset_conversion_menu(&self, block_type: BlockType, ctx: &mut ViewContext<Self>) {
|
||||
let block_name = block_type.label();
|
||||
self.block_conversion_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_name(block_name, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_dropdown_event(
|
||||
&mut self,
|
||||
_handle: ViewHandle<CompactDropdown<OmnibarAction>>,
|
||||
event: &CompactDropdownEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
CompactDropdownEvent::Close => {
|
||||
// In case the menu was closed without converting to a new type of block, reset it to
|
||||
// the original block type. If the block _was_ converted, this will be overridden
|
||||
// by the incoming model event.
|
||||
if let Some(block_type) = &self.active_block_type {
|
||||
self.reset_conversion_menu(BlockType::from(block_type), ctx);
|
||||
}
|
||||
// When the dropdown menu closes, restore focus to the parent editor view. Otherwise,
|
||||
// opening it (even if it's then dismissed) prevents typing.
|
||||
ctx.dispatch_typed_action(&EditorViewAction::Focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Omnibar {
|
||||
type Event = OmnibarEvent;
|
||||
}
|
||||
|
||||
impl View for Omnibar {
|
||||
fn ui_name() -> &'static str {
|
||||
"Omnibar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut actions = Flex::row().with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
let text_format_enabled = match self.active_block_type.as_ref() {
|
||||
Some(ContentBlockType::Item(_)) => false,
|
||||
Some(ContentBlockType::Text(block)) => block.allows_formatting(),
|
||||
None => true,
|
||||
};
|
||||
|
||||
actions.add_child(
|
||||
Container::new(ChildView::new(&self.block_conversion_dropdown).finish())
|
||||
.with_margin_left(OMNIBAR_PADDING)
|
||||
.with_margin_right(OMNIBAR_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if text_format_enabled {
|
||||
actions.add_child(self.render_separator(appearance));
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Bold,
|
||||
OmnibarAction::BoldSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_normal_weight()),
|
||||
&self.bold_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Italic,
|
||||
OmnibarAction::ItalicizeSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_italic()),
|
||||
&self.italicize_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Underline,
|
||||
OmnibarAction::UnderlineSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_underlined()),
|
||||
&self.underline_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::Strikethrough,
|
||||
OmnibarAction::StrikeThroughSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_strikethrough()),
|
||||
&self.strikethrough_button_state,
|
||||
),
|
||||
);
|
||||
|
||||
let link_active = self
|
||||
.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_link());
|
||||
actions.add_child(self.render_action_button(
|
||||
appearance,
|
||||
Icon::Link,
|
||||
if link_active {
|
||||
OmnibarAction::UnstyleLink
|
||||
} else {
|
||||
OmnibarAction::OpenLinkEditor
|
||||
},
|
||||
link_active,
|
||||
&self.link_button_state,
|
||||
));
|
||||
|
||||
actions.add_child(
|
||||
self.render_action_button(
|
||||
appearance,
|
||||
Icon::InlineCode,
|
||||
OmnibarAction::InlineCodeSelection,
|
||||
self.active_text_styles
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_inline_code()),
|
||||
&self.inline_code_button_state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let bar = Container::new(
|
||||
ConstrainedBox::new(actions.finish())
|
||||
.with_height(OMNIBAR_HEIGHT - 2. * OMNIBAR_PADDING)
|
||||
.with_min_width(0.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(OMNIBAR_PADDING)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish();
|
||||
|
||||
Compact::new(bar).finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OmnibarAction {
|
||||
/// Toggle bold styling on the selected text.
|
||||
BoldSelection,
|
||||
/// Toggle italic styling on the selected text.
|
||||
ItalicizeSelection,
|
||||
UnderlineSelection,
|
||||
StrikeThroughSelection,
|
||||
InlineCodeSelection,
|
||||
OpenLinkEditor,
|
||||
UnstyleLink,
|
||||
/// Convert the selected text to a particular kind of block.
|
||||
ConvertBlock(BufferBlockStyle),
|
||||
}
|
||||
|
||||
impl TypedActionView for Omnibar {
|
||||
type Action = OmnibarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
OmnibarAction::BoldSelection => self.toggle_style(TextStyles::default().bold(), ctx),
|
||||
OmnibarAction::ItalicizeSelection => {
|
||||
self.toggle_style(TextStyles::default().italic(), ctx)
|
||||
}
|
||||
OmnibarAction::UnderlineSelection => {
|
||||
self.toggle_style(TextStyles::default().underline(), ctx)
|
||||
}
|
||||
OmnibarAction::StrikeThroughSelection => {
|
||||
self.toggle_style(TextStyles::default().strikethrough(), ctx)
|
||||
}
|
||||
OmnibarAction::InlineCodeSelection => {
|
||||
self.toggle_style(TextStyles::default().inline_code(), ctx)
|
||||
}
|
||||
OmnibarAction::OpenLinkEditor => ctx.emit(OmnibarEvent::OpenLinkEditor),
|
||||
OmnibarAction::UnstyleLink => self.unset_link(ctx),
|
||||
OmnibarAction::ConvertBlock(style) => {
|
||||
self.convert_block(style.clone(), ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_accessibility_contents(
|
||||
&mut self,
|
||||
action: &Self::Action,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ActionAccessibilityContent {
|
||||
match action {
|
||||
OmnibarAction::BoldSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::bold()),
|
||||
OmnibarAction::ItalicizeSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::Italic),
|
||||
OmnibarAction::UnderlineSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::Underline),
|
||||
OmnibarAction::StrikeThroughSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::StrikeThrough),
|
||||
OmnibarAction::InlineCodeSelection => self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.style_toggle_a11y(BufferTextStyle::InlineCode),
|
||||
OmnibarAction::ConvertBlock(style) => {
|
||||
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
|
||||
format!("Convert to {}", BlockType::from(style).label()),
|
||||
WarpA11yRole::UserAction,
|
||||
))
|
||||
}
|
||||
OmnibarAction::OpenLinkEditor => ActionAccessibilityContent::from_debug(),
|
||||
OmnibarAction::UnstyleLink => ActionAccessibilityContent::Custom(
|
||||
AccessibilityContent::new_without_help("Remove link", WarpA11yRole::UserAction),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a dropdown item for converting to the given block type.
|
||||
fn conversion_item(
|
||||
block_type: BlockType,
|
||||
appearance: &Appearance,
|
||||
) -> CompactDropdownItem<OmnibarAction> {
|
||||
let action = OmnibarAction::ConvertBlock(block_type.into());
|
||||
let mut item = CompactDropdownItem::new(block_type.icon(), block_type.label(), action);
|
||||
if let Some(icon_fill) = block_type.icon_color(appearance) {
|
||||
item = item.with_icon_color(icon_fill);
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// Small UI element that disregards the parent's minimum size constraint. This
|
||||
/// lets its child shrink to its content size. It's useful for offset-positioned
|
||||
/// [`Flex`] elements, which often have a minimum size constraint of their parent's
|
||||
/// size, and would otherwise expand to fill it.
|
||||
struct Compact {
|
||||
child: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl Compact {
|
||||
fn new(child: Box<dyn Element>) -> Self {
|
||||
Self { child }
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Compact {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: warpui::SizeConstraint,
|
||||
ctx: &mut warpui::LayoutContext,
|
||||
app: &warpui::AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(
|
||||
SizeConstraint {
|
||||
min: Vector2F::zero(),
|
||||
max: constraint.max,
|
||||
},
|
||||
ctx,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
origin: Vector2F,
|
||||
ctx: &mut warpui::PaintContext,
|
||||
app: &warpui::AppContext,
|
||||
) {
|
||||
self.child.paint(origin, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &warpui::event::DispatchedEvent,
|
||||
ctx: &mut warpui::EventContext,
|
||||
app: &warpui::AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn z_index(&self) -> Option<warpui::elements::ZIndex> {
|
||||
self.child.z_index()
|
||||
}
|
||||
|
||||
fn bounds(&self) -> Option<RectF> {
|
||||
self.child.bounds()
|
||||
}
|
||||
|
||||
fn parent_data(&self) -> Option<&dyn std::any::Any> {
|
||||
self.child.parent_data()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,662 @@
|
||||
use crate::features::FeatureFlag;
|
||||
use async_channel::TryRecvError;
|
||||
use std::sync::Arc;
|
||||
use string_offset::CharOffset;
|
||||
use warp_editor::render::{
|
||||
element::RichTextAction,
|
||||
model::{HitTestBlockType, Location, RenderEvent},
|
||||
};
|
||||
use warp_util::user_input::UserInput;
|
||||
|
||||
use warpui::event::ModifiersState;
|
||||
use warpui::r#async::block_on;
|
||||
use warpui::windowing::WindowManager;
|
||||
use warpui::{platform::WindowStyle, presenter::ChildView, App, Element, Entity, View, ViewHandle};
|
||||
use warpui::{SingletonEntity, TypedActionView, WindowId};
|
||||
|
||||
use super::{EditorViewAction, RichTextEditorConfig, RichTextEditorView};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::link_editor::LinkEditorAction;
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
use crate::settings::FontSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::UserWorkspaces;
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel, search::files::model::FileSearchModel,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
/// Container for a [`RichTextEditorView`] in unit tests.
|
||||
struct TestView {
|
||||
editor: ViewHandle<RichTextEditorView>,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
ChildView::new(&self.editor).finish()
|
||||
}
|
||||
}
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
fn initialize_editor(
|
||||
app: &mut App,
|
||||
) -> (
|
||||
WindowId,
|
||||
ViewHandle<RichTextEditorView>,
|
||||
ViewHandle<TestView>,
|
||||
) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resources = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default());
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(repo_metadata::RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
#[cfg(feature = "voice_input")]
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
let team_client_mock = Arc::new(MockTeamClient::new());
|
||||
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client_mock.clone(),
|
||||
workspace_client_mock.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let (window, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let window_id = ctx.window_id();
|
||||
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
|
||||
let editor_model = ctx.add_model(|ctx| {
|
||||
let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
|
||||
NotebooksEditorModel::new(styles, window_id, ctx)
|
||||
});
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
RichTextEditorView::new(
|
||||
String::new(),
|
||||
editor_model,
|
||||
links,
|
||||
RichTextEditorConfig::default(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
TestView { editor }
|
||||
});
|
||||
|
||||
let editor_view = app.read(|ctx| test_view.as_ref(ctx).editor.clone());
|
||||
(window, editor_view, test_view)
|
||||
}
|
||||
|
||||
async fn reset_editor_with_markdown(
|
||||
app: &mut App,
|
||||
editor_view: &ViewHandle<RichTextEditorView>,
|
||||
markdown: &str,
|
||||
) {
|
||||
editor_view.update(app, |editor, ctx| {
|
||||
editor.reset_with_markdown(markdown, ctx);
|
||||
editor.set_interaction_state(InteractionState::Editable, ctx);
|
||||
});
|
||||
let render_state = editor_view.read(app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
}
|
||||
|
||||
fn rendered_mermaid_block_range(
|
||||
editor: &RichTextEditorView,
|
||||
ctx: &warpui::AppContext,
|
||||
) -> Option<std::ops::Range<CharOffset>> {
|
||||
let render_state = editor.model.as_ref(ctx).render_state().clone();
|
||||
let render_state = render_state.as_ref(ctx);
|
||||
let content = render_state.content();
|
||||
let mut block_start = CharOffset::zero();
|
||||
|
||||
for block in content.block_items() {
|
||||
let block_end = block_start + block.content_length();
|
||||
if matches!(
|
||||
block,
|
||||
warp_editor::render::model::BlockItem::MermaidDiagram { .. }
|
||||
) {
|
||||
return Some(block_start..block_end);
|
||||
}
|
||||
block_start = block_end;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window, editor_view, test_view) = initialize_editor(&mut app);
|
||||
|
||||
// The editor isn't focused, so it should ignore the typed characters.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::UserTyped(UserInput::new("abc")), ctx);
|
||||
});
|
||||
editor_view.read(&app, |editor, ctx| assert!(editor.markdown(ctx).is_empty()));
|
||||
|
||||
// Once the editor gains focus, it should start dispatching key events.
|
||||
editor_view.update(&mut app, |_, ctx| {
|
||||
ctx.focus_self();
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::UserTyped(UserInput::new("abc")), ctx);
|
||||
});
|
||||
editor_view.read(&app, |editor, ctx| assert_eq!(&editor.markdown(ctx), "abc"));
|
||||
|
||||
// Focus the root view to ensure that the editor is not focused at the framework level.
|
||||
test_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
assert_ne!(app.focused_view_id(window), Some(editor_view.id()));
|
||||
|
||||
// Clicking into the editor should restore focus.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
});
|
||||
assert_eq!(app.focused_view_id(window), Some(editor_view.id()));
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_focus() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, editor_view, _) = initialize_editor(&mut app);
|
||||
|
||||
// Initially, the editor is not focused.
|
||||
editor_view.read(&app, |editor, ctx| assert!(!editor.is_focused(ctx)));
|
||||
|
||||
// If the editor is focused, but not the window, it's still not considered focused.
|
||||
editor_view.update(&mut app, |editor, ctx| editor.focus(ctx));
|
||||
editor_view.read(&app, |editor, ctx| assert!(!editor.is_focused(ctx)));
|
||||
|
||||
// Once the window is focused, we treat the editor as focused too.
|
||||
WindowManager::handle(&app).update(&mut app, |windowing_state, _| {
|
||||
windowing_state.overwrite_for_test(windowing_state.stage(), Some(window_id));
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| assert!(editor.is_focused(ctx)));
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_appearance_changes() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
|
||||
let render_model = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
|
||||
// Subscribe to layout updates from the render model to verify edits.
|
||||
let layouts = {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&render_model, move |_, event, _| {
|
||||
if let RenderEvent::LayoutUpdated = event {
|
||||
block_on(tx.send(*event)).unwrap();
|
||||
}
|
||||
})
|
||||
});
|
||||
rx
|
||||
};
|
||||
|
||||
// Wait for initial layout.
|
||||
assert!(layouts.recv().await.is_ok());
|
||||
|
||||
// First, focus the editor so it is editable.
|
||||
editor_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.user_typed("ABC", ctx);
|
||||
});
|
||||
|
||||
// Wait for the typed text to lay out.
|
||||
assert!(layouts.recv().await.is_ok());
|
||||
|
||||
// Simulate an appearance change.
|
||||
Appearance::handle(&app).update(&mut app, |appearance, ctx| {
|
||||
appearance.set_monospace_font_family(warpui::fonts::FamilyId(123), ctx);
|
||||
ctx.notify()
|
||||
});
|
||||
|
||||
// The appearance change should cause a re-layout.
|
||||
assert!(layouts.recv().await.is_ok());
|
||||
|
||||
render_model.update(&mut app, |model, _| {
|
||||
// The render model's style should be updated.
|
||||
assert_eq!(
|
||||
model.styles().code_text.font_family,
|
||||
warpui::fonts::FamilyId(123)
|
||||
);
|
||||
});
|
||||
|
||||
assert_eq!(layouts.try_recv().unwrap_err(), TryRecvError::Empty);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_omnibar_is_hidden_for_rendered_mermaid_selection() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
editor.selection_start(mermaid_block_range.start, false, ctx);
|
||||
editor.selection_update(mermaid_block_range.end, ctx);
|
||||
editor.selection_end(ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
assert!(!editor.should_show_omnibar(ctx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shift_click_on_rendered_mermaid_dispatches_selection_update_to_block_boundary() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
editor.selection_end(ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action = <EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_down(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
ModifiersState {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
1,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_end))
|
||||
);
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
let mermaid_block_end = rendered_mermaid_block_range(editor, ctx)
|
||||
.expect("Expected rendered Mermaid block")
|
||||
.end;
|
||||
editor.selection_start(mermaid_block_end + 2, false, ctx);
|
||||
editor.selection_end(ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action = <EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_down(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
ModifiersState {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
1,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_start))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drag_on_rendered_mermaid_dispatches_selection_update_to_block_boundary() {
|
||||
App::test((), |mut app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action =
|
||||
<EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_dragged(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_end))
|
||||
);
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_end(ctx);
|
||||
let mermaid_block_end = rendered_mermaid_block_range(editor, ctx)
|
||||
.expect("Expected rendered Mermaid block")
|
||||
.end;
|
||||
editor.selection_start(mermaid_block_end + 2, false, ctx);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
let mermaid_block_start = mermaid_block_range.start;
|
||||
let mermaid_block_end = mermaid_block_range.end;
|
||||
|
||||
let action =
|
||||
<EditorViewAction as RichTextAction<RichTextEditorView>>::left_mouse_dragged(
|
||||
Location::Block {
|
||||
start_offset: mermaid_block_start,
|
||||
end_offset: mermaid_block_end,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
&editor_view.downgrade(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
Some(EditorViewAction::SelectionUpdate(mermaid_block_start))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_editing() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
// First, focus the editor so it is editable.
|
||||
editor_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
|
||||
// Select some text and open the link editor. This must be split across several updates so
|
||||
// that model changes don't close the link editor.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.user_typed("Some text", ctx);
|
||||
editor.handle_action(&EditorViewAction::SelectBackwardsByWord, ctx);
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
|
||||
// Populate the link editor to create a hyperlink.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert!(editor.link_editor_open);
|
||||
let link_editor = editor.link_editor.as_ref(ctx);
|
||||
assert!(link_editor.url_editor().is_focused(ctx));
|
||||
assert_eq!(
|
||||
link_editor.tag_editor().as_ref(ctx).buffer_text(ctx),
|
||||
"text"
|
||||
);
|
||||
|
||||
link_editor
|
||||
.url_editor()
|
||||
.clone()
|
||||
.update(ctx, |url_editor, ctx| {
|
||||
url_editor.user_insert("https://warp.dev", ctx);
|
||||
});
|
||||
|
||||
editor.link_editor.update(ctx, |link_editor, ctx| {
|
||||
link_editor.handle_action(&LinkEditorAction::ApplyLink, ctx)
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure that the link was created.
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
assert_eq!(
|
||||
editor.model.as_ref(ctx).debug_buffer(ctx),
|
||||
"<text>Some <a_https://warp.dev>text<a>"
|
||||
);
|
||||
});
|
||||
|
||||
// Create a separate link after the first one, with no initial text selection.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::MoveToLineEnd, ctx);
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert!(editor.link_editor_open);
|
||||
let tag_editor = editor.link_editor.as_ref(ctx).tag_editor().clone();
|
||||
let url_editor = editor.link_editor.as_ref(ctx).url_editor().clone();
|
||||
|
||||
url_editor.update(ctx, |url_editor, ctx| {
|
||||
url_editor.user_insert("https://example.com", ctx);
|
||||
});
|
||||
tag_editor.update(ctx, |tag_editor, ctx| {
|
||||
assert!(tag_editor.is_empty(ctx));
|
||||
tag_editor.user_insert("new link", ctx)
|
||||
});
|
||||
|
||||
editor.link_editor.update(ctx, |link_editor, ctx| {
|
||||
link_editor.handle_action(&LinkEditorAction::ApplyLink, ctx)
|
||||
});
|
||||
});
|
||||
editor_view.read(&app, |editor, ctx| {
|
||||
assert_eq!(
|
||||
editor.model.as_ref(ctx).debug_buffer(ctx),
|
||||
"<text>Some <a_https://warp.dev>text<a><a_https://example.com>new link<a>"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_command_from_text_selection() {
|
||||
// This tests that, starting from a text selection, we can still run a command.
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
let (tx, has_layout) = futures::channel::oneshot::channel();
|
||||
app.update(|ctx| {
|
||||
let mut tx = Some(tx);
|
||||
let render_state = editor_view
|
||||
.as_ref(ctx)
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.render_state()
|
||||
.clone();
|
||||
ctx.subscribe_to_model(&render_state, move |_, event, _ctx| {
|
||||
if let RenderEvent::LayoutUpdated = event {
|
||||
if let Some(tx) = tx.take() {
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.reset_with_markdown("Text\n```\necho hi\n```\n```\necho hello\n```", ctx);
|
||||
});
|
||||
has_layout.await.expect("Model was not laid out");
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
// Simulate cmd-enter in a non-text block, which should be a no-op.
|
||||
editor.selection_start(3.into(), false, ctx);
|
||||
editor.run_selected_commands(ctx);
|
||||
assert!(!editor.model.as_ref(ctx).has_command_selection(ctx));
|
||||
|
||||
// If the cursor is in a command block, cmd-enter should auto-select it.
|
||||
editor.selection_start(8.into(), false, ctx);
|
||||
editor.run_selected_commands(ctx);
|
||||
let selected_command = editor
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_command_workflow(ctx)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected_command
|
||||
.workflow
|
||||
.as_workflow()
|
||||
.command()
|
||||
.expect("Workflow is Command Workflow"),
|
||||
"echo hi"
|
||||
);
|
||||
|
||||
// If the text cursor was in one command block, but another is selected, cmd-enter
|
||||
// should run the selected command.
|
||||
editor.command_down(ctx);
|
||||
editor.run_selected_commands(ctx);
|
||||
let selected_command = editor
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_command_workflow(ctx)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected_command
|
||||
.workflow
|
||||
.as_workflow()
|
||||
.command()
|
||||
.expect("Workflow is Command Workflow"),
|
||||
"echo hello"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_editing_disabled_for_multiselect() {
|
||||
// Ensure that if multiple selections are made, that the link editor is not opened.
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
// First, focus the editor so it is editable.
|
||||
editor_view.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
|
||||
// Select some text and open the link editor. This must be split across several updates so
|
||||
// that model changes don't close the link editor.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.user_typed("Some text", ctx);
|
||||
editor.handle_action(&EditorViewAction::SelectBackwardsByWord, ctx);
|
||||
});
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert_eq!(editor.model().as_ref(ctx).selected_text(ctx), "text");
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
|
||||
// Populate the link editor to create a hyperlink.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
assert!(editor.link_editor_open);
|
||||
let link_editor = editor.link_editor.as_ref(ctx);
|
||||
assert!(link_editor.url_editor().is_focused(ctx));
|
||||
assert_eq!(
|
||||
link_editor.tag_editor().as_ref(ctx).buffer_text(ctx),
|
||||
"text"
|
||||
);
|
||||
|
||||
link_editor
|
||||
.url_editor()
|
||||
.clone()
|
||||
.update(ctx, |url_editor, ctx| {
|
||||
url_editor.user_insert("https://warp.dev", ctx);
|
||||
});
|
||||
|
||||
editor.link_editor.update(ctx, |link_editor, ctx| {
|
||||
link_editor.handle_action(&LinkEditorAction::ApplyLink, ctx)
|
||||
});
|
||||
});
|
||||
|
||||
// Add another selection.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(
|
||||
&EditorViewAction::SelectionStart {
|
||||
offset: 1.into(),
|
||||
multiselect: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Try to open the link editor.
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(&EditorViewAction::CreateOrEditLink, ctx);
|
||||
});
|
||||
|
||||
// Ensure that the link editor was not opened.
|
||||
editor_view.read(&app, |editor, _ctx| {
|
||||
assert!(!editor.link_editor_open);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use repo_metadata::{repositories::DetectedRepositories, watcher::DirectoryWatcher};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use warp_files::FileModel;
|
||||
use warpui::{platform::WindowStyle, App, SingletonEntity, View};
|
||||
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::{
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
notebooks::{editor::keys::NotebookKeybindings, file::is_markdown_file},
|
||||
search::files::model::FileSearchModel,
|
||||
server::server_api::ServerApiProvider,
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::model::session::Session,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
use crate::notebooks::context_menu::MenuSource;
|
||||
|
||||
use super::{FileNotebookView, FileState};
|
||||
|
||||
fn init_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(FileModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
let team_client_mock = Arc::new(MockTeamClient::new());
|
||||
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client_mock.clone(),
|
||||
workspace_client_mock.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
#[cfg(feature = "voice_input")]
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_local() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, FileNotebookView::new);
|
||||
let session = Arc::new(Session::test());
|
||||
handle
|
||||
.update(&mut app, |file_notebook, ctx| {
|
||||
file_notebook.open_local("../README.md", Some(session), ctx);
|
||||
|
||||
let file_id = file_notebook
|
||||
.file_id
|
||||
.expect("File should be opened and have a file_id");
|
||||
|
||||
let future_handle = FileModel::as_ref(ctx)
|
||||
.get_future_handle(file_id)
|
||||
.expect("Loading future should be present");
|
||||
|
||||
ctx.await_spawned_future(future_handle.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
app.read(|ctx| {
|
||||
assert_eq!(&handle.as_ref(ctx).title(), "README.md");
|
||||
let location = handle
|
||||
.as_ref(ctx)
|
||||
.location
|
||||
.as_ref()
|
||||
.expect("Location should be set");
|
||||
assert_eq!(location.breadcrumbs, "..");
|
||||
|
||||
let editor = handle.as_ref(ctx).editor.as_ref(ctx);
|
||||
assert!(!editor.is_editable(ctx));
|
||||
// We don't want to check the actual README contents, but it should be clearly non-empty.
|
||||
assert!(editor.markdown(ctx).len() > 4);
|
||||
|
||||
// Rendering should not panic.
|
||||
handle.as_ref(ctx).render(ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_before_session() {
|
||||
// There might not be a session if:
|
||||
// * Restoring a file notebook, since terminal panes won't have bootstrapped yet
|
||||
// * Only notebooks are open
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
let (window_id, handle) = app.add_window(WindowStyle::NotStealFocus, FileNotebookView::new);
|
||||
|
||||
// Open a file we know exists to verify that the view can render.
|
||||
handle
|
||||
.update(&mut app, |file_notebook, ctx| {
|
||||
file_notebook.open_local("../README.md", None, ctx);
|
||||
match &file_notebook.file_state {
|
||||
FileState::Loading(source) => {
|
||||
assert_eq!(source.local_path(), Some(Path::new("../README.md")))
|
||||
}
|
||||
other => panic!("Expected FileState::Loading, got {other:?}"),
|
||||
}
|
||||
|
||||
let file_id = file_notebook
|
||||
.file_id
|
||||
.expect("File should be opened and have a file_id");
|
||||
|
||||
let future_handle = FileModel::as_ref(ctx)
|
||||
.get_future_handle(file_id)
|
||||
.expect("Loading future should be present");
|
||||
|
||||
ctx.await_spawned_future(future_handle.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
handle.read(&app, |view, _| {
|
||||
let expected_path = dunce::canonicalize("../README.md").expect("Path exists");
|
||||
|
||||
assert_eq!(view.title(), expected_path.display().to_string());
|
||||
assert!(view.location.is_none());
|
||||
|
||||
match &view.file_state {
|
||||
FileState::Loaded(source) => {
|
||||
assert_eq!(source.local_path(), Some(expected_path.as_path()));
|
||||
}
|
||||
other => panic!("Expected FileState::Loaded, got {other:?}"),
|
||||
};
|
||||
});
|
||||
|
||||
// Once a local session is available, the view should use it.
|
||||
let session = Arc::new(Session::test());
|
||||
ActiveSession::handle(&app).update(&mut app, |active_session, ctx| {
|
||||
active_session.set_session_for_test(window_id, session.clone(), Some("."), None, ctx);
|
||||
});
|
||||
|
||||
handle.read(&app, |view, _| {
|
||||
assert_eq!(&view.title(), "README.md");
|
||||
// The location should be set, but the exact breadcrumbs depend on where the repo
|
||||
// is located.
|
||||
assert!(view.location.is_some());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_static() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, FileNotebookView::new);
|
||||
|
||||
handle.update(&mut app, |file_notebook, ctx| {
|
||||
file_notebook.open_static("Test Title", "Test Content", ctx);
|
||||
assert!(file_notebook.file_id.is_none());
|
||||
|
||||
assert!(matches!(file_notebook.file_state, FileState::Loaded(_)));
|
||||
assert_eq!(file_notebook.title(), "Test Title");
|
||||
assert!(file_notebook.location.is_none());
|
||||
|
||||
let editor = file_notebook.editor.as_ref(ctx);
|
||||
assert!(!editor.is_editable(ctx));
|
||||
// We don't want to check the actual README contents, but it should be clearly non-empty.
|
||||
assert!(editor.markdown(ctx).len() > 4);
|
||||
|
||||
// Rendering should not panic.
|
||||
file_notebook.render(ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_file_detection() {
|
||||
assert!(is_markdown_file("README.md"));
|
||||
assert!(is_markdown_file("DATABASE.MD"));
|
||||
assert!(is_markdown_file("notes.markdown"));
|
||||
assert!(is_markdown_file("README"));
|
||||
assert!(is_markdown_file("license"));
|
||||
assert!(is_markdown_file("CHANGELOG"));
|
||||
assert!(is_markdown_file("ReadMe"));
|
||||
|
||||
assert!(!is_markdown_file("README.txt"));
|
||||
assert!(!is_markdown_file("main.rs"));
|
||||
assert!(!is_markdown_file("notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_notebook_mermaid_context_menu_does_not_show_copy_image() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, FileNotebookView::new);
|
||||
|
||||
handle.update(&mut app, |file_notebook, ctx| {
|
||||
file_notebook.open_static("Test Title", "```mermaid\ngraph TD\nA --> B\n```", ctx);
|
||||
|
||||
let source = MenuSource::RichTextEditor {
|
||||
parent_offset: vec2f(0., 0.),
|
||||
editor: file_notebook.editor.clone(),
|
||||
};
|
||||
file_notebook.context_menu.show_context_menu(source, ctx);
|
||||
|
||||
let item_names = file_notebook.context_menu.item_names(ctx);
|
||||
assert!(!item_names.contains(&"Copy image"));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Link-opening behavior for notebooks.
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt,
|
||||
future::{self, Future},
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use futures_util::future::Either;
|
||||
use url::Url;
|
||||
use warp_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
use warpui::{
|
||||
r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::{is_supported_image_file, resolve_file_target, FileTarget};
|
||||
use crate::{
|
||||
drive::OpenWarpDriveObjectArgs,
|
||||
terminal::model::session::Session,
|
||||
uri::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink},
|
||||
workspace::ActiveSession,
|
||||
};
|
||||
|
||||
use super::file::is_markdown_file;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "link_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// The target of a notebook link.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LinkTarget {
|
||||
Url(Url),
|
||||
LocalFile {
|
||||
path: PathBuf,
|
||||
line_and_column: Option<LineAndColumnArg>,
|
||||
/// The base session when the link was resolved. It's stored here in case it changes
|
||||
/// between resolving and opening the link.
|
||||
session: Arc<Session>,
|
||||
/// Whether or not this file is a Markdown file viewable in Warp.
|
||||
is_markdown: bool,
|
||||
},
|
||||
LocalDirectory {
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl LinkTarget {
|
||||
/// A secondary action to show in the tooltip for this link.
|
||||
pub fn secondary_action(&self) -> Option<SecondaryAction> {
|
||||
match self {
|
||||
LinkTarget::LocalDirectory { .. } => Some(SecondaryAction {
|
||||
label: "New session".into(),
|
||||
tooltip: Some("Open a new terminal session in this directory".into()),
|
||||
accessibility_content: "Open in terminal session".into(),
|
||||
}),
|
||||
LinkTarget::LocalFile {
|
||||
is_markdown: true, ..
|
||||
} => Some(SecondaryAction {
|
||||
label: "Open in editor".into(),
|
||||
tooltip: None,
|
||||
accessibility_content: "Edit Markdown file".into(),
|
||||
}),
|
||||
LinkTarget::Url(_) | LinkTarget::LocalFile { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for LinkTarget {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Url(my_url), Self::Url(other_url)) => my_url == other_url,
|
||||
(
|
||||
Self::LocalFile {
|
||||
path: my_path,
|
||||
line_and_column: my_location,
|
||||
session: my_session,
|
||||
..
|
||||
},
|
||||
Self::LocalFile {
|
||||
path: other_path,
|
||||
line_and_column: other_location,
|
||||
session: other_session,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
my_path == other_path
|
||||
&& my_location == other_location
|
||||
&& Arc::ptr_eq(my_session, other_session)
|
||||
}
|
||||
(Self::LocalDirectory { path: my_path }, Self::LocalDirectory { path: other_path }) => {
|
||||
my_path == other_path
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LinkTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
LinkTarget::Url(url) => url.fmt(f),
|
||||
LinkTarget::LocalFile { path, .. } => path.display().fmt(f),
|
||||
LinkTarget::LocalDirectory { path, .. } => path.display().fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Model for resolving and opening links in a notebook, taking into account their context (for
|
||||
/// example, resolving relative file paths).
|
||||
pub struct NotebookLinks {
|
||||
session_source: SessionSource,
|
||||
}
|
||||
|
||||
impl NotebookLinks {
|
||||
pub fn new(session_source: SessionSource, ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.observe(
|
||||
&ActiveSession::handle(ctx),
|
||||
Self::handle_active_session_change,
|
||||
);
|
||||
|
||||
Self { session_source }
|
||||
}
|
||||
|
||||
/// Resolve a link target. If the link is a valid URL or starts with a potential domain name,
|
||||
/// it's treated as an URL. Otherwise, it's treated as a local file path, possibly with a line
|
||||
/// and column number. This returns `None` if the link is known to be invalid (for example, it
|
||||
/// resolves to a nonexistent file path).
|
||||
pub fn resolve(
|
||||
&self,
|
||||
link: &str,
|
||||
ctx: &AppContext,
|
||||
) -> impl Future<Output = Result<LinkTarget, ResolveError>> {
|
||||
if let Ok(url) = Url::parse(link) {
|
||||
// The `url` crate only provides `to_file_path` on certain platforms.
|
||||
#[cfg(feature = "local_fs")]
|
||||
if url.scheme() == "file" {
|
||||
// Unlike below, if there's missing information, we can still fall back to the
|
||||
// system for file:// URL handling.
|
||||
if let Some(session) = self.session_source.session(ctx) {
|
||||
if let Ok(file) = url.to_file_path() {
|
||||
// TODO(ben): Support line and column in file:// URLs.
|
||||
return Either::Left(Self::resolve_file(file, session, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
|
||||
}
|
||||
|
||||
// If parsing failed, see if this is a web URL without a scheme.
|
||||
// The heuristic we use is to take the substring up to the first slash (if present), and
|
||||
// check for a valid public domain name or IP address.
|
||||
let maybe_domain = link.split_once('/').map_or(link, |(start, _)| start);
|
||||
if addr::parse_domain_name(maybe_domain)
|
||||
.is_ok_and(|domain| domain.has_known_suffix() && domain.root().is_some())
|
||||
|| maybe_domain.parse::<IpAddr>().is_ok()
|
||||
{
|
||||
if let Ok(url) = Url::parse(&format!("http://{link}")) {
|
||||
return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we can only resolve file targets, which require a session.
|
||||
match self.session_source.session(ctx) {
|
||||
Some(session) if session.launch_data().is_some() => {
|
||||
let launch_data = session
|
||||
.launch_data()
|
||||
.expect("Session launch data should exist");
|
||||
let clean_path = CleanPathResult::with_line_and_column_number(link);
|
||||
let path = match self.session_source.base_directory(ctx) {
|
||||
Some(base_directory) => {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
let Some(path) = crate::util::file::absolute_path_if_valid(
|
||||
&clean_path,
|
||||
crate::util::file::ShellPathType::PlatformNative(base_directory.to_path_buf()),
|
||||
Some(launch_data),
|
||||
) else {
|
||||
return Either::Right(future::ready(Err(ResolveError::FileNotFound)));
|
||||
};
|
||||
path
|
||||
} else {
|
||||
// If we don't have a local filesystem, we append the path naively.
|
||||
base_directory.join(clean_path.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let Some(path) = launch_data.maybe_convert_absolute_path(&clean_path.path)
|
||||
else {
|
||||
return Either::Right(future::ready(Err(ResolveError::MissingContext)));
|
||||
};
|
||||
// To open a relative path, we must have a base directory. Otherwise, we don't know for
|
||||
// sure how the path will be resolved.
|
||||
if path.is_relative() {
|
||||
return Either::Right(future::ready(Err(ResolveError::MissingContext)));
|
||||
}
|
||||
path
|
||||
}
|
||||
};
|
||||
|
||||
Either::Left(Self::resolve_file(
|
||||
path,
|
||||
session,
|
||||
clean_path.line_and_column_num,
|
||||
))
|
||||
}
|
||||
Some(session) => {
|
||||
let clean_path_result = CleanPathResult::with_line_and_column_number(link);
|
||||
let clean_path = Path::new(&clean_path_result.path);
|
||||
let path = if clean_path.is_relative() {
|
||||
// To open a relative path, we must have a base directory. Otherwise, we don't know for
|
||||
// sure how the path will be resolved.
|
||||
match self.session_source.base_directory(ctx) {
|
||||
Some(directory) => directory.join(clean_path),
|
||||
None => {
|
||||
return Either::Right(future::ready(Err(ResolveError::MissingContext)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clean_path.to_path_buf()
|
||||
};
|
||||
|
||||
Either::Left(Self::resolve_file(
|
||||
path,
|
||||
session,
|
||||
clean_path_result.line_and_column_num,
|
||||
))
|
||||
}
|
||||
None => Either::Right(future::ready(Err(ResolveError::MissingContext))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a file path into a [`LinkTarget`], checking if it exists.
|
||||
async fn resolve_file(
|
||||
path: PathBuf,
|
||||
session: Arc<Session>,
|
||||
line_and_column: Option<LineAndColumnArg>,
|
||||
) -> Result<LinkTarget, ResolveError> {
|
||||
let metadata = async_fs::metadata(&path).await?;
|
||||
Ok(if metadata.is_dir() {
|
||||
// Discard line/column information, which doesn't make sense for a directory.
|
||||
LinkTarget::LocalDirectory { path }
|
||||
} else {
|
||||
LinkTarget::LocalFile {
|
||||
is_markdown: is_markdown_file(&path),
|
||||
path,
|
||||
line_and_column,
|
||||
session,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a resolved link:
|
||||
/// * URLs are opened in the web browser or system-default application.
|
||||
/// * Markdown files are opened in Warp (if the `FileNotebooks` feature flag is enabled).
|
||||
/// * Other files are opened in the configured editor or system-default application.
|
||||
pub fn open(&self, link: LinkTarget, ctx: &mut ModelContext<Self>) {
|
||||
match link {
|
||||
LinkTarget::Url(url) => {
|
||||
if let Some(WarpWebLink::DriveObject(args)) = get_item_data_from_warp_link(&url) {
|
||||
return ctx.emit(LinkEvent::OpenWarpDriveLink {
|
||||
open_warp_drive_args: *args,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.open_url(url.as_str())
|
||||
}
|
||||
LinkTarget::LocalFile {
|
||||
path,
|
||||
session,
|
||||
is_markdown: true,
|
||||
..
|
||||
} => {
|
||||
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
|
||||
}
|
||||
LinkTarget::LocalFile {
|
||||
path,
|
||||
line_and_column,
|
||||
..
|
||||
} => open_file(path, line_and_column, ctx),
|
||||
LinkTarget::LocalDirectory { path, .. } => ctx.open_file_path(&path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the secondary action for this link.
|
||||
pub fn secondary_action(&self, link: &LinkTarget, ctx: &mut ModelContext<Self>) {
|
||||
match link {
|
||||
LinkTarget::LocalDirectory { path } => {
|
||||
ctx.emit(LinkEvent::StartLocalSession { path: path.clone() })
|
||||
}
|
||||
LinkTarget::LocalFile {
|
||||
path,
|
||||
line_and_column,
|
||||
is_markdown: true,
|
||||
..
|
||||
} => {
|
||||
// The default action for Markdown file links is to open them in Warp. As a
|
||||
// secondary action, open them in an external app.
|
||||
open_file(path.clone(), *line_and_column, ctx)
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asynchronously resolve and open a link.
|
||||
pub fn resolve_and_open(
|
||||
&self,
|
||||
link: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> SpawnedFutureHandle {
|
||||
ctx.spawn(self.resolve(link, ctx), |me, resolved, ctx| {
|
||||
if let Ok(link) = resolved {
|
||||
me.open(link, ctx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_session_source(&mut self, source: SessionSource, ctx: &mut ModelContext<Self>) {
|
||||
self.session_source = source;
|
||||
ctx.emit(LinkEvent::RefreshLinks);
|
||||
}
|
||||
|
||||
/// Listen for session changes that might invalidate resolved links.
|
||||
fn handle_active_session_change(
|
||||
&mut self,
|
||||
_handle: ModelHandle<ActiveSession>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Re-resolve links against the new session info, especially if the working directory
|
||||
// changed.
|
||||
if matches!(self.session_source, SessionSource::Active(_)) {
|
||||
ctx.emit(LinkEvent::RefreshLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a file respecting user's editor settings.
|
||||
// The `line_and_column` argument is unused when there is no local filesystem.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
fn open_file(
|
||||
path: PathBuf,
|
||||
line_and_column: Option<LineAndColumnArg>,
|
||||
ctx: &mut ModelContext<NotebookLinks>,
|
||||
) {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
let target = if is_supported_image_file(&path) {
|
||||
FileTarget::SystemGeneric
|
||||
} else {
|
||||
let settings = EditorSettings::as_ref(ctx);
|
||||
resolve_file_target(&path, settings, None)
|
||||
};
|
||||
ctx.emit(LinkEvent::OpenFileWithTarget {
|
||||
path,
|
||||
target,
|
||||
line_col: line_and_column,
|
||||
});
|
||||
}
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
ctx.open_file_path(&path);
|
||||
}
|
||||
|
||||
impl Entity for NotebookLinks {
|
||||
type Event = LinkEvent;
|
||||
}
|
||||
|
||||
/// An error resolving a file link.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolveError {
|
||||
/// The target file does not exist.
|
||||
FileNotFound,
|
||||
/// The context needed to resolve a file is missing.
|
||||
MissingContext,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ResolveError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
ResolveError::FileNotFound
|
||||
} else {
|
||||
ResolveError::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ResolveError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ResolveError::FileNotFound => f.write_str("File not found"),
|
||||
ResolveError::MissingContext => f.write_str("No base directory"),
|
||||
ResolveError::Unknown => f.write_str("Broken file link"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LinkEvent {
|
||||
/// Emitted when the view should open a Markdown file as a notebook.
|
||||
OpenFileNotebook {
|
||||
path: PathBuf,
|
||||
session: Arc<Session>,
|
||||
},
|
||||
OpenWarpDriveLink {
|
||||
open_warp_drive_args: OpenWarpDriveObjectArgs,
|
||||
},
|
||||
/// This event tells the parent pane group to open a new terminal session in the given
|
||||
/// directory.
|
||||
StartLocalSession { path: PathBuf },
|
||||
/// Signal to views that they should re-resolve links because the backing context for
|
||||
/// resolution has changed.
|
||||
RefreshLinks,
|
||||
#[cfg(feature = "local_fs")]
|
||||
/// Emitted when a file should be opened in Warp (code editor or markdown viewer).
|
||||
OpenFileWithTarget {
|
||||
path: PathBuf,
|
||||
target: FileTarget,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A secondary action for a link, besides opening it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecondaryAction {
|
||||
pub label: Cow<'static, str>,
|
||||
pub tooltip: Option<Cow<'static, str>>,
|
||||
pub accessibility_content: Cow<'static, str>,
|
||||
}
|
||||
|
||||
/// Source for the [`Session`] and working directory to use when opening Markdown files as notebooks.
|
||||
pub enum SessionSource {
|
||||
/// Use the specific target session and directory.
|
||||
Target {
|
||||
session: Arc<Session>,
|
||||
base_directory: PathBuf,
|
||||
},
|
||||
/// Use the window's active session and working directory.
|
||||
Active(WindowId),
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
fn session(&self, ctx: &AppContext) -> Option<Arc<Session>> {
|
||||
match self {
|
||||
SessionSource::Target { session, .. } => Some(session.clone()),
|
||||
SessionSource::Active(window_id) => ActiveSession::as_ref(ctx).session(*window_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_directory<'a>(&'a self, ctx: &'a AppContext) -> Option<&'a Path> {
|
||||
match self {
|
||||
SessionSource::Target { base_directory, .. } => Some(base_directory.as_path()),
|
||||
SessionSource::Active(window_id) => {
|
||||
ActiveSession::as_ref(ctx).path_if_local(*window_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
use std::{
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::Mutex;
|
||||
use tempfile::tempdir;
|
||||
use url::Url;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::{App, ModelHandle, WindowId};
|
||||
|
||||
use crate::{
|
||||
notebooks::{file::is_markdown_file, link::LinkEvent},
|
||||
terminal::{model::session::Session, shell::ShellType},
|
||||
util::openable_file_type::FileTarget,
|
||||
workspace::ActiveSession,
|
||||
};
|
||||
|
||||
use super::{LinkTarget, NotebookLinks, ResolveError, SessionSource};
|
||||
|
||||
fn url(s: &str) -> LinkTarget {
|
||||
LinkTarget::Url(Url::parse(s).expect("Invalid URL"))
|
||||
}
|
||||
|
||||
fn local_directory(path: impl Into<PathBuf>) -> LinkTarget {
|
||||
LinkTarget::LocalDirectory { path: path.into() }
|
||||
}
|
||||
|
||||
fn local_file(path: impl Into<PathBuf>) -> LinkTarget {
|
||||
let path = path.into();
|
||||
LinkTarget::LocalFile {
|
||||
is_markdown: is_markdown_file(&path),
|
||||
path,
|
||||
line_and_column: None,
|
||||
session: TEST_SESSION.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_file_location(path: impl Into<PathBuf>, line: usize, column: Option<usize>) -> LinkTarget {
|
||||
let path = path.into();
|
||||
LinkTarget::LocalFile {
|
||||
is_markdown: is_markdown_file(&path),
|
||||
path,
|
||||
line_and_column: Some(LineAndColumnArg {
|
||||
line_num: line,
|
||||
column_num: column,
|
||||
}),
|
||||
session: TEST_SESSION.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
// ActiveSession holds a weak reference to the session, so we need this strong one to keep it
|
||||
// alive.
|
||||
static ref TEST_SESSION: Arc<Session> = Arc::new(Session::test().with_shell_launch_data(crate::terminal::ShellLaunchData::Executable { executable_path: PathBuf::from("/bin/bash"), shell_type: ShellType::Bash }));
|
||||
}
|
||||
|
||||
/// Initialize the app and link resolver. For test purposes, we only care about the base
|
||||
/// directory's value, not how it was obtained.
|
||||
fn init_link_model(app: &mut App, base_directory: Option<&Path>) -> ModelHandle<NotebookLinks> {
|
||||
let window_id = WindowId::new();
|
||||
let source = match base_directory {
|
||||
Some(dir) => SessionSource::Target {
|
||||
session: TEST_SESSION.clone(),
|
||||
base_directory: dir.to_owned(),
|
||||
},
|
||||
// File links can't be resolved without a session, even if there's no working directory.
|
||||
None => SessionSource::Active(window_id),
|
||||
};
|
||||
app.add_singleton_model(|ctx| {
|
||||
let mut session = ActiveSession::default();
|
||||
session.set_session_for_test(window_id, TEST_SESSION.clone(), base_directory, None, ctx);
|
||||
session
|
||||
});
|
||||
app.add_model(|ctx| NotebookLinks::new(source, ctx))
|
||||
}
|
||||
|
||||
async fn resolve(app: &App, links: &ModelHandle<NotebookLinks>, link: &str) -> LinkTarget {
|
||||
match links.read(app, |links, ctx| links.resolve(link, ctx)).await {
|
||||
Ok(target) => target,
|
||||
Err(err) => panic!("Error resolving {link}: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a file exists, creating its parents if necessary.
|
||||
async fn touch(path: impl AsRef<Path>) {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(err) = async_fs::create_dir_all(parent).await {
|
||||
if err.kind() != ErrorKind::AlreadyExists {
|
||||
panic!("Creating parent {} failed: {}", parent.display(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async_fs::File::create(path)
|
||||
.await
|
||||
.expect("Creating test file failed")
|
||||
.sync_all()
|
||||
.await
|
||||
.expect("Syncing test file failed");
|
||||
}
|
||||
|
||||
fn next_link_event(events: &Arc<Mutex<Vec<LinkEvent>>>) -> LinkEvent {
|
||||
events.lock().remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_bare_url() {
|
||||
App::test((), |mut app| async move {
|
||||
let base = tempdir().unwrap();
|
||||
let base_path = base.path();
|
||||
touch(base_path.join("nodot/slash")).await;
|
||||
touch(base_path.join(".vscode/settings.json")).await;
|
||||
touch(base_path.join("myfile.swift")).await;
|
||||
touch(base_path.join("license.txt")).await;
|
||||
touch(base_path.join("app/src/main.rs")).await;
|
||||
|
||||
let links = init_link_model(&mut app, Some(base_path));
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "example.com/some-path").await,
|
||||
url("http://example.com/some-path")
|
||||
);
|
||||
|
||||
// These should not be considered URLs.
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "nodot/slash").await,
|
||||
local_file(base_path.join("nodot/slash"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, ".vscode/settings.json").await,
|
||||
local_file(base_path.join(".vscode/settings.json"))
|
||||
);
|
||||
|
||||
// These rely on domain name validation.
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "google.com").await,
|
||||
url("http://google.com")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "warp.dev").await,
|
||||
url("http://warp.dev")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "bbc.co.uk").await,
|
||||
url("http://bbc.co.uk")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "192.168.0.1/admin").await,
|
||||
url("http://192.168.0.1/admin")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "myfile.swift").await,
|
||||
local_file(base_path.join("myfile.swift"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "license.txt").await,
|
||||
local_file(base_path.join("license.txt"))
|
||||
);
|
||||
|
||||
// `app` is a valid TLD, so this tests that we need both a TLD and a root domain to link as an
|
||||
// URL.
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "app/src/main.rs").await,
|
||||
local_file(base_path.join("app/src/main.rs"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_local_image_uses_system_generic_target() {
|
||||
App::test((), |mut app| async move {
|
||||
let base = tempdir().unwrap();
|
||||
let base_path = base.path();
|
||||
let image_path = base_path.join("images/example.png");
|
||||
touch(&image_path).await;
|
||||
let links = init_link_model(&mut app, Some(base_path));
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
{
|
||||
let events = events.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&links, move |_, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
links.update(&mut app, |links, ctx| {
|
||||
links.open(local_file(&image_path), ctx);
|
||||
});
|
||||
|
||||
match next_link_event(&events) {
|
||||
LinkEvent::OpenFileWithTarget {
|
||||
path,
|
||||
target,
|
||||
line_col,
|
||||
} => {
|
||||
assert_eq!(path, image_path);
|
||||
assert_eq!(target, FileTarget::SystemGeneric);
|
||||
assert_eq!(line_col, None);
|
||||
}
|
||||
other => panic!("Expected OpenFileWithTarget event, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_valid_url() {
|
||||
App::test((), |mut app| async move {
|
||||
let links = init_link_model(&mut app, None);
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "https://warp.dev").await,
|
||||
url("https://warp.dev")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "mailto:test@warp.dev").await,
|
||||
url("mailto:test@warp.dev")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "TODO(CORE-3626)")]
|
||||
#[test]
|
||||
fn test_resolve_file_url() {
|
||||
App::test((), |mut app| async move {
|
||||
let base = tempdir().unwrap();
|
||||
let base_path = base.path();
|
||||
let test_file = base_path.join("some/path.txt");
|
||||
touch(&test_file).await;
|
||||
let links = init_link_model(&mut app, Some(base_path));
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, &format!("file://{}", test_file.display())).await,
|
||||
local_file(&test_file)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(
|
||||
&app,
|
||||
&links,
|
||||
&format!("file://localhost{}", test_file.display())
|
||||
)
|
||||
.await,
|
||||
local_file(&test_file)
|
||||
);
|
||||
|
||||
// file:// URLs can have non-local hosts on Windows. If we encounter one, it should be kept a
|
||||
// URL for the system to handle.
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "file://remote/some/path.txt").await,
|
||||
url("file://remote/some/path.txt")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_relative_file_no_base() {
|
||||
App::test((), |mut app| async move {
|
||||
let links = init_link_model(&mut app, None);
|
||||
|
||||
let absolute_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("Cargo.toml")
|
||||
.canonicalize()
|
||||
.expect("Path exists");
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, absolute_path.to_str().unwrap()).await,
|
||||
local_file(absolute_path)
|
||||
);
|
||||
|
||||
let absolute_directory = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.canonicalize()
|
||||
.expect("Path exists");
|
||||
assert_eq!(
|
||||
resolve(&app, &links, absolute_directory.to_str().unwrap()).await,
|
||||
local_directory(absolute_directory)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
links
|
||||
.read(&app, |links, ctx| links.resolve("relative/path.txt", ctx))
|
||||
.await,
|
||||
Err(ResolveError::MissingContext)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_relative_file_base() {
|
||||
// This absolute path is specifically not within the base directory.
|
||||
let absolute_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("Cargo.toml")
|
||||
.canonicalize()
|
||||
.expect("Path exists");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let base = tempdir().unwrap();
|
||||
let base_path = base.path();
|
||||
touch(base_path.join("relative/path.txt")).await;
|
||||
touch(base_path.join("dotted.txt")).await;
|
||||
let links = init_link_model(&mut app, Some(base_path));
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, absolute_path.to_str().unwrap()).await,
|
||||
local_file(&absolute_path)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "relative/path.txt").await,
|
||||
local_file(base_path.join("relative/path.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "./dotted.txt").await,
|
||||
local_file(base_path.join("dotted.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "./relative/../dotted.txt").await,
|
||||
local_file(base_path.join("relative/../dotted.txt"))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "./relative").await,
|
||||
local_directory(base_path.join("relative"))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
links
|
||||
.read(&app, |links, ctx| links.resolve("missing.txt", ctx))
|
||||
.await,
|
||||
Err(ResolveError::FileNotFound)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_file_with_line() {
|
||||
App::test((), |mut app| async move {
|
||||
let base = tempdir().unwrap();
|
||||
let base_path = base.path();
|
||||
touch(base_path.join("src/main.rs")).await;
|
||||
touch(base_path.join("path/to/index.html")).await;
|
||||
|
||||
let links = init_link_model(&mut app, Some(base_path));
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "./src/main.rs:123").await,
|
||||
local_file_location(base_path.join("src/main.rs"), 123, None)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve(&app, &links, "path/to/index.html:99:51").await,
|
||||
local_file_location(base_path.join("path/to/index.html"), 99, Some(51))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_markdown_file() {
|
||||
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if !root.join("README.md").exists() {
|
||||
root = root.parent().unwrap().to_path_buf();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let links = init_link_model(&mut app, Some(&root));
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
{
|
||||
let events = events.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&links, move |_, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
links
|
||||
.update(&mut app, |links, ctx| {
|
||||
// The `./` in the link is important: `.md` is the TLD for Moldova, so this will be
|
||||
// resolved as a web link otherwise.
|
||||
let future = links.resolve_and_open("./README.md", ctx);
|
||||
ctx.await_spawned_future(future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
let events = events.lock();
|
||||
assert_eq!(events.len(), 1);
|
||||
match events.first() {
|
||||
Some(LinkEvent::OpenFileNotebook { path, session }) => {
|
||||
assert_eq!(path, &root.join("README.md"));
|
||||
assert!(Arc::ptr_eq(&TEST_SESSION, session));
|
||||
}
|
||||
other => panic!("Expected OpenFileNotebook event, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
use std::collections::{hash_map::Entry, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::stream::AbortHandle;
|
||||
use markdown_parser::markdown_parser::parse_markdown_to_raw_text;
|
||||
use warpui::{
|
||||
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::persistence::{CloudModel, CloudModelEvent},
|
||||
Owner,
|
||||
},
|
||||
drive::OpenWarpDriveObjectSettings,
|
||||
pane_group::{NotebookPane, PaneContent},
|
||||
safe_debug, safe_warn,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::SyncId,
|
||||
},
|
||||
workspace::PaneViewLocator,
|
||||
};
|
||||
|
||||
use super::{notebook::NotebookView, CloudNotebook};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "manager_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A singleton model tracking open notebooks.
|
||||
///
|
||||
/// This is tightly tied to the [workspace](crate::workspace::Workspace) and
|
||||
/// [pane group](crate::pane_group::PaneGroup) views, as they contain all open notebook panes.
|
||||
///
|
||||
/// The overall flow is:
|
||||
/// 1. A `Workspace` is asked to open a notebook (from the Warp Drive index, universal search, etc.).
|
||||
/// 2. It checks the `NotebookManager` to see if the notebook is already open.
|
||||
/// 3. If it is, the existing notebook pane is focused (this may be in another window).
|
||||
/// 4. If not, the `Workspace` uses the `NotebookManager` to create a new notebook pane and
|
||||
/// attaches it to the active tab.
|
||||
/// 5. When the new pane is attached to a pane group, it registers itself with the `NotebookManager`.
|
||||
/// This is because we need the pane group's ID in order to re-focus the pane.
|
||||
/// 6. When the pane is closed, it de-registers itself from the `NotebookManager`.
|
||||
///
|
||||
/// During session restoration, notebook panes are created and attached by the `PaneGroup`.
|
||||
///
|
||||
/// NotebookManager also manages a cache of the raw, unformatted text of notebooks
|
||||
/// which is needed for notebook search.
|
||||
pub struct NotebookManager {
|
||||
panes_by_hashed_id: HashMap<String, NotebookPaneData>,
|
||||
// Cache
|
||||
raw_text_by_hashed_id: HashMap<String, NotebookRawTextStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NotebookRawTextStatus {
|
||||
NotParsed,
|
||||
ParseInFlight(AbortHandle),
|
||||
// We store this as an arc so it can be used in fuzzy searches
|
||||
// without cloning the notebook's entire parsed contents.
|
||||
Parsed(Arc<str>),
|
||||
ParseError,
|
||||
}
|
||||
|
||||
/// Source for a new notebook pane.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NotebookSource {
|
||||
Existing(SyncId),
|
||||
New {
|
||||
title: Option<String>,
|
||||
owner: Owner,
|
||||
initial_folder_id: Option<SyncId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl NotebookManager {
|
||||
/// Create a new [`NotebookManager`] singleton.
|
||||
pub fn new(cached_notebooks: Vec<CloudNotebook>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&UpdateManager::handle(ctx),
|
||||
Self::handle_update_manager_event,
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&CloudModel::handle(ctx), Self::handle_cloud_model_event);
|
||||
|
||||
let mut raw_text_by_hashed_id: HashMap<String, NotebookRawTextStatus> = HashMap::new();
|
||||
// Parse all the cached notebook raw text
|
||||
|
||||
cached_notebooks.into_iter().for_each(|notebook| {
|
||||
let hashed_id = notebook.id.uid();
|
||||
let handle = Self::spawn_raw_text_parse_for_notebook(notebook, ctx);
|
||||
raw_text_by_hashed_id.insert(
|
||||
hashed_id,
|
||||
NotebookRawTextStatus::ParseInFlight(handle.abort_handle()),
|
||||
);
|
||||
});
|
||||
|
||||
Self {
|
||||
panes_by_hashed_id: HashMap::new(),
|
||||
raw_text_by_hashed_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_raw_text_parse_for_notebook(
|
||||
notebook: CloudNotebook,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> SpawnedFutureHandle {
|
||||
let hashed_id = notebook.id.uid();
|
||||
ctx.spawn(
|
||||
async move { parse_markdown_to_raw_text(¬ebook.model().data) },
|
||||
move |manager, response, _ctx| match response {
|
||||
Ok(parsed_text) => {
|
||||
manager.raw_text_by_hashed_id.insert(
|
||||
hashed_id,
|
||||
NotebookRawTextStatus::Parsed(Arc::from(parsed_text)),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
manager
|
||||
.raw_text_by_hashed_id
|
||||
.insert(hashed_id, NotebookRawTextStatus::ParseError);
|
||||
log::error!("Cached Notebook raw text failed to parse: {err}.");
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a mock [`NotebookManager`] for use in tests.
|
||||
#[cfg(test)]
|
||||
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new(Vec::new(), ctx)
|
||||
}
|
||||
|
||||
/// If the notebook is already open in a pane, finds the location of that pane.
|
||||
pub fn find_pane(&self, source: &NotebookSource) -> Option<(WindowId, PaneViewLocator)> {
|
||||
match source {
|
||||
NotebookSource::Existing(notebook_id) => {
|
||||
let pane_data = self.panes_by_hashed_id.get(¬ebook_id.uid())?;
|
||||
Some((pane_data.window_id, pane_data.locator))
|
||||
}
|
||||
NotebookSource::New { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
||||
if let CloudModelEvent::ObjectUpdated { type_and_id, .. } = event {
|
||||
if let Some(notebook_id) = type_and_id.as_notebook_id() {
|
||||
self.update_raw_text_for_notebook(notebook_id, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw text of a given notebook id - if it exists in the cache.
|
||||
pub fn notebook_raw_text(&self, notebook_id: SyncId) -> Option<&str> {
|
||||
match self
|
||||
.raw_text_by_hashed_id
|
||||
.get(¬ebook_id.uid())
|
||||
.unwrap_or(&NotebookRawTextStatus::NotParsed)
|
||||
{
|
||||
NotebookRawTextStatus::Parsed(text) => Some(text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a shared handle to the parsed raw text.
|
||||
pub fn notebook_raw_text_shared(&self, notebook_id: SyncId) -> Option<Arc<str>> {
|
||||
match self
|
||||
.raw_text_by_hashed_id
|
||||
.get(¬ebook_id.uid())
|
||||
.unwrap_or(&NotebookRawTextStatus::NotParsed)
|
||||
{
|
||||
NotebookRawTextStatus::Parsed(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconditionally create a new notebook pane.
|
||||
pub fn create_pane(
|
||||
&mut self,
|
||||
source: &NotebookSource,
|
||||
settings: &OpenWarpDriveObjectSettings,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> NotebookPane {
|
||||
let view = ctx.add_typed_action_view(window_id, NotebookView::new);
|
||||
|
||||
match source {
|
||||
NotebookSource::Existing(notebook_id) => {
|
||||
let notebook = CloudModel::as_ref(ctx).get_notebook(notebook_id).cloned();
|
||||
if let Some(notebook) = notebook {
|
||||
view.update(ctx, |view, ctx| view.load(notebook, settings, ctx));
|
||||
} else {
|
||||
// If the notebook doesn't exist yet, try waiting for initial load and check again
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.wait_for_initial_load_then_load(*notebook_id, settings, window_id, ctx)
|
||||
});
|
||||
}
|
||||
}
|
||||
NotebookSource::New {
|
||||
title,
|
||||
owner,
|
||||
initial_folder_id,
|
||||
} => view.update(ctx, |view, ctx| {
|
||||
view.open_new_notebook(title.clone(), *owner, *initial_folder_id, ctx);
|
||||
}),
|
||||
}
|
||||
|
||||
NotebookPane::new(view, ctx)
|
||||
}
|
||||
|
||||
/// Register an open notebook pane once it's bound to a pane group.
|
||||
pub fn register_pane(
|
||||
&mut self,
|
||||
pane: &NotebookPane,
|
||||
pane_group_id: EntityId,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(notebook_id) = pane.notebook_view(ctx).as_ref(ctx).notebook_id(ctx) else {
|
||||
log::warn!("Notebook pane has no notebook ID");
|
||||
return;
|
||||
};
|
||||
|
||||
let entry = self.panes_by_hashed_id.entry(notebook_id.uid());
|
||||
if let Entry::Vacant(entry) = entry {
|
||||
entry.insert(NotebookPaneData {
|
||||
notebook_id,
|
||||
window_id,
|
||||
locator: PaneViewLocator {
|
||||
pane_group_id,
|
||||
pane_id: pane.id(),
|
||||
},
|
||||
handle: pane.notebook_view(ctx).downgrade(),
|
||||
});
|
||||
} else {
|
||||
safe_warn!(
|
||||
safe: ("Ignoring duplicate notebook pane registration"),
|
||||
full: ("Ignoring duplicate notebook pane registration for {notebook_id}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// De-register an open notebook pane when it's removed from a pane group.
|
||||
pub fn deregister_pane(&mut self, pane: &NotebookPane, ctx: &mut ModelContext<Self>) {
|
||||
let Some(notebook_id) = pane.notebook_view(ctx).as_ref(ctx).notebook_id(ctx) else {
|
||||
log::warn!("Notebook pane has no notebook ID");
|
||||
return;
|
||||
};
|
||||
|
||||
// If a notebook pane is restored, the notebook may have been reopened in the meantime. In
|
||||
// that case, don't let closing the original pane clear out the new pane.
|
||||
if let Entry::Occupied(entry) = self.panes_by_hashed_id.entry(notebook_id.uid()) {
|
||||
if entry.get().locator.pane_id == pane.id() {
|
||||
entry.remove();
|
||||
} else {
|
||||
log::warn!(
|
||||
"Ignoring duplicate registration of panes for {}",
|
||||
notebook_id.uid()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns an async thread to compute the notebook's raw text, adds this
|
||||
/// result to the cache ones the operation has been completed.
|
||||
fn update_raw_text_for_notebook(&mut self, notebook_id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
log::debug!("Updating raw text cache for {}", notebook_id.uid());
|
||||
let Some(notebook) = CloudModel::handle(ctx).read(ctx, |model, _| {
|
||||
Some(model.get_notebook(¬ebook_id)?.clone())
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(NotebookRawTextStatus::ParseInFlight(abort_handle)) =
|
||||
self.raw_text_by_hashed_id.get(¬ebook_id.uid())
|
||||
{
|
||||
// If there's already a parse in flight, abort it
|
||||
abort_handle.abort();
|
||||
}
|
||||
|
||||
let handle = Self::spawn_raw_text_parse_for_notebook(notebook, ctx);
|
||||
|
||||
self.raw_text_by_hashed_id.insert(
|
||||
notebook_id.uid(),
|
||||
NotebookRawTextStatus::ParseInFlight(handle.abort_handle()),
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
event: &UpdateManagerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !matches!(&result.success_type, OperationSuccessType::Success) {
|
||||
return;
|
||||
}
|
||||
if let ObjectOperation::Create { .. } = result.operation {
|
||||
let server_id = result.server_id.expect("Expect server id on success");
|
||||
let Some(server_id) = CloudModel::as_ref(ctx)
|
||||
.get_notebook_by_uid(&server_id.uid())
|
||||
.and_then(|notebook| notebook.id.into_server())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(client_id) = result.client_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(mut pane) = self.panes_by_hashed_id.remove(&client_id.to_string()) {
|
||||
pane.notebook_id = SyncId::ServerId(server_id);
|
||||
self.panes_by_hashed_id
|
||||
.insert(server_id.uid().clone(), pane);
|
||||
}
|
||||
if let Some(parse_status) = self.raw_text_by_hashed_id.remove(&client_id.to_string()) {
|
||||
self.raw_text_by_hashed_id
|
||||
.insert(server_id.uid(), parse_status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the ID of the notebook open in a pane. This assumes the pane location and view are
|
||||
/// unchanged.
|
||||
pub(super) fn swap_notebook(&mut self, old_id: SyncId, new_id: SyncId) {
|
||||
if let Some(mut pane_data) = self.panes_by_hashed_id.remove(&old_id.uid()) {
|
||||
debug_assert_eq!(pane_data.notebook_id, old_id);
|
||||
pane_data.notebook_id = new_id;
|
||||
debug_assert!(
|
||||
self.panes_by_hashed_id
|
||||
.insert(new_id.uid(), pane_data)
|
||||
.is_none(),
|
||||
"New notebook was already open"
|
||||
);
|
||||
} else {
|
||||
log::warn!("Tried to swap notebooks, but the old one was not open");
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all open notebooks, saving any changes. This is called before the app terminates to
|
||||
/// prevent data loss, since notebooks are not saved immediately after every user edit.
|
||||
pub fn close_notebooks(&self, ctx: &mut ModelContext<Self>) {
|
||||
for pane in self.panes_by_hashed_id.values() {
|
||||
if let Some(notebook_view) = pane.handle.upgrade(ctx) {
|
||||
safe_debug!(
|
||||
safe : ("Closing notebook on termination"),
|
||||
full: ("Closing notebook {} on termination", pane.notebook_id)
|
||||
);
|
||||
notebook_view.update(ctx, |view, ctx| view.on_detach(ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the notebook manager state for logout.
|
||||
///
|
||||
/// This _does not_ save any pending notebook changes.
|
||||
pub fn reset(&mut self) {
|
||||
self.panes_by_hashed_id.clear();
|
||||
for (_, status) in self.raw_text_by_hashed_id.drain() {
|
||||
if let NotebookRawTextStatus::ParseInFlight(handle) = status {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotebookPaneData {
|
||||
notebook_id: SyncId,
|
||||
window_id: WindowId,
|
||||
handle: WeakViewHandle<NotebookView>,
|
||||
locator: PaneViewLocator,
|
||||
}
|
||||
|
||||
impl Entity for NotebookManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for NotebookManager {}
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
platform::WindowStyle, AddSingletonModel, App, EntityId, ModelHandle, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::BlocklistAIHistoryModel,
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
cloud_object::{
|
||||
model::{actions::ObjectActions, persistence::CloudModel, view::CloudViewModel},
|
||||
Owner,
|
||||
},
|
||||
network::NetworkStatus,
|
||||
notebooks::{editor::keys::NotebookKeybindings, notebook::NotebookView},
|
||||
pane_group::NotebookPane,
|
||||
persistence::ModelEvent,
|
||||
search::files::model::FileSearchModel,
|
||||
server::{
|
||||
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
|
||||
sync_queue::SyncQueue, telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
settings::PrivacySettings,
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::{
|
||||
keys::TerminalKeybindings, shared_session::permissions_manager::SessionPermissionsManager,
|
||||
},
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
use super::NotebookManager;
|
||||
|
||||
struct TestState {
|
||||
manager: ModelHandle<NotebookManager>,
|
||||
model_events: mpsc::Receiver<ModelEvent>,
|
||||
}
|
||||
|
||||
impl TestState {
|
||||
/// Add a notebook view, configured by `init`, and register it with the [`NotebookManager`].
|
||||
fn add_notebook<F>(&self, app: &mut App, init: F) -> ViewHandle<NotebookView>
|
||||
where
|
||||
F: FnOnce(&mut NotebookView, &mut ViewContext<NotebookView>),
|
||||
{
|
||||
let (window, notebook) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let mut view = NotebookView::new(ctx);
|
||||
init(&mut view, ctx);
|
||||
view
|
||||
});
|
||||
|
||||
self.manager.update(app, |manager, ctx| {
|
||||
let pane = NotebookPane::new(notebook.clone(), ctx);
|
||||
manager.register_pane(&pane, EntityId::new(), window, ctx)
|
||||
});
|
||||
|
||||
notebook
|
||||
}
|
||||
|
||||
/// All model events not yet received.
|
||||
fn model_events(&self) -> Vec<ModelEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.model_events.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut App) -> TestState {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resources = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(|_| UserProfiles::new(vec![]));
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default());
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(repo_metadata::RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(SessionPermissionsManager::new);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
#[cfg(feature = "voice_input")]
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
|
||||
let (sender, receiver) = mpsc::sync_channel(10);
|
||||
let objects_client = ServerApiProvider::new_for_test().get_cloud_objects_client();
|
||||
let sync_queue = app
|
||||
.add_singleton_model(|ctx| SyncQueue::new(Default::default(), objects_client.clone(), ctx));
|
||||
app.add_singleton_model(|ctx| UpdateManager::new(Some(sender), objects_client.clone(), ctx));
|
||||
sync_queue.update(app, |queue, ctx| queue.start_dequeueing(ctx));
|
||||
|
||||
app.add_singleton_model(CloudViewModel::mock);
|
||||
let manager = app.add_singleton_model(NotebookManager::mock);
|
||||
TestState {
|
||||
manager,
|
||||
model_events: receiver,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_on_close() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_app(&mut app);
|
||||
let notebook = state.add_notebook(&mut app, |view, ctx| {
|
||||
view.open_new_notebook(
|
||||
Some("Test Notebook".to_string()),
|
||||
Owner::mock_current_user(),
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Ensure the notebook has a pending edit.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.input_editor().update(ctx, |editor, ctx| {
|
||||
editor.user_typed("Hello", ctx);
|
||||
});
|
||||
});
|
||||
|
||||
// There will be an initial model event to save the notebook.
|
||||
let events = state.model_events();
|
||||
assert_eq!(events.len(), 1, "Expected 1 event, got {events:?}");
|
||||
|
||||
// Closing the notebook manager should trigger a save.
|
||||
state
|
||||
.manager
|
||||
.update(&mut app, |manager, ctx| manager.close_notebooks(ctx));
|
||||
|
||||
// There should now be a pending model event to save the notebook.
|
||||
let events = state.model_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
ModelEvent::UpsertNotebook { notebook } => {
|
||||
assert_eq!(notebook.model().title, "Test Notebook");
|
||||
assert_eq!(notebook.model().data, "Hello");
|
||||
}
|
||||
other => panic!("Expected an UpsertNotebook event, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
pub mod active_notebook_data;
|
||||
mod context_menu;
|
||||
pub mod editor;
|
||||
pub mod file;
|
||||
pub mod link;
|
||||
pub mod manager;
|
||||
pub mod notebook;
|
||||
mod styles;
|
||||
pub mod telemetry;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use anyhow::Result;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warpui::AppContext;
|
||||
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::{
|
||||
ai::document::ai_document_model::AIDocumentId,
|
||||
appearance::Appearance,
|
||||
cloud_object::{
|
||||
CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
|
||||
GenericCloudObject, GenericServerObject, ObjectType, Owner, Revision, ServerCloudObject,
|
||||
UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{
|
||||
items::{notebook::WarpDriveNotebook, WarpDriveItem},
|
||||
CloudObjectTypeAndId,
|
||||
},
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
ids::{ServerId, SyncId},
|
||||
server_api::object::ObjectClient,
|
||||
sync_queue::{QueueItem, SerializedModel},
|
||||
},
|
||||
};
|
||||
|
||||
/// Serialized representation of a notebook for sync queue
|
||||
/// The AIDocumentID and ConversationID are stored here to avoid polluting the
|
||||
/// generic CreateObjectRequest type.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct SerializedNotebook {
|
||||
pub(crate) data: String,
|
||||
pub(crate) ai_document_id: Option<String>,
|
||||
pub(crate) conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `CloudNotebook` is a notebook retrieved from the server.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct CloudNotebookModel {
|
||||
pub title: String,
|
||||
pub data: String,
|
||||
pub ai_document_id: Option<AIDocumentId>,
|
||||
/// This is the server-generated conversation token, not the client-side AIConversationId.
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
pub type CloudNotebook = GenericCloudObject<NotebookId, CloudNotebookModel>;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl CloudModelType for CloudNotebookModel {
|
||||
type CloudObjectType = CloudNotebook;
|
||||
type IdType = NotebookId;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
if self.ai_document_id.is_some() {
|
||||
"Plan"
|
||||
} else {
|
||||
"Notebook"
|
||||
}
|
||||
}
|
||||
|
||||
fn object_type(&self) -> ObjectType {
|
||||
ObjectType::Notebook
|
||||
}
|
||||
|
||||
fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId {
|
||||
CloudObjectTypeAndId::Notebook(id)
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn set_display_name(&mut self, name: &str) {
|
||||
name.clone_into(&mut self.title);
|
||||
}
|
||||
|
||||
fn upsert_event(&self, notebook: &CloudNotebook) -> ModelEvent {
|
||||
ModelEvent::UpsertNotebook {
|
||||
notebook: notebook.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_upsert_event(objects: &[CloudNotebook]) -> ModelEvent {
|
||||
ModelEvent::UpsertNotebooks(objects.to_vec())
|
||||
}
|
||||
|
||||
fn create_object_queue_item(
|
||||
&self,
|
||||
notebook: &CloudNotebook,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
initiated_by: InitiatedBy,
|
||||
) -> Option<QueueItem> {
|
||||
if let SyncId::ClientId(client_id) = notebook.id {
|
||||
let title = Some(notebook.model().display_name())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(Arc::new);
|
||||
|
||||
let serialized_model = Some(Arc::new(notebook.model().serialized()));
|
||||
|
||||
return Some(QueueItem::CreateObject {
|
||||
object_type: self.object_type(),
|
||||
owner: notebook.permissions.owner,
|
||||
id: client_id,
|
||||
title,
|
||||
serialized_model,
|
||||
initial_folder_id: notebook.metadata.folder_id,
|
||||
entrypoint,
|
||||
initiated_by,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
notebook: &CloudNotebook,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateNotebook {
|
||||
// Note that this is intentionally a deep clone of the model because we are grabbing
|
||||
// a snapshot to update at a moment in time.
|
||||
model: notebook.model().clone().into(),
|
||||
id: notebook.id,
|
||||
revision: revision_ts.or_else(|| notebook.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_update_after_server_conflict(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::Notebook(server_notebook) = server_cloud_object {
|
||||
return Some(CloudNotebookModel {
|
||||
title: server_notebook.model.title.clone(),
|
||||
data: server_notebook.model.data.clone(),
|
||||
ai_document_id: server_notebook.model.ai_document_id,
|
||||
conversation_id: None, // conversation_id is not returned from server, just used for initial plan artifact creation
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn serialized(&self) -> SerializedModel {
|
||||
let serialized = SerializedNotebook {
|
||||
data: self.data.clone(),
|
||||
ai_document_id: self.ai_document_id.as_ref().map(|id| id.to_string()),
|
||||
conversation_id: self.conversation_id.clone(),
|
||||
};
|
||||
let json = serde_json::to_string(&serialized).expect("Failed to serialize notebook");
|
||||
SerializedModel::new(json)
|
||||
}
|
||||
|
||||
async fn send_create_request(
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
request: CreateObjectRequest,
|
||||
) -> Result<CreateCloudObjectResult> {
|
||||
object_client.create_notebook(request).await
|
||||
}
|
||||
|
||||
async fn send_update_request(
|
||||
&self,
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
server_id: ServerId,
|
||||
revision: Option<Revision>,
|
||||
) -> Result<UpdateCloudObjectResult<GenericServerObject<NotebookId, Self>>> {
|
||||
object_client
|
||||
.update_notebook(
|
||||
server_id.into(),
|
||||
Some(self.title.clone()),
|
||||
Some(self.data.clone().into()),
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn can_export(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
id: SyncId,
|
||||
_appearance: &Appearance,
|
||||
notebook: &CloudNotebook,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
Some(Box::new(WarpDriveNotebook::new(
|
||||
self.cloud_object_type_and_id(id),
|
||||
notebook.clone(),
|
||||
notebook.model().ai_document_id.is_some(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the notebook_id in the database associated with this notebook.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct NotebookId(ServerId);
|
||||
crate::server_id_traits! { NotebookId, "Notebook" }
|
||||
|
||||
impl From<NotebookId> for SyncId {
|
||||
fn from(id: NotebookId) -> Self {
|
||||
Self::ServerId(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A notebook location. Mainly, this lets us distinguish between cloud and file-based notebooks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum NotebookLocation {
|
||||
/// A cloud notebook in the user's personal space.
|
||||
PersonalCloud,
|
||||
/// A cloud notebook in a team space.
|
||||
Team,
|
||||
/// A notebook backed by a local file.
|
||||
LocalFile,
|
||||
/// A notebook backed by a remote file.
|
||||
RemoteFile,
|
||||
}
|
||||
|
||||
impl From<Owner> for NotebookLocation {
|
||||
fn from(owner: Owner) -> Self {
|
||||
// TODO(ben): Account for shared objects in notebook telemetry.
|
||||
match owner {
|
||||
Owner::User { .. } => NotebookLocation::PersonalCloud,
|
||||
Owner::Team { .. } => NotebookLocation::Team,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize notebooks-related keybindings.
|
||||
pub fn init(app: &mut AppContext) {
|
||||
self::notebook::init(app);
|
||||
self::file::init(app);
|
||||
self::editor::view::init(app);
|
||||
}
|
||||
|
||||
/// Post process a notebook's content read from an external system. This cleans up extra
|
||||
/// whitespace, and, in the future, may filter out unsupported syntax extensions.
|
||||
///
|
||||
/// See CLD-944.
|
||||
pub fn post_process_notebook(data: &str) -> String {
|
||||
// TODO(kevin): We should not strip out newlines in the code block.
|
||||
data.lines().filter(|line| !line.is_empty()).join("\n")
|
||||
}
|
||||
|
||||
/// Translate a notebook's Markdown content into an external Markdown format.
|
||||
///
|
||||
/// This:
|
||||
/// * Normalizes code block languages
|
||||
/// * Includes extra context for embedded objects.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn export_notebook(data: &str, ctx: &AppContext) -> anyhow::Result<String> {
|
||||
use warp_editor::content::{buffer::Buffer, markdown::MarkdownStyle};
|
||||
|
||||
// Parse the Markdown directly rather than using [`Buffer::from_markdown`] so that we can
|
||||
// report errors to the exporter.
|
||||
let parsed = markdown_parser::parse_markdown(data)?;
|
||||
Ok(Buffer::export_to_markdown(
|
||||
parsed,
|
||||
Some(editor::notebook_embedded_item_conversion),
|
||||
MarkdownStyle::Export {
|
||||
app_context: Some(ctx),
|
||||
should_not_escape_markdown_punctuation: false,
|
||||
},
|
||||
))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
//! Components for the notebook header.
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Container, CrossAxisAlignment, Flex, Highlight, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{
|
||||
breadcrumbs::ContainingObject,
|
||||
model::view::{Editor, EditorState},
|
||||
},
|
||||
drive::sharing::ContentEditability,
|
||||
notebooks::{active_notebook_data::Mode, styles},
|
||||
ui_components::{
|
||||
breadcrumb::{render_breadcrumbs, BreadcrumbState},
|
||||
buttons::{accent_icon_button, icon_button},
|
||||
icons::Icon,
|
||||
},
|
||||
workspaces::user_profiles::UserProfiles,
|
||||
};
|
||||
|
||||
use super::{super::active_notebook_data::ActiveNotebookData, NotebookAction, EDIT_BUTTON_MARGIN};
|
||||
|
||||
/// Component to show details about a notebook:
|
||||
/// * Interactive breadcrumbs for its location within Warp Drive
|
||||
/// * The current editor of the notebook
|
||||
/// * Grab-the-baton UI controls
|
||||
pub struct DetailsBar {
|
||||
breadcrumbs: Vec<BreadcrumbState<ContainingObject>>,
|
||||
edit_mode_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl DetailsBar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
breadcrumbs: Vec::new(),
|
||||
edit_mode_button_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the cached breadcrumbs in the notebook header.
|
||||
pub fn update_breadcrumbs(&mut self, notebook_data: &ActiveNotebookData, ctx: &AppContext) {
|
||||
self.breadcrumbs = notebook_data
|
||||
.breadcrumbs(ctx)
|
||||
.map(|breadcrumbs| breadcrumbs.into_iter().map(BreadcrumbState::new).collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
notebook_data: &ActiveNotebookData,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut header_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
header_row.add_child(
|
||||
Shrinkable::new(
|
||||
2.,
|
||||
render_breadcrumbs(
|
||||
self.breadcrumbs.iter().cloned(),
|
||||
appearance,
|
||||
|ctx, _, breadcrumb| {
|
||||
ctx.dispatch_typed_action(NotebookAction::ViewInWarpDrive(
|
||||
breadcrumb.kind.into_item_id(),
|
||||
));
|
||||
},
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut editing_state_row = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
if let Some(editor) = notebook_data.current_editor(app) {
|
||||
editing_state_row.add_child(
|
||||
Shrinkable::new(1., self.render_editor(&editor, appearance, app)).finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let editability = if FeatureFlag::SharedWithMe.is_enabled() {
|
||||
notebook_data.editability(app)
|
||||
} else {
|
||||
ContentEditability::Editable
|
||||
};
|
||||
if matches!(
|
||||
editability,
|
||||
ContentEditability::RequiresLogin | ContentEditability::Editable
|
||||
) {
|
||||
editing_state_row.add_child(self.render_mode_toggle(
|
||||
notebook_data.mode,
|
||||
editability,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
header_row.add_child(Shrinkable::new(1., editing_state_row.finish()).finish());
|
||||
|
||||
header_row.finish()
|
||||
}
|
||||
|
||||
/// Renders a toggle button for the editing mode.
|
||||
fn render_mode_toggle(
|
||||
&self,
|
||||
mode: Mode,
|
||||
editability: ContentEditability,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut edit_button = match mode {
|
||||
Mode::View => icon_button(
|
||||
appearance,
|
||||
Icon::Pencil,
|
||||
false,
|
||||
self.edit_mode_button_mouse_state.clone(),
|
||||
),
|
||||
Mode::Editing => accent_icon_button(
|
||||
appearance,
|
||||
Icon::Pencil,
|
||||
false,
|
||||
self.edit_mode_button_mouse_state.clone(),
|
||||
),
|
||||
};
|
||||
|
||||
if matches!(editability, ContentEditability::RequiresLogin) {
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
edit_button = edit_button.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Sign in to edit".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
});
|
||||
}
|
||||
|
||||
Container::new(
|
||||
edit_button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
if editability.can_edit() {
|
||||
ctx.dispatch_typed_action(NotebookAction::ToggleMode)
|
||||
}
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(EDIT_BUTTON_MARGIN)
|
||||
.with_margin_right(EDIT_BUTTON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a label for the current editor.
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Editor,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let base_text_styles = UiComponentStyles {
|
||||
font_color: Some(styles::title_text_fill(appearance).into_solid()),
|
||||
..Default::default()
|
||||
};
|
||||
let theme = appearance.theme();
|
||||
match editor.state {
|
||||
EditorState::None => appearance
|
||||
.ui_builder()
|
||||
.span("Viewing")
|
||||
.with_style(base_text_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
EditorState::CurrentUser => appearance
|
||||
.ui_builder()
|
||||
.span("Editing")
|
||||
.with_style(base_text_styles)
|
||||
.build()
|
||||
.finish(),
|
||||
EditorState::OtherUserActive | EditorState::OtherUserIdle => {
|
||||
let editor = editor_display_name(editor.email.as_deref(), app);
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(format!("{editor} is editing"))
|
||||
.with_style(base_text_styles)
|
||||
.with_highlights(
|
||||
(0..editor.chars().count()).collect(),
|
||||
Highlight::new().with_foreground_color(
|
||||
theme.main_text_color(theme.background()).into_solid(),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display name for an editor.
|
||||
fn editor_display_name(email: Option<&str>, app: &AppContext) -> String {
|
||||
match email {
|
||||
Some(email) => UserProfiles::as_ref(app)
|
||||
.displayable_identifier_for_email(email)
|
||||
.unwrap_or_else(|| email.to_string()),
|
||||
None => "Other user".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "details_bar_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
workspaces::user_profiles::{UserProfileWithUID, UserProfiles},
|
||||
};
|
||||
|
||||
use super::editor_display_name;
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
app.add_singleton_model(|_| UserProfiles::new(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_display_name() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
UserProfiles::handle(&app).update(&mut app, |profiles, _ctx| {
|
||||
profiles.insert_profiles(&vec![
|
||||
UserProfileWithUID {
|
||||
firebase_uid: UserUid::new("abc123"),
|
||||
display_name: Some("The Editor".to_string()),
|
||||
email: "editor@warp.dev".to_string(),
|
||||
photo_url: "http://example.com/profile.jpg".to_string(),
|
||||
},
|
||||
UserProfileWithUID {
|
||||
firebase_uid: UserUid::new("def456"),
|
||||
display_name: None,
|
||||
email: "anon@warp.dev".to_string(),
|
||||
photo_url: "http://example.com/profile.jpg".to_string(),
|
||||
},
|
||||
])
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
// If there's no known editor, default to "Other user";
|
||||
assert_eq!(&editor_display_name(None, ctx), "Other user");
|
||||
|
||||
// If the editor doesn't have a profile, default to their email.
|
||||
assert_eq!(
|
||||
&editor_display_name(Some("unknown@warp.dev"), ctx),
|
||||
"unknown@warp.dev"
|
||||
);
|
||||
|
||||
// If the profile is missing a display name, default to the email.
|
||||
assert_eq!(
|
||||
&editor_display_name(Some("anon@warp.dev"), ctx),
|
||||
"anon@warp.dev"
|
||||
);
|
||||
|
||||
// If there's a display name available, use that.
|
||||
assert_eq!(
|
||||
&editor_display_name(Some("editor@warp.dev"), ctx),
|
||||
"The Editor"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use futures_util::future::BoxFuture;
|
||||
use itertools::Itertools;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::editor::EditorView;
|
||||
use warpui::{
|
||||
platform::WindowStyle, presenter::ChildView, r#async::Timer, telemetry::EventPayload,
|
||||
AddSingletonModel, App, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
auth_manager::AuthManager,
|
||||
user::{TEST_USER_EMAIL, TEST_USER_UID},
|
||||
AuthStateProvider, UserUid,
|
||||
},
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::ObjectActions,
|
||||
persistence::CloudModel,
|
||||
view::{CloudViewModel, Editor, EditorState},
|
||||
},
|
||||
Owner, Revision, ServerCloudObject, ServerMetadata, ServerNotebook, ServerPermissions,
|
||||
},
|
||||
drive::OpenWarpDriveObjectSettings,
|
||||
editor::{DisplayPoint, EditorAction, InteractionState, SelectAction},
|
||||
network::NetworkStatus,
|
||||
notebooks::{
|
||||
active_notebook_data::Mode,
|
||||
editor::{
|
||||
keys::NotebookKeybindings, notebook_command::NotebookCommand, view::EditorViewAction,
|
||||
},
|
||||
notebook::FocusedComponent,
|
||||
CloudNotebook, CloudNotebookModel, NotebookLocation,
|
||||
},
|
||||
pane_group::PaneEvent,
|
||||
search::files::model::FileSearchModel,
|
||||
server::{
|
||||
cloud_objects::update_manager::{InitialLoadResponse, UpdateManager},
|
||||
ids::{ClientId, SyncId::ServerId},
|
||||
server_api::ServerApiProvider,
|
||||
sync_queue::{QueueItem, SyncQueue, SyncQueueEvent},
|
||||
telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::keys::TerminalKeybindings,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workflows::{workflow::Workflow, WorkflowSource, WorkflowType},
|
||||
workspace::ActiveSession,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus,
|
||||
user_profiles::{UserProfileWithUID, UserProfiles},
|
||||
user_workspaces::UserWorkspaces,
|
||||
},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider, PrivacySettings,
|
||||
};
|
||||
|
||||
use super::{NotebookEvent, NotebookView, EDIT_WINDOW_DURATION, SAVE_PERIOD};
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resources = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default());
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(repo_metadata::RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(CloudViewModel::mock);
|
||||
app.add_singleton_model(|_| UserProfiles::new(vec![]));
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
#[cfg(feature = "voice_input")]
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
}
|
||||
|
||||
/// Container so that [`NotebookView`] can be registered as a typed action view.
|
||||
struct Root {
|
||||
notebook: ViewHandle<NotebookView>,
|
||||
events: Vec<NotebookEvent>,
|
||||
}
|
||||
|
||||
impl Entity for Root {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for Root {
|
||||
fn ui_name() -> &'static str {
|
||||
"Root"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.notebook).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for Root {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
fn create_notebook(app: &mut App) -> (WindowId, ViewHandle<NotebookView>, ViewHandle<Root>) {
|
||||
let (window, root) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let notebook = ctx.add_typed_action_view(NotebookView::new);
|
||||
|
||||
ctx.subscribe_to_view(¬ebook, |me: &mut Root, _, event, _| {
|
||||
me.events.push(event.clone())
|
||||
});
|
||||
|
||||
Root {
|
||||
notebook,
|
||||
events: Vec::new(),
|
||||
}
|
||||
});
|
||||
let notebook = app.read(|ctx| root.as_ref(ctx).notebook.clone());
|
||||
(window, notebook, root)
|
||||
}
|
||||
|
||||
/// Opens a notebook in the given view.
|
||||
fn open_notebook(
|
||||
app: &mut App,
|
||||
handle: &ViewHandle<NotebookView>,
|
||||
notebook: CloudNotebook,
|
||||
) -> BoxFuture<'static, ()> {
|
||||
let load_future = handle.update(app, |view, ctx| {
|
||||
view.load(notebook, &OpenWarpDriveObjectSettings::default(), ctx)
|
||||
});
|
||||
app.update(|ctx| ctx.await_spawned_future(load_future.future_id()))
|
||||
}
|
||||
|
||||
fn cloud_notebook(title: impl Into<String>, data: impl Into<String>) -> CloudNotebook {
|
||||
CloudNotebook::new_local(
|
||||
CloudNotebookModel {
|
||||
title: title.into(),
|
||||
data: data.into(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
Owner::mock_current_user(),
|
||||
None,
|
||||
ClientId::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Mock a server notebook
|
||||
fn mock_server_notebook(title: impl Into<String>, data: impl Into<String>) -> ServerNotebook {
|
||||
let metadata_ts = Utc::now().into();
|
||||
ServerNotebook {
|
||||
id: ServerId(123.into()),
|
||||
model: CloudNotebookModel {
|
||||
title: title.into(),
|
||||
data: data.into(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
metadata: ServerMetadata {
|
||||
uid: 123.into(),
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: metadata_ts,
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: metadata_ts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Send changed objects to [`UpdateManager`] so that tests requiring "up-to-date" metadata can run.
|
||||
async fn initial_load(app: &mut App, updated_notebooks: impl Into<Vec<ServerNotebook>>) {
|
||||
let response = InitialLoadResponse {
|
||||
updated_notebooks: updated_notebooks.into(),
|
||||
deleted_notebooks: Default::default(),
|
||||
updated_workflows: Default::default(),
|
||||
deleted_workflows: Default::default(),
|
||||
updated_folders: Default::default(),
|
||||
deleted_folders: Default::default(),
|
||||
user_profiles: Default::default(),
|
||||
updated_generic_string_objects: Default::default(),
|
||||
deleted_generic_string_objects: Default::default(),
|
||||
action_histories: Default::default(),
|
||||
mcp_gallery: Default::default(),
|
||||
};
|
||||
|
||||
let load_complete = UpdateManager::handle(app).update(app, |update_manager, ctx| {
|
||||
update_manager.mock_initial_load(response, ctx);
|
||||
update_manager.initial_load_complete()
|
||||
});
|
||||
load_complete.await
|
||||
}
|
||||
|
||||
/// Wait for all edits to be saved.
|
||||
async fn ensure_saved(app: &mut App, notebook_view: &ViewHandle<NotebookView>) {
|
||||
loop {
|
||||
let has_edits = notebook_view.read(app, |notebook, _| {
|
||||
notebook.content_is_dirty || notebook.title_is_dirty
|
||||
});
|
||||
if has_edits {
|
||||
Timer::after(SAVE_PERIOD).await;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that any updates from the debounced save were processed.
|
||||
app.update(|_| ());
|
||||
}
|
||||
|
||||
/// Test that command-block execution events are correctly translated into workflows.
|
||||
#[test]
|
||||
fn test_command_block_dispatches_event() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, []).await;
|
||||
|
||||
let (window, notebook, root) = create_notebook(&mut app);
|
||||
open_notebook(
|
||||
&mut app,
|
||||
¬ebook,
|
||||
cloud_notebook(
|
||||
"Test Notebook",
|
||||
r#"A command:
|
||||
```
|
||||
echo hello
|
||||
```
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// First, make sure the editor is focused.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.focus_input(ctx);
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
let input = ¬ebook.as_ref(ctx).input;
|
||||
let command = input
|
||||
.as_ref(ctx)
|
||||
.runnable_command_at(11.into(), ctx)
|
||||
.expect("Command should exist")
|
||||
.as_any()
|
||||
.downcast_ref::<NotebookCommand>()
|
||||
.expect("Should convert");
|
||||
|
||||
// Use the command's own to_workflow implementation to use as much of the real code
|
||||
// path as possible.
|
||||
let workflow = command
|
||||
.to_workflow(ctx)
|
||||
.expect("Can't convert command to a workflow");
|
||||
|
||||
ctx.dispatch_typed_action_for_view(
|
||||
window,
|
||||
input.id(),
|
||||
&EditorViewAction::RunWorkflow(workflow),
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let events = &root.as_ref(ctx).events;
|
||||
assert!(
|
||||
events.contains(&NotebookEvent::RunWorkflow {
|
||||
workflow: Arc::new(WorkflowType::Notebook(Workflow::new(
|
||||
"Command from Test Notebook",
|
||||
"echo hello"
|
||||
))),
|
||||
source: WorkflowSource::Notebook {
|
||||
notebook_id: None,
|
||||
team_uid: None,
|
||||
location: NotebookLocation::PersonalCloud,
|
||||
},
|
||||
}),
|
||||
"No RunWorkflow event in {events:#?}"
|
||||
);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus_tracking() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, []).await;
|
||||
|
||||
let (window, notebook, root) = create_notebook(&mut app);
|
||||
open_notebook(
|
||||
&mut app,
|
||||
¬ebook,
|
||||
cloud_notebook("Test Notebook", "This is a notebook"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (title_view, input_view) = notebook.read(&app, |notebook, _| {
|
||||
(notebook.title.clone(), notebook.input.clone())
|
||||
});
|
||||
|
||||
// Focus the title editor by selecting.
|
||||
app.update(|ctx| {
|
||||
ctx.dispatch_typed_action_for_view(
|
||||
window,
|
||||
title_view.id(),
|
||||
&EditorAction::Select(SelectAction::begin(DisplayPoint::new(0, 4))),
|
||||
);
|
||||
});
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
notebook.as_ref(ctx).last_focused_component,
|
||||
FocusedComponent::Title
|
||||
);
|
||||
|
||||
let events = &root.as_ref(ctx).events;
|
||||
assert_eq!(
|
||||
events,
|
||||
&[
|
||||
// This is from focusing the title editor.
|
||||
NotebookEvent::Pane(PaneEvent::FocusSelf)
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// When blurring the notebook and restoring focus, focus should go to the title editor.
|
||||
root.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
notebook.update(&mut app, |notebook, ctx| notebook.focus(ctx));
|
||||
assert_eq!(app.focused_view_id(window), Some(title_view.id()));
|
||||
app.read(|ctx| {
|
||||
let events = &root.as_ref(ctx).events;
|
||||
assert_eq!(
|
||||
events,
|
||||
&[
|
||||
// This is the prior focus event.
|
||||
NotebookEvent::Pane(PaneEvent::FocusSelf),
|
||||
// This is from focusing the title editor again.
|
||||
NotebookEvent::Pane(PaneEvent::FocusSelf)
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Focus the input view, which should emit a focused event.
|
||||
input_view.update(&mut app, |view, ctx| view.focus(ctx));
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
notebook.as_ref(ctx).last_focused_component,
|
||||
FocusedComponent::Input
|
||||
);
|
||||
|
||||
let events = &root.as_ref(ctx).events;
|
||||
assert_eq!(
|
||||
events,
|
||||
&[
|
||||
// These are prior events.
|
||||
NotebookEvent::Pane(PaneEvent::FocusSelf),
|
||||
NotebookEvent::Pane(PaneEvent::FocusSelf),
|
||||
// This is from focusing the input editor.
|
||||
NotebookEvent::Pane(PaneEvent::FocusSelf),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Now, focus should be restored to the input editor.
|
||||
root.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
notebook.update(&mut app, |notebook, ctx| notebook.focus(ctx));
|
||||
assert_eq!(app.focused_view_id(window), Some(input_view.id()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_edit_telemetry() {
|
||||
fn edit_events() -> Vec<serde_json::Value> {
|
||||
warpui::telemetry::flush_events()
|
||||
.into_iter()
|
||||
.filter_map(|event| match event.payload {
|
||||
EventPayload::NamedEvent { name, value, .. } if name == "Notebook Edited" => value,
|
||||
_ => None,
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, []).await;
|
||||
|
||||
let (_, notebook, _) = create_notebook(&mut app);
|
||||
open_notebook(
|
||||
&mut app,
|
||||
¬ebook,
|
||||
cloud_notebook("Test Notebook", "This is a notebook"),
|
||||
)
|
||||
.await;
|
||||
let input_view = notebook.read(&app, |notebook, _| notebook.input.clone());
|
||||
|
||||
// The notebook should show in edit mode, with telemetry recording.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.grab_edit_access(true, ctx);
|
||||
assert_eq!(
|
||||
notebook.active_notebook_data.as_ref(ctx).mode,
|
||||
Mode::Editing
|
||||
);
|
||||
assert!(notebook.edit_telemetry_handle.is_some());
|
||||
notebook.focus_input(ctx);
|
||||
});
|
||||
|
||||
// With no edits, there are no events.
|
||||
ensure_saved(&mut app, ¬ebook).await;
|
||||
Timer::after(2 * EDIT_WINDOW_DURATION).await;
|
||||
assert!(edit_events().is_empty());
|
||||
|
||||
// Make a small edit, which should get reported as non-meaningful.
|
||||
input_view.update(&mut app, |input, ctx| {
|
||||
input.user_typed("Hi", ctx);
|
||||
});
|
||||
|
||||
ensure_saved(&mut app, ¬ebook).await;
|
||||
Timer::after(2 * EDIT_WINDOW_DURATION).await;
|
||||
assert_eq!(
|
||||
edit_events(),
|
||||
vec![serde_json::json!({
|
||||
"notebook_id": None::<()>,
|
||||
"meaningful_change": false,
|
||||
})]
|
||||
);
|
||||
|
||||
// If we switch to view mode, we stop recording elemetry.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.switch_to_view(ctx);
|
||||
assert!(notebook.edit_telemetry_handle.is_none());
|
||||
});
|
||||
|
||||
// Telemetry resumes when we switch to editing.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.switch_to_edit(ctx);
|
||||
notebook.focus_input(ctx);
|
||||
assert!(notebook.edit_telemetry_handle.is_some());
|
||||
});
|
||||
|
||||
// Finally, a meaningful edit is recorded as such.
|
||||
input_view.update(&mut app, |input, ctx| {
|
||||
input.user_typed(
|
||||
"This is a very very very very long edit. This is a heavy notebook user.",
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
ensure_saved(&mut app, ¬ebook).await;
|
||||
Timer::after(2 * EDIT_WINDOW_DURATION).await;
|
||||
assert_eq!(
|
||||
edit_events(),
|
||||
vec![serde_json::json!({
|
||||
"notebook_id": None::<()>,
|
||||
"meaningful_change": true,
|
||||
})]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we eagerly enter edit mode when user is already the current editor
|
||||
#[test]
|
||||
fn test_eager_baton_grab_same_current_editor() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
// Complete the initial load so that grab-the-baton behavior applies.
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
let mut cloud_notebook = cloud_notebook("Test Notebook", r#"A notebook"#);
|
||||
|
||||
// Set the current editor of the notebook to be the test notebook
|
||||
cloud_notebook.metadata.current_editor_uid = Some(TEST_USER_UID.to_string().clone());
|
||||
|
||||
// Add the notebook to cloud model
|
||||
CloudModel::handle(&app).update(&mut app, |model, _| {
|
||||
model.add_object(cloud_notebook.id, cloud_notebook.clone())
|
||||
});
|
||||
|
||||
// Open the notebook
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// Assert that the editor is the current editor from the test user email
|
||||
notebook_view.update(&mut app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::CurrentUser,
|
||||
email: Some(TEST_USER_EMAIL.to_string())
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
let mode = notebook_view.read(&app, |notebook, ctx| notebook.mode(ctx));
|
||||
// Assert that we are in edit mode open since the editor is the current editor
|
||||
assert_eq!(mode, Mode::Editing);
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we do not eagerly enter edit mode when there is another editor
|
||||
#[test]
|
||||
fn test_not_eager_baton_grab_different_editor() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
// Complete the initial load so that grab-the-baton behavior applies.
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
let uid = "ian@warp.dev".to_string();
|
||||
let email = "ian@warp.dev".to_string();
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
let mut cloud_notebook = cloud_notebook("Test Notebook", r#"A notebook"#);
|
||||
|
||||
// Set the current editor of the notebook to be another email
|
||||
cloud_notebook.metadata.current_editor_uid = Some(uid.clone());
|
||||
UserProfiles::handle(&app).update(&mut app, |user_profiles, _| {
|
||||
user_profiles.insert_profiles(&vec![UserProfileWithUID {
|
||||
firebase_uid: UserUid::new(&uid),
|
||||
display_name: Some(email.clone()),
|
||||
email: email.clone(),
|
||||
photo_url: "".to_string(),
|
||||
}]);
|
||||
});
|
||||
|
||||
// Add the notebook to cloud model
|
||||
CloudModel::handle(&app).update(&mut app, |model, _| {
|
||||
model.add_object(cloud_notebook.id, cloud_notebook.clone())
|
||||
});
|
||||
|
||||
// Open the notebook
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// Assert that the editor is the other email
|
||||
notebook_view.update(&mut app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::OtherUserActive,
|
||||
email: Some(email)
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
let mode = notebook_view.read(&app, |notebook, ctx| notebook.mode(ctx));
|
||||
|
||||
// Assert that we are in view mode open since there is another editor
|
||||
assert_eq!(mode, Mode::View);
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we do not eagerly enter edit mode when another editor took the baton
|
||||
/// while Warp was closed.
|
||||
#[test]
|
||||
fn test_baton_grab_editor_changed_offline() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let other_uid = "ben@warp.dev";
|
||||
let other_email = "ben@warp.dev";
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
// Create a notebook with no editor.
|
||||
let mut server_notebook = mock_server_notebook("Test Notebook", "Some text");
|
||||
let cloud_notebook = CloudNotebook::new_from_server(server_notebook.clone());
|
||||
|
||||
// Add the notebook to the cloud model, with no editor.
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
|
||||
// Open the notebook, before initial load has finished.
|
||||
let open_future = open_notebook(&mut app, ¬ebook_view, cloud_notebook);
|
||||
|
||||
// In the meantime, complete initial load with a new editor.
|
||||
server_notebook.metadata.metadata_last_updated_ts =
|
||||
(Utc::now() + Duration::seconds(1)).into();
|
||||
server_notebook.metadata.current_editor_uid = Some(other_uid.to_string());
|
||||
UserProfiles::handle(&app).update(&mut app, |user_profiles, _| {
|
||||
user_profiles.insert_profiles(&vec![UserProfileWithUID {
|
||||
firebase_uid: UserUid::new(other_uid),
|
||||
display_name: Some(other_email.to_string()),
|
||||
email: other_email.to_string(),
|
||||
photo_url: "".to_string(),
|
||||
}]);
|
||||
});
|
||||
|
||||
initial_load(&mut app, vec![server_notebook]).await;
|
||||
|
||||
// The notebook should load and not take the baton.
|
||||
open_future.await;
|
||||
notebook_view.read(&app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::OtherUserActive,
|
||||
email: Some(other_email.to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(notebook.mode_app_ctx(ctx), Mode::View);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we can eagerly grab the baton if the previous editor exits offline.
|
||||
#[test]
|
||||
fn test_baton_grab_editor_left_offline() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let other_uid = "ben@warp.dev";
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
// Create a notebook with an editor.
|
||||
let mut server_notebook = mock_server_notebook("Test Notebook", "Some text");
|
||||
server_notebook.metadata.current_editor_uid = Some(other_uid.to_string());
|
||||
let cloud_notebook = CloudNotebook::new_from_server(server_notebook.clone());
|
||||
|
||||
// Add the notebook to the cloud model, with the saved editor.
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
|
||||
// Open the notebook, before initial load has finished.
|
||||
let open_future = open_notebook(&mut app, ¬ebook_view, cloud_notebook);
|
||||
|
||||
// In the meantime, complete initial load with no editor.
|
||||
server_notebook.metadata.metadata_last_updated_ts =
|
||||
(Utc::now() + Duration::seconds(1)).into();
|
||||
server_notebook.metadata.current_editor_uid = None;
|
||||
initial_load(&mut app, vec![server_notebook]).await;
|
||||
|
||||
// The notebook should load and take the baton.
|
||||
open_future.await;
|
||||
notebook_view.read(&app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::CurrentUser,
|
||||
email: Some(TEST_USER_EMAIL.to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(notebook.mode_app_ctx(ctx), Mode::Editing);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_with_pending_changes() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
// Stop dequeueing, so that we can verify the queue contents.
|
||||
SyncQueue::handle(&app).update(&mut app, |sync_queue, _ctx| {
|
||||
sync_queue.stop_dequeueing();
|
||||
assert_eq!(sync_queue.queue().len(), 0);
|
||||
});
|
||||
|
||||
// Create a notebook with a server ID, so it can be synced.
|
||||
let cloud_notebook =
|
||||
CloudNotebook::new_from_server(mock_server_notebook("Test", "Some text"));
|
||||
let notebook_id = cloud_notebook.id;
|
||||
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// Edit the notebook. It should not be saved yet.
|
||||
notebook_view.update(&mut app, |notebook: &mut NotebookView, ctx| {
|
||||
notebook.input.update(ctx, |input, ctx| {
|
||||
input.user_typed("Hello ", ctx);
|
||||
})
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let object = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(¬ebook_id.uid())
|
||||
.expect("Notebook should exist");
|
||||
assert!(!object.metadata().has_pending_content_changes());
|
||||
});
|
||||
|
||||
// Closing the notebook should force a save.
|
||||
notebook_view.update(&mut app, |notebook, ctx| notebook.on_detach(ctx));
|
||||
|
||||
app.read(|ctx| {
|
||||
let object = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(¬ebook_id.uid())
|
||||
.expect("Notebook should exist");
|
||||
assert!(object.metadata().has_pending_content_changes());
|
||||
|
||||
let sync_queue = SyncQueue::as_ref(ctx).queue();
|
||||
assert_eq!(sync_queue.len(), 1);
|
||||
match &sync_queue[0].1 {
|
||||
QueueItem::UpdateNotebook { model, id, .. } => {
|
||||
assert_eq!(model.title, "Test".to_string());
|
||||
assert_eq!(model.data, "Hello Some text".to_string());
|
||||
assert_eq!(id, ¬ebook_id);
|
||||
}
|
||||
other => panic!("Expected UpdateNotebook, got {other:?}"),
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_unmodified() {
|
||||
// If we close a notebook with no pending changes, it should not save.
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
// Stop dequeueing, so that we can verify the queue contents.
|
||||
SyncQueue::handle(&app).update(&mut app, |sync_queue, _ctx| {
|
||||
sync_queue.stop_dequeueing();
|
||||
assert_eq!(sync_queue.queue().len(), 0);
|
||||
});
|
||||
|
||||
// Create a notebook with a server ID, so it can be synced.
|
||||
let cloud_notebook =
|
||||
CloudNotebook::new_from_server(mock_server_notebook("Test", "Some text"));
|
||||
let notebook_id = cloud_notebook.id;
|
||||
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// Close the notebook with no changes.
|
||||
notebook_view.update(&mut app, |notebook, ctx| notebook.on_detach(ctx));
|
||||
|
||||
app.read(|ctx| {
|
||||
let object = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(¬ebook_id.uid())
|
||||
.expect("Notebook should exist");
|
||||
assert!(!object.metadata().has_pending_content_changes());
|
||||
|
||||
let sync_queue = SyncQueue::as_ref(ctx).queue();
|
||||
assert!(sync_queue.is_empty());
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_user_title_edits_synced() {
|
||||
// This tests that we only sync user edits, and don't echo back received title changes.
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
// Stop dequeueing, so that we can verify the queue contents.
|
||||
SyncQueue::handle(&app).update(&mut app, |sync_queue, _ctx| {
|
||||
sync_queue.stop_dequeueing();
|
||||
assert_eq!(sync_queue.queue().len(), 0);
|
||||
});
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
// Create a notebook with a server ID, so it can be synced.
|
||||
let mut server_notebook = mock_server_notebook("Initial Title", "Notebook contents");
|
||||
let cloud_notebook: CloudNotebook = CloudNotebook::new_from_server(server_notebook.clone());
|
||||
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// When a new title comes in, we should update the buffer but not emit a change.
|
||||
server_notebook.model.title = "New Title".to_string();
|
||||
server_notebook.metadata.revision = (Utc::now() + Duration::seconds(2)).into();
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, ctx| {
|
||||
cloud_model.upsert_from_server_notebook(server_notebook, ctx);
|
||||
});
|
||||
|
||||
notebook_view.read(&app, |notebook, ctx| {
|
||||
assert_eq!(notebook.title(ctx), "New Title");
|
||||
assert!(!notebook.title_is_dirty);
|
||||
});
|
||||
|
||||
// When the _user_ edits the title, that should be synced.
|
||||
notebook_view.update(&mut app, |notebook, ctx| {
|
||||
notebook.title.update(ctx, |title, ctx| {
|
||||
title.user_insert("!!!", ctx);
|
||||
});
|
||||
});
|
||||
// This is outside the `update` callback so that it runs after the event is dispatched.
|
||||
notebook_view.read(&app, |notebook, _| {
|
||||
assert!(notebook.title_is_dirty);
|
||||
});
|
||||
|
||||
ensure_saved(&mut app, ¬ebook_view).await;
|
||||
|
||||
SyncQueue::handle(&app).read(&app, |sync_queue, _| match sync_queue.queue().first() {
|
||||
Some((_, QueueItem::UpdateNotebook { model, .. })) => {
|
||||
assert_eq!(model.title.as_str(), "New Title!!!");
|
||||
}
|
||||
other => panic!("Expected notebook title update, got {other:?}"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflicting_notebook_read_only() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
let mut server_notebook = mock_server_notebook("A Notebook", "Local Data");
|
||||
let server_id = server_notebook.id;
|
||||
let mut cloud_notebook: CloudNotebook =
|
||||
CloudNotebook::new_from_server(server_notebook.clone());
|
||||
server_notebook.model.data = "Remote Data".to_string();
|
||||
cloud_notebook.set_conflicting_object(Arc::new(server_notebook.clone()));
|
||||
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// The notebook should load into view mode.
|
||||
app.read(|ctx| {
|
||||
let active_notebook_data = notebook_view.as_ref(ctx).active_notebook_data.as_ref(ctx);
|
||||
assert!(active_notebook_data.has_conflicts(ctx));
|
||||
assert_eq!(active_notebook_data.mode, Mode::View);
|
||||
assert_eq!(
|
||||
notebook_view
|
||||
.as_ref(ctx)
|
||||
.input
|
||||
.as_ref(ctx)
|
||||
.interaction_state(ctx),
|
||||
InteractionState::Selectable
|
||||
);
|
||||
});
|
||||
|
||||
// While there are conflicts, the user should not be able to start editing.
|
||||
notebook_view.update(&mut app, |notebook_view, ctx| {
|
||||
notebook_view.grab_edit_access_or_display_access_dialog(ctx);
|
||||
assert!(
|
||||
!notebook_view
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.show_grab_edit_access_modal
|
||||
);
|
||||
assert_eq!(notebook_view.mode(ctx), Mode::View);
|
||||
});
|
||||
|
||||
// Resolving the conflict should make the notebook editable again.
|
||||
notebook_view.update(&mut app, |notebook_view, ctx| {
|
||||
notebook_view.conflict_dialog_refresh_button_clicked(ctx);
|
||||
assert_eq!(notebook_view.content(ctx), "Remote Data");
|
||||
|
||||
notebook_view.grab_edit_access_or_display_access_dialog(ctx);
|
||||
assert_eq!(notebook_view.mode(ctx), Mode::Editing);
|
||||
});
|
||||
|
||||
// If there's another conflict, the notebook should switch back to view mode.
|
||||
// Trigger this via the SyncQueue so that the UpdateManager records the conflict in CloudModel.
|
||||
SyncQueue::handle(&app).update(&mut app, |_, ctx| {
|
||||
ctx.emit(SyncQueueEvent::ObjectUpdateRejected {
|
||||
id: server_id.uid(),
|
||||
object: ServerCloudObject::Notebook(server_notebook).into(),
|
||||
});
|
||||
});
|
||||
|
||||
notebook_view.read(&app, |notebook_view, ctx| {
|
||||
assert!(notebook_view
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.has_conflicts(ctx));
|
||||
assert_eq!(notebook_view.mode(ctx), Mode::View);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untitled_notebook() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let (_, notebook, _) = create_notebook(&mut app);
|
||||
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.open_new_notebook(None, Owner::mock_current_user(), None, ctx);
|
||||
});
|
||||
|
||||
notebook.read(&app, |notebook, ctx| {
|
||||
assert_eq!(notebook.title(ctx), "Untitled");
|
||||
});
|
||||
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.switch_to_edit(ctx);
|
||||
notebook.focus_title(ctx);
|
||||
notebook.title.update(ctx, |title, ctx| {
|
||||
title.user_insert("My Notebook", ctx);
|
||||
});
|
||||
assert_eq!(notebook.title(ctx), "My Notebook");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Shared styles for notebooks.
|
||||
|
||||
use warpui::{
|
||||
elements::{
|
||||
Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement,
|
||||
},
|
||||
fonts,
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponent as _,
|
||||
units::{IntoPixels, Pixels},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
settings::{derived_notebook_font_size, FontSettings},
|
||||
themes::theme::Fill,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
};
|
||||
|
||||
/// Multiplier of the notebook font size for the title. At the default size, this
|
||||
/// is 20px.
|
||||
const TITLE_FONT_MULTIPLIER: f32 = 1.4;
|
||||
|
||||
const EDITOR_MAX_WIDTH: f32 = 640.;
|
||||
|
||||
const TITLE_MARGIN: f32 = 16.;
|
||||
const EDITOR_PADDING_LEFT: f32 = 4.;
|
||||
const EDITOR_PADDING_TOP: f32 = 4.;
|
||||
|
||||
/// Font size for the notebook title.
|
||||
pub fn title_font_size(font_settings: &FontSettings) -> f32 {
|
||||
derived_notebook_font_size(font_settings) * TITLE_FONT_MULTIPLIER
|
||||
}
|
||||
|
||||
/// Font properties for the notebook title.
|
||||
pub const TITLE_FONT_PROPERTIES: fonts::Properties = fonts::Properties {
|
||||
style: fonts::Style::Normal,
|
||||
weight: fonts::Weight::Bold,
|
||||
};
|
||||
|
||||
/// Wraps the title element in spacing. If not `None`, the details element is shown above the title.
|
||||
pub fn wrap_title(title: Box<dyn Element>, details: Option<Box<dyn Element>>) -> Box<dyn Element> {
|
||||
let mut contents = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center);
|
||||
|
||||
if let Some(details) = details {
|
||||
contents.add_child(Container::new(details).with_padding_bottom(4.).finish())
|
||||
};
|
||||
|
||||
contents.add_child(title);
|
||||
|
||||
Container::new(contents.finish())
|
||||
.with_uniform_margin(TITLE_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Wraps the body element in spacing.
|
||||
pub fn wrap_body(body: Box<dyn Element>) -> Box<dyn Element> {
|
||||
Container::new(body)
|
||||
.with_padding_left(EDITOR_PADDING_LEFT)
|
||||
.with_padding_top(EDITOR_PADDING_TOP)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// The color to use for title/heading text.
|
||||
pub fn title_text_fill(appearance: &Appearance) -> Fill {
|
||||
let theme = appearance.theme();
|
||||
theme.sub_text_color(theme.background())
|
||||
}
|
||||
|
||||
/// Builds an action button for a block's footer (such as the button to run a command or embedded
|
||||
/// workflow).
|
||||
pub(super) fn block_footer_action_button(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
tooltip: impl Into<String> + 'static,
|
||||
keybinding: Option<String>,
|
||||
) -> Hoverable {
|
||||
let tooltip_builder = appearance.ui_builder().clone();
|
||||
icon_button(appearance, icon, false, mouse_state_handle)
|
||||
.with_tooltip(move || match keybinding {
|
||||
Some(keybinding) => tooltip_builder
|
||||
.tool_tip_with_sublabel(tooltip.into(), keybinding)
|
||||
.build()
|
||||
.finish(),
|
||||
None => tooltip_builder.tool_tip(tooltip.into()).build().finish(),
|
||||
})
|
||||
.build()
|
||||
// Revert to the default cursor instead of the editor I-beam
|
||||
.with_cursor(Cursor::Arrow)
|
||||
}
|
||||
|
||||
// Maximum notebook editor width.
|
||||
pub fn notebook_editor_max_width() -> Pixels {
|
||||
EDITOR_MAX_WIDTH.into_pixels()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Notebook-specific telemetry definitions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{server::ids::ServerId, workflows::WorkflowId};
|
||||
|
||||
use super::editor::BlockInsertionSource;
|
||||
|
||||
/// A user action within a notebook. Some actions, like running a command, are not included here
|
||||
/// because they're covered by existing telemetry.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "action")]
|
||||
pub enum NotebookTelemetryAction {
|
||||
/// The user manually took edit control.
|
||||
GrabEditingBaton,
|
||||
/// An object was embedded into the notebook.
|
||||
InsertEmbeddedObject(EmbeddedObjectInfo),
|
||||
/// A block within the notebook was copied to the clipboard.
|
||||
/// Currently, this only applies to command-like blocks.
|
||||
CopyBlock {
|
||||
#[serde(flatten)]
|
||||
block: BlockInfo,
|
||||
entrypoint: ActionEntrypoint,
|
||||
},
|
||||
/// The user opened the block insertion menu.
|
||||
OpenBlockInsertionMenu { source: BlockInsertionSource },
|
||||
/// The user opened the search menu for embedded objects.
|
||||
OpenEmbeddedObjectSearch,
|
||||
/// The user opened the find bar.
|
||||
OpenFindBar,
|
||||
/// The user opened the right-click context menu.
|
||||
OpenContextMenu,
|
||||
/// The selection mode changed.
|
||||
ChangeSelectionMode { mode: SelectionMode },
|
||||
/// The user navigated between command/code blocks or embedded workflows with the keyboard.
|
||||
CommandKeyboardNavigation,
|
||||
}
|
||||
|
||||
/// Generic entrypoint information for actions that might be keyboard or mouse driven.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ActionEntrypoint {
|
||||
/// A keyboard shortcut.
|
||||
Keyboard,
|
||||
/// A button in the UI.
|
||||
Button,
|
||||
/// A menu item.
|
||||
Menu,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "object_type")]
|
||||
pub enum EmbeddedObjectInfo {
|
||||
Workflow {
|
||||
workflow_id: Option<WorkflowId>,
|
||||
team_uid: Option<ServerId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Information about a block in the notebook.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "block_type")]
|
||||
pub enum BlockInfo {
|
||||
/// A workflow embedded in the notebook.
|
||||
EmbeddedWorkflow {
|
||||
workflow_id: Option<WorkflowId>,
|
||||
team_uid: Option<ServerId>,
|
||||
},
|
||||
/// A code or command block within the notebook.
|
||||
CodeBlock,
|
||||
}
|
||||
|
||||
/// A selection/navigation mode within the notebook.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SelectionMode {
|
||||
/// Navigate between command/code blocks and embedded workflows.
|
||||
Command,
|
||||
/// Navigate with a text cursor/selection.
|
||||
Text,
|
||||
}
|
||||
Reference in New Issue
Block a user