Complete local-first content migration slice

This commit is contained in:
2026-08-05 16:24:04 -05:00
parent 993abb96df
commit f850bae77c
60 changed files with 2755 additions and 2729 deletions
+82 -6
View File
@@ -68,6 +68,7 @@ use crate::drive::panel::DrivePanelAction;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
use crate::env_vars::CloudEnvVarCollection;
use crate::features::FeatureFlag;
use crate::local_object_repository::LocalObjectRepository;
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
use crate::network::NetworkStatus;
use crate::notebooks::CloudNotebookModel;
@@ -3522,9 +3523,31 @@ impl DriveIndex {
cloud_object_type_and_id: CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.trash_object(cloud_object_type_and_id, ctx);
});
match cloud_object_type_and_id {
CloudObjectTypeAndId::Notebook(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_notebook_trashed(id, true, ctx);
});
}
CloudObjectTypeAndId::Workflow(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_workflow_trashed(id, true, ctx);
});
}
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id,
} => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_env_var_collection_trashed(id, true, ctx);
});
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.trash_object(cloud_object_type_and_id, ctx);
});
}
}
self.reset_menus(ctx);
ctx.notify();
}
@@ -3534,6 +3557,37 @@ impl DriveIndex {
cloud_object_type_and_id: &CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
match cloud_object_type_and_id {
CloudObjectTypeAndId::Notebook(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_notebook_trashed(*id, false, ctx);
});
self.reset_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::Workflow(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_workflow_trashed(*id, false, ctx);
});
self.reset_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id,
} => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_env_var_collection_trashed(*id, false, ctx);
});
self.reset_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {}
}
// Check if object being untrashed is in team space, if it is, then check
// corresponding object limits for that team.
if let Some(space) =
@@ -3663,9 +3717,31 @@ impl DriveIndex {
cloud_object_type_and_id: &CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx);
});
match cloud_object_type_and_id {
CloudObjectTypeAndId::Notebook(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.delete_notebook(*id, ctx);
});
}
CloudObjectTypeAndId::Workflow(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.delete_workflow(*id, ctx);
});
}
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id,
} => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.delete_env_var_collection(*id, ctx);
});
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx);
});
}
}
self.reset_menus(ctx);
ctx.notify();
}
+14 -4
View File
@@ -37,6 +37,13 @@ fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(
None,
Some(Owner::mock_current_user()),
ctx,
)
});
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(|_| Appearance::mock());
@@ -131,39 +138,42 @@ fn test_retry_menu_item_visibility() {
// by default, it doesn't show up
index.update(&mut app, |index, ctx| {
let menu_items = index.menu_items(&Space::Personal, &warp_drive_item_id, ctx);
assert_eq!(menu_items.len(), 5);
assert_eq!(menu_items.len(), 6);
assert_eq!(label_for_menu_item(&menu_items[0]), "Edit");
assert_eq!(label_for_menu_item(&menu_items[1]), "Copy workflow text");
assert_eq!(label_for_menu_item(&menu_items[2]), "Share");
assert_eq!(label_for_menu_item(&menu_items[3]), "Duplicate");
assert_eq!(label_for_menu_item(&menu_items[4]), "Export");
assert_eq!(label_for_menu_item(&menu_items[5]), "Trash");
});
// when the object is in error, it should show up
set_object_in_error(&mut app, &cloud_object_type_and_id);
index.update(&mut app, |index, ctx| {
let menu_items = index.menu_items(&Space::Personal, &warp_drive_item_id, ctx);
assert_eq!(menu_items.len(), 6);
assert_eq!(menu_items.len(), 7);
assert_eq!(label_for_menu_item(&menu_items[0]), "Retry");
assert_eq!(label_for_menu_item(&menu_items[1]), "Edit");
assert_eq!(label_for_menu_item(&menu_items[2]), "Copy workflow text");
assert_eq!(label_for_menu_item(&menu_items[3]), "Share");
assert_eq!(label_for_menu_item(&menu_items[4]), "Duplicate");
assert_eq!(label_for_menu_item(&menu_items[5]), "Export");
assert_eq!(label_for_menu_item(&menu_items[6]), "Trash");
});
// but if we're offline, it shouldn't show up
// local trash remains available offline
NetworkStatus::handle(&app).update(&mut app, |network_status, ctx| {
network_status.reachability_changed(false, ctx);
});
index.update(&mut app, |index, ctx| {
let menu_items = index.menu_items(&Space::Personal, &warp_drive_item_id, ctx);
assert_eq!(menu_items.len(), 5);
assert_eq!(menu_items.len(), 6);
assert_eq!(label_for_menu_item(&menu_items[0]), "Edit");
assert_eq!(label_for_menu_item(&menu_items[1]), "Copy workflow text");
assert_eq!(label_for_menu_item(&menu_items[2]), "Share");
assert_eq!(label_for_menu_item(&menu_items[3]), "Duplicate");
assert_eq!(label_for_menu_item(&menu_items[4]), "Export");
assert_eq!(label_for_menu_item(&menu_items[5]), "Trash");
});
})
}
+38 -1
View File
@@ -26,6 +26,7 @@ use crate::cloud_object::{
};
use crate::env_vars::manager::EnvVarCollectionSource;
use crate::env_vars::CloudEnvVarCollection;
use crate::local_object_repository::{local_owner, LocalObjectRepository};
use crate::notebooks::manager::NotebookSource;
use crate::notebooks::CloudNotebook;
use crate::server::cloud_objects::update_manager::{InitiatedBy, UpdateManager};
@@ -130,7 +131,12 @@ impl DrivePanel {
Some(folder_id) => CloudModel::as_ref(app)
.get_folder(folder_id)
.map(|folder| folder.permissions.owner),
None => UserWorkspaces::as_ref(app).space_to_owner(space, app),
None => match space {
Space::Personal => Some(local_owner()),
Space::Shared | Space::Team { .. } => {
UserWorkspaces::as_ref(app).space_to_owner(space, app)
}
},
}
}
@@ -357,6 +363,37 @@ impl DrivePanel {
cloud_object_type_and_id: &CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
match cloud_object_type_and_id {
CloudObjectTypeAndId::Notebook(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.duplicate_notebook(*id, ctx);
});
self.reset_all_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::Workflow(id) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.duplicate_workflow(*id, ctx);
});
self.reset_all_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id,
} => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.duplicate_env_var_collection(*id, ctx);
});
self.reset_all_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {}
}
// Check if object being duplicated is in team space, if it is, then check
// corresponding object limits for that team.
if let Some(space) =
+19 -39
View File
@@ -34,7 +34,7 @@ use crate::appearance::Appearance;
use crate::auth::UserUid;
use crate::cloud_object::breadcrumbs::{ContainingObject, ContainingObjectKind};
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Revision};
use crate::cloud_object::{CloudObject, ObjectType, Owner, Revision};
use crate::drive::cloud_object_styling::warp_drive_icon_color;
use crate::drive::items::WarpDriveItemId;
use crate::drive::{CloudObjectTypeAndId, DriveObjectType};
@@ -43,9 +43,8 @@ use crate::editor::{
PlainTextEditorViewAction as EditorAction, PropagateAndNoOpNavigationKeys, TextOptions,
TextStyleOperation,
};
use crate::local_object_repository::LocalObjectRepository;
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ClientId, ServerId, SyncId};
use crate::server::server_api::ai::AIClient;
use crate::themes::theme::AnsiColorIdentifier;
@@ -661,37 +660,25 @@ impl WorkflowModal {
// Close workflow editor
self.close(true, ctx);
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.trash_object(
CloudObjectTypeAndId::from_id_and_type(workflow_id, ObjectType::Workflow),
ctx,
);
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_workflow_trashed(workflow_id, true, ctx);
});
}
}
fn menu_items(&self, app: &AppContext) -> Vec<MenuItem<WorkflowModalAction>> {
let mut menu_items = Vec::new();
// Add "Copy workflow text" to menu
menu_items.push(
vec![
// Add "Copy workflow text" to menu
MenuItemFields::new("Copy workflow text")
.with_on_select_action(WorkflowModalAction::CopyObjectToClipboard)
.with_icon(Icon::CopyMenuItem)
.into_item(),
);
// Add "Trash" to menu
if self.is_online(app) {
menu_items.push(
MenuItemFields::new("Trash")
.with_on_select_action(WorkflowModalAction::TrashObject)
.with_icon(Icon::Trash)
.into_item(),
);
}
menu_items
// Add "Trash" to menu
MenuItemFields::new("Trash")
.with_on_select_action(WorkflowModalAction::TrashObject)
.with_icon(Icon::Trash)
.into_item(),
]
}
pub fn should_show_unsaved_changes_dialog(&self, app: &AppContext) -> bool {
@@ -731,20 +718,17 @@ impl WorkflowModal {
match (self.workflow_id, self.owner) {
(Some(workflow_id), None) => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.update_workflow(workflow, workflow_id, self.revision_ts.clone(), ctx);
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.update_workflow(workflow_id, workflow, ctx);
});
ctx.emit(WorkflowModalEvent::UpdatedWorkflow(workflow_id));
}
(None, Some(owner)) => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_workflow(
workflow,
owner,
(None, Some(_)) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_workflow_with_id(
SyncId::ClientId(ClientId::new()),
self.initial_folder_id,
ClientId::default(),
CloudObjectEventEntrypoint::Unknown,
true,
workflow,
ctx,
);
});
@@ -1348,10 +1332,6 @@ impl WorkflowModal {
)
}
fn is_online(&self, app: &AppContext) -> bool {
NetworkStatus::as_ref(app).is_online()
}
fn render_header_menu_and_close(&self, appearance: &Appearance) -> Box<dyn Element> {
let mut row = Flex::row();
+37 -1
View File
@@ -2,11 +2,12 @@ use std::sync::Arc;
use galaxy_core::ui::appearance::Appearance;
use warpui::platform::WindowStyle;
use warpui::{App, SingletonEntity, ViewHandle};
use warpui::{AddSingletonModel, App, SingletonEntity, ViewHandle};
use super::WorkflowModal;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObject, Owner};
use crate::editor::PlainTextEditorViewAction as EditorAction;
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
@@ -21,6 +22,13 @@ fn initialize_app(app: &mut App) {
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(
None,
Some(Owner::mock_current_user()),
ctx,
)
});
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
@@ -63,6 +71,34 @@ fn build_argument(
}
}
#[test]
fn test_save_new_workflow_persists_locally() {
App::test((), |mut app| async move {
let modal_view = create_modal(&mut app);
modal_view.update(&mut app, |view, ctx| {
view.open_with_new(Owner::mock_current_user(), None, ctx);
view.title_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("Local workflow", ctx);
});
view.content_editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("cargo test", ctx);
});
view.save_workflow_and_close(ctx);
});
app.read(|ctx| {
let workflows = CloudModel::as_ref(ctx)
.get_all_active_workflows()
.collect::<Vec<_>>();
assert_eq!(workflows.len(), 1);
assert_eq!(workflows[0].model().data.name(), "Local workflow");
assert_eq!(workflows[0].model().data.command(), Some("cargo test"));
assert!(!workflows[0].metadata().has_pending_content_changes());
});
});
}
#[test]
fn test_pasting_command_no_argument_overlap_fewer_arguments() {
App::test((), |mut app| async move {