Complete local-first content migration slice
This commit is contained in:
@@ -1,16 +1,12 @@
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{CloudNotebookModel, NotebookId};
|
||||
use super::CloudNotebookModel;
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
use crate::cloud_object::breadcrumbs::ContainingObject;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::model::view::{CloudViewModel, Editor, EditorState};
|
||||
use crate::cloud_object::model::view::Editor;
|
||||
use crate::cloud_object::{CloudObject, Owner, Space};
|
||||
use crate::drive::sharing::{ContentEditability, SharingAccessLevel};
|
||||
use crate::notebooks::CloudNotebook;
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
};
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
@@ -52,17 +48,10 @@ pub struct ActiveNotebookData {
|
||||
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);
|
||||
@@ -75,21 +64,6 @@ impl ActiveNotebookData {
|
||||
|
||||
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
||||
match event {
|
||||
CloudModelEvent::NotebookEditorChangedFromServer { notebook_id } => {
|
||||
if self.is_active_notebook(*notebook_id) {
|
||||
if let Some(new_editor) =
|
||||
CloudViewModel::as_ref(ctx).object_current_editor(¬ebook_id.uid(), ctx)
|
||||
{
|
||||
if self.mode == Mode::Editing
|
||||
&& matches!(new_editor.state, EditorState::OtherUserActive)
|
||||
{
|
||||
self.mode = Mode::View;
|
||||
ctx.emit(ActiveNotebookDataEvent::ModeChangedFromServer);
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
CloudModelEvent::ObjectMoved { type_and_id, .. } => {
|
||||
if let Some(notebook_id) = type_and_id.as_notebook_id() {
|
||||
// Update breadcrumb when a notebook is moved, whether by the user or a
|
||||
@@ -99,102 +73,16 @@ impl ActiveNotebookData {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
CloudModelEvent::NotebookEditorChangedFromServer { .. }
|
||||
| CloudModelEvent::ObjectUpdated { .. }
|
||||
| CloudModelEvent::ObjectTrashed { .. }
|
||||
| CloudModelEvent::ObjectUntrashed { .. }
|
||||
| CloudModelEvent::ObjectCreated { .. }
|
||||
| CloudModelEvent::ObjectDeleted { .. }
|
||||
| CloudModelEvent::ObjectPermissionsUpdated { .. }
|
||||
| CloudModelEvent::ObjectSynced { .. }
|
||||
| CloudModelEvent::ObjectForceExpanded { .. }
|
||||
| CloudModelEvent::InitialLoadCompleted => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +91,6 @@ impl ActiveNotebookData {
|
||||
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(
|
||||
@@ -255,12 +142,9 @@ impl ActiveNotebookData {
|
||||
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(_))
|
||||
)
|
||||
/// Whether the notebook has been committed to Galaxy's local repository.
|
||||
pub fn is_persisted(&self) -> bool {
|
||||
matches!(&self.active_notebook, ActiveNotebook::CommittedNotebook(_))
|
||||
}
|
||||
|
||||
/// Calculate the breadcrumbs for this object.
|
||||
@@ -309,23 +193,14 @@ impl ActiveNotebookData {
|
||||
/// 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()
|
||||
})
|
||||
#[cfg(test)]
|
||||
pub fn has_conflicts(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
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)
|
||||
/// Local notebooks have no remote editing baton.
|
||||
pub fn current_editor(&self) -> Option<Editor> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Checks if this notebook is trashed or deleted.
|
||||
@@ -347,43 +222,11 @@ impl ActiveNotebookData {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -15,9 +15,6 @@ use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::drive::OpenGalaxyDriveObjectSettings;
|
||||
use crate::pane_group::{NotebookPane, PaneContent};
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use crate::{safe_debug, safe_warn};
|
||||
@@ -75,11 +72,6 @@ pub enum NotebookSource {
|
||||
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();
|
||||
@@ -194,9 +186,8 @@ impl NotebookManager {
|
||||
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)
|
||||
view.load_local_or_show_not_found(*notebook_id, settings, window_id, ctx)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,43 +281,6 @@ impl NotebookManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
_: ModelHandle<UpdateManager>,
|
||||
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) {
|
||||
|
||||
+146
-495
@@ -47,9 +47,7 @@ use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::grab_edit_access_modal::{GrabEditAccessModal, GrabEditAccessModalEvent};
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
|
||||
use crate::cloud_object::model::view::{Editor, EditorState};
|
||||
use crate::cloud_object::{CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Space};
|
||||
use crate::drive::drive_helpers::has_feature_gated_anonymous_user_reached_notebook_limit;
|
||||
use crate::cloud_object::{CloudObject, ObjectType, Owner, Space};
|
||||
use crate::drive::export::ExportManager;
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::drive::sharing::ShareableObject;
|
||||
@@ -59,15 +57,14 @@ use crate::editor::{
|
||||
SingleLineEditorOptions, TextColors, TextOptions,
|
||||
};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_object_repository::LocalObjectRepository;
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent};
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::notebooks::CloudNotebook;
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
|
||||
use crate::pane_group::pane::view;
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
|
||||
use crate::server::cloud_objects::update_manager::{FetchSingleObjectOption, UpdateManager};
|
||||
use crate::server::ids::{ClientId, ServerId, SyncId};
|
||||
use crate::server::telemetry::{
|
||||
CloudObjectTelemetryMetadata, NotebookActionEvent, NotebookTelemetryMetadata,
|
||||
@@ -108,9 +105,7 @@ const REFRESH_BUTTON_TEXT: &str = "Refresh";
|
||||
|
||||
const FEATURE_NOT_AVAILABLE_MESSAGE: &str = "This notebook could not be saved to the server because the feature is temporarily unavailable. The changes are saved locally. Please retry later.";
|
||||
|
||||
/// The frequency at which we check for modifications and save the notebook to the server. This
|
||||
/// lets us trade off how quickly edits appear on other clients with the load on the server for RTC
|
||||
/// object updates.
|
||||
/// The frequency at which we flush notebook modifications to local storage.
|
||||
const SAVE_PERIOD: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The minimum size of an edit delta (in terms of the change in byte length of the serialized
|
||||
@@ -327,11 +322,6 @@ impl NotebookView {
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&NetworkStatus::handle(ctx),
|
||||
Self::handle_network_status_event,
|
||||
);
|
||||
|
||||
let active_notebook_data = ctx.add_model(ActiveNotebookData::new);
|
||||
ctx.subscribe_to_model(&active_notebook_data, Self::handle_active_notebook_event);
|
||||
ctx.observe(&active_notebook_data, Self::handle_active_notebook_change);
|
||||
@@ -481,7 +471,7 @@ impl NotebookView {
|
||||
}
|
||||
|
||||
/// The notebook title. This is pulled from the title editor, and may be more recent than
|
||||
/// what's been persisted to the server.
|
||||
/// what's been persisted locally.
|
||||
fn title(&self, app: &AppContext) -> String {
|
||||
Self::title_from_editor(&self.title, app)
|
||||
}
|
||||
@@ -564,40 +554,9 @@ impl NotebookView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ActiveNotebookDataEvent::ModeChangedFromServer => {
|
||||
log::info!("Edit mode stolen");
|
||||
self.switch_to_view(ctx);
|
||||
}
|
||||
ActiveNotebookDataEvent::SwitchedToEditMode => {
|
||||
log::info!("Edit mode confirmed from server");
|
||||
self.set_editor_interaction_state(InteractionState::Editable, ctx);
|
||||
}
|
||||
ActiveNotebookDataEvent::EditRejected => {
|
||||
log::info!("Edit rejected, switching to view mode");
|
||||
self.switch_to_view(ctx);
|
||||
}
|
||||
ActiveNotebookDataEvent::BreadcrumbsChanged => {
|
||||
self.update_breadcrumbs(ctx);
|
||||
}
|
||||
ActiveNotebookDataEvent::CreatedOnServer => {
|
||||
ctx.emit(NotebookEvent::Pane(PaneEvent::AppStateChanged));
|
||||
if let Some(id) = self
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.id()
|
||||
.and_then(SyncId::into_server)
|
||||
{
|
||||
self.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
pane_config
|
||||
.set_shareable_object(Some(ShareableObject::WarpDriveObject(id)), ctx);
|
||||
})
|
||||
}
|
||||
}
|
||||
ActiveNotebookDataEvent::TrashStatusChanged | ActiveNotebookDataEvent::MovedToSpace => {
|
||||
self.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
pane_config.refresh_pane_header_overflow_menu_items(ctx)
|
||||
});
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -634,10 +593,8 @@ impl NotebookView {
|
||||
ctx.emit(NotebookEvent::Pane(PaneEvent::FocusSelf));
|
||||
}
|
||||
EditorEvent::Edited(edit_origin) => {
|
||||
// We only want to queue up a request to edit the title on the server
|
||||
// if this was a user-initiated request. We don't want to do this for
|
||||
// system edits because that could end up in an infinite loop (e.g.
|
||||
// open notebook -> system edit -> update server -> receive update -> system update -> ...).
|
||||
// Only user edits should enqueue a local title save. System edits
|
||||
// could otherwise cause a model-update loop.
|
||||
if matches!(
|
||||
edit_origin,
|
||||
EditOrigin::UserTyped | EditOrigin::UserInitiated
|
||||
@@ -688,7 +645,7 @@ impl NotebookView {
|
||||
ctx.notify();
|
||||
});
|
||||
log::info!("Explicitly grabbing edit access, stealing from active editor");
|
||||
self.grab_edit_access(false, ctx);
|
||||
self.grab_edit_access(ctx);
|
||||
self.send_telemetry_action(NotebookTelemetryAction::GrabEditingBaton, ctx);
|
||||
}
|
||||
}
|
||||
@@ -782,7 +739,7 @@ impl NotebookView {
|
||||
self.input.as_ref(ctx).markdown(ctx)
|
||||
}
|
||||
|
||||
/// Saves the notebook's current Markdown content, via the [`UpdateManager`].
|
||||
/// Saves the notebook's current Markdown content locally.
|
||||
fn save_content(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.send_edit_telemetry = true;
|
||||
let content = Arc::new(self.content(ctx));
|
||||
@@ -811,44 +768,51 @@ impl NotebookView {
|
||||
}
|
||||
|
||||
let active_notebook = self.active_notebook_data.as_ref(ctx).active_notebook();
|
||||
match active_notebook {
|
||||
// If the notebook has already been committed, then update the local
|
||||
// memory and server data via update manager
|
||||
ActiveNotebook::CommittedNotebook(id) => UpdateManager::handle(ctx)
|
||||
.update(ctx, move |update_manager, ctx| {
|
||||
update_manager.update_notebook_data(content, id, ctx)
|
||||
let saved = match active_notebook {
|
||||
ActiveNotebook::CommittedNotebook(id) => LocalObjectRepository::handle(ctx)
|
||||
.update(ctx, |repository, ctx| {
|
||||
repository.update_notebook_data(id, content.to_string(), ctx)
|
||||
}),
|
||||
// If the notebook hasn't been committed yet, create the notebook through update
|
||||
// manager, and update the active notebook
|
||||
ActiveNotebook::NewNotebook(notebook) => {
|
||||
if let Some(client_id) = notebook.id.into_client() {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
client_id,
|
||||
notebook.permissions.owner,
|
||||
notebook.metadata.folder_id,
|
||||
CloudNotebookModel {
|
||||
title: notebook.model().title.clone(),
|
||||
data: content.to_string(),
|
||||
ai_document_id: notebook.model().ai_document_id,
|
||||
conversation_id: notebook.model().conversation_id.clone(),
|
||||
},
|
||||
CloudObjectEventEntrypoint::Unknown,
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
self.active_notebook_data.update(ctx, |data, _| {
|
||||
data.active_notebook =
|
||||
ActiveNotebook::CommittedNotebook(SyncId::ClientId(client_id))
|
||||
});
|
||||
}
|
||||
let id = notebook.id;
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
id,
|
||||
notebook.metadata.folder_id,
|
||||
CloudNotebookModel {
|
||||
title: notebook.model().title.clone(),
|
||||
data: content.to_string(),
|
||||
ai_document_id: notebook.model().ai_document_id,
|
||||
conversation_id: notebook.model().conversation_id.clone(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.local_notebook_created(id, ctx);
|
||||
true
|
||||
}
|
||||
ActiveNotebook::None => log::error!("Tried to save notebook, but none were active"),
|
||||
ActiveNotebook::None => {
|
||||
log::error!("Tried to save notebook, but none were active");
|
||||
false
|
||||
}
|
||||
};
|
||||
if saved {
|
||||
self.active_notebook_data.update(ctx, |data, ctx| {
|
||||
data.saving_status = SavingStatus::Saved;
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn local_notebook_created(&mut self, id: SyncId, ctx: &mut ViewContext<Self>) {
|
||||
self.active_notebook_data.update(ctx, |data, ctx| {
|
||||
data.active_notebook = ActiveNotebook::CommittedNotebook(id);
|
||||
data.saving_status = SavingStatus::Saved;
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.emit(NotebookEvent::Pane(PaneEvent::AppStateChanged));
|
||||
}
|
||||
|
||||
/// Check for edit activity and send telemetry accordingly.
|
||||
///
|
||||
/// This runs as a recursive async task that reports if an edit was made over the past
|
||||
@@ -892,15 +856,7 @@ impl NotebookView {
|
||||
self.edit_telemetry_handle = Some(next_check.abort_handle());
|
||||
}
|
||||
|
||||
/// Checks if the user is the current known editor of the notebook, if they
|
||||
/// are, then sets the current editor to be None both locally and on the server
|
||||
fn try_give_up_edit_access(&self, ctx: &mut ViewContext<Self>) {
|
||||
let id = self.active_notebook_data.as_ref(ctx).id();
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
if let Some(id) = id {
|
||||
update_manager.give_up_notebook_edit_access(id, ctx);
|
||||
}
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
@@ -917,7 +873,7 @@ impl NotebookView {
|
||||
}
|
||||
|
||||
if self.title_is_dirty {
|
||||
self.update_title_in_server(ctx);
|
||||
self.save_title(ctx);
|
||||
self.title_is_dirty = false;
|
||||
}
|
||||
}
|
||||
@@ -930,8 +886,7 @@ impl NotebookView {
|
||||
.try_send(NotebookUpdateRequestDebounceArg {})
|
||||
.context("Error enqueuing content save"));
|
||||
self.active_notebook_data.update(ctx, |data, ctx| {
|
||||
// Mark the notebook as saving as soon as there are changes to be saved. It won't be
|
||||
// marked as Saved until we get a response from the server.
|
||||
// Mark the notebook as saving as soon as there are changes to flush.
|
||||
data.saving_status = SavingStatus::Saving;
|
||||
ctx.notify();
|
||||
});
|
||||
@@ -1099,64 +1054,25 @@ impl NotebookView {
|
||||
self.check_edited(ctx);
|
||||
}
|
||||
|
||||
/// Sends a request to the server to grab notebook edit access, if the user is taking
|
||||
/// access from another user, we wait to actually switch them into edit mode. If we are
|
||||
/// not taking access, we go ahead and optimistically switch them in.
|
||||
fn grab_edit_access(&mut self, optimistically_grant_access: bool, ctx: &mut ViewContext<Self>) {
|
||||
fn grab_edit_access(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let active_notebook = self.active_notebook_data.as_ref(ctx);
|
||||
if !active_notebook.trash_status(ctx).is_editable() {
|
||||
// Do not allow grabbing edit access if the notebook is trashed or feature flag is turned off.
|
||||
// Trashed notebooks remain read-only until restored.
|
||||
return;
|
||||
}
|
||||
if FeatureFlag::SharedWithMe.is_enabled() && !active_notebook.editability(ctx).can_edit() {
|
||||
return;
|
||||
}
|
||||
|
||||
let id = active_notebook.id();
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
if let Some(id) = id {
|
||||
update_manager.grab_notebook_edit_access(id, optimistically_grant_access, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// If we are optimistically granting access, go ahead and switch into edit mode.
|
||||
if optimistically_grant_access {
|
||||
self.switch_to_edit(ctx);
|
||||
}
|
||||
self.switch_to_edit(ctx);
|
||||
|
||||
ctx.focus(&self.input);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Called when a user hits the edit button from within a notebook view.
|
||||
/// If there's not another editor, grabs notebook edit access and directly switches it
|
||||
/// into edit mode. If there is another editor currently, displays the grab edit access
|
||||
/// dialog.
|
||||
pub fn grab_edit_access_or_display_access_dialog(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let active_notebook_data = self.active_notebook_data.as_ref(ctx);
|
||||
if active_notebook_data.has_conflicts(ctx) {
|
||||
// Do not attempt to grab edit access if there are conflicts.
|
||||
return;
|
||||
}
|
||||
|
||||
let current_editor = active_notebook_data
|
||||
.current_editor(ctx)
|
||||
.unwrap_or(Editor::no_editor());
|
||||
if current_editor.state == EditorState::OtherUserActive {
|
||||
self.active_notebook_data.update(ctx, |data, ctx| {
|
||||
data.show_grab_edit_access_modal = true;
|
||||
ctx.notify();
|
||||
});
|
||||
} else {
|
||||
log::info!("Explicitly grabbing edit access, no active editor");
|
||||
self.grab_edit_access(true, ctx);
|
||||
}
|
||||
|
||||
self.grab_edit_access(ctx);
|
||||
self.focus_input(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Reset the notebook title editor's content as a system edit, which is not synced to the server.
|
||||
/// Reset the notebook title editor's content as a system edit, which is not a user save.
|
||||
fn set_title(&mut self, notebook_title: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.title.update(ctx, |title, ctx| {
|
||||
title.system_reset_buffer_text(notebook_title, ctx);
|
||||
@@ -1207,11 +1123,8 @@ impl NotebookView {
|
||||
|
||||
fn duplicate_object(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(notebook_id) = self.notebook_id(ctx) {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.duplicate_object(
|
||||
&CloudObjectTypeAndId::from_id_and_type(notebook_id, ObjectType::Notebook),
|
||||
ctx,
|
||||
);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.duplicate_notebook(notebook_id, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -1221,26 +1134,16 @@ impl NotebookView {
|
||||
if let Some(notebook_id) = self.notebook_id(ctx) {
|
||||
self.close(ctx);
|
||||
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.trash_object(
|
||||
CloudObjectTypeAndId::from_id_and_type(notebook_id, ObjectType::Notebook),
|
||||
ctx,
|
||||
);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.set_notebook_trashed(notebook_id, true, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn untrash_notebook(&self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(notebook_id) = self.notebook_id(ctx) {
|
||||
if has_feature_gated_anonymous_user_reached_notebook_limit(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.untrash_object(
|
||||
CloudObjectTypeAndId::from_id_and_type(notebook_id, ObjectType::Notebook),
|
||||
ctx,
|
||||
);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.set_notebook_trashed(notebook_id, false, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1285,18 +1188,11 @@ impl NotebookView {
|
||||
ActiveNotebook::None => None,
|
||||
};
|
||||
|
||||
let copy_client_id = ClientId::new();
|
||||
let copy_sync_id = SyncId::ClientId(copy_client_id);
|
||||
let copy_sync_id = SyncId::ClientId(ClientId::new());
|
||||
|
||||
let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) else {
|
||||
log::warn!("User drive not available for copying notebook");
|
||||
return;
|
||||
};
|
||||
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
copy_client_id,
|
||||
personal_drive,
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
copy_sync_id,
|
||||
None,
|
||||
CloudNotebookModel {
|
||||
title: title.clone(),
|
||||
@@ -1304,8 +1200,6 @@ impl NotebookView {
|
||||
ai_document_id,
|
||||
conversation_id: None,
|
||||
},
|
||||
CloudObjectEventEntrypoint::Unknown,
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
@@ -1321,9 +1215,7 @@ impl NotebookView {
|
||||
active_notebook.open_existing(copy_sync_id, ctx);
|
||||
});
|
||||
|
||||
// Because the notebook was just created, and is in the user's personal space, grabbing
|
||||
// access must be safe.
|
||||
self.grab_edit_access(true, ctx);
|
||||
self.grab_edit_access(ctx);
|
||||
|
||||
// Save the new notebook ID for session restoration.
|
||||
ctx.emit(NotebookEvent::Pane(PaneEvent::AppStateChanged));
|
||||
@@ -1335,20 +1227,6 @@ impl NotebookView {
|
||||
});
|
||||
}
|
||||
|
||||
fn online_only_operation_allowed(
|
||||
&self,
|
||||
cloud_object_type_and_id: CloudObjectTypeAndId,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
if let Some(object) = CloudModel::as_ref(app).get_by_uid(&cloud_object_type_and_id.uid()) {
|
||||
return self.is_online(app)
|
||||
&& cloud_object_type_and_id.has_server_id()
|
||||
&& !object.metadata().has_pending_online_only_change();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn notebook_link(&self, ctx: &AppContext) -> Option<String> {
|
||||
let id = self.notebook_id(ctx)?;
|
||||
|
||||
@@ -1362,44 +1240,14 @@ impl NotebookView {
|
||||
/// Items to show in the pane header overflow menu.
|
||||
fn overflow_menu_items(&self, ctx: &AppContext) -> Vec<MenuItem<NotebookAction>> {
|
||||
let active_notebook_data = self.active_notebook_data.as_ref(ctx);
|
||||
let access_level = active_notebook_data.access_level(ctx);
|
||||
let mut menu_items = Vec::new();
|
||||
|
||||
if !active_notebook_data.is_on_server()
|
||||
if !active_notebook_data.is_persisted()
|
||||
|| active_notebook_data.trash_status(ctx) != TrashStatus::Active
|
||||
{
|
||||
return menu_items;
|
||||
}
|
||||
|
||||
// Add "Move to <team> space" to menu
|
||||
let team_spaces = UserWorkspaces::as_ref(ctx).team_spaces();
|
||||
|
||||
if let (Some(space), Some(cloud_id)) =
|
||||
(active_notebook_data.space(ctx), active_notebook_data.id())
|
||||
{
|
||||
let cloud_object_type =
|
||||
CloudObjectTypeAndId::from_id_and_type(cloud_id, ObjectType::Notebook);
|
||||
let can_move = self.online_only_operation_allowed(cloud_object_type, ctx);
|
||||
|
||||
if can_move {
|
||||
match space {
|
||||
Space::Personal => {
|
||||
menu_items.extend(team_spaces.iter().map(|space| {
|
||||
MenuItemFields::new(format!("Move to {}", space.name(ctx)))
|
||||
.with_on_select_action(NotebookAction::MoveToSpace {
|
||||
cloud_object_type_and_id: cloud_object_type,
|
||||
new_space: *space,
|
||||
})
|
||||
.with_icon(Icon::Move)
|
||||
.into_item()
|
||||
}));
|
||||
}
|
||||
Space::Shared => {} // TODO: Revisit these menu items with sharing in mind
|
||||
Space::Team { .. } => {} // TODO: When we do team -> personal sharing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ai_document_id) = self.active_notebook_data.as_ref(ctx).ai_document_id(ctx) {
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Attach to active session")
|
||||
@@ -1409,44 +1257,12 @@ impl NotebookView {
|
||||
);
|
||||
}
|
||||
|
||||
// Add "Copy Link" to menu
|
||||
if let Some(link) = self.notebook_link(ctx) {
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Copy link")
|
||||
.with_on_select_action(NotebookAction::CopyLink(link))
|
||||
.with_icon(icons::Icon::Link)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
if !galaxyui::platform::is_mobile_device()
|
||||
&& !ContextFlag::HideOpenOnDesktopButton.is_enabled()
|
||||
&& *UserAppInstallDetectionSettings::as_ref(ctx)
|
||||
.user_app_installation_detected
|
||||
.value()
|
||||
== UserAppInstallStatus::Detected
|
||||
{
|
||||
if let Some(link) = self.notebook_link(ctx) {
|
||||
if let Ok(url) = Url::parse(&link) {
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Open on Desktop")
|
||||
.with_on_select_action(NotebookAction::OpenLinkOnDesktop(url))
|
||||
.with_icon(icons::Icon::Laptop)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add "Duplicate" to menu
|
||||
if active_notebook_data.space(ctx) != Some(Space::Shared) {
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Duplicate")
|
||||
.with_on_select_action(NotebookAction::Duplicate)
|
||||
.with_icon(icons::Icon::Duplicate)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Duplicate")
|
||||
.with_on_select_action(NotebookAction::Duplicate)
|
||||
.with_icon(icons::Icon::Duplicate)
|
||||
.into_item(),
|
||||
);
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
@@ -1458,117 +1274,33 @@ impl NotebookView {
|
||||
);
|
||||
}
|
||||
|
||||
// Add "Trash" to menu
|
||||
if self.is_online(ctx)
|
||||
&& (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash())
|
||||
{
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Trash")
|
||||
.with_on_select_action(NotebookAction::Trash)
|
||||
.with_icon(icons::Icon::Trash)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Trash")
|
||||
.with_on_select_action(NotebookAction::Trash)
|
||||
.with_icon(icons::Icon::Trash)
|
||||
.into_item(),
|
||||
);
|
||||
|
||||
menu_items
|
||||
}
|
||||
|
||||
fn handle_network_status_event(
|
||||
&mut self,
|
||||
_handle: ModelHandle<NetworkStatus>,
|
||||
event: &NetworkStatusEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let NetworkStatusEvent::NetworkStatusChanged { new_status: _ } = event;
|
||||
self.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
pane_config.refresh_pane_header_overflow_menu_items(ctx)
|
||||
});
|
||||
}
|
||||
|
||||
fn is_online(&self, app: &AppContext) -> bool {
|
||||
NetworkStatus::as_ref(app).is_online()
|
||||
}
|
||||
|
||||
/// Takes a given `notebook_id`, and tries to load it into view after initial load completes.
|
||||
/// If the notebook still does not exist in memory after initial load, displaces an error message in
|
||||
/// the given window.
|
||||
///
|
||||
/// Used for code paths such as link opening, where we are often trying to open notebooks before
|
||||
/// the initial response from the server has completed.
|
||||
pub fn wait_for_initial_load_then_load(
|
||||
/// Load a locally restored notebook, or show a not-found message.
|
||||
pub fn load_local_or_show_not_found(
|
||||
&mut self,
|
||||
notebook_id: SyncId,
|
||||
settings: &OpenGalaxyDriveObjectSettings,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let initial_load_complete = UpdateManager::as_ref(ctx).initial_load_complete();
|
||||
// TODO @ianhodge CLD-2002: it could be nice to have a loading screen here while we wait for the load
|
||||
let settings = settings.clone();
|
||||
ctx.spawn(initial_load_complete, move |me, _, ctx| {
|
||||
let notebook = CloudModel::as_ref(ctx).get_notebook(¬ebook_id).cloned();
|
||||
let fetch_needed = notebook.is_none()
|
||||
|| settings
|
||||
.focused_folder_id
|
||||
.map(SyncId::ServerId)
|
||||
.map(|folder_id| CloudModel::as_ref(ctx).get_folder(&folder_id).is_none())
|
||||
.unwrap_or(false);
|
||||
if fetch_needed {
|
||||
if let Some(server_id) = notebook_id.into_server() {
|
||||
me.fetch_and_load_notebook(server_id, &settings, window_id, ctx);
|
||||
} else {
|
||||
log::warn!("Tried to load notebook without server id {notebook_id:?}");
|
||||
}
|
||||
} else if let Some(notebook) = notebook {
|
||||
me.load(notebook, &settings, ctx);
|
||||
} else {
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast_by_type(
|
||||
ToastType::CloudObjectNotFound,
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
log::warn!("Tried to open unknown notebook {notebook_id:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(notebook) = CloudModel::as_ref(ctx).get_notebook(¬ebook_id).cloned() {
|
||||
self.load(notebook, settings, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
fn fetch_and_load_notebook(
|
||||
&mut self,
|
||||
notebook_id: ServerId,
|
||||
settings: &OpenGalaxyDriveObjectSettings,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// If we have a parent folder we are trying to load as a part of this notebook, fetch that instead
|
||||
let id_to_fetch = settings.focused_folder_id.unwrap_or(notebook_id);
|
||||
let fetch_cloud_object_rx =
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.fetch_single_cloud_object(
|
||||
&id_to_fetch,
|
||||
FetchSingleObjectOption::None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let settings = settings.clone();
|
||||
ctx.spawn(fetch_cloud_object_rx, move |me, _, ctx| {
|
||||
if let Some(notebook) = CloudModel::as_ref(ctx)
|
||||
.get_notebook(&SyncId::ServerId(notebook_id))
|
||||
.cloned()
|
||||
{
|
||||
me.load(notebook, &settings, ctx);
|
||||
} else {
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast_by_type(
|
||||
ToastType::CloudObjectNotFound,
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
log::warn!("Tried to open unknown notebook {notebook_id:?} after fetching");
|
||||
}
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast_by_type(ToastType::CloudObjectNotFound, window_id, ctx);
|
||||
});
|
||||
log::warn!("Tried to open unknown local notebook {notebook_id:?}");
|
||||
}
|
||||
|
||||
/// Takes a `CloudNotebook` and loads it into the view.
|
||||
@@ -1611,53 +1343,7 @@ impl NotebookView {
|
||||
ctx
|
||||
);
|
||||
|
||||
// Once we've received metadata from the server, check if we can eagerly edit the notebook.
|
||||
let has_metadata = UpdateManager::as_ref(ctx).initial_load_complete();
|
||||
let baton_future = ctx.spawn(has_metadata, |me, _, ctx| {
|
||||
let active_notebook_data = me.active_notebook_data.as_ref(ctx);
|
||||
|
||||
if FeatureFlag::SharedWithMe.is_enabled() && !active_notebook_data.editability(ctx).can_edit() {
|
||||
log::debug!("Notebook is view-only, opening in view mode");
|
||||
} else if active_notebook_data.has_conflicts(ctx) {
|
||||
log::debug!("Notebook has conflicts, opening in view mode");
|
||||
} else {
|
||||
let current_editor = active_notebook_data.current_editor(ctx);
|
||||
|
||||
// If there's not currently an editor or the current editor has been idle, we want to automatically
|
||||
// switch the user into edit mode.
|
||||
match current_editor {
|
||||
Some(editor) => {
|
||||
let email = editor.email.unwrap_or_default();
|
||||
match editor.state {
|
||||
EditorState::None => {
|
||||
log::info!("Optimistically grabbing edit access, no notebook editor");
|
||||
me.grab_edit_access(true, ctx);
|
||||
}
|
||||
EditorState::CurrentUser => {
|
||||
safe_info!(
|
||||
safe: ("Optimistically grabbing edit access, already the editor"),
|
||||
full: ("Optmisitically grabbing edit access, user {email} is already the editor")
|
||||
);
|
||||
me.grab_edit_access(true, ctx);
|
||||
}
|
||||
EditorState::OtherUserIdle => {
|
||||
safe_info!(
|
||||
safe: ("Optimistically grabbing edit access, editor is idle"),
|
||||
full: ("Optmisitically grabbing edit access, editor {email} is idle")
|
||||
);
|
||||
me.grab_edit_access(true, ctx);
|
||||
}
|
||||
EditorState::OtherUserActive => {
|
||||
log::info!("Opening in view mode, notebook is being edited")
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::info!("Opening in view mode, unknown editor");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let edit_future = ctx.spawn(async {}, |me, _, ctx| me.grab_edit_access(ctx));
|
||||
self.update_breadcrumbs(ctx);
|
||||
if let Some(invitee_email) = settings.invitee_email.clone() {
|
||||
let object_id_to_share = settings
|
||||
@@ -1677,7 +1363,7 @@ impl NotebookView {
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
baton_future
|
||||
edit_future
|
||||
}
|
||||
|
||||
/// Reset this view to show a new, empty notebook.
|
||||
@@ -1699,7 +1385,7 @@ impl NotebookView {
|
||||
|
||||
if let Some(title) = title {
|
||||
self.set_title(&title, ctx);
|
||||
self.update_title_in_server(ctx);
|
||||
self.save_title(ctx);
|
||||
} else {
|
||||
self.title.update(ctx, |title_editor, ctx| {
|
||||
title_editor.system_clear_buffer(true, ctx);
|
||||
@@ -1711,8 +1397,8 @@ impl NotebookView {
|
||||
self.switch_to_edit(ctx);
|
||||
}
|
||||
|
||||
/// Updates the notebook title on the server with the current contents of the title editor.
|
||||
pub fn update_title_in_server(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
/// Save the current notebook title locally.
|
||||
pub fn save_title(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let title: Arc<String> = self.title.as_ref(ctx).buffer_text(ctx).into();
|
||||
|
||||
// Block saving if secrets are detected in the notebook title when secret redaction is enabled.
|
||||
@@ -1739,40 +1425,39 @@ impl NotebookView {
|
||||
}
|
||||
|
||||
let active_notebook = self.active_notebook_data.as_ref(ctx).active_notebook();
|
||||
match active_notebook {
|
||||
// If the notebook has already been committed, then update the local
|
||||
// memory and server data via update manager
|
||||
ActiveNotebook::CommittedNotebook(id) => UpdateManager::handle(ctx)
|
||||
.update(ctx, |update_manager, ctx| {
|
||||
update_manager.update_notebook_title(title.clone(), id, ctx)
|
||||
let saved = match active_notebook {
|
||||
ActiveNotebook::CommittedNotebook(id) => LocalObjectRepository::handle(ctx)
|
||||
.update(ctx, |repository, ctx| {
|
||||
repository.update_notebook_title(id, title.to_string(), ctx)
|
||||
}),
|
||||
// If the notebook hasn't been committed yet, create the notebook through update
|
||||
// manager, and update the active notebook
|
||||
ActiveNotebook::NewNotebook(notebook) => {
|
||||
if let Some(client_id) = notebook.id.into_client() {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
client_id,
|
||||
notebook.permissions.owner,
|
||||
notebook.metadata.folder_id,
|
||||
CloudNotebookModel {
|
||||
title: title.to_string(),
|
||||
data: notebook.model().data.to_owned(),
|
||||
ai_document_id: notebook.model().ai_document_id,
|
||||
conversation_id: notebook.model().conversation_id.clone(),
|
||||
},
|
||||
CloudObjectEventEntrypoint::Unknown,
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.active_notebook_data.update(ctx, |data, _| {
|
||||
data.active_notebook =
|
||||
ActiveNotebook::CommittedNotebook(SyncId::ClientId(client_id))
|
||||
});
|
||||
}
|
||||
let id = notebook.id;
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
id,
|
||||
notebook.metadata.folder_id,
|
||||
CloudNotebookModel {
|
||||
title: title.to_string(),
|
||||
data: notebook.model().data.to_owned(),
|
||||
ai_document_id: notebook.model().ai_document_id,
|
||||
conversation_id: notebook.model().conversation_id.clone(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.local_notebook_created(id, ctx);
|
||||
true
|
||||
}
|
||||
ActiveNotebook::None => log::error!("Tried to save notebook, but none were active"),
|
||||
ActiveNotebook::None => {
|
||||
log::error!("Tried to save notebook, but none were active");
|
||||
false
|
||||
}
|
||||
};
|
||||
if saved {
|
||||
self.active_notebook_data.update(ctx, |data, ctx| {
|
||||
data.saving_status = SavingStatus::Saved;
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,12 +1520,7 @@ impl NotebookView {
|
||||
return;
|
||||
};
|
||||
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.replace_object_with_conflict(&id.uid(), ctx);
|
||||
});
|
||||
|
||||
// Load the server's version of the notebook now that the cloud model has been updated.
|
||||
// This will also switch back to edit mode if there isn't an active editor.
|
||||
// Reload the locally persisted version of the notebook.
|
||||
if let Some(notebook) = CloudModel::as_ref(ctx).get_notebook(&id) {
|
||||
self.load(
|
||||
notebook.clone(),
|
||||
@@ -1954,34 +1634,28 @@ impl NotebookView {
|
||||
|
||||
let active_notebook_data = self.active_notebook_data.as_ref(app);
|
||||
|
||||
if !FeatureFlag::SharedWithMe.is_enabled()
|
||||
|| active_notebook_data.access_level(app).can_trash()
|
||||
{
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
action_row.add_child(
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Basic,
|
||||
self.button_mouse_states.restore_from_trash_button.clone(),
|
||||
)
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Restore notebook from trash".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.with_text_label("Restore".to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(NotebookAction::Untrash)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
action_row.add_child(
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Basic,
|
||||
self.button_mouse_states.restore_from_trash_button.clone(),
|
||||
)
|
||||
.with_tooltip(move || {
|
||||
ui_builder
|
||||
.tool_tip("Restore notebook from trash".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.with_text_label("Restore".to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(NotebookAction::Untrash))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if active_notebook_data.space(app) != Some(Space::Personal) {
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
@@ -2212,21 +1886,6 @@ impl View for NotebookView {
|
||||
stack.add_child(ChildView::new(&self.grab_edit_access_modal).finish());
|
||||
}
|
||||
|
||||
if self
|
||||
.active_notebook_data
|
||||
.as_ref(app)
|
||||
.feature_not_available()
|
||||
{
|
||||
stack.add_child(self.render_sync_banner(
|
||||
NotebookSyncError::FeatureNotAvailable,
|
||||
Appearance::as_ref(app),
|
||||
));
|
||||
} else if self.active_notebook_data.as_ref(app).has_conflicts(app) {
|
||||
stack.add_child(
|
||||
self.render_sync_banner(NotebookSyncError::InConflict, Appearance::as_ref(app)),
|
||||
);
|
||||
}
|
||||
|
||||
self.context_menu.render(&mut stack);
|
||||
|
||||
SavePosition::new(stack.finish(), &self.view_position_id).finish()
|
||||
@@ -2240,15 +1899,7 @@ impl View for NotebookView {
|
||||
Mode::View => context.set.insert("NotebookViewing"),
|
||||
};
|
||||
|
||||
if !FeatureFlag::SharedWithMe.is_enabled()
|
||||
|| self
|
||||
.active_notebook_data
|
||||
.as_ref(app)
|
||||
.editability(app)
|
||||
.can_edit()
|
||||
{
|
||||
context.set.insert("NotebookIsEditable");
|
||||
}
|
||||
context.set.insert("NotebookIsEditable");
|
||||
|
||||
let font_settings = FontSettings::as_ref(app);
|
||||
if !font_settings.match_notebook_to_monospace_font_size.value() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Components for the notebook header.
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, Highlight, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
@@ -77,27 +76,17 @@ impl DetailsBar {
|
||||
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) {
|
||||
if let Some(editor) = notebook_data.current_editor() {
|
||||
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,
|
||||
));
|
||||
}
|
||||
editing_state_row.add_child(self.render_mode_toggle(
|
||||
notebook_data.mode,
|
||||
ContentEditability::Editable,
|
||||
appearance,
|
||||
));
|
||||
|
||||
header_row.add_child(Shrinkable::new(1., editing_state_row.finish()).finish());
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ use crate::server::cloud_objects::update_manager::{InitialLoadResponse, UpdateMa
|
||||
use crate::server::ids::ClientId;
|
||||
use crate::server::ids::SyncId::ServerId;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::{QueueItem, SyncQueue, SyncQueueEvent};
|
||||
use crate::server::sync_queue::{SyncQueue, SyncQueueEvent};
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
@@ -58,6 +58,13 @@ fn initialize_app(app: &mut 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(|ctx| {
|
||||
crate::local_object_repository::LocalObjectRepository::new(
|
||||
None,
|
||||
Some(Owner::mock_current_user()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
@@ -407,7 +414,7 @@ fn test_edit_telemetry() {
|
||||
|
||||
// The notebook should show in edit mode, with telemetry recording.
|
||||
notebook.update(&mut app, |notebook, ctx| {
|
||||
notebook.grab_edit_access(true, ctx);
|
||||
notebook.grab_edit_access(ctx);
|
||||
assert_eq!(
|
||||
notebook.active_notebook_data.as_ref(ctx).mode,
|
||||
Mode::Editing
|
||||
@@ -469,205 +476,26 @@ fn test_edit_telemetry() {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
fn test_local_notebook_ignores_legacy_remote_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"#);
|
||||
let mut cloud_notebook = cloud_notebook("Test Notebook", "A notebook");
|
||||
cloud_notebook.metadata.current_editor_uid = Some("legacy-user".to_string());
|
||||
|
||||
// Set the current editor of the notebook to be the test notebook
|
||||
cloud_notebook.metadata.current_editor_uid = Some(TEST_USER_UID.to_string().clone());
|
||||
|
||||
// Add the notebook to cloud model
|
||||
CloudModel::handle(&app).update(&mut app, |model, _| {
|
||||
model.add_object(cloud_notebook.id, cloud_notebook.clone())
|
||||
});
|
||||
|
||||
// Open the notebook
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// Assert that the editor is the current editor from the test user email
|
||||
notebook_view.update(&mut app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::CurrentUser,
|
||||
email: Some(TEST_USER_EMAIL.to_string())
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
let mode = notebook_view.read(&app, |notebook, ctx| notebook.mode(ctx));
|
||||
// Assert that we are in edit mode open since the editor is the current editor
|
||||
assert_eq!(mode, Mode::Editing);
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we do not eagerly enter edit mode when there is another editor
|
||||
#[test]
|
||||
fn test_not_eager_baton_grab_different_editor() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
// Complete the initial load so that grab-the-baton behavior applies.
|
||||
initial_load(&mut app, vec![]).await;
|
||||
|
||||
let uid = "ian@warp.dev".to_string();
|
||||
let email = "ian@warp.dev".to_string();
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
let mut cloud_notebook = cloud_notebook("Test Notebook", r#"A notebook"#);
|
||||
|
||||
// Set the current editor of the notebook to be another email
|
||||
cloud_notebook.metadata.current_editor_uid = Some(uid.clone());
|
||||
UserProfiles::handle(&app).update(&mut app, |user_profiles, _| {
|
||||
user_profiles.insert_profiles(&vec![UserProfileWithUID {
|
||||
firebase_uid: UserUid::new(&uid),
|
||||
display_name: Some(email.clone()),
|
||||
email: email.clone(),
|
||||
photo_url: "".to_string(),
|
||||
}]);
|
||||
});
|
||||
|
||||
// Add the notebook to cloud model
|
||||
CloudModel::handle(&app).update(&mut app, |model, _| {
|
||||
model.add_object(cloud_notebook.id, cloud_notebook.clone())
|
||||
});
|
||||
|
||||
// Open the notebook
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// Assert that the editor is the other email
|
||||
notebook_view.update(&mut app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::OtherUserActive,
|
||||
email: Some(email)
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
let mode = notebook_view.read(&app, |notebook, ctx| notebook.mode(ctx));
|
||||
|
||||
// Assert that we are in view mode open since there is another editor
|
||||
assert_eq!(mode, Mode::View);
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we do not eagerly enter edit mode when another editor took the baton
|
||||
/// while Warp was closed.
|
||||
#[test]
|
||||
fn test_baton_grab_editor_changed_offline() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let other_uid = "ben@warp.dev";
|
||||
let other_email = "ben@warp.dev";
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
// Create a notebook with no editor.
|
||||
let mut server_notebook = mock_server_notebook("Test Notebook", "Some text");
|
||||
let cloud_notebook = CloudNotebook::new_from_server(server_notebook.clone());
|
||||
|
||||
// Add the notebook to the cloud model, with no editor.
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
|
||||
// Open the notebook, before initial load has finished.
|
||||
let open_future = open_notebook(&mut app, ¬ebook_view, cloud_notebook);
|
||||
|
||||
// In the meantime, complete initial load with a new editor.
|
||||
server_notebook.metadata.metadata_last_updated_ts =
|
||||
(Utc::now() + Duration::seconds(1)).into();
|
||||
server_notebook.metadata.current_editor_uid = Some(other_uid.to_string());
|
||||
UserProfiles::handle(&app).update(&mut app, |user_profiles, _| {
|
||||
user_profiles.insert_profiles(&vec![UserProfileWithUID {
|
||||
firebase_uid: UserUid::new(other_uid),
|
||||
display_name: Some(other_email.to_string()),
|
||||
email: other_email.to_string(),
|
||||
photo_url: "".to_string(),
|
||||
}]);
|
||||
});
|
||||
|
||||
initial_load(&mut app, vec![server_notebook]).await;
|
||||
|
||||
// The notebook should load and not take the baton.
|
||||
open_future.await;
|
||||
notebook_view.read(&app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::OtherUserActive,
|
||||
email: Some(other_email.to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(notebook.mode_app_ctx(ctx), Mode::View);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Test to make sure we can eagerly grab the baton if the previous editor exits offline.
|
||||
#[test]
|
||||
fn test_baton_grab_editor_left_offline() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let other_uid = "ben@warp.dev";
|
||||
|
||||
let (_, notebook_view, _) = create_notebook(&mut app);
|
||||
|
||||
// Create a notebook with an editor.
|
||||
let mut server_notebook = mock_server_notebook("Test Notebook", "Some text");
|
||||
server_notebook.metadata.current_editor_uid = Some(other_uid.to_string());
|
||||
let cloud_notebook = CloudNotebook::new_from_server(server_notebook.clone());
|
||||
|
||||
// Add the notebook to the cloud model, with the saved editor.
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
});
|
||||
|
||||
// Open the notebook, before initial load has finished.
|
||||
let open_future = open_notebook(&mut app, ¬ebook_view, cloud_notebook);
|
||||
|
||||
// In the meantime, complete initial load with no editor.
|
||||
server_notebook.metadata.metadata_last_updated_ts =
|
||||
(Utc::now() + Duration::seconds(1)).into();
|
||||
server_notebook.metadata.current_editor_uid = None;
|
||||
initial_load(&mut app, vec![server_notebook]).await;
|
||||
|
||||
// The notebook should load and take the baton.
|
||||
open_future.await;
|
||||
notebook_view.read(&app, |notebook, ctx| {
|
||||
assert_eq!(
|
||||
notebook
|
||||
.active_notebook_data
|
||||
.as_ref(ctx)
|
||||
.current_editor(ctx),
|
||||
Some(Editor {
|
||||
state: EditorState::CurrentUser,
|
||||
email: Some(TEST_USER_EMAIL.to_string())
|
||||
})
|
||||
notebook.active_notebook_data.as_ref(ctx).current_editor(),
|
||||
None
|
||||
);
|
||||
assert_eq!(notebook.mode_app_ctx(ctx), Mode::Editing);
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -717,18 +545,13 @@ fn test_close_with_pending_changes() {
|
||||
let object = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(¬ebook_id.uid())
|
||||
.expect("Notebook should exist");
|
||||
assert!(object.metadata().has_pending_content_changes());
|
||||
|
||||
let sync_queue = SyncQueue::as_ref(ctx).queue();
|
||||
assert_eq!(sync_queue.len(), 1);
|
||||
match &sync_queue[0].1 {
|
||||
QueueItem::UpdateNotebook { model, id, .. } => {
|
||||
assert_eq!(model.title, "Test".to_string());
|
||||
assert_eq!(model.data, "Hello Some text".to_string());
|
||||
assert_eq!(id, ¬ebook_id);
|
||||
}
|
||||
other => panic!("Expected UpdateNotebook, got {other:?}"),
|
||||
}
|
||||
assert!(!object.metadata().has_pending_content_changes());
|
||||
let notebook = CloudModel::as_ref(ctx)
|
||||
.get_notebook(¬ebook_id)
|
||||
.expect("Notebook should exist");
|
||||
assert_eq!(notebook.model().title, "Test");
|
||||
assert_eq!(notebook.model().data, "Hello Some text");
|
||||
assert!(SyncQueue::as_ref(ctx).queue().is_empty());
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -776,8 +599,8 @@ fn test_close_unmodified() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_user_title_edits_synced() {
|
||||
// This tests that we only sync user edits, and don't echo back received title changes.
|
||||
fn test_only_user_title_edits_are_persisted_locally() {
|
||||
// This tests that we only persist 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;
|
||||
@@ -793,6 +616,7 @@ fn test_only_user_title_edits_synced() {
|
||||
// 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());
|
||||
let notebook_id = cloud_notebook.id;
|
||||
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(cloud_notebook.id, cloud_notebook.clone());
|
||||
@@ -824,17 +648,19 @@ fn test_only_user_title_edits_synced() {
|
||||
|
||||
ensure_saved(&mut app, ¬ebook_view).await;
|
||||
|
||||
SyncQueue::handle(&app).read(&app, |sync_queue, _| match sync_queue.queue().first() {
|
||||
Some((_, QueueItem::UpdateNotebook { model, .. })) => {
|
||||
assert_eq!(model.title.as_str(), "New Title!!!");
|
||||
}
|
||||
other => panic!("Expected notebook title update, got {other:?}"),
|
||||
app.read(|ctx| {
|
||||
let notebook = CloudModel::as_ref(ctx)
|
||||
.get_notebook(¬ebook_id)
|
||||
.expect("Notebook should exist");
|
||||
assert_eq!(notebook.model().title, "New Title!!!");
|
||||
assert!(!notebook.metadata.has_pending_content_changes());
|
||||
assert!(SyncQueue::as_ref(ctx).queue().is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflicting_notebook_read_only() {
|
||||
fn test_legacy_conflict_does_not_block_local_editing() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
initial_load(&mut app, vec![]).await;
|
||||
@@ -842,7 +668,6 @@ fn test_conflicting_notebook_read_only() {
|
||||
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();
|
||||
@@ -853,58 +678,19 @@ fn test_conflicting_notebook_read_only() {
|
||||
});
|
||||
open_notebook(&mut app, ¬ebook_view, cloud_notebook).await;
|
||||
|
||||
// The notebook should load into view mode.
|
||||
app.read(|ctx| {
|
||||
let active_notebook_data = notebook_view.as_ref(ctx).active_notebook_data.as_ref(ctx);
|
||||
assert!(active_notebook_data.has_conflicts(ctx));
|
||||
assert_eq!(active_notebook_data.mode, Mode::View);
|
||||
assert!(!active_notebook_data.has_conflicts());
|
||||
assert_eq!(active_notebook_data.mode, Mode::Editing);
|
||||
assert_eq!(
|
||||
notebook_view
|
||||
.as_ref(ctx)
|
||||
.input
|
||||
.as_ref(ctx)
|
||||
.interaction_state(ctx),
|
||||
InteractionState::Selectable
|
||||
InteractionState::Editable
|
||||
);
|
||||
});
|
||||
|
||||
// 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);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user