[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
This commit is contained in:
Ericson "Fogo" Soares 2023-11-03 14:06:34 -03:00 committed by GitHub
parent b7354a4580
commit f23e0b13c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1164 additions and 342 deletions

43
Cargo.lock generated
View file

@ -270,13 +270,15 @@ dependencies = [
[[package]]
name = "async-channel"
version = "1.9.0"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
checksum = "336d835910fab747186c56586562cb46f42809c2843ef3a84f47509009522838"
dependencies = [
"concurrent-queue",
"event-listener",
"event-listener 3.0.1",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
@ -305,7 +307,18 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b"
dependencies = [
"event-listener",
"event-listener 2.5.3",
]
[[package]]
name = "async-recursion"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.32",
]
[[package]]
@ -1942,6 +1955,27 @@ version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "event-listener"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01cec0252c2afff729ee6f00e903d479fba81784c8e2bd77447673471fdfaea1"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96b852f1345da36d551b9473fa1e2b1eb5c5195585c6c018118bc92a8d91160"
dependencies = [
"event-listener 3.0.1",
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.7.0"
@ -6677,6 +6711,7 @@ version = "0.1.2"
dependencies = [
"aovec",
"async-channel",
"async-recursion",
"async-stream",
"async-trait",
"axum",

View file

@ -77,17 +77,6 @@ pub fn sd_server_plugin<R: Runtime>(node: Arc<Node>) -> io::Result<TauriPlugin<R
))
.on_event(move |app, e| {
if let RunEvent::Exit { .. } = e {
debug!("Closing all open windows...");
app
.windows()
.iter()
.for_each(|(window_name, window)| {
debug!("closing window: {window_name}");
if let Err(e) = window.close() {
error!("failed to close window '{}': {:#?}", window_name, e);
}
});
block_in_place(|| {
block_on(node.shutdown());
block_on(tx.send(())).ok();

View file

@ -97,9 +97,9 @@ strum_macros = "0.25"
regex = "1.9.5"
hex = "0.4.3"
int-enum = "0.5.0"
tokio-stream = "0.1.14"
tokio-stream = { version = "0.1.14", features = ["fs"] }
futures-concurrency = "7.4.3"
async-channel = "1.9"
async-channel = "2.0.0"
tokio-util = { version = "0.7.8", features = ["io"] }
slotmap = "1.0.6"
flate2 = "1.0.27"
@ -112,6 +112,7 @@ bytes = "1.5.0"
reqwest = { version = "0.11.20", features = ["json", "native-tls-vendored"] }
directories = "5.0.1"
streamunordered = "0.5.3"
async-recursion = "1.0.5"
# Override features of transitive dependencies
[dependencies.openssl]

View file

@ -0,0 +1,542 @@
use crate::{
api::utils::library,
invalidate_query,
library::Library,
location::file_path_helper::IsolatedFilePathData,
object::{
fs::{error::FileSystemJobsError, find_available_filename_for_duplicate},
media::media_data_extractor::{
can_extract_media_data_for_image, extract_media_data, MediaDataError,
},
},
util::error::FileIOError,
};
use sd_file_ext::extensions::ImageExtension;
use sd_media_metadata::MediaMetadata;
use std::{ffi::OsStr, path::PathBuf, str::FromStr};
use async_recursion::async_recursion;
use futures_concurrency::future::TryJoin;
use regex::Regex;
use rspc::{alpha::AlphaRouter, ErrorCode};
use serde::Deserialize;
use specta::Type;
use tokio::{fs, io};
use tokio_stream::{wrappers::ReadDirStream, StreamExt};
use tracing::{error, warn};
use super::{
files::{create_directory, FromPattern},
Ctx, R,
};
const UNTITLED_FOLDER_STR: &str = "Untitled Folder";
pub(crate) fn mount() -> AlphaRouter<Ctx> {
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<String>,
}
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<PathBuf>| 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::<Vec<_>>()
.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<PathBuf>,
}
#[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::<Vec<_>>()
.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<PathBuf>,
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::<Vec<_>>()
.try_join()
.await?
.into_iter()
.partition::<Vec<_>, _>(|(_, _, 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::<Vec<_>>()
.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::<Result<Vec<_>, _>>()
.await?;
if !more_files.is_empty() {
Self {
sources: more_files,
target_dir: target,
}
.copy(library)
.await
} else {
Ok(())
}
})
.collect::<Vec<_>>()
.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::<Vec<_>>()
.try_join()
.await?;
invalidate_query!(library, "search.ephemeralPaths");
Ok(())
}
}

View file

@ -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<Ctx> {
})
})
})
.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<Ctx> {
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<String>,
}
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<Ctx> {
.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<Ctx> {
})
})
.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<Ctx> {
})
}
async fn create_directory(
pub(super) async fn create_directory(
mut target_path: PathBuf,
library: &Library,
) -> Result<String, rspc::Error> {
@ -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,
}

View file

@ -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<Router> {
.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())

View file

@ -169,19 +169,20 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}
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,

View file

@ -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<SJob: StatefulJob>(
let init_abort_handle = init_task.abort_handle();
let mut msg_stream = (
let mut msg_stream = pin!((
stream::once(init_task).map(StreamMessage::<SJob>::InitResult),
commands_rx.clone().map(StreamMessage::<SJob>::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<SJob: StatefulJob>(
let mut status = JobStatus::Running;
let mut msg_stream = (
let mut msg_stream = pin!((
stream::once(&mut step_task).map(StreamMessage::<SJob>::StepResult),
commands_rx.clone().map(StreamMessage::<SJob>::NewCommand),
)
.merge();
.merge());
let mut commands_rx = pin!(commands_rx);
'messages: while let Some(msg) = msg_stream.next().await {
match msg {

View file

@ -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;

View file

@ -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;

View file

@ -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!

View file

@ -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<oneshot::Receiver<()>> = 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;
}

View file

@ -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',

View file

@ -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 (
<Menu.Item
@ -43,9 +60,8 @@ export const Delete = new ConditionalItem({
dialogManager.create((dp) => (
<DeleteDialog
{...dp}
rescan={rescan}
locationId={locationId}
pathIds={selectedFilePaths.map((p) => 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 }) => (
<CopyAsPathBase
getPath={() => libraryClient.query(['files.getPath', selectedFilePaths[0].id])}
/>
)
Component: ({ selectedFilePaths, selectedEphemeralPaths }) => {
if (selectedFilePaths.length === 1) {
return (
<CopyAsPathBase
getPath={() => libraryClient.query(['files.getPath', selectedFilePaths[0].id])}
/>
);
} else if (selectedEphemeralPaths.length === 1) {
return <CopyAsPathBase getPath={async () => selectedEphemeralPaths[0].path} />;
}
}
});
export const Compress = new ConditionalItem({

View file

@ -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 {};
},

View file

@ -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) => {
<Dialog
form={form}
onSubmit={form.handleSubmit(async () => {
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={<Icon theme="light" name={icon} size={28} />}
dialog={useDialog(props)}

View file

@ -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
}

View file

@ -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 (
<CM.Root trigger={props.children}>
{parent?.type === 'Location' && cutCopyState.type !== 'Idle' && (
<>
<CM.Item
label="Paste"
keybind={keybind([ModifierKeys.Control], ['V'])}
onClick={async () => {
const path = currentPath ?? '/';
const { type, sourcePathIds, sourceParentPath, sourceLocationId } =
cutCopyState;
{(parent?.type === 'Location' || parent?.type === 'Ephemeral') &&
cutCopyState.type !== 'Idle' && (
<>
<CM.Item
label="Paste"
keybind={keybind([ModifierKeys.Control], ['V'])}
onClick={async () => {
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}
/>
<CM.Item
label="Deselect"
onClick={() => {
getExplorerStore().cutCopyState = {
type: 'Idle'
};
}}
icon={FileX}
/>
<CM.Item
label="Deselect"
onClick={() => {
getExplorerStore().cutCopyState = {
type: 'Idle'
};
}}
icon={FileX}
/>
<CM.Separator />
</>
)}
<CM.Separator />
</>
)}
<CM.Item
label="Share"

View file

@ -13,6 +13,7 @@ import {
useState
} from 'react';
import {
getEphemeralPath,
getExplorerItemData,
getIndexedItemFilePath,
ObjectKindKey,
@ -90,6 +91,11 @@ export const QuickPreview = () => {
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) => (
<DeleteDialog
{...dp}
indexedArgs={{
locationId: path.location_id!,
pathIds: [path.id]
}}
dirCount={path.is_dir ? 1 : 0}
fileCount={path.is_dir ? 0 : 1}
/>
));
}
dialogManager.create((dp) => (
<DeleteDialog
{...dp}
locationId={path.location_id!}
pathIds={[path.id]}
dirCount={path.is_dir ? 1 : 0}
fileCount={path.is_dir ? 0 : 1}
/>
));
const ephemeralFile = getEphemeralPath(item);
if (ephemeralFile != null) {
return dialogManager.create((dp) => (
<DeleteDialog
{...dp}
ephemeralArgs={{
paths: [ephemeralFile.path]
}}
dirCount={ephemeralFile.is_dir ? 1 : 0}
fileCount={ephemeralFile.is_dir ? 0 : 1}
/>
));
}
});
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}.`
});
}
}}
/>
) : (
<Tooltip label={name} className="truncate">
<span
onClick={() =>
name &&
item.type !== 'NonIndexedPath' &&
setIsRenaming(true)
}
className={clsx(
item.type === 'NonIndexedPath'
? 'cursor-default'
: 'cursor-text'
)}
onClick={() => name && setIsRenaming(true)}
className={clsx('cursor-text')}
>
{name}
</span>
@ -393,12 +449,10 @@ export const QuickPreview = () => {
]}
/>
{item.type !== 'NonIndexedPath' && (
<DropdownMenu.Item
label="Rename"
onClick={() => name && setIsRenaming(true)}
/>
)}
<DropdownMenu.Item
label="Rename"
onClick={() => name && setIsRenaming(true)}
/>
<SeparatedConditional
items={[ObjectItems.AssignTag]}

View file

@ -49,6 +49,16 @@ export const useExplorerTopBarOptions = () => {
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: <FolderPlus className={TOP_BAR_ICON_STYLE} />,
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'

View file

@ -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 (

View file

@ -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<CutCopyState>) {
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([

View file

@ -20,6 +20,10 @@ export type ExplorerParent =
location: Location;
subPath?: FilePath;
}
| {
type: 'Ephemeral';
path: string;
}
| {
type: 'Tag';
tag: Tag;

View file

@ -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 (
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal

View file

@ -1,9 +1,10 @@
import { useKeys } from 'rooks';
import { useItemsAsFilePaths, useLibraryMutation } from '@sd/client';
import { useItemsAsEphemeralPaths, useItemsAsFilePaths, useLibraryMutation } from '@sd/client';
import { toast } from '@sd/ui';
import { useExplorerContext } from '~/app/$libraryId/Explorer/Context';
import { getExplorerStore, useExplorerStore } from '~/app/$libraryId/Explorer/store';
import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
import { isNonEmpty } from '~/util';
import { useKeyMatcher } from './useKeyMatcher';
@ -13,17 +14,36 @@ export const useKeyCopyCutPaste = () => {
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({

View file

@ -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<ExplorerItem>, 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) => (
<DeleteDialog
{...dp}
locationId={locationId}
pathIds={pathIds}
indexedArgs={indexedArgs}
ephemeralArgs={ephemeralArgs}
dirCount={dirCount}
fileCount={fileCount}
/>

View file

@ -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<null>, result: { [key in Category]: number } } |
{ key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } |
{ key: "files.get", input: LibraryArgs<GetArgs>, 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<number>, result: MediaMetadata } |
{ key: "files.getPath", input: LibraryArgs<number>, result: string | null } |
{ key: "invalidation.test-invalidate", input: never, result: number } |
@ -48,13 +48,16 @@ export type Procedures = {
{ key: "backups.backup", input: LibraryArgs<null>, result: string } |
{ key: "backups.delete", input: string, result: null } |
{ key: "backups.restore", input: string, result: null } |
{ key: "ephemeralFiles.copyFiles", input: LibraryArgs<EphemeralFileSystemOps>, result: null } |
{ key: "ephemeralFiles.createFolder", input: LibraryArgs<CreateEphemeralFolderArgs>, result: string } |
{ key: "ephemeralFiles.cutFiles", input: LibraryArgs<EphemeralFileSystemOps>, result: null } |
{ key: "ephemeralFiles.deleteFiles", input: LibraryArgs<string[]>, result: null } |
{ key: "ephemeralFiles.renameFile", input: LibraryArgs<EphemeralRenameFileArgs>, result: null } |
{ key: "files.convertImage", input: LibraryArgs<ConvertImageArgs>, result: null } |
{ key: "files.copyFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
{ key: "files.createEphemeralFolder", input: LibraryArgs<CreateEphemeralFolderArgs>, result: string } |
{ key: "files.createFolder", input: LibraryArgs<CreateFolderArgs>, result: string } |
{ key: "files.cutFiles", input: LibraryArgs<FileCutterJobInit>, result: null } |
{ key: "files.deleteFiles", input: LibraryArgs<FileDeleterJobInit>, result: null } |
{ key: "files.duplicateFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
{ key: "files.eraseFiles", input: LibraryArgs<FileEraserJobInit>, result: null } |
{ key: "files.removeAccessTime", input: LibraryArgs<number[]>, result: null } |
{ key: "files.renameFile", input: LibraryArgs<RenameFileArgs>, result: null } |
@ -158,10 +161,20 @@ export type DoubleClickAction = "openFile" | "quickPreview"
export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined<string> }
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 }
/**

View file

@ -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