Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
use std::env::current_dir;
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::App;
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
network::NetworkStatus,
|
||||
server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue},
|
||||
workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
use super::expand_dirs;
|
||||
|
||||
#[test]
|
||||
fn test_expand_directories() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
|
||||
let global_resource_handles = GlobalResourceHandles::mock(&mut app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
|
||||
let directory = current_dir()
|
||||
.expect("current directory should exist")
|
||||
.parent()
|
||||
.expect("parent directory should exist")
|
||||
.to_path_buf()
|
||||
.join("crates")
|
||||
.join("integration");
|
||||
|
||||
// Open a folder and verify we could expand it into the correct folder tree structure.
|
||||
assert_eq!(warpui::r#async::block_on(expand_dirs([directory].into_iter().collect())).debug_print(), "(integration(tests(INTEGRATION_TESTING, data(test, test_launch_config, test_theme, test_theme_with_name, test_workflow))))");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod modal;
|
||||
mod modal_body;
|
||||
mod nodes;
|
||||
mod queue;
|
||||
@@ -0,0 +1,385 @@
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject, Owner},
|
||||
server::{ids::SyncId, sync_queue::SyncQueue},
|
||||
themes::theme::WarpTheme,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, Flex, Highlight, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth,
|
||||
Shrinkable, Stack, Text,
|
||||
},
|
||||
platform::{FilePickerConfiguration, FileType},
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyEvent};
|
||||
|
||||
const CLOSE_BUTTON_SIZE: f32 = 24.;
|
||||
const HEADER_FONT_SIZE: f32 = 16.;
|
||||
const MODAL_CORNER_RADIUS: f32 = 8.;
|
||||
pub const BODY_HEIGHT: f32 = 244.;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ImportModalAction {
|
||||
Close,
|
||||
}
|
||||
|
||||
pub enum ImportModalEvent {
|
||||
OpenTargetWithHashedId(String),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct ImportModal {
|
||||
import_modal: ViewHandle<ImportModalBody>,
|
||||
owner: Option<Owner>,
|
||||
folder_id: Option<SyncId>,
|
||||
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
footer_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ImportModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let import_modal_body = ctx.add_typed_action_view(ImportModalBody::new);
|
||||
|
||||
ctx.subscribe_to_view(&import_modal_body, move |me, _, event, ctx| {
|
||||
me.handle_import_body_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
import_modal: import_modal_body,
|
||||
owner: None,
|
||||
folder_id: None,
|
||||
clipped_scroll_state: Default::default(),
|
||||
close_button_mouse_state: Default::default(),
|
||||
footer_button_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_with_target(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
initial_folder_id: Option<SyncId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// TODO: This should take an owner OR folder.
|
||||
self.owner = Some(owner);
|
||||
self.folder_id = initial_folder_id;
|
||||
|
||||
self.import_modal.update(ctx, |import_modal, _ctx| {
|
||||
import_modal.set_new_target(owner, initial_folder_id);
|
||||
});
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn open_file_picker(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let window_id = ctx.window_id();
|
||||
let import_body_id = self.import_modal.id();
|
||||
|
||||
let sync_queue_is_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
|
||||
let allowed_file_types = vec![FileType::Yaml, FileType::Markdown];
|
||||
|
||||
let mut file_picker_config = FilePickerConfiguration::new()
|
||||
.allow_multi_select()
|
||||
.set_allowed_file_types(allowed_file_types);
|
||||
|
||||
// Files under a folder could only be uploaded when the folder is created on the server.
|
||||
// When sync queue is not dequeueing, disable folder upload in the import modal.
|
||||
if sync_queue_is_dequeueing {
|
||||
file_picker_config = file_picker_config.allow_folder();
|
||||
}
|
||||
|
||||
ctx.open_file_picker(
|
||||
move |result, ctx| match result {
|
||||
Ok(paths) if !paths.is_empty() => {
|
||||
ctx.dispatch_typed_action_for_view(
|
||||
window_id,
|
||||
import_body_id,
|
||||
&ImportModalBodyAction::PathsSelected(paths),
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
ctx.dispatch_typed_action_for_view(
|
||||
window_id,
|
||||
import_body_id,
|
||||
&ImportModalBodyAction::FilePickerCancelled,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.dispatch_typed_action_for_view(
|
||||
window_id,
|
||||
import_body_id,
|
||||
&ImportModalBodyAction::FilePickerError(err),
|
||||
);
|
||||
}
|
||||
},
|
||||
file_picker_config,
|
||||
);
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_import_body_event(
|
||||
&mut self,
|
||||
event: &ImportModalBodyEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ImportModalBodyEvent::OpenFilePicker => self.open_file_picker(ctx),
|
||||
ImportModalBodyEvent::OpenTargetWithHashedId(hashed_id) => {
|
||||
ctx.emit(ImportModalEvent::OpenTargetWithHashedId(hashed_id.clone()))
|
||||
}
|
||||
ImportModalBodyEvent::UploadCompleted
|
||||
| ImportModalBodyEvent::AllFileSavedLocally
|
||||
| ImportModalBodyEvent::UploadSelected => ctx.notify(),
|
||||
}
|
||||
}
|
||||
|
||||
fn breadcrumb(&self, app: &AppContext) -> (String, Vec<usize>) {
|
||||
let (text, highlight_start) = match self
|
||||
.folder_id
|
||||
.as_ref()
|
||||
.and_then(|folder_id| CloudModel::as_ref(app).get_folder(folder_id))
|
||||
{
|
||||
Some(folder) => {
|
||||
let breadcrumbs = folder.breadcrumbs(app);
|
||||
(
|
||||
format!("{} / {}", breadcrumbs, folder.display_name()),
|
||||
breadcrumbs.chars().count() + 3,
|
||||
)
|
||||
}
|
||||
None => (
|
||||
// Convert to a Space for display, in case we're importing into a shared folder.
|
||||
self.owner
|
||||
.map(|owner| {
|
||||
UserWorkspaces::as_ref(app)
|
||||
.owner_to_space(owner, app)
|
||||
.name(app)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
0,
|
||||
),
|
||||
};
|
||||
|
||||
// The unit of highlight index is character index not byte index.
|
||||
let highlight_range = (highlight_start..text.chars().count()).collect();
|
||||
(text, highlight_range)
|
||||
}
|
||||
|
||||
fn render_breadcrumbs(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
theme: &WarpTheme,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let (breadcrumb_text, highlight_indices) = self.breadcrumb(app);
|
||||
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(breadcrumb_text)
|
||||
.with_highlights(
|
||||
highlight_indices,
|
||||
Highlight::new().with_foreground_color(
|
||||
theme.main_text_color(theme.surface_2()).into_solid(),
|
||||
),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(theme.sub_text_color(theme.surface_2()).into_solid()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(6.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.close_button(CLOSE_BUTTON_SIZE, self.close_button_mouse_state.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ImportModalAction::Close))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
theme: &WarpTheme,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let top_row = Flex::row()
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.0,
|
||||
Align::new(
|
||||
Text::new_inline("Import", appearance.ui_font_family(), HEADER_FONT_SIZE)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_close_button(appearance))
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish();
|
||||
|
||||
let header = Flex::column()
|
||||
.with_child(top_row)
|
||||
.with_child(self.render_breadcrumbs(appearance, theme, app))
|
||||
.finish();
|
||||
|
||||
Container::new(header)
|
||||
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(MODAL_CORNER_RADIUS)))
|
||||
.with_padding_left(24.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_right(16.)
|
||||
.with_padding_bottom(16.)
|
||||
.with_border(Border::bottom(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_body(&self, theme: &WarpTheme) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
ChildView::new(&self.import_modal).finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
theme.disabled_text_color(theme.surface_2()).into(),
|
||||
theme.main_text_color(theme.surface_2()).into(),
|
||||
theme.surface_2().into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(BODY_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(5.)
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(
|
||||
MODAL_CORNER_RADIUS,
|
||||
)))
|
||||
.with_background(theme.surface_2())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_footer(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let button_text = if !self.import_modal.as_ref(app).upload_in_progress(app) {
|
||||
"Close".to_string()
|
||||
} else {
|
||||
"Cancel".to_string()
|
||||
};
|
||||
|
||||
Container::new(
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
self.footer_button_mouse_state.clone(),
|
||||
)
|
||||
.with_centered_text_label(button_text.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
width: Some(150.),
|
||||
height: Some(40.),
|
||||
font_size: Some(14.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ImportModalAction::Close))
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(16.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ImportModal {
|
||||
type Event = ImportModalEvent;
|
||||
}
|
||||
|
||||
impl View for ImportModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"ImportModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let mut contents = Flex::column()
|
||||
.with_child(self.render_header(appearance, theme, app))
|
||||
.with_child(self.render_body(theme));
|
||||
|
||||
if !self.import_modal.as_ref(app).before_upload() {
|
||||
contents.add_child(self.render_footer(appearance, app));
|
||||
}
|
||||
|
||||
let modal = ConstrainedBox::new(
|
||||
Container::new(contents.finish())
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MODAL_CORNER_RADIUS)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_margin_top(35.)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(500.)
|
||||
.with_height(300.)
|
||||
.finish();
|
||||
|
||||
// Stack needed so that modal can get bounds information,
|
||||
// specifically to ensure no overlap with the window's traffic lights
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background_color(Fill::blur().into())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ImportModal {
|
||||
type Action = ImportModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ImportModalAction::Close => {
|
||||
self.import_modal.update(ctx, |import_modal_body, ctx| {
|
||||
import_modal_body.reset(ctx);
|
||||
});
|
||||
|
||||
ctx.emit(ImportModalEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
use futures_util::stream::AbortHandle;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::path::PathBuf;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
},
|
||||
platform::{file_picker::FilePickerError, Cursor},
|
||||
ui_components::{
|
||||
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::Owner,
|
||||
server::{
|
||||
ids::{ClientId, SyncId},
|
||||
sync_queue::SyncQueue,
|
||||
},
|
||||
ui_components::icons::Icon,
|
||||
view_components::DismissibleToast,
|
||||
workspace::ToastStack,
|
||||
};
|
||||
|
||||
use super::{
|
||||
modal::BODY_HEIGHT,
|
||||
nodes::{
|
||||
expand_dirs, parse_file, FileContent, FileId, FileUploadState, FolderId, UploadResult,
|
||||
},
|
||||
queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent},
|
||||
};
|
||||
|
||||
const FILE_PICKER_BUTTON_WIDTH: f32 = 250.;
|
||||
const BUTTON_FONT_SIZE: f32 = 14.;
|
||||
const BUTTON_BORDER_RADIUS: f32 = 4.;
|
||||
pub(super) const IMPORT_FONT_SIZE: f32 = 14.;
|
||||
pub(super) const INDENT_MARGIN: f32 = 22.;
|
||||
pub(super) const BASE_INDENT: f32 = 30.;
|
||||
|
||||
const FILE_TYPE_DOCS_URL: &str =
|
||||
"https://docs.warp.dev/knowledge-and-collaboration/warp-drive#import-and-export";
|
||||
const SUPPORTED_FILE_TYPE_TEXT: &str = "md, yaml, yml";
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "import_tests.rs"]
|
||||
mod import_tests;
|
||||
|
||||
/// Current state of the import modal.
|
||||
///
|
||||
/// The entire import flow goes as follows:
|
||||
/// 1. Modal prompts user with native file picker
|
||||
/// 2. User selects paths from the file picker
|
||||
/// 3. The modal expands paths into a tree of folders and matching files
|
||||
/// 4. Insert all folders we need to upload into the import queue
|
||||
/// 5. Iteratively parse out all file contents from the file paths
|
||||
/// - If we fail to parse the file, mark the file node as errored
|
||||
/// - If we successfully parsed the file, push the file content to the import queue
|
||||
enum ImportState {
|
||||
// Before the user opens the file picker.
|
||||
Upload,
|
||||
// We are waiting for users to select paths from the file picker.
|
||||
Loading,
|
||||
// Users have selected paths from the file picker.
|
||||
PathLoaded,
|
||||
PathExpanded(FileUploadState),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ImportModalBodyAction {
|
||||
RetryFile(FileId),
|
||||
OpenFilePicker,
|
||||
FilePickerCancelled,
|
||||
PathsSelected(Vec<String>),
|
||||
FilePickerError(FilePickerError),
|
||||
ClickedToOpenTarget(String),
|
||||
}
|
||||
|
||||
pub enum ImportModalBodyEvent {
|
||||
OpenFilePicker,
|
||||
UploadCompleted,
|
||||
AllFileSavedLocally,
|
||||
UploadSelected,
|
||||
OpenTargetWithHashedId(String),
|
||||
}
|
||||
|
||||
pub struct ImportModalBody {
|
||||
state: ImportState,
|
||||
in_progress_handle: Option<AbortHandle>,
|
||||
// Queue to handle requests to upload objects to warp drive.
|
||||
// All updates should go through the queue rather than calling
|
||||
// UpdateManager directly.
|
||||
import_queue: ModelHandle<ImportQueue>,
|
||||
owner: Option<Owner>,
|
||||
initial_folder_id: Option<SyncId>,
|
||||
|
||||
file_picker_mouse_state: MouseStateHandle,
|
||||
link_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ImportModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let import_queue = ctx.add_model(ImportQueue::new);
|
||||
ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| {
|
||||
me.handle_import_queue_event(event, ctx)
|
||||
});
|
||||
|
||||
Self {
|
||||
state: ImportState::Upload,
|
||||
owner: None,
|
||||
initial_folder_id: None,
|
||||
import_queue,
|
||||
file_picker_mouse_state: Default::default(),
|
||||
link_mouse_state: Default::default(),
|
||||
in_progress_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_import_queue_event(&mut self, event: &ImportQueueEvent, ctx: &mut ViewContext<Self>) {
|
||||
// Only handle event when path is expanded.
|
||||
if let ImportState::PathExpanded(state) = &mut self.state {
|
||||
match event {
|
||||
ImportQueueEvent::FileCompleted { file_id, server_id } => {
|
||||
let result = match server_id {
|
||||
Some(id) => UploadResult::Success(id.clone()),
|
||||
None => UploadResult::Error("Failed to upload file to server".to_string()),
|
||||
};
|
||||
|
||||
// Update the upstream folder status with the upload success state.
|
||||
if state.update_tree_with_file_upload_result(result, *file_id) {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
ImportQueueEvent::FolderCompleted {
|
||||
folder_id,
|
||||
server_id,
|
||||
} => {
|
||||
let result = match server_id {
|
||||
Some(id) => UploadResult::Success(id.clone()),
|
||||
None => {
|
||||
UploadResult::Error("Failed to upload folder to server".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
state.mark_folder_synced(result, *folder_id);
|
||||
ctx.notify();
|
||||
}
|
||||
ImportQueueEvent::FileSavedLocally(file_id) => {
|
||||
let file_node = state
|
||||
.file_id_to_node
|
||||
.get_mut(file_id)
|
||||
.expect("File node should exist");
|
||||
|
||||
file_node.saved_locally();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
|
||||
if !sync_queue_dequeueing && state.all_files_saved_locally() {
|
||||
ctx.emit(ImportModalBodyEvent::AllFileSavedLocally);
|
||||
} else if state.is_complete() {
|
||||
ctx.emit(ImportModalBodyEvent::UploadCompleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_new_target(&mut self, owner: Owner, initial_folder_id: Option<SyncId>) {
|
||||
// TODO: this should take an owner OR folder.
|
||||
self.owner = Some(owner);
|
||||
self.initial_folder_id = initial_folder_id;
|
||||
}
|
||||
|
||||
// Push a new update to the import queue to sync with the server.
|
||||
fn push_new_update(&mut self, arg: ImportQueueArgs, ctx: &mut ViewContext<Self>) {
|
||||
self.import_queue
|
||||
.update(ctx, |queue, ctx| queue.enqueue(arg, ctx));
|
||||
}
|
||||
|
||||
// Whether there is an active upload in progress (If all uploads are completed,
|
||||
// we don't consider the import modal upload to be in progress).
|
||||
pub fn upload_in_progress(&self, app: &AppContext) -> bool {
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
|
||||
match &self.state {
|
||||
ImportState::Upload => false,
|
||||
ImportState::PathExpanded(state)
|
||||
if !sync_queue_dequeueing && state.all_files_saved_locally() =>
|
||||
{
|
||||
false
|
||||
}
|
||||
ImportState::PathExpanded(state) if state.is_complete() => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn before_upload(&self) -> bool {
|
||||
matches!(&self.state, ImportState::Upload | ImportState::Loading)
|
||||
}
|
||||
|
||||
fn parent_id_for_upload(&self, parent_folder_cloud_id: Option<ClientId>) -> ParentId {
|
||||
match parent_folder_cloud_id {
|
||||
Some(id) => ParentId::FolderToUpload(id),
|
||||
None => ParentId::InitialFolder(self.initial_folder_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate folder nodes with actual cloud objects.
|
||||
pub(super) fn populate_folder_cloud_object(
|
||||
&mut self,
|
||||
state: &mut FileUploadState,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Start with the first non-root node folder.
|
||||
let mut id = FolderId::root_id();
|
||||
id += 1;
|
||||
|
||||
let Some(owner) = self.owner else {
|
||||
log::warn!("Import modal opened without owner");
|
||||
return;
|
||||
};
|
||||
|
||||
// Push all folders to the queue in order. This is more time efficient when dequeueing
|
||||
// from the queue.
|
||||
while let Some(node) = state.folder_id_to_node.get(&id) {
|
||||
let parent_id = node.parent_id();
|
||||
|
||||
// If a node's parent is the root / dummy node, consider it to have no initial folder.
|
||||
let parent_folder_cloud_id = state.folder_cloud_id(parent_id);
|
||||
self.push_new_update(
|
||||
ImportQueueArgs {
|
||||
owner,
|
||||
parent_id: self.parent_id_for_upload(parent_folder_cloud_id),
|
||||
content: RequestContent::Folder {
|
||||
name: node.name(),
|
||||
client_id: node.cloud_id(),
|
||||
folder_id: id,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the next file that has not been uploaded. We determine the next file by iteratively
|
||||
/// by adding 1 to the previous uploaded file id.
|
||||
fn parse_next_file(
|
||||
&mut self,
|
||||
file_id: FileId,
|
||||
continue_parsing_after_completion: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let ImportState::PathExpanded(state) = &self.state {
|
||||
let Some(node) = state.file_id_to_node.get(&file_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let path = node.full_path();
|
||||
let file_type = node.file_type();
|
||||
|
||||
let handle = ctx.spawn(parse_file(path, file_type), move |view, response, ctx| {
|
||||
let metadata = match &mut view.state {
|
||||
ImportState::PathExpanded(state) => {
|
||||
// If there is an error with the file, update the file state with the error and notify
|
||||
// upstream folders.
|
||||
if let Err(e) = &response {
|
||||
state.update_tree_with_file_upload_result(
|
||||
UploadResult::Error(e.to_string()),
|
||||
file_id,
|
||||
);
|
||||
}
|
||||
|
||||
state.file_name_and_parent_cloud_id(file_id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some((file_name, parent_cloud_id)) = metadata else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(owner) = view.owner else {
|
||||
log::warn!("Import modal opened without owner");
|
||||
return;
|
||||
};
|
||||
|
||||
match response {
|
||||
Ok(FileContent::Notebook(data)) => {
|
||||
let client_id = ClientId::default();
|
||||
|
||||
view.push_new_update(
|
||||
ImportQueueArgs {
|
||||
owner,
|
||||
parent_id: view.parent_id_for_upload(parent_cloud_id),
|
||||
content: RequestContent::Notebook {
|
||||
title: file_name,
|
||||
data,
|
||||
client_id,
|
||||
file_id,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
Ok(FileContent::Workflow {
|
||||
workflows,
|
||||
workflow_enums,
|
||||
}) => view.push_new_update(
|
||||
ImportQueueArgs {
|
||||
owner,
|
||||
parent_id: view.parent_id_for_upload(parent_cloud_id),
|
||||
content: RequestContent::Workflow {
|
||||
workflows: workflows
|
||||
.into_iter()
|
||||
.map(|workflow| (workflow, ClientId::new()))
|
||||
.collect(),
|
||||
workflow_enums,
|
||||
file_id,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let next_file_id = file_id + 1;
|
||||
match &mut view.state {
|
||||
ImportState::PathExpanded(state) => {
|
||||
if continue_parsing_after_completion
|
||||
&& state.file_id_to_node.contains_key(&next_file_id)
|
||||
{
|
||||
view.parse_next_file(
|
||||
next_file_id,
|
||||
continue_parsing_after_completion,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
// If we reach the end of the parsable files or should not continue parsing, reset the abort handle.
|
||||
view.in_progress_handle = None;
|
||||
}
|
||||
}
|
||||
_ => panic!("Validated state is path expanded already"),
|
||||
};
|
||||
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
self.in_progress_handle = Some(handle.abort_handle());
|
||||
} else {
|
||||
log::error!("State should be path expanded when parsing files");
|
||||
};
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.state = ImportState::Upload;
|
||||
if let Some(handle) = self.in_progress_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_upload_state(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let is_loading = matches!(self.state, ImportState::PathLoaded | ImportState::Loading);
|
||||
let base_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.file_picker_mouse_state.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(BUTTON_FONT_SIZE),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
padding: Some(Coords {
|
||||
top: 10.,
|
||||
bottom: 10.,
|
||||
left: 70.,
|
||||
right: 70.,
|
||||
}),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BUTTON_BORDER_RADIUS))),
|
||||
border_color: Some(appearance.theme().outline().into()),
|
||||
width: Some(FILE_PICKER_BUTTON_WIDTH),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let file_picker_button = if is_loading {
|
||||
base_button
|
||||
.with_centered_text_label("Preparing...".to_string())
|
||||
.disabled()
|
||||
} else {
|
||||
base_button.with_text_and_icon_label(
|
||||
TextAndIcon::new(
|
||||
TextAndIconAlignment::TextFirst,
|
||||
"Choose files...".to_string(),
|
||||
Icon::Import.to_warpui_icon(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().accent_button_color()),
|
||||
),
|
||||
MainAxisSize::Max,
|
||||
MainAxisAlignment::Center,
|
||||
vec2f(16., 16.),
|
||||
)
|
||||
.with_inner_padding(4.),
|
||||
)
|
||||
};
|
||||
|
||||
let file_picker_element = file_picker_button
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ImportModalBodyAction::OpenFilePicker)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let supported_file_type = appearance
|
||||
.ui_builder()
|
||||
.span(SUPPORTED_FILE_TYPE_TEXT)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.hint_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let link_to_document = appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
"Learn about file support and formatting".to_string(),
|
||||
Some(FILE_TYPE_DOCS_URL.to_string()),
|
||||
None,
|
||||
self.link_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Align::new(
|
||||
Flex::column()
|
||||
.with_child(file_picker_element)
|
||||
.with_child(
|
||||
Container::new(supported_file_type)
|
||||
.with_margin_top(16.)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(link_to_document)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(BODY_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_loaded_state(
|
||||
&self,
|
||||
file_upload_state: &FileUploadState,
|
||||
sync_queue_dequeueing: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column();
|
||||
let folder_id_to_node = &file_upload_state.folder_id_to_node;
|
||||
let file_id_to_node = &file_upload_state.file_id_to_node;
|
||||
|
||||
let folder_node = folder_id_to_node
|
||||
.get(&FolderId::root_id())
|
||||
.expect("Root node should exist");
|
||||
|
||||
for item in folder_node.children() {
|
||||
column.add_child(item.render(
|
||||
appearance,
|
||||
0,
|
||||
file_upload_state.is_complete(),
|
||||
sync_queue_dequeueing,
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
));
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_left(10.)
|
||||
.with_margin_top(20.)
|
||||
.with_margin_right(10.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ImportModalBody {
|
||||
type Event = ImportModalBodyEvent;
|
||||
}
|
||||
|
||||
impl View for ImportModalBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"ImportModalBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
match &self.state {
|
||||
ImportState::Upload | ImportState::Loading | ImportState::PathLoaded => {
|
||||
self.render_upload_state(appearance)
|
||||
}
|
||||
ImportState::PathExpanded(paths) => {
|
||||
self.render_loaded_state(paths, sync_queue_dequeueing, appearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ImportModalBody {
|
||||
type Action = ImportModalBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ImportModalBodyAction::OpenFilePicker => {
|
||||
self.state = ImportState::Loading;
|
||||
ctx.emit(ImportModalBodyEvent::OpenFilePicker);
|
||||
ctx.notify();
|
||||
}
|
||||
ImportModalBodyAction::FilePickerCancelled => {
|
||||
self.state = ImportState::Upload;
|
||||
ctx.emit(ImportModalBodyEvent::UploadSelected);
|
||||
ctx.notify();
|
||||
}
|
||||
ImportModalBodyAction::FilePickerError(err) => {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(format!("{err}")),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
self.state = ImportState::Upload;
|
||||
ctx.emit(ImportModalBodyEvent::UploadSelected);
|
||||
ctx.notify();
|
||||
}
|
||||
ImportModalBodyAction::RetryFile(file_id) => {
|
||||
if let ImportState::PathExpanded(state) = &mut self.state {
|
||||
state.set_file_and_parent_to_loading(*file_id);
|
||||
}
|
||||
self.parse_next_file(*file_id, false, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
ImportModalBodyAction::ClickedToOpenTarget(hashed_id) => {
|
||||
self.reset(ctx);
|
||||
ctx.emit(ImportModalBodyEvent::OpenTargetWithHashedId(
|
||||
hashed_id.clone(),
|
||||
));
|
||||
}
|
||||
ImportModalBodyAction::PathsSelected(paths) => {
|
||||
self.state = ImportState::PathLoaded;
|
||||
|
||||
let paths_cloned = paths.clone();
|
||||
let handle = ctx.spawn(
|
||||
expand_dirs(paths_cloned.into_iter().map(PathBuf::from).collect()),
|
||||
move |view, mut upload_state, ctx| {
|
||||
view.populate_folder_cloud_object(&mut upload_state, ctx);
|
||||
view.state = ImportState::PathExpanded(upload_state);
|
||||
view.parse_next_file(FileId::first_id(), true, ctx);
|
||||
ctx.notify();
|
||||
},
|
||||
);
|
||||
|
||||
self.in_progress_handle = Some(handle.abort_handle());
|
||||
ctx.notify();
|
||||
ctx.emit(ImportModalBodyEvent::UploadSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::drive::import::nodes::{UploadResult, UploadStatus};
|
||||
|
||||
use super::{FileId, FileNode, FileType, FileUploadState, FolderId, FolderNode, ImportedNode};
|
||||
|
||||
fn mock_tree() -> FileUploadState {
|
||||
let mut folder_id_to_node = HashMap::new();
|
||||
let mut file_id_to_node = HashMap::new();
|
||||
|
||||
let mut root_folder = FolderNode::new(String::new(), FolderId(0));
|
||||
|
||||
let top_level_file = FileNode::new(
|
||||
"top_level".to_string(),
|
||||
FileType::Notebook,
|
||||
PathBuf::new(),
|
||||
FolderId(0),
|
||||
);
|
||||
|
||||
let mut top_level_folder = FolderNode::new("top_folder".to_string(), FolderId::root_id());
|
||||
|
||||
let second_level_file = FileNode::new(
|
||||
"second_level".to_string(),
|
||||
FileType::Workflow,
|
||||
PathBuf::new(),
|
||||
FolderId(1),
|
||||
);
|
||||
|
||||
top_level_folder
|
||||
.children
|
||||
.push(ImportedNode::File(FileId(1)));
|
||||
root_folder.children.push(ImportedNode::File(FileId(0)));
|
||||
root_folder.children.push(ImportedNode::Folder(FolderId(1)));
|
||||
|
||||
file_id_to_node.insert(FileId(1), second_level_file);
|
||||
file_id_to_node.insert(FileId(0), top_level_file);
|
||||
folder_id_to_node.insert(FolderId(1), top_level_folder);
|
||||
folder_id_to_node.insert(FolderId(0), root_folder);
|
||||
|
||||
let state = FileUploadState {
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
};
|
||||
|
||||
assert_eq!(state.debug_print(), "(top_folder(second_level), top_level)");
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_update_in_tree() {
|
||||
let mut state = mock_tree();
|
||||
|
||||
state.mark_folder_synced(
|
||||
UploadResult::Success("mock-folder".to_string()),
|
||||
FolderId(1),
|
||||
);
|
||||
|
||||
// Only the second level file is loaded. Top-level folder should be loaded but
|
||||
// root folder should still be loading.
|
||||
state.update_tree_with_file_upload_result(
|
||||
UploadResult::Success("mock-markdown".to_string()),
|
||||
FileId(1),
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(1))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loaded("mock-folder".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(0))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loading
|
||||
);
|
||||
|
||||
// Top level file is also loaded. Root level folder should be marked as loaded.
|
||||
state.update_tree_with_file_upload_result(
|
||||
UploadResult::Success("mock-root".to_string()),
|
||||
FileId(0),
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(0))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loaded(String::new())
|
||||
);
|
||||
|
||||
// Set second level file to be loading. All of the folders should be loading.
|
||||
state.set_file_and_parent_to_loading(FileId(1));
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(1))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loading
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(0))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loading
|
||||
);
|
||||
|
||||
// Second level file finished loading. All of the folders should be loaded.
|
||||
state.update_tree_with_file_upload_result(
|
||||
UploadResult::Success("mock-folder".to_string()),
|
||||
FileId(1),
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(1))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loaded("mock-folder".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(0))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loaded(String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_folders_update() {
|
||||
let mut folder_id_to_node = HashMap::new();
|
||||
let file_id_to_node = HashMap::new();
|
||||
|
||||
let mut root_folder = FolderNode::new(String::new(), FolderId(0));
|
||||
let empty_folder_1 = FolderNode::new("empty".to_string(), FolderId(0));
|
||||
let empty_folder_2 = FolderNode::new("empty1".to_string(), FolderId(0));
|
||||
root_folder.children.push(ImportedNode::Folder(FolderId(1)));
|
||||
root_folder.children.push(ImportedNode::Folder(FolderId(2)));
|
||||
|
||||
folder_id_to_node.insert(FolderId(0), root_folder);
|
||||
folder_id_to_node.insert(FolderId(1), empty_folder_1);
|
||||
folder_id_to_node.insert(FolderId(2), empty_folder_2);
|
||||
|
||||
let mut state = FileUploadState {
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
};
|
||||
|
||||
assert_eq!(state.debug_print(), "(empty, empty1)");
|
||||
|
||||
state.mark_folder_synced(
|
||||
UploadResult::Success("mock-folder".to_string()),
|
||||
FolderId(1),
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(1))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loaded("mock-folder".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(0))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loading
|
||||
);
|
||||
|
||||
// Errored uploads should also be considered as completed uploads.
|
||||
state.mark_folder_synced(UploadResult::Error("Failure".to_string()), FolderId(2));
|
||||
assert_eq!(
|
||||
state
|
||||
.folder_id_to_node
|
||||
.get(&FolderId(0))
|
||||
.expect("Should exist")
|
||||
.status,
|
||||
UploadStatus::Loaded(String::new())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
use crate::{
|
||||
drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType},
|
||||
notebooks::post_process_notebook,
|
||||
workflows::{
|
||||
export_workflow::export_deserialize, workflow::Workflow, workflow_enum::WorkflowEnum,
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use async_recursion::async_recursion;
|
||||
use futures_lite::StreamExt;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsStr,
|
||||
ops::{Add, AddAssign, SubAssign},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
|
||||
MouseStateHandle, ParentElement, Radius, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance, notebooks::file::is_markdown_file, server::ids::ClientId,
|
||||
themes::theme::Fill, ui_components::icons::Icon,
|
||||
};
|
||||
|
||||
use super::modal_body::{ImportModalBodyAction, BASE_INDENT, IMPORT_FONT_SIZE, INDENT_MARGIN};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "node_tests.rs"]
|
||||
mod node_tests;
|
||||
|
||||
/// Unique ID for a file node.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct FileId(pub usize);
|
||||
|
||||
impl FileId {
|
||||
pub(super) fn first_id() -> Self {
|
||||
FileId(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique ID for a folder node.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) struct FolderId(pub usize);
|
||||
|
||||
impl FolderId {
|
||||
pub(super) fn root_id() -> Self {
|
||||
FolderId(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for FileId {
|
||||
fn from(value: usize) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for FolderId {
|
||||
fn from(value: usize) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<usize> for FileId {
|
||||
fn add_assign(&mut self, rhs: usize) {
|
||||
self.0 += rhs
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<usize> for FileId {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, rhs: usize) -> Self::Output {
|
||||
Self(self.0 + rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<usize> for FolderId {
|
||||
fn add_assign(&mut self, rhs: usize) {
|
||||
self.0 += rhs
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<usize> for FolderId {
|
||||
fn sub_assign(&mut self, rhs: usize) {
|
||||
self.0 -= rhs
|
||||
}
|
||||
}
|
||||
|
||||
/// A node representing either a imported folder or a file.
|
||||
pub(super) enum ImportedNode {
|
||||
File(FileId),
|
||||
Folder(FolderId),
|
||||
}
|
||||
|
||||
impl ImportedNode {
|
||||
/// Initiate the import file tree from a path.
|
||||
#[async_recursion]
|
||||
pub(super) async fn initiate_from_path(
|
||||
full_path: PathBuf,
|
||||
parent_folder_id: FolderId,
|
||||
next_folder_id: &mut FolderId,
|
||||
next_file_id: &mut FileId,
|
||||
folder_id_to_node: &mut HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &mut HashMap<FileId, FileNode>,
|
||||
) -> Option<Self> {
|
||||
let name = full_path.file_stem()?.to_str()?.to_string();
|
||||
|
||||
if full_path.is_dir() {
|
||||
let current_folder_id = *next_folder_id;
|
||||
let mut folder_node = FolderNode::new(name, parent_folder_id);
|
||||
*next_folder_id += 1;
|
||||
|
||||
let mut entries = async_fs::read_dir(full_path).await.ok()?;
|
||||
|
||||
// Recursively create children nodes from the files and folders under the current directory.
|
||||
while let Some(entry) = entries.try_next().await.ok()? {
|
||||
let child_path = entry.path();
|
||||
|
||||
if let Some(child_node) = ImportedNode::initiate_from_path(
|
||||
child_path,
|
||||
current_folder_id,
|
||||
next_folder_id,
|
||||
next_file_id,
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
)
|
||||
.await
|
||||
{
|
||||
folder_node.children.push(child_node);
|
||||
}
|
||||
}
|
||||
|
||||
// If the folder's parent is the root node, we should keep the folder node even if
|
||||
// it has no children.
|
||||
if !folder_node.children.is_empty() || parent_folder_id == FolderId::root_id() {
|
||||
folder_id_to_node.insert(current_folder_id, folder_node);
|
||||
return Some(ImportedNode::Folder(current_folder_id));
|
||||
}
|
||||
|
||||
// If the folder children is empty, we don't consider the folder as an import node.
|
||||
*next_folder_id -= 1;
|
||||
} else if full_path.is_file() {
|
||||
let file_type = full_path.as_path().try_into();
|
||||
if let Ok(file_type) = file_type {
|
||||
let file_node = FileNode::new(name, file_type, full_path, parent_folder_id);
|
||||
let current_file_id = *next_file_id;
|
||||
file_id_to_node.insert(current_file_id, file_node);
|
||||
*next_file_id += 1;
|
||||
|
||||
return Some(ImportedNode::File(current_file_id));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
indent_level: usize,
|
||||
allow_click_to_open_target: bool,
|
||||
sync_queue_dequeueing: bool,
|
||||
folder_id_to_node: &HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &HashMap<FileId, FileNode>,
|
||||
) -> Box<dyn Element> {
|
||||
match &self {
|
||||
ImportedNode::File(file_id) => {
|
||||
let file_node = file_id_to_node.get(file_id).expect("Should exist");
|
||||
file_node.render(
|
||||
indent_level,
|
||||
sync_queue_dequeueing,
|
||||
allow_click_to_open_target,
|
||||
*file_id,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
ImportedNode::Folder(folder_id) => {
|
||||
let folder_node = folder_id_to_node.get(folder_id).expect("Should exist");
|
||||
folder_node.render(
|
||||
sync_queue_dequeueing,
|
||||
appearance,
|
||||
indent_level,
|
||||
allow_click_to_open_target,
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn debug_print(
|
||||
&self,
|
||||
folder_id_to_node: &HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &HashMap<FileId, FileNode>,
|
||||
) -> String {
|
||||
match &self {
|
||||
ImportedNode::File(file_id) => {
|
||||
let file_node = file_id_to_node.get(file_id).expect("Should exist");
|
||||
file_node.debug_print()
|
||||
}
|
||||
ImportedNode::Folder(folder_id) => {
|
||||
let folder_node = folder_id_to_node.get(folder_id).expect("Should exist");
|
||||
folder_node.debug_print(folder_id_to_node, file_id_to_node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct FolderNode {
|
||||
parent_id: FolderId,
|
||||
cloud_object_id: ClientId,
|
||||
name: String,
|
||||
children: Vec<ImportedNode>,
|
||||
server_id: Option<String>,
|
||||
all_children_synced: bool,
|
||||
status: UploadStatus,
|
||||
|
||||
open_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl FolderNode {
|
||||
fn new(name: String, parent_id: FolderId) -> Self {
|
||||
Self {
|
||||
name,
|
||||
parent_id,
|
||||
cloud_object_id: ClientId::new(),
|
||||
children: Vec::new(),
|
||||
server_id: None,
|
||||
all_children_synced: false,
|
||||
status: UploadStatus::Loading,
|
||||
open_button_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn debug_print(
|
||||
&self,
|
||||
folder_id_to_node: &HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &HashMap<FileId, FileNode>,
|
||||
) -> String {
|
||||
use itertools::Itertools;
|
||||
|
||||
if self.children.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
|
||||
let children_string = self
|
||||
.children
|
||||
.iter()
|
||||
.map(|child_node| child_node.debug_print(folder_id_to_node, file_id_to_node))
|
||||
.sorted()
|
||||
.join(", ");
|
||||
format!("{}({})", self.name.clone(), children_string)
|
||||
}
|
||||
|
||||
fn are_children_saved_locally(
|
||||
&self,
|
||||
folder_id_to_node: &HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &HashMap<FileId, FileNode>,
|
||||
) -> bool {
|
||||
self.children.iter().all(|node| match node {
|
||||
ImportedNode::File(file_id) => file_id_to_node
|
||||
.get(file_id)
|
||||
.map(|file_node| file_node.status.is_saved_locally())
|
||||
.unwrap_or(true),
|
||||
ImportedNode::Folder(folder_id) => folder_id_to_node
|
||||
.get(folder_id)
|
||||
.map(|folder_node| folder_node.status.is_loaded())
|
||||
.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the folder is loaded. This is true if all of its children are loaded.
|
||||
fn are_children_loaded(
|
||||
&self,
|
||||
folder_id_to_node: &HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &HashMap<FileId, FileNode>,
|
||||
) -> bool {
|
||||
self.children.iter().all(|node| match node {
|
||||
ImportedNode::File(file_id) => file_id_to_node
|
||||
.get(file_id)
|
||||
.map(|file_node| file_node.status.is_loaded())
|
||||
.unwrap_or(true),
|
||||
ImportedNode::Folder(folder_id) => folder_id_to_node
|
||||
.get(folder_id)
|
||||
.map(|folder_node| folder_node.status.is_loaded())
|
||||
.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn cloud_id(&self) -> ClientId {
|
||||
self.cloud_object_id
|
||||
}
|
||||
|
||||
pub(super) fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
pub(super) fn parent_id(&self) -> FolderId {
|
||||
self.parent_id
|
||||
}
|
||||
|
||||
pub(super) fn children(&self) -> &Vec<ImportedNode> {
|
||||
&self.children
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
sync_queue_dequeueing: bool,
|
||||
appearance: &Appearance,
|
||||
indent_level: usize,
|
||||
allow_click_to_open_target: bool,
|
||||
folder_id_to_node: &HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: &HashMap<FileId, FileNode>,
|
||||
) -> Box<dyn Element> {
|
||||
let override_color = self.status.override_text_color(appearance);
|
||||
let status_icon = self
|
||||
.status
|
||||
.render_status_icon(sync_queue_dequeueing, appearance);
|
||||
|
||||
let icon_color =
|
||||
override_color.unwrap_or(warp_drive_icon_color(appearance, DriveObjectType::Folder));
|
||||
let icon = ConstrainedBox::new(
|
||||
Icon::Folder
|
||||
.to_warpui_icon(Fill::Solid(icon_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(IMPORT_FONT_SIZE)
|
||||
.with_width(IMPORT_FONT_SIZE)
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
let row = Flex::row()
|
||||
.with_child(status_icon)
|
||||
.with_child(Container::new(icon).with_margin_right(3.).finish())
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(self.name.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(IMPORT_FONT_SIZE),
|
||||
font_color: override_color,
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish();
|
||||
|
||||
let total_indent = BASE_INDENT + INDENT_MARGIN * indent_level as f32;
|
||||
let folder_row = match &self.status {
|
||||
UploadStatus::Loaded(server_id) if allow_click_to_open_target => {
|
||||
render_highlighted_pill(
|
||||
row,
|
||||
total_indent,
|
||||
self.open_button_mouse_state.clone(),
|
||||
server_id.to_owned(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
_ => Container::new(row)
|
||||
.with_margin_left(total_indent)
|
||||
.with_padding_top(10.)
|
||||
.with_padding_bottom(10.)
|
||||
.finish(),
|
||||
};
|
||||
|
||||
column.add_child(folder_row);
|
||||
|
||||
for item in &self.children {
|
||||
column.add_child(item.render(
|
||||
appearance,
|
||||
indent_level + 1,
|
||||
allow_click_to_open_target,
|
||||
sync_queue_dequeueing,
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
));
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum FileContent {
|
||||
Workflow {
|
||||
workflows: Vec<Workflow>,
|
||||
workflow_enums: HashMap<ClientId, WorkflowEnum>,
|
||||
},
|
||||
Notebook(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) enum FileType {
|
||||
Workflow,
|
||||
Notebook,
|
||||
}
|
||||
|
||||
impl TryFrom<&Path> for FileType {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(path: &Path) -> Result<Self, Self::Error> {
|
||||
if is_markdown_file(path) {
|
||||
Ok(FileType::Notebook)
|
||||
} else {
|
||||
let extension = path.extension();
|
||||
if extension == Some(OsStr::new("yaml")) || extension == Some(OsStr::new("yml")) {
|
||||
Ok(FileType::Workflow)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum UploadStatus {
|
||||
Loading,
|
||||
SavedLocally,
|
||||
Loaded(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl UploadStatus {
|
||||
pub(super) fn is_loaded(&self) -> bool {
|
||||
!matches!(&self, Self::Loading | Self::SavedLocally)
|
||||
}
|
||||
|
||||
pub(super) fn is_saved_locally(&self) -> bool {
|
||||
!matches!(&self, Self::Loading)
|
||||
}
|
||||
|
||||
fn override_text_color(&self, appearance: &Appearance) -> Option<ColorU> {
|
||||
match &self {
|
||||
Self::Error(_) => Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
.into_solid(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status_icon(
|
||||
&self,
|
||||
sync_queue_dequeueing: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let status_icon_element = match &self {
|
||||
UploadStatus::SavedLocally if !sync_queue_dequeueing => Icon::Laptop
|
||||
.to_warpui_icon(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1()),
|
||||
)
|
||||
.finish(),
|
||||
UploadStatus::Loading | UploadStatus::SavedLocally => Icon::Refresh
|
||||
.to_warpui_icon(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1()),
|
||||
)
|
||||
.finish(),
|
||||
UploadStatus::Loaded(_) => Icon::Check
|
||||
.to_warpui_icon(Fill::Solid(ColorU::new(11, 142, 71, 255)))
|
||||
.finish(),
|
||||
UploadStatus::Error(_) => Icon::AlertTriangle
|
||||
.to_warpui_icon(Fill::Solid(appearance.theme().ui_error_color()))
|
||||
.finish(),
|
||||
};
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(status_icon_element)
|
||||
.with_height(IMPORT_FONT_SIZE)
|
||||
.with_width(IMPORT_FONT_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct FileNode {
|
||||
/// The display name of the file. This is not necessarily the same as its on-disk filename.
|
||||
name: String,
|
||||
file_type: FileType,
|
||||
status: UploadStatus,
|
||||
full_path: PathBuf,
|
||||
parent_id: FolderId,
|
||||
|
||||
refresh_button_mouse_state: MouseStateHandle,
|
||||
open_button_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl FileNode {
|
||||
fn new(name: String, file_type: FileType, full_path: PathBuf, parent_id: FolderId) -> Self {
|
||||
Self {
|
||||
name,
|
||||
file_type,
|
||||
full_path,
|
||||
parent_id,
|
||||
status: UploadStatus::Loading,
|
||||
|
||||
refresh_button_mouse_state: Default::default(),
|
||||
open_button_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn debug_print(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
pub(super) fn full_path(&self) -> PathBuf {
|
||||
self.full_path.clone()
|
||||
}
|
||||
|
||||
pub(super) fn file_type(&self) -> FileType {
|
||||
self.file_type
|
||||
}
|
||||
|
||||
pub(super) fn saved_locally(&mut self) {
|
||||
if !self.status.is_loaded() {
|
||||
self.status = UploadStatus::SavedLocally;
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
indent_level: usize,
|
||||
sync_queue_dequeueing: bool,
|
||||
allow_click_to_open_target: bool,
|
||||
file_id: FileId,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let status_icon = self
|
||||
.status
|
||||
.render_status_icon(sync_queue_dequeueing, appearance);
|
||||
|
||||
let override_color = self.status.override_text_color(appearance);
|
||||
|
||||
let icon_element = match &self.file_type {
|
||||
FileType::Workflow => Icon::Workflow
|
||||
.to_warpui_icon(Fill::Solid(override_color.unwrap_or(
|
||||
warp_drive_icon_color(appearance, DriveObjectType::Workflow),
|
||||
)))
|
||||
.finish(),
|
||||
FileType::Notebook => Icon::Notebook
|
||||
.to_warpui_icon(Fill::Solid(override_color.unwrap_or(
|
||||
warp_drive_icon_color(
|
||||
appearance,
|
||||
DriveObjectType::Notebook {
|
||||
is_ai_document: false,
|
||||
},
|
||||
),
|
||||
)))
|
||||
.finish(),
|
||||
};
|
||||
let icon = ConstrainedBox::new(icon_element)
|
||||
.with_height(IMPORT_FONT_SIZE)
|
||||
.with_width(IMPORT_FONT_SIZE)
|
||||
.finish();
|
||||
|
||||
let mut item_row = Flex::row()
|
||||
.with_child(status_icon)
|
||||
.with_child(Container::new(icon).with_margin_right(4.).finish())
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(self.name.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(IMPORT_FONT_SIZE),
|
||||
font_color: override_color,
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let total_indent = BASE_INDENT + INDENT_MARGIN * indent_level as f32;
|
||||
match &self.status {
|
||||
UploadStatus::Error(e) => {
|
||||
item_row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Align::new(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.retry_button(16., self.refresh_button_mouse_state.clone())
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ImportModalBodyAction::RetryFile(
|
||||
file_id,
|
||||
))
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let error_row = appearance
|
||||
.ui_builder()
|
||||
.span(e.to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().ui_error_color()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(item_row.finish())
|
||||
.with_child(
|
||||
Container::new(error_row)
|
||||
.with_margin_top(4.)
|
||||
.with_margin_left(INDENT_MARGIN)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(total_indent)
|
||||
.with_padding_top(10.)
|
||||
.with_padding_bottom(10.)
|
||||
.finish()
|
||||
}
|
||||
UploadStatus::Loaded(server_id) if allow_click_to_open_target => {
|
||||
render_highlighted_pill(
|
||||
item_row.finish(),
|
||||
total_indent,
|
||||
self.open_button_mouse_state.clone(),
|
||||
server_id.to_owned(),
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
_ => Container::new(item_row.finish())
|
||||
.with_margin_left(total_indent)
|
||||
.with_padding_top(10.)
|
||||
.with_padding_bottom(10.)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn expand_dirs(dirs: Vec<PathBuf>) -> FileUploadState {
|
||||
let mut next_folder_id = FolderId::from(0);
|
||||
let mut next_file_id = FileId::from(0);
|
||||
|
||||
let mut folder_id_to_node = HashMap::new();
|
||||
let mut file_id_to_node = HashMap::new();
|
||||
|
||||
let current_folder_id = next_folder_id;
|
||||
let mut dummy_root_node = FolderNode::new(String::new(), current_folder_id);
|
||||
|
||||
next_folder_id += 1;
|
||||
for child_path in dirs {
|
||||
if let Some(child_node) = ImportedNode::initiate_from_path(
|
||||
child_path,
|
||||
current_folder_id,
|
||||
&mut next_folder_id,
|
||||
&mut next_file_id,
|
||||
&mut folder_id_to_node,
|
||||
&mut file_id_to_node,
|
||||
)
|
||||
.await
|
||||
{
|
||||
dummy_root_node.children.push(child_node);
|
||||
}
|
||||
}
|
||||
folder_id_to_node.insert(current_folder_id, dummy_root_node);
|
||||
|
||||
FileUploadState::new(folder_id_to_node, file_id_to_node)
|
||||
}
|
||||
|
||||
pub(super) struct FileUploadState {
|
||||
pub(super) folder_id_to_node: HashMap<FolderId, FolderNode>,
|
||||
pub(super) file_id_to_node: HashMap<FileId, FileNode>,
|
||||
}
|
||||
|
||||
impl FileUploadState {
|
||||
fn new(
|
||||
folder_id_to_node: HashMap<FolderId, FolderNode>,
|
||||
file_id_to_node: HashMap<FileId, FileNode>,
|
||||
) -> Self {
|
||||
Self {
|
||||
folder_id_to_node,
|
||||
file_id_to_node,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn debug_print(&self) -> String {
|
||||
ImportedNode::Folder(FolderId::root_id())
|
||||
.debug_print(&self.folder_id_to_node, &self.file_id_to_node)
|
||||
}
|
||||
|
||||
// Get the cloud id for the provided folder id. Returns None if the folder
|
||||
// is the root node folder.
|
||||
pub(super) fn folder_cloud_id(&self, folder_id: FolderId) -> Option<ClientId> {
|
||||
if folder_id == FolderId::root_id() {
|
||||
// Root folder should not have a client id.
|
||||
return None;
|
||||
}
|
||||
match self.folder_id_to_node.get(&folder_id) {
|
||||
Some(folder_node) => Some(folder_node.cloud_id()),
|
||||
None => {
|
||||
log::error!("Provided folder id should exist");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_name_and_parent_cloud_id(
|
||||
&self,
|
||||
file_id: FileId,
|
||||
) -> Option<(String, Option<ClientId>)> {
|
||||
let file_node = self.file_id_to_node.get(&file_id)?;
|
||||
let parent_cloud_id = self.folder_cloud_id(file_node.parent_id);
|
||||
Some((file_node.name.clone(), parent_cloud_id))
|
||||
}
|
||||
|
||||
pub(super) fn mark_folder_synced(&mut self, result: UploadResult, folder_id: FolderId) {
|
||||
let parent_id = if let Some(folder) = self.folder_id_to_node.get_mut(&folder_id) {
|
||||
let should_update_upstream_folders = match result {
|
||||
// If uploading the folder is not successful, its children will not upload.
|
||||
// Mark the folder as errored and update upstream folders.
|
||||
UploadResult::Error(e) => {
|
||||
folder.status = UploadStatus::Error(e);
|
||||
true
|
||||
}
|
||||
// If the folder has no children, mark the folder as completed and update
|
||||
// upstream folders.
|
||||
UploadResult::Success(server_id) => {
|
||||
folder.server_id = Some(server_id.clone());
|
||||
|
||||
// If a folder has no children or all of its children complete syncing,
|
||||
// we need to bubble the state up in the folder hierachy tree.
|
||||
if folder.children().is_empty() || folder.all_children_synced {
|
||||
folder.status = UploadStatus::Loaded(server_id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if should_update_upstream_folders {
|
||||
Some(folder.parent_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(parent_id) = parent_id {
|
||||
self.update_upstream_folders_loaded(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_file_and_parent_to_loading(&mut self, file_id: FileId) {
|
||||
let parent_id = match self.file_id_to_node.get_mut(&file_id) {
|
||||
Some(file_node) => {
|
||||
file_node.status = UploadStatus::Loading;
|
||||
Some(file_node.parent_id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
if let Some(parent_id) = parent_id {
|
||||
self.update_upstream_folders_loading(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// File upload is completed if the root node is marked as complete.
|
||||
pub(super) fn is_complete(&self) -> bool {
|
||||
self.folder_id_to_node
|
||||
.get(&FolderId::root_id())
|
||||
.expect("Root node should exist")
|
||||
.status
|
||||
.is_loaded()
|
||||
}
|
||||
|
||||
pub(super) fn all_files_saved_locally(&self) -> bool {
|
||||
self.folder_id_to_node
|
||||
.get(&FolderId::root_id())
|
||||
.expect("Root node should exist")
|
||||
.are_children_saved_locally(&self.folder_id_to_node, &self.file_id_to_node)
|
||||
}
|
||||
|
||||
/// This recursively updates the upstream folder to be loading.
|
||||
fn update_upstream_folders_loading(&mut self, parent_folder_id: FolderId) {
|
||||
let mut next_folder_to_update = parent_folder_id;
|
||||
|
||||
while let Some(folder_node) = self.folder_id_to_node.get(&next_folder_to_update) {
|
||||
let parent_node = folder_node.parent_id;
|
||||
|
||||
if folder_node.status.is_loaded() {
|
||||
let folder_node = self
|
||||
.folder_id_to_node
|
||||
.get_mut(&next_folder_to_update)
|
||||
.expect("Should exist");
|
||||
folder_node.all_children_synced = false;
|
||||
folder_node.status = UploadStatus::Loading
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if next_folder_to_update == FolderId::root_id() {
|
||||
break;
|
||||
}
|
||||
|
||||
next_folder_to_update = parent_node;
|
||||
}
|
||||
}
|
||||
|
||||
/// This recursively updates the upstream folder if it is completed.
|
||||
fn update_upstream_folders_loaded(&mut self, parent_folder_id: FolderId) {
|
||||
let mut next_folder_to_update = parent_folder_id;
|
||||
|
||||
while let Some(folder_node) = self.folder_id_to_node.get(&next_folder_to_update) {
|
||||
let parent_node = folder_node.parent_id;
|
||||
let folder_is_root_node = next_folder_to_update == FolderId::root_id();
|
||||
|
||||
if !folder_node.status.is_loaded()
|
||||
&& folder_node.are_children_loaded(&self.folder_id_to_node, &self.file_id_to_node)
|
||||
{
|
||||
let folder_node = self
|
||||
.folder_id_to_node
|
||||
.get_mut(&next_folder_to_update)
|
||||
.expect("Should exist");
|
||||
folder_node.all_children_synced = true;
|
||||
|
||||
if let Some(id) = &folder_node.server_id {
|
||||
folder_node.status = UploadStatus::Loaded(id.clone());
|
||||
} else if folder_is_root_node {
|
||||
folder_node.status = UploadStatus::Loaded(String::new());
|
||||
} else {
|
||||
// Normally a folder should have been uploaded for a file to be uploaded.
|
||||
// However, in the rare case when a file errors out when parsing and it happens
|
||||
// to be the last file in the folder, we could get into a situation where the
|
||||
// the folder is not uploaded but all of its children complete syncing.
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if folder_is_root_node {
|
||||
break;
|
||||
}
|
||||
|
||||
next_folder_to_update = parent_node;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_tree_with_file_upload_result(
|
||||
&mut self,
|
||||
result: UploadResult,
|
||||
file_id: FileId,
|
||||
) -> bool {
|
||||
let Some(file_node_to_update) = self.file_id_to_node.get_mut(&file_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
file_node_to_update.status = match result {
|
||||
UploadResult::Success(id) => UploadStatus::Loaded(id),
|
||||
UploadResult::Error(e) => UploadStatus::Error(format!("Failed to parse file: {e}")),
|
||||
};
|
||||
|
||||
let parent_id = file_node_to_update.parent_id;
|
||||
self.update_upstream_folders_loaded(parent_id);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn parse_file(path: PathBuf, file_type: FileType) -> Result<FileContent> {
|
||||
match file_type {
|
||||
FileType::Notebook => Ok(FileContent::Notebook(post_process_notebook(
|
||||
&async_fs::read_to_string(path).await?,
|
||||
))),
|
||||
FileType::Workflow => {
|
||||
let file = async_fs::read(path).await?;
|
||||
let mut workflow_enums: HashMap<ClientId, WorkflowEnum> = HashMap::new();
|
||||
let mut workflows = vec![];
|
||||
|
||||
for document in serde_yaml::Deserializer::from_slice(&file) {
|
||||
let (workflow, new_enums) = export_deserialize(document)?;
|
||||
|
||||
workflows.push(workflow);
|
||||
workflow_enums.extend(new_enums);
|
||||
}
|
||||
|
||||
Ok(FileContent::Workflow {
|
||||
workflows,
|
||||
workflow_enums,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum UploadResult {
|
||||
Success(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
fn render_highlighted_pill(
|
||||
row: Box<dyn Element>,
|
||||
total_indent: f32,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
server_id: String,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let inner = Container::new(row)
|
||||
.with_margin_left(total_indent)
|
||||
.with_padding_top(5.)
|
||||
.with_padding_bottom(5.)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Hoverable::new(mouse_state_handle, |state| {
|
||||
if state.is_hovered() || state.is_clicked() {
|
||||
Container::new(inner)
|
||||
.with_background(appearance.theme().surface_3())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish()
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ImportModalBodyAction::ClickedToOpenTarget(
|
||||
server_id.clone(),
|
||||
))
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(5.)
|
||||
.with_padding_bottom(5.)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
cloud_object::{model::persistence::CloudModel, CloudObjectEventEntrypoint, Owner},
|
||||
drive::folders::FolderId,
|
||||
notebooks::CloudNotebookModel,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ClientId, SyncId},
|
||||
},
|
||||
workflows::{workflow::Workflow, workflow_enum::WorkflowEnum},
|
||||
};
|
||||
|
||||
use super::nodes::{self, FileId};
|
||||
|
||||
pub(super) enum ImportQueueEvent {
|
||||
FileCompleted {
|
||||
file_id: FileId,
|
||||
server_id: Option<String>,
|
||||
},
|
||||
FolderCompleted {
|
||||
folder_id: nodes::FolderId,
|
||||
server_id: Option<String>,
|
||||
},
|
||||
FileSavedLocally(FileId),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum ParentId {
|
||||
FolderToUpload(ClientId),
|
||||
InitialFolder(Option<SyncId>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ImportQueueArgs {
|
||||
pub(super) owner: Owner,
|
||||
pub(super) parent_id: ParentId,
|
||||
pub(super) content: RequestContent,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum RequestContent {
|
||||
Folder {
|
||||
name: String,
|
||||
client_id: ClientId,
|
||||
folder_id: nodes::FolderId,
|
||||
},
|
||||
Notebook {
|
||||
title: String,
|
||||
data: String,
|
||||
client_id: ClientId,
|
||||
file_id: FileId,
|
||||
},
|
||||
Workflow {
|
||||
workflows: Vec<(Workflow, ClientId)>,
|
||||
workflow_enums: HashMap<ClientId, WorkflowEnum>,
|
||||
file_id: FileId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FileCompletionCounter {
|
||||
client_id_to_file_id: HashMap<ClientId, FileId>,
|
||||
file_id_to_counter: HashMap<FileId, usize>,
|
||||
}
|
||||
|
||||
impl FileCompletionCounter {
|
||||
fn request_completed(&mut self, client_id: ClientId) -> Option<FileId> {
|
||||
if let Some(file_id) = self.client_id_to_file_id.get(&client_id) {
|
||||
let completed = match self.file_id_to_counter.get_mut(file_id) {
|
||||
Some(counter) => {
|
||||
*counter = counter.saturating_sub(1);
|
||||
*counter == 0
|
||||
}
|
||||
None => {
|
||||
log::error!("File completion counter should exist but it doesn't");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if completed {
|
||||
return Some(*file_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn add_entry(&mut self, client_id: ClientId, file_id: FileId) {
|
||||
self.client_id_to_file_id.insert(client_id, file_id);
|
||||
*self.file_id_to_counter.entry(file_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ImportQueue {
|
||||
queue: Vec<ImportQueueArgs>,
|
||||
client_to_server_id: HashMap<ClientId, Option<FolderId>>,
|
||||
client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>,
|
||||
file_completion: FileCompletionCounter,
|
||||
}
|
||||
|
||||
impl ImportQueue {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let update_manager = UpdateManager::handle(ctx);
|
||||
ctx.subscribe_to_model(&update_manager, |me, event, ctx| {
|
||||
me.handle_update_manager_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
client_to_server_id: HashMap::default(),
|
||||
file_completion: Default::default(),
|
||||
client_to_node_folder_id: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Whether all dependcies of an item has been sync-ed.
|
||||
fn dependency_synced(&self, item: &ImportQueueArgs) -> bool {
|
||||
match &item.parent_id {
|
||||
ParentId::FolderToUpload(id) => self
|
||||
.client_to_server_id
|
||||
.get(id)
|
||||
.map(|item| item.is_some())
|
||||
.unwrap_or(false),
|
||||
ParentId::InitialFolder(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue a new request to the import queue.
|
||||
pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) {
|
||||
// Update internal tracker of the object.
|
||||
match &arg.content {
|
||||
RequestContent::Folder {
|
||||
client_id,
|
||||
folder_id,
|
||||
..
|
||||
} => {
|
||||
self.client_to_server_id.insert(*client_id, None);
|
||||
self.client_to_node_folder_id.insert(*client_id, *folder_id);
|
||||
}
|
||||
RequestContent::Notebook {
|
||||
client_id, file_id, ..
|
||||
} => self.file_completion.add_entry(*client_id, *file_id),
|
||||
RequestContent::Workflow {
|
||||
workflows, file_id, ..
|
||||
} => {
|
||||
for (_, client_id) in workflows {
|
||||
self.file_completion.add_entry(*client_id, *file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.queue.push(arg);
|
||||
self.dequeue(ctx);
|
||||
}
|
||||
|
||||
// Dequeue a new request from the import queue.
|
||||
pub fn dequeue(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(idx) = self
|
||||
.queue
|
||||
.iter()
|
||||
.position(|item| self.dependency_synced(item))
|
||||
{
|
||||
let dequeued_item = self.queue.remove(idx);
|
||||
let parent_id = match dequeued_item.parent_id {
|
||||
ParentId::FolderToUpload(client_id) => Some(SyncId::ServerId(
|
||||
self.client_to_server_id
|
||||
.get(&client_id)
|
||||
.expect("Client id entry should exist")
|
||||
.expect("Server id entry should exist")
|
||||
.into(),
|
||||
)),
|
||||
ParentId::InitialFolder(folder_id) => folder_id,
|
||||
};
|
||||
|
||||
match dequeued_item.content {
|
||||
RequestContent::Folder {
|
||||
name, client_id, ..
|
||||
} => {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
name,
|
||||
dequeued_item.owner,
|
||||
client_id,
|
||||
parent_id,
|
||||
false,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
RequestContent::Notebook {
|
||||
title,
|
||||
data,
|
||||
client_id,
|
||||
file_id,
|
||||
} => {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
client_id,
|
||||
dequeued_item.owner,
|
||||
parent_id,
|
||||
CloudNotebookModel {
|
||||
title,
|
||||
data,
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
}
|
||||
RequestContent::Workflow {
|
||||
workflows,
|
||||
workflow_enums,
|
||||
file_id,
|
||||
} => {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
// Create any new workflow enums
|
||||
for (client_id, workflow_enum) in workflow_enums {
|
||||
update_manager.create_workflow_enum(
|
||||
workflow_enum,
|
||||
dequeued_item.owner,
|
||||
client_id,
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the workflow
|
||||
for (workflow, client_id) in workflows {
|
||||
update_manager.create_workflow(
|
||||
workflow,
|
||||
dequeued_item.owner,
|
||||
parent_id,
|
||||
client_id,
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
}
|
||||
}
|
||||
self.dequeue(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
event: &UpdateManagerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if matches!(&result.operation, ObjectOperation::Create { .. }) {
|
||||
let Some(client_id) = result.client_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let is_successful = matches!(&result.success_type, OperationSuccessType::Success);
|
||||
let server_id = result.server_id;
|
||||
if let Some(file_id) = self.file_completion.request_completed(client_id) {
|
||||
ctx.emit(ImportQueueEvent::FileCompleted {
|
||||
file_id,
|
||||
server_id: server_id.map(|server_id| server_id.uid()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return early if we are not successfully uploading a folder.
|
||||
if !is_successful {
|
||||
if let Some(node_id) = self.client_to_node_folder_id.get(&client_id) {
|
||||
ctx.emit(ImportQueueEvent::FolderCompleted {
|
||||
folder_id: *node_id,
|
||||
server_id: server_id.map(|server_id| server_id.uid()),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
|
||||
let Some(folder_id) = cloud_model
|
||||
.get_folder_by_uid(&result.server_id.expect("Expect id").uid())
|
||||
.and_then(|folder| folder.id.into_server())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let replaced = match self.client_to_server_id.get_mut(&client_id) {
|
||||
Some(value) if value.is_none() => {
|
||||
*value = Some(folder_id.into());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if replaced {
|
||||
if let Some(node_id) = self.client_to_node_folder_id.get(&client_id) {
|
||||
ctx.emit(ImportQueueEvent::FolderCompleted {
|
||||
folder_id: *node_id,
|
||||
server_id: server_id.map(|server_id| server_id.uid()),
|
||||
});
|
||||
}
|
||||
self.dequeue(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ImportQueue {
|
||||
type Event = ImportQueueEvent;
|
||||
}
|
||||
Reference in New Issue
Block a user