[ENG-862, ENG-921] Ephemeral locations (#1092)

* 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 <vasconcellos.dev@gmail.com>
Co-authored-by: Oscar Beaumont <oscar@otbeaumont.me>
This commit is contained in:
Ericson "Fogo" Soares 2023-08-23 14:26:07 -03:00 committed by GitHub
parent 47af1a9080
commit 28d106a2d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 1582 additions and 741 deletions

View file

@ -3,6 +3,7 @@
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer",
"oscartbeaumont.rspc-vscode",
"EditorConfig.EditorConfig"
"EditorConfig.EditorConfig",
"bradlc.vscode-tailwindcss"
]
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }) => (
<Pressable onPress={() => handlePress(item)}>
{layoutMode === 'grid' ? (

View file

@ -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 (
<FileThumbWrapper size={size}>

View file

@ -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 (
<View style={twStyle('mt-1 flex flex-row flex-wrap', style)}>
{/* Kind */}
<InfoPill
containerStyle={tw`mr-1`}
text={isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]!}
/>
<InfoPill containerStyle={tw`mr-1`} text={getExplorerItemData(data).kind} />
{/* Extension */}
{filePath?.extension && (
<InfoPill text={filePath.extension} containerStyle={tw`mr-1`} />

View file

@ -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<ModalRef, FileInfoModalProps>((props, ref) => {
title="Created"
value={dayjs(item?.date_created).format('MMM Do YYYY')}
/>
{/* Indexed */}
<MetaItem
icon={Barcode}
title="Indexed"
value={dayjs(filePathData?.date_indexed).format('MMM Do YYYY')}
/>
{filePathData && (
{filePathData && 'cas_id' in filePathData && (
<>
{/* Indexed */}
<MetaItem
icon={Barcode}
title="Indexed"
value={dayjs(filePathData.date_indexed).format('MMM Do YYYY')}
/>
{/* TODO: Note */}
{filePathData.cas_id && (
<MetaItem

View file

@ -17,7 +17,7 @@ import {
import { SettingsItem } from '~/components/settings/SettingsItem';
import { useZodForm, z } from '~/hooks/useZodForm';
import { tw, twStyle } from '~/lib/tailwind';
import { SettingsStackScreenProps } from '~/navigation/SettingsNavigator';
import { type SettingsStackScreenProps } from '~/navigation/SettingsNavigator';
const schema = z.object({
displayName: z.string().nullable(),
@ -185,7 +185,12 @@ const EditLocationSettingsScreen = ({
<SettingsItem
title="Full Reindex"
rightArea={
<AnimatedButton size="sm" onPress={() => fullRescan.mutate({ location_id: id, reidentify_objects: true })}>
<AnimatedButton
size="sm"
onPress={() =>
fullRescan.mutate({ location_id: id, reidentify_objects: true })
}
>
<ArrowsClockwise color="white" size={20} />
</AnimatedButton>
}

View file

@ -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<Vec<String>>,
item: location::Data,
},
NonIndexedPath {
has_local_thumbnail: bool,
thumbnail_key: Option<Vec<String>>,
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<Utc> {
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<Utc> {
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 });

View file

@ -27,7 +27,7 @@ mod files;
mod jobs;
mod keys;
mod libraries;
mod locations;
pub mod locations;
mod nodes;
pub mod notifications;
mod p2p;

View file

@ -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<Ctx> {
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<FilePathSearchOrdering>,
}
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")]

View file

@ -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<DateTime<Utc>>) {
let notification = Notification {
id: NotificationId::Node(self.notifications._internal_next_id()),

View file

@ -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<RulePerKind>,
default: bool,
}
impl From<SystemIndexerRule> 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,

View file

@ -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<Path>,
node: &Arc<Node>,
) {
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<Path>,

View file

@ -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<Library>,
location_pub_id: Uuid,
location_path: impl AsRef<Path>,
indexer_rules_ids: &[i32],
dry_run: bool,
) -> Result<Option<CreatedLocationResult>, LocationError> {
let Library { db, sync, .. } = &**library;
let mut path = location_path.as_ref().to_path_buf();
pub(crate) fn normalize_path(path: impl AsRef<Path>) -> 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<Library>,
location_pub_id: Uuid,
location_path: impl AsRef<Path>,
indexer_rules_ids: &[i32],
dry_run: bool,
) -> Result<Option<CreatedLocationResult>, 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<Path>,
node: &Arc<Node>,
) {
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),
});
}

View file

@ -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<NonIndexedLocationError> 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<P: AsRef<Path>> 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<ExplorerItem>,
pub errors: Vec<rspc::Error>,
}
#[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<Utc>,
pub date_modified: DateTime<Utc>,
pub size_in_bytes_bytes: Vec<u8>,
}
pub async fn walk(
full_path: impl AsRef<Path>,
with_hidden_files: bool,
node: Arc<Node>,
library: Arc<Library>,
) -> Result<NonIndexedFileSystemEntries, NonIndexedLocationError> {
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::<HashMap<_, _>>();
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 })
}

View file

@ -155,6 +155,12 @@ pub async fn generate_image_thumbnail<P: AsRef<Path>>(
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)
}

View file

@ -155,8 +155,6 @@ impl PreferenceKVs {
acc
});
dbg!(&entries);
T::from_entries(entries)
}
}

View file

@ -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<PathBuf>,
#[specta(type = String)]
#[serde_as(as = "DisplayFromStr")]
@ -124,8 +124,15 @@ pub async fn get_volumes() -> Vec<Volume> {
.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<Volume> {
// 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<Volume> {
});
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<Volume> {
}
}
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 {

View file

@ -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<Path>,
output_thumbnail_path: impl AsRef<Path>,
) -> 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?,

View file

@ -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<ObjectKind>();
const set = new Set<ObjectKindEnum>();
for (const o of selectedObjects) {
if (o.kind === null || !ConvertableKinds.includes(o.kind)) break;

View file

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

View file

@ -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 }) => (
<SeparatedConditional items={[SharedItems.Details]} />
<ContextMenu.Separator />
<Conditional
<SeparatedConditional
items={[
SharedItems.RevealInNativeExplorer,
SharedItems.Rename,

View file

@ -1,6 +1,6 @@
import clsx from 'clsx';
import {
ComponentProps,
type ComponentProps,
forwardRef,
useCallback,
useEffect,
@ -16,7 +16,7 @@ import { useIsTextTruncated, useOperatingSystem } from '~/hooks';
import { useExplorerViewContext } from '../ViewContext';
type Props = ComponentProps<'div'> & {
itemId: number;
itemId?: null | number;
locationId: number | null;
text: string | null;
activeClassName?: string;
@ -76,13 +76,10 @@ export const RenameTextBoxBase = forwardRef<HTMLDivElement | null, Props>(
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<HTMLDivElement | null, Props>(
});
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<Props, 'renameHandler'>) => {
// 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<Props, 'renameHandler'>) => {
indexer_rules_ids: []
});
} catch (e) {
reset();
showAlertDialog({
title: 'Error',
value: String(e)

View file

@ -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<string>();
const [loaded, setLoaded] = useState<boolean>(false);
const [thumbType, setThumbType] = useState<ThumbType>('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 (
<div
@ -160,10 +164,9 @@ export const FileThumb = memo((props: ThumbProps) => {
);
switch (thumbType) {
case 'original': {
case ThumbType.Original: {
switch (itemData.extension === 'pdf' ? 'PDF' : itemData.kind) {
case 'PDF':
if (!pdfViewerEnabled()) return;
return (
<PDFViewer
src={src}
@ -177,7 +180,6 @@ export const FileThumb = memo((props: ThumbProps) => {
crossOrigin="anonymous" // Here it is ok, because it is not a react attr
/>
);
case 'Text':
return (
<TEXTViewer
@ -246,7 +248,7 @@ export const FileThumb = memo((props: ThumbProps) => {
}
// eslint-disable-next-line no-fallthrough
case 'thumbnail':
case ThumbType.Thumbnail:
return (
<Thumbnail
src={src}
@ -258,12 +260,13 @@ export const FileThumb = memo((props: ThumbProps) => {
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<HTMLImageElement>(null);
const size = useSize(ref);
const { style: blackBarsStyle } = useBlackBars(size, blackBarsSize);
return (

View file

@ -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<HTMLDivElement> {
showThumbnail?: boolean;
@ -92,7 +113,7 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => {
<>
{lastThreeItems.map((item, i, thumbs) => (
<FileThumb
key={item.item.id}
key={uniqueId(item)}
data={item}
loadOriginal
frame
@ -107,7 +128,7 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => {
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 (
<>
<h3 className="truncate px-3 pb-1 pt-2 text-base font-bold">
{filePathData?.name}
{filePathData?.extension && `.${filePathData.extension}`}
{name}
{extension && `.${extension}`}
</h3>
{objectData && (
@ -173,38 +205,27 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
<Divider />
<MetaContainer>
<MetaData
icon={Cube}
label="Size"
value={`${byteSize(filePathData?.size_in_bytes_bytes)}`}
/>
<MetaData icon={Cube} label="Size" value={`${size}`} />
<MetaData icon={Clock} label="Created" value={formatDate(item.item.date_created)} />
<MetaData icon={Clock} label="Created" value={formatDate(dateCreated)} />
<MetaData
icon={Eraser}
label="Modified"
value={formatDate(filePathData?.date_modified)}
/>
<MetaData icon={Eraser} label="Modified" value={formatDate(dateModified)} />
<MetaData
icon={Barcode}
label="Indexed"
value={formatDate(filePathData?.date_indexed)}
/>
<MetaData
icon={FolderOpen}
label="Accessed"
value={formatDate(objectData?.date_accessed)}
/>
{isNonIndexed || (
<MetaData icon={Barcode} label="Indexed" value={formatDate(dateIndexed)} />
)}
{isNonIndexed || (
<MetaData icon={FolderOpen} label="Accessed" value={formatDate(dateAccessed)} />
)}
<MetaData
icon={Path}
label="Path"
value={fileFullPath.data}
value={fileFullPath}
onClick={() => {
// TODO: Add toast notification
fileFullPath.data && navigator.clipboard.writeText(fileFullPath.data);
fileFullPath && navigator.clipboard.writeText(fileFullPath);
}}
/>
</MetaContainer>
@ -212,9 +233,9 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
<Divider />
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
<InfoPill>{isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]}</InfoPill>
<InfoPill>{isDir ? 'Folder' : kind}</InfoPill>
{filePathData?.extension && <InfoPill>{filePathData.extension}</InfoPill>}
{extension && <InfoPill>{extension}</InfoPill>}
{tags.data?.map((tag) => (
<Tooltip key={tag.id} label={tag.name || ''} className="flex overflow-hidden">
@ -246,21 +267,19 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
<Divider />
<MetaContainer>
<MetaData
icon={Snowflake}
label="Content ID"
value={filePathData?.cas_id}
/>
{isNonIndexed || (
<MetaData icon={Snowflake} label="Content ID" value={casId} />
)}
{filePathData?.integrity_checksum && (
{integrityChecksum && (
<MetaData
icon={CircleWavyCheck}
label="Checksum"
value={filePathData.integrity_checksum}
value={integrityChecksum}
/>
)}
<MetaData icon={Hash} label="Object ID" value={pubId} />
{isNonIndexed || <MetaData icon={Hash} label="Object ID" value={pubId} />}
</MetaContainer>
</>
)}
@ -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<ExplorerItem['type']>;
kinds: Map<string, ExplorerItem[]>;
}
),
[items, getDate]
);
const onlyNonIndexed = metadata.types.has('NonIndexedPath') && metadata.types.size === 1;
return (
<>
<MetaContainer>
<MetaData icon={Cube} label="Size" value={`${byteSize(metadata.size)}`} />
<MetaData icon={Clock} label="Created" value={formatDate(metadata.created)} />
<MetaData icon={Eraser} label="Modified" value={formatDate(metadata.modified)} />
<MetaData icon={Barcode} label="Indexed" value={formatDate(metadata.indexed)} />
<MetaData
icon={FolderOpen}
label="Accessed"
value={formatDate(metadata.accessed)}
/>
{onlyNonIndexed || (
<MetaData icon={Barcode} label="Indexed" value={formatDate(metadata.indexed)} />
)}
{onlyNonIndexed || (
<MetaData
icon={FolderOpen}
label="Accessed"
value={formatDate(metadata.accessed)}
/>
)}
</MetaContainer>
<Divider />

View file

@ -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) {
<Dialog.Title className="mx-auto my-2 font-bold">
Preview -{' '}
<span className="inline-block max-w-xs truncate align-sub text-sm text-ink-dull">
{'name' in item && item.name
? item.name
: 'Unknown Object'}
{name || 'Unknown Object'}
</span>
</Dialog.Title>
</nav>

View file

@ -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<Selecto>;
selectoUnSelected: React.MutableRefObject<Set<ExplorerItemHash>>;
selectoUnSelected: React.MutableRefObject<Set<string>>;
} | null>(null);
type RenderItem = (item: { item: ExplorerItem; selected: boolean; cut: boolean }) => ReactNode;
@ -24,11 +32,12 @@ const GridListItem = (props: {
onMouseDown: (e: React.MouseEvent<HTMLDivElement, 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<Selecto>(null);
const selectoUnSelected = useRef<Set<ExplorerItemHash>>(new Set());
const selectoUnSelected = useRef<Set<string>>(new Set());
const selectoFirstColumn = useRef<number | undefined>();
const selectoLastColumn = useRef<number | undefined>();
@ -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));
}
}
});

View file

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

View file

@ -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 (
<div className="relative flex items-center">
@ -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 (
<InfoPill className="bg-app-button/50">
{isPath(file) && file.item.is_dir
? 'Folder'
: ObjectKind[getItemObject(file)?.kind || 0]}
</InfoPill>
);
}
accessorFn: (file) => getExplorerItemData(file).kind,
cell: (cell) => (
<InfoPill className="bg-app-button/50">
{getExplorerItemData(cell.row.original).kind}
</InfoPill>
)
},
{
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<HTMLDivElement, MouseEvent>,
row: Row<ExplorerItem>
) {
// 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 (
<div

View file

@ -1,6 +1,6 @@
/* eslint-disable no-case-declarations */
import clsx from 'clsx';
import { ExplorerItem, getItemFilePath, getItemLocation } from '@sd/client';
import { type ExplorerItem } from '@sd/client';
import { RenameLocationTextBox, RenamePathTextBox } from '../FilePath/RenameTextBox';
export default function RenamableItemText(props: {
@ -22,32 +22,37 @@ export default function RenamableItemText(props: {
disabled: !selected || disabled
};
switch (item.type) {
case 'Path':
case 'Object':
const filePathData = getItemFilePath(item);
if (!filePathData) break;
if (item.type === 'Location') {
const locationData = item.item;
return (
<RenameLocationTextBox
locationId={locationData.id}
itemId={locationData.id}
text={locationData.name}
{...sharedProps}
/>
);
} else {
const filePathData =
item.type === 'Path' || item.type === 'NonIndexedPath'
? item.item
: item.type === 'Object'
? item.item.file_paths[0]
: null;
if (filePathData) {
return (
<RenamePathTextBox
itemId={filePathData.id}
itemId={'id' in filePathData ? filePathData.id : null}
text={filePathData.name}
extension={filePathData.extension}
isDir={filePathData.is_dir || false}
locationId={filePathData.location_id}
{...sharedProps}
/>
);
case 'Location':
const locationData = getItemLocation(item);
if (!locationData) break;
return (
<RenameLocationTextBox
locationId={locationData.id}
itemId={locationData.id}
text={locationData.name}
locationId={'location_id' in filePathData ? filePathData.location_id : null}
{...sharedProps}
/>
);
}
}
return <div />;
}

View file

@ -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<T = FilePath | Location>(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;

View file

@ -1,6 +1,4 @@
import { ReactNode, RefObject, createContext, useContext } from 'react';
export type ExplorerViewSelection = number | Set<number>;
import { type ReactNode, type RefObject, createContext, useContext } from 'react';
export interface ExplorerViewContext {
ref: RefObject<HTMLDivElement>;

View file

@ -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<Props>) {
<div className="flex-1 overflow-hidden">
<div
ref={explorer.scrollRef}
className="explorer-scroll relative h-screen overflow-x-hidden overflow-y-auto"
className="explorer-scroll relative h-screen overflow-y-auto overflow-x-hidden"
style={{
paddingTop: TOP_BAR_HEIGHT,
paddingRight: explorerStore.showInspector ? INSPECTOR_WIDTH : 0

View file

@ -1,12 +1,13 @@
import type { ReadonlyDeep } from 'type-fest';
import { proxy, useSnapshot } from 'valtio';
import { proxySet } from 'valtio/utils';
import { z } from 'zod';
import {
DoubleClickAction,
ExplorerItem,
ExplorerLayout,
ExplorerSettings,
SortOrder,
type DoubleClickAction,
type ExplorerItem,
type ExplorerLayout,
type ExplorerSettings,
type SortOrder,
resetStore
} from '@sd/client';
@ -139,9 +140,10 @@ export function getExplorerStore() {
return explorerStore;
}
export function isCut(id: number) {
const state = explorerStore.cutCopyState;
return state.type === 'Cut' && state.sourcePathIds.includes(id);
export function isCut(item: ExplorerItem, cutCopyState: ReadonlyDeep<CutCopyState>) {
return item.type === 'NonIndexedPath'
? false
: cutCopyState.type === 'Cut' && cutCopyState.sourcePathIds.includes(item.item.id);
}
export const filePathOrderingKeysSchema = z.union([

View file

@ -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<TOrder extends Ordering> {
settings: ReturnType<typeof useExplorerSettings<TOrder>>;
}
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<TOrder extends Ordering>({
orderingKeys
}: {
settings: ReturnType<typeof createDefaultExplorerSettings<TOrder>>;
onSettingsChanged: (settings: ExplorerSettings<TOrder>) => any;
onSettingsChanged?: (settings: ExplorerSettings<TOrder>) => any;
orderingKeys?: z.ZodUnion<
[z.ZodLiteral<OrderingKeys<TOrder>>, ...z.ZodLiteral<OrderingKeys<TOrder>>[]]
>;
@ -94,7 +94,7 @@ export function useExplorerSettings<TOrder extends Ordering>({
useEffect(
() =>
subscribe(store, () => {
onSettingsChanged(snapshot(store) as ExplorerSettings<TOrder>);
onSettingsChanged?.(snapshot(store) as ExplorerSettings<TOrder>);
}),
[onSettingsChanged, store]
);
@ -113,12 +113,12 @@ export type UseExplorerSettings<TOrder extends Ordering> = 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<ExplorerItem, ExplorerItemHash>());
const itemHashesWeakMap = useRef(new WeakMap<ExplorerItem, string>());
// 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<ExplorerItemHash>()
value: new Set<string>()
}));
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<ExplorerItemHash, ExplorerItem>()),
}, new Map<string, ExplorerItem>()),
[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]

View file

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

View file

@ -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 () => {
</SidebarLink>
)}
</div>
<EphemeralSection />
{library && (
<LibraryContextProvider library={library}>
<LibrarySection />

View file

@ -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<string | null>(null);
const platform = usePlatform();
platform.userHomeDir?.().then(setHome);
const volumes = useBridgeQuery(['volumes.list']).data ?? [];
return home == null && volumes.length < 1 ? null : (
<>
<Section name="Explore">
{home && (
<SidebarLink
to={`ephemeral/0?path=${home}`}
className="group relative w-full border border-transparent"
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<span className="truncate">Home</span>
</SidebarLink>
)}
{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 (
<SidebarLink
to={`ephemeral/${key}?path=${mountPoint}`}
key={key}
className="group relative w-full border border-transparent"
>
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
<Folder size={18} />
</div>
<span className="truncate">{name}</span>
</SidebarLink>
);
});
})}
</Section>
</>
);
};

View file

@ -65,12 +65,10 @@ export const LibrarySection = () => {
<Section
name="Nodes"
actionArea={
isPairingEnabled ? (
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton />
</Link>
) : (
<SubtleButton />
)
}
>

View file

@ -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 (
<div className="group mt-5">
<div className="mb-1 flex items-center justify-between">
<CategoryHeading className="ml-1">{props.name}</CategoryHeading>
<div className="text-sidebar-inkFaint opacity-0 transition-all duration-300 hover:!opacity-100 group-hover:opacity-30">
{props.actionArea}
</div>
{props.actionArea && (
<div className="text-sidebar-inkFaint opacity-0 transition-all duration-300 hover:!opacity-100 group-hover:opacity-30">
{props.actionArea}
</div>
)}
</div>
{props.children}
</div>

View file

@ -5,6 +5,7 @@ import TopBar from '.';
interface TopBarContext {
left: HTMLDivElement | null;
right: HTMLDivElement | null;
setNoSearch: (value: boolean) => void;
}
const TopBarContext = createContext<TopBarContext | null>(null);
@ -12,10 +13,11 @@ const TopBarContext = createContext<TopBarContext | null>(null);
export const Component = () => {
const [left, setLeft] = useState<HTMLDivElement | null>(null);
const [right, setRight] = useState<HTMLDivElement | null>(null);
const [noSearch, setNoSearch] = useState(false);
return (
<TopBarContext.Provider value={{ left, right }}>
<TopBar leftRef={setLeft} rightRef={setRight} />
<TopBarContext.Provider value={{ left, right, setNoSearch }}>
<TopBar leftRef={setLeft} rightRef={setRight} noSearch={noSearch} />
<Outlet />
</TopBarContext.Provider>
);

View file

@ -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)}
</>
);
};

View file

@ -37,7 +37,7 @@ export default ({ options }: TopBarChildrenProps) => {
}, []);
return (
<div data-tauri-drag-region className="flex w-full flex-row justify-end">
<div data-tauri-drag-region className="flex flex-row">
<div data-tauri-drag-region className={`flex gap-0`}>
{options?.map((group, groupIndex) => {
return group.map(

View file

@ -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<HTMLDivElement>;
rightRef?: Ref<HTMLDivElement>;
noSearch?: boolean;
}
const TopBar = (props: Props) => {
@ -17,20 +18,21 @@ const TopBar = (props: Props) => {
return (
<div
data-tauri-drag-region
style={{ height: TOP_BAR_HEIGHT }}
className={clsx(
'duration-250 top-bar-blur absolute left-0 top-0 z-50 flex',
'h-[46px] w-full flex-row items-center justify-center overflow-hidden',
'w-full flex-row items-center justify-between overflow-hidden',
'border-b border-sidebar-divider bg-app/90 px-3.5',
'transition-[background-color,border-color] ease-out',
isDragging && 'pointer-events-none'
)}
>
<div data-tauri-drag-region className="flex flex-1 flex-row items-center">
<div data-tauri-drag-region className="flex min-w-0 flex-row items-center">
<NavigationButtons />
<div ref={props.leftRef} />
<div ref={props.leftRef} className="contents" />
</div>
<SearchBar />
<div className="flex-1" ref={props.rightRef} />
{props.noSearch || <SearchBar />}
<div ref={props.rightRef} className="contents" />
</div>
);
};

View file

@ -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<FilePathSearchOrdering>({
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 (
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal
left={
<Tooltip
label="Add path as an indexed location"
className="w-max min-w-0 shrink"
>
<AddLocationButton path={path} />
</Tooltip>
}
right={<DefaultTopBarOptions />}
noSearch={true}
/>
<Explorer />
</ExplorerContextProvider>
);
});
export const Component = () => {
const [pathParams] = useZodSearchParams(PathParamsSchema);
const path = useDeferredValue(pathParams);
return (
<Suspense>
<EphemeralExplorer args={path} />
</Suspense>
);
};

View file

@ -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') }
];

View file

@ -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
</PopoverSection>
<PopoverDivider />
<PopoverSection>
<OptionButton onClick={() => scanLocationSubPath.mutate(
{
location_id: location.id,
sub_path: path ?? ''
<OptionButton
onClick={() =>
scanLocationSubPath.mutate({
location_id: location.id,
sub_path: path ?? ''
})
}
)}>
>
<FolderDotted />
Re-index
</OptionButton>

View file

@ -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 ? (
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer
emptyNotice={<EmptyNotice message={`No results found for "${search}"`} />}
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer
emptyNotice={
<EmptyNotice
icon={
search ? (
<MagnifyingGlass
size={110}
className="mb-5 text-ink-faint"
opacity={0.3}
/>
) : null
}
message={
search ? `No results found for "${search}"` : 'Search for files...'
}
/>
</ExplorerContextProvider>
) : (
<div className="flex flex-1 flex-col items-center justify-center">
<MagnifyingGlass size={110} className="mb-5 text-ink-faint" opacity={0.3} />
<p className="text-xs text-ink-faint">Search for files...</p>
</div>
)}
</>
}
/>
</ExplorerContextProvider>
);
});

View file

@ -228,7 +228,12 @@ const EditLocationForm = () => {
<FlexCol>
<div>
<Button
onClick={() => fullRescan.mutate({ location_id: locationId, reidentify_objects: true })}
onClick={() =>
fullRescan.mutate({
location_id: locationId,
reidentify_objects: true
})
}
size="sm"
variant="outline"
>

View file

@ -1,30 +1,83 @@
import clsx from 'clsx';
import { Button, ButtonProps, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components/AlertDialog';
import { motion } from 'framer-motion';
import { FolderSimplePlus } from 'phosphor-react';
import { useRef, useState } from 'react';
import { Button, type ButtonProps, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { useCallbackToWatchResize } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { AddLocationDialog, openDirectoryPickerDialog } from './AddLocationDialog';
export const AddLocationButton = ({ className, ...props }: ButtonProps) => {
interface AddLocationButton extends ButtonProps {
path?: string;
}
export const AddLocationButton = ({ path, className, ...props }: AddLocationButton) => {
const platform = usePlatform();
const transition = {
type: 'keyframes',
ease: 'easeInOut',
repeat: Infinity,
duration: 5
};
const textRef = useRef<HTMLSpanElement>(null);
const overflowRef = useRef<HTMLSpanElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useCallbackToWatchResize(() => {
const text = textRef.current;
const overflow = overflowRef.current;
if (!(text && overflow)) return;
setIsOverflowing(text.scrollWidth > overflow.clientWidth);
}, [overflowRef, textRef]);
return (
<>
<Button
variant="dotted"
className={clsx('w-full', className)}
onClick={() =>
openDirectoryPickerDialog(platform)
.then((path) => {
if (path !== '')
dialogManager.create((dp) => (
<AddLocationDialog path={path ?? ''} {...dp} />
));
})
.catch((error) => showAlertDialog({ title: 'Error', value: String(error) }))
}
onClick={async () => {
if (!path) {
try {
path = (await openDirectoryPickerDialog(platform)) ?? undefined;
} catch (error) {
showAlertDialog({ title: 'Error', value: String(error) });
}
}
if (path)
dialogManager.create((dp) => (
<AddLocationDialog path={path ?? ''} {...dp} />
));
}}
{...props}
>
Add Location
{path ? (
<div className="flex h-full w-full flex-row items-end whitespace-nowrap font-mono text-sm">
<FolderSimplePlus size={22} className="shrink-0" />
<div className="ml-1 overflow-hidden">
<motion.span
ref={overflowRef}
animate={isOverflowing && { x: ['0%', '100%', '0%'] }}
className="inline-block w-full"
transition={{ ...transition }}
>
<motion.span
ref={textRef}
animate={isOverflowing && { x: ['0%', '-100%', '0%'] }}
className="inline-block w-auto"
transition={{ ...transition }}
>
{path}
</motion.span>
</motion.span>
</div>
</div>
) : (
'Add Location'
)}
</Button>
</>
);

View file

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

View file

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

View file

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

View file

@ -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<string>;
// Opens a file path with a given ID
openFilePaths?(library: string, ids: number[]): any;
revealItems?(

View file

@ -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": {

View file

@ -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<null>, result: LibraryPreferences } |
{ key: "search.ephemeral-paths", input: LibraryArgs<NonIndexedPath>, result: NonIndexedFileSystemEntries } |
{ key: "search.objects", input: LibraryArgs<ObjectSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
@ -115,7 +116,14 @@ export type DoubleClickAction = "openFile" | "quickPreview"
export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined<string> }
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.
*/

View file

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

View file

@ -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[]) => {

View file

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

View file

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

View file

@ -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 <div className="indeterminate-progress-bar h-1 bg-app-button">
<div className="indeterminate-progress-bar__progress bg-accent"></div>
</div>
return (
<div className="indeterminate-progress-bar h-1 bg-app-button">
<div className="indeterminate-progress-bar__progress bg-accent"></div>
</div>
);
}
return (
<ProgressPrimitive.Root
value={percentage}
className={clsx("h-1 w-[94%] overflow-hidden rounded-full bg-app-button")}
className={clsx('h-1 w-[94%] overflow-hidden rounded-full bg-app-button')}
>
<ProgressPrimitive.Indicator
style={{ width: `${percentage}%` }}
className={clsx("h-full bg-accent duration-500 ease-in-out")}
className={clsx('h-full bg-accent duration-500 ease-in-out')}
/>
</ProgressPrimitive.Root>
);
});

View file

@ -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)}
>
<SliderPrimitive.Track className="bg-app-slider relative h-2 grow rounded-full outline-none">
<SliderPrimitive.Track className="relative h-2 grow rounded-full bg-app-slider outline-none">
<SliderPrimitive.Range className="absolute h-full rounded-full bg-accent outline-none" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb

View file

@ -49,7 +49,7 @@ importers:
version: 5.0.4
vite:
specifier: ^4.3.9
version: 4.3.9(@types/node@18.15.1)
version: 4.3.9(less@4.1.3)
apps/desktop:
dependencies:
@ -566,7 +566,7 @@ importers:
version: 5.0.4
vite:
specifier: ^4.0.4
version: 4.3.9(@types/node@18.15.1)
version: 4.3.9(less@4.1.3)
vite-plugin-html:
specifier: ^3.2.0
version: 3.2.0(vite@4.3.9)
@ -606,7 +606,7 @@ importers:
version: 4.8.2
vite:
specifier: ^4.0.4
version: 4.3.9(@types/node@18.15.1)
version: 4.3.9(less@4.1.3)
interface:
dependencies:
@ -760,6 +760,9 @@ importers:
ts-deepmerge:
specifier: ^6.0.3
version: 6.0.3
type-fest:
specifier: ^4.2.0
version: 4.2.0
use-count-up:
specifier: ^3.0.1
version: 3.0.1(react@18.2.0)
@ -4790,6 +4793,7 @@ packages:
/@emotion/memoize@0.7.4:
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
requiresBuild: true
dev: false
optional: true
@ -8663,7 +8667,7 @@ packages:
remark-slug: 6.1.0
rollup: 3.24.1
typescript: 5.0.4
vite: 4.3.9(@types/node@18.15.1)
vite: 4.3.9(less@4.1.3)
transitivePeerDependencies:
- supports-color
dev: true
@ -9265,7 +9269,7 @@ packages:
react: 18.2.0
react-docgen: 6.0.0-alpha.3
react-dom: 18.2.0(react@18.2.0)
vite: 4.3.9(@types/node@18.15.1)
vite: 4.3.9(less@4.1.3)
transitivePeerDependencies:
- '@preact/preset-vite'
- supports-color
@ -10677,7 +10681,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.22.1)
magic-string: 0.26.7
react-refresh: 0.14.0
vite: 4.3.9(sass@1.55.0)
vite: 4.3.9(@types/node@18.15.1)
transitivePeerDependencies:
- supports-color
@ -10922,6 +10926,7 @@ packages:
/amdefine@1.0.1:
resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==}
engines: {node: '>=0.4.2'}
requiresBuild: true
dev: false
optional: true
@ -11548,6 +11553,7 @@ packages:
/boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
requiresBuild: true
dev: true
optional: true
@ -12287,6 +12293,7 @@ packages:
/config-chain@1.1.13:
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
requiresBuild: true
dependencies:
ini: 1.3.8
proto-list: 1.2.4
@ -12654,8 +12661,8 @@ packages:
'@daybrush/utils': 1.13.0
dev: false
/css-to-mat@1.0.3:
resolution: {integrity: sha512-HADRhVqPc8wFqEp6ClK+uuPYg+FMBinNo2ReLyI/KQCncmHPJ60o5zldyJG7NjsTqXWbdfGJO51jnoxfMvWJiA==}
/css-to-mat@1.1.1:
resolution: {integrity: sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==}
dependencies:
'@daybrush/utils': 1.13.0
'@scena/matrix': 1.1.1
@ -12983,6 +12990,7 @@ packages:
/detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
requiresBuild: true
dev: true
optional: true
@ -13433,6 +13441,7 @@ packages:
/es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
requiresBuild: true
dev: true
optional: true
@ -15255,6 +15264,7 @@ packages:
/glob@6.0.4:
resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==}
requiresBuild: true
dependencies:
inflight: 1.0.6
inherits: 2.0.4
@ -15870,6 +15880,7 @@ packages:
/iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
requiresBuild: true
dependencies:
safer-buffer: 2.1.2
optional: true
@ -16894,6 +16905,7 @@ packages:
/json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
requiresBuild: true
dev: true
optional: true
@ -17362,6 +17374,7 @@ packages:
/matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'}
requiresBuild: true
dependencies:
escape-string-regexp: 4.0.0
dev: true
@ -19008,6 +19021,7 @@ packages:
/ncp@2.0.0:
resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==}
hasBin: true
requiresBuild: true
dev: false
optional: true
@ -19213,6 +19227,7 @@ packages:
/npm-conf@1.1.3:
resolution: {integrity: sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==}
engines: {node: '>=4'}
requiresBuild: true
dependencies:
config-chain: 1.1.13
pify: 3.0.0
@ -20237,6 +20252,7 @@ packages:
/proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
requiresBuild: true
dev: true
optional: true
@ -20296,6 +20312,7 @@ packages:
/prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
requiresBuild: true
optional: true
/pseudomap@1.0.2:
@ -21626,6 +21643,7 @@ packages:
/rimraf@2.4.5:
resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==}
hasBin: true
requiresBuild: true
dependencies:
glob: 6.0.4
dev: false
@ -21660,6 +21678,7 @@ packages:
/roarr@2.15.4:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
requiresBuild: true
dependencies:
boolean: 3.2.0
detect-node: 2.1.0
@ -21874,7 +21893,7 @@ packages:
'@scena/dragscroll': 1.4.0
'@scena/event-emitter': 1.0.5
css-styled: 1.0.8
css-to-mat: 1.0.3
css-to-mat: 1.1.1
framework-utils: 1.1.0
gesto: 1.19.1
keycon: 1.4.0
@ -21883,6 +21902,7 @@ packages:
/semver-compare@1.0.0:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
requiresBuild: true
dev: true
optional: true
@ -21953,6 +21973,7 @@ packages:
/serialize-error@7.0.1:
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
engines: {node: '>=10'}
requiresBuild: true
dependencies:
type-fest: 0.13.1
dev: true
@ -22297,6 +22318,7 @@ packages:
/sprintf-js@1.1.2:
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
requiresBuild: true
dev: true
optional: true
@ -23451,6 +23473,7 @@ packages:
/tunnel@0.0.6:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
requiresBuild: true
dev: true
optional: true
@ -23560,6 +23583,7 @@ packages:
/type-fest@0.13.1:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
requiresBuild: true
dev: true
optional: true
@ -23605,6 +23629,11 @@ packages:
engines: {node: '>=14.16'}
dev: false
/type-fest@4.2.0:
resolution: {integrity: sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==}
engines: {node: '>=16'}
dev: false
/type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@ -24251,7 +24280,7 @@ packages:
dependencies:
'@rollup/pluginutils': 4.2.1
'@svgr/core': 6.5.1
vite: 4.3.9(sass@1.55.0)
vite: 4.3.9(@types/node@18.15.1)
transitivePeerDependencies:
- supports-color
dev: true
@ -24265,7 +24294,7 @@ packages:
globrex: 0.1.2
recrawl-sync: 2.2.3
tsconfig-paths: 4.2.0
vite: 4.3.9(@types/node@18.15.1)
vite: 4.3.9(less@4.1.3)
transitivePeerDependencies:
- supports-color
dev: true