Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
use super::{CloudObject, Space};
use crate::{
drive::{folders::CloudFolder, items::WarpDriveItemId, CloudObjectTypeAndId},
ui_components::breadcrumb::Breadcrumb,
};
use warpui::AppContext;
// Encapsulates an object that can contain other objects, and keeps
// information necessary to show breadcrumbs.
#[derive(Clone, Debug)]
pub struct ContainingObject {
pub name: String,
pub kind: ContainingObjectKind,
}
impl Breadcrumb for ContainingObject {
fn label(&self) -> String {
self.name.clone()
}
fn enabled(&self) -> bool {
true
}
}
impl From<&CloudFolder> for ContainingObject {
fn from(folder: &CloudFolder) -> Self {
Self {
name: folder.display_name().clone(),
kind: ContainingObjectKind::Object(CloudObjectTypeAndId::Folder(folder.id)),
}
}
}
impl Space {
pub fn into_containing_object(self, app: &AppContext) -> ContainingObject {
ContainingObject {
name: self.name(app).clone(),
kind: ContainingObjectKind::Space(self),
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum ContainingObjectKind {
Space(Space),
Object(CloudObjectTypeAndId),
}
impl ContainingObjectKind {
pub fn into_item_id(self) -> WarpDriveItemId {
match self {
ContainingObjectKind::Space(space) => WarpDriveItemId::Space(space),
ContainingObjectKind::Object(object) => WarpDriveItemId::Object(object),
}
}
}
@@ -0,0 +1,153 @@
use warpui::fonts::{Properties, Style, Weight};
use warpui::platform::Cursor;
use warpui::ui_components::button::ButtonVariant;
use warpui::{elements::Element, AppContext, View};
use warpui::{Entity, SingletonEntity, TypedActionView, ViewContext};
use crate::appearance::Appearance;
use crate::ui_components::buttons::close_button;
use crate::ui_components::dialog::{dialog_styles, Dialog};
use warpui::elements::{Container, MouseStateHandle, Text};
use warpui::ui_components::components::UiComponent;
const EDIT_ANYWAY_CTA_LABEL: &str = "Edit anyway";
const CANCEL_CTA_LABEL: &str = "Cancel";
const EDIT_ANYWAY_TEXT: &str =
"If you take edit controls, the current editor will be forced into view mode";
const CURRENTLY_EDITED_LABEL: &str = "This notebook is currently being edited";
#[derive(Default)]
struct MouseStateHandles {
close_button: MouseStateHandle,
edit_anyway_button: MouseStateHandle,
cancel_button: MouseStateHandle,
}
pub struct GrabEditAccessModal {
mouse_state_handles: MouseStateHandles,
}
impl Default for GrabEditAccessModal {
fn default() -> Self {
Self::new()
}
}
impl GrabEditAccessModal {
pub fn new() -> Self {
Self {
mouse_state_handles: Default::default(),
}
}
pub fn close(&self, ctx: &mut ViewContext<Self>) {
ctx.emit(GrabEditAccessModalEvent::Close);
}
pub fn grab_edit_access(&self, ctx: &mut ViewContext<Self>) {
// TODO @ianhodge actually make the call to grab access on the server
ctx.emit(GrabEditAccessModalEvent::GrabEditAccess);
}
pub fn render_modal(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_builder = appearance.ui_builder();
let description = Text::new(EDIT_ANYWAY_TEXT, appearance.ui_font_family(), 13.)
.with_style(Properties {
style: Style::Normal,
weight: Weight::Bold,
})
.with_color(theme.active_ui_text_color().into())
.finish();
let close_button = close_button(appearance, self.mouse_state_handles.close_button.clone())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(GrabEditAccessModalAction::Close))
.with_cursor(Cursor::PointingHand)
.finish();
Dialog::new(
CURRENTLY_EDITED_LABEL.to_string(),
None,
dialog_styles(appearance),
)
.with_close_button(close_button)
.with_child(description)
.with_bottom_row_child(
Container::new(
ui_builder
.button(
ButtonVariant::Basic,
self.mouse_state_handles.cancel_button.clone(),
)
.with_text_label(CANCEL_CTA_LABEL.to_string())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(GrabEditAccessModalAction::Close)
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
.with_padding_right(5.)
.finish(),
)
.with_bottom_row_child(
ui_builder
.button(
ButtonVariant::Warn,
self.mouse_state_handles.edit_anyway_button.clone(),
)
.with_text_label(EDIT_ANYWAY_CTA_LABEL.to_string())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(GrabEditAccessModalAction::GrabEditAccess)
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
.build()
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(GrabEditAccessModalAction::Close))
.finish()
}
}
impl Entity for GrabEditAccessModal {
type Event = GrabEditAccessModalEvent;
}
#[derive(PartialEq, Eq)]
pub enum GrabEditAccessModalEvent {
Close,
GrabEditAccess,
}
#[derive(Clone, Copy, Debug)]
pub enum GrabEditAccessModalAction {
Close,
GrabEditAccess,
}
impl TypedActionView for GrabEditAccessModal {
type Action = GrabEditAccessModalAction;
fn handle_action(&mut self, action: &GrabEditAccessModalAction, ctx: &mut ViewContext<Self>) {
use GrabEditAccessModalAction::*;
match action {
Close => self.close(ctx),
GrabEditAccess => self.grab_edit_access(ctx),
}
}
}
impl View for GrabEditAccessModal {
fn ui_name() -> &'static str {
"GrabEditAccessModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
self.render_modal(appearance)
}
}
File diff suppressed because it is too large Load Diff
+478
View File
@@ -0,0 +1,478 @@
use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::{
persistence::model::PersistedObjectAction,
server::ids::{parse_sqlite_id_to_uid, HashedSqliteId, ObjectUid},
};
pub enum ObjectActionsEvent {}
/// The type of action that occurred on an object, such as an execution, selection, so on
/// and so forth.
#[derive(Clone, Debug, PartialEq)]
pub enum ObjectActionType {
Execute,
}
// In order to convert from a graphql type and from a SQLite read, the action type
// implements to_string().
//
// Temporarily suppress clippy warnings about the `ToString` impl until we
// move `ObjectType` away from using `std::fmt::Display` for serialization.
#[allow(clippy::to_string_trait_impl)]
impl ToString for ObjectActionType {
fn to_string(&self) -> String {
match self {
ObjectActionType::Execute => String::from("EXECUTE"),
}
}
}
impl ObjectActionType {
fn singular(&self) -> String {
match self {
ObjectActionType::Execute => "run".to_string(),
}
}
fn plural(&self) -> String {
match self {
ObjectActionType::Execute => "runs".to_string(),
}
}
}
/// We track object actions, both those that have been sent to the server and not, through this
/// type. A single ObjectAction represents an object_id, action pair and a subtype that contains data
/// about the action(s). Each ObjectAction either represents one action or a summary of identical actions
/// that occurred at different times. We summarize old actions in order to save memory footprint on the client.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjectAction {
pub action_type: ObjectActionType,
pub uid: ObjectUid,
pub hashed_sqlite_id: HashedSqliteId,
// This action either represents one action or a consolidation of multiple actions.
pub action_subtype: ObjectActionSubtype,
}
impl ObjectAction {
pub fn is_pending(&self) -> bool {
match self.action_subtype {
ObjectActionSubtype::SingleAction { pending, .. } => pending,
_ => false,
}
}
}
impl TryFrom<PersistedObjectAction> for ObjectAction {
type Error = ();
fn try_from(other: PersistedObjectAction) -> Result<Self, Self::Error> {
// Each persisted object action is either a single action or a bundled action.
// If there's any inconsistencies from the SQL row, we return an error.
let action_subtype = if let Some(count) = other.count {
let oldest_timestamp = other
.oldest_timestamp
.as_ref()
.map(|time| time.and_utc())
.ok_or(())?;
let latest_timestamp = other
.latest_timestamp
.as_ref()
.map(|time| time.and_utc())
.ok_or(())?;
// When the db row is a bundled action, the processed_at_timestamp field refers
// to the latest processed_at_timestamp in the bundle. Because bundled actions come
// from the server, this is a value, not an option.
let latest_processed_at_timestamp = other
.processed_at_timestamp
.as_ref()
.map(|time| time.and_utc())
.ok_or(())?;
ObjectActionSubtype::BundledActions {
count,
oldest_timestamp,
latest_timestamp,
latest_processed_at_timestamp,
}
} else {
let timestamp = other
.timestamp
.as_ref()
.map(|time| time.and_utc())
.ok_or(())?;
let pending = other.pending.ok_or(())?;
// The processed_at_timestamp is still None when the action hasn't been synced.
let processed_at_timestamp = other
.processed_at_timestamp
.as_ref()
.map(|time| time.and_utc());
ObjectActionSubtype::SingleAction {
timestamp,
data: other.data,
pending,
processed_at_timestamp,
}
};
// The object_sync_id stored in SQLite is the hashed id that's used to index into the ObjectActions
// model.
let hashed_object_id = other.hashed_object_id;
let action_type = match other.action.as_str() {
s if s == ObjectActionType::Execute.to_string() => ObjectActionType::Execute,
_ => return Err(()),
};
// NOTE: This is needed since we only store the sqlite hash, but we need the uid (the second part of the hash)
// to index into CloudModel and store the object actions in memory.
let uid = parse_sqlite_id_to_uid(hashed_object_id.clone())?;
Ok(ObjectAction {
uid: uid.to_string(),
hashed_sqlite_id: hashed_object_id,
action_type,
action_subtype,
})
}
}
/// The server communicates the action history of an object via an "ObjectActionHistory" type that
/// contains the uid, a list of actions (single or bundled), and the timestamp of the most recent action
/// (which is redundant from the list of actions). We use this type to convert from the graphql layer into
/// an identical type the sync_queue and update_manager can pass around.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjectActionHistory {
pub uid: ObjectUid,
pub hashed_sqlite_id: HashedSqliteId,
pub latest_processed_at_timestamp: DateTime<Utc>,
pub actions: Vec<ObjectAction>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ObjectActionSubtype {
SingleAction {
// When the action occurred.
timestamp: DateTime<Utc>,
// When the action was processed by the server (used to order actions against eachother).
// None if the action has not been synced.
processed_at_timestamp: Option<DateTime<Utc>>,
// A JSON representation of anything else we might want to track about the action.
// For example, the exit code of a workflow execution.
data: Option<String>,
// Whether or not this action has been successfully synced to the server.
pending: bool,
},
BundledActions {
// The number of distinct actions that are coalesced into one entry here.
count: i32,
// The timestamp of the oldest action within this bundle.
oldest_timestamp: DateTime<Utc>,
// The timestamp of the most recent action within the bundle.
latest_timestamp: DateTime<Utc>,
// The most recent processed_at timestamp contained in the bundle (used to order actions and determine
// how up-to-date the client's actions are.)
latest_processed_at_timestamp: DateTime<Utc>,
},
}
/// A singleton model representing the actions that have occurred on a per-object basis. These
/// represent actions taken by the user or by teammates. The actions have a pending status that is
/// true when the server doesn't know about it and is false anytime after the action is successfully
/// synced.
pub struct ObjectActions {
#[allow(dead_code)]
object_actions_by_id: HashMap<ObjectUid, Vec<ObjectAction>>,
}
impl ObjectActions {
/// Accepts a vector of object actions read out of SQLite.
pub fn new(persisted_actions: Vec<ObjectAction>) -> Self {
// Partitions the actions by object id and plops them into the map.
let object_actions_by_id = persisted_actions.into_iter().fold(
HashMap::new(),
|mut map: HashMap<ObjectUid, Vec<ObjectAction>>, object_action| {
map.entry(object_action.uid.clone())
.or_default()
.push(object_action);
map
},
);
Self {
object_actions_by_id,
}
}
/// Insert a single action into the model. Returns the created action.
pub fn insert_action(
&mut self,
uid: ObjectUid,
hashed_sqlite_id: HashedSqliteId,
action_type: ObjectActionType,
data: Option<String>,
timestamp: DateTime<Utc>,
ctx: &mut ModelContext<Self>,
) -> ObjectAction {
// Create an action with pending=true.
let action = ObjectAction {
action_type,
uid: uid.clone(),
hashed_sqlite_id,
action_subtype: ObjectActionSubtype::SingleAction {
timestamp,
data,
pending: true,
processed_at_timestamp: None,
},
};
// Insert the action into the model.
self.object_actions_by_id
.entry(uid)
.or_default()
.push(action.clone());
ctx.notify();
action
}
/// Remove the action from the model with the corresponding object_id, timestamp, and pending=true.
pub fn remove_pending_action(
&mut self,
uid: &ObjectUid,
timestamp_of_action: &DateTime<Utc>,
ctx: &mut ModelContext<Self>,
) {
if let Some(actions) = self.object_actions_by_id.get_mut(uid) {
// Remove the action that has a matching timestamp and pending=true
if let Some(index) = actions.iter().position(|a| {
matches!(
&a.action_subtype,
ObjectActionSubtype::SingleAction {
timestamp,
pending: true,
..
} if timestamp == timestamp_of_action
)
}) {
actions.remove(index);
} else {
log::warn!(
"Could not find the pending action to remove from the ObjectActions model"
)
}
} else {
log::warn!("Could not find the object id in the ObjectActions model")
}
ctx.notify();
}
/// Get the processed_at_timestamp of the most recent server-synced action we have for a given object. This determines
/// whether or not we should accept some update from the server.
pub fn get_latest_processed_at_ts(&self, uid: &ObjectUid) -> Option<DateTime<Utc>> {
if let Some(actions) = self.object_actions_by_id.get(uid) {
actions
.iter()
.filter_map(|a| match a.action_subtype {
ObjectActionSubtype::SingleAction {
processed_at_timestamp,
pending: false,
..
} => processed_at_timestamp,
ObjectActionSubtype::BundledActions {
latest_processed_at_timestamp,
..
} => Some(latest_processed_at_timestamp),
_ => None,
})
.max()
} else {
None
}
}
/// Takes a list of ObjectActions for a single object from the server and replaces the existing actions
/// for this object with the new ones. Any pending actions are persisted so we make sure we don't delete actions
/// that are currently in the process of syncing.
pub fn overwrite_action_history_for_object(
&mut self,
uid: &ObjectUid,
mut actions: Vec<ObjectAction>,
ctx: &mut ModelContext<Self>,
) {
// Get the pending actions out of the old set.
let old_pending_actions: Vec<ObjectAction> = self
.object_actions_by_id
.get(uid)
.map(|actions| {
actions
.iter()
.filter(|a| {
matches!(
a.action_subtype,
ObjectActionSubtype::SingleAction { pending: true, .. }
)
})
.cloned()
.collect()
})
.unwrap_or_default();
actions.extend(old_pending_actions);
self.object_actions_by_id
.insert(uid.to_string(), actions.clone());
ctx.notify();
}
/// Returns a time-boxed summary of the number of times this action type has occurred on this object.
/// This summary prioritizes smaller units of time where possible, starting from Day and going to Year.
/// If the action type has occurred on the object in the last day, we return "X actions in the last day".
/// If not, we increase the time unit from Day to Week to Month. If no actions have occurred in the last month,
/// we return however many actions have occurred in the last year, possibly 0.
///
/// This function operates by cloning a filtered Iterator<Item=&ObjectAction>, saving some performance overhead
/// by cloning references instead of objects.
pub fn get_action_history_summary_for_action_type(
&self,
uid: &ObjectUid,
action_type: ObjectActionType,
) -> Option<String> {
// If the object is not in the model, return 0.
let all_actions_on_this_object = self.object_actions_by_id.get(uid);
if all_actions_on_this_object.is_none() {
return Some("0 runs in the last year".to_string());
}
// If the object doesn't have any of these action types recorded, return 0.
let all_relevant_actions = all_actions_on_this_object?
.iter()
.filter(|a| a.action_type == action_type);
if all_relevant_actions.clone().count() == 0 {
return Some("0 runs in the last year".to_string());
}
// If the action has occurred in the last day, return Day as the time unit.
let one_day_ago = Utc::now() - Duration::days(1);
let in_the_last_day = all_relevant_actions.clone().filter(|a| matches!(a.action_subtype, ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_day_ago)).count();
if in_the_last_day > 0 {
return Some(format!(
"{} {} in the last day",
in_the_last_day,
if in_the_last_day == 1 {
action_type.singular()
} else {
action_type.plural()
}
));
}
// If the action has occurred in the last week, return Week as the time unit.
let one_week_ago = Utc::now() - Duration::days(7);
let in_the_last_week = all_relevant_actions.clone().filter(|a| matches!(a.action_subtype, ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_week_ago)).count();
if in_the_last_week > 0 {
return Some(format!(
"{} {} in the last week",
in_the_last_week,
if in_the_last_week == 1 {
action_type.singular()
} else {
action_type.plural()
}
));
}
// If the action has occurred in the last month, return Month as the time unit.
let one_month_ago = Utc::now() - Duration::days(30);
let in_the_last_month = all_relevant_actions.clone().filter(|a| matches!(a.action_subtype, ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_month_ago)).count();
if in_the_last_month > 0 {
return Some(format!(
"{} {} in the last month",
in_the_last_month,
if in_the_last_month == 1 {
action_type.singular()
} else {
action_type.plural()
}
));
}
// Finally, if all else turned up fruitless, return the yearly count.
let one_year_ago = Utc::now() - Duration::days(365);
let in_the_last_year: i32 = all_relevant_actions
.clone()
.filter_map(|a| match a.action_subtype {
ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_year_ago => {
Some(1)
}
ObjectActionSubtype::BundledActions {
count,
oldest_timestamp,
..
} if oldest_timestamp > one_year_ago => Some(count),
_ => None,
})
.sum();
Some(format!(
"{} {} in the last year",
in_the_last_year,
if in_the_last_year == 1 {
action_type.singular()
} else {
action_type.plural()
}
))
}
/// Returns all the actions on the objects specified by the parameter hashed_object_ids.
/// The return value is a HashMap, which represents a subset of the model, filtered to just the actions
/// that occurred on the requested objects.
pub fn get_actions_for_objects(
&self,
uids: Vec<&ObjectUid>,
) -> HashMap<ObjectUid, Vec<ObjectAction>> {
uids.iter()
.map(|&uid| {
let actions_on_this_object = self
.object_actions_by_id
.get(uid)
.cloned()
.unwrap_or_default();
(uid.clone(), actions_on_this_object)
})
.collect()
}
pub fn delete_actions_for_object(&mut self, uid: &ObjectUid, ctx: &mut ModelContext<Self>) {
self.object_actions_by_id.remove(uid);
ctx.notify()
}
#[cfg(test)]
pub fn count_actions_for_object(&mut self, uid: &ObjectUid) -> usize {
self.object_actions_by_id.get(uid).map_or(0, |v| v.len())
}
}
impl Entity for ObjectActions {
type Event = ObjectActionsEvent;
}
impl SingletonEntity for ObjectActions {}
#[cfg(test)]
#[path = "actions_tests.rs"]
pub mod tests;
+565
View File
@@ -0,0 +1,565 @@
use warpui::App;
use super::{ObjectAction, ObjectActionSubtype, ObjectActionType, ObjectActions};
use chrono::{Duration, Utc};
#[test]
fn test_object_actions_daily() {
App::test((), |mut app| async move {
let actions: Vec<ObjectAction> = vec![
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::minutes(4),
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::hours(23),
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(4),
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(10),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(28),
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::BundledActions {
count: 5,
oldest_timestamp: Utc::now() - Duration::days(250),
latest_timestamp: Utc::now() - Duration::days(50),
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
},
},
];
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
object_actions_handle.read(&app, |handle, _ctx| {
assert_eq!(
handle.get_action_history_summary_for_action_type(
&"asdfljk".to_string(),
ObjectActionType::Execute,
),
Some("2 runs in the last day".to_string())
)
});
});
}
#[test]
fn test_object_actions_rollup_weekly() {
App::test((), |mut app| async move {
let actions: Vec<ObjectAction> = vec![
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::minutes(4),
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::hours(23),
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(4),
processed_at_timestamp: Some(Utc::now()),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(10),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(28),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::BundledActions {
count: 5,
oldest_timestamp: Utc::now() - Duration::days(250),
latest_timestamp: Utc::now() - Duration::days(50),
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
},
},
];
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
object_actions_handle.read(&app, |handle, _ctx| {
assert_eq!(
handle.get_action_history_summary_for_action_type(
&"q23423aaf".to_string(),
ObjectActionType::Execute,
),
Some("1 run in the last week".to_string())
)
});
});
}
#[test]
fn test_object_actions_rollup_monthly() {
App::test((), |mut app| async move {
let actions: Vec<ObjectAction> = vec![
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::minutes(4),
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::hours(23),
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(4),
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(10),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(15),
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(28),
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::BundledActions {
count: 5,
oldest_timestamp: Utc::now() - Duration::days(250),
latest_timestamp: Utc::now() - Duration::days(50),
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
},
},
];
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
object_actions_handle.read(&app, |handle, _ctx| {
assert_eq!(
handle.get_action_history_summary_for_action_type(
&"q23423aaf".to_string(),
ObjectActionType::Execute,
),
Some("3 runs in the last month".to_string())
)
});
});
}
#[test]
fn test_object_actions_rollup_yearly() {
App::test((), |mut app| async move {
let actions: Vec<ObjectAction> = vec![
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::minutes(4),
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::hours(23),
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(4),
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(10),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(15),
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(28),
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "q23423aaf".to_string(),
action_subtype: ObjectActionSubtype::BundledActions {
count: 5,
oldest_timestamp: Utc::now() - Duration::days(250),
latest_timestamp: Utc::now() - Duration::days(50),
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
},
},
];
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
object_actions_handle.read(&app, |handle, _ctx| {
assert_eq!(
handle.get_action_history_summary_for_action_type(
&"q23423aaf".to_string(),
ObjectActionType::Execute,
),
Some("5 runs in the last year".to_string())
)
});
});
}
#[test]
fn test_object_actions_rollup_out_of_date_bundle() {
App::test((), |mut app| async move {
let actions: Vec<ObjectAction> = vec![
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::minutes(4),
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::hours(23),
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(4),
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(10),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(15),
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(28),
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "q23423aaf".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::BundledActions {
count: 5,
oldest_timestamp: Utc::now() - Duration::days(400),
latest_timestamp: Utc::now() - Duration::days(350),
latest_processed_at_timestamp: Utc::now() - Duration::days(350),
},
},
];
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
object_actions_handle.read(&app, |handle, _ctx| {
assert_eq!(
handle.get_action_history_summary_for_action_type(
&"q23423aaf".to_string(),
ObjectActionType::Execute,
),
Some("0 runs in the last year".to_string())
)
});
});
}
#[test]
fn test_object_actions_rollup_none() {
App::test((), |mut app| async move {
let actions: Vec<ObjectAction> = vec![
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::minutes(4),
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::hours(23),
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(4),
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(10),
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(15),
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::SingleAction {
timestamp: Utc::now() - Duration::days(28),
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
data: Some("Some data".to_string()),
pending: false,
},
},
ObjectAction {
action_type: ObjectActionType::Execute,
uid: "asdfljk".to_string(),
hashed_sqlite_id: "asdfljk".to_string(),
action_subtype: ObjectActionSubtype::BundledActions {
count: 5,
oldest_timestamp: Utc::now() - Duration::days(400),
latest_timestamp: Utc::now() - Duration::days(350),
latest_processed_at_timestamp: Utc::now() - Duration::days(350),
},
},
];
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
object_actions_handle.read(&app, |handle, _ctx| {
assert_eq!(
handle.get_action_history_summary_for_action_type(
&"q23423aaf".to_string(),
ObjectActionType::Execute,
),
Some("0 runs in the last year".to_string())
)
});
});
}
@@ -0,0 +1,390 @@
use std::{fmt::Debug, sync::Arc};
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::{
appearance::Appearance,
cloud_object::{
CloudModelType, CloudObject, CloudObjectEventEntrypoint, CreateCloudObjectResult,
CreateObjectRequest, GenericCloudObject, GenericServerObject, GenericStringObjectFormat,
GenericStringObjectUniqueKey, ObjectType, Revision, ServerCloudObject,
UpdateCloudObjectResult,
},
drive::{items::WarpDriveItem, CloudObjectTypeAndId},
persistence::ModelEvent,
server::{
ids::{ObjectUid, ServerId, SyncId},
server_api::object::ObjectClient,
sync_queue::{QueueItem, SerializedModel},
},
};
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
/// A trait that generic string-based objects should implement.
pub trait CloudStringObject: CloudObject + Send + Sync {
/// Returns the object format for this object.
fn generic_string_object_format(&self) -> GenericStringObjectFormat;
/// Returns the id for this specific object.
fn id(&self) -> SyncId;
/// Returns a serialized model from this string object.
fn serialized(&self) -> SerializedModel;
/// Returns a cloned boxed version of this cloud object.
/// Note that we can't force this trait to derive from Cloned
/// directly because that would make the trait not object safe. This
/// is a workaround.
fn clone_box(&self) -> Box<dyn CloudStringObject>;
}
/// A `StringModel` is a model that can be serialized and deserialized as a simple string.
///
/// Any model that has a simple string representation (e.g. JSON, markdown, yaml) that can be atomically updated
/// can implement this trait and get most cloud object functionality for free.
///
/// Objects that implement this type all share common storage and server apis.
pub trait StringModel: Clone + Debug + PartialEq + Send + Sync + 'static {
type CloudObjectType: CloudObject + 'static;
/// Returns the name of this model type (e.g. Workflow, Folder, Notebook)
fn model_type_name(&self) -> &'static str;
/// Whether we should enforce revisions for this model type.
/// If revisions are not enforced, updates will have last-write-wins semantics.
/// If revisions are enforced, the object will need to add logic to
/// the update manager for how conflicts are resolved.
fn should_enforce_revisions() -> bool;
/// Returns the serialization format for this model.
fn model_format() -> GenericStringObjectFormat;
/// Whether to show update toasts for this type of model.
fn should_show_activity_toasts() -> bool;
/// Whether to show a warning if this type of model is unsaved at quit time
/// (which typically blocks the user from quitting)
fn warn_if_unsaved_at_quit() -> bool;
/// Returns the display name for this model.
fn display_name(&self) -> String;
/// Returns whether to render this model as a WarpDriveItem.
fn renders_in_warp_drive(&self) -> bool {
false
}
/// Returns whether this model can be exported to a file
fn can_export(&self) -> bool {
false
}
/// Returns whether this model can be shared via a link
fn supports_linking(&self) -> bool {
false
}
/// Sets the display name for this model
fn set_display_name(&mut self, _name: &str) {}
/// 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<Box<dyn WarpDriveItem>> {
None
}
/// 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<Revision>,
object: &Self::CloudObjectType,
) -> QueueItem;
/// Returns a new instance from a server update, or None if the update should be ignored.
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self>;
/// Returns whether this model type should clear on a unique key conflict.
fn should_clear_on_unique_key_conflict(&self) -> bool {
false
}
/// Returns a unique key for this object, if one exists. Unique keys are used
/// to enforce that only one object with a given key can exist in the generic string
/// object server database.
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey>;
}
/// A serializer goes from a model to a string and back.
pub trait Serializer<M>: Debug + Clone + 'static {
fn serialize(model: &M) -> SerializedModel;
fn deserialize_owned(serialized: &str) -> Result<M>
where
Self: Sized;
}
/// A `GenericStringModel` is a generic implementation of model types that can serialize to/from string.
/// given a particular serializer.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct GenericStringModel<M, S>
where
M: StringModel<
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
>,
S: Serializer<M>,
{
pub string_model: M,
}
impl<M, S> CloudStringObject for GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>
where
M: StringModel<
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
>,
S: Serializer<M>,
{
fn generic_string_object_format(&self) -> GenericStringObjectFormat {
M::model_format()
}
fn id(&self) -> SyncId {
self.id
}
fn serialized(&self) -> SerializedModel {
self.model.serialized()
}
fn clone_box(&self) -> Box<dyn CloudStringObject> {
Box::new(self.clone())
}
}
/// Implements the CloudModelType trait for all generic string models.
///
/// This has common logic for storing string models to SQLite, sending them to the server
/// updating from the server -- basically for anything not specific to the contents
/// of the string model.
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl<M, S> CloudModelType for GenericStringModel<M, S>
where
M: StringModel<
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
>,
S: Serializer<M>,
{
type CloudObjectType = GenericCloudObject<GenericStringObjectId, Self>;
type IdType = GenericStringObjectId;
fn model_type_name(&self) -> &'static str {
self.string_model.model_type_name()
}
fn object_type(&self) -> ObjectType {
ObjectType::GenericStringObject(M::model_format())
}
fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId {
CloudObjectTypeAndId::GenericStringObject {
object_type: M::model_format(),
id,
}
}
fn display_name(&self) -> String {
self.string_model.display_name()
}
fn set_display_name(&mut self, name: &str) {
self.string_model.set_display_name(name);
}
fn upsert_event(&self, object: &GenericCloudObject<GenericStringObjectId, Self>) -> ModelEvent {
let object = object as &dyn CloudStringObject;
ModelEvent::UpsertGenericStringObject {
object: CloudStringObject::clone_box(object),
}
}
fn supports_linking(&self) -> bool {
self.string_model.supports_linking()
}
fn should_show_activity_toasts(&self) -> bool {
M::should_show_activity_toasts()
}
fn warn_if_unsaved_at_quit(&self) -> bool {
M::warn_if_unsaved_at_quit()
}
fn can_export(&self) -> bool {
self.string_model.can_export()
}
fn bulk_upsert_event(
objects: &[GenericCloudObject<GenericStringObjectId, Self>],
) -> ModelEvent {
ModelEvent::UpsertGenericStringObjects(
objects.iter().map(CloudStringObject::clone_box).collect(),
)
}
fn create_object_queue_item(
&self,
object: &GenericCloudObject<GenericStringObjectId, Self>,
entrypoint: CloudObjectEventEntrypoint,
initiated_by: InitiatedBy,
) -> Option<QueueItem> {
if let SyncId::ClientId(client_id) = object.id {
return Some(QueueItem::CreateObject {
object_type: self.object_type(),
owner: object.permissions.owner,
id: client_id,
title: None,
serialized_model: Some(object.model.serialized().into()),
initial_folder_id: object.metadata.folder_id,
entrypoint,
initiated_by,
});
}
None
}
fn update_object_queue_item(
&self,
revision_ts: Option<Revision>,
object: &GenericCloudObject<GenericStringObjectId, Self>,
) -> QueueItem {
self.string_model
.update_object_queue_item(revision_ts, object)
}
fn should_clear_on_unique_key_conflict(&self) -> bool {
self.string_model.should_clear_on_unique_key_conflict()
}
fn should_update_after_server_conflict(&self) -> bool {
true
}
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
self.string_model
.new_from_server_update(server_cloud_object)
.map(Self::new)
}
fn serialized(&self) -> SerializedModel {
S::serialize(&self.string_model)
}
async fn send_create_request(
object_client: Arc<dyn ObjectClient>,
request: CreateObjectRequest,
) -> Result<CreateCloudObjectResult> {
let model_as_str = request
.serialized_model
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing serialized model"))?
.model_as_str();
let model = S::deserialize_owned(model_as_str)?;
object_client
.create_generic_string_object(M::model_format(), model.uniqueness_key(), request)
.await
}
async fn send_update_request(
&self,
object_client: Arc<dyn ObjectClient>,
server_id: ServerId,
revision: Option<Revision>,
) -> Result<UpdateCloudObjectResult<GenericServerObject<GenericStringObjectId, Self>>> {
let revision =
if M::should_enforce_revisions() {
Some(revision.ok_or_else(|| {
anyhow::anyhow!("Missing revision on update of generic object")
})?)
} else {
None
};
let res = object_client
.update_generic_string_object(server_id.into(), self.serialized(), revision)
.await;
res.and_then(|update_result| match update_result {
UpdateCloudObjectResult::Success {
revision_and_editor,
} => Ok(UpdateCloudObjectResult::Success {
revision_and_editor,
}),
UpdateCloudObjectResult::Rejected { object } => {
// Downcast to the concrete type to handle an update rejection (should be rare)
let concrete_object: Option<&GenericServerObject<GenericStringObjectId, Self>> =
(&object).into();
let object = concrete_object
.cloned()
.ok_or_else(|| anyhow::anyhow!("Failed to convert object to concrete type"))?;
Ok(UpdateCloudObjectResult::Rejected { object })
}
})
}
fn renders_in_warp_drive(&self) -> bool {
self.string_model.renders_in_warp_drive()
}
fn to_warp_drive_item(
&self,
id: SyncId,
appearance: &Appearance,
object: &GenericCloudObject<GenericStringObjectId, Self>,
) -> Option<Box<dyn WarpDriveItem>> {
self.string_model.to_warp_drive_item(id, appearance, object)
}
}
impl<M, S> GenericStringModel<M, S>
where
M: StringModel<
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
>,
S: Serializer<M>,
{
pub fn deserialize_owned(serialized: &str) -> Result<Self> {
S::deserialize_owned(serialized).map(Self::new)
}
pub fn new(model: M) -> Self {
Self {
string_model: model,
}
}
pub fn json_model(&self) -> &M {
&self.string_model
}
}
/// Object id type that is common for all generic string objects.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct GenericStringObjectId(ServerId);
crate::server_id_traits! { GenericStringObjectId, "GenericStringObject" }
impl From<GenericStringObjectId> for SyncId {
fn from(id: GenericStringObjectId) -> Self {
Self::ServerId(id.into())
}
}
impl GenericStringObjectId {
pub fn uid(&self) -> ObjectUid {
self.0.uid()
}
}
+27
View File
@@ -0,0 +1,27 @@
use serde::{de::DeserializeOwned, Serialize};
use crate::{cloud_object::JsonObjectType, server::sync_queue::SerializedModel};
use super::generic_string_model::{Serializer, StringModel};
/// A `JsonModel` is a string model that can be serialized to and deserialized from JSON.
pub trait JsonModel: StringModel + Serialize + DeserializeOwned + 'static {
/// Returns the JsonObjectType for this model.
fn json_object_type() -> JsonObjectType;
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct JsonSerializer;
impl<M: JsonModel> Serializer<M> for JsonSerializer {
fn serialize(model: &M) -> SerializedModel {
SerializedModel::new(serde_json::to_string(model).expect("model should serialize"))
}
fn deserialize_owned(serialized: &str) -> anyhow::Result<M>
where
Self: Sized,
{
Ok(serde_json::from_str(serialized)?)
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod actions;
pub mod generic_string_model;
pub mod json_model;
pub mod persistence;
pub mod view;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
use std::{cell::RefCell, collections::HashMap};
use chrono::{Duration, Utc};
use warp_graphql::scalars::time::ServerTimestamp;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
use crate::{
auth::{AuthStateProvider, UserUid},
cloud_object::{CloudObject, CloudObjectLocation, Space},
drive::{
folders::CloudFolder,
sharing::{ContentEditability, SharingAccessLevel},
},
safe_info,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::{ObjectUid, SyncId},
},
workspaces::user_profiles::UserProfiles,
};
use super::persistence::{CloudModel, CloudModelEvent};
pub const EDITOR_TIMEOUT_DURATION_MINUTES: i64 = 15;
#[derive(Default, Clone, Debug, PartialEq)]
pub enum EditorState {
#[default]
None,
CurrentUser,
OtherUserActive,
OtherUserIdle,
}
/// Stores information about the current editor of
/// a particular notebook, for display purposes.
/// For now, this just includes the state and
/// an email, but will eventually hold more information
/// about the user.
#[derive(Default, Clone, Debug, PartialEq)]
pub struct Editor {
pub state: EditorState,
pub email: Option<String>,
}
impl Editor {
pub fn no_editor() -> Self {
Self {
state: EditorState::None,
email: None,
}
}
}
/// Singleton model for storing and querying the data and logic logic needed by various view, based on the information
/// stored in [CloudModel]. As a general, rule, any new API that requires logic beyond just retriving the raw value
/// in [CloudModel], should be stored here. This includes logic such as object trashed status, the object current editor,
/// and object location.
///
/// Any API added to this model should be unit tested in model_test.rs
pub struct CloudViewModel {
folder_timestamp_cache: FolderTimestampCache,
}
type FolderTimestampCache = RefCell<HashMap<SyncId, ServerTimestamp>>;
pub enum CloudViewModelEvent {
/// A model change has invalidated object sort timestamps.
SortTimestampsChanged,
}
impl CloudViewModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(&CloudModel::handle(ctx), Self::handle_cloud_model_event);
ctx.subscribe_to_model(
&UpdateManager::handle(ctx),
Self::handle_update_manager_event,
);
Self {
folder_timestamp_cache: Default::default(),
}
}
#[cfg(test)]
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
Self::new(ctx)
}
/// Returns the current editor of the object based on what current exists in CloudModel. If the current editor
/// matches the logged in user's email, we assume that that user is the current editor.
/// If the current editor hasn't made an edit in the past 15 minutes, they are considered idle and
/// we instead just return Editor::OtherUserIdle. This is to prevent introducing friction into the baton grabbing process
/// when it's not needed. For more info see:
/// https://docs.google.com/document/d/1KgDFLApPg1uDVP-vOwhZzL1kRIviS8mMECIZg2VCKLY/edit
pub fn object_current_editor(&self, uid: &ObjectUid, ctx: &AppContext) -> Option<Editor> {
let cloud_model = CloudModel::as_ref(ctx);
let object = cloud_model.get_by_uid(uid)?;
match &object.metadata().current_editor_uid {
Some(uid) => {
let auth_state = AuthStateProvider::as_ref(ctx).get();
let user_uid = auth_state.user_id();
// If the logged in user matches the current UID, then the editor is the current
// user.
if user_uid.is_some_and(|user_uid| user_uid.as_string() == uid.clone()) {
return Some(Editor {
state: EditorState::CurrentUser,
email: auth_state.user_email().clone(),
});
}
let editor_uid = UserUid::new(uid);
let editor_email = UserProfiles::as_ref(ctx)
.profile_for_uid(editor_uid)
.map(|profile| profile.email.clone());
match &object.metadata().revision {
Some(revision) => {
let time_since_last_edit = Utc::now() - revision.utc();
let time_since_last_metadata_change = Utc::now()
- object
.metadata()
.metadata_last_updated_ts
.unwrap_or(Utc::now().into())
.utc();
if time_since_last_edit > Duration::minutes(EDITOR_TIMEOUT_DURATION_MINUTES)
&& time_since_last_metadata_change
> Duration::minutes(EDITOR_TIMEOUT_DURATION_MINUTES)
{
safe_info!(
safe: ("Current editor idle, eagerly grabbing edit access for notebook"),
full: ("Current editor idle, eagerly grabbing edit access for notebook with editor: {}", uid.clone())
);
Some(Editor {
state: EditorState::OtherUserIdle,
email: editor_email,
})
} else {
Some(Editor {
state: EditorState::OtherUserActive,
email: editor_email,
})
}
}
None => Some(Editor {
state: EditorState::OtherUserActive,
email: editor_email,
}),
}
}
_ => Some(Editor::no_editor()),
}
}
/// Get the [`Space`] that contains an object.
pub fn object_space(&self, id: &ObjectUid, app: &AppContext) -> Option<Space> {
CloudModel::as_ref(app)
.get_by_uid(id)
.map(|object| object.space(app))
}
/// Get the current user's access level on a Warp Drive object.
///
/// This is based on the client's current view of the object permissions, which may be stale. The
/// server is the source of truth for all permission data, and it may reject a request that the
/// client expects is allowed.
pub fn access_level(&self, object_uid: &ObjectUid, app: &AppContext) -> SharingAccessLevel {
match CloudModel::as_ref(app).get_by_uid(object_uid) {
Some(object) => Self::object_access_level(object, app),
None => SharingAccessLevel::View,
}
}
fn object_access_level(object: &dyn CloudObject, app: &AppContext) -> SharingAccessLevel {
match object.space(app) {
// For now, users have full access to all objects in their own drives. We may introduce
// drive-level ACLs in the future.
Space::Personal | Space::Team { .. } => SharingAccessLevel::Full,
Space::Shared => {
let mut access_level = SharingAccessLevel::View;
// Check the default link-based access (if set, this is *at least* View).
if let Some(link_settings) = &object.permissions().anyone_with_link {
access_level = link_settings.access_level;
}
let user_uid = AuthStateProvider::as_ref(app).get().user_id();
if let Some(user_uid) = user_uid {
for guest in object.permissions().guests.iter() {
if guest.subject.is_user(user_uid) {
access_level = access_level.max(guest.access_level);
}
}
}
// If the user created an object in a shared space, they will be treated as a guest and not the owner.
// The guest permissions aren't fetched until the object is re-fetched, and this fixes this behavior
// by forcing edit access if they created the object.
if let (Some(creator_uid), Some(user_uid)) =
(object.metadata().creator_uid.clone(), user_uid)
{
if creator_uid == user_uid.as_string() {
access_level = access_level.max(SharingAccessLevel::Edit);
}
}
access_level
}
}
}
/// Get the current user's editability state for a Warp Drive object.
pub fn object_editability(
&self,
object_uid: &ObjectUid,
app: &AppContext,
) -> ContentEditability {
match CloudModel::as_ref(app).get_by_uid(object_uid) {
Some(object) => {
let access_level = Self::object_access_level(object, app);
if access_level < SharingAccessLevel::Edit {
ContentEditability::ReadOnly
} else if AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out()
{
// The object is editable, but the user is not logged in.
if object.space(app) == Space::Personal {
ContentEditability::Editable
} else {
ContentEditability::RequiresLogin
}
} else {
ContentEditability::Editable
}
}
// Assume objects not yet in CloudModel are new, and therefore editable.
None => ContentEditability::Editable,
}
}
/// Get the timestamp to sort `object` according to `timestamp_kind`.
pub fn object_sorting_timestamp(
&self,
object: &dyn CloudObject,
timestamp_kind: UpdateTimestamp,
app: &AppContext,
) -> Option<ServerTimestamp> {
match timestamp_kind {
// When sorting in the trash, we only ever consider the object's own trashed timestamp.
// For trashed folders, their indirectly-trashed children will not have a trashed_ts,
// so there's no need to recurse.
UpdateTimestamp::Trashed => object.metadata().trashed_ts,
// When sorting in the main index, we consider all of the children of a folder. This
// can be expensive, so it's cached.
UpdateTimestamp::Revision => {
self.sorting_timestamp_rec(object, CloudModel::as_ref(app), app)
}
}
}
/// Calculate the sorting timestamp for `object`:
/// * For a folder, this is the max of the folder's timestamp and all of its children's timestamps
/// (recursively, for sub-folders).
/// * For other objects, this is the object's own timestamp.
fn sorting_timestamp_rec(
&self,
object: &dyn CloudObject,
cloud_model: &CloudModel,
app: &AppContext,
) -> Option<ServerTimestamp> {
let folder: Option<&CloudFolder> = object.into();
match folder {
// For non-folder objects, always use the object's own timestamp.
None => object.metadata().revision.clone().map(Into::into),
Some(folder) => self
.folder_timestamp_cache
// Skip the cache if it's already mutably borrowed. This should not happen in practice,
// because the UI framework is single-threaded.
.try_borrow()
.ok()
.and_then(|cache| cache.get(&folder.id).cloned())
.or_else(|| {
let max_child_timestamp = cloud_model
.active_cloud_objects_in_location_without_descendents(
CloudObjectLocation::Folder(folder.id),
app,
)
// TODO(ben): This check won't be needed soon.
.filter(|child| child.permissions().owner == folder.permissions().owner)
.filter_map(|child| self.sorting_timestamp_rec(child, cloud_model, app))
.max();
// The `Ord` implementation of `Option` always considers `None` less than
// `Some`.
let folder_timestamp = folder.metadata().revision.clone().map(Into::into);
let timestamp = max_child_timestamp.max(folder_timestamp);
if let Some(timestamp) = timestamp {
if let Ok(mut cache) = self.folder_timestamp_cache.try_borrow_mut() {
cache.insert(folder.id, timestamp);
}
}
timestamp
}),
}
}
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
match event {
CloudModelEvent::ObjectUpdated { type_and_id, .. }
| CloudModelEvent::ObjectTrashed { type_and_id, .. }
| CloudModelEvent::ObjectUntrashed { type_and_id, .. }
| CloudModelEvent::ObjectPermissionsUpdated { type_and_id, .. } => {
// If an object is updated, we need to recompute the timestamps of its parents.
if self.invalidate_object_timestamps(&type_and_id.uid(), CloudModel::as_ref(ctx)) {
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
}
}
CloudModelEvent::ObjectMoved {
from_folder,
to_folder,
..
} => {
// Both the old parent and the new parent need to be invalidated, since this object
// could affect the sort timestamp of both. Even if the moved object were a folder,
// its own sort timestamp isn't affected.
let cloud_model = CloudModel::as_ref(ctx);
let old_parent_changed = from_folder.is_some_and(|folder_id| {
self.invalidate_folder_timestamps(&folder_id, cloud_model)
});
let new_parent_changed = to_folder.is_some_and(|folder_id| {
self.invalidate_folder_timestamps(&folder_id, cloud_model)
});
if old_parent_changed || new_parent_changed {
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
}
}
CloudModelEvent::ObjectCreated { type_and_id } => {
// There are three cases for an ObjectCreated event:
// 1. We created a new object locally (in which case type_and_id is a client ID)
// 2. We were notified about a new object from the server.
// 3. A locally-created object was saved to the server, so we now have a server ID
// for it.
// Because we sort on server timestamps, only the second or third cases can affect
// sorting.
if type_and_id.has_server_id()
&& self
.invalidate_object_timestamps(&type_and_id.uid(), CloudModel::as_ref(ctx))
{
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
}
}
CloudModelEvent::ObjectDeleted { folder_id, .. } => {
if let Some(folder_id) = folder_id {
if self.invalidate_folder_timestamps(folder_id, CloudModel::as_ref(ctx)) {
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
}
}
}
CloudModelEvent::NotebookEditorChangedFromServer { .. }
| CloudModelEvent::ObjectForceExpanded { .. }
| CloudModelEvent::ObjectSynced { .. }
| CloudModelEvent::InitialLoadCompleted => (),
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ModelContext<Self>,
) {
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
return;
};
if result.success_type != OperationSuccessType::Success {
return;
}
let cloud_model = CloudModel::as_ref(ctx);
if let ObjectOperation::Create { .. } = result.operation {
// If a folder was created, remove the cache entry tied to its client ID.
// TODO @ianhodge: Update the way we do this check once we remove the generic
let server_id = &result.server_id.expect("Expect server id on success");
if cloud_model.get_folder_by_uid(&server_id.uid()).is_some() {
if let Some(client_id) = result.client_id {
let sync_id = SyncId::ClientId(client_id);
self.folder_timestamp_cache.borrow_mut().remove(&sync_id);
}
}
// For any new object, we need to recalculate its ancestors' timestamp with their
// new child.
if let Some(parent_id) = cloud_model
.get_by_uid(&server_id.uid())
.and_then(|object| object.metadata().folder_id)
{
if self.invalidate_folder_timestamps(&parent_id, cloud_model) {
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
}
}
}
}
/// Invalidate all cached timestamps for the object with the given ID, and its parents.
fn invalidate_object_timestamps(&mut self, uid: &ObjectUid, cloud_model: &CloudModel) -> bool {
let Some(object) = cloud_model.get_by_uid(uid) else {
return false;
};
let folder: Option<&CloudFolder> = object.into();
match folder {
Some(folder) => self.invalidate_folder_timestamps(&folder.id, cloud_model),
None => {
if let Some(parent_id) = object.metadata().folder_id {
self.invalidate_folder_timestamps(&parent_id, cloud_model)
} else {
false
}
}
}
}
/// Invalidate all cached timestamps for the given folder and its parents.
fn invalidate_folder_timestamps(
&mut self,
folder_id: &SyncId,
cloud_model: &CloudModel,
) -> bool {
let had_revision_ts = self
.folder_timestamp_cache
.borrow_mut()
.remove(folder_id)
.is_some();
let had_parent_ts = cloud_model
.get_folder(folder_id)
.and_then(|folder| folder.metadata().folder_id.as_ref())
.is_some_and(|parent| self.invalidate_folder_timestamps(parent, cloud_model));
had_revision_ts || had_parent_ts
}
}
impl Entity for CloudViewModel {
type Event = CloudViewModelEvent;
}
/// Mark CloudViewModel as global application state.
impl SingletonEntity for CloudViewModel {}
/// The timestamp to use when sorting objects by their last updated time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UpdateTimestamp {
/// Sort objects by their revision timestamp, when they were last edited.
#[default]
Revision,
/// Sort objects by their trashed timestamp.
Trashed,
}
+140
View File
@@ -0,0 +1,140 @@
use warpui::AppContext;
use crate::server::cloud_objects::update_manager::{
InitiatedBy, ObjectOperation, OperationSuccessType,
};
use super::{CloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType};
pub struct CloudObjectToastMessage;
impl CloudObjectToastMessage {
pub fn toast_message(
object: &dyn CloudObject,
operation: &ObjectOperation,
success_type: &OperationSuccessType,
app: &AppContext,
) -> Option<String> {
let object_name = object.model_type_name().to_owned();
let object_name_lowercase = object_name.to_ascii_lowercase();
match (object.object_type(), operation, success_type) {
// We should only show toasts for creates initiated by the user, not by the system
(_, ObjectOperation::Create { initiated_by: InitiatedBy::User }, OperationSuccessType::Success) => {
let containing_object_name = object.containing_object_name(app);
Some(format!("{object_name} saved to {containing_object_name}"))
}
// notebooks intentionally do not have an update message, as they are updated
// as the user types and so toasts would be VERY noisy
(
ObjectType::Notebook,
ObjectOperation::Update,
OperationSuccessType::Success,
) => None,
(_, ObjectOperation::Update, OperationSuccessType::Success) => {
Some(format!("{object_name} updated"))
}
(_, ObjectOperation::MoveToFolder, OperationSuccessType::Success) | (_, ObjectOperation::MoveToDrive, OperationSuccessType::Success) => {
let containing_object_name = object.containing_object_name(app);
Some(format!("{object_name} moved to {containing_object_name}"))
}
(_, ObjectOperation::Trash, OperationSuccessType::Success) => {
Some(format!("{object_name} trashed"))
}
(_, ObjectOperation::Untrash, OperationSuccessType::Success) => {
Some(format!("{object_name} restored"))
}
(_, ObjectOperation::Leave, OperationSuccessType::Success) => {
Some(format!("Left {object_name}"))
}
(_, ObjectOperation::Create { initiated_by: InitiatedBy::User }, OperationSuccessType::Failure) => {
Some(format!("Failed to create {object_name_lowercase}"))
}
(_, ObjectOperation::Create { initiated_by: InitiatedBy::User }, OperationSuccessType::Denied(message)) => {
Some(message.to_string())
}
(_, ObjectOperation::Update, OperationSuccessType::Failure) => {
Some(format!("Failed to update {object_name_lowercase}"))
}
(_, ObjectOperation::MoveToFolder, OperationSuccessType::Failure) | (_, ObjectOperation::MoveToDrive, OperationSuccessType::Failure) => {
Some(format!("Failed to move {object_name_lowercase}"))
}
(_, ObjectOperation::Trash, OperationSuccessType::Failure) => {
Some(format!("Failed to trash {object_name_lowercase}"))
}
(_, ObjectOperation::Untrash, OperationSuccessType::Failure) => {
Some(format!("Failed to restore {object_name_lowercase}"))
}
// We should only show deletion failure toasts for user-initiated deletions.
(_, ObjectOperation::Delete { initiated_by: InitiatedBy::User }, OperationSuccessType::Failure) => {
Some(format!("Failed to delete {object_name_lowercase}"))
}
(_, ObjectOperation::Leave, OperationSuccessType::Failure) => {
Some(format!("Failed to leave {object_name}"))
}
(
ObjectType::Workflow,
ObjectOperation::Update,
OperationSuccessType::Rejection,
) => {
Some("This workflow could not be saved because changes were made while you were editing.".to_string())
}
(
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection)),
ObjectOperation::Update,
OperationSuccessType::Rejection,
) => {
Some("Environment variables could not be saved because changes were made while you were editing.".to_string())
}
(
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(JsonObjectType::AIFact)),
ObjectOperation::Update,
OperationSuccessType::Rejection,
) => {
Some("Rule could not be saved because changes were made while you were editing.".to_string())
}
(_, ObjectOperation::TakeEditAccess, OperationSuccessType::Failure) => {
Some(format!("Failed to start editing {object_name_lowercase}"))
}
(_, ObjectOperation::UpdatePermissions, OperationSuccessType::Success) => {
Some(format!("Successfully updated permissions for {object_name_lowercase}"))
}
(_, ObjectOperation::UpdatePermissions, OperationSuccessType::Failure) => {
Some(format!("Failed to update permissions for {object_name_lowercase}"))
}
_ => None,
}
}
pub fn toast_deletion_confirm_message(
num_objects: i32,
operation: &ObjectOperation,
success_type: &OperationSuccessType,
) -> Option<String> {
let count_objects_message = match num_objects {
1 => "1 object".to_string(),
n => {
format!("{n} objects")
}
};
match (operation, success_type) {
// We should only show deletion failure toasts for user-initiated deletions.
(
ObjectOperation::Delete {
initiated_by: InitiatedBy::User,
},
OperationSuccessType::Success,
) => Some(format!("{count_objects_message} deleted forever")),
(ObjectOperation::EmptyTrash, OperationSuccessType::Success) => Some(format!(
"Trash emptied: {count_objects_message} deleted forever"
)),
(ObjectOperation::EmptyTrash, OperationSuccessType::Failure) => {
Some("Failed to empty trash".to_string())
}
(ObjectOperation::EmptyTrash, OperationSuccessType::Rejection) => {
Some("No objects in trash to empty".to_string())
}
_ => None,
}
}
}