Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+418
View File
@@ -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(&notebook_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;
}
+354
View File
@@ -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),
}
+257
View File
@@ -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);
}
}
+647
View File
@@ -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)
}
}
+381
View File
@@ -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())
}
}
+639
View File
@@ -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;
}
+66
View File
@@ -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())
}
+296
View File
@@ -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);
}
}
}
+365
View File
@@ -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 &current_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()
}
+546
View File
@@ -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
+662
View File
@@ -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
+232
View File
@@ -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"));
});
});
}
+465
View File
@@ -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)
}
}
}
}
+400
View File
@@ -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:?}"),
}
});
}
+384
View File
@@ -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(&notebook.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(&notebook_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(&notebook_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(&notebook_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(&notebook_id)?.clone())
}) else {
return;
};
if let Some(NotebookRawTextStatus::ParseInFlight(abort_handle)) =
self.raw_text_by_hashed_id.get(&notebook_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 {}
+159
View File
@@ -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:?}"),
}
});
}
+285
View File
@@ -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
+218
View File
@@ -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"
);
});
})
}
+942
View File
@@ -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(&notebook, |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,
&notebook,
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 = &notebook.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,
&notebook,
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,
&notebook,
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, &notebook).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, &notebook).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, &notebook).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, &notebook_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, &notebook_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, &notebook_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, &notebook_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, &notebook_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(&notebook_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(&notebook_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, &notebook_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, &notebook_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(&notebook_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, &notebook_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, &notebook_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, &notebook_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");
});
});
}
+100
View File
@@ -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()
}
+79
View File
@@ -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,
}