mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-02 11:13:29 +00:00
[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:
parent
47af1a9080
commit
28d106a2d5
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -3,6 +3,7 @@
|
|||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"oscartbeaumont.rspc-vscode",
|
||||
"EditorConfig.EditorConfig"
|
||||
"EditorConfig.EditorConfig",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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' ? (
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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`} />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -27,7 +27,7 @@ mod files;
|
|||
mod jobs;
|
||||
mod keys;
|
||||
mod libraries;
|
||||
mod locations;
|
||||
pub mod locations;
|
||||
mod nodes;
|
||||
pub mod notifications;
|
||||
mod p2p;
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
246
core/src/location/non_indexed.rs
Normal file
246
core/src/location/non_indexed.rs
Normal 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 })
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -155,8 +155,6 @@ impl PreferenceKVs {
|
|||
acc
|
||||
});
|
||||
|
||||
dbg!(&entries);
|
||||
|
||||
T::from_entries(entries)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {};
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 />
|
||||
|
|
58
interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx
Normal file
58
interface/app/$libraryId/Layout/Sidebar/EphemeralSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -65,12 +65,10 @@ export const LibrarySection = () => {
|
|||
<Section
|
||||
name="Nodes"
|
||||
actionArea={
|
||||
isPairingEnabled ? (
|
||||
isPairingEnabled && (
|
||||
<Link to="settings/library/nodes">
|
||||
<SubtleButton />
|
||||
</Link>
|
||||
) : (
|
||||
<SubtleButton />
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
99
interface/app/$libraryId/ephemeral.tsx
Normal file
99
interface/app/$libraryId/ephemeral.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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') }
|
||||
];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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?(
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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[]) => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue