From a1f952ef5e3deac03ea553116d068971eecf5cd9 Mon Sep 17 00:00:00 2001 From: "Ericson \"Fogo\" Soares" Date: Fri, 3 Nov 2023 14:06:34 -0300 Subject: [PATCH] [ENG-1181] File System actions for Ephemeral Files (#1677) * Backend side * Rust fmt * Removing uneeded duplicate files rspc route * Create folder for ephemeral files * Ephemeral delete files * First draft on copy, cut and delete, still buggy * Fixing copy function and updating async-channel dep * Rename and some fixes --- Cargo.lock | Bin 235504 -> 236387 bytes apps/desktop/src-tauri/src/tauri_plugins.rs | 11 - core/Cargo.toml | 5 +- core/src/api/ephemeral_files.rs | 542 ++++++++++++++++++ core/src/api/files.rs | 82 +-- core/src/api/mod.rs | 2 + core/src/api/tags.rs | 27 +- core/src/job/mod.rs | 13 +- core/src/job/worker.rs | 7 +- core/src/object/media/media_processor/job.rs | 24 +- core/src/object/media/thumbnail/actor.rs | 3 - core/src/object/media/thumbnail/worker.rs | 43 +- .../ContextMenu/FilePath/CutCopyItems.tsx | 58 +- .../Explorer/ContextMenu/FilePath/Items.tsx | 67 ++- .../Explorer/ContextMenu/SharedItems.tsx | 7 +- .../Explorer/FilePath/DeleteDialog.tsx | 32 +- .../$libraryId/Explorer/Inspector/index.tsx | 2 +- .../$libraryId/Explorer/ParentContextMenu.tsx | 138 +++-- .../Explorer/QuickPreview/index.tsx | 146 +++-- .../app/$libraryId/Explorer/TopBarOptions.tsx | 29 +- .../Explorer/View/RenamableItemText.tsx | 39 +- interface/app/$libraryId/Explorer/store.ts | 30 +- .../app/$libraryId/Explorer/useExplorer.ts | 4 + interface/app/$libraryId/ephemeral.tsx | 4 + interface/hooks/useKeyCopyCutPaste.ts | 93 ++- interface/hooks/useKeyDeleteFile.tsx | 32 +- packages/client/src/core.ts | 19 +- packages/client/src/utils/explorerItem.ts | 4 + 28 files changed, 1125 insertions(+), 338 deletions(-) create mode 100644 core/src/api/ephemeral_files.rs diff --git a/Cargo.lock b/Cargo.lock index 516c396d2b287b4cb42992131adb537c1168f4be..b1ce7c6b2d09221c282d2dbac5441d950cd808ca 100644 GIT binary patch delta 483 zcmZ{gJ!@1!7=_sz2#Li?Ky$^F&1F++)_G^{X)A`p~;`rRv4!aWdZ; z@66_L5~o|U`P9zio1K_v9bGavwR t-f&C70H{AWqi%coui*T&cjNct-pbkO%316Cn|}shF2=>_V!U+q_AlASni&89 delta 132 zcmV-~0DJ%Awhr*G4zLs(ldP;%KR7WtH8W#1GC4CdHZU1H#K56F*7kVVrFJxG&W>2m+|8Q7?ZynA(Me16qk7y0vD66 m9|p5X976(=5^5T=t{(node: Arc) -> io::Result AlphaRouter { + R.router() + .procedure("getMediaData", { + R.query(|_, full_path: PathBuf| async move { + let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) else { + return Ok(None); + }; + + // TODO(fogodev): change this when we have media data for audio and videos + let image_extension = ImageExtension::from_str(extension).map_err(|e| { + error!("Failed to parse image extension: {e:#?}"); + rspc::Error::new(ErrorCode::BadRequest, "Invalid image extension".to_string()) + })?; + + if !can_extract_media_data_for_image(&image_extension) { + return Ok(None); + } + + match extract_media_data(full_path).await { + Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))), + Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath( + _, + ))) => Ok(None), + Err(e) => Err(rspc::Error::with_cause( + ErrorCode::InternalServerError, + "Failed to extract media data".to_string(), + e, + )), + } + }) + }) + .procedure("createFolder", { + #[derive(Type, Deserialize)] + pub struct CreateEphemeralFolderArgs { + pub path: PathBuf, + pub name: Option, + } + R.with2(library()).mutation( + |(_, library), + CreateEphemeralFolderArgs { mut path, name }: CreateEphemeralFolderArgs| async move { + path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR)); + + create_directory(path, &library).await + }, + ) + }) + .procedure("deleteFiles", { + R.with2(library()) + .mutation(|(_, library), paths: Vec| async move { + paths + .into_iter() + .map(|path| async move { + match fs::metadata(&path).await { + Ok(metadata) => if metadata.is_dir() { + fs::remove_dir_all(&path).await + } else { + fs::remove_file(&path).await + } + .map_err(|e| FileIOError::from((path, e, "Failed to delete file"))), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(FileIOError::from(( + path, + e, + "Failed to get file metadata for deletion", + ))), + } + }) + .collect::>() + .try_join() + .await?; + + invalidate_query!(library, "search.ephemeralPaths"); + + Ok(()) + }) + }) + .procedure("copyFiles", { + R.with2(library()) + .mutation(|(_, library), args: EphemeralFileSystemOps| async move { + args.copy(&library).await + }) + }) + .procedure("cutFiles", { + R.with2(library()) + .mutation(|(_, library), args: EphemeralFileSystemOps| async move { + args.cut(&library).await + }) + }) + .procedure("renameFile", { + #[derive(Type, Deserialize)] + pub struct EphemeralRenameOne { + pub from_path: PathBuf, + pub to: String, + } + + #[derive(Type, Deserialize)] + pub struct EphemeralRenameMany { + pub from_pattern: FromPattern, + pub to_pattern: String, + pub from_paths: Vec, + } + + #[derive(Type, Deserialize)] + pub enum EphemeralRenameKind { + One(EphemeralRenameOne), + Many(EphemeralRenameMany), + } + + #[derive(Type, Deserialize)] + pub struct EphemeralRenameFileArgs { + pub kind: EphemeralRenameKind, + } + + impl EphemeralRenameFileArgs { + pub async fn rename_one( + EphemeralRenameOne { from_path, to }: EphemeralRenameOne, + ) -> Result<(), rspc::Error> { + let Some(old_name) = from_path.file_name() else { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Missing file name on file to be renamed".to_string(), + )); + }; + + if old_name == OsStr::new(&to) { + return Ok(()); + } + + let (new_file_name, new_extension) = + IsolatedFilePathData::separate_name_and_extension_from_str(&to).map_err( + |e| rspc::Error::with_cause(ErrorCode::BadRequest, e.to_string(), e), + )?; + + if !IsolatedFilePathData::accept_file_name(new_file_name) { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Invalid file name".to_string(), + )); + } + + let Some(parent) = from_path.parent() else { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Missing parent path on file to be renamed".to_string(), + )); + }; + + let new_file_full_path = parent.join(if !new_extension.is_empty() { + &to + } else { + new_file_name + }); + + match fs::metadata(&new_file_full_path).await { + Ok(_) => Err(rspc::Error::new( + ErrorCode::Conflict, + "Renaming would overwrite a file".to_string(), + )), + + Err(e) => { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(rspc::Error::with_cause( + ErrorCode::InternalServerError, + "Failed to check if file exists".to_string(), + e, + )); + } + + fs::rename(&from_path, new_file_full_path) + .await + .map_err(|e| { + FileIOError::from((from_path, e, "Failed to rename file")) + .into() + }) + } + } + } + + pub async fn rename_many( + EphemeralRenameMany { + ref from_pattern, + ref to_pattern, + from_paths, + }: EphemeralRenameMany, + ) -> Result<(), rspc::Error> { + let from_regex = &Regex::new(&from_pattern.pattern).map_err(|e| { + rspc::Error::with_cause( + rspc::ErrorCode::BadRequest, + "Invalid `from` regex pattern".to_string(), + e, + ) + })?; + + from_paths + .into_iter() + .map(|old_path| async move { + let Some(old_name) = old_path.file_name() else { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Missing file name on file to be renamed".to_string(), + )); + }; + + let Some(old_name_str) = old_name.to_str() else { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "File with non UTF-8 name".to_string(), + )); + }; + + let replaced_full_name = if from_pattern.replace_all { + from_regex.replace_all(old_name_str, to_pattern) + } else { + from_regex.replace(old_name_str, to_pattern) + }; + + if !IsolatedFilePathData::accept_file_name(replaced_full_name.as_ref()) + { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Invalid file name".to_string(), + )); + } + + let Some(parent) = old_path.parent() else { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Missing parent path on file to be renamed".to_string(), + )); + }; + + let new_path = parent.join(replaced_full_name.as_ref()); + + fs::rename(&old_path, &new_path).await.map_err(|e| { + error!( + "Failed to rename file from: '{}' to: '{}'; Error: {e:#?}", + old_path.display(), + new_path.display() + ); + let e = FileIOError::from((old_path, e, "Failed to rename file")); + rspc::Error::with_cause(ErrorCode::Conflict, e.to_string(), e) + }) + }) + .collect::>() + .try_join() + .await?; + + Ok(()) + } + } + + R.with2(library()).mutation( + |(_, library), EphemeralRenameFileArgs { kind }: EphemeralRenameFileArgs| async move { + let res = match kind { + EphemeralRenameKind::One(one) => { + EphemeralRenameFileArgs::rename_one(one).await + } + EphemeralRenameKind::Many(many) => { + EphemeralRenameFileArgs::rename_many(many).await + } + }; + + if res.is_ok() { + invalidate_query!(library, "search.ephemeralPaths"); + } + + res + }, + ) + }) +} + +#[derive(Type, Deserialize)] +struct EphemeralFileSystemOps { + sources: Vec, + target_dir: PathBuf, +} + +impl EphemeralFileSystemOps { + async fn check_target_directory(&self) -> Result<(), rspc::Error> { + match fs::metadata(&self.target_dir).await { + Ok(metadata) => { + if !metadata.is_dir() { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Target is not a directory".to_string(), + )); + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + let e = FileIOError::from((&self.target_dir, e, "Target directory not found")); + return Err(rspc::Error::with_cause( + ErrorCode::BadRequest, + e.to_string(), + e, + )); + } + Err(e) => { + return Err(FileIOError::from(( + &self.target_dir, + e, + "Failed to get target metadata", + )) + .into()); + } + } + + Ok(()) + } + + fn check_sources(&self) -> Result<(), rspc::Error> { + if self.sources.is_empty() { + return Err(rspc::Error::new( + ErrorCode::BadRequest, + "Sources cannot be empty".to_string(), + )); + } + + Ok(()) + } + + async fn check(&self) -> Result<(), rspc::Error> { + self.check_sources()?; + self.check_target_directory().await?; + + Ok(()) + } + + #[async_recursion] + async fn copy(self, library: &Library) -> Result<(), rspc::Error> { + self.check().await?; + + let EphemeralFileSystemOps { + sources, + target_dir, + } = self; + + let (directories_to_create, files_to_copy) = sources + .into_iter() + .filter_map(|source| { + if let Some(name) = source.file_name() { + let target = target_dir.join(name); + Some((source, target)) + } else { + warn!("Skipping file with no name: '{}'", source.display()); + None + } + }) + .map(|(source, target)| async move { + match fs::metadata(&source).await { + Ok(metadata) => Ok((source, target, metadata.is_dir())), + Err(e) => Err(FileIOError::from(( + source, + e, + "Failed to get source file metadata", + ))), + } + }) + .collect::>() + .try_join() + .await? + .into_iter() + .partition::, _>(|(_, _, is_dir)| *is_dir); + + files_to_copy + .into_iter() + .map(|(source, mut target, _)| async move { + match fs::metadata(&target).await { + Ok(_) => target = find_available_filename_for_duplicate(&target).await?, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // Everything is awesome! + } + Err(e) => { + return Err(FileSystemJobsError::FileIO(FileIOError::from(( + target, + e, + "Failed to get target file metadata", + )))); + } + } + + fs::copy(&source, target).await.map_err(|e| { + FileSystemJobsError::FileIO(FileIOError::from(( + source, + e, + "Failed to copy file", + ))) + }) + }) + .collect::>() + .try_join() + .await?; + + if !directories_to_create.is_empty() { + directories_to_create + .into_iter() + .map(|(source, mut target, _)| async move { + match fs::metadata(&target).await { + Ok(_) => target = find_available_filename_for_duplicate(&target).await?, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // Everything is awesome! + } + Err(e) => { + return Err(rspc::Error::from(FileIOError::from(( + target, + e, + "Failed to get target file metadata", + )))); + } + } + + fs::create_dir_all(&target).await.map_err(|e| { + FileIOError::from((&target, e, "Failed to create directory")) + })?; + + let more_files = + ReadDirStream::new(fs::read_dir(&source).await.map_err(|e| { + FileIOError::from((&source, e, "Failed to read directory to be copied")) + })?) + .map(|read_dir| match read_dir { + Ok(dir_entry) => Ok(dir_entry.path()), + Err(e) => Err(FileIOError::from(( + &source, + e, + "Failed to read directory to be copied", + ))), + }) + .collect::, _>>() + .await?; + + if !more_files.is_empty() { + Self { + sources: more_files, + target_dir: target, + } + .copy(library) + .await + } else { + Ok(()) + } + }) + .collect::>() + .try_join() + .await?; + } + + invalidate_query!(library, "search.ephemeralPaths"); + + Ok(()) + } + + async fn cut(self, library: &Library) -> Result<(), rspc::Error> { + self.check().await?; + + let EphemeralFileSystemOps { + sources, + target_dir, + } = self; + + sources + .into_iter() + .filter_map(|source| { + if let Some(name) = source.file_name() { + let target = target_dir.join(name); + Some((source, target)) + } else { + warn!("Skipping file with no name: '{}'", source.display()); + None + } + }) + .map(|(source, target)| async move { + match fs::metadata(&target).await { + Ok(_) => { + return Err(FileSystemJobsError::WouldOverwrite( + target.into_boxed_path(), + )); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // Everything is awesome! + } + Err(e) => { + return Err(FileSystemJobsError::FileIO(FileIOError::from(( + source, + e, + "Failed to get target file metadata", + )))); + } + } + + fs::rename(&source, target).await.map_err(|e| { + FileSystemJobsError::FileIO(FileIOError::from(( + source, + e, + "Failed to move file", + ))) + }) + }) + .collect::>() + .try_join() + .await?; + + invalidate_query!(library, "search.ephemeralPaths"); + + Ok(()) + } +} diff --git a/core/src/api/files.rs b/core/src/api/files.rs index a8610f3f4..58a137a57 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -15,25 +15,19 @@ use crate::{ erase::FileEraserJobInit, error::FileSystemJobsError, find_available_filename_for_duplicate, }, - media::{ - media_data_extractor::{ - can_extract_media_data_for_image, extract_media_data, MediaDataError, - }, - media_data_image_from_prisma_data, - }, + media::media_data_image_from_prisma_data, }, prisma::{file_path, location, object}, util::{db::maybe_missing, error::FileIOError}, }; -use sd_file_ext::{extensions::ImageExtension, kind::ObjectKind}; +use sd_file_ext::kind::ObjectKind; use sd_images::ConvertableExtension; use sd_media_metadata::MediaMetadata; use std::{ ffi::OsString, path::{Path, PathBuf}, - str::FromStr, sync::Arc, }; @@ -93,35 +87,6 @@ pub(crate) fn mount() -> AlphaRouter { }) }) }) - .procedure("getEphemeralMediaData", { - R.query(|_, full_path: PathBuf| async move { - let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) else { - return Ok(None); - }; - - // TODO(fogodev): change this when we have media data for audio and videos - let image_extension = ImageExtension::from_str(extension).map_err(|e| { - error!("Failed to parse image extension: {e:#?}"); - rspc::Error::new(ErrorCode::BadRequest, "Invalid image extension".to_string()) - })?; - - if !can_extract_media_data_for_image(&image_extension) { - return Ok(None); - } - - match extract_media_data(full_path).await { - Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))), - Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath( - _, - ))) => Ok(None), - Err(e) => Err(rspc::Error::with_cause( - ErrorCode::InternalServerError, - "Failed to extract media data".to_string(), - e, - )), - } - }) - }) .procedure("getPath", { R.with2(library()) .query(|(_, library), id: i32| async move { @@ -223,23 +188,6 @@ pub(crate) fn mount() -> AlphaRouter { path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR)); - dbg!(&path); - - create_directory(path, &library).await - }, - ) - }) - .procedure("createEphemeralFolder", { - #[derive(Type, Deserialize)] - pub struct CreateEphemeralFolderArgs { - pub path: PathBuf, - pub name: Option, - } - R.with2(library()).mutation( - |(_, library), - CreateEphemeralFolderArgs { mut path, name }: CreateEphemeralFolderArgs| async move { - path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR)); - create_directory(path, &library).await }, ) @@ -509,15 +457,6 @@ pub(crate) fn mount() -> AlphaRouter { .map_err(Into::into) }) }) - .procedure("duplicateFiles", { - R.with2(library()) - .mutation(|(node, library), args: FileCopierJobInit| async move { - Job::new(args) - .spawn(&node, &library) - .await - .map_err(Into::into) - }) - }) .procedure("copyFiles", { R.with2(library()) .mutation(|(node, library), args: FileCopierJobInit| async move { @@ -537,12 +476,6 @@ pub(crate) fn mount() -> AlphaRouter { }) }) .procedure("renameFile", { - #[derive(Type, Deserialize)] - pub struct FromPattern { - pub pattern: String, - pub replace_all: bool, - } - #[derive(Type, Deserialize)] pub struct RenameOne { pub from_file_path_id: file_path::id::Type, @@ -747,7 +680,7 @@ pub(crate) fn mount() -> AlphaRouter { }) } -async fn create_directory( +pub(super) async fn create_directory( mut target_path: PathBuf, library: &Library, ) -> Result { @@ -775,10 +708,9 @@ async fn create_directory( .await .map_err(|e| FileIOError::from((&target_path, e, "Failed to create directory")))?; - println!("Created directory: {}", target_path.display()); - invalidate_query!(library, "search.objects"); invalidate_query!(library, "search.paths"); + invalidate_query!(library, "search.ephemeralPaths"); Ok(target_path .file_name() @@ -786,3 +718,9 @@ async fn create_directory( .to_string_lossy() .to_string()) } + +#[derive(Type, Deserialize)] +pub struct FromPattern { + pub pattern: String, + pub replace_all: bool, +} diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index de3fd4e61..f066100b0 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -50,6 +50,7 @@ impl BackendFeature { mod auth; mod backups; mod categories; +mod ephemeral_files; mod files; mod jobs; mod keys; @@ -172,6 +173,7 @@ pub(crate) fn mount() -> Arc { .merge("categories.", categories::mount()) // .merge("keys.", keys::mount()) .merge("locations.", locations::mount()) + .merge("ephemeralFiles.", ephemeral_files::mount()) .merge("files.", files::mount()) .merge("jobs.", jobs::mount()) .merge("p2p.", p2p::mount()) diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index 2838a7233..59b6f22f1 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -169,19 +169,20 @@ pub(crate) fn mount() -> AlphaRouter { } if args.unassign { - let query = - db.tag_on_object().delete_many(vec![ - tag_on_object::tag_id::equals(args.tag_id), - tag_on_object::object_id::in_vec( - objects - .iter() - .map(|o| o.id) - .chain(file_paths.iter().filter_map(|fp| { - fp.object.as_ref().map(|o| o.id.clone()) - })) - .collect(), - ), - ]); + let query = db.tag_on_object().delete_many(vec![ + tag_on_object::tag_id::equals(args.tag_id), + tag_on_object::object_id::in_vec( + objects + .iter() + .map(|o| o.id) + .chain( + file_paths + .iter() + .filter_map(|fp| fp.object.as_ref().map(|o| o.id)), + ) + .collect(), + ), + ]); sync.write_ops( db, diff --git a/core/src/job/mod.rs b/core/src/job/mod.rs index 6b4d21a31..2b94cb72d 100644 --- a/core/src/job/mod.rs +++ b/core/src/job/mod.rs @@ -5,6 +5,7 @@ use std::{ fmt, hash::{Hash, Hasher}, mem, + pin::pin, sync::Arc, time::Instant, }; @@ -810,11 +811,13 @@ async fn handle_init_phase( let init_abort_handle = init_task.abort_handle(); - let mut msg_stream = ( + let mut msg_stream = pin!(( stream::once(init_task).map(StreamMessage::::InitResult), commands_rx.clone().map(StreamMessage::::NewCommand), ) - .merge(); + .merge()); + + let mut commands_rx = pin!(commands_rx); 'messages: while let Some(msg) = msg_stream.next().await { match msg { @@ -1036,11 +1039,13 @@ async fn handle_single_step( let mut status = JobStatus::Running; - let mut msg_stream = ( + let mut msg_stream = pin!(( stream::once(&mut step_task).map(StreamMessage::::StepResult), commands_rx.clone().map(StreamMessage::::NewCommand), ) - .merge(); + .merge()); + + let mut commands_rx = pin!(commands_rx); 'messages: while let Some(msg) = msg_stream.next().await { match msg { diff --git a/core/src/job/worker.rs b/core/src/job/worker.rs index 2de4b7a4c..626e51c07 100644 --- a/core/src/job/worker.rs +++ b/core/src/job/worker.rs @@ -2,6 +2,7 @@ use crate::{api::CoreEvent, invalidate_query, library::Library, Node}; use std::{ fmt, + pin::pin, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -361,7 +362,7 @@ impl Worker { let mut last_reporter_watch_update = Instant::now(); invalidate_query!(library, "jobs.reports"); - let mut finalized_events_rx = events_rx.clone(); + let mut finalized_events_rx = pin!(events_rx.clone()); let mut is_paused = false; @@ -391,12 +392,12 @@ impl Worker { Tick, } - let mut msg_stream = ( + let mut msg_stream = pin!(( stream::once(&mut run_task).map(StreamMessage::JobResult), events_rx.map(StreamMessage::NewEvent), IntervalStream::new(timeout_checker).map(|_| StreamMessage::Tick), ) - .merge(); + .merge()); let mut events_ended = false; diff --git a/core/src/object/media/media_processor/job.rs b/core/src/object/media/media_processor/job.rs index f84237831..0d42ea569 100644 --- a/core/src/object/media/media_processor/job.rs +++ b/core/src/object/media/media_processor/job.rs @@ -17,6 +17,7 @@ use crate::{ use std::{ hash::Hash, path::{Path, PathBuf}, + pin::pin, time::Duration, }; @@ -225,19 +226,20 @@ impl StatefulJob for MediaProcessorJobInit { )), ]); - let mut progress_rx = - if let Some(progress_rx) = data.maybe_thumbnailer_progress_rx.clone() { - progress_rx - } else { - let (progress_tx, progress_rx) = chan::unbounded(); + let mut progress_rx = pin!(if let Some(progress_rx) = + data.maybe_thumbnailer_progress_rx.clone() + { + progress_rx + } else { + let (progress_tx, progress_rx) = chan::unbounded(); - ctx.node - .thumbnailer - .register_reporter(self.location.id, progress_tx) - .await; + ctx.node + .thumbnailer + .register_reporter(self.location.id, progress_tx) + .await; - progress_rx - }; + progress_rx + }); let mut total_completed = 0; diff --git a/core/src/object/media/thumbnail/actor.rs b/core/src/object/media/thumbnail/actor.rs index 4d25c3097..ac0a2bcf6 100644 --- a/core/src/object/media/thumbnail/actor.rs +++ b/core/src/object/media/thumbnail/actor.rs @@ -272,7 +272,6 @@ impl Thumbnailer { #[inline] pub async fn shutdown(&self) { - let start = Instant::now(); let (tx, rx) = oneshot::channel(); self.cancel_tx .send(tx) @@ -281,8 +280,6 @@ impl Thumbnailer { rx.await .expect("critical thumbnailer error: failed to receive shutdown signal response"); - - debug!("Thumbnailer has been shutdown in {:?}", start.elapsed()); } /// WARNING!!!! DON'T USE THIS METHOD IN A LOOP!!!!!!!!!!!!! It will be pretty slow on purpose! diff --git a/core/src/object/media/thumbnail/worker.rs b/core/src/object/media/thumbnail/worker.rs index 23c2a4c45..6e1fa8c9e 100644 --- a/core/src/object/media/thumbnail/worker.rs +++ b/core/src/object/media/thumbnail/worker.rs @@ -1,6 +1,6 @@ use crate::api::CoreEvent; -use std::{collections::HashMap, ffi::OsString, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, ffi::OsString, path::PathBuf, pin::pin, sync::Arc}; use sd_prisma::prisma::location; @@ -78,12 +78,12 @@ pub(super) async fn worker( let (batch_report_progress_tx, batch_report_progress_rx) = chan::bounded(8); let (stop_older_processing_tx, stop_older_processing_rx) = chan::bounded(1); - let mut shutdown_leftovers_rx = leftovers_rx.clone(); - let mut shutdowm_batch_report_progress_rx = batch_report_progress_rx.clone(); + let mut shutdown_leftovers_rx = pin!(leftovers_rx.clone()); + let mut shutdowm_batch_report_progress_rx = pin!(batch_report_progress_rx.clone()); let mut current_batch_processing_rx: Option> = None; - let mut msg_stream = ( + let mut msg_stream = pin!(( IntervalStream::new(to_remove_interval).map(|_| StreamMessage::RemovalTick), cas_ids_to_delete_rx.map(StreamMessage::ToDelete), databases_rx.map(StreamMessage::Database), @@ -95,7 +95,7 @@ pub(super) async fn worker( cancel_rx.map(StreamMessage::Shutdown), IntervalStream::new(idle_interval).map(|_| StreamMessage::IdleTick), ) - .merge(); + .merge()); while let Some(msg) = msg_stream.next().await { match msg { @@ -258,24 +258,27 @@ pub(super) async fn worker( StreamMessage::Shutdown(cancel_tx) => { debug!("Thumbnail actor is shutting down..."); + let start = Instant::now(); // First stopping the current batch processing - let (tx, rx) = oneshot::channel(); - match stop_older_processing_tx.try_send(tx) { - Ok(()) => { - // We put a timeout here to avoid a deadlock in case the older processing already - // finished its batch - if timeout(ONE_SEC, rx).await.is_err() { + if current_batch_processing_rx.is_some() { + let (tx, rx) = oneshot::channel(); + match stop_older_processing_tx.try_send(tx) { + Ok(()) => { + // We put a timeout here to avoid a deadlock in case the older processing already + // finished its batch + if timeout(ONE_SEC, rx).await.is_err() { + stop_older_processing_rx.recv().await.ok(); + } + } + Err(e) if e.is_full() => { + // The last signal we sent happened after a batch was already processed + // So we clean the channel and we're good to go. stop_older_processing_rx.recv().await.ok(); } - } - Err(e) if e.is_full() => { - // The last signal we sent happened after a batch was already processed - // So we clean the channel and we're good to go. - stop_older_processing_rx.recv().await.ok(); - } - Err(_) => { - error!("Thumbnail actor died when trying to stop older processing"); + Err(_) => { + error!("Thumbnail actor died when trying to stop older processing"); + } } } @@ -312,6 +315,8 @@ pub(super) async fn worker( // Signaling that we're done shutting down cancel_tx.send(()).ok(); + + debug!("Thumbnailer has been shutdown in {:?}", start.elapsed()); return; } diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx index 52d5eb9eb..4fb00c8bc 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx @@ -13,17 +13,35 @@ import { useContextMenuContext } from '../context'; export const CutCopyItems = new ConditionalItem({ useCondition: () => { const { parent } = useExplorerContext(); - const { selectedFilePaths } = useContextMenuContext(); + const { selectedFilePaths, selectedEphemeralPaths } = useContextMenuContext(); - if (parent?.type !== 'Location' || !isNonEmpty(selectedFilePaths)) return null; + if ( + (parent?.type !== 'Location' && parent?.type !== 'Ephemeral') || + (!isNonEmpty(selectedFilePaths) && !isNonEmpty(selectedEphemeralPaths)) + ) + return null; - return { locationId: parent.location.id, selectedFilePaths }; + return { parent, selectedFilePaths, selectedEphemeralPaths }; }, - Component: ({ locationId, selectedFilePaths }) => { + Component: ({ parent, selectedFilePaths, selectedEphemeralPaths }) => { const keybind = useKeybindFactory(); const [{ path }] = useExplorerSearchParams(); const copyFiles = useLibraryMutation('files.copyFiles'); + const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles'); + + const indexedArgs = + parent.type === 'Location' && isNonEmpty(selectedFilePaths) + ? { + sourceLocationId: parent.location.id, + sourcePathIds: selectedFilePaths.map((p) => p.id) + } + : undefined; + + const ephemeralArgs = + parent.type === 'Ephemeral' && isNonEmpty(selectedEphemeralPaths) + ? { sourcePaths: selectedEphemeralPaths.map((p) => p.path) } + : undefined; return ( <> @@ -33,8 +51,8 @@ export const CutCopyItems = new ConditionalItem({ onClick={() => { getExplorerStore().cutCopyState = { sourceParentPath: path ?? '/', - sourceLocationId: locationId, - sourcePathIds: selectedFilePaths.map((p) => p.id), + indexedArgs, + ephemeralArgs, type: 'Cut' }; }} @@ -47,8 +65,8 @@ export const CutCopyItems = new ConditionalItem({ onClick={() => { getExplorerStore().cutCopyState = { sourceParentPath: path ?? '/', - sourceLocationId: locationId, - sourcePathIds: selectedFilePaths.map((p) => p.id), + indexedArgs, + ephemeralArgs, type: 'Copy' }; }} @@ -60,12 +78,24 @@ export const CutCopyItems = new ConditionalItem({ keybind={keybind([ModifierKeys.Control], ['D'])} onClick={async () => { try { - await copyFiles.mutateAsync({ - source_location_id: locationId, - sources_file_path_ids: selectedFilePaths.map((p) => p.id), - target_location_id: locationId, - target_location_relative_directory_path: path ?? '/' - }); + if (parent.type === 'Location' && isNonEmpty(selectedFilePaths)) { + await copyFiles.mutateAsync({ + source_location_id: parent.location.id, + sources_file_path_ids: selectedFilePaths.map((p) => p.id), + target_location_id: parent.location.id, + target_location_relative_directory_path: path ?? '/' + }); + } + + if ( + parent.type === 'Ephemeral' && + isNonEmpty(selectedEphemeralPaths) + ) { + await copyEphemeralFiles.mutateAsync({ + sources: selectedEphemeralPaths.map((p) => p.path), + target_dir: path ?? '/' + }); + } } catch (error) { toast.error({ title: 'Failed to duplicate file', diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx index a3422c685..3dfd6dd2f 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx @@ -17,21 +17,38 @@ export * from './CutCopyItems'; export const Delete = new ConditionalItem({ useCondition: () => { - const { selectedFilePaths } = useContextMenuContext(); - if (!isNonEmpty(selectedFilePaths)) return null; + const { selectedFilePaths, selectedEphemeralPaths } = useContextMenuContext(); - const locationId = selectedFilePaths[0].location_id; - if (locationId === null) return null; + if (!isNonEmpty(selectedFilePaths) && !isNonEmpty(selectedEphemeralPaths)) return null; - return { selectedFilePaths, locationId }; + return { selectedFilePaths, selectedEphemeralPaths }; }, - Component: ({ selectedFilePaths, locationId }) => { + Component: ({ selectedFilePaths, selectedEphemeralPaths }) => { const keybind = useKeybindFactory(); const rescan = useQuickRescan(); - const dirCount = selectedFilePaths.filter((p) => p.is_dir).length; - const fileCount = selectedFilePaths.filter((p) => !p.is_dir).length; + const dirCount = + selectedFilePaths.filter((p) => p.is_dir).length + + selectedEphemeralPaths.filter((p) => p.is_dir).length; + const fileCount = + selectedFilePaths.filter((p) => !p.is_dir).length + + selectedEphemeralPaths.filter((p) => !p.is_dir).length; + + const indexedArgs = + isNonEmpty(selectedFilePaths) && selectedFilePaths[0].location_id + ? { + locationId: selectedFilePaths[0].location_id, + rescan, + pathIds: selectedFilePaths.map((p) => p.id) + } + : undefined; + + const ephemeralArgs = isNonEmpty(selectedEphemeralPaths) + ? { + paths: selectedEphemeralPaths.map((p) => p.path) + } + : undefined; return ( ( p.id)} + indexedArgs={indexedArgs} + ephemeralArgs={ephemeralArgs} dirCount={dirCount} fileCount={fileCount} /> @@ -58,16 +74,29 @@ export const Delete = new ConditionalItem({ export const CopyAsPath = new ConditionalItem({ useCondition: () => { - const { selectedFilePaths } = useContextMenuContext(); - if (!isNonEmpty(selectedFilePaths) || selectedFilePaths.length > 1) return null; + const { selectedFilePaths, selectedEphemeralPaths } = useContextMenuContext(); + if ( + !isNonEmpty(selectedFilePaths) || + selectedFilePaths.length > 1 || + !isNonEmpty(selectedEphemeralPaths) || + selectedEphemeralPaths.length > 1 || + (selectedFilePaths.length === 1 && selectedEphemeralPaths.length === 1) // should never happen + ) + return null; - return { selectedFilePaths }; + return { selectedFilePaths, selectedEphemeralPaths }; }, - Component: ({ selectedFilePaths }) => ( - libraryClient.query(['files.getPath', selectedFilePaths[0].id])} - /> - ) + Component: ({ selectedFilePaths, selectedEphemeralPaths }) => { + if (selectedFilePaths.length === 1) { + return ( + libraryClient.query(['files.getPath', selectedFilePaths[0].id])} + /> + ); + } else if (selectedEphemeralPaths.length === 1) { + return selectedEphemeralPaths[0].path} />; + } + } }); export const Compress = new ConditionalItem({ diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index 4b13739b3..cf15ddddc 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -96,12 +96,7 @@ export const Rename = new ConditionalItem({ const settings = useExplorerContext().useSettingsSnapshot(); - if ( - settings.layoutMode === 'media' || - selectedItems.length > 1 || - selectedItems.some((item) => item.type === 'NonIndexedPath') - ) - return null; + if (settings.layoutMode === 'media' || selectedItems.length > 1) return null; return {}; }, diff --git a/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx index 3d1f01abe..abd1ec6d9 100644 --- a/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx @@ -3,9 +3,14 @@ import { CheckBox, Dialog, Tooltip, useDialog, UseDialogProps } from '@sd/ui'; import { Icon } from '~/components'; interface Props extends UseDialogProps { - locationId: number; - rescan?: () => void; - pathIds: number[]; + indexedArgs?: { + locationId: number; + rescan?: () => void; + pathIds: number[]; + }; + ephemeralArgs?: { + paths: string[]; + }; dirCount?: number; fileCount?: number; } @@ -39,9 +44,10 @@ function getWording(dirCount: number, fileCount: number) { export default (props: Props) => { const deleteFile = useLibraryMutation('files.deleteFiles'); + const deleteEphemeralFile = useLibraryMutation('ephemeralFiles.deleteFiles'); const form = useZodForm(); - const { dirCount = 0, fileCount = 0 } = props; + const { dirCount = 0, fileCount = 0, indexedArgs, ephemeralArgs } = props; const { type, prefix } = getWording(dirCount, fileCount); @@ -52,12 +58,20 @@ export default (props: Props) => { { - await deleteFile.mutateAsync({ - location_id: props.locationId, - file_path_ids: props.pathIds - }); + if (indexedArgs != undefined) { + const { locationId, rescan, pathIds } = indexedArgs; + await deleteFile.mutateAsync({ + location_id: locationId, + file_path_ids: pathIds + }); - props.rescan?.(); + rescan?.(); + } + + if (ephemeralArgs != undefined) { + const { paths } = ephemeralArgs; + await deleteEphemeralFile.mutateAsync(paths); + } })} icon={} dialog={useDialog(props)} diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index f53659151..3dc944ad2 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -226,7 +226,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { }); const ephemeralLocationMediaData = useBridgeQuery( - ['files.getEphemeralMediaData', ephemeralPathData != null ? ephemeralPathData.path : ''], + ['ephemeralFiles.getMediaData', ephemeralPathData != null ? ephemeralPathData.path : ''], { enabled: ephemeralPathData?.kind === ObjectKindEnum.Image && readyToFetch } diff --git a/interface/app/$libraryId/Explorer/ParentContextMenu.tsx b/interface/app/$libraryId/Explorer/ParentContextMenu.tsx index 1379a269f..7d2f4143d 100644 --- a/interface/app/$libraryId/Explorer/ParentContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/ParentContextMenu.tsx @@ -23,65 +23,105 @@ export default (props: PropsWithChildren) => { const objectValidator = useLibraryMutation('jobs.objectValidator'); const rescanLocation = useLibraryMutation('locations.subPathRescan'); const copyFiles = useLibraryMutation('files.copyFiles'); + const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles'); const cutFiles = useLibraryMutation('files.cutFiles'); + const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles'); return ( - {parent?.type === 'Location' && cutCopyState.type !== 'Idle' && ( - <> - { - const path = currentPath ?? '/'; - const { type, sourcePathIds, sourceParentPath, sourceLocationId } = - cutCopyState; + {(parent?.type === 'Location' || parent?.type === 'Ephemeral') && + cutCopyState.type !== 'Idle' && ( + <> + { + const path = currentPath ?? '/'; + const { type, sourceParentPath, indexedArgs, ephemeralArgs } = + cutCopyState; - const sameLocation = - sourceLocationId === parent.location.id && - sourceParentPath === path; + try { + if (type == 'Copy') { + if ( + parent?.type === 'Location' && + indexedArgs != undefined + ) { + await copyFiles.mutateAsync({ + source_location_id: indexedArgs.sourceLocationId, + sources_file_path_ids: [ + ...indexedArgs.sourcePathIds + ], + target_location_id: parent.location.id, + target_location_relative_directory_path: path + }); + } - try { - if (type == 'Copy') { - await copyFiles.mutateAsync({ - source_location_id: sourceLocationId, - sources_file_path_ids: [...sourcePathIds], - target_location_id: parent.location.id, - target_location_relative_directory_path: path - }); - } else if (sameLocation) { - toast.error('File already exists in this location'); - } else { - await cutFiles.mutateAsync({ - source_location_id: sourceLocationId, - sources_file_path_ids: [...sourcePathIds], - target_location_id: parent.location.id, - target_location_relative_directory_path: path + if ( + parent?.type === 'Ephemeral' && + ephemeralArgs != undefined + ) { + await copyEphemeralFiles.mutateAsync({ + sources: [...ephemeralArgs.sourcePaths], + target_dir: path + }); + } + } else { + if ( + parent?.type === 'Location' && + indexedArgs != undefined + ) { + if ( + indexedArgs.sourceLocationId === + parent.location.id && + sourceParentPath === path + ) { + toast.error('File already exists in this location'); + } + await cutFiles.mutateAsync({ + source_location_id: indexedArgs.sourceLocationId, + sources_file_path_ids: [ + ...indexedArgs.sourcePathIds + ], + target_location_id: parent.location.id, + target_location_relative_directory_path: path + }); + } + + if ( + parent?.type === 'Ephemeral' && + ephemeralArgs != undefined + ) { + if (sourceParentPath !== path) { + await cutEphemeralFiles.mutateAsync({ + sources: [...ephemeralArgs.sourcePaths], + target_dir: path + }); + } + } + } + } catch (error) { + toast.error({ + title: `Failed to ${type.toLowerCase()} file`, + body: `Error: ${error}.` }); } - } catch (error) { - toast.error({ - title: `Failed to ${type.toLowerCase()} file`, - body: `Error: ${error}.` - }); - } - }} - icon={Clipboard} - /> + }} + icon={Clipboard} + /> - { - getExplorerStore().cutCopyState = { - type: 'Idle' - }; - }} - icon={FileX} - /> + { + getExplorerStore().cutCopyState = { + type: 'Idle' + }; + }} + icon={FileX} + /> - - - )} + + + )} { onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) }); + const renameEphemeralFile = useLibraryMutation(['ephemeralFiles.renameFile'], { + onError: () => setNewName(null), + onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + }); + const changeCurrentItem = (index: number) => { if (items[index]) getQuickPreviewStore().itemIndex = index; }; @@ -204,17 +210,33 @@ export const QuickPreview = () => { const path = getIndexedItemFilePath(item); - if (!path || path.location_id === null) return; + if (path != null && path.location_id !== null) { + return dialogManager.create((dp) => ( + + )); + } - dialogManager.create((dp) => ( - - )); + const ephemeralFile = getEphemeralPath(item); + if (ephemeralFile != null) { + return dialogManager.create((dp) => ( + + )); + } }); if (!item) return null; @@ -321,48 +343,82 @@ export const QuickPreview = () => { onRename={(newName) => { setIsRenaming(false); - if ( - !('id' in item.item) || - !newName || - newName === name - ) - return; + if (!newName || newName === name) return; - const filePathData = - getIndexedItemFilePath(item); + try { + switch (item.type) { + case 'Path': + case 'Object': { + const filePathData = + getIndexedItemFilePath(item); - if (!filePathData) return; + if (!filePathData) + throw new Error( + 'Failed to get file path object' + ); - const locationId = filePathData.location_id; + const { id, location_id } = + filePathData; - if (locationId === null) return; + if (!location_id) + throw new Error( + 'Missing location id' + ); - renameFile.mutate({ - location_id: locationId, - kind: { - One: { - from_file_path_id: item.item.id, - to: newName + renameFile.mutate({ + location_id, + kind: { + One: { + from_file_path_id: id, + to: newName + } + } + }); + + break; } - } - }); + case 'NonIndexedPath': { + const ephemeralFile = + getEphemeralPath(item); - setNewName(newName); + if (!ephemeralFile) + throw new Error( + 'Failed to get ephemeral file object' + ); + + renameEphemeralFile.mutate({ + kind: { + One: { + from_path: + ephemeralFile.path, + to: newName + } + } + }); + + break; + } + + default: + throw new Error( + 'Invalid explorer item type' + ); + } + + setNewName(newName); + } catch (e) { + toast.error({ + title: `Could not rename ${itemData.fullName} to ${newName}`, + body: `Error: ${e}.` + }); + } }} /> ) : ( - name && - item.type !== 'NonIndexedPath' && - setIsRenaming(true) - } - className={clsx( - item.type === 'NonIndexedPath' - ? 'cursor-default' - : 'cursor-text' - )} + onClick={() => name && setIsRenaming(true)} + className={clsx('cursor-text')} > {name} @@ -393,12 +449,10 @@ export const QuickPreview = () => { ]} /> - {item.type !== 'NonIndexedPath' && ( - name && setIsRenaming(true)} - /> - )} + name && setIsRenaming(true)} + /> { rescan(); } }); + const createEphemeralFolder = useLibraryMutation(['ephemeralFiles.createFolder'], { + onError: (e) => { + toast.error({ title: 'Error creating folder', body: `Error: ${e}.` }); + console.error(e); + }, + onSuccess: (folder) => { + toast.success({ title: `Created new folder "${folder}"` }); + rescan(); + } + }); const viewOptions = useMemo( () => @@ -123,15 +133,22 @@ export const useExplorerTopBarOptions = () => { }); const toolOptions = [ - parent?.type === 'Location' && { + (parent?.type === 'Location' || parent?.type === 'Ephemeral') && { toolTipLabel: 'New Folder', icon: , onClick: () => { - createFolder.mutate({ - location_id: parent.location.id, - sub_path: path || null, - name: null - }); + if (parent?.type === 'Location') { + createFolder.mutate({ + location_id: parent.location.id, + sub_path: path || null, + name: null + }); + } else { + createEphemeralFolder.mutate({ + path: parent?.path, + name: null + }); + } }, individual: true, showAtResolution: 'xs:flex' diff --git a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx index 4f9e56918..d3efbc793 100644 --- a/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx +++ b/interface/app/$libraryId/Explorer/View/RenamableItemText.tsx @@ -1,8 +1,9 @@ import clsx from 'clsx'; import { useMemo, useRef } from 'react'; import { + getEphemeralPath, getExplorerItemData, - getItemFilePath, + getIndexedItemFilePath, useLibraryMutation, useRspcLibraryContext, type ExplorerItem @@ -41,6 +42,11 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }: onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) }); + const renameEphemeralFile = useLibraryMutation(['ephemeralFiles.renameFile'], { + onError: () => reset(), + onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) + }); + const renameLocation = useLibraryMutation(['locations.update'], { onError: () => reset(), onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths']) @@ -71,11 +77,11 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }: break; } - default: { - const filePathData = getItemFilePath(item); + case 'Path': + case 'Object': { + const filePathData = getIndexedItemFilePath(item); - if (!filePathData || !('id' in filePathData)) - throw new Error('Unable to rename file'); + if (!filePathData) throw new Error('Failed to get file path object'); const { id, location_id } = filePathData; @@ -90,7 +96,29 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }: } } }); + + break; } + + case 'NonIndexedPath': { + const ephemeralFile = getEphemeralPath(item); + + if (!ephemeralFile) throw new Error('Failed to get ephemeral file object'); + + renameEphemeralFile.mutate({ + kind: { + One: { + from_path: ephemeralFile.path, + to: newName + } + } + }); + + break; + } + + default: + throw new Error('Invalid explorer item type'); } } catch (e) { reset(); @@ -105,7 +133,6 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }: !selected || explorer.selectedItems.size > 1 || quickPreviewStore.open || - item.type === 'NonIndexedPath' || item.type === 'SpacedropPeer'; return ( diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts index 43e32c2bf..d22fb8313 100644 --- a/interface/app/$libraryId/Explorer/store.ts +++ b/interface/app/$libraryId/Explorer/store.ts @@ -114,8 +114,13 @@ type CutCopyState = | { type: 'Cut' | 'Copy'; sourceParentPath: string; // this is used solely for preventing copy/cutting to the same path (as that will truncate the file) - sourceLocationId: number; - sourcePathIds: number[]; + indexedArgs?: { + sourceLocationId: number; + sourcePathIds: number[]; + }; + ephemeralArgs?: { + sourcePaths: string[]; + }; }; const state = { @@ -156,9 +161,24 @@ export function getExplorerStore() { } export function isCut(item: ExplorerItem, cutCopyState: ReadonlyDeep) { - return item.type === 'NonIndexedPath' || item.type === 'SpacedropPeer' - ? false - : cutCopyState.type === 'Cut' && cutCopyState.sourcePathIds.includes(item.item.id); + switch (item.type) { + case 'NonIndexedPath': + return ( + cutCopyState.type === 'Cut' && + cutCopyState.ephemeralArgs != undefined && + cutCopyState.ephemeralArgs.sourcePaths.includes(item.item.path) + ); + + case 'Path': + return ( + cutCopyState.type === 'Cut' && + cutCopyState.indexedArgs != undefined && + cutCopyState.indexedArgs.sourcePathIds.includes(item.item.id) + ); + + default: + return false; + } } export const filePathOrderingKeysSchema = z.union([ diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index 04b9fa9c1..98d2786b5 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -20,6 +20,10 @@ export type ExplorerParent = location: Location; subPath?: FilePath; } + | { + type: 'Ephemeral'; + path: string; + } | { type: 'Tag'; tag: Tag; diff --git a/interface/app/$libraryId/ephemeral.tsx b/interface/app/$libraryId/ephemeral.tsx index 4685443ec..c9da9a7f1 100644 --- a/interface/app/$libraryId/ephemeral.tsx +++ b/interface/app/$libraryId/ephemeral.tsx @@ -16,6 +16,7 @@ import { getDismissibleNoticeStore, useDismissibleNoticeStore, useIsDark, + useKeyDeleteFile, useOperatingSystem, useZodSearchParams } from '~/hooks'; @@ -203,10 +204,13 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => { const explorer = useExplorer({ items, + parent: path != null ? { type: 'Ephemeral', path } : undefined, settings: explorerSettings, layouts: { media: false } }); + useKeyDeleteFile(explorer.selectedItems, null); + return ( { const metaCtrlKey = useKeyMatcher('Meta').key; const copyFiles = useLibraryMutation('files.copyFiles'); + const copyEphemeralFiles = useLibraryMutation('ephemeralFiles.copyFiles'); const cutFiles = useLibraryMutation('files.cutFiles'); + const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles'); const explorer = useExplorerContext(); + + const { parent } = explorer; + const selectedFilePaths = useItemsAsFilePaths(Array.from(explorer.selectedItems)); + const selectedEphemeralPaths = useItemsAsEphemeralPaths(Array.from(explorer.selectedItems)); + + const indexedArgs = + parent?.type === 'Location' && !isNonEmpty(selectedFilePaths) + ? { + sourceLocationId: parent.location.id, + sourcePathIds: selectedFilePaths.map((p) => p.id) + } + : undefined; + + const ephemeralArgs = + parent?.type === 'Ephemeral' && !isNonEmpty(selectedEphemeralPaths) + ? { sourcePaths: selectedEphemeralPaths.map((p) => p.path) } + : undefined; useKeys([metaCtrlKey, 'KeyC'], (e) => { e.stopPropagation(); if (explorer.parent?.type === 'Location') { getExplorerStore().cutCopyState = { sourceParentPath: path ?? '/', - sourceLocationId: explorer.parent.location.id, - sourcePathIds: selectedFilePaths.map((p) => p.id), + indexedArgs, + ephemeralArgs, type: 'Copy' }; } @@ -34,8 +54,8 @@ export const useKeyCopyCutPaste = () => { if (explorer.parent?.type === 'Location') { getExplorerStore().cutCopyState = { sourceParentPath: path ?? '/', - sourceLocationId: explorer.parent.location.id, - sourcePathIds: selectedFilePaths.map((p) => p.id), + indexedArgs, + ephemeralArgs, type: 'Cut' }; } @@ -44,29 +64,54 @@ export const useKeyCopyCutPaste = () => { useKeys([metaCtrlKey, 'KeyV'], async (e) => { e.stopPropagation(); const parent = explorer.parent; - if (parent?.type === 'Location' && cutCopyState.type !== 'Idle' && path) { - const { type, sourcePathIds, sourceParentPath, sourceLocationId } = cutCopyState; - - const sameLocation = - sourceLocationId === parent.location.id && sourceParentPath === path; + if ( + (parent?.type === 'Location' || parent?.type === 'Ephemeral') && + cutCopyState.type !== 'Idle' && + path + ) { + const { type, sourceParentPath, indexedArgs, ephemeralArgs } = cutCopyState; try { if (type == 'Copy') { - await copyFiles.mutateAsync({ - source_location_id: sourceLocationId, - sources_file_path_ids: [...sourcePathIds], - target_location_id: parent.location.id, - target_location_relative_directory_path: path - }); - } else if (sameLocation) { - toast.error('File already exists in this location'); + if (parent?.type === 'Location' && indexedArgs != undefined) { + await copyFiles.mutateAsync({ + source_location_id: indexedArgs.sourceLocationId, + sources_file_path_ids: [...indexedArgs.sourcePathIds], + target_location_id: parent.location.id, + target_location_relative_directory_path: path + }); + } + + if (parent?.type === 'Ephemeral' && ephemeralArgs != undefined) { + await copyEphemeralFiles.mutateAsync({ + sources: [...ephemeralArgs.sourcePaths], + target_dir: path + }); + } } else { - await cutFiles.mutateAsync({ - source_location_id: sourceLocationId, - sources_file_path_ids: [...sourcePathIds], - target_location_id: parent.location.id, - target_location_relative_directory_path: path - }); + if (parent?.type === 'Location' && indexedArgs != undefined) { + if ( + indexedArgs.sourceLocationId === parent.location.id && + sourceParentPath === path + ) { + toast.error('File already exists in this location'); + } + await cutFiles.mutateAsync({ + source_location_id: indexedArgs.sourceLocationId, + sources_file_path_ids: [...indexedArgs.sourcePathIds], + target_location_id: parent.location.id, + target_location_relative_directory_path: path + }); + } + + if (parent?.type === 'Ephemeral' && ephemeralArgs != undefined) { + if (sourceParentPath !== path) { + await cutEphemeralFiles.mutateAsync({ + sources: [...ephemeralArgs.sourcePaths], + target_dir: path + }); + } + } } } catch (error) { toast.error({ diff --git a/interface/hooks/useKeyDeleteFile.tsx b/interface/hooks/useKeyDeleteFile.tsx index 58fafebf4..4a2b29bfa 100644 --- a/interface/hooks/useKeyDeleteFile.tsx +++ b/interface/hooks/useKeyDeleteFile.tsx @@ -1,35 +1,43 @@ import { useKey, useKeys } from 'rooks'; -import type { ExplorerItem } from '@sd/client'; +import { useItemsAsEphemeralPaths, useItemsAsFilePaths, type ExplorerItem } from '@sd/client'; import { dialogManager } from '@sd/ui'; import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog'; +import { isNonEmpty } from '~/util'; import { useOperatingSystem } from './useOperatingSystem'; export const useKeyDeleteFile = (selectedItems: Set, locationId?: number | null) => { const os = useOperatingSystem(); + const filePaths = useItemsAsFilePaths([...selectedItems]); + const ephemeralPaths = useItemsAsEphemeralPaths([...selectedItems]); + const deleteHandler = (e: KeyboardEvent) => { e.preventDefault(); - if (!locationId || selectedItems.size === 0) return; - const pathIds: number[] = []; + if ((locationId == null || !isNonEmpty(filePaths)) && !isNonEmpty(ephemeralPaths)) return; + + const indexedArgs = + locationId != null && isNonEmpty(filePaths) + ? { locationId, pathIds: filePaths.map((p) => p.id) } + : undefined; + const ephemeralArgs = isNonEmpty(ephemeralPaths) + ? { paths: ephemeralPaths.map((p) => p.path) } + : undefined; + let dirCount = 0; let fileCount = 0; - for (const item of selectedItems) { - if (item.type === 'Path') { - pathIds.push(item.item.id); - - dirCount += item.item.is_dir ? 1 : 0; - fileCount += item.item.is_dir ? 0 : 1; - } + for (const entry of [...filePaths, ...ephemeralPaths]) { + dirCount += entry.is_dir ? 1 : 0; + fileCount += entry.is_dir ? 0 : 1; } dialogManager.create((dp) => ( diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 11c5f17fd..55701e489 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -7,9 +7,9 @@ export type Procedures = { { key: "backups.getAll", input: never, result: GetAll } | { key: "buildInfo", input: never, result: BuildInfo } | { key: "categories.list", input: LibraryArgs, result: { [key in Category]: number } } | + { key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } | { key: "files.get", input: LibraryArgs, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] } | null } | { key: "files.getConvertableImageExtensions", input: never, result: string[] } | - { key: "files.getEphemeralMediaData", input: string, result: MediaMetadata | null } | { key: "files.getMediaData", input: LibraryArgs, result: MediaMetadata } | { key: "files.getPath", input: LibraryArgs, result: string | null } | { key: "invalidation.test-invalidate", input: never, result: number } | @@ -48,13 +48,16 @@ export type Procedures = { { key: "backups.backup", input: LibraryArgs, result: string } | { key: "backups.delete", input: string, result: null } | { key: "backups.restore", input: string, result: null } | + { key: "ephemeralFiles.copyFiles", input: LibraryArgs, result: null } | + { key: "ephemeralFiles.createFolder", input: LibraryArgs, result: string } | + { key: "ephemeralFiles.cutFiles", input: LibraryArgs, result: null } | + { key: "ephemeralFiles.deleteFiles", input: LibraryArgs, result: null } | + { key: "ephemeralFiles.renameFile", input: LibraryArgs, result: null } | { key: "files.convertImage", input: LibraryArgs, result: null } | { key: "files.copyFiles", input: LibraryArgs, result: null } | - { key: "files.createEphemeralFolder", input: LibraryArgs, result: string } | { key: "files.createFolder", input: LibraryArgs, result: string } | { key: "files.cutFiles", input: LibraryArgs, result: null } | { key: "files.deleteFiles", input: LibraryArgs, result: null } | - { key: "files.duplicateFiles", input: LibraryArgs, result: null } | { key: "files.eraseFiles", input: LibraryArgs, result: null } | { key: "files.removeAccessTime", input: LibraryArgs, result: null } | { key: "files.renameFile", input: LibraryArgs, result: null } | @@ -158,10 +161,20 @@ export type DoubleClickAction = "openFile" | "quickPreview" export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined } +export type EphemeralFileSystemOps = { sources: string[]; target_dir: string } + export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null } +export type EphemeralRenameFileArgs = { kind: EphemeralRenameKind } + +export type EphemeralRenameKind = { One: EphemeralRenameOne } | { Many: EphemeralRenameMany } + +export type EphemeralRenameMany = { from_pattern: FromPattern; to_pattern: string; from_paths: string[] } + +export type EphemeralRenameOne = { from_path: string; to: string } + export type Error = { code: ErrorCode; message: string } /** diff --git a/packages/client/src/utils/explorerItem.ts b/packages/client/src/utils/explorerItem.ts index e7f0baabe..35b3d10c5 100644 --- a/packages/client/src/utils/explorerItem.ts +++ b/packages/client/src/utils/explorerItem.ts @@ -13,6 +13,10 @@ export function getItemFilePath(data: ExplorerItem) { return (data.type === 'Object' && data.item.file_paths[0]) || null; } +export function getEphemeralPath(data: ExplorerItem) { + return data.type === 'NonIndexedPath' ? data.item : null; +} + export function getIndexedItemFilePath(data: ExplorerItem) { return data.type === 'Path' ? data.item