584 lines
21 KiB
Rust
584 lines
21 KiB
Rust
use futures_util::stream::AbortHandle;
|
|
use galaxyui::{
|
|
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 pathfinder_geometry::vector::vec2f;
|
|
use std::path::PathBuf;
|
|
|
|
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_galaxyui_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);
|
|
}
|
|
}
|
|
}
|
|
}
|