From 28d106a2d575cdf33230cbd6cbb3ef8103f3cbc3 Mon Sep 17 00:00:00 2001 From: "Ericson \"Fogo\" Soares" Date: Wed, 23 Aug 2023 14:26:07 -0300 Subject: [PATCH] [ENG-862, ENG-921] Ephemeral locations (#1092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Some initial drafts * Finising the first draft on non-indexed locations * Minor tweaks * Fix warnings * Adding date_created and date_modified to non indexed path entries * Add id and path properties to NonIndexedPathItem * Working ephemeral location (hardcoded home for now) * Fix UI for ephemeral locations * Fix windows * Passing ephemeral thumbnails to thumbnails remover * Indexing rules for ephemeral paths walking * Animate Location button when path text overflow it's size * Fix Linux not showing all volumes * Fix Linux - Add some missing no_os_protected rules for macOS - Improve ephemeral location names * Remove unecessary import * Fix Mobile * Improve resizing behaviour for ephemeral location topbar path button - Improve Search View (Replace custom empty component with Explorer's emptyNotice ) - Improve how TopBar children positioning * Hide EphemeralSection if there is no volume or home - Disable Ephemeral topbar path button animation when text is not overflowing * minor fixes * Introducing ordering for ephemeral paths * TS Format * Ephemeral locations UI fixes - Fix indexed Locations having no metadata - Remove date indexed/accessed options for sorting Ephemeral locations - Remove empty three dots from SideBar element when no settings is linked * Add tooltip to add location button in ephemeral locations * Fix indexed Locations selecting other folder/files in Ephemeral location * Minor fixes * Fix app breaking due to wrong logic to get item full path in Explorer * Revert some recent changes to Thumb.tsx * Fix original not loading for overview items - Fix QuickPreview name broken for overview items * Improve imports * Revert replace useEffect with useLayoutEffect for locked logic in ListView It was causing the component to full reload when clicking a header to sort per column * Changes from feedback * Hide some unused Inspector metadata fields on NonIndexedPaths - Merge formatDate functions while retaining original behaviour * Use tauri api for getting user home * Change ThumbType to a string enum to allow for string comparisons * Improve ObjectKind typing --------- Co-authored-by: VĂ­tor Vasconcellos Co-authored-by: Oscar Beaumont --- .vscode/extensions.json | 3 +- apps/desktop/crates/linux/src/lib.rs | 2 +- apps/desktop/crates/windows/Cargo.toml | 2 +- apps/desktop/crates/windows/src/lib.rs | 11 +- apps/desktop/src/App.tsx | 2 + .../src/components/explorer/Explorer.tsx | 8 +- .../src/components/explorer/FileThumb.tsx | 6 +- .../explorer/sections/InfoTagPills.tsx | 9 +- .../modal/inspector/FileInfoModal.tsx | 18 +- .../settings/library/EditLocationSettings.tsx | 9 +- core/src/api/locations.rs | 94 ++++++- core/src/api/mod.rs | 2 +- core/src/api/search.rs | 82 +++++- core/src/lib.rs | 6 + core/src/location/indexer/rules/seed.rs | 25 +- core/src/location/manager/watcher/utils.rs | 58 +---- core/src/location/mod.rs | 139 +++++++--- core/src/location/non_indexed.rs | 246 ++++++++++++++++++ core/src/object/preview/thumbnail/mod.rs | 6 + core/src/preferences/kv.rs | 2 - core/src/volume/mod.rs | 40 ++- crates/ffmpeg/src/thumbnailer.rs | 13 +- .../Explorer/ContextMenu/Object/Items.tsx | 4 +- .../Explorer/ContextMenu/SharedItems.tsx | 9 +- .../$libraryId/Explorer/ContextMenu/index.tsx | 7 +- .../Explorer/FilePath/RenameTextBox.tsx | 31 ++- .../$libraryId/Explorer/FilePath/Thumb.tsx | 90 ++++--- .../$libraryId/Explorer/Inspector/index.tsx | 224 ++++++++-------- .../Explorer/QuickPreview/index.tsx | 8 +- .../app/$libraryId/Explorer/View/GridList.tsx | 64 +++-- .../app/$libraryId/Explorer/View/GridView.tsx | 3 +- .../app/$libraryId/Explorer/View/ListView.tsx | 208 +++++++-------- .../Explorer/View/RenamableItemText.tsx | 43 +-- .../app/$libraryId/Explorer/View/index.tsx | 111 +++++--- .../app/$libraryId/Explorer/ViewContext.ts | 4 +- interface/app/$libraryId/Explorer/index.tsx | 4 +- interface/app/$libraryId/Explorer/store.ts | 18 +- .../app/$libraryId/Explorer/useExplorer.ts | 40 +-- interface/app/$libraryId/Explorer/util.ts | 21 +- .../$libraryId/Layout/Sidebar/Contents.tsx | 13 +- .../Layout/Sidebar/EphemeralSection.tsx | 58 +++++ .../Layout/Sidebar/LibrarySection.tsx | 4 +- .../app/$libraryId/Layout/Sidebar/Section.tsx | 10 +- interface/app/$libraryId/TopBar/Layout.tsx | 6 +- interface/app/$libraryId/TopBar/Portal.tsx | 17 +- .../app/$libraryId/TopBar/TopBarOptions.tsx | 2 +- interface/app/$libraryId/TopBar/index.tsx | 14 +- interface/app/$libraryId/ephemeral.tsx | 99 +++++++ interface/app/$libraryId/index.tsx | 3 +- .../$libraryId/location/LocationOptions.tsx | 14 +- interface/app/$libraryId/search.tsx | 51 ++-- .../settings/library/locations/$id.tsx | 7 +- .../library/locations/AddLocationButton.tsx | 81 +++++- interface/app/index.tsx | 2 +- interface/hooks/useKeyDeleteFile.tsx | 2 +- interface/package.json | 1 + interface/util/Platform.tsx | 3 +- package.json | 2 +- packages/client/src/core.ts | 16 +- packages/client/src/lib/byte-size.ts | 1 + packages/client/src/utils/explorerItem.ts | 70 +++-- packages/client/src/utils/objectKind.ts | 12 +- packages/config/eslint/base.js | 96 +++---- packages/ui/src/ProgressBar.tsx | 14 +- packages/ui/src/Slider.tsx | 2 +- pnpm-lock.yaml | Bin 919379 -> 920100 bytes 66 files changed, 1542 insertions(+), 730 deletions(-) create mode 100644 core/src/location/non_indexed.rs create mode 100644 interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx create mode 100644 interface/app/$libraryId/ephemeral.tsx diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 469c56d32..30d5e4722 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "tauri-apps.tauri-vscode", "rust-lang.rust-analyzer", "oscartbeaumont.rspc-vscode", - "EditorConfig.EditorConfig" + "EditorConfig.EditorConfig", + "bradlc.vscode-tailwindcss" ] } diff --git a/apps/desktop/crates/linux/src/lib.rs b/apps/desktop/crates/linux/src/lib.rs index 14ddcb43e..65a5e70d8 100644 --- a/apps/desktop/crates/linux/src/lib.rs +++ b/apps/desktop/crates/linux/src/lib.rs @@ -4,4 +4,4 @@ mod app_info; mod env; pub use app_info::{list_apps_associated_with_ext, open_file_path, open_files_path_with}; -pub use env::{is_appimage, is_flatpak, is_snap, normalize_environment}; +pub use env::{get_current_user_home, is_appimage, is_flatpak, is_snap, normalize_environment}; diff --git a/apps/desktop/crates/windows/Cargo.toml b/apps/desktop/crates/windows/Cargo.toml index 8ca878561..680930d45 100644 --- a/apps/desktop/crates/windows/Cargo.toml +++ b/apps/desktop/crates/windows/Cargo.toml @@ -12,4 +12,4 @@ libc = "0.2" [target.'cfg(target_os = "windows")'.dependencies.windows] version = "0.48" -features = ["Win32_UI_Shell", "Win32_System_Com"] +features = ["Win32_UI_Shell", "Win32_Foundation", "Win32_System_Com"] diff --git a/apps/desktop/crates/windows/src/lib.rs b/apps/desktop/crates/windows/src/lib.rs index 80e47f2ee..cf11ea539 100644 --- a/apps/desktop/crates/windows/src/lib.rs +++ b/apps/desktop/crates/windows/src/lib.rs @@ -2,21 +2,22 @@ use std::{ ffi::{OsStr, OsString}, - os::windows::ffi::OsStrExt, - path::Path, + os::windows::{ffi::OsStrExt, prelude::OsStringExt}, + path::{Path, PathBuf}, }; use normpath::PathExt; use windows::{ - core::{HSTRING, PCWSTR}, + core::{GUID, HSTRING, PCWSTR}, Win32::{ + Foundation::HANDLE, System::Com::{ CoInitializeEx, CoUninitialize, IDataObject, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE, }, UI::Shell::{ - BHID_DataObject, IAssocHandler, IShellItem, SHAssocEnumHandlers, - SHCreateItemFromParsingName, ASSOC_FILTER_RECOMMENDED, + BHID_DataObject, FOLDERID_Profile, IAssocHandler, IShellItem, SHAssocEnumHandlers, + SHCreateItemFromParsingName, SHGetKnownFolderPath, ASSOC_FILTER_RECOMMENDED, }, }, }; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8c9c9b0a1..9dc38e67d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { dialog, invoke, os, shell } from '@tauri-apps/api'; import { confirm } from '@tauri-apps/api/dialog'; import { listen } from '@tauri-apps/api/event'; +import { homeDir } from '@tauri-apps/api/path'; import { convertFileSrc } from '@tauri-apps/api/tauri'; import { appWindow } from '@tauri-apps/api/window'; import { useEffect } from 'react'; @@ -76,6 +77,7 @@ const platform: Platform = { saveFilePickerDialog: () => dialog.save(), showDevtools: () => invoke('show_devtools'), confirm: (msg, cb) => confirm(msg).then(cb), + userHomeDir: homeDir, ...commands }; diff --git a/apps/mobile/src/components/explorer/Explorer.tsx b/apps/mobile/src/components/explorer/Explorer.tsx index bdf78a725..85ba3cdfe 100644 --- a/apps/mobile/src/components/explorer/Explorer.tsx +++ b/apps/mobile/src/components/explorer/Explorer.tsx @@ -3,11 +3,11 @@ import { useNavigation } from '@react-navigation/native'; import { Rows, SquaresFour } from 'phosphor-react-native'; import { useState } from 'react'; import { Pressable, View } from 'react-native'; -import { ExplorerItem, isPath } from '@sd/client'; +import { type ExplorerItem, isPath } from '@sd/client'; import SortByMenu from '~/components/menu/SortByMenu'; import Layout from '~/constants/Layout'; import { tw } from '~/lib/tailwind'; -import { SharedScreenProps } from '~/navigation/SharedScreens'; +import { type SharedScreenProps } from '~/navigation/SharedScreens'; import { getExplorerStore } from '~/stores/explorerStore'; import { useActionsModalStore } from '~/stores/modalStore'; import FileItem from './FileItem'; @@ -65,7 +65,9 @@ const Explorer = ({ items }: ExplorerProps) => { key={layoutMode} numColumns={layoutMode === 'grid' ? getExplorerStore().gridNumColumns : 1} data={items} - keyExtractor={(item) => item.item.id.toString()} + keyExtractor={(item) => + item.type === 'NonIndexedPath' ? item.item.path : item.item.id.toString() + } renderItem={({ item }) => ( handlePress(item)}> {layoutMode === 'grid' ? ( diff --git a/apps/mobile/src/components/explorer/FileThumb.tsx b/apps/mobile/src/components/explorer/FileThumb.tsx index 3a6b92a17..0ce520d21 100644 --- a/apps/mobile/src/components/explorer/FileThumb.tsx +++ b/apps/mobile/src/components/explorer/FileThumb.tsx @@ -1,9 +1,9 @@ import { getIcon } from '@sd/assets/util'; -import { PropsWithChildren, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { type PropsWithChildren, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { Image, View } from 'react-native'; import { DocumentDirectoryPath } from 'react-native-fs'; import { - ExplorerItem, + type ExplorerItem, getExplorerItemData, getItemFilePath, getItemLocation, @@ -122,7 +122,7 @@ export default function FileThumb({ size = 1, ...props }: FileThumbProps) { if (isDir !== null) setSrc(getIcon(kind, isDarkTheme(), extension, isDir)); break; } - }, [filePath?.id, itemData, props.data.item.id, thumbType]); + }, [itemData, thumbType]); return ( diff --git a/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx b/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx index 42ee337f2..6998d3540 100644 --- a/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx +++ b/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Alert, Pressable, View, ViewStyle } from 'react-native'; import { ExplorerItem, - ObjectKind, + getExplorerItemData, getItemFilePath, getItemObject, isPath, @@ -21,7 +21,7 @@ const InfoTagPills = ({ data, style }: Props) => { const filePath = getItemFilePath(data); const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { - enabled: Boolean(objectData) + enabled: objectData != null }); const isDir = data && isPath(data) ? data.item.is_dir : false; @@ -29,10 +29,7 @@ const InfoTagPills = ({ data, style }: Props) => { return ( {/* Kind */} - + {/* Extension */} {filePath?.extension && ( diff --git a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx index f6e6e8892..0a13530ff 100644 --- a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx +++ b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx @@ -11,7 +11,7 @@ import { import { forwardRef } from 'react'; import { Pressable, Text, View } from 'react-native'; import { - ExplorerItem, + type ExplorerItem, byteSize, getItemFilePath, getItemObject, @@ -19,7 +19,7 @@ import { } from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; -import { Modal, ModalRef, ModalScrollView } from '~/components/layout/Modal'; +import { Modal, type ModalRef, ModalScrollView } from '~/components/layout/Modal'; import { Divider } from '~/components/primitive/Divider'; import useForwardedRef from '~/hooks/useForwardedRef'; import { tw } from '~/lib/tailwind'; @@ -112,15 +112,15 @@ const FileInfoModal = forwardRef((props, ref) => { title="Created" value={dayjs(item?.date_created).format('MMM Do YYYY')} /> - {/* Indexed */} - - {filePathData && ( + {filePathData && 'cas_id' in filePathData && ( <> + {/* Indexed */} + {/* TODO: Note */} {filePathData.cas_id && ( fullRescan.mutate({ location_id: id, reidentify_objects: true })}> + + fullRescan.mutate({ location_id: id, reidentify_objects: true }) + } + > } diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 5c0164c2a..4ca1b1c45 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -2,8 +2,9 @@ use crate::{ invalidate_query, location::{ delete_location, find_location, indexer::rules::IndexerRuleCreateArgs, light_scan_location, - location_with_indexer_rules, relink_location, scan_location, scan_location_sub_path, - LocationCreateArgs, LocationError, LocationUpdateArgs, + location_with_indexer_rules, non_indexed::NonIndexedPathItem, relink_location, + scan_location, scan_location_sub_path, LocationCreateArgs, LocationError, + LocationUpdateArgs, }, prisma::{file_path, indexer_rule, indexer_rules_in_location, location, object, SortOrder}, util::AbortOnDrop, @@ -11,6 +12,7 @@ use crate::{ use std::path::PathBuf; +use chrono::{DateTime, Utc}; use rspc::{self, alpha::AlphaRouter, ErrorCode}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -38,6 +40,94 @@ pub enum ExplorerItem { thumbnail_key: Option>, item: location::Data, }, + NonIndexedPath { + has_local_thumbnail: bool, + thumbnail_key: Option>, + item: NonIndexedPathItem, + }, +} + +impl ExplorerItem { + pub fn name(&self) -> &str { + match self { + ExplorerItem::Path { + item: file_path_with_object::Data { name, .. }, + .. + } + | ExplorerItem::Location { + item: location::Data { name, .. }, + .. + } => name.as_deref().unwrap_or(""), + ExplorerItem::NonIndexedPath { item, .. } => item.name.as_str(), + _ => "", + } + } + + pub fn size_in_bytes(&self) -> u64 { + match self { + ExplorerItem::Path { + item: file_path_with_object::Data { + size_in_bytes_bytes, + .. + }, + .. + } => size_in_bytes_bytes + .as_ref() + .map(|size| { + u64::from_be_bytes([ + size[0], size[1], size[2], size[3], size[4], size[5], size[6], size[7], + ]) + }) + .unwrap_or(0), + + ExplorerItem::NonIndexedPath { + item: NonIndexedPathItem { + size_in_bytes_bytes, + .. + }, + .. + } => u64::from_be_bytes([ + size_in_bytes_bytes[0], + size_in_bytes_bytes[1], + size_in_bytes_bytes[2], + size_in_bytes_bytes[3], + size_in_bytes_bytes[4], + size_in_bytes_bytes[5], + size_in_bytes_bytes[6], + size_in_bytes_bytes[7], + ]), + _ => 0, + } + } + + pub fn date_created(&self) -> DateTime { + match self { + ExplorerItem::Path { + item: file_path_with_object::Data { date_created, .. }, + .. + } + | ExplorerItem::Object { + item: object_with_file_paths::Data { date_created, .. }, + .. + } + | ExplorerItem::Location { + item: location::Data { date_created, .. }, + .. + } => date_created.map(Into::into).unwrap_or_default(), + + ExplorerItem::NonIndexedPath { item, .. } => item.date_created, + } + } + + pub fn date_modified(&self) -> DateTime { + match self { + ExplorerItem::Path { item, .. } => { + item.date_modified.map(Into::into).unwrap_or_default() + } + ExplorerItem::NonIndexedPath { item, .. } => item.date_modified, + _ => Default::default(), + } + } } file_path::include!(file_path_with_object { object }); diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 40be4998a..7c49a0a90 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -27,7 +27,7 @@ mod files; mod jobs; mod keys; mod libraries; -mod locations; +pub mod locations; mod nodes; pub mod notifications; mod p2p; diff --git a/core/src/api/search.rs b/core/src/api/search.rs index f5dc918a1..26ff2f825 100644 --- a/core/src/api/search.rs +++ b/core/src/api/search.rs @@ -6,19 +6,20 @@ use crate::{ library::{Category, Library}, location::{ file_path_helper::{check_file_path_exists, IsolatedFilePathData}, - LocationError, + non_indexed, LocationError, }, object::preview::get_thumb_key, prisma::{self, file_path, location, object, tag, tag_on_object, PrismaClient}, }; -use std::collections::BTreeSet; +use std::{collections::BTreeSet, path::PathBuf}; use chrono::{DateTime, FixedOffset, Utc}; use prisma_client_rust::{operator, or}; use rspc::{alpha::AlphaRouter, ErrorCode}; use serde::{Deserialize, Serialize}; use specta::Type; +use tracing::trace; use super::{Ctx, R}; @@ -274,6 +275,83 @@ impl ObjectFilterArgs { pub fn mount() -> AlphaRouter { R.router() + .procedure("ephemeral-paths", { + #[derive(Deserialize, Type, Debug)] + #[serde(rename_all = "camelCase")] + struct NonIndexedPath { + path: PathBuf, + with_hidden_files: bool, + #[specta(optional)] + order: Option, + } + + R.with2(library()).query( + |(node, library), + NonIndexedPath { + path, + with_hidden_files, + order, + }| async move { + let mut paths = + non_indexed::walk(path, with_hidden_files, node, library).await?; + + if let Some(order) = order { + match order { + FilePathSearchOrdering::Name(order) => { + paths.entries.sort_unstable_by(|path1, path2| { + if let SortOrder::Desc = order { + path2 + .name() + .to_lowercase() + .cmp(&path1.name().to_lowercase()) + } else { + path1 + .name() + .to_lowercase() + .cmp(&path2.name().to_lowercase()) + } + }); + } + FilePathSearchOrdering::SizeInBytes(order) => { + paths.entries.sort_unstable_by(|path1, path2| { + if let SortOrder::Desc = order { + path2.size_in_bytes().cmp(&path1.size_in_bytes()) + } else { + path1.size_in_bytes().cmp(&path2.size_in_bytes()) + } + }); + } + FilePathSearchOrdering::DateCreated(order) => { + paths.entries.sort_unstable_by(|path1, path2| { + if let SortOrder::Desc = order { + path2.date_created().cmp(&path1.date_created()) + } else { + path1.date_created().cmp(&path2.date_created()) + } + }); + } + FilePathSearchOrdering::DateModified(order) => { + paths.entries.sort_unstable_by(|path1, path2| { + if let SortOrder::Desc = order { + path2.date_modified().cmp(&path1.date_modified()) + } else { + path1.date_modified().cmp(&path2.date_modified()) + } + }); + } + FilePathSearchOrdering::DateIndexed(_) => { + trace!("Can't order by indexed date on ephemeral paths route, ignoring...") + } + FilePathSearchOrdering::Object(_) => { + trace!("Receive an Object sort ordeding at ephemeral paths route, ignoring...") + } + } + } + + Ok(paths) + }, + ) + }) .procedure("paths", { #[derive(Deserialize, Type, Debug)] #[serde(rename_all = "camelCase")] diff --git a/core/src/lib.rs b/core/src/lib.rs index 15cffd3dd..e502068e9 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -209,6 +209,12 @@ impl Node { info!("Spacedrive Core shutdown successful!"); } + pub(crate) fn emit(&self, event: CoreEvent) { + if let Err(e) = self.event_bus.0.send(event) { + warn!("Error sending event to event bus: {e:?}"); + } + } + pub async fn emit_notification(&self, data: NotificationData, expires: Option>) { let notification = Notification { id: NotificationId::Node(self.notifications._internal_next_id()), diff --git a/core/src/location/indexer/rules/seed.rs b/core/src/location/indexer/rules/seed.rs index d2ed421b1..9abbe6c8d 100644 --- a/core/src/location/indexer/rules/seed.rs +++ b/core/src/location/indexer/rules/seed.rs @@ -1,6 +1,6 @@ use crate::{ library::Library, - location::indexer::rules::{IndexerRuleError, RulePerKind}, + location::indexer::rules::{IndexerRule, IndexerRuleError, RulePerKind}, }; use chrono::Utc; use sd_prisma::prisma::indexer_rule; @@ -15,12 +15,25 @@ pub enum SeederError { DatabaseError(#[from] prisma_client_rust::QueryError), } -struct SystemIndexerRule { +pub struct SystemIndexerRule { name: &'static str, rules: Vec, default: bool, } +impl From for IndexerRule { + fn from(rule: SystemIndexerRule) -> Self { + Self { + id: None, + name: rule.name.to_string(), + default: rule.default, + rules: rule.rules, + date_created: Utc::now(), + date_modified: Utc::now(), + } + } +} + /// Seeds system indexer rules into a new or existing library, pub async fn new_or_existing_library(library: &Library) -> Result<(), SeederError> { // DO NOT REORDER THIS ARRAY! @@ -56,7 +69,7 @@ pub async fn new_or_existing_library(library: &Library) -> Result<(), SeederErro Ok(()) } -fn no_os_protected() -> SystemIndexerRule { +pub fn no_os_protected() -> SystemIndexerRule { SystemIndexerRule { // TODO: On windows, beside the listed files, any file with the FILE_ATTRIBUTE_SYSTEM should be considered a system file // https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants#FILE_ATTRIBUTE_SYSTEM @@ -114,8 +127,10 @@ fn no_os_protected() -> SystemIndexerRule { ], #[cfg(target_os = "macos")] vec![ - "/{System,Network,Library,Applications}", + "/{System,Network,Library,Applications,.PreviousSystemInformation,.com.apple.templatemigration.boot-install}", + "/System/Volumes/Data/{System,Network,Library,Applications,.PreviousSystemInformation,.com.apple.templatemigration.boot-install}", "/Users/*/{Library,Applications}", + "/System/Volumes/Data/Users/*/{Library,Applications}", "**/*.photoslibrary/{database,external,private,resources,scope}", // Files that might appear in the root of a volume "**/.{DocumentRevisions-V100,fseventsd,Spotlight-V100,TemporaryItems,Trashes,VolumeIcon.icns,com.apple.timemachine.donotpresent}", @@ -160,7 +175,7 @@ fn no_os_protected() -> SystemIndexerRule { } } -fn no_hidden() -> SystemIndexerRule { +pub fn no_hidden() -> SystemIndexerRule { SystemIndexerRule { name: "No Hidden", default: true, diff --git a/core/src/location/manager/watcher/utils.rs b/core/src/location/manager/watcher/utils.rs index c3b1126c9..39be8456c 100644 --- a/core/src/location/manager/watcher/utils.rs +++ b/core/src/location/manager/watcher/utils.rs @@ -10,14 +10,12 @@ use crate::{ loose_find_existing_file_path_params, FilePathError, FilePathMetadata, IsolatedFilePathData, MetadataExt, }, - find_location, location_with_indexer_rules, + find_location, generate_thumbnail, location_with_indexer_rules, manager::LocationManagerError, scan_location_sub_path, }, object::{ - file_identifier::FileMetadata, - preview::{can_generate_thumbnail_for_image, generate_image_thumbnail, get_thumbnail_path}, - validation::hash::file_checksum, + file_identifier::FileMetadata, preview::get_thumbnail_path, validation::hash::file_checksum, }, prisma::{file_path, location, object}, util::{ @@ -38,12 +36,9 @@ use std::{ ffi::OsStr, fs::Metadata, path::{Path, PathBuf}, - str::FromStr, sync::Arc, }; -use sd_file_ext::extensions::ImageExtension; - use chrono::{DateTime, Local, Utc}; use notify::Event; use prisma_client_rust::{raw, PrismaValue}; @@ -51,7 +46,7 @@ use sd_prisma::prisma_sync; use sd_sync::OperationFactory; use serde_json::json; use tokio::{fs, io::ErrorKind}; -use tracing::{debug, error, trace, warn}; +use tracing::{debug, trace, warn}; use uuid::Uuid; use super::INodeAndDevice; @@ -738,53 +733,6 @@ pub(super) async fn remove_by_file_path( Ok(()) } -async fn generate_thumbnail( - extension: &str, - cas_id: &str, - path: impl AsRef, - node: &Arc, -) { - let path = path.as_ref(); - let output_path = get_thumbnail_path(node, cas_id); - - if let Err(e) = fs::metadata(&output_path).await { - if e.kind() != ErrorKind::NotFound { - error!( - "Failed to check if thumbnail exists, but we will try to generate it anyway: {e}" - ); - } - // Otherwise we good, thumbnail doesn't exist so we can generate it - } else { - debug!( - "Skipping thumbnail generation for {} because it already exists", - path.display() - ); - return; - } - - if let Ok(extension) = ImageExtension::from_str(extension) { - if can_generate_thumbnail_for_image(&extension) { - if let Err(e) = generate_image_thumbnail(path, &output_path).await { - error!("Failed to image thumbnail on location manager: {e:#?}"); - } - } - } - - #[cfg(feature = "ffmpeg")] - { - use crate::object::preview::{can_generate_thumbnail_for_video, generate_video_thumbnail}; - use sd_file_ext::extensions::VideoExtension; - - if let Ok(extension) = VideoExtension::from_str(extension) { - if can_generate_thumbnail_for_video(&extension) { - if let Err(e) = generate_video_thumbnail(path, &output_path).await { - error!("Failed to video thumbnail on location manager: {e:#?}"); - } - } - } - } -} - pub(super) async fn extract_inode_and_device_from_path( location_id: location::id::Type, path: impl AsRef, diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index b5b02d01d..a906672e5 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -1,11 +1,15 @@ use crate::{ + api::CoreEvent, invalidate_query, job::{JobBuilder, JobError, JobManagerError}, library::Library, location::file_path_helper::filter_existing_file_path_params, object::{ file_identifier::{self, file_identifier_job::FileIdentifierJobInit}, - preview::{shallow_thumbnailer, thumbnailer_job::ThumbnailerJobInit}, + preview::{ + can_generate_thumbnail_for_image, generate_image_thumbnail, get_thumb_key, + get_thumbnail_path, shallow_thumbnailer, thumbnailer_job::ThumbnailerJobInit, + }, }, prisma::{file_path, indexer_rules_in_location, location, PrismaClient}, util::error::FileIOError, @@ -15,9 +19,12 @@ use crate::{ use std::{ collections::HashSet, path::{Component, Path, PathBuf}, + str::FromStr, sync::Arc, }; +use sd_file_ext::extensions::ImageExtension; + use chrono::Utc; use futures::future::TryFutureExt; use normpath::PathExt; @@ -29,7 +36,7 @@ use serde::Deserialize; use serde_json::json; use specta::Type; use tokio::{fs, io}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; mod error; @@ -37,6 +44,7 @@ pub mod file_path_helper; pub mod indexer; mod manager; mod metadata; +pub mod non_indexed; pub use error::LocationError; use indexer::IndexerJobInit; @@ -510,17 +518,8 @@ pub struct CreatedLocationResult { pub data: location_with_indexer_rules::Data, } -async fn create_location( - library: &Arc, - location_pub_id: Uuid, - location_path: impl AsRef, - indexer_rules_ids: &[i32], - dry_run: bool, -) -> Result, LocationError> { - let Library { db, sync, .. } = &**library; - - let mut path = location_path.as_ref().to_path_buf(); - +pub(crate) fn normalize_path(path: impl AsRef) -> io::Result<(String, String)> { + let mut path = path.as_ref().to_path_buf(); let (location_path, normalized_path) = path // Normalize path and also check if it exists .normalize() @@ -542,8 +541,7 @@ async fn create_location( ))?, normalized_path, )) - }) - .map_err(|_| LocationError::DirectoryNotFound(path.clone()))?; + })?; // Not needed on Windows because the normalization already handles it if cfg!(not(windows)) { @@ -556,24 +554,6 @@ async fn create_location( } } - if library - .db - .location() - .count(vec![location::path::equals(Some(location_path.clone()))]) - .exec() - .await? > 0 - { - return Err(LocationError::LocationAlreadyExists(path)); - } - - if check_nested_location(&location_path, &library.db).await? { - return Err(LocationError::NestedLocation(path)); - } - - if dry_run { - return Ok(None); - } - // Use `to_string_lossy` because a partially corrupted but identifiable name is better than nothing let mut name = path.localize_name().to_string_lossy().to_string(); @@ -586,6 +566,43 @@ async fn create_location( name = "Unknown".to_string() } + Ok((location_path, name)) +} + +async fn create_location( + library: &Arc, + location_pub_id: Uuid, + location_path: impl AsRef, + indexer_rules_ids: &[i32], + dry_run: bool, +) -> Result, LocationError> { + let Library { db, sync, .. } = &**library; + + let (path, name) = normalize_path(&location_path) + .map_err(|_| LocationError::DirectoryNotFound(location_path.as_ref().to_path_buf()))?; + + if library + .db + .location() + .count(vec![location::path::equals(Some(path.clone()))]) + .exec() + .await? > 0 + { + return Err(LocationError::LocationAlreadyExists( + location_path.as_ref().to_path_buf(), + )); + } + + if check_nested_location(&location_path, &library.db).await? { + return Err(LocationError::NestedLocation( + location_path.as_ref().to_path_buf(), + )); + } + + if dry_run { + return Ok(None); + } + let date_created = Utc::now(); let location = sync @@ -598,7 +615,7 @@ async fn create_location( }, [ (location::name::NAME, json!(&name)), - (location::path::NAME, json!(&location_path)), + (location::path::NAME, json!(&path)), (location::date_created::NAME, json!(date_created)), ( location::instance::NAME, @@ -613,7 +630,7 @@ async fn create_location( location_pub_id.as_bytes().to_vec(), vec![ location::name::set(Some(name.clone())), - location::path::set(Some(location_path)), + location::path::set(Some(path)), location::date_created::set(Some(date_created.into())), location::instance_id::set(Some(library.config.instance_id)), // location::instance::connect(instance::id::equals( @@ -818,3 +835,55 @@ async fn check_nested_location( Ok(parents_count > 0 || is_a_child_location) } + +pub(super) async fn generate_thumbnail( + extension: &str, + cas_id: &str, + path: impl AsRef, + node: &Arc, +) { + let path = path.as_ref(); + let output_path = get_thumbnail_path(node, cas_id); + + if let Err(e) = fs::metadata(&output_path).await { + if e.kind() != io::ErrorKind::NotFound { + error!( + "Failed to check if thumbnail exists, but we will try to generate it anyway: {e}" + ); + } + // Otherwise we good, thumbnail doesn't exist so we can generate it + } else { + debug!( + "Skipping thumbnail generation for {} because it already exists", + path.display() + ); + return; + } + + if let Ok(extension) = ImageExtension::from_str(extension) { + if can_generate_thumbnail_for_image(&extension) { + if let Err(e) = generate_image_thumbnail(path, &output_path).await { + error!("Failed to image thumbnail on location manager: {e:#?}"); + } + } + } + + #[cfg(feature = "ffmpeg")] + { + use crate::object::preview::{can_generate_thumbnail_for_video, generate_video_thumbnail}; + use sd_file_ext::extensions::VideoExtension; + + if let Ok(extension) = VideoExtension::from_str(extension) { + if can_generate_thumbnail_for_video(&extension) { + if let Err(e) = generate_video_thumbnail(path, &output_path).await { + error!("Failed to video thumbnail on location manager: {e:#?}"); + } + } + } + } + + trace!("Emitting new thumbnail event"); + node.emit(CoreEvent::NewThumbnail { + thumb_key: get_thumb_key(cas_id), + }); +} diff --git a/core/src/location/non_indexed.rs b/core/src/location/non_indexed.rs new file mode 100644 index 000000000..101451e91 --- /dev/null +++ b/core/src/location/non_indexed.rs @@ -0,0 +1,246 @@ +use crate::{ + api::locations::ExplorerItem, + library::Library, + object::{cas::generate_cas_id, preview::get_thumb_key}, + prisma::location, + util::error::FileIOError, + Node, +}; + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use sd_file_ext::{extensions::Extension, kind::ObjectKind}; + +use chrono::{DateTime, Utc}; +use rspc::ErrorCode; +use sd_utils::chain_optional_iter; +use serde::Serialize; +use specta::Type; +use thiserror::Error; +use tokio::{fs, io}; +use tracing::{error, warn}; + +use super::{ + file_path_helper::MetadataExt, + generate_thumbnail, + indexer::rules::{ + seed::{no_hidden, no_os_protected}, + IndexerRule, RuleKind, + }, + normalize_path, +}; + +#[derive(Debug, Error)] +pub enum NonIndexedLocationError { + #[error("path not found: {}", .0.display())] + NotFound(PathBuf), + + #[error(transparent)] + FileIO(#[from] FileIOError), + + #[error("database error: {0}")] + Database(#[from] prisma_client_rust::QueryError), +} + +impl From for rspc::Error { + fn from(err: NonIndexedLocationError) -> Self { + match err { + NonIndexedLocationError::NotFound(_) => { + rspc::Error::with_cause(ErrorCode::NotFound, err.to_string(), err) + } + _ => rspc::Error::with_cause(ErrorCode::InternalServerError, err.to_string(), err), + } + } +} + +impl> From<(P, io::Error)> for NonIndexedLocationError { + fn from((path, source): (P, io::Error)) -> Self { + if source.kind() == io::ErrorKind::NotFound { + Self::NotFound(path.as_ref().into()) + } else { + Self::FileIO(FileIOError::from((path, source))) + } + } +} + +#[derive(Serialize, Type, Debug)] +pub struct NonIndexedFileSystemEntries { + pub entries: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Type, Debug)] +pub struct NonIndexedPathItem { + pub path: String, + pub name: String, + pub extension: String, + pub kind: i32, + pub is_dir: bool, + pub date_created: DateTime, + pub date_modified: DateTime, + pub size_in_bytes_bytes: Vec, +} + +pub async fn walk( + full_path: impl AsRef, + with_hidden_files: bool, + node: Arc, + library: Arc, +) -> Result { + let path = full_path.as_ref(); + let mut read_dir = fs::read_dir(path).await.map_err(|e| (path, e))?; + + let mut directories = vec![]; + let mut errors = vec![]; + let mut entries = vec![]; + + let rules = chain_optional_iter( + [IndexerRule::from(no_os_protected())], + [(!with_hidden_files).then(|| IndexerRule::from(no_hidden()))], + ); + + while let Some(entry) = read_dir.next_entry().await.map_err(|e| (path, e))? { + let Ok((entry_path, name)) = normalize_path(entry.path()) + .map_err(|e| errors.push(NonIndexedLocationError::from((path, e)).into())) else { + continue; + }; + + if let Ok(rule_results) = IndexerRule::apply_all(&rules, &entry_path) + .await + .map_err(|e| errors.push(e.into())) + { + // No OS Protected and No Hidden rules, must always be from this kind, should panic otherwise + if rule_results[&RuleKind::RejectFilesByGlob] + .iter() + .any(|reject| !reject) + { + continue; + } + } else { + continue; + } + + let Ok(metadata) = entry.metadata() + .await + .map_err(|e| errors.push(NonIndexedLocationError::from((path, e)).into())) + else { + continue; + }; + + if metadata.is_dir() { + directories.push((entry_path, name, metadata)); + } else { + let path = Path::new(&entry_path); + + let Some(name) = path.file_stem() + .and_then(|s| s.to_str().map(str::to_string)) + else { + warn!("Failed to extract name from path: {}", &entry_path); + continue; + }; + + let extension = path + .extension() + .and_then(|s| s.to_str().map(str::to_string)) + .unwrap_or("".to_string()); + + let kind = Extension::resolve_conflicting(&path, false) + .await + .map(Into::into) + .unwrap_or(ObjectKind::Unknown); + + let thumbnail_key = if matches!(kind, ObjectKind::Image | ObjectKind::Video) { + if let Ok(cas_id) = generate_cas_id(&entry_path, metadata.len()) + .await + .map_err(|e| errors.push(NonIndexedLocationError::from((path, e)).into())) + { + let thumbnail_key = get_thumb_key(&cas_id); + let entry_path = entry_path.clone(); + let extension = extension.clone(); + let inner_node = Arc::clone(&node); + let inner_cas_id = cas_id.clone(); + tokio::spawn(async move { + generate_thumbnail(&extension, &inner_cas_id, entry_path, &inner_node) + .await; + }); + + node.thumbnail_remover + .new_non_indexed_thumbnail(cas_id) + .await; + + Some(thumbnail_key) + } else { + None + } + } else { + None + }; + + entries.push(ExplorerItem::NonIndexedPath { + has_local_thumbnail: thumbnail_key.is_some(), + thumbnail_key, + item: NonIndexedPathItem { + path: entry_path, + name, + extension, + kind: kind as i32, + is_dir: false, + date_created: metadata.created_or_now().into(), + date_modified: metadata.modified_or_now().into(), + size_in_bytes_bytes: metadata.len().to_be_bytes().to_vec(), + }, + }); + } + } + + let mut locations = library + .db + .location() + .find_many(vec![location::path::in_vec( + directories + .iter() + .map(|(path, _, _)| path.clone()) + .collect(), + )]) + .exec() + .await? + .into_iter() + .flat_map(|location| { + location + .path + .clone() + .map(|location_path| (location_path, location)) + }) + .collect::>(); + + for (directory, name, metadata) in directories { + if let Some(location) = locations.remove(&directory) { + entries.push(ExplorerItem::Location { + has_local_thumbnail: false, + thumbnail_key: None, + item: location, + }); + } else { + entries.push(ExplorerItem::NonIndexedPath { + has_local_thumbnail: false, + thumbnail_key: None, + item: NonIndexedPathItem { + path: directory, + name, + extension: "".to_string(), + kind: ObjectKind::Folder as i32, + is_dir: true, + date_created: metadata.created_or_now().into(), + date_modified: metadata.modified_or_now().into(), + size_in_bytes_bytes: metadata.len().to_be_bytes().to_vec(), + }, + }); + } + } + + Ok(NonIndexedFileSystemEntries { entries, errors }) +} diff --git a/core/src/object/preview/thumbnail/mod.rs b/core/src/object/preview/thumbnail/mod.rs index 1b369e8bf..32187b785 100644 --- a/core/src/object/preview/thumbnail/mod.rs +++ b/core/src/object/preview/thumbnail/mod.rs @@ -155,6 +155,12 @@ pub async fn generate_image_thumbnail>( Ok(encoder.encode(THUMBNAIL_QUALITY).deref().to_owned()) })?; + fs::create_dir_all(output_path.as_ref().parent().ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot determine parent directory", + ))?) + .await?; + fs::write(output_path, &webp).await.map_err(Into::into) } diff --git a/core/src/preferences/kv.rs b/core/src/preferences/kv.rs index b42e1c8e8..7d8dece6c 100644 --- a/core/src/preferences/kv.rs +++ b/core/src/preferences/kv.rs @@ -155,8 +155,6 @@ impl PreferenceKVs { acc }); - dbg!(&entries); - T::from_entries(entries) } } diff --git a/core/src/volume/mod.rs b/core/src/volume/mod.rs index 0594774de..0a2ef5951 100644 --- a/core/src/volume/mod.rs +++ b/core/src/volume/mod.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use specta::Type; -use std::{ffi::OsString, fmt::Display, path::PathBuf, sync::OnceLock}; +use std::{fmt::Display, path::PathBuf, sync::OnceLock}; use sysinfo::{DiskExt, System, SystemExt}; use thiserror::Error; use tokio::sync::Mutex; @@ -35,7 +35,7 @@ impl Display for DiskType { #[serde_as] #[derive(Serialize, Deserialize, Debug, Clone, Type)] pub struct Volume { - pub name: OsString, + pub name: String, pub mount_points: Vec, #[specta(type = String)] #[serde_as(as = "DisplayFromStr")] @@ -124,8 +124,15 @@ pub async fn get_volumes() -> Vec { .expect("Volume index is present so the Volume must be present too"); // Update mount point if not already present - if volume.mount_points.iter().all(|p| p != &mount_point) { - volume.mount_points.push(mount_point); + let mount_points = &mut volume.mount_points; + if mount_point.iter().all(|p| p != &mount_point) { + mount_points.push(mount_point); + let mount_points_to_check = mount_points.clone(); + mount_points.retain(|candidate| { + !mount_points_to_check + .iter() + .any(|path| candidate.starts_with(path) && candidate != path) + }); if !volume.is_root_filesystem { volume.is_root_filesystem = is_root_filesystem; } @@ -147,8 +154,13 @@ pub async fn get_volumes() -> Vec { // Assign volume to disk path path_to_volume_index.insert(disk_path.into_os_string(), volumes.len()); + let mut name = disk_name.to_string_lossy().to_string(); + if name.replace(char::REPLACEMENT_CHARACTER, "") == "" { + name = "Unknown".to_string() + } + volumes.push(Volume { - name: disk_name.to_os_string(), + name, disk_type: if disk.is_removable() { DiskType::Removable } else { @@ -232,9 +244,20 @@ pub async fn get_volumes() -> Vec { }); future::join_all(sys.disks().iter().map(|disk| async { + #[cfg(not(windows))] let disk_name = disk.name(); let mount_point = disk.mount_point().to_path_buf(); + #[cfg(windows)] + let Ok((disk_name, mount_point)) = ({ + use normpath::PathExt; + mount_point.normalize_virtually().map(|p| { + (p.localize_name().to_os_string(), p.into_path_buf()) + }) + }) else { + return None; + }; + #[cfg(target_os = "macos")] { // Ignore mounted DMGs @@ -301,8 +324,13 @@ pub async fn get_volumes() -> Vec { } } + let mut name = disk_name.to_string_lossy().to_string(); + if name.replace(char::REPLACEMENT_CHARACTER, "") == "" { + name = "Unknown".to_string() + } + Some(Volume { - name: disk_name.to_os_string(), + name, disk_type: if disk.is_removable() { DiskType::Removable } else { diff --git a/crates/ffmpeg/src/thumbnailer.rs b/crates/ffmpeg/src/thumbnailer.rs index a10327cb4..0c634f61f 100644 --- a/crates/ffmpeg/src/thumbnailer.rs +++ b/crates/ffmpeg/src/thumbnailer.rs @@ -1,6 +1,6 @@ use crate::{film_strip_filter, MovieDecoder, ThumbnailSize, ThumbnailerError, VideoFrame}; -use std::{ops::Deref, path::Path}; +use std::{io, ops::Deref, path::Path}; use tokio::{fs, task::spawn_blocking}; use tracing::error; use webp::Encoder; @@ -19,6 +19,17 @@ impl Thumbnailer { video_file_path: impl AsRef, output_thumbnail_path: impl AsRef, ) -> Result<(), ThumbnailerError> { + fs::create_dir_all( + output_thumbnail_path + .as_ref() + .parent() + .ok_or(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot determine parent directory", + ))?, + ) + .await?; + fs::write( output_thumbnail_path, &*self.process_to_webp_bytes(video_file_path).await?, diff --git a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx index eacf2089d..7091f09df 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/Object/Items.tsx @@ -1,6 +1,6 @@ import { ArrowBendUpRight, TagSimple } from 'phosphor-react'; import { useMemo } from 'react'; -import { ObjectKind, useLibraryMutation } from '@sd/client'; +import { ObjectKind, type ObjectKindEnum, useLibraryMutation } from '@sd/client'; import { ContextMenu } from '@sd/ui'; import { showAlertDialog } from '~/components'; import AssignTagMenuItems from '~/components/AssignTagMenuItems'; @@ -66,7 +66,7 @@ export const ConvertObject = new ConditionalItem({ const { selectedObjects } = useContextMenuContext(); const kinds = useMemo(() => { - const set = new Set(); + const set = new Set(); for (const o of selectedObjects) { if (o.kind === null || !ConvertableKinds.includes(o.kind)) break; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx index be61353e8..dcd8c706e 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/SharedItems.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { ContextMenu, ModifierKeys } from '@sd/ui'; import { useKeybindFactory } from '~/hooks/useKeybindFactory'; import { isNonEmpty } from '~/util'; -import { Platform } from '~/util/Platform'; +import { type Platform } from '~/util/Platform'; import { useExplorerContext } from '../Context'; import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer'; import { useExplorerViewContext } from '../ViewContext'; @@ -54,7 +54,12 @@ export const Rename = new ConditionalItem({ const settings = useExplorerContext().useSettingsSnapshot(); - if (settings.layoutMode === 'media' || selectedItems.length > 1) return null; + if ( + settings.layoutMode === 'media' || + selectedItems.length > 1 || + selectedItems.some((item) => item.type === 'NonIndexedPath') + ) + return null; return {}; }, diff --git a/interface/app/$libraryId/Explorer/ContextMenu/index.tsx b/interface/app/$libraryId/Explorer/ContextMenu/index.tsx index 8060a2d70..68affde7f 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/index.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/index.tsx @@ -1,9 +1,9 @@ import { Plus } from 'phosphor-react'; -import { ReactNode, useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { ContextMenu } from '@sd/ui'; import { isNonEmpty } from '~/util'; import { useExplorerContext } from '../Context'; -import { Conditional, ConditionalGroupProps } from './ConditionalItem'; +import { Conditional, type ConditionalGroupProps } from './ConditionalItem'; import * as FilePathItems from './FilePath/Items'; import * as ObjectItems from './Object/Items'; import * as SharedItems from './SharedItems'; @@ -20,8 +20,7 @@ const Items = ({ children }: { children?: () => ReactNode }) => ( - - & { - itemId: number; + itemId?: null | number; locationId: number | null; text: string | null; activeClassName?: string; @@ -76,13 +76,10 @@ export const RenameTextBoxBase = forwardRef( if (!ref?.current) return; const newName = ref?.current.innerText.trim(); - if (!newName) return reset(); - - if (!locationId) return; + if (!(newName && locationId)) return reset(); const oldName = text; - - if (!oldName || !locationId || newName === oldName) return; + if (!oldName || newName === oldName) return; await renameHandler(newName); } @@ -155,15 +152,16 @@ export const RenameTextBoxBase = forwardRef( }); useEffect(() => { + const elem = ref.current; const scroll = (e: WheelEvent) => { if (allowRename) { e.preventDefault(); - if (ref.current) ref.current.scrollTop += e.deltaY; + if (elem) elem.scrollTop += e.deltaY; } }; - ref.current?.addEventListener('wheel', scroll); - return () => ref.current?.removeEventListener('wheel', scroll); + elem?.addEventListener('wheel', scroll); + return () => elem?.removeEventListener('wheel', scroll); }, [allowRename]); return ( @@ -230,7 +228,11 @@ export const RenamePathTextBox = ({ // Handle renaming async function rename(newName: string) { - if (!props.locationId || newName === fileName) return; + // TODO: Warn user on rename fails + if (!props.locationId || !props.itemId || newName === fileName) { + reset(); + return; + } try { await renameFile.mutateAsync({ location_id: props.locationId, @@ -242,6 +244,7 @@ export const RenamePathTextBox = ({ } }); } catch (e) { + reset(); showAlertDialog({ title: 'Error', value: `Could not rename ${fileName} to ${newName}, due to an error: ${e}` @@ -270,7 +273,10 @@ export const RenameLocationTextBox = (props: Omit) => { // Handle renaming async function rename(newName: string) { - if (!props.locationId) return; + if (!props.locationId) { + reset(); + return; + } try { await renameLocation.mutateAsync({ id: props.locationId, @@ -281,6 +287,7 @@ export const RenameLocationTextBox = (props: Omit) => { indexer_rules_ids: [] }); } catch (e) { + reset(); showAlertDialog({ title: 'Error', value: String(e) diff --git a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx index 1320d80b8..e4dfd5252 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx @@ -1,10 +1,10 @@ import { getIcon, iconNames } from '@sd/assets/util'; import clsx from 'clsx'; import { - CSSProperties, - ImgHTMLAttributes, - RefObject, - VideoHTMLAttributes, + type CSSProperties, + type ImgHTMLAttributes, + type RefObject, + type VideoHTMLAttributes, memo, useEffect, useLayoutEffect, @@ -12,7 +12,7 @@ import { useRef, useState } from 'react'; -import { ExplorerItem, getItemFilePath, useLibraryContext } from '@sd/client'; +import { type ExplorerItem, getItemFilePath, useLibraryContext } from '@sd/client'; import { PDFViewer, TEXTViewer } from '~/components'; import { useCallbackToWatchResize, useIsDark } from '~/hooks'; import { usePlatform } from '~/util/Platform'; @@ -23,13 +23,11 @@ import { useExplorerItemData } from '../util'; import LayeredFileIcon from './LayeredFileIcon'; import classes from './Thumb.module.scss'; -const THUMB_TYPE = { - ICON: 'icon', - ORIGINAL: 'original', - THUMBNAIL: 'thumbnail' -} as const; - -type ThumbType = (typeof THUMB_TYPE)[keyof typeof THUMB_TYPE]; +export const enum ThumbType { + Icon = 'ICON', + Original = 'ORIGINAL', + Thumbnail = 'THUMBNAIL' +} export interface ThumbProps { data: ExplorerItem; @@ -43,7 +41,7 @@ export interface ThumbProps { mediaControls?: boolean; pauseVideo?: boolean; className?: string; - childClassName?: string | ((type: ThumbType) => string | undefined); + childClassName?: string | ((type: ThumbType | `${ThumbType}`) => string | undefined); } export const FileThumb = memo((props: ThumbProps) => { @@ -58,7 +56,7 @@ export const FileThumb = memo((props: ThumbProps) => { const [src, setSrc] = useState(); const [loaded, setLoaded] = useState(false); - const [thumbType, setThumbType] = useState('icon'); + const [thumbType, setThumbType] = useState(ThumbType.Icon); const childClassName = 'max-h-full max-w-full object-contain'; const frameClassName = clsx( @@ -71,7 +69,9 @@ export const FileThumb = memo((props: ThumbProps) => { const onError = () => { setLoaded(false); setThumbType((prevThumbType) => - prevThumbType === 'original' && itemData.hasLocalThumbnail ? 'thumbnail' : 'icon' + prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail + ? ThumbType.Thumbnail + : ThumbType.Icon ); }; @@ -82,9 +82,13 @@ export const FileThumb = memo((props: ThumbProps) => { setSrc(undefined); setLoaded(false); - if (props.loadOriginal) setThumbType('original'); - else if (itemData.hasLocalThumbnail) setThumbType('thumbnail'); - else setThumbType('icon'); + if (props.loadOriginal) { + setThumbType(ThumbType.Original); + } else if (itemData.hasLocalThumbnail) { + setThumbType(ThumbType.Thumbnail); + } else { + setThumbType(ThumbType.Icon); + } }, [props.loadOriginal, itemData]); useEffect(() => { @@ -92,24 +96,33 @@ export const FileThumb = memo((props: ThumbProps) => { itemData.locationId ?? (parent?.type === 'Location' ? parent.location.id : null); switch (thumbType) { - case 'original': - if (locationId === null) setThumbType('thumbnail'); - else { + case ThumbType.Original: + if ( + locationId && + filePath && + 'id' in filePath && + (itemData.extension !== 'pdf' || pdfViewerEnabled()) + ) { setSrc( platform.getFileUrl( library.uuid, locationId, - filePath?.id || props.data.item.id, + filePath.id, // Workaround Linux webview not supporting playing video and audio through custom protocol urls - itemData.kind == 'Video' || itemData.kind == 'Audio' + itemData.kind === 'Video' || itemData.kind === 'Audio' ) ); + } else { + setThumbType(ThumbType.Thumbnail); } break; - case 'thumbnail': - if (!itemData.casId || !itemData.thumbnailKey) setThumbType('icon'); - else setSrc(platform.getThumbnailUrlByThumbKey(itemData.thumbnailKey)); + case ThumbType.Thumbnail: + if (itemData.thumbnailKey) { + setSrc(platform.getThumbnailUrlByThumbKey(itemData.thumbnailKey)); + } else { + setThumbType(ThumbType.Icon); + } break; default: @@ -123,16 +136,7 @@ export const FileThumb = memo((props: ThumbProps) => { ); break; } - }, [ - props.data.item.id, - filePath?.id, - isDark, - library.uuid, - itemData, - platform, - thumbType, - parent - ]); + }, [props.data.item, filePath, isDark, library.uuid, itemData, platform, thumbType, parent]); return (
{ ); switch (thumbType) { - case 'original': { + case ThumbType.Original: { switch (itemData.extension === 'pdf' ? 'PDF' : itemData.kind) { case 'PDF': - if (!pdfViewerEnabled()) return; return ( { crossOrigin="anonymous" // Here it is ok, because it is not a react attr /> ); - case 'Text': return ( { } // eslint-disable-next-line no-fallthrough - case 'thumbnail': + case ThumbType.Thumbnail: return ( { props.cover ? 'min-h-full min-w-full object-cover object-center' : className, - - props.frame && (itemData.kind !== 'Video' || !props.blackBars) + props.frame && !(itemData.kind === 'Video' && props.blackBars) ? frameClassName : null )} - crossOrigin={thumbType !== 'original' ? 'anonymous' : undefined} // Here it is ok, because it is not a react attr + crossOrigin={ + thumbType !== ThumbType.Original ? 'anonymous' : undefined + } // Here it is ok, because it is not a react attr blackBars={ props.blackBars && itemData.kind === 'Video' && !props.cover } @@ -317,6 +320,7 @@ const Thumbnail = memo( const ref = useRef(null); const size = useSize(ref); + const { style: blackBarsStyle } = useBlackBars(size, blackBarsSize); return ( diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index eb0f2073f..06cd1353a 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -15,12 +15,18 @@ import { Path, Snowflake } from 'phosphor-react'; -import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { - ExplorerItem, - ObjectKind, + type HTMLAttributes, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; +import { + type ExplorerItem, byteSize, - bytesToNumber, + getExplorerItemData, getItemFilePath, getItemObject, useItemsAsObjects, @@ -30,10 +36,10 @@ import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui'; import AssignTagMenuItems from '~/components/AssignTagMenuItems'; import { useIsDark } from '~/hooks'; import { isNonEmpty } from '~/util'; -import { stringify } from '~/util/uuid'; import { useExplorerContext } from '../Context'; import { FileThumb } from '../FilePath/Thumb'; import { useExplorerStore } from '../store'; +import { uniqueId, useExplorerItemData } from '../util'; import FavoriteButton from './FavoriteButton'; import Note from './Note'; @@ -43,7 +49,22 @@ export const PlaceholderPill = tw.span`inline border px-1 text-[11px] shadow sha export const MetaContainer = tw.div`flex flex-col px-4 py-2 gap-1`; export const MetaTitle = tw.h5`text-xs font-bold`; +type MetadataDate = Date | { from: Date; to: Date } | null; + const DATE_FORMAT = 'D MMM YYYY'; +const formatDate = (date: MetadataDate | string | undefined) => { + if (!date) return; + if (date instanceof Date || typeof date === 'string') return dayjs(date).format(DATE_FORMAT); + + const { from, to } = date; + + const sameMonth = from.getMonth() === to.getMonth(); + const sameYear = from.getFullYear() === to.getFullYear(); + + const format = ['D', !sameMonth && 'MMM', !sameYear && 'YYYY'].filter(Boolean).join(' '); + + return `${dayjs(from).format(format)} - ${dayjs(to).format(DATE_FORMAT)}`; +}; interface Props extends HTMLAttributes { showThumbnail?: boolean; @@ -92,7 +113,7 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => { <> {lastThreeItems.map((item, i, thumbs) => ( { i === 2 && 'z-10 !h-[84%] !w-[84%] rotate-[7deg]' )} childClassName={(type) => - type !== 'icon' && thumbs.length > 1 + type !== 'ICON' && thumbs.length > 1 ? 'shadow-md shadow-app-shade' : undefined } @@ -118,37 +139,48 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => { }; const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { - const filePathData = getItemFilePath(item); const objectData = getItemObject(item); - - const isDir = item.type === 'Path' && item.item.is_dir; - const readyToFetch = useIsFetchReady(item); + const isNonIndexed = item.type === 'NonIndexedPath'; - const tags = useLibraryQuery(['tags.getForObject', objectData?.id || -1], { - enabled: readyToFetch && !!objectData + const tags = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { + enabled: !!objectData && readyToFetch }); - const object = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], { - enabled: readyToFetch && !!objectData + const object = useLibraryQuery(['files.get', { id: objectData?.id ?? -1 }], { + enabled: !!objectData && readyToFetch }); - const fileFullPath = useLibraryQuery(['files.getPath', filePathData?.id || -1], { - enabled: readyToFetch && !!filePathData + let { data: fileFullPath } = useLibraryQuery(['files.getPath', objectData?.id ?? -1], { + enabled: !!objectData && readyToFetch }); - const pubId = useMemo( - () => (object?.data?.pub_id ? stringify(object.data.pub_id) : null), - [object?.data?.pub_id] - ); + if (fileFullPath == null) { + switch (item.type) { + case 'Location': + case 'NonIndexedPath': + fileFullPath = item.item.path; + } + } - const formatDate = (date: string | null | undefined) => date && dayjs(date).format(DATE_FORMAT); + const { name, isDir, kind, size, casId, dateCreated, dateAccessed, dateModified, dateIndexed } = + useExplorerItemData(item); + + const pubId = object?.data ? uniqueId(object?.data) : null; + + let extension, integrityChecksum; + const filePathItem = getItemFilePath(item); + if (filePathItem) { + extension = 'extension' in filePathItem ? filePathItem.extension : null; + integrityChecksum = + 'integrity_checksum' in filePathItem ? filePathItem.integrity_checksum : null; + } return ( <>

- {filePathData?.name} - {filePathData?.extension && `.${filePathData.extension}`} + {name} + {extension && `.${extension}`}

{objectData && ( @@ -173,38 +205,27 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { - + - + - + - - + {isNonIndexed || ( + + )} + + {isNonIndexed || ( + + )} { // TODO: Add toast notification - fileFullPath.data && navigator.clipboard.writeText(fileFullPath.data); + fileFullPath && navigator.clipboard.writeText(fileFullPath); }} /> @@ -212,9 +233,9 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { - {isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]} + {isDir ? 'Folder' : kind} - {filePathData?.extension && {filePathData.extension}} + {extension && {extension}} {tags.data?.map((tag) => ( @@ -246,21 +267,19 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { - + {isNonIndexed || ( + + )} - {filePathData?.integrity_checksum && ( + {integrityChecksum && ( )} - + {isNonIndexed || } )} @@ -268,8 +287,6 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { ); }; -type MetadataDate = Date | { from: Date; to: Date } | null; - const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { const explorerStore = useExplorerStore(); @@ -287,21 +304,6 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { { enabled: readyToFetch && !explorerStore.isDragging } ); - const formatDate = (metadataDate: MetadataDate) => { - if (!metadataDate) return; - - if (metadataDate instanceof Date) return dayjs(metadataDate).format(DATE_FORMAT); - - const { from, to } = metadataDate; - - const sameMonth = from.getMonth() === to.getMonth(); - const sameYear = from.getFullYear() === to.getFullYear(); - - const format = ['D', !sameMonth && 'MMM', !sameYear && 'YYYY'].filter(Boolean).join(' '); - - return `${dayjs(from).format(format)} - ${dayjs(to).format(DATE_FORMAT)}`; - }; - const getDate = useCallback((metadataDate: MetadataDate, date: Date) => { date.setHours(0, 0, 0, 0); @@ -322,78 +324,62 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { () => items.reduce( (metadata, item) => { - const filePathData = getItemFilePath(item); - const objectData = getItemObject(item); + const { kind, size, dateCreated, dateAccessed, dateModified, dateIndexed } = + getExplorerItemData(item); - if (filePathData?.size_in_bytes_bytes) { - metadata.size += bytesToNumber(filePathData.size_in_bytes_bytes); - } + metadata.size += size.original; - if (filePathData?.date_created) { - metadata.created = getDate( - metadata.created, - new Date(filePathData.date_created) - ); - } + if (dateCreated) + metadata.created = getDate(metadata.created, new Date(dateCreated)); - if (filePathData?.date_modified) { - metadata.modified = getDate( - metadata.modified, - new Date(filePathData.date_modified) - ); - } + if (dateModified) + metadata.modified = getDate(metadata.modified, new Date(dateModified)); - if (filePathData?.date_indexed) { - metadata.indexed = getDate( - metadata.indexed, - new Date(filePathData.date_indexed) - ); - } + if (dateIndexed) + metadata.indexed = getDate(metadata.indexed, new Date(dateIndexed)); - if (objectData?.date_accessed) { - metadata.accessed = getDate( - metadata.accessed, - new Date(objectData.date_accessed) - ); - } + if (dateAccessed) + metadata.accessed = getDate(metadata.accessed, new Date(dateAccessed)); - const kind = - item.type === 'Path' && item.item.is_dir - ? 'Folder' - : ObjectKind[objectData?.kind || 0]; + metadata.types.add(item.type); - if (kind) { - const kindItems = metadata.kinds.get(kind); - if (!kindItems) metadata.kinds.set(kind, [item]); - else metadata.kinds.set(kind, [...kindItems, item]); - } + const kindItems = metadata.kinds.get(kind); + if (!kindItems) metadata.kinds.set(kind, [item]); + else metadata.kinds.set(kind, [...kindItems, item]); return metadata; }, - { size: BigInt(0), indexed: null, kinds: new Map() } as { + { size: BigInt(0), indexed: null, types: new Set(), kinds: new Map() } as { size: bigint; created: MetadataDate; modified: MetadataDate; indexed: MetadataDate; accessed: MetadataDate; + types: Set; kinds: Map; } ), [items, getDate] ); + const onlyNonIndexed = metadata.types.has('NonIndexedPath') && metadata.types.size === 1; + return ( <> - - + {onlyNonIndexed || ( + + )} + {onlyNonIndexed || ( + + )} diff --git a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx index cf7e70b26..931752099 100644 --- a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx +++ b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx @@ -3,7 +3,7 @@ import { animated, useTransition } from '@react-spring/web'; import { X } from 'phosphor-react'; import { useEffect, useState } from 'react'; import { subscribeKey } from 'valtio/utils'; -import { type ExplorerItem } from '@sd/client'; +import { type ExplorerItem, getExplorerItemData } from '@sd/client'; import { Button } from '@sd/ui'; import { FileThumb } from '../FilePath/Thumb'; import { getExplorerStore } from '../store'; @@ -65,7 +65,7 @@ export function QuickPreview({ transformOrigin }: QuickPreviewProps) { {transitions((styles, show) => { if (!show || explorerItem == null) return null; - const { item } = explorerItem; + const { name } = getExplorerItemData(explorerItem); return ( <> @@ -104,9 +104,7 @@ export function QuickPreview({ transformOrigin }: QuickPreviewProps) { Preview -{' '} - {'name' in item && item.name - ? item.name - : 'Unknown Object'} + {name || 'Unknown Object'} diff --git a/interface/app/$libraryId/Explorer/View/GridList.tsx b/interface/app/$libraryId/Explorer/View/GridList.tsx index 3764919ff..7f2e3f14e 100644 --- a/interface/app/$libraryId/Explorer/View/GridList.tsx +++ b/interface/app/$libraryId/Explorer/View/GridList.tsx @@ -1,18 +1,26 @@ -import { ReactNode, createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState +} from 'react'; import Selecto from 'react-selecto'; import { useKey } from 'rooks'; -import { ExplorerItem } from '@sd/client'; +import { type ExplorerItem } from '@sd/client'; import { GridList, useGridList } from '~/components'; import { useOperatingSystem } from '~/hooks'; import { useExplorerContext } from '../Context'; import { useExplorerViewContext } from '../ViewContext'; import { getExplorerStore, isCut, useExplorerStore } from '../store'; -import { ExplorerItemHash } from '../useExplorer'; -import { explorerItemHash } from '../util'; +import { uniqueId } from '../util'; const SelectoContext = createContext<{ selecto: React.RefObject; - selectoUnSelected: React.MutableRefObject>; + selectoUnSelected: React.MutableRefObject>; } | null>(null); type RenderItem = (item: { item: ExplorerItem; selected: boolean; cut: boolean }) => ReactNode; @@ -24,11 +32,12 @@ const GridListItem = (props: { onMouseDown: (e: React.MouseEvent) => void; }) => { const explorer = useExplorerContext(); + const explorerStore = useExplorerStore(); const explorerView = useExplorerViewContext(); const selecto = useContext(SelectoContext); - const cut = isCut(props.item.item.id); + const cut = isCut(props.item, explorerStore.cutCopyState); const selected = useMemo( // Even though this checks object equality, it should still be safe since `selectedItems` @@ -37,21 +46,21 @@ const GridListItem = (props: { [explorer.selectedItems, props.item] ); - const hash = explorerItemHash(props.item); + const itemId = uniqueId(props.item); useEffect(() => { - if (!selecto?.selecto.current || !selecto.selectoUnSelected.current.has(hash)) return; + if (!selecto?.selecto.current || !selecto.selectoUnSelected.current.has(itemId)) return; if (!selected) { - selecto.selectoUnSelected.current.delete(hash); + selecto.selectoUnSelected.current.delete(itemId); return; } - const element = document.querySelector(`[data-selectable-id="${hash}"]`); + const element = document.querySelector(`[data-selectable-id="${itemId}"]`); if (!element) return; - selecto.selectoUnSelected.current.delete(hash); + selecto.selectoUnSelected.current.delete(itemId); selecto.selecto.current.setSelectedTargets([ ...selecto.selecto.current.getSelectedTargets(), element as HTMLElement @@ -64,8 +73,8 @@ const GridListItem = (props: { if (!selecto) return; return () => { - const element = document.querySelector(`[data-selectable-id="${hash}"]`); - if (selected && !element) selecto.selectoUnSelected.current.add(hash); + const element = document.querySelector(`[data-selectable-id="${itemId}"]`); + if (selected && !element) selecto.selectoUnSelected.current.add(itemId); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -76,7 +85,7 @@ const GridListItem = (props: { className="h-full w-full" data-selectable="" data-selectable-index={props.index} - data-selectable-id={hash} + data-selectable-id={itemId} onMouseDown={props.onMouseDown} onContextMenu={(e) => { if (explorerView.selectable && !explorer.selectedItems.has(props.item)) { @@ -103,7 +112,7 @@ export default ({ children }: { children: RenderItem }) => { const explorerView = useExplorerViewContext(); const selecto = useRef(null); - const selectoUnSelected = useRef>(new Set()); + const selectoUnSelected = useRef>(new Set()); const selectoFirstColumn = useRef(); const selectoLastColumn = useRef(); @@ -123,11 +132,14 @@ export default ({ children }: { children: RenderItem }) => { ? { width: settings.gridItemSize, height: itemHeight } : undefined, columns: settings.layoutMode === 'media' ? settings.mediaColumns : undefined, - getItemId: (index) => { - const item = explorer.items?.[index]; - return item ? explorerItemHash(item) : undefined; - }, - getItemData: (index) => explorer.items?.[index], + getItemId: useCallback( + (index: number) => { + const item = explorer.items?.[index]; + return item ? uniqueId(item) : undefined; + }, + [explorer.items] + ), + getItemData: useCallback((index: number) => explorer.items?.[index], [explorer.items]), padding: explorerView.padding || settings.layoutMode === 'grid' ? 12 : undefined, gap: explorerView.gap || @@ -136,7 +148,7 @@ export default ({ children }: { children: RenderItem }) => { }); function getElementId(element: Element) { - return element.getAttribute('data-selectable-id') as ExplorerItemHash | null; + return element.getAttribute('data-selectable-id'); } function getElementIndex(element: Element) { @@ -244,7 +256,7 @@ export default ({ children }: { children: RenderItem }) => { if (!explorer.allowMultiSelect) explorer.resetSelectedItems([newSelectedItem.data]); else { const selectedItemDom = document.querySelector( - `[data-selectable-id="${explorerItemHash(newSelectedItem.data)}"]` + `[data-selectable-id="${uniqueId(newSelectedItem.data)}"]` ); if (!selectedItemDom) return; @@ -388,7 +400,7 @@ export default ({ children }: { children: RenderItem }) => { if (e.added[0]) explorer.addSelectedItem(item.data); else explorer.removeSelectedItem(item.data); } else if (inputEvent.type === 'mousemove') { - const unselectedItems: ExplorerItemHash[] = []; + const unselectedItems: string[] = []; e.added.forEach((el) => { const item = getElementItem(el); @@ -522,7 +534,7 @@ export default ({ children }: { children: RenderItem }) => { explorer.addSelectedItem(item); if (inDragArea) unselectedItems.push( - explorerItemHash(item) + uniqueId(item) ); } } else if (!inDragArea) @@ -530,9 +542,7 @@ export default ({ children }: { children: RenderItem }) => { else { explorer.addSelectedItem(item); if (inDragArea) - unselectedItems.push( - explorerItemHash(item) - ); + unselectedItems.push(uniqueId(item)); } } }); diff --git a/interface/app/$libraryId/Explorer/View/GridView.tsx b/interface/app/$libraryId/Explorer/View/GridView.tsx index b2b85aa1e..b29657a8c 100644 --- a/interface/app/$libraryId/Explorer/View/GridView.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView.tsx @@ -1,11 +1,10 @@ import clsx from 'clsx'; import { memo } from 'react'; -import { ExplorerItem, byteSize, getItemFilePath, getItemLocation } from '@sd/client'; +import { type ExplorerItem, byteSize, getItemFilePath, getItemLocation } from '@sd/client'; import { ViewItem } from '.'; import { useExplorerContext } from '../Context'; import { FileThumb } from '../FilePath/Thumb'; import { useExplorerViewContext } from '../ViewContext'; -import { useExplorerStore } from '../store'; import GridList from './GridList'; import RenamableItemText from './RenamableItemText'; diff --git a/interface/app/$libraryId/Explorer/View/ListView.tsx b/interface/app/$libraryId/Explorer/View/ListView.tsx index a848c3b70..2eff5de06 100644 --- a/interface/app/$libraryId/Explorer/View/ListView.tsx +++ b/interface/app/$libraryId/Explorer/View/ListView.tsx @@ -1,7 +1,7 @@ import { - ColumnDef, - ColumnSizingState, - Row, + type ColumnDef, + type ColumnSizingState, + type Row, flexRender, getCoreRowModel, useReactTable @@ -10,21 +10,19 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { CaretDown, CaretUp } from 'phosphor-react'; -import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; import { useKey, useMutationObserver, useWindowEventListener } from 'rooks'; import useResizeObserver from 'use-resize-observer'; import { - ExplorerItem, - ExplorerSettings, - FilePath, - ObjectKind, + type ExplorerItem, + type FilePath, + type NonIndexedPathItem, byteSize, getExplorerItemData, getItemFilePath, getItemLocation, - getItemObject, - isPath + getItemObject } from '@sd/client'; import { Tooltip } from '@sd/ui'; import { useIsTextTruncated, useScrolled } from '~/hooks'; @@ -35,10 +33,9 @@ import { useExplorerContext } from '../Context'; import { FileThumb } from '../FilePath/Thumb'; import { InfoPill } from '../Inspector'; import { useExplorerViewContext } from '../ViewContext'; -import { createOrdering, getOrderingDirection, orderingKey } from '../store'; +import { createOrdering, getOrderingDirection, orderingKey, useExplorerStore } from '../store'; import { isCut } from '../store'; -import { ExplorerItemHash } from '../useExplorer'; -import { explorerItemHash } from '../util'; +import { uniqueId } from '../util'; import RenamableItemText from './RenamableItemText'; interface ListViewItemProps { @@ -89,10 +86,11 @@ const HeaderColumnName = ({ name }: { name: string }) => { ); }; -type Range = [ExplorerItemHash, ExplorerItemHash]; +type Range = [string, string]; export default () => { const explorer = useExplorerContext(); + const explorerStore = useExplorerStore(); const settings = explorer.useSettingsSnapshot(); const explorerView = useExplorerViewContext(); const layout = useLayoutContext(); @@ -131,7 +129,8 @@ export default () => { const { width: tableWidth = 0 } = useResizeObserver({ ref: tableRef }); const { width: headerWidth = 0 } = useResizeObserver({ ref: tableHeaderRef }); - const getFileName = (path: FilePath) => `${path.name}${path.extension && `.${path.extension}`}`; + const getFileName = (path: FilePath | NonIndexedPathItem) => + `${path.name}${path.extension && `.${path.extension}`}`; useEffect(() => { //we need this to trigger a re-render with the updated column sizes from the store @@ -161,7 +160,7 @@ export default () => { const selected = explorer.selectedItems.has(cell.row.original); - const cut = isCut(item.item.id); + const cut = isCut(item, explorerStore.cutCopyState); return (
@@ -189,21 +188,12 @@ export default () => { header: 'Type', size: settings.colSizes['kind'], enableSorting: false, - accessorFn: (file) => { - return isPath(file) && file.item.is_dir - ? 'Folder' - : ObjectKind[getItemObject(file)?.kind || 0]; - }, - cell: (cell) => { - const file = cell.row.original; - return ( - - {isPath(file) && file.item.is_dir - ? 'Folder' - : ObjectKind[getItemObject(file)?.kind || 0]} - - ); - } + accessorFn: (file) => getExplorerItemData(file).kind, + cell: (cell) => ( + + {getExplorerItemData(cell.row.original).kind} + + ) }, { id: 'sizeInBytes', @@ -232,8 +222,12 @@ export default () => { { id: 'dateIndexed', header: 'Date Indexed', - accessorFn: (file) => - dayjs(getItemFilePath(file)?.date_indexed).format('MMM Do YYYY') + accessorFn: (file) => { + const item = getItemFilePath(file); + return dayjs( + (item && 'date_indexed' in item && item.date_indexed) || null + ).format('MMM Do YYYY'); + } }, { id: 'dateAccessed', @@ -262,27 +256,27 @@ export default () => { } } ], - [explorer.selectedItems, settings.colSizes] + [explorer.selectedItems, settings.colSizes, explorerStore.cutCopyState] ); const table = useReactTable({ - data: explorer.items || [], + data: explorer.items ?? [], columns, defaultColumn: { minSize: 100, maxSize: 250 }, state: { columnSizing }, onColumnSizingChange: setColumnSizing, columnResizeMode: 'onChange', - getCoreRowModel: getCoreRowModel(), - getRowId: (item) => explorerItemHash(item) + getCoreRowModel: useMemo(() => getCoreRowModel(), []), + getRowId: uniqueId }); + const rows = table.getRowModel().rows; const tableLength = table.getTotalSize(); - const rows = useMemo(() => table.getRowModel().rows, [explorer.items]); const rowVirtualizer = useVirtualizer({ count: explorer.items ? rows.length : 100, - getScrollElement: () => explorer.scrollRef.current, - estimateSize: () => rowHeight, + getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]), + estimateSize: useCallback(() => rowHeight, []), paddingStart: paddingY + (isScrolled ? 35 : 0), paddingEnd: paddingY, scrollMargin: listOffset @@ -426,6 +420,7 @@ export default () => { e: React.MouseEvent, row: Row ) { + // Ensure mouse click is with left button if (e.button !== 0) return; const rowIndex = row.index; @@ -447,7 +442,7 @@ export default () => { const [rangeStart] = items; if (rangeStart) { - setRanges([[explorerItemHash(rangeStart), explorerItemHash(item)]]); + setRanges([[uniqueId(rangeStart), uniqueId(item)]]); } explorer.resetSelectedItems(items); @@ -498,7 +493,7 @@ export default () => { const item = row.original; - if (explorerItemHash(item) === explorerItemHash(range.start.original)) return; + if (uniqueId(item) === uniqueId(range.start.original)) return; if ( !range.direction || @@ -559,7 +554,7 @@ export default () => { setRanges([ ..._ranges.slice(0, _ranges.length - 1), - [explorerItemHash(range.start.original), explorerItemHash(newRangeEnd)] + [uniqueId(range.start.original), uniqueId(newRangeEnd)] ]); } else if (e.metaKey) { const { rows } = table.getCoreRowModel(); @@ -588,12 +583,8 @@ export default () => { setRanges([ ..._ranges, [ - explorerItemHash( - closestRange.direction === 'down' ? start : end - ), - explorerItemHash( - closestRange.direction === 'down' ? end : start - ) + uniqueId(closestRange.direction === 'down' ? start : end), + uniqueId(closestRange.direction === 'down' ? end : start) ] ]); } else { @@ -614,10 +605,7 @@ export default () => { if (start !== undefined) { const end = rangeStart === item ? rangeEnd : rangeStart; - setRanges([ - ..._ranges, - [explorerItemHash(start), explorerItemHash(end)] - ]); + setRanges([..._ranges, [uniqueId(start), uniqueId(end)]]); } } else { const rowBefore = rows[row.index - 1]; @@ -625,13 +613,13 @@ export default () => { if (rowBefore && rowAfter) { const firstRange = [ - explorerItemHash(rangeStart), - explorerItemHash(rowBefore.original) + uniqueId(rangeStart), + uniqueId(rowBefore.original) ] satisfies Range; const secondRange = [ - explorerItemHash(rowAfter.original), - explorerItemHash(rangeEnd) + uniqueId(rowAfter.original), + uniqueId(rangeEnd) ] satisfies Range; const _ranges = ranges.filter( @@ -645,7 +633,7 @@ export default () => { } else { explorer.addSelectedItem(item); - const itemRange: Range = [explorerItemHash(item), explorerItemHash(item)]; + const itemRange: Range = [uniqueId(item), uniqueId(item)]; const _ranges = [...ranges, itemRange]; @@ -669,8 +657,8 @@ export default () => { setRanges([ ..._ranges, [ - explorerItemHash(rangeUp.sorted.start.original), - explorerItemHash(rangeDown.sorted.end.original) + uniqueId(rangeUp.sorted.start.original), + uniqueId(rangeDown.sorted.end.original) ], itemRange ]); @@ -683,8 +671,8 @@ export default () => { setRanges([ ..._ranges, [ - explorerItemHash(item), - explorerItemHash( + uniqueId(item), + uniqueId( closestRange.direction === 'down' ? closestRange.sorted.end.original : closestRange.sorted.start.original @@ -698,7 +686,7 @@ export default () => { } } else { explorer.resetSelectedItems([item]); - const hash = explorerItemHash(item); + const hash = uniqueId(item); setRanges([[hash, hash]]); } } else { @@ -713,18 +701,19 @@ export default () => { if (!isSelected(item)) { explorer.resetSelectedItems([item]); - const hash = explorerItemHash(item); + const hash = uniqueId(item); setRanges([[hash, hash]]); } } - function handleResize() { + useEffect(() => { if (locked && Object.keys(columnSizing).length > 0) { table.setColumnSizing((sizing) => { const nameSize = sizing.name; const nameColumnMinSize = table.getColumn('name')?.columnDef.minSize; const newNameSize = (nameSize || 0) + tableWidth - paddingX * 2 - scrollBarWidth - tableLength; + return { ...sizing, ...(nameSize !== undefined && nameColumnMinSize !== undefined @@ -740,34 +729,36 @@ export default () => { } else if (Math.abs(tableWidth - (tableLength + paddingX * 2 + scrollBarWidth)) < 15) { setLocked(true); } - } - - useEffect(() => handleResize(), [tableWidth]); + // TODO: This should only depends on tableWidth, the lock logic should be behind a useEffectEvent (experimental) + // https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableWidth]); useEffect(() => setRanges([]), [explorer.items]); // Measure initial column widths useEffect(() => { - if (tableRef.current) { - const columns = table.getAllColumns(); - const sizings = columns.reduce( - (sizings, column) => ({ ...sizings, [column.id]: column.getSize() }), - {} as ColumnSizingState - ); - const scrollWidth = tableRef.current.offsetWidth; - const sizingsSum = Object.values(sizings).reduce((a, b) => a + b, 0); + if (!tableRef.current || sized) return; - if (sizingsSum < scrollWidth) { - const nameColSize = sizings.name; - const nameWidth = - scrollWidth - paddingX * 2 - scrollBarWidth - (sizingsSum - (nameColSize || 0)); + const columns = table.getAllColumns(); + const sizings = columns.reduce( + (sizings, column) => ({ ...sizings, [column.id]: column.getSize() }), + {} as ColumnSizingState + ); + const scrollWidth = tableRef.current.offsetWidth; + const sizingsSum = Object.values(sizings).reduce((a, b) => a + b, 0); - table.setColumnSizing({ ...sizings, name: nameWidth }); - setLocked(true); - } else table.setColumnSizing(sizings); - setSized(true); - } - }, []); + if (sizingsSum < scrollWidth) { + const nameColSize = sizings.name; + const nameWidth = + scrollWidth - paddingX * 2 - scrollBarWidth - (sizingsSum - (nameColSize || 0)); + + table.setColumnSizing({ ...sizings, name: nameWidth }); + setLocked(true); + } else table.setColumnSizing(sizings); + + setSized(true); + }, [sized, table, paddingX]); // Load more items useEffect(() => { @@ -822,12 +813,12 @@ export default () => { let _ranges = [...ranges]; _ranges[backRange.index] = [ - explorerItemHash( + uniqueId( backRange.direction !== keyDirection ? backRange.start.original : nextRow.original ), - explorerItemHash( + uniqueId( backRange.direction !== keyDirection ? nextRow.original : backRange.end.original @@ -842,13 +833,10 @@ export default () => { } else { _ranges[frontRange.index] = frontRange.start.index === frontRange.end.index - ? [ - explorerItemHash(nextRow.original), - explorerItemHash(nextRow.original) - ] + ? [uniqueId(nextRow.original), uniqueId(nextRow.original)] : [ - explorerItemHash(frontRange.start.original), - explorerItemHash(nextRow.original) + uniqueId(frontRange.start.original), + uniqueId(nextRow.original) ]; } @@ -856,10 +844,7 @@ export default () => { } else { setRanges([ ...ranges.slice(0, ranges.length - 1), - [ - explorerItemHash(range.start.original), - explorerItemHash(nextRow.original) - ] + [uniqueId(range.start.original), uniqueId(nextRow.original)] ]); } } else { @@ -891,8 +876,8 @@ export default () => { : backRange.end.original; _ranges[backRange.index] = [ - explorerItemHash(backRangeStart), - explorerItemHash(backRangeEnd) + uniqueId(backRangeStart), + uniqueId(backRangeEnd) ]; if ( @@ -902,19 +887,13 @@ export default () => { ) { _ranges[backRange.index] = rangeEndRow.original === backRangeStart - ? [ - explorerItemHash(backRangeEnd), - explorerItemHash(backRangeStart) - ] - : [ - explorerItemHash(backRangeStart), - explorerItemHash(backRangeEnd) - ]; + ? [uniqueId(backRangeEnd), uniqueId(backRangeStart)] + : [uniqueId(backRangeStart), uniqueId(backRangeEnd)]; } _ranges[frontRange.index] = [ - explorerItemHash(frontRange.start.original), - explorerItemHash(rangeEndRow.original) + uniqueId(frontRange.start.original), + uniqueId(rangeEndRow.original) ]; if (closestRange) { @@ -929,16 +908,13 @@ export default () => { setRanges([ ..._ranges.slice(0, _ranges.length - 1), - [ - explorerItemHash(range.start.original), - explorerItemHash(rangeEndRow.original) - ] + [uniqueId(range.start.original), uniqueId(rangeEndRow.original)] ]); } } } else { explorer.resetSelectedItems([item]); - const hash = explorerItemHash(item); + const hash = uniqueId(item); setRanges([[hash, hash]]); } } else explorer.resetSelectedItems([item]); @@ -1174,7 +1150,7 @@ export default () => { const selectedNext = nextRow && isSelected(nextRow.original); - const cut = isCut(row.original.item.id); + const cut = isCut(row.original, explorerStore.cutCopyState); return (
+ ); + } else { + const filePathData = + item.type === 'Path' || item.type === 'NonIndexedPath' + ? item.item + : item.type === 'Object' + ? item.item.file_paths[0] + : null; + + if (filePathData) { return ( - ); - case 'Location': - const locationData = getItemLocation(item); - if (!locationData) break; - return ( - ); + } } + return
; } diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 7cd77eec4..556d66999 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { Columns, GridFour, Icon, MonitorPlay, Rows } from 'phosphor-react'; +import { Columns, GridFour, type Icon, MonitorPlay, Rows } from 'phosphor-react'; import { type HTMLAttributes, type PropsWithChildren, @@ -17,8 +17,8 @@ import { type ExplorerItem, type FilePath, type Location, + type NonIndexedPathItem, type Object, - getItemFilePath, getItemObject, isPath, useLibraryContext, @@ -36,6 +36,7 @@ import { useQuickPreviewContext } from '../QuickPreview/Context'; import { type ExplorerViewContext, ViewContext, useExplorerViewContext } from '../ViewContext'; import { useExplorerConfigStore } from '../config'; import { getExplorerStore } from '../store'; +import { uniqueId } from '../util'; import GridView from './GridView'; import ListView from './ListView'; import MediaView from './MediaView'; @@ -56,39 +57,47 @@ export const ViewItem = ({ data, children, ...props }: ViewItemProps) => { const updateAccessTime = useLibraryMutation('files.updateAccessTime'); - function updateList(list: T[], item: T, push: boolean) { - return !push ? [item, ...list] : [...list, item]; - } - const onDoubleClick = async () => { const selectedItems = [...explorer.selectedItems].reduce( (items, item) => { - const sameAsClicked = data.item.id === item.item.id; + const sameAsClicked = uniqueId(data) === uniqueId(item); switch (item.type) { - case 'Path': - case 'Object': { - const filePath = getItemFilePath(item); - if (filePath) { - if (isPath(item) && item.item.is_dir) { - items.dirs = updateList(items.dirs, filePath, !sameAsClicked); - } else items.paths = updateList(items.paths, filePath, !sameAsClicked); - } + case 'Location': { + items.locations.splice(sameAsClicked ? 0 : -1, 0, item.item); break; } - - case 'Location': { - items.locations = updateList(items.locations, item.item, !sameAsClicked); + case 'NonIndexedPath': { + items.non_indexed.splice(sameAsClicked ? 0 : -1, 0, item.item); + break; + } + default: { + for (const filePath of item.type === 'Path' + ? [item.item] + : item.item.file_paths) { + if (isPath(item) && item.item.is_dir) { + items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath); + } else { + items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath); + } + } + break; } } return items; }, { - paths: [], dirs: [], - locations: [] - } as { paths: FilePath[]; dirs: FilePath[]; locations: Location[] } + paths: [], + locations: [], + non_indexed: [] + } as { + dirs: FilePath[]; + paths: FilePath[]; + locations: Location[]; + non_indexed: NonIndexedPathItem[]; + } ); if (selectedItems.paths.length > 0 && !explorerView.isRenaming) { @@ -119,25 +128,39 @@ export const ViewItem = ({ data, children, ...props }: ViewItemProps) => { } if (selectedItems.dirs.length > 0) { - const item = selectedItems.dirs[0]; - if (!item) return; + const [item] = selectedItems.dirs; + if (item) { + navigate({ + pathname: `../location/${item.location_id}`, + search: createSearchParams({ + path: `${item.materialized_path}${item.name}/` + }).toString() + }); + return; + } + } - navigate({ - pathname: `../location/${item.location_id}`, - search: createSearchParams({ - path: `${item.materialized_path}${item.name}/` - }).toString() - }); - } else if (selectedItems.locations.length > 0) { - const location = selectedItems.locations[0]; - if (!location) return; + if (selectedItems.locations.length > 0) { + const [location] = selectedItems.locations; + if (location) { + navigate({ + pathname: `../location/${location.id}`, + search: createSearchParams({ + path: `/` + }).toString() + }); + return; + } + } - navigate({ - pathname: `../location/${location.id}`, - search: createSearchParams({ - path: `/` - }).toString() - }); + if (selectedItems.non_indexed.length > 0) { + const [non_indexed] = selectedItems.non_indexed; + if (non_indexed) { + navigate({ + search: createSearchParams({ path: non_indexed.path }).toString() + }); + return; + } } }; @@ -307,11 +330,13 @@ const useKeyDownHandlers = ({ isRenaming }: { isRenaming: boolean }) => { const paths: number[] = []; - for (const item of explorer.selectedItems) { - const path = getItemFilePath(item); - if (!path) return; - paths.push(path.id); - } + for (const item of explorer.selectedItems) + for (const path of item.type === 'Path' + ? [item.item] + : item.type === 'Object' + ? item.item.file_paths + : []) + paths.push(path.id); if (!isNonEmpty(paths)) return; diff --git a/interface/app/$libraryId/Explorer/ViewContext.ts b/interface/app/$libraryId/Explorer/ViewContext.ts index b6ea61c57..073b72520 100644 --- a/interface/app/$libraryId/Explorer/ViewContext.ts +++ b/interface/app/$libraryId/Explorer/ViewContext.ts @@ -1,6 +1,4 @@ -import { ReactNode, RefObject, createContext, useContext } from 'react'; - -export type ExplorerViewSelection = number | Set; +import { type ReactNode, type RefObject, createContext, useContext } from 'react'; export interface ExplorerViewContext { ref: RefObject; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index a9efb45d0..c4d590630 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -1,5 +1,5 @@ import { FolderNotchOpen } from 'phosphor-react'; -import { PropsWithChildren, ReactNode } from 'react'; +import { type PropsWithChildren, type ReactNode } from 'react'; import { useLibrarySubscription } from '@sd/client'; import { TOP_BAR_HEIGHT } from '../TopBar'; import { useExplorerContext } from './Context'; @@ -44,7 +44,7 @@ export default function Explorer(props: PropsWithChildren) {
) { + return item.type === 'NonIndexedPath' + ? false + : cutCopyState.type === 'Cut' && cutCopyState.sourcePathIds.includes(item.item.id); } export const filePathOrderingKeysSchema = z.union([ diff --git a/interface/app/$libraryId/Explorer/useExplorer.ts b/interface/app/$libraryId/Explorer/useExplorer.ts index fbc1e3283..1b92c5bb2 100644 --- a/interface/app/$libraryId/Explorer/useExplorer.ts +++ b/interface/app/$libraryId/Explorer/useExplorer.ts @@ -1,9 +1,16 @@ -import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { proxy, snapshot, subscribe, useSnapshot } from 'valtio'; import { z } from 'zod'; -import { ExplorerItem, ExplorerSettings, FilePath, Location, NodeState, Tag } from '@sd/client'; -import { Ordering, OrderingKeys, createDefaultExplorerSettings } from './store'; -import { explorerItemHash } from './util'; +import type { + ExplorerItem, + ExplorerSettings, + FilePath, + Location, + NodeState, + Tag +} from '@sd/client'; +import { type Ordering, type OrderingKeys, createDefaultExplorerSettings } from './store'; +import { uniqueId } from './util'; export type ExplorerParent = | { @@ -41,13 +48,6 @@ export interface UseExplorerProps { settings: ReturnType>; } -export type ExplorerItemMeta = { - type: 'Location' | 'Path' | 'Object'; - id: number; -}; - -export type ExplorerItemHash = `${ExplorerItemMeta['type']}:${ExplorerItemMeta['id']}`; - /** * Controls top-level config and state for the explorer. * View- and inspector-specific state is not handled here. @@ -80,7 +80,7 @@ export function useExplorerSettings({ orderingKeys }: { settings: ReturnType>; - onSettingsChanged: (settings: ExplorerSettings) => any; + onSettingsChanged?: (settings: ExplorerSettings) => any; orderingKeys?: z.ZodUnion< [z.ZodLiteral>, ...z.ZodLiteral>[]] >; @@ -94,7 +94,7 @@ export function useExplorerSettings({ useEffect( () => subscribe(store, () => { - onSettingsChanged(snapshot(store) as ExplorerSettings); + onSettingsChanged?.(snapshot(store) as ExplorerSettings); }), [onSettingsChanged, store] ); @@ -113,12 +113,12 @@ export type UseExplorerSettings = ReturnType< function useSelectedItems(items: ExplorerItem[] | null) { // Doing pointer lookups for hashes is a bit faster than assembling a bunch of strings // WeakMap ensures that ExplorerItems aren't held onto after they're evicted from cache - const itemHashesWeakMap = useRef(new WeakMap()); + const itemHashesWeakMap = useRef(new WeakMap()); // Store hashes of items instead as objects are unique by reference but we // still need to differentate between item variants const [selectedItemHashes, setSelectedItemHashes] = useState(() => ({ - value: new Set() + value: new Set() })); const updateHashes = useCallback( @@ -129,11 +129,11 @@ function useSelectedItems(items: ExplorerItem[] | null) { const itemsMap = useMemo( () => (items ?? []).reduce((items, item) => { - const hash = itemHashesWeakMap.current.get(item) ?? explorerItemHash(item); + const hash = itemHashesWeakMap.current.get(item) ?? uniqueId(item); itemHashesWeakMap.current.set(item, hash); items.set(hash, item); return items; - }, new Map()), + }, new Map()), [items] ); @@ -152,14 +152,14 @@ function useSelectedItems(items: ExplorerItem[] | null) { selectedItemHashes, addSelectedItem: useCallback( (item: ExplorerItem) => { - selectedItemHashes.value.add(explorerItemHash(item)); + selectedItemHashes.value.add(uniqueId(item)); updateHashes(); }, [selectedItemHashes.value, updateHashes] ), removeSelectedItem: useCallback( (item: ExplorerItem) => { - selectedItemHashes.value.delete(explorerItemHash(item)); + selectedItemHashes.value.delete(uniqueId(item)); updateHashes(); }, [selectedItemHashes.value, updateHashes] @@ -167,7 +167,7 @@ function useSelectedItems(items: ExplorerItem[] | null) { resetSelectedItems: useCallback( (items?: ExplorerItem[]) => { selectedItemHashes.value.clear(); - items?.forEach((item) => selectedItemHashes.value.add(explorerItemHash(item))); + items?.forEach((item) => selectedItemHashes.value.add(uniqueId(item))); updateHashes(); }, [selectedItemHashes.value, updateHashes] diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index 6762030d4..1eaeab994 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -1,9 +1,8 @@ import { useMemo } from 'react'; -import { ExplorerItem, getExplorerItemData } from '@sd/client'; +import { type ExplorerItem, getExplorerItemData } from '@sd/client'; import { ExplorerParamsSchema } from '~/app/route-schemas'; import { useZodSearchParams } from '~/hooks'; import { flattenThumbnailKey, useExplorerStore } from './store'; -import { ExplorerItemHash } from './useExplorer'; export function useExplorerSearchParams() { return useZodSearchParams(ExplorerParamsSchema); @@ -28,6 +27,18 @@ export function useExplorerItemData(explorerItem: ExplorerItem) { }, [explorerItem, newThumbnail]); } -export function explorerItemHash(item: ExplorerItem): ExplorerItemHash { - return `${item.type}:${item.item.id}`; -} +export const pubIdToString = (pub_id: number[]) => + pub_id.map((b) => b.toString(16).padStart(2, '0')).join(''); + +export const uniqueId = (item: ExplorerItem | { pub_id: number[] }) => { + if ('pub_id' in item) return pubIdToString(item.pub_id); + + const { type } = item; + + switch (type) { + case 'NonIndexedPath': + return item.item.path; + default: + return pubIdToString(item.item.pub_id); + } +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx index 5612f820d..772f56fde 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx @@ -1,15 +1,7 @@ -import { - ArchiveBox, - ArrowsClockwise, - Broadcast, - CopySimple, - Crosshair, - Eraser, - FilmStrip, - Planet -} from 'phosphor-react'; +import { ArrowsClockwise, CopySimple, Crosshair, Eraser, FilmStrip, Planet } from 'phosphor-react'; import { LibraryContextProvider, useClientContext, useFeatureFlag } from '@sd/client'; import { SubtleButton } from '~/components/SubtleButton'; +import { EphemeralSection } from './EphemeralSection'; import Icon from './Icon'; import { LibrarySection } from './LibrarySection'; import SidebarLink from './Link'; @@ -40,6 +32,7 @@ export default () => { )}
+ {library && ( diff --git a/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx b/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx new file mode 100644 index 000000000..0935eb04c --- /dev/null +++ b/interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { useBridgeQuery } from '@sd/client'; +import { Folder } from '~/components'; +import { usePlatform } from '~/util/Platform'; +import SidebarLink from './Link'; +import Section from './Section'; + +export const EphemeralSection = () => { + const [home, setHome] = useState(null); + + const platform = usePlatform(); + platform.userHomeDir?.().then(setHome); + + const volumes = useBridgeQuery(['volumes.list']).data ?? []; + + return home == null && volumes.length < 1 ? null : ( + <> +
+ {home && ( + +
+ +
+ + Home +
+ )} + {volumes.map((volume, volumeIndex) => { + const mountPoints = volume.mount_points; + mountPoints.sort((a, b) => a.length - b.length); + return mountPoints.map((mountPoint, index) => { + const key = `${volumeIndex}-${index}`; + if (mountPoint == home) return null; + + const name = + mountPoint === '/' ? 'Root' : index === 0 ? volume.name : mountPoint; + return ( + +
+ +
+ + {name} +
+ ); + }); + })} +
+ + ); +}; diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx index ec5f851c0..30205243d 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx @@ -65,12 +65,10 @@ export const LibrarySection = () => {
- ) : ( - ) } > diff --git a/interface/app/$libraryId/Layout/Sidebar/Section.tsx b/interface/app/$libraryId/Layout/Sidebar/Section.tsx index 5585119de..dd0a0a355 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Section.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Section.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { CategoryHeading } from '@sd/ui'; export default ( @@ -10,9 +10,11 @@ export default (
{props.name} -
- {props.actionArea} -
+ {props.actionArea && ( +
+ {props.actionArea} +
+ )}
{props.children}
diff --git a/interface/app/$libraryId/TopBar/Layout.tsx b/interface/app/$libraryId/TopBar/Layout.tsx index d74e36a29..0752b3200 100644 --- a/interface/app/$libraryId/TopBar/Layout.tsx +++ b/interface/app/$libraryId/TopBar/Layout.tsx @@ -5,6 +5,7 @@ import TopBar from '.'; interface TopBarContext { left: HTMLDivElement | null; right: HTMLDivElement | null; + setNoSearch: (value: boolean) => void; } const TopBarContext = createContext(null); @@ -12,10 +13,11 @@ const TopBarContext = createContext(null); export const Component = () => { const [left, setLeft] = useState(null); const [right, setRight] = useState(null); + const [noSearch, setNoSearch] = useState(false); return ( - - + + ); diff --git a/interface/app/$libraryId/TopBar/Portal.tsx b/interface/app/$libraryId/TopBar/Portal.tsx index 0fbd733e0..922a0c4c0 100644 --- a/interface/app/$libraryId/TopBar/Portal.tsx +++ b/interface/app/$libraryId/TopBar/Portal.tsx @@ -1,14 +1,23 @@ -import { ReactNode } from 'react'; +import { type ReactNode, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useTopBarContext } from './Layout'; -export const TopBarPortal = (props: { left?: ReactNode; right?: ReactNode }) => { +interface Props { + left?: ReactNode; + right?: ReactNode; + noSearch?: boolean; +} +export const TopBarPortal = ({ left, right, noSearch }: Props) => { const ctx = useTopBarContext(); + useEffect(() => { + ctx.setNoSearch(noSearch ?? false); + }, [ctx, noSearch]); + return ( <> - {props.left && ctx.left && createPortal(props.left, ctx.left)} - {props.right && ctx.right && createPortal(props.right, ctx.right)} + {left && ctx.left && createPortal(left, ctx.left)} + {right && ctx.right && createPortal(right, ctx.right)} ); }; diff --git a/interface/app/$libraryId/TopBar/TopBarOptions.tsx b/interface/app/$libraryId/TopBar/TopBarOptions.tsx index 9373922b9..87bae08eb 100644 --- a/interface/app/$libraryId/TopBar/TopBarOptions.tsx +++ b/interface/app/$libraryId/TopBar/TopBarOptions.tsx @@ -37,7 +37,7 @@ export default ({ options }: TopBarChildrenProps) => { }, []); return ( -
+
{options?.map((group, groupIndex) => { return group.map( diff --git a/interface/app/$libraryId/TopBar/index.tsx b/interface/app/$libraryId/TopBar/index.tsx index 2da9ed866..94142ec57 100644 --- a/interface/app/$libraryId/TopBar/index.tsx +++ b/interface/app/$libraryId/TopBar/index.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { Ref } from 'react'; +import type { Ref } from 'react'; import { useExplorerStore } from '../Explorer/store'; import { NavigationButtons } from './NavigationButtons'; import SearchBar from './SearchBar'; @@ -9,6 +9,7 @@ export const TOP_BAR_HEIGHT = 46; interface Props { leftRef?: Ref; rightRef?: Ref; + noSearch?: boolean; } const TopBar = (props: Props) => { @@ -17,20 +18,21 @@ const TopBar = (props: Props) => { return (
-
+
-
+
- -
+ {props.noSearch || } +
); }; diff --git a/interface/app/$libraryId/ephemeral.tsx b/interface/app/$libraryId/ephemeral.tsx new file mode 100644 index 000000000..38f5197ee --- /dev/null +++ b/interface/app/$libraryId/ephemeral.tsx @@ -0,0 +1,99 @@ +import { Suspense, memo, useDeferredValue, useMemo } from 'react'; +import { type FilePathSearchOrdering, getExplorerItemData, useLibraryQuery } from '@sd/client'; +import { Tooltip } from '@sd/ui'; +import { type PathParams, PathParamsSchema } from '~/app/route-schemas'; +import { useOperatingSystem, useZodSearchParams } from '~/hooks'; +import Explorer from './Explorer'; +import { ExplorerContextProvider } from './Explorer/Context'; +import { DefaultTopBarOptions } from './Explorer/TopBarOptions'; +import { + createDefaultExplorerSettings, + filePathOrderingKeysSchema, + getExplorerStore +} from './Explorer/store'; +import { useExplorer, useExplorerSettings } from './Explorer/useExplorer'; +import { TopBarPortal } from './TopBar/Portal'; +import { AddLocationButton } from './settings/library/locations/AddLocationButton'; + +const EphemeralExplorer = memo((props: { args: PathParams }) => { + const os = useOperatingSystem(); + const { path } = props.args; + + const explorerSettings = useExplorerSettings({ + settings: useMemo( + () => + createDefaultExplorerSettings({ + order: { + field: 'name', + value: 'Asc' + } + }), + [] + ), + orderingKeys: filePathOrderingKeysSchema + }); + + const settingsSnapshot = explorerSettings.useSettingsSnapshot(); + + const query = useLibraryQuery( + [ + 'search.ephemeral-paths', + { + path: path ?? (os === 'windows' ? 'C:\\' : '/'), + withHiddenFiles: true, + order: settingsSnapshot.order + } + ], + { + enabled: path != null, + suspense: true, + onSuccess: () => getExplorerStore().resetNewThumbnails() + } + ); + + const items = + useMemo(() => { + const items = query.data?.entries; + if (settingsSnapshot.layoutMode !== 'media') return items; + + return items?.filter((item) => { + const { kind } = getExplorerItemData(item); + return kind === 'Video' || kind === 'Image'; + }); + }, [query.data, settingsSnapshot.layoutMode]) ?? []; + + const explorer = useExplorer({ + items, + settings: explorerSettings + }); + + return ( + + + + + } + right={} + noSearch={true} + /> + + + ); +}); + +export const Component = () => { + const [pathParams] = useZodSearchParams(PathParamsSchema); + + const path = useDeferredValue(pathParams); + + return ( + + + + ); +}; diff --git a/interface/app/$libraryId/index.tsx b/interface/app/$libraryId/index.tsx index ff36f2183..e52a09356 100644 --- a/interface/app/$libraryId/index.tsx +++ b/interface/app/$libraryId/index.tsx @@ -1,4 +1,4 @@ -import { RouteObject } from 'react-router-dom'; +import type { RouteObject } from 'react-router-dom'; import settingsRoutes from './settings'; // Routes that should be contained within the standard Page layout @@ -24,6 +24,7 @@ const explorerRoutes: RouteObject[] = [ { path: 'location/:id', lazy: () => import('./location/$id') }, { path: 'node/:id', lazy: () => import('./node/$id') }, { path: 'tag/:id', lazy: () => import('./tag/$id') }, + { path: 'ephemeral/:id', lazy: () => import('./ephemeral') }, { path: 'search', lazy: () => import('./search') } ]; diff --git a/interface/app/$libraryId/location/LocationOptions.tsx b/interface/app/$libraryId/location/LocationOptions.tsx index 6e99da558..ed75f7f0f 100644 --- a/interface/app/$libraryId/location/LocationOptions.tsx +++ b/interface/app/$libraryId/location/LocationOptions.tsx @@ -1,7 +1,7 @@ import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg'; import { Archive, Copy, FolderDotted, Gear, IconContext, Image } from 'phosphor-react'; import { useNavigate } from 'react-router'; -import { Location, useLibraryMutation } from '@sd/client'; +import { type Location, useLibraryMutation } from '@sd/client'; import { Button, Input, @@ -73,12 +73,14 @@ export default function LocationOptions({ location, path }: { location: Location - scanLocationSubPath.mutate( - { - location_id: location.id, - sub_path: path ?? '' + + scanLocationSubPath.mutate({ + location_id: location.id, + sub_path: path ?? '' + }) } - )}> + > Re-index diff --git a/interface/app/$libraryId/search.tsx b/interface/app/$libraryId/search.tsx index bccd3bb69..292eb39b3 100644 --- a/interface/app/$libraryId/search.tsx +++ b/interface/app/$libraryId/search.tsx @@ -1,7 +1,7 @@ import { MagnifyingGlass } from 'phosphor-react'; import { Suspense, memo, useDeferredValue, useMemo } from 'react'; -import { FilePathSearchOrdering, getExplorerItemData, useLibraryQuery } from '@sd/client'; -import { SearchParams, SearchParamsSchema } from '~/app/route-schemas'; +import { type FilePathSearchOrdering, getExplorerItemData, useLibraryQuery } from '@sd/client'; +import { type SearchParams, SearchParamsSchema } from '~/app/route-schemas'; import { useZodSearchParams } from '~/hooks'; import Explorer from './Explorer'; import { ExplorerContextProvider } from './Explorer/Context'; @@ -35,23 +35,20 @@ const SearchExplorer = memo((props: { args: SearchParams }) => { }), [] ), - onSettingsChanged: () => {}, orderingKeys: filePathOrderingKeysSchema }); const settingsSnapshot = explorerSettings.useSettingsSnapshot(); const items = useMemo(() => { - const items = query.data?.items ?? null; + const items = query.data?.items ?? []; if (settingsSnapshot.layoutMode !== 'media') return items; - return ( - items?.filter((item) => { - const { kind } = getExplorerItemData(item); - return kind === 'Video' || kind === 'Image'; - }) || null - ); + return items?.filter((item) => { + const { kind } = getExplorerItemData(item); + return kind === 'Video' || kind === 'Image'; + }); }, [query.data, settingsSnapshot.layoutMode]); const explorer = useExplorer({ @@ -60,21 +57,27 @@ const SearchExplorer = memo((props: { args: SearchParams }) => { }); return ( - <> - {search ? ( - - } /> - } + + } /> + + ) : null + } + message={ + search ? `No results found for "${search}"` : 'Search for files...' + } /> - - ) : ( -
- -

Search for files...

-
- )} - + } + /> +
); }); diff --git a/interface/app/$libraryId/settings/library/locations/$id.tsx b/interface/app/$libraryId/settings/library/locations/$id.tsx index b20bb1650..58e8824d8 100644 --- a/interface/app/$libraryId/settings/library/locations/$id.tsx +++ b/interface/app/$libraryId/settings/library/locations/$id.tsx @@ -228,7 +228,12 @@ const EditLocationForm = () => {
); diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 1943568d0..5b0b6ce6d 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Navigate, Outlet, RouteObject, useMatches } from 'react-router-dom'; +import { Navigate, Outlet, type RouteObject, useMatches } from 'react-router-dom'; import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client'; import { Dialogs, Toaster } from '@sd/ui'; import { RouterErrorBoundary } from '~/ErrorFallback'; diff --git a/interface/hooks/useKeyDeleteFile.tsx b/interface/hooks/useKeyDeleteFile.tsx index 1473c2066..55b5e575e 100644 --- a/interface/hooks/useKeyDeleteFile.tsx +++ b/interface/hooks/useKeyDeleteFile.tsx @@ -1,5 +1,5 @@ import { useKey } from 'rooks'; -import { ExplorerItem } from '@sd/client'; +import type { ExplorerItem } from '@sd/client'; import { dialogManager } from '@sd/ui'; import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog'; diff --git a/interface/package.json b/interface/package.json index d47a420c6..f56259aa2 100644 --- a/interface/package.json +++ b/interface/package.json @@ -67,6 +67,7 @@ "rooks": "^5.14.0", "tailwindcss": "^3.3.2", "ts-deepmerge": "^6.0.3", + "type-fest": "^4.2.0", "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", "use-resize-observer": "^9.1.0", diff --git a/interface/util/Platform.tsx b/interface/util/Platform.tsx index 680a8e020..10d4ad468 100644 --- a/interface/util/Platform.tsx +++ b/interface/util/Platform.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, createContext, useContext } from 'react'; +import { type PropsWithChildren, createContext, useContext } from 'react'; export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown'; @@ -23,6 +23,7 @@ export type Platform = { showDevtools?(): void; openPath?(path: string): void; openLogsDir?(): void; + userHomeDir?(): Promise; // Opens a file path with a given ID openFilePaths?(library: string, ids: number[]): any; revealItems?( diff --git a/package.json b/package.json index 1ba8bda98..824a201b1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "typecheck": "pnpm -r typecheck", "lint": "turbo run lint", "lint:fix": "turbo run lint -- --fix", - "clean": "rimraf -g \"node_modules/\" \"**/node_modules/\" \"target/\" \"**/.build/\" \"**/.next/\" \"**/dist/!(.gitignore)**\"" + "clean": "rimraf -g \"node_modules/\" \"**/node_modules/\" \"target/\" \"**/.build/\" \"**/.next/\" \"**/dist/!(.gitignore)**\" \"**/tsconfig.tsbuildinfo\"" }, "pnpm": { "overrides": { diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index a8da9e20b..b2f1be5b9 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -25,6 +25,7 @@ export type Procedures = { { key: "notifications.dismissAll", input: never, result: null } | { key: "notifications.get", input: never, result: Notification[] } | { key: "preferences.get", input: LibraryArgs, result: LibraryPreferences } | + { key: "search.ephemeral-paths", input: LibraryArgs, result: NonIndexedFileSystemEntries } | { key: "search.objects", input: LibraryArgs, result: SearchData } | { key: "search.paths", input: LibraryArgs, result: SearchData } | { key: "sync.messages", input: LibraryArgs, result: CRDTOperation[] } | @@ -115,7 +116,14 @@ export type DoubleClickAction = "openFile" | "quickPreview" export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined } -export type ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: FilePathWithObject } | { type: "Object"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: Location } +export type Error = { code: ErrorCode; message: string } + +/** + * TODO + */ +export type ErrorCode = "BadRequest" | "Unauthorized" | "Forbidden" | "NotFound" | "Timeout" | "Conflict" | "PreconditionFailed" | "PayloadTooLarge" | "MethodNotSupported" | "ClientClosedRequest" | "InternalServerError" + +export type ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: FilePathWithObject } | { type: "Object"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: Location } | { type: "NonIndexedPath"; has_local_thumbnail: boolean; thumbnail_key: string[] | null; item: NonIndexedPathItem } export type ExplorerLayout = "grid" | "list" | "media" @@ -226,6 +234,12 @@ export type MediaData = { id: number; pixel_width: number | null; pixel_height: export type NodeState = ({ id: string; name: string; p2p_port: number | null; p2p_email: string | null; p2p_img_url: string | null }) & { data_path: string } +export type NonIndexedFileSystemEntries = { entries: ExplorerItem[]; errors: Error[] } + +export type NonIndexedPath = { path: string; withHiddenFiles: boolean; order?: FilePathSearchOrdering | null } + +export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[] } + /** * Represents a single notification. */ diff --git a/packages/client/src/lib/byte-size.ts b/packages/client/src/lib/byte-size.ts index 6122eb5e3..6d1a0ca4a 100644 --- a/packages/client/src/lib/byte-size.ts +++ b/packages/client/src/lib/byte-size.ts @@ -70,6 +70,7 @@ export const byteSize = ( (unit.from === 0n ? Number(bytes) : Number((bytes * BigInt(precisionFactor)) / unit.from) / precisionFactor), + original: value, toString() { return `${defaultFormat.format(this.value)} ${this.unit}`; } diff --git a/packages/client/src/utils/explorerItem.ts b/packages/client/src/utils/explorerItem.ts index ca94d68d5..6d626e6ef 100644 --- a/packages/client/src/utils/explorerItem.ts +++ b/packages/client/src/utils/explorerItem.ts @@ -1,36 +1,70 @@ import { useMemo } from 'react'; -import { ExplorerItem, FilePath, Object } from '../core'; -import { ObjectKind, ObjectKindKey } from './objectKind'; +import type { ExplorerItem, FilePath, Object } from '../core'; +import { byteSize } from '../lib'; +import { ObjectKind } from './objectKind'; export function getItemObject(data: ExplorerItem) { return data.type === 'Object' ? data.item : data.type === 'Path' ? data.item.object : null; } export function getItemFilePath(data: ExplorerItem) { - return data.type === 'Path' - ? data.item - : data.type === 'Object' - ? data.item.file_paths[0] - : null; + if (data.type === 'Path' || data.type === 'NonIndexedPath') return data.item; + return (data.type === 'Object' && data.item.file_paths[0]) || null; } export function getItemLocation(data: ExplorerItem) { return data.type === 'Location' ? data.item : null; } -export function getExplorerItemData(data: ExplorerItem) { - const filePath = getItemFilePath(data); - const objectData = getItemObject(data); +export function getExplorerItemData(data?: null | ExplorerItem) { + const itemObj = data ? getItemObject(data) : null; - return { - kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null, - casId: filePath?.cas_id || null, - isDir: getItemFilePath(data)?.is_dir || false, - extension: filePath?.extension || null, - locationId: filePath?.location_id || null, - hasLocalThumbnail: data.has_local_thumbnail, // this will be overwritten if new thumbnail is generated - thumbnailKey: data.thumbnail_key + const kind = (itemObj?.kind ? ObjectKind[itemObj.kind] : null) ?? 'Unknown'; + + const itemData = { + name: null as string | null, + size: byteSize(0), + kind, + isDir: false, + casId: null as string | null, + extension: null as string | null, + locationId: null as number | null, + dateIndexed: null as string | null, + dateCreated: data?.item.date_created ?? itemObj?.date_created ?? null, + dateModified: null as string | null, + dateAccessed: itemObj?.date_accessed ?? null, + thumbnailKey: data?.thumbnail_key ?? [], + hasLocalThumbnail: data?.has_local_thumbnail ?? false // this will be overwritten if new thumbnail is generated }; + + if (!data) return itemData; + + const filePath = getItemFilePath(data); + const location = getItemLocation(data); + if (filePath) { + itemData.name = filePath.name; + itemData.size = byteSize(filePath.size_in_bytes_bytes); + itemData.isDir = filePath.is_dir ?? false; + itemData.extension = filePath.extension; + if ('kind' in filePath) itemData.kind = ObjectKind[filePath.kind] ?? 'Unknown'; + if ('cas_id' in filePath) itemData.casId = filePath.cas_id; + if ('location_id' in filePath) itemData.locationId = filePath.location_id; + if ('date_indexed' in filePath) itemData.dateIndexed = filePath.date_indexed; + if ('date_modified' in filePath) itemData.dateModified = filePath.date_modified; + } else if (location) { + if (location.total_capacity != null && location.available_capacity != null) + itemData.size = byteSize(location.total_capacity - location.available_capacity); + + itemData.name = location.name; + itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown'; + itemData.isDir = true; + itemData.locationId = location.id; + itemData.dateIndexed = location.date_created; + } + + if (data.type == 'Path' && itemData.isDir) itemData.kind = 'Folder'; + + return itemData; } export const useItemsAsObjects = (items: ExplorerItem[]) => { diff --git a/packages/client/src/utils/objectKind.ts b/packages/client/src/utils/objectKind.ts index 875ab6929..d7600d919 100644 --- a/packages/client/src/utils/objectKind.ts +++ b/packages/client/src/utils/objectKind.ts @@ -1,6 +1,6 @@ // An array of Object kinds. -// Note: The order of this enum should never change, and always be kept in sync with `crates/file_ext/src/kind.rs` -export enum ObjectKind { +// Note: The order of this enum should never change, and always be kept in sync with `crates/file-ext/src/kind.rs` +export enum ObjectKindEnum { Unknown, Document, Folder, @@ -26,4 +26,10 @@ export enum ObjectKind { Book } -export type ObjectKindKey = keyof typeof ObjectKind; +export type ObjectKindKey = keyof typeof ObjectKindEnum; + +// This is ugly, but typescript doesn't support type narrowing for enum index access yet: +// https://github.com/microsoft/TypeScript/issues/38806 +export const ObjectKind = ObjectKindEnum as typeof ObjectKindEnum & { + [key: number]: ObjectKindKey | undefined; +}; diff --git a/packages/config/eslint/base.js b/packages/config/eslint/base.js index 3999d65e2..28c3ec8f5 100644 --- a/packages/config/eslint/base.js +++ b/packages/config/eslint/base.js @@ -1,51 +1,51 @@ const path = require('node:path'); module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaFeatures: { - jsx: true - }, - ecmaVersion: 12, - sourceType: 'module' - }, - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:@typescript-eslint/recommended', - 'turbo', - 'prettier' - ], - plugins: ['react'], - rules: { - 'react/display-name': 'off', - 'react/prop-types': 'off', - 'react/no-unescaped-entities': 'off', - 'react/react-in-jsx-scope': 'off', - 'react-hooks/rules-of-hooks': 'warn', - 'react-hooks/exhaustive-deps': 'warn', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-empty-interface': 'off', - '@typescript-eslint/no-empty-function': 'off', - '@typescript-eslint/ban-types': 'off', - 'no-control-regex': 'off', - 'no-mixed-spaces-and-tabs': ['warn', 'smart-tabs'], - 'turbo/no-undeclared-env-vars': [ - 'error', - { - cwd: path.resolve(path.join(__dirname, '..', '..', '..')) - } - ] - }, - ignorePatterns: ['dist', '**/*.js', '**/*.json', 'node_modules'], - settings: { - react: { - version: 'detect' - } - } + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true + }, + ecmaVersion: 12, + sourceType: 'module' + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:@typescript-eslint/recommended', + 'turbo', + 'prettier' + ], + plugins: ['react'], + rules: { + 'react/display-name': 'off', + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/rules-of-hooks': 'warn', + 'react-hooks/exhaustive-deps': 'warn', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/ban-types': 'off', + 'no-control-regex': 'off', + 'no-mixed-spaces-and-tabs': ['warn', 'smart-tabs'], + 'turbo/no-undeclared-env-vars': [ + 'error', + { + cwd: path.resolve(path.join(__dirname, '..', '..', '..')) + } + ] + }, + ignorePatterns: ['dist', '**/*.js', '**/*.json', 'node_modules'], + settings: { + react: { + version: 'detect' + } + } }; diff --git a/packages/ui/src/ProgressBar.tsx b/packages/ui/src/ProgressBar.tsx index c427a02a1..9265a38c8 100644 --- a/packages/ui/src/ProgressBar.tsx +++ b/packages/ui/src/ProgressBar.tsx @@ -11,22 +11,22 @@ export interface ProgressBarProps { export const ProgressBar = memo((props: ProgressBarProps) => { const percentage = props.pending ? 0 : Math.round((props.value / props.total) * 100); - if (props.pending) { - return
-
-
+ return ( +
+
+
+ ); } return ( - ); }); diff --git a/packages/ui/src/Slider.tsx b/packages/ui/src/Slider.tsx index 11f18e2e1..541346398 100644 --- a/packages/ui/src/Slider.tsx +++ b/packages/ui/src/Slider.tsx @@ -6,7 +6,7 @@ export const Slider = (props: SliderPrimitive.SliderProps) => ( {...props} className={clsx('relative flex h-6 w-full select-none items-center', props.className)} > - + 6z)w{F@zFpWA|1Jd-Cm3vKpye#s0G4{%>8#8FaNkgA)OT0Hrn zo%rM`8%bs(J%i>?k?o%%8Ml9mWO8qq9_Gs{JH6l$lYD#ZEhZpl-X434<)_2+Tm@F2 z=^w5!a!re6<=VbqnzbbmBxbU`A(yrK()15|d3~l&&|zZT?&8308#ZYlLQ(6;!xIMkN`UX9cAg zm1UOZ8k@SC7n(*y8HIaU7KLZIg!=l07@PSTdnB6}7CIOCM7a5yX1aR&x<@1z1r&Ry zMY#HDTjb~$WjdJzR+X2RXS!BSZ_H$opB|&eEwkM$fqkAH#3^6<*nO=b%;Zz-jNcHh z+Fo&hV_VSlHOD!0rY8$=ifj)P=KL@nqBMOqCx`QNjT>A#)4!{7C``Y@#=|u|;Uug0 z_9O2&13yg9f6Xp2J%E*2X}kG-uKzM%mD|1kaEbRr^fz^L?_>utC8itPV-nu(HHdwsbNG4b(Sq)%FPtG0Vv-@+r%7&CiMS56TG&Pt46TnSSsrm)!IQ6)w){|28u7 eZcj+$eI5l$B}Lme?%}m(gr_IY?SA`sSv>*&i~#Zg delta 580 zcmZ27#o}_G#Rg5*$qjL$n@w3?*#a5rn{Az6GJ{2o+}8*-D@3&`L@{nxh+=YRm>#9h z$vu6;O(uo*#kZJ%n0fo+TP&X(rc3VUjhmiO&dNRgt{AJ}b~YK-;y{p~?e?{~tksvM z$M50goz7~>qA~q|KBLI?N(Z*pYSWKBVN%*2m(CU*KRs$S6W4TyTkPW7-yLJ)%9x(z z&MGpU^%MvHc4K|^69&_#*0E`XdN{fSWrP(5BwLib6}lFfIp=t5mzD-Zrfa+TI%Veh z9a`(+h}6h?$6rKGug`I{PM<>gnT`Id!y zWllHr;*gzw{v?as^wpa=xwh|3V4tEl-Rl6%Jzf3mzSeNQFa%2xwi*Z@a!;vdq8}< z^GY7MrPJAhc-6Nr@Z(kBn_jz~gKN4#5U<4a*$KSd+gW3IOWC%Yr|>?CLPXm%Ge*Vf Y2D^A!x7Y0F^