Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+42
View File
@@ -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))))");
});
}
+4
View File
@@ -0,0 +1,4 @@
pub mod modal;
mod modal_body;
mod nodes;
mod queue;
+385
View File
@@ -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);
}
}
}
}
+583
View File
@@ -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);
}
}
}
}
+194
View File
@@ -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())
);
}
+963
View File
@@ -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()
}
+328
View File
@@ -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;
}