diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 000000000000..154626ef4e81 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/.github/scripts/setup-system.sh b/.github/scripts/setup-system.sh index 4ba2c55be9e5..735d833f069e 100755 --- a/.github/scripts/setup-system.sh +++ b/.github/scripts/setup-system.sh @@ -280,9 +280,8 @@ elif [ "$SYSNAME" = "Darwin" ]; then echo "Download ffmpeg build..." _page=1 while [ $_page -gt 0 ]; do - # TODO: Filter only actions triggered by the main branch _success=$(gh_curl "${_gh_url}/${_sd_gh_path}/actions/workflows/ffmpeg-macos.yml/runs?page=${_page}&per_page=100&status=success" \ - | jq -r '. as $raw | .workflow_runs | if length == 0 then error("Error: \($raw)") else .[] | .artifacts_url end' \ + | jq -r '. as $raw | .workflow_runs | if length == 0 then error("Error: \($raw)") else .[] | select(.head_branch == "main") | .artifacts_url end' \ | while IFS= read -r _artifacts_url; do if _artifact_path="$( gh_curl "$_artifacts_url" \ diff --git a/core/crates/sync/src/ingest.rs b/core/crates/sync/src/ingest.rs index 5b5bfff4268c..4b37fc13a351 100644 --- a/core/crates/sync/src/ingest.rs +++ b/core/crates/sync/src/ingest.rs @@ -118,10 +118,7 @@ impl Actor { let mut timestamp = { let mut clocks = self.timestamps.write().await; - clocks - .entry(op.instance) - .or_insert_with(|| op.timestamp) - .clone() + *clocks.entry(op.instance).or_insert_with(|| op.timestamp) }; if timestamp < op.timestamp { diff --git a/core/crates/sync/src/manager.rs b/core/crates/sync/src/manager.rs index 42ba1b1581a6..35d0dcb46599 100644 --- a/core/crates/sync/src/manager.rs +++ b/core/crates/sync/src/manager.rs @@ -14,19 +14,19 @@ pub struct Manager { shared: Arc, } -pub struct SyncManagerNew { - pub manager: Manager, - pub rx: broadcast::Receiver, -} - #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)] pub struct GetOpsArgs { pub clocks: Vec<(Uuid, NTP64)>, pub count: u32, } +pub struct New { + pub manager: T, + pub rx: broadcast::Receiver, +} + impl Manager { - pub fn new(db: &Arc, instance: Uuid) -> SyncManagerNew { + pub fn new(db: &Arc, instance: Uuid) -> New { let (tx, rx) = broadcast::channel(64); let timestamps: Timestamps = Default::default(); @@ -41,7 +41,7 @@ impl Manager { let ingest = ingest::Actor::spawn(shared.clone()); - SyncManagerNew { + New { manager: Self { shared, tx, ingest }, rx, } diff --git a/core/crates/sync/tests/lib.rs b/core/crates/sync/tests/lib.rs index 942bc67d05cc..e683dfa80ac3 100644 --- a/core/crates/sync/tests/lib.rs +++ b/core/crates/sync/tests/lib.rs @@ -112,15 +112,14 @@ async fn bruh() -> Result<(), Box> { async move { while let Ok(msg) = sync_rx1.recv().await { - match msg { - SyncMessage::Created => instance2 + if let SyncMessage::Created = msg { + instance2 .sync .ingest .event_tx .send(ingest::Event::Notification) .await - .unwrap(), - _ => {} + .unwrap() } } } diff --git a/core/src/api/files.rs b/core/src/api/files.rs index 218b648d0f75..add34c55123f 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -14,6 +14,7 @@ use crate::{ erase::FileEraserJobInit, }, prisma::{file_path, location, object}, + util::{db::maybe_missing, error::FileIOError}, }; use std::path::Path; @@ -24,8 +25,8 @@ use regex::Regex; use rspc::{alpha::AlphaRouter, ErrorCode}; use serde::Deserialize; use specta::Type; -use tokio::fs; -use tracing::error; +use tokio::{fs, io}; +use tracing::{error, warn}; use super::{Ctx, R}; @@ -129,12 +130,12 @@ pub(crate) fn mount() -> AlphaRouter { }) .procedure("updateAccessTime", { R.with2(library()) - .mutation(|(_, library), id: i32| async move { + .mutation(|(_, library), ids: Vec| async move { library .db .object() - .update( - object::id::equals(id), + .update_many( + vec![object::id::in_vec(ids)], vec![object::date_accessed::set(Some(Utc::now().into()))], ) .exec() @@ -176,10 +177,76 @@ pub(crate) fn mount() -> AlphaRouter { .procedure("deleteFiles", { R.with2(library()) .mutation(|(node, library), args: FileDeleterJobInit| async move { - Job::new(args) - .spawn(&node, &library) - .await - .map_err(Into::into) + match args.file_path_ids.len() { + 0 => Ok(()), + 1 => { + let (maybe_location, maybe_file_path) = library + .db + ._batch(( + library + .db + .location() + .find_unique(location::id::equals(args.location_id)) + .select(location::select!({ path })), + library + .db + .file_path() + .find_unique(file_path::id::equals(args.file_path_ids[0])) + .select(file_path_to_isolate::select()), + )) + .await?; + + let location_path = maybe_missing( + maybe_location + .ok_or(LocationError::IdNotFound(args.location_id))? + .path, + "location.path", + ) + .map_err(LocationError::from)?; + + let file_path = maybe_file_path.ok_or(LocationError::FilePath( + FilePathError::IdNotFound(args.file_path_ids[0]), + ))?; + + let full_path = Path::new(&location_path).join( + IsolatedFilePathData::try_from(&file_path) + .map_err(LocationError::MissingField)?, + ); + + match if maybe_missing(file_path.is_dir, "file_path.is_dir") + .map_err(LocationError::MissingField)? + { + fs::remove_dir_all(&full_path).await + } else { + fs::remove_file(&full_path).await + } { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + warn!( + "File not found in the file system, will remove from database: {}", + full_path.display() + ); + library + .db + .file_path() + .delete(file_path::id::equals(args.file_path_ids[0])) + .exec() + .await + .map_err(LocationError::from)?; + + Ok(()) + } + Err(e) => { + Err(LocationError::from(FileIOError::from((full_path, e))) + .into()) + } + } + } + _ => Job::new(args) + .spawn(&node, &library) + .await + .map_err(Into::into), + } }) }) .procedure("eraseFiles", { diff --git a/core/src/api/jobs.rs b/core/src/api/jobs.rs index a33d5b83b370..b32066a18754 100644 --- a/core/src/api/jobs.rs +++ b/core/src/api/jobs.rs @@ -48,8 +48,8 @@ pub(crate) fn mount() -> AlphaRouter { } }; - let instant = intervals.entry(progress_event.id).or_insert_with(|| - Instant::now() + let instant = intervals.entry(progress_event.id).or_insert_with( + Instant::now ); if instant.elapsed() <= Duration::from_secs_f64(1.0 / 30.0) { diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 9bc6bae62769..5c0164c2a640 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -9,7 +9,7 @@ use crate::{ util::AbortOnDrop, }; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use rspc::{self, alpha::AlphaRouter, ErrorCode}; use serde::{Deserialize, Serialize}; diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index dc10c1cf7806..7b469813d5b3 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use chrono::Utc; use rspc::{alpha::AlphaRouter, ErrorCode}; use sd_prisma::prisma_sync; @@ -36,6 +38,42 @@ pub(crate) fn mount() -> AlphaRouter { .await?) }) }) + .procedure("getWithObjects", { + R.with2(library()).query( + |(_, library), object_ids: Vec| async move { + let Library { db, .. } = library.as_ref(); + + let tags_with_objects = db + .tag() + .find_many(vec![tag::tag_objects::some(vec![ + tag_on_object::object_id::in_vec(object_ids.clone()), + ])]) + .select(tag::select!({ + id + tag_objects(vec![tag_on_object::object_id::in_vec(object_ids.clone())]): select { + object: select { + id + } + } + })) + .exec() + .await?; + + Ok(tags_with_objects + .into_iter() + .map(|tag| { + ( + tag.id, + tag.tag_objects + .into_iter() + .map(|rel| rel.object.id) + .collect::>(), + ) + }) + .collect::>()) + }, + ) + }) .procedure("get", { R.with2(library()) .query(|(_, library), tag_id: i32| async move { @@ -137,6 +175,7 @@ pub(crate) fn mount() -> AlphaRouter { } invalidate_query!(library, "tags.getForObject"); + invalidate_query!(library, "tags.getWithObjects"); Ok(()) }) diff --git a/core/src/api/utils/invalidate.rs b/core/src/api/utils/invalidate.rs index 29308edd1f6a..d62a369c48b0 100644 --- a/core/src/api/utils/invalidate.rs +++ b/core/src/api/utils/invalidate.rs @@ -114,20 +114,20 @@ impl InvalidRequests { /// ); /// ``` #[macro_export] -#[allow(clippy::crate_in_macro_def)] +// #[allow(clippy::crate_in_macro_def)] macro_rules! invalidate_query { ($ctx:expr, $key:literal) => {{ - let ctx: &crate::library::Library = &$ctx; // Assert the context is the correct type + let ctx: &$crate::library::Library = &$ctx; // Assert the context is the correct type #[cfg(debug_assertions)] { #[ctor::ctor] fn invalidate() { - crate::api::utils::INVALIDATION_REQUESTS + $crate::api::utils::INVALIDATION_REQUESTS .lock() .unwrap() .queries - .push(crate::api::utils::InvalidationRequest { + .push($crate::api::utils::InvalidationRequest { key: $key, arg_ty: None, result_ty: None, @@ -139,23 +139,23 @@ macro_rules! invalidate_query { ::tracing::trace!(target: "sd_core::invalidate-query", "invalidate_query!(\"{}\") at {}", $key, concat!(file!(), ":", line!())); // The error are ignored here because they aren't mission critical. If they fail the UI might be outdated for a bit. - ctx.emit(crate::api::CoreEvent::InvalidateOperation( - crate::api::utils::InvalidateOperationEvent::dangerously_create($key, serde_json::Value::Null, None) + ctx.emit($crate::api::CoreEvent::InvalidateOperation( + $crate::api::utils::InvalidateOperationEvent::dangerously_create($key, serde_json::Value::Null, None) )) }}; ($ctx:expr, $key:literal: $arg_ty:ty, $arg:expr $(,)?) => {{ let _: $arg_ty = $arg; // Assert the type the user provided is correct - let ctx: &crate::library::Library = &$ctx; // Assert the context is the correct type + let ctx: &$crate::library::Library = &$ctx; // Assert the context is the correct type #[cfg(debug_assertions)] { #[ctor::ctor] fn invalidate() { - crate::api::utils::INVALIDATION_REQUESTS + $crate::api::utils::INVALIDATION_REQUESTS .lock() .unwrap() .queries - .push(crate::api::utils::InvalidationRequest { + .push($crate::api::utils::InvalidationRequest { key: $key, arg_ty: Some(<$arg_ty as rspc::internal::specta::Type>::reference(rspc::internal::specta::DefOpts { parent_inline: false, @@ -172,8 +172,8 @@ macro_rules! invalidate_query { // The error are ignored here because they aren't mission critical. If they fail the UI might be outdated for a bit. let _ = serde_json::to_value($arg) .map(|v| - ctx.emit(crate::api::CoreEvent::InvalidateOperation( - crate::api::utils::InvalidateOperationEvent::dangerously_create($key, v, None), + ctx.emit($crate::api::CoreEvent::InvalidateOperation( + $crate::api::utils::InvalidateOperationEvent::dangerously_create($key, v, None), )) ) .map_err(|_| { @@ -182,17 +182,17 @@ macro_rules! invalidate_query { }}; ($ctx:expr, $key:literal: $arg_ty:ty, $arg:expr, $result_ty:ty: $result:expr $(,)?) => {{ let _: $arg_ty = $arg; // Assert the type the user provided is correct - let ctx: &crate::library::Library = &$ctx; // Assert the context is the correct type + let ctx: &$crate::library::Library = &$ctx; // Assert the context is the correct type #[cfg(debug_assertions)] { #[ctor::ctor] fn invalidate() { - crate::api::utils::INVALIDATION_REQUESTS + $crate::api::utils::INVALIDATION_REQUESTS .lock() .unwrap() .queries - .push(crate::api::utils::InvalidationRequest { + .push($crate::api::utils::InvalidationRequest { key: $key, arg_ty: Some(<$arg_ty as rspc::internal::specta::Type>::reference(rspc::internal::specta::DefOpts { parent_inline: false, @@ -214,8 +214,8 @@ macro_rules! invalidate_query { .and_then(|arg| serde_json::to_value($result) .map(|result| - ctx.emit(crate::api::CoreEvent::InvalidateOperation( - crate::api::utils::InvalidateOperationEvent::dangerously_create($key, arg, Some(result)), + ctx.emit($crate::api::CoreEvent::InvalidateOperation( + $crate::api::utils::InvalidateOperationEvent::dangerously_create($key, arg, Some(result)), )) ) ) diff --git a/core/src/library/manager/mod.rs b/core/src/library/manager/mod.rs index 7ae1d545af84..8bb26e2bda9c 100644 --- a/core/src/library/manager/mod.rs +++ b/core/src/library/manager/mod.rs @@ -387,7 +387,7 @@ impl Libraries { identity, // key_manager, db, - &node, + node, Arc::new(sync.manager), ) .await; diff --git a/core/src/object/fs/copy.rs b/core/src/object/fs/copy.rs index ddba276617e4..1fd5aa6ef76c 100644 --- a/core/src/object/fs/copy.rs +++ b/core/src/object/fs/copy.rs @@ -137,26 +137,39 @@ impl StatefulJob for FileCopierJobInit { .expect("We got the children path from the read_dir, so it should be a child of the source path"), ); - // Currently not supporting file_name suffixes children files in a directory being copied - more_steps.push(FileCopierJobStep { - target_full_path: target_children_full_path, - source_file_data: get_file_data_from_isolated_file_path( - &ctx.library.db, + match get_file_data_from_isolated_file_path( + &ctx.library.db, + &data.sources_location_path, + &IsolatedFilePathData::new( + init.source_location_id, &data.sources_location_path, - &IsolatedFilePathData::new( - init.source_location_id, - &data.sources_location_path, - &children_path, - children_entry - .metadata() - .await - .map_err(|e| FileIOError::from((&children_path, e)))? - .is_dir(), - ) - .map_err(FileSystemJobsError::from)?, + &children_path, + children_entry + .metadata() + .await + .map_err(|e| FileIOError::from((&children_path, e)))? + .is_dir(), ) - .await?, - }); + .map_err(FileSystemJobsError::from)?, + ) + .await + { + Ok(source_file_data) => { + // Currently not supporting file_name suffixes children files in a directory being copied + more_steps.push(FileCopierJobStep { + target_full_path: target_children_full_path, + source_file_data, + }); + } + Err(FileSystemJobsError::FilePathNotFound(path)) => { + // FilePath doesn't exist in the database, it possibly wasn't indexed, so we skip it + warn!( + "Skipping duplicating {} as it wasn't indexed", + path.display() + ); + } + Err(e) => return Err(e.into()), + } } Ok(more_steps.into()) diff --git a/core/src/object/fs/mod.rs b/core/src/object/fs/mod.rs index 5b8ad93d31a9..1236e36a42c0 100644 --- a/core/src/object/fs/mod.rs +++ b/core/src/object/fs/mod.rs @@ -95,6 +95,7 @@ pub async fn get_file_data_from_isolated_file_path( location_path: impl AsRef, iso_file_path: &IsolatedFilePathData<'_>, ) -> Result { + let location_path = location_path.as_ref(); db.file_path() .find_unique(iso_file_path.into()) .include(file_path_with_object::include()) @@ -102,16 +103,12 @@ pub async fn get_file_data_from_isolated_file_path( .await? .ok_or_else(|| { FileSystemJobsError::FilePathNotFound( - AsRef::::as_ref(iso_file_path) - .to_path_buf() - .into_boxed_path(), + location_path.join(iso_file_path).into_boxed_path(), ) }) .and_then(|path_data| { Ok(FileData { - full_path: location_path - .as_ref() - .join(IsolatedFilePathData::try_from(&path_data)?), + full_path: location_path.join(IsolatedFilePathData::try_from(&path_data)?), file_path: path_data, }) }) diff --git a/core/src/p2p/pairing/mod.rs b/core/src/p2p/pairing/mod.rs index a33ec736ab15..f96668d85196 100644 --- a/core/src/p2p/pairing/mod.rs +++ b/core/src/p2p/pairing/mod.rs @@ -129,8 +129,7 @@ impl PairingManager { .get_all() .await .into_iter() - .find(|i| i.id == library_id) - .is_some() + .any(|i| i.id == library_id) { self.emit_progress(pairing_id, PairingStatus::LibraryAlreadyExists); @@ -246,7 +245,7 @@ impl PairingManager { .send(P2PEvent::PairingRequest { id: pairing_id, name: remote_instance.node_name.clone(), - os: remote_instance.node_platform.clone().into(), + os: remote_instance.node_platform.into(), }) .ok(); diff --git a/core/src/p2p/pairing/proto.rs b/core/src/p2p/pairing/proto.rs index 27313d86dd50..6f8c7baf0f85 100644 --- a/core/src/p2p/pairing/proto.rs +++ b/core/src/p2p/pairing/proto.rs @@ -222,8 +222,8 @@ mod tests { node_id: Uuid::new_v4(), node_name: "Node Name".into(), node_platform: Platform::current(), - last_seen: Utc::now().into(), - date_created: Utc::now().into(), + last_seen: Utc::now(), + date_created: Utc::now(), }; { diff --git a/core/src/p2p/sync/mod.rs b/core/src/p2p/sync/mod.rs index 14a8b5437669..e8c630522ad9 100644 --- a/core/src/p2p/sync/mod.rs +++ b/core/src/p2p/sync/mod.rs @@ -263,7 +263,7 @@ mod originator { dbg!(&library.instances); // TODO: Deduplicate any duplicate peer ids -> This is an edge case but still - for (_, instance) in &library.instances { + for instance in library.instances.values() { let InstanceState::Connected(peer_id) = *instance else { continue; }; @@ -276,12 +276,7 @@ mod originator { "Alerting peer '{peer_id:?}' of new sync events for library '{library_id:?}'" ); - let mut stream = p2p - .manager - .stream(peer_id.clone()) - .await - .map_err(|_| ()) - .unwrap(); // TODO: handle providing incorrect peer id + let mut stream = p2p.manager.stream(peer_id).await.map_err(|_| ()).unwrap(); // TODO: handle providing incorrect peer id stream .write_all(&Header::Sync(library_id).to_bytes()) diff --git a/core/src/p2p/sync/proto.rs b/core/src/p2p/sync/proto.rs index 0c4295cc9cc7..bcbe12bb7786 100644 --- a/core/src/p2p/sync/proto.rs +++ b/core/src/p2p/sync/proto.rs @@ -27,10 +27,10 @@ impl SyncMessage { #[cfg(test)] mod tests { - use sd_core_sync::NTP64; - use sd_sync::SharedOperation; - use serde_json::Value; - use uuid::Uuid; + // use sd_core_sync::NTP64; + // use sd_sync::SharedOperation; + // use serde_json::Value; + // use uuid::Uuid; use super::*; diff --git a/core/src/preferences/kv.rs b/core/src/preferences/kv.rs index 8ca78875d1cd..b42e1c8e839c 100644 --- a/core/src/preferences/kv.rs +++ b/core/src/preferences/kv.rs @@ -42,7 +42,8 @@ impl PreferenceValue { pub fn new(value: impl Serialize) -> Self { let mut bytes = vec![]; - rmp_serde::encode::write_named(&mut bytes, &value).unwrap(); + rmp_serde::encode::write_named(&mut bytes, &value) + .expect("Failed to serialize preference value"); // let value = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap(); @@ -52,7 +53,8 @@ impl PreferenceValue { pub fn from_value(value: Value) -> Self { let mut bytes = vec![]; - rmpv::encode::write_value(&mut bytes, &value).unwrap(); + rmpv::encode::write_value(&mut bytes, &value) + .expect("Failed to serialize preference value"); Self(bytes) } @@ -76,6 +78,7 @@ pub enum Entry { Nested(Entries), } +#[allow(clippy::unwrap_used, clippy::panic)] impl Entry { pub fn expect_value(self) -> T { match self { diff --git a/core/src/preferences/mod.rs b/core/src/preferences/mod.rs index 7dabf1d6166e..c036af351146 100644 --- a/core/src/preferences/mod.rs +++ b/core/src/preferences/mod.rs @@ -1,14 +1,15 @@ -mod kv; - -pub use kv::*; -use specta::Type; - use crate::prisma::PrismaClient; + use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use specta::Type; +use tracing::error; use uuid::Uuid; +mod kv; +pub use kv::*; + // Preferences are a set of types that are serialized as a list of key-value pairs, // where nested type keys are serialized as a dot-separated path. // They are serailized as a list because this allows preferences to be a synchronisation boundary, @@ -36,9 +37,15 @@ impl LibraryPreferences { let prefs = PreferenceKVs::new( kvs.into_iter() .filter_map(|data| { - let a = rmpv::decode::read_value(&mut data.value?.as_slice()).unwrap(); - - Some((PreferenceKey::new(data.key), PreferenceValue::from_value(a))) + rmpv::decode::read_value(&mut data.value?.as_slice()) + .map_err(|e| error!("{e:#?}")) + .ok() + .map(|value| { + ( + PreferenceKey::new(data.key), + PreferenceValue::from_value(value), + ) + }) }) .collect(), ); @@ -101,9 +108,10 @@ where entries .into_iter() .map(|(key, value)| { - let id = Uuid::parse_str(&key).unwrap(); - - (id, V::from_entries(value.expect_nested())) + ( + Uuid::parse_str(&key).expect("invalid uuid in preferences"), + V::from_entries(value.expect_nested()), + ) }) .collect() } diff --git a/core/src/util/mpscrr.rs b/core/src/util/mpscrr.rs index d7ba7a858af9..c3a1084678db 100644 --- a/core/src/util/mpscrr.rs +++ b/core/src/util/mpscrr.rs @@ -276,7 +276,7 @@ mod tests { .await .unwrap(); - assert!(true, "recv a closed"); + // assert!(true, "recv a closed"); } }); @@ -297,7 +297,7 @@ mod tests { .await .unwrap(); - assert!(true, "recv b closed"); + // assert!(true, "recv b closed"); } }); diff --git a/interface/app/$libraryId/Explorer/Context.tsx b/interface/app/$libraryId/Explorer/Context.tsx index 8bde17d9ab54..64d5031149f9 100644 --- a/interface/app/$libraryId/Explorer/Context.tsx +++ b/interface/app/$libraryId/Explorer/Context.tsx @@ -1,30 +1,11 @@ import { createContext, useContext } from 'react'; -import { FilePath, Location, NodeState, Tag } from '@sd/client'; - -export type ExplorerParent = - | { - type: 'Location'; - location: Location; - subPath?: FilePath; - } - | { - type: 'Tag'; - tag: Tag; - } - | { - type: 'Node'; - node: NodeState; - }; - -interface ExplorerContext { - parent?: ExplorerParent; -} +import { UseExplorer } from './useExplorer'; /** * Context that must wrap anything to do with the explorer. * This includes explorer views, the inspector, and top bar items. */ -export const ExplorerContext = createContext(null); +export const ExplorerContext = createContext(null); export const useExplorerContext = () => { const ctx = useContext(ExplorerContext); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx b/interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx new file mode 100644 index 000000000000..5074088d75c3 --- /dev/null +++ b/interface/app/$libraryId/Explorer/ContextMenu/ConditionalItem.tsx @@ -0,0 +1,45 @@ +import { ReactNode } from 'react'; + +type UseCondition = () => TProps | null; + +export class ConditionalItem { + // Named like a hook to please eslint + useCondition: UseCondition; + // Capital 'C' to please eslint + make rendering after destructuring easier + Component: React.FC; + + constructor(public args: { useCondition: UseCondition; Component: React.FC }) { + this.useCondition = args.useCondition; + this.Component = args.Component; + } +} + +export interface ConditionalGroupProps { + items: ConditionalItem[]; + children?: (children: ReactNode) => ReactNode; +} + +/** + * Takes an array of `ConditionalItem` and attempts to render them all, + * returning `null` if all conditions are `null`. + * + * @param items An array of `ConditionalItem` to render. + * @param children An optional render function that can be used to wrap the rendered items. + */ +export const Conditional = ({ items, children }: ConditionalGroupProps) => { + const itemConditions = items.map((item) => item.useCondition()); + + if (itemConditions.every((c) => c === null)) return null; + + const renderedItems = ( + <> + {itemConditions.map((props, i) => { + if (props === null) return null; + const { Component } = items[i]!; + return ; + })} + + ); + + return <>{children ? children(renderedItems) : renderedItems}; +}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx index 84fed0bd770f..5119bf690734 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/CutCopyItems.tsx @@ -1,74 +1,81 @@ import { Copy, Scissors } from 'phosphor-react'; -import { FilePath, useLibraryMutation } from '@sd/client'; +import { useLibraryMutation } from '@sd/client'; import { ContextMenu, ModifierKeys } from '@sd/ui'; import { showAlertDialog } from '~/components'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; +import { isNonEmpty } from '~/util'; +import { useExplorerContext } from '../../Context'; import { getExplorerStore } from '../../store'; import { useExplorerSearchParams } from '../../util'; +import { ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; -interface Props { - locationId: number; - filePath: FilePath; -} +export const CutCopyItems = new ConditionalItem({ + useCondition: () => { + const { parent } = useExplorerContext(); + const { selectedFilePaths } = useContextMenuContext(); -export const CutCopyItems = ({ locationId, filePath }: Props) => { - const keybind = useKeybindFactory(); - const [{ path }] = useExplorerSearchParams(); + if (parent?.type !== 'Location' || !isNonEmpty(selectedFilePaths)) return null; - const copyFiles = useLibraryMutation('files.copyFiles'); + return { locationId: parent.location.id, selectedFilePaths }; + }, + Component: ({ locationId, selectedFilePaths }) => { + const keybind = useKeybindFactory(); + const [{ path }] = useExplorerSearchParams(); - return ( - <> - { - getExplorerStore().cutCopyState = { - sourceParentPath: path ?? '/', - sourceLocationId: locationId, - sourcePathId: filePath.id, - actionType: 'Cut', - active: true - }; - }} - icon={Scissors} - /> + const copyFiles = useLibraryMutation('files.copyFiles'); - { - getExplorerStore().cutCopyState = { - sourceParentPath: path ?? '/', - sourceLocationId: locationId, - sourcePathId: filePath.id, - actionType: 'Copy', - active: true - }; - }} - icon={Copy} - /> + return ( + <> + { + getExplorerStore().cutCopyState = { + sourceParentPath: path ?? '/', + sourceLocationId: locationId, + sourcePathIds: selectedFilePaths.map((p) => p.id), + type: 'Cut' + }; + }} + icon={Scissors} + /> - { - try { - await copyFiles.mutateAsync({ - source_location_id: locationId, - sources_file_path_ids: [filePath.id], - target_location_id: locationId, - target_location_relative_directory_path: path ?? '/', - target_file_name_suffix: ' copy' - }); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to duplcate file, due to an error: ${error}` - }); - } - }} - /> - - ); -}; + { + getExplorerStore().cutCopyState = { + sourceParentPath: path ?? '/', + sourceLocationId: locationId, + sourcePathIds: selectedFilePaths.map((p) => p.id), + type: 'Copy' + }; + }} + icon={Copy} + /> + + { + 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 ?? '/', + target_file_name_suffix: ' copy' + }); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to duplcate file, due to an error: ${error}` + }); + } + }} + /> + + ); + } +}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx index beeec7f2b88b..87e97f6645de 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/Items.tsx @@ -1,86 +1,99 @@ -import { ClipboardText, Image, Package, Trash, TrashSimple } from 'phosphor-react'; -import { FilePath, libraryClient, useLibraryContext, useLibraryMutation } from '@sd/client'; +import { Image, Package, Trash, TrashSimple } from 'phosphor-react'; +import { libraryClient, useLibraryContext, useLibraryMutation } from '@sd/client'; import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui'; import { showAlertDialog } from '~/components'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; +import { isNonEmpty } from '~/util'; import { usePlatform } from '~/util/Platform'; +import { useExplorerContext } from '../../Context'; +import { CopyAsPathBase } from '../../CopyAsPath'; import DeleteDialog from '../../FilePath/DeleteDialog'; import EraseDialog from '../../FilePath/EraseDialog'; +import { Conditional, ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; import OpenWith from './OpenWith'; export * from './CutCopyItems'; -interface FilePathProps { - filePath: FilePath; -} +export const Delete = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; -export const Delete = ({ filePath }: FilePathProps) => { - const keybind = useKeybindFactory(); + const locationId = selectedFilePaths[0].location_id; + if (locationId === null) return null; - const locationId = filePath.location_id; + return { selectedFilePaths, locationId }; + }, + Component: ({ selectedFilePaths, locationId }) => { + const keybind = useKeybindFactory(); - return ( - <> - {locationId != null && ( - - dialogManager.create((dp) => ( - - )) - } - /> - )} - - ); -}; - -export const CopyAsPath = ({ pathOrId }: { pathOrId: number | string }) => { - return ( - { - try { - const path = - typeof pathOrId === 'string' - ? pathOrId - : await libraryClient.query(['files.getPath', pathOrId]); - - if (path == null) throw new Error('No file path available'); - - navigator.clipboard.writeText(path); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to copy file path: ${error}` - }); + return ( + + dialogManager.create((dp) => ( + p.id)} + /> + )) } - }} - /> - ); -}; + /> + ); + } +}); -export const Compress = (_: FilePathProps) => { - const keybind = useKeybindFactory(); +export const CopyAsPath = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths) || selectedFilePaths.length > 1) return null; - return ( - ( + libraryClient.query(['files.getPath', selectedFilePaths[0].id])} /> - ); -}; + ) +}); + +export const Compress = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; -export const Crypto = (_: FilePathProps) => { - return ( - <> - {/* { + const keybind = useKeybindFactory(); + + return ( + + ); + } +}); + +export const Crypto = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; + + return { selectedFilePaths }; + }, + Component: ({ selectedFilePaths: _ }) => { + return ( + <> + {/* { } }} /> */} - {/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */} - {/* { } }} /> */} - - ); -}; + + ); + } +}); -export const SecureDelete = ({ filePath }: FilePathProps) => { - const locationId = filePath.location_id; +export const SecureDelete = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + if (!isNonEmpty(selectedFilePaths)) return null; - return ( - <> - {locationId && ( - - dialogManager.create((dp) => ( - - )) - } - disabled - /> - )} - - ); -}; - -export const ParentFolderActions = ({ - filePath, - locationId -}: FilePathProps & { locationId: number }) => { - const fullRescan = useLibraryMutation('locations.fullRescan'); - const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation'); - - return ( - <> - { - try { - await fullRescan.mutateAsync({ - location_id: locationId, - reidentify_objects: false - }); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to rescan location, due to an error: ${error}` - }); - } - }} - label="Rescan Directory" - icon={Package} - /> - { - try { - await generateThumbnails.mutateAsync({ - id: locationId, - path: filePath.materialized_path ?? '/' - }); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to generate thumbnails, due to an error: ${error}` - }); - } - }} - label="Regen Thumbnails" - icon={Image} - /> - - ); -}; + const locationId = selectedFilePaths[0].location_id; + if (locationId === null) return null; -export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => { - const keybind = useKeybindFactory(); - const { platform, openFilePaths: openFilePath } = usePlatform(); - const updateAccessTime = useLibraryMutation('files.updateAccessTime'); + return { locationId, selectedFilePaths }; + }, + Component: ({ locationId, selectedFilePaths }) => ( + + dialogManager.create((dp) => ( + + )) + } + disabled + /> + ) +}); + +export const ParentFolderActions = new ConditionalItem({ + useCondition: () => { + const { parent } = useExplorerContext(); - const { library } = useLibraryContext(); + if (parent?.type !== 'Location') return null; + + return { parent }; + }, + Component: ({ parent }) => { + const { selectedFilePaths } = useContextMenuContext(); + + const fullRescan = useLibraryMutation('locations.fullRescan'); + const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation'); - if (platform === 'web') return ; - else return ( <> - {openFilePath && ( + { + try { + await fullRescan.mutateAsync({ + location_id: parent.location.id, + reidentify_objects: false + }); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to rescan location, due to an error: ${error}` + }); + } + }} + label="Rescan Directory" + icon={Package} + /> + { + try { + await generateThumbnails.mutateAsync({ + id: parent.location.id, + path: selectedFilePaths[0]?.materialized_path ?? '/' + }); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to generate thumbnails, due to an error: ${error}` + }); + } + }} + label="Regen Thumbnails" + icon={Image} + /> + + ); + } +}); + +export const OpenOrDownload = new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + const { openFilePaths } = usePlatform(); + + if (!openFilePaths || !isNonEmpty(selectedFilePaths)) return null; + + return { openFilePaths, selectedFilePaths }; + }, + Component: ({ openFilePaths, selectedFilePaths }) => { + const keybind = useKeybindFactory(); + const { platform } = usePlatform(); + const updateAccessTime = useLibraryMutation('files.updateAccessTime'); + + const { library } = useLibraryContext(); + + if (platform === 'web') return ; + else + return ( + <> { - if (filePath.object_id) - updateAccessTime - .mutateAsync(filePath.object_id) - .catch(console.error); + if (selectedFilePaths.length < 1) return; + + updateAccessTime + .mutateAsync( + selectedFilePaths.map((p) => p.object_id!).filter(Boolean) + ) + .catch(console.error); try { - await openFilePath(library.uuid, [filePath.id]); + await openFilePaths( + library.uuid, + selectedFilePaths.map((p) => p.id) + ); } catch (error) { showAlertDialog({ title: 'Error', @@ -232,8 +271,8 @@ export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => { } }} /> - )} - - - ); -}; + + + ); + } +}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx index fc8432b9a617..9aeed7a172ed 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/OpenWith.tsx @@ -1,21 +1,26 @@ import { useQuery } from '@tanstack/react-query'; import { Suspense } from 'react'; -import { FilePath, useLibraryContext } from '@sd/client'; +import { useLibraryContext } from '@sd/client'; import { ContextMenu } from '@sd/ui'; import { showAlertDialog } from '~/components'; import { Platform, usePlatform } from '~/util/Platform'; +import { ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; -export default (props: { filePath: FilePath }) => { - const { getFilePathOpenWithApps, openFilePathWith } = usePlatform(); +export default new ConditionalItem({ + useCondition: () => { + const { selectedFilePaths } = useContextMenuContext(); + const { getFilePathOpenWithApps, openFilePathWith } = usePlatform(); - if (!getFilePathOpenWithApps || !openFilePathWith) return null; - if (props.filePath.is_dir) return null; + if (!getFilePathOpenWithApps || !openFilePathWith) return null; + if (selectedFilePaths.some((p) => p.is_dir)) return null; - return ( + return { getFilePathOpenWithApps, openFilePathWith }; + }, + Component: ({ getFilePathOpenWithApps, openFilePathWith }) => ( { /> - ); -}; + ) +}); const Items = ({ - filePath, actions }: { - filePath: FilePath; actions: Required>; }) => { + const { selectedFilePaths } = useContextMenuContext(); + const { library } = useLibraryContext(); + const ids = selectedFilePaths.map((obj) => obj.id); + const items = useQuery( - ['openWith', filePath.id], - () => actions.getFilePathOpenWithApps(library.uuid, [filePath.id]), + ['openWith', ids], + () => actions.getFilePathOpenWithApps(library.uuid, ids), { suspense: true } ); @@ -49,9 +56,10 @@ const Items = ({ key={id} onClick={async () => { try { - await actions.openFilePathWith(library.uuid, [ - [filePath.id, data.url] - ]); + await actions.openFilePathWith( + library.uuid, + ids.map((id) => [id, data.url]) + ); } catch (e) { console.error(e); showAlertDialog({ diff --git a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx deleted file mode 100644 index 11bd154a4057..000000000000 --- a/interface/app/$libraryId/Explorer/ContextMenu/FilePath/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Plus } from 'phosphor-react'; -import { ExplorerItem } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; -import { useExplorerContext } from '../../Context'; -import { ExtraFn, FilePathItems, ObjectItems, SharedItems } from '../../ContextMenu'; - -interface Props { - data: Extract; - extra?: ExtraFn; -} - -export default ({ data, extra }: Props) => { - const filePath = data.item; - const { object } = filePath; - - const { parent } = useExplorerContext(); - - // const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false; - // const mountedKeys = useLibraryQuery(['keys.listMounted']); - // const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0; - - return ( - <> - - - - - - - - - - - - - - - {extra?.({ - object: filePath.object ?? undefined, - filePath: filePath - })} - - - - - - - - - - {object && } - - - - - - - - - {object && } - - {parent?.type === 'Location' && ( - - )} - - - - - - - - - ); -}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx deleted file mode 100644 index 1f8281f2de89..000000000000 --- a/interface/app/$libraryId/Explorer/ContextMenu/Location/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ExplorerItem } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; -import { ExtraFn, SharedItems } from '..'; - -interface Props { - data: Extract; - extra?: ExtraFn; -} - -export default ({ data, extra }: Props) => { - const location = data.item; - - return ( - <> - - - - - - - - - - - {extra?.({ location })} - - - - - - ); -}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx index eaf6fa3cf9fd..eacf2089d2d7 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx @@ -1,56 +1,92 @@ import { ArrowBendUpRight, TagSimple } from 'phosphor-react'; -import { FilePath, ObjectKind, Object as ObjectType, useLibraryMutation } from '@sd/client'; +import { useMemo } from 'react'; +import { ObjectKind, useLibraryMutation } from '@sd/client'; import { ContextMenu } from '@sd/ui'; import { showAlertDialog } from '~/components'; -import AssignTagMenuItems from './AssignTagMenuItems'; - -export const RemoveFromRecents = ({ object }: { object: ObjectType }) => { - const removeFromRecents = useLibraryMutation('files.removeAccessTime'); - - return ( - <> - {object.date_accessed !== null && ( - { - try { - await removeFromRecents.mutateAsync([object.id]); - } catch (error) { - showAlertDialog({ - title: 'Error', - value: `Failed to remove file from recents, due to an error: ${error}` - }); - } - }} - /> - )} - - ); -}; +import AssignTagMenuItems from '~/components/AssignTagMenuItems'; +import { isNonEmpty } from '~/util'; +import { ConditionalItem } from '../ConditionalItem'; +import { useContextMenuContext } from '../context'; + +export const RemoveFromRecents = new ConditionalItem({ + useCondition: () => { + const { selectedObjects } = useContextMenuContext(); + + if (!isNonEmpty(selectedObjects)) return null; + + return { selectedObjects }; + }, -export const AssignTag = ({ object }: { object: ObjectType }) => ( - - - -); + Component: ({ selectedObjects }) => { + const removeFromRecents = useLibraryMutation('files.removeAccessTime'); + + return ( + { + try { + await removeFromRecents.mutateAsync( + selectedObjects.map((object) => object.id) + ); + } catch (error) { + showAlertDialog({ + title: 'Error', + value: `Failed to remove file from recents, due to an error: ${error}` + }); + } + }} + /> + ); + } +}); + +export const AssignTag = new ConditionalItem({ + useCondition: () => { + const { selectedObjects } = useContextMenuContext(); + if (!isNonEmpty(selectedObjects)) return null; + + return { selectedObjects }; + }, + Component: ({ selectedObjects }) => ( + + + + ) +}); const ObjectConversions: Record = { [ObjectKind.Image]: ['PNG', 'WebP', 'Gif'], [ObjectKind.Video]: ['MP4', 'MOV', 'AVI'] }; -export const ConvertObject = ({ object, filePath }: { object: ObjectType; filePath: FilePath }) => { - const { kind } = object; - - return ( - <> - {kind !== null && [ObjectKind.Image, ObjectKind.Video].includes(kind as ObjectKind) && ( - - {ObjectConversions[kind]?.map((ext) => ( - - ))} - - )} - - ); -}; +const ConvertableKinds = [ObjectKind.Image, ObjectKind.Video]; + +export const ConvertObject = new ConditionalItem({ + useCondition: () => { + const { selectedObjects } = useContextMenuContext(); + + const kinds = useMemo(() => { + const set = new Set(); + + for (const o of selectedObjects) { + if (o.kind === null || !ConvertableKinds.includes(o.kind)) break; + set.add(o.kind); + } + + return [...set]; + }, [selectedObjects]); + + if (!isNonEmpty(kinds) || kinds.length > 1) return null; + + const [kind] = kinds; + + return { kind }; + }, + Component: ({ kind }) => ( + + {ObjectConversions[kind]?.map((ext) => ( + + ))} + + ) +}); diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx deleted file mode 100644 index ecb28114347b..000000000000 --- a/interface/app/$libraryId/Explorer/ContextMenu/Object/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Plus } from 'phosphor-react'; -import { ExplorerItem } from '@sd/client'; -import { ContextMenu } from '@sd/ui'; -import { ExtraFn, FilePathItems, ObjectItems, SharedItems } from '..'; - -interface Props { - data: Extract; - extra?: ExtraFn; -} - -export default ({ data, extra }: Props) => { - const object = data.item; - const filePath = data.item.file_paths[0]; - - return ( - <> - {filePath && } - - - - - - - - - - {filePath && } - - - - {extra?.({ - object: object, - filePath: filePath - })} - - - - - - {(object || filePath) && } - - {object && } - - {filePath && ( - - - - - - - )} - - {filePath && ( - <> - - - - )} - - ); -}; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index dde848d970ad..734dd1e90868 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -1,121 +1,143 @@ import { FileX, Share as ShareIcon } from 'phosphor-react'; import { useMemo } from 'react'; -import { ExplorerItem, FilePath, useLibraryContext } from '@sd/client'; import { ContextMenu, ModifierKeys } from '@sd/ui'; -import { useOperatingSystem } from '~/hooks'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; -import { usePlatform } from '~/util/Platform'; +import { isNonEmpty } from '~/util'; +import { Platform } from '~/util/Platform'; +import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer'; import { useExplorerViewContext } from '../ViewContext'; import { getExplorerStore, useExplorerStore } from '../store'; +import { ConditionalItem } from './ConditionalItem'; +import { useContextMenuContext } from './context'; -export const OpenQuickView = ({ item }: { item: ExplorerItem }) => { +export const OpenQuickView = () => { const keybind = useKeybindFactory(); + const { selectedItems } = useContextMenuContext(); return ( (getExplorerStore().quickViewObject = item)} + onClick={() => + // using [0] is not great + (getExplorerStore().quickViewObject = selectedItems[0]) + } /> ); }; -export const Details = () => { - const { showInspector } = useExplorerStore(); - const keybind = useKeybindFactory(); +export const Details = new ConditionalItem({ + useCondition: () => { + const { showInspector } = useExplorerStore(); + if (showInspector) return null; - return ( - <> - {!showInspector && ( - (getExplorerStore().showInspector = true)} - /> - )} - - ); -}; + return {}; + }, + Component: () => { + const keybind = useKeybindFactory(); -export const Rename = () => { - const explorerStore = useExplorerStore(); - const keybind = useKeybindFactory(); - const explorerView = useExplorerViewContext(); + return ( + (getExplorerStore().showInspector = true)} + /> + ); + } +}); - return ( - <> - {explorerStore.layoutMode !== 'media' && ( - explorerView.setIsRenaming(true)} - /> - )} - - ); -}; +export const Rename = new ConditionalItem({ + useCondition: () => { + const { selectedItems } = useContextMenuContext(); + const explorerStore = useExplorerStore(); -export const RevealInNativeExplorer = (props: { locationId: number } | { filePath: FilePath }) => { - const os = useOperatingSystem(); - const keybind = useKeybindFactory(); - const { revealItems } = usePlatform(); - const { library } = useLibraryContext(); + if (explorerStore.layoutMode === 'media' || selectedItems.length > 1) return null; - const osFileBrowserName = useMemo(() => { - const lookup: Record = { - macOS: 'Finder', - windows: 'Explorer' - }; + return {}; + }, + Component: () => { + const explorerView = useExplorerViewContext(); + const keybind = useKeybindFactory(); - return lookup[os] ?? 'file manager'; - }, [os]); + return ( + explorerView.setIsRenaming(true)} + /> + ); + } +}); - return ( - <> - {revealItems && ( - ( - console.log(props), - revealItems(library.uuid, [ - 'filePath' in props - ? { - FilePath: { - id: props.filePath.id - } - } - : { - Location: { - id: props.locationId - } - } - ]) - )} - /> - )} - - ); -}; +export const RevealInNativeExplorer = new ConditionalItem({ + useCondition: () => { + const { selectedItems } = useContextMenuContext(); -export const Deselect = () => { - const { cutCopyState } = useExplorerStore(); + const items = useMemo(() => { + const array: Parameters>[1] = []; - return ( + for (const item of selectedItems) { + switch (item.type) { + case 'Path': { + array.push({ + FilePath: { id: item.item.id } + }); + break; + } + case 'Object': { + // this isn't good but it's the current behaviour + const filePath = item.item.file_paths[0]; + if (filePath) + array.push({ + FilePath: { + id: filePath.id + } + }); + else return []; + break; + } + case 'Location': { + array.push({ + Location: { + id: item.item.id + } + }); + break; + } + } + } + + return array; + }, [selectedItems]); + + if (!isNonEmpty(items)) return null; + + return { items }; + }, + Component: ({ items }) => +}); + +export const Deselect = new ConditionalItem({ + useCondition: () => { + const { cutCopyState } = useExplorerStore(); + + if (cutCopyState.type === 'Idle') return null; + + return {}; + }, + Component: () => (