use std::any::Any; use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{Duration, Utc}; use derivative::Derivative; use galaxy_core::channel::Channel; use galaxy_core::features::FeatureFlag; use galaxy_graphql::queries::get_updated_cloud_objects::UpdatedObjectInput; use galaxy_graphql::scalars::time::ServerTimestamp; use galaxyui::{AppContext, SingletonEntity}; use lazy_static::lazy_static; use regex::Regex; use url::Url; use self::breadcrumbs::ContainingObject; use self::model::actions::ObjectActions; use self::model::generic_string_model::{ GenericStringModel, GenericStringObjectId, Serializer, StringModel, }; use self::model::persistence::CloudModel; use crate::appearance::Appearance; use crate::auth::UserUid; use crate::channel::ChannelState; use crate::drive::items::WarpDriveItem; use crate::drive::{ CloudObjectTypeAndId, OpenGalaxyDriveObjectArgs, OpenGalaxyDriveObjectSettings, }; use crate::persistence::ModelEvent; use crate::server::cloud_objects::update_manager::InitiatedBy; use crate::server::ids::{HashableId, HashedSqliteId, ObjectUid, ServerId, SyncId, ToServerId}; use crate::server::server_api::object::ObjectClient; use crate::server::sync_queue::{QueueItem, SerializedModel}; use crate::util::time_format::format_approx_duration_from_now_utc; use crate::workflows::{CloudWorkflow, WorkflowSource}; use crate::workspaces::user_profiles::UserProfiles; use crate::workspaces::user_workspaces::UserWorkspaces; pub mod breadcrumbs; pub mod grab_edit_access_modal; pub mod model; pub mod toast_message; pub use cloud_objects::cloud_object::*; /// A CloudObject represents /// therefore shareable and editable (i.e. Notebooks and Workflows). In order /// to support collaborative editing of these objects, they must each store local /// revision numbers to ensure a stable way of accepting and rejecting edits. /// /// Note that this trait must be object-safe and non-generic. The reason for this /// is that (a) we need to be able to store instances of it as trait objects in /// CloudModel and (b) we need to be able to support mixed collections of different /// instances of it (e.g. in the map of id -> CloudObject in CloudModel). /// /// There are two closely related types to this: /// 1) GenericCloudObject: This is the concrete generic implementation of CloudObject that /// holds onto a model of type CloudModelType and an id of type SyncId. /// 2) CloudModelType: This is a trait that defines the model type for a CloudObject - /// this is what implementors of new cloud types typically have to implement. /// /// These types are tightly coupled. In an ideal world, rust would allow a mechanism /// for us having a single interface that new model types could implement that could /// be generic on id and model types, but as far as I (zach) can tell, that's not currently /// possible. /// /// The typical usage pattern for these types is to use dyn CloudObject whenever you /// don't need access to a model or id, and to downcast to a GenericCloudObject whenever you do. /// /// This implies that, for now, *all* CloudObjects must implement GenericCloudObject. /// /// Additionally, they must support the "grab the baton" UX for editing, where any /// user can grab edit access of an object, revoking it from anyone else currently /// editing. /// /// For more info on revisions: https://docs.google.com/document/d/1SGtX_5AiSJmUxXCRk5NzGTzrC_XrxQRsio-KZOec_ng/edit /// And grab the baton: https://docs.google.com/document/d/1LgGaz8bB40AONTzC0ZFOw5Kg0SD8_RM10V_nyt3zOvY/edit#heading=h.tcup5oqi82p4 pub trait CloudObject: Debug { /// Returns the name of this model type (e.g. Workflow, Folder, Notebook) fn model_type_name(&self) -> &'static str; /// Returns the uid for this object. fn uid(&self) -> ObjectUid; /// Returns the [`SyncId`] that currently identifies this object. fn sync_id(&self) -> SyncId; /// Returns the id used to index into sqlite, this is the object's UID with its type /// prefixed, such as "Workflow-{UID}" fn hashed_sqlite_id(&self) -> HashedSqliteId; /// Returns the CloudObjectMetadata struct associated with this object. fn metadata(&self) -> &CloudObjectMetadata; /// Returns a mutable reference to the CloudObjectMetadata struct associated with this object. fn metadata_mut(&mut self) -> &mut CloudObjectMetadata; /// Returns the CloudObjectPermissions struct associated with this object. fn permissions(&self) -> &CloudObjectPermissions; /// Returnsa mutable reference to the CloudObjectPermissions struct associated with this object. fn permissions_mut(&mut self) -> &mut CloudObjectPermissions; /// Returns the ObjectType i.e. 'Workflow' or 'Notebook' fn object_type(&self) -> ObjectType; /// Returns the CloudObjectTypeAndId for this object. fn cloud_object_type_and_id(&self) -> CloudObjectTypeAndId; /// Sets the server id on this object. fn set_server_id(&mut self, server_id: ServerId); /// Returns whether this object can be moved to the given space. fn can_move_to_space(&self, _space: Space, _app: &AppContext) -> bool { true } // Whether to clear this object from the local SQLite DB on a unique key conflict. fn should_clear_on_unique_key_conflict(&self) -> bool { false } /// Whether to show a warning if this object is unsaved at quit time /// (which typically blocks the user from quitting) fn warn_if_unsaved_at_quit(&self) -> bool { true } /// Returns the "upsert" event for inserting / updating this object in the SQLite DB. fn upsert_event(&self) -> ModelEvent; // Returns the name of the object. fn display_name(&self) -> String; /// Returns an optional UpdatedObjectInput to use during initial load, where /// the object's timestamps are sent to the server for comparison fn versions(&self, app: &AppContext) -> Option; /// Returns an optional sync queue item of this object that would allow it to /// created properly on the server. Returns None if it's already been created /// server-side. fn create_object_queue_item( &self, entrypoint: CloudObjectEventEntrypoint, initiated_by: InitiatedBy, ) -> Option; /// Returns a sync queue item of this object that would allow it to be updated /// properly on the server. Takes an optional revision_ts to set as the revision /// in the sync queue item. fn update_object_queue_item(&self, revision_ts: Option) -> QueueItem; /// Returns whether this model type should render as a warp drive item. fn renders_in_warp_drive(&self) -> bool; /// Returns whether this model type should show update toasts in the UI. fn should_show_activity_toasts(&self) -> bool { true } /// Creates a new Warp Drive item for this object. Returns None if this /// object is not rendered in Warp Drive. fn to_warp_drive_item(&self, appearance: &Appearance) -> Option>; /// Returns the web link of this object. Will return none if we do not support web links /// for this particular object (i.e. if it's not yet sync'd to the server, or if we don't /// yet support linking to that object type). /// /// The format of an objects link follows the pattern: /// {channel}/drive/{object-type}/{object-name}-{uid}. For more information on this, /// see the linkable objects PRD (https://docs.google.com/document/d/1VQZ4sgLs4M9r2NDYyecfOalLlPmcf2fd_rDdqG35Zd8/edit) /// or tech doc (https://docs.google.com/document/d/1_TK19mRcD_0eLwbr5uFRabacIzfKocfahjEvoRcs5ko/edit) fn object_link(&self) -> Option; /// The space containing this object. /// /// If the object is shared with the current user, the space will reflect that, not the /// object's actual owner. fn space(&self, app: &AppContext) -> Space { UserWorkspaces::as_ref(app).owner_to_space(self.permissions().owner, app) } /// Whether or not this object can be "left". For shared objects, this removes all ACLs for the /// current user. Only top-level items in the shared space can be left. fn can_leave(&self, app: &AppContext) -> bool { if self.space(app) == Space::Shared { self.metadata() .folder_id .is_none_or(|parent| CloudModel::as_ref(app).get_folder(&parent).is_none()) } else { false } } /// Returns the name of the containing "object" for this object. /// This could be a folder, or in the case of top-level objects, /// the name of the space it belongs to. fn containing_object_name(&self, app: &AppContext) -> String { self.containing_objects_path(app) .into_iter() .next_back() .expect("Object should have at least one ancestor") .name } // Returns the path of all the containing "objects" for this object. // This could include folders or spaces. fn containing_objects_path(&self, app: &AppContext) -> Vec { let space = self.space(app); match self.metadata().folder_id { Some(folder_id) => { let cloud_model = CloudModel::as_ref(app); if let Some(folder) = cloud_model.get_folder_by_uid(&folder_id.uid()) { let mut path = vec![]; let ancestors = folder.containing_objects_path(app); path.extend(ancestors); path.push(folder.into()); path } else { // if for whatever reason the folder id is messed up, // just default to showing the top-level space it wound up in vec![space.into_containing_object(app)] } } None => vec![space.into_containing_object(app)], } } fn breadcrumbs(&self, app: &AppContext) -> String { self.containing_objects_path(app) .into_iter() .map(|object| object.name) .collect::>() .join(" / ") } /// Returns whether this CloudObject is in the given space fn is_in_space(&self, space: Space, app: &AppContext) -> bool { self.space(app) == space } fn is_welcome_object(&self) -> bool { self.metadata().is_welcome_object } /// Returns the direct location of the object. If the object /// is not in a folder, this will be the object's space. Otherwise, it will /// be the folder the object is placed in directly, even if that folder is nested. fn location(&self, cloud_model: &CloudModel, app: &AppContext) -> CloudObjectLocation { if let Some(folder_id) = self.metadata().folder_id { if cloud_model.get_folder(&folder_id).is_some() { return CloudObjectLocation::Folder(folder_id); } } CloudObjectLocation::Space(self.space(app)) } /// Return true is this object or any of its ancestors are trashed. Also returns true /// if a cycle is detected. fn is_trashed(&self, cloud_model: &CloudModel) -> bool { self.is_trashed_internal(cloud_model, &mut HashSet::new()) } /// Helper function for is_trashed. fn is_trashed_internal( &self, cloud_model: &CloudModel, ancestors: &mut HashSet, ) -> bool { // Base case: If the object is trashed, return true. if self.metadata().trashed_ts.is_some() { return true; } // Else: return true if the object's parent is trashed. Return false if the object has no parent. match self.metadata().folder_id.map(|parent_id| parent_id.uid()) { Some(hashed_parent_id) => { // We need to check for cycles to avoid causing a stack overflow. If a cycle is detected, return that the object is trashed. if ancestors.contains(&hashed_parent_id) { return true; } ancestors.insert(hashed_parent_id.clone()); match cloud_model.get_by_uid(&hashed_parent_id) { Some(parent) => parent.is_trashed_internal(cloud_model, ancestors), None => { // If the object has a parent, but the parent is not in CloudModel, assume // the object is shared, but not its parent. For backwards compatibility, // if sharing is disabled, default to trashed rather than untrashed. !FeatureFlag::SharedWithMe.is_enabled() } } } None => false, } } /// Returns whether this object has conflicting changes with the server. fn has_conflicting_changes(&self) -> bool; /// Returns the revision of the conflicting object, if any. /// This is used for object-safe access to conflict information. fn conflicting_object_revision(&self) -> Option; /// Clears the conflict status back to NoConflicts. fn clear_conflict_status(&mut self); /// Updates the object to deal with any conflict status. fn replace_object_with_conflict(&mut self); /// Sets the content sync status of this object to `InFlight` (if it wasn't already) and /// increments the number of in flight requests tracked in the `InFlight` enum. fn increment_in_flight_request_count(&mut self) { let new_reqs = match &self.metadata().pending_changes_statuses.content_sync_status { CloudObjectSyncStatus::InFlight(reqs) => reqs.0 + 1, _ => 1, }; self.set_pending_content_changes_status(CloudObjectSyncStatus::InFlight( NumInFlightRequests(new_reqs), )) } /// Decrements the number of in flight requests tracked in this object's `InFlight` enum. If /// that number becomes 0, it's no longer in flight, so it will be set to `status_if_no_reqs`. /// Returns true if the object is no longer in flight. fn decrement_in_flight_request_count( &mut self, status_if_no_reqs: CloudObjectSyncStatus, ) -> bool { match &self.metadata().pending_changes_statuses.content_sync_status { CloudObjectSyncStatus::InFlight(reqs) => { if reqs.0 - 1 == 0 { self.set_pending_content_changes_status(status_if_no_reqs); return true; } else { self.set_pending_content_changes_status(CloudObjectSyncStatus::InFlight( NumInFlightRequests(reqs.0 - 1), )); return false; } } _ => log::error!( "called decrement_in_flight_request_count with a non-`InFlight` cloud status" ), } true } /// Sets the content change status on this object's metadata fn set_pending_content_changes_status( &mut self, pending_content_changes_status: CloudObjectSyncStatus, ) { self.metadata_mut() .pending_changes_statuses .content_sync_status = pending_content_changes_status; } /// Whether or not this object can be exported. fn can_export(&self) -> bool; /// Returns this object as a ref to the Any type. Needed for typecasts. fn as_any(&self) -> &dyn Any; /// Returns this object as a mut ref to Any type. Needed for typecasts. fn as_any_mut(&mut self) -> &mut dyn Any; /// Returns the trait object as a concrete type reference by downcasting it. /// Returns None if the downcast fails. fn as_model_type(cloud_object: &dyn CloudObject) -> Option<&GenericCloudObject> where Self: Sized, K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { cloud_object .as_any() .downcast_ref::>() } /// Returns the trait object as a concrete mutable type reference by downcasting it. /// Returns None if the downcast fails. fn as_model_type_mut( cloud_object: &mut dyn CloudObject, ) -> Option<&mut GenericCloudObject> where Self: Sized, K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { cloud_object .as_any_mut() .downcast_mut::>() } /// Returns a cloned boxed version of this cloud object. /// Note that we can't force the CloudObject trait to derive from Cloned /// directly because that would make the trait not object safe. This /// is a workaround. fn clone_box(&self) -> Box; } /// Defines a common trait for cloud models to implement. /// The "model" is the domain specific piece of data for a cloud object, /// e.g. it contains the notebook, workflow, or folder specific data, but has /// no logic around metadata, permissions, or sync status. /// /// See the comments for CloudObject to understand the relationship between /// this trait, CloudObject and GenericCloudObject. They are tightly coupled. /// /// When building new model types (e.g. for settings or launch configs) we should just /// have to implement this trait, and not the entire CloudObject trait. #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] pub trait CloudModelType: Debug + Clone + Send + Sync { /// The associated CloudObject type for this model (e.g. CloudNotebook, CloudWorkflow, etc) type CloudObjectType: CloudObject + 'static; // TODO: @ianhodge - remove for sync ID refactor. type IdType: HashableId + ToServerId + Debug + Into + Clone + 'static; /// Returns the name of this model type (e.g. Workflow, Folder, Notebook) fn model_type_name(&self) -> &'static str; /// Returns the CloudObjectTypeAndId for this object. fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId; /// Returns the ObjectType for this model. fn object_type(&self) -> ObjectType; /// Returns whether this model type should render as a warp drive item. fn renders_in_warp_drive(&self) -> bool; /// Returns whether this model type should show update toasts in the UI. fn should_show_activity_toasts(&self) -> bool { true } /// Whether to show a warning if this model is unsaved at quit time /// (which typically blocks the user from quitting) fn warn_if_unsaved_at_quit(&self) -> bool { true } /// Creates a new warp drive item for this model type. Returns None /// if this object does not render in Warp Drive. fn to_warp_drive_item( &self, id: SyncId, appearance: &Appearance, object: &Self::CloudObjectType, ) -> Option>; /// Returns the display name for this model (e.g. to show in the Warp Drive index) fn display_name(&self) -> String; /// Sets the display name to show in the Warp Drive Index. Setting the name /// is not currently supported by all object types, hence the default empty /// implementation. fn set_display_name(&mut self, _name: &str) {} /// Returns the upsert event for putting this model into the SQLite database. fn upsert_event(params: CloudObjectUpsertParams) -> ModelEvent where Self: Sized; /// Returns a bulk upsert event for putting a list of this model into the SQLite database. fn bulk_upsert_event(objects: Vec>) -> ModelEvent where Self: Sized; /// Returns the sync queue item for creating this model on the server. fn create_object_queue_item( &self, object: &Self::CloudObjectType, entrypoint: CloudObjectEventEntrypoint, initiated_by: InitiatedBy, ) -> Option; /// Returns the sync queue item for updating this model on the server. /// Takes an optional revision timestamp to set in the queue item. fn update_object_queue_item( &self, revision_ts: Option, object: &Self::CloudObjectType, ) -> QueueItem; /// Returns a serialized model. fn serialized(&self) -> SerializedModel; /// Sends a request to the server to create this model. async fn send_create_request( object_client: Arc, request: CreateObjectRequest, ) -> Result; /// Sends a request to the server to update this model. async fn send_update_request( &self, object_client: Arc, server_id: ServerId, revision: Option, ) -> Result>>; /// Returns whether this model type supports being moved to the given space. fn can_move_to_space(&self, _current_space: Space, _new_space: Space) -> bool { true } /// Returns whether this model type should clear on a unique key conflict. fn should_clear_on_unique_key_conflict(&self) -> bool { false } /// Returns whether this model type supports web links fn supports_linking(&self) -> bool { true } /// Returns whether this model type should be updated after a server conflict. /// Note that for now the only model type that this is relevant for is Notebooks, /// where we show a banner in case of conflicts and ask users to manually take action. /// For other types we typically just want to replace the local object with the server /// revision, which doesn't go through this code path. fn should_update_after_server_conflict(&self) -> bool; /// Whether this model type can be exported. fn can_export(&self) -> bool { false } } /// Provides app-local typed lookup helpers for generic cloud object aliases. pub trait CloudObjectLookup: Sized + Clone { fn get_all(app: &AppContext) -> Vec; fn get_by_id<'a>(sync_id: &'a SyncId, app: &'a AppContext) -> Option<&'a Self>; } impl CloudObjectLookup for GenericCloudObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn get_all(app: &AppContext) -> Vec { CloudModel::as_ref(app) .get_all_objects_of_type::() .cloned() .collect() } fn get_by_id<'a>(sync_id: &'a SyncId, app: &'a AppContext) -> Option<&'a Self> { CloudModel::as_ref(app).get_object_of_type::(sync_id) } } /// Marks string model payloads that can be looked up by UUID. pub trait CloudObjectUuid { fn uuid(&self) -> uuid::Uuid; } /// Provides app-local UUID lookups for cloud objects whose payload exposes a UUID. pub trait CloudObjectUuidLookup: Sized { fn get_by_uuid<'a>(uuid: &'a uuid::Uuid, app: &'a AppContext) -> Option<&'a Self>; } impl CloudObjectUuidLookup for GenericCloudObject> where T: StringModel< CloudObjectType = GenericCloudObject>, > + CloudObjectUuid, S: Serializer, { fn get_by_uuid<'a>(uuid: &'a uuid::Uuid, app: &'a AppContext) -> Option<&'a Self> { CloudModel::as_ref(app) .get_all_objects_of_type::>() .find(|object| object.model().string_model.uuid() == *uuid) } } lazy_static! { static ref SPACE_DETECT_RE: Regex = Regex::new(r"\s+").expect("Expect regex to be valid"); static ref SAFE_URL_CHAR_RE: Regex = Regex::new(r"[^a-zA-Z0-9\s-]").expect("Expect regex to be valid"); } impl CloudObject for GenericCloudObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn model_type_name(&self) -> &'static str { self.model().model_type_name() } fn uid(&self) -> ObjectUid { self.id.uid() } fn hashed_sqlite_id(&self) -> HashedSqliteId { self.id.sqlite_uid_hash(self.object_type().into()) } fn sync_id(&self) -> SyncId { self.id } fn should_show_activity_toasts(&self) -> bool { self.model().should_show_activity_toasts() } fn warn_if_unsaved_at_quit(&self) -> bool { self.model().warn_if_unsaved_at_quit() } fn metadata(&self) -> &CloudObjectMetadata { &self.metadata } fn metadata_mut(&mut self) -> &mut CloudObjectMetadata { &mut self.metadata } fn permissions(&self) -> &CloudObjectPermissions { &self.permissions } fn permissions_mut(&mut self) -> &mut CloudObjectPermissions { &mut self.permissions } fn object_type(&self) -> ObjectType { self.model().object_type() } fn cloud_object_type_and_id(&self) -> CloudObjectTypeAndId { self.model().cloud_object_type_and_id(self.id) } fn should_clear_on_unique_key_conflict(&self) -> bool { self.model().should_clear_on_unique_key_conflict() } fn can_move_to_space(&self, space: Space, app: &AppContext) -> bool { self.model().can_move_to_space(self.space(app), space) } fn has_conflicting_changes(&self) -> bool { self.conflict_status.has_conflicts() } fn conflicting_object_revision(&self) -> Option { match &self.conflict_status { ConflictStatus::ConflictingChanges { object } => Some(object.metadata.revision.clone()), ConflictStatus::NoConflicts => None, } } fn clear_conflict_status(&mut self) { self.conflict_status = ConflictStatus::NoConflicts; } fn replace_object_with_conflict(&mut self) { let mut new_conflict = ConflictStatus::NoConflicts; std::mem::swap(&mut new_conflict, &mut self.conflict_status); self.set_pending_content_changes_status(CloudObjectSyncStatus::NoLocalChanges); if let ConflictStatus::ConflictingChanges { object } = new_conflict { if self.model().should_update_after_server_conflict() { // Update metadata revision from the server object. self.metadata.update_revision_from_server(&object.metadata); // Update the model from the server. self.set_model(object.model.clone()); // Update conflict status - this may create a new conflict if there are pending changes. if self.metadata.has_pending_content_changes() { self.conflict_status = ConflictStatus::ConflictingChanges { object }; } else { self.conflict_status = ConflictStatus::NoConflicts; } } } } fn set_server_id(&mut self, server_id: ServerId) { self.id = SyncId::ServerId(server_id); } fn object_link(&self) -> Option { if !self.model().supports_linking() { return None; } let display_name = self.model().display_name(); // First remove all the url unsafe chars let name_without_unsafe_chars = SAFE_URL_CHAR_RE.replace_all(display_name.trim(), ""); // Then turn all the spaces into dashes let link_safe_name = SPACE_DETECT_RE.replace_all(&name_without_unsafe_chars, "-"); match &self.id { SyncId::ClientId(_) => None, SyncId::ServerId(id) => { let object_type = self.object_type(); let object_type_for_link = if self .as_any() .downcast_ref::() .is_some_and(|w| w.model().data.is_agent_mode_workflow()) { "prompt".to_string() } else { object_type.to_string() }; let mut link = format!( "{}/drive/{}/{}-{}", ChannelState::server_root_url(), object_type_for_link, link_safe_name, id.uid() ); // If this is a preview build, ensure the link routes to a preview build. if matches!(ChannelState::channel(), Channel::Preview) { link.push_str("?preview=true"); } Some(link) } } } fn upsert_event(&self) -> ModelEvent { M::upsert_event(self.upsert_params(self.object_type())) } fn display_name(&self) -> String { self.model().display_name() } fn versions(&self, app: &AppContext) -> Option { match (self.id, self.metadata.revision.as_ref()) { (SyncId::ServerId(id), Some(revision)) => { let actions_ts = ObjectActions::as_ref(app) .get_latest_processed_at_ts(&self.id.uid()) .map(|t| t.into()); Some(UpdatedObjectInput { uid: id.into(), revision_ts: revision.timestamp(), metadata_ts: self.metadata.metadata_last_updated_ts, permissions_ts: self.permissions.permissions_last_updated_ts, actions_ts, }) } _ => None, } } fn create_object_queue_item( &self, entrypoint: CloudObjectEventEntrypoint, initiated_by: InitiatedBy, ) -> Option { self.model() .create_object_queue_item(self, entrypoint, initiated_by) } fn update_object_queue_item(&self, revision_ts: Option) -> QueueItem { self.model().update_object_queue_item(revision_ts, self) } fn renders_in_warp_drive(&self) -> bool { self.model().renders_in_warp_drive() } fn to_warp_drive_item(&self, appearance: &Appearance) -> Option> { self.model().to_warp_drive_item(self.id, appearance, self) } fn can_export(&self) -> bool { self.model().can_export() } fn as_any(&self) -> &dyn Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } fn clone_box(&self) -> Box { Box::new(self.clone()) } } /// Extracts the server id and object type from a (caller validated) Drive link. /// Intended use is deriving metadata from links such that Warp objects /// can be opened natively in Warp with no web interaction. pub fn extract_server_id_and_object_type_from_warp_drive_link( url: &Url, ) -> Option { let server_id = url .path_segments() .and_then(|mut segments| segments.next_back()) .and_then(|last_segment| last_segment.split('-').next_back()) .map(|id| id.to_string()); let object_type = url.path_segments().and_then(|mut segments| segments.nth(1)); // Parse the object portion of the path segment (warp.dev/drive/{object}) // into an object type let object_type = match object_type { Some("notebook") => ObjectType::Notebook, Some("workflow") => ObjectType::Workflow, _ => return None, }; let query_string: HashMap<_, _> = url.query_pairs().collect(); let focused_folder_id: Option = query_string .get("focused_folder_id") .and_then(|s| s.to_string().try_into().ok()); let invitee_email: Option = query_string.get("invitee_email").map(|s| s.to_string()); Some(OpenGalaxyDriveObjectArgs { object_type, server_id: match server_id { Some(server_id) => server_id.try_into().ok()?, _ => return None, }, settings: OpenGalaxyDriveObjectSettings { focused_folder_id, invitee_email, }, }) } impl<'a, K, M> From<&'a dyn CloudObject> for Option<&'a GenericCloudObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn from(value: &'a dyn CloudObject) -> Self { as CloudObject>::as_model_type(value) } } impl<'a, K, M> From<&'a Box> for Option<&'a GenericCloudObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn from(value: &'a Box) -> Self { as CloudObject>::as_model_type(value.as_ref()) } } impl<'a, K, M> From<&'a mut Box> for Option<&'a mut GenericCloudObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn from(value: &'a mut Box) -> Self { as CloudObject>::as_model_type_mut(value.as_mut()) } } impl Clone for Box { fn clone(&self) -> Self { self.clone_box() } } impl From<&dyn CloudObject> for ObjectType { fn from(value: &dyn CloudObject) -> Self { value.object_type() } } impl From<&Box> for ObjectType { fn from(value: &Box) -> Self { >::from(value.as_ref()) } } /// Extension trait for CloudObjectMetadata with methods that require AppContext. pub trait CloudObjectMetadataExt { /// Returns a semantic summary of the last edit to the object. For example, "Alice edited 4 weeks ago". /// Returns None if the revision and last_editor are None. fn semantic_editing_history(&self, app: &AppContext) -> Option; /// Returns a semantic summary of the object's creator. For example, "Alice" or "joan@warp.dev". #[cfg_attr(target_family = "wasm", expect(dead_code))] fn semantic_creator(&self, app: &AppContext) -> Option; /// Returns semantic summary of countdown of days until permadeletion. /// Ex: "27 days until permanent deletion" fn semantic_permadeletion_countdown(&self, app: &AppContext) -> Option; } impl CloudObjectMetadataExt for CloudObjectMetadata { fn semantic_editing_history(&self, app: &AppContext) -> Option { let user_profiles = UserProfiles::as_ref(app); // First, the editor. For example, "Joan Didion" or "joan@warp.dev". let editor_string = self .last_editor_uid .as_ref() .and_then(|uid| user_profiles.displayable_identifier_for_uid(UserUid::new(uid))); // Second, the time elapsed since the edit. For example, "just now" or "3 months ago". let time_ago_string = self .revision .clone() .map(|r| format_approx_duration_from_now_utc(r.utc())); let full_string = match (editor_string, time_ago_string) { (Some(name), Some(time_ago)) if name.is_empty() => format!("Edited {time_ago}"), (Some(name), Some(time_ago)) => format!("{name} edited {time_ago}"), (None, Some(time_ago)) => format!("Edited {time_ago}"), (Some(name), None) => format!("Last edited by {name}"), _ => return None, }; Some(full_string) } fn semantic_creator(&self, app: &AppContext) -> Option { // Todo(Jack): add creation ts. let user_profiles = UserProfiles::as_ref(app); self.creator_uid .as_ref() .and_then(|uid| user_profiles.displayable_identifier_for_uid(UserUid::new(uid))) } fn semantic_permadeletion_countdown(&self, app: &AppContext) -> Option { // 2 cases: // 1) Either the object is a root level object. // 2) Or the object is inside folder(s), call recursive function to get trashed_ts of top level folder. if let Some(trashed_ts) = self .trashed_ts .or_else(|| get_top_folder_trashed_ts(self.folder_id, app)) { let deletion_time = trashed_ts.utc() + Duration::days(31); let current_time = Utc::now(); let days_left = deletion_time.signed_duration_since(current_time).num_days(); let full_string = match days_left { 0 | 1 => "1 day until permanent deletion".to_string(), _ => format!("{days_left} days until permanent deletion"), }; Some(full_string) } else { None } } } /// Helper function to retrieve trashed_ts of top level folder given a folder_id of an object. fn get_top_folder_trashed_ts( folder_id: Option, app: &AppContext, ) -> Option { let mut folder_id = folder_id; let cloud_model = CloudModel::as_ref(app); while let Some(current_folder_id) = folder_id { // If the parent folder isn't in CloudModel, short-circuit so we don't loop forever. let folder = cloud_model.get_folder_by_uid(¤t_folder_id.uid())?; if let Some(_parent_folder_id) = folder.metadata.folder_id { folder_id = folder.metadata.folder_id } else { return folder.metadata.trashed_ts; } } None } pub use cloud_object_client::{ ObjectDeleteResult, ObjectMetadataUpdateResult, ObjectPermissionsUpdateData, }; pub use cloud_object_models::{ ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment, ServerCloudAgentConfig, ServerCloudObject, ServerEnvVarCollection, ServerFolder, ServerMCPServer, ServerNotebook, ServerPreference, ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflow, ServerWorkflowEnum, TryFromGql, }; #[derive(Default, Clone, Copy, Debug, Eq, Derivative)] #[derivative(PartialEq, Hash)] pub enum Space { /// The current user's personal drive. #[default] Personal, /// A team that the current user belongs to. Team { team_uid: ServerId }, /// An object shared from a drive the user is not a member of. Shared, } impl Space { pub fn name(&self, app: &AppContext) -> String { match self { Space::Personal => "Personal".to_string(), Space::Team { team_uid, .. } => { let user_workspaces = UserWorkspaces::as_ref(app); if let Some(team) = user_workspaces.team_from_uid(*team_uid) { team.name.clone() } else { "Team".to_string() } } Space::Shared => "Shared with me".to_string(), } } } /// Enum for specifying the location of a warp drive object. /// Objects can live in top level spaces, or a specific folder. #[derive(Eq, PartialEq, Copy, Clone, Debug, Hash)] pub enum CloudObjectLocation { Space(Space), Folder(SyncId), Trash, } impl From for WorkflowSource { fn from(space: Space) -> Self { match space { Space::Personal => WorkflowSource::PersonalCloud, Space::Team { team_uid } => WorkflowSource::Team { team_uid }, // TODO(ben): Model sharing in workflow telemetry. Space::Shared => WorkflowSource::PersonalCloud, } } } impl From for WorkflowSource { fn from(owner: Owner) -> WorkflowSource { match owner { // TODO(ben): Represent shared objects in telemetry. Owner::User { .. } => Self::PersonalCloud, Owner::Team { team_uid } => Self::Team { team_uid }, } } }