[ENG-864] Create directory (#1458)
* folder * wrote function * Abstracting duplicate on file name * Spliting between ephemeral and indexed * Now with more type safety * Forgot to prep * location + path based * bruh * link frontend + error toast * strip main separator * dumb * bruh * create directory Co-authored-by: Brendan Allan <Brendonovich@users.noreply.github.com> * make some reactivity happen --------- Co-authored-by: Ericson Fogo Soares <ericson.ds999@gmail.com> Co-authored-by: Brendan Allan <brendonovich@outlook.com> Co-authored-by: Brendan Allan <Brendonovich@users.noreply.github.com>
@@ -5,14 +5,16 @@ use crate::{
|
||||
library::Library,
|
||||
location::{
|
||||
file_path_helper::{
|
||||
file_path_to_isolate, file_path_to_isolate_with_id, FilePathError, IsolatedFilePathData,
|
||||
file_path_to_full_path, file_path_to_isolate, file_path_to_isolate_with_id,
|
||||
FilePathError, IsolatedFilePathData,
|
||||
},
|
||||
get_location_path_from_location_id, LocationError,
|
||||
},
|
||||
object::{
|
||||
fs::{
|
||||
copy::FileCopierJobInit, cut::FileCutterJobInit, delete::FileDeleterJobInit,
|
||||
erase::FileEraserJobInit,
|
||||
erase::FileEraserJobInit, error::FileSystemJobsError,
|
||||
find_available_filename_for_duplicate,
|
||||
},
|
||||
media::{
|
||||
media_data_extractor::{
|
||||
@@ -31,7 +33,7 @@ use sd_media_metadata::MediaMetadata;
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
path::{Path, PathBuf, MAIN_SEPARATOR, MAIN_SEPARATOR_STR},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -47,6 +49,8 @@ use tracing::{error, warn};
|
||||
|
||||
use super::{Ctx, R};
|
||||
|
||||
const UNTITLED_FOLDER_STR: &str = "Untitled Folder";
|
||||
|
||||
pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
R.router()
|
||||
.procedure("get", {
|
||||
@@ -194,6 +198,53 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.procedure("createFolder", {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct CreateFolderArgs {
|
||||
pub location_id: location::id::Type,
|
||||
pub sub_path: Option<PathBuf>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
R.with2(library()).mutation(
|
||||
|(_, library),
|
||||
CreateFolderArgs {
|
||||
location_id,
|
||||
sub_path,
|
||||
name,
|
||||
}: CreateFolderArgs| async move {
|
||||
let mut path =
|
||||
get_location_path_from_location_id(&library.db, location_id).await?;
|
||||
|
||||
if let Some(sub_path) = sub_path
|
||||
.as_ref()
|
||||
.and_then(|sub_path| sub_path.strip_prefix("/").ok())
|
||||
{
|
||||
path.push(sub_path);
|
||||
}
|
||||
|
||||
path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR));
|
||||
|
||||
dbg!(&path);
|
||||
|
||||
create_directory(path, &library).await
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("createEphemeralFolder", {
|
||||
#[derive(Type, Deserialize)]
|
||||
pub struct CreateEphemeralFolderArgs {
|
||||
pub path: PathBuf,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
R.with2(library()).mutation(
|
||||
|(_, library),
|
||||
CreateEphemeralFolderArgs { mut path, name }: CreateEphemeralFolderArgs| async move {
|
||||
path.push(name.as_deref().unwrap_or(UNTITLED_FOLDER_STR));
|
||||
|
||||
create_directory(path, &library).await
|
||||
},
|
||||
)
|
||||
})
|
||||
.procedure("updateAccessTime", {
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), ids: Vec<i32>| async move {
|
||||
@@ -692,3 +743,43 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
mut target_path: PathBuf,
|
||||
library: &Library,
|
||||
) -> Result<String, rspc::Error> {
|
||||
match fs::metadata(&target_path).await {
|
||||
Ok(metadata) if metadata.is_dir() => {
|
||||
target_path = find_available_filename_for_duplicate(&target_path).await?;
|
||||
}
|
||||
Ok(_) => {
|
||||
return Err(FileSystemJobsError::WouldOverwrite(target_path.into_boxed_path()).into())
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
// Everything is awesome!
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(FileIOError::from((
|
||||
target_path,
|
||||
e,
|
||||
"Failed to access file system and get metadata on directory to be created",
|
||||
))
|
||||
.into())
|
||||
}
|
||||
};
|
||||
|
||||
fs::create_dir(&target_path)
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&target_path, e, "Failed to create directory")))?;
|
||||
|
||||
println!("Created directory: {}", target_path.display());
|
||||
|
||||
invalidate_query!(library, "search.objects");
|
||||
invalidate_query!(library, "search.paths");
|
||||
|
||||
Ok(target_path
|
||||
.file_name()
|
||||
.expect("Failed to get file name")
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use uuid::Uuid;
|
||||
use super::{JobManagerError, JobReport, JobStatus, StatefulJob};
|
||||
|
||||
// db is single threaded, nerd
|
||||
const MAX_WORKERS: usize = 1;
|
||||
const MAX_WORKERS: usize = 5;
|
||||
|
||||
pub enum JobManagerEvent {
|
||||
IngestJob(Arc<Library>, Box<dyn DynJob>),
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
use std::{ffi::OsStr, hash::Hash, path::PathBuf};
|
||||
use std::{hash::Hash, path::PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -22,8 +22,8 @@ use tokio::{fs, io};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use super::{
|
||||
append_digit_to_filename, construct_target_filename, error::FileSystemJobsError,
|
||||
fetch_source_and_target_location_paths, get_file_data_from_isolated_file_path,
|
||||
construct_target_filename, error::FileSystemJobsError, fetch_source_and_target_location_paths,
|
||||
find_available_filename_for_duplicate, get_file_data_from_isolated_file_path,
|
||||
get_many_files_datas, FileData,
|
||||
};
|
||||
|
||||
@@ -173,68 +173,27 @@ impl StatefulJob for FileCopierJobInit {
|
||||
} else {
|
||||
match fs::metadata(target_full_path).await {
|
||||
Ok(_) => {
|
||||
let new_file_name =
|
||||
target_full_path
|
||||
.file_stem()
|
||||
.ok_or(JobError::JobDataNotFound(
|
||||
"No stem on file path, but it's supposed to be a file".to_string(),
|
||||
))?;
|
||||
|
||||
let new_file_full_path_without_suffix = target_full_path.parent().map_or_else(
|
||||
|| {
|
||||
Err(JobError::JobDataNotFound(
|
||||
"No parent for file path, which is supposed to be directory"
|
||||
.to_string(),
|
||||
))
|
||||
},
|
||||
|x| Ok(x.to_path_buf()),
|
||||
)?;
|
||||
|
||||
for i in 1..u32::MAX {
|
||||
let mut new_file_full_path_candidate =
|
||||
new_file_full_path_without_suffix.clone();
|
||||
|
||||
append_digit_to_filename(
|
||||
&mut new_file_full_path_candidate,
|
||||
new_file_name.to_str().ok_or(JobError::JobDataNotFound(
|
||||
"Unable to convert file name to &str".to_string(),
|
||||
))?,
|
||||
target_full_path.extension().and_then(OsStr::to_str),
|
||||
i,
|
||||
);
|
||||
|
||||
match fs::metadata(&new_file_full_path_candidate).await {
|
||||
Ok(_) => {
|
||||
// This candidate already exists, so we try the next one
|
||||
continue;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
fs::copy(
|
||||
&source_file_data.full_path,
|
||||
&new_file_full_path_candidate,
|
||||
)
|
||||
// Already exist a file with this name, so we need to find an available name
|
||||
match find_available_filename_for_duplicate(target_full_path).await {
|
||||
Ok(new_path) => {
|
||||
fs::copy(&source_file_data.full_path, &new_path)
|
||||
.await
|
||||
// Using the ? here because we don't want to increase the completed task
|
||||
// count in case of file system errors
|
||||
.map_err(|e| {
|
||||
FileIOError::from((new_file_full_path_candidate, e))
|
||||
})?;
|
||||
.map_err(|e| FileIOError::from((new_path, e)))?;
|
||||
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(
|
||||
FileIOError::from((new_file_full_path_candidate, e)).into()
|
||||
)
|
||||
}
|
||||
Ok(().into())
|
||||
}
|
||||
}
|
||||
|
||||
Ok(JobRunErrors(vec![FileSystemJobsError::WouldOverwrite(
|
||||
target_full_path.clone().into_boxed_path(),
|
||||
)
|
||||
.to_string()])
|
||||
.into())
|
||||
Err(FileSystemJobsError::FailedToFindAvailableName(path)) => {
|
||||
Ok(JobRunErrors(vec![
|
||||
FileSystemJobsError::WouldOverwrite(path).to_string()
|
||||
])
|
||||
.into())
|
||||
}
|
||||
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
trace!(
|
||||
@@ -251,7 +210,7 @@ impl StatefulJob for FileCopierJobInit {
|
||||
|
||||
Ok(().into())
|
||||
}
|
||||
Err(e) => return Err(FileIOError::from((target_full_path, e)).into()),
|
||||
Err(e) => Err(FileIOError::from((target_full_path, e)).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// use crate::{
|
||||
// library::LibraryContext,
|
||||
// location::{check_virtual_path_exists, fetch_location, LocationError},
|
||||
// };
|
||||
|
||||
// use super::error::VirtualFSError;
|
||||
// use crate::prisma::{file_path, location};
|
||||
|
||||
// // TODO: we should create an action handler for all FS operations, that can work for both local and remote locations
|
||||
// // if the location is remote, we queue a job for that client specifically
|
||||
// // the actual create_folder function should be an option on an enum for all vfs actions
|
||||
// pub async fn create_folder(
|
||||
// location_id: location::id::Type,
|
||||
// path: &str,
|
||||
// name: Option<&str>,
|
||||
// library: &LibraryContext,
|
||||
// ) -> Result<(), VirtualFSError> {
|
||||
// let location = fetch_location(library, location_id)
|
||||
// .exec()
|
||||
// .await?
|
||||
// .ok_or(LocationError::IdNotFound(location_id))?;
|
||||
|
||||
// let name = name.unwrap_or("Untitled Folder");
|
||||
|
||||
// let exists = check_virtual_path_exists(library, location_id, subpath).await?;
|
||||
|
||||
// std::fs::create_dir_all(&obj_path)?;
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::{
|
||||
location::{file_path_helper::FilePathError, LocationError},
|
||||
prisma::file_path,
|
||||
util::{db::MissingFieldError, error::FileIOError},
|
||||
util::{
|
||||
db::MissingFieldError,
|
||||
error::{FileIOError, NonUtf8PathError},
|
||||
},
|
||||
};
|
||||
|
||||
use std::path::Path;
|
||||
@@ -28,4 +31,20 @@ pub enum FileSystemJobsError {
|
||||
WouldOverwrite(Box<Path>),
|
||||
#[error("missing-field: {0}")]
|
||||
MissingField(#[from] MissingFieldError),
|
||||
#[error("no parent for path, which is supposed to be directory: <path='{}'>", .0.display())]
|
||||
MissingParentPath(Box<Path>),
|
||||
#[error("no stem on file path, but it's supposed to be a file: <path='{}'>", .0.display())]
|
||||
MissingFileStem(Box<Path>),
|
||||
#[error(transparent)]
|
||||
FileIO(#[from] FileIOError),
|
||||
#[error(transparent)]
|
||||
NonUTF8Path(#[from] NonUtf8PathError),
|
||||
#[error("failed to find an available name to avoid duplication: <path='{}'>", .0.display())]
|
||||
FailedToFindAvailableName(Box<Path>),
|
||||
}
|
||||
|
||||
impl From<FileSystemJobsError> for rspc::Error {
|
||||
fn from(e: FileSystemJobsError) -> Self {
|
||||
Self::with_cause(rspc::ErrorCode::InternalServerError, e.to_string(), e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,21 @@ use crate::{
|
||||
LocationError,
|
||||
},
|
||||
prisma::{file_path, location, PrismaClient},
|
||||
util::db::{maybe_missing, MissingFieldError},
|
||||
util::{
|
||||
db::{maybe_missing, MissingFieldError},
|
||||
error::{FileIOError, NonUtf8PathError},
|
||||
},
|
||||
};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod create;
|
||||
pub mod delete;
|
||||
pub mod erase;
|
||||
|
||||
@@ -26,6 +31,7 @@ pub mod cut;
|
||||
pub mod error;
|
||||
|
||||
use error::FileSystemJobsError;
|
||||
use tokio::{fs, io};
|
||||
|
||||
static DUPLICATE_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r" \(\d+\)").expect("Failed to compile hardcoded regex"));
|
||||
@@ -167,3 +173,48 @@ pub fn append_digit_to_filename(
|
||||
final_path.push(format!("{new_file_name} ({current_int})"));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_available_filename_for_duplicate(
|
||||
target_path: impl AsRef<Path>,
|
||||
) -> Result<PathBuf, FileSystemJobsError> {
|
||||
let target_path = target_path.as_ref();
|
||||
|
||||
let new_file_name = target_path
|
||||
.file_stem()
|
||||
.ok_or_else(|| {
|
||||
FileSystemJobsError::MissingFileStem(target_path.to_path_buf().into_boxed_path())
|
||||
})?
|
||||
.to_str()
|
||||
.ok_or_else(|| NonUtf8PathError(target_path.to_path_buf().into_boxed_path()))?;
|
||||
|
||||
let new_file_full_path_without_suffix =
|
||||
target_path.parent().map(Path::to_path_buf).ok_or_else(|| {
|
||||
FileSystemJobsError::MissingParentPath(target_path.to_path_buf().into_boxed_path())
|
||||
})?;
|
||||
|
||||
for i in 1..u32::MAX {
|
||||
let mut new_file_full_path_candidate = new_file_full_path_without_suffix.clone();
|
||||
|
||||
append_digit_to_filename(
|
||||
&mut new_file_full_path_candidate,
|
||||
new_file_name,
|
||||
target_path.extension().and_then(OsStr::to_str),
|
||||
i,
|
||||
);
|
||||
|
||||
match fs::metadata(&new_file_full_path_candidate).await {
|
||||
Ok(_) => {
|
||||
// This candidate already exists, so we try the next one
|
||||
continue;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(new_file_full_path_candidate);
|
||||
}
|
||||
Err(e) => return Err(FileIOError::from((new_file_full_path_candidate, e)).into()),
|
||||
}
|
||||
}
|
||||
|
||||
Err(FileSystemJobsError::FailedToFindAvailableName(
|
||||
target_path.to_path_buf().into_boxed_path(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { libraryClient, useLibraryMutation } from '@sd/client';
|
||||
import { ContextMenu, dialogManager, ModifierKeys, toast } from '@sd/ui';
|
||||
import { Menu } from '~/components/Menu';
|
||||
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
|
||||
import { useQuickRescan } from '~/hooks/useQuickRescan';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import { useExplorerContext } from '../../Context';
|
||||
@@ -27,6 +28,8 @@ export const Delete = new ConditionalItem({
|
||||
Component: ({ selectedFilePaths, locationId }) => {
|
||||
const keybind = useKeybindFactory();
|
||||
|
||||
const rescan = useQuickRescan();
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
icon={Trash}
|
||||
@@ -37,6 +40,7 @@ export const Delete = new ConditionalItem({
|
||||
dialogManager.create((dp) => (
|
||||
<DeleteDialog
|
||||
{...dp}
|
||||
rescan={rescan}
|
||||
locationId={locationId}
|
||||
pathIds={selectedFilePaths.map((p) => p.id)}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CheckBox, Dialog, Tooltip, useDialog, UseDialogProps } from '@sd/ui';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
locationId: number;
|
||||
rescan?: () => void;
|
||||
pathIds: number[];
|
||||
}
|
||||
|
||||
@@ -14,12 +15,14 @@ export default (props: Props) => {
|
||||
return (
|
||||
<Dialog
|
||||
form={form}
|
||||
onSubmit={form.handleSubmit(() =>
|
||||
deleteFile.mutateAsync({
|
||||
onSubmit={form.handleSubmit(async () => {
|
||||
await deleteFile.mutateAsync({
|
||||
location_id: props.locationId,
|
||||
file_path_ids: props.pathIds
|
||||
})
|
||||
)}
|
||||
});
|
||||
|
||||
props.rescan?.();
|
||||
})}
|
||||
dialog={useDialog(props)}
|
||||
title="Delete a file"
|
||||
description="Warning: This will delete your file forever, we don't have a trash can yet..."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ArrowClockwise,
|
||||
FolderPlus,
|
||||
Key,
|
||||
MonitorPlay,
|
||||
Rows,
|
||||
@@ -8,11 +9,14 @@ import {
|
||||
SquaresFour,
|
||||
Tag
|
||||
} from '@phosphor-icons/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRspcLibraryContext } from '@sd/client';
|
||||
import { useKeyMatcher } from '~/hooks';
|
||||
import { useLibraryMutation, useLibraryQuery, useRspcLibraryContext } from '@sd/client';
|
||||
import { ModifierKeys, toast } from '@sd/ui';
|
||||
import { useKeybind, useKeyMatcher, useOperatingSystem } from '~/hooks';
|
||||
|
||||
import { useQuickRescan } from '../../../hooks/useQuickRescan';
|
||||
import { KeyManager } from '../KeyManager';
|
||||
import TopBarOptions, { ToolOption, TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
||||
import { useExplorerContext } from './Context';
|
||||
@@ -24,9 +28,21 @@ export const useExplorerTopBarOptions = () => {
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorer = useExplorerContext();
|
||||
const controlIcon = useKeyMatcher('Meta').icon;
|
||||
|
||||
const settings = explorer.useSettingsSnapshot();
|
||||
|
||||
const rescan = useQuickRescan();
|
||||
|
||||
const createFolder = useLibraryMutation(['files.createFolder'], {
|
||||
onError: (e) => {
|
||||
toast.error({ title: 'Error creating folder', body: `Error: ${e}.` });
|
||||
console.error(e);
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.success({ title: `Created new folder "${folder}"` });
|
||||
rescan();
|
||||
}
|
||||
});
|
||||
|
||||
const viewOptions: ToolOption[] = [
|
||||
{
|
||||
toolTipLabel: 'Grid view',
|
||||
@@ -67,7 +83,7 @@ export const useExplorerTopBarOptions = () => {
|
||||
icon: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
|
||||
popOverComponent: <OptionsPanel />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
showAtResolution: 'sm:flex'
|
||||
},
|
||||
{
|
||||
toolTipLabel: 'Show Inspector',
|
||||
@@ -87,19 +103,28 @@ export const useExplorerTopBarOptions = () => {
|
||||
}
|
||||
];
|
||||
|
||||
// subscription so that we can cancel it if in progress
|
||||
const quickRescanSubscription = useRef<() => void | undefined>();
|
||||
|
||||
// gotta clean up any rescan subscriptions if the exist
|
||||
useEffect(() => () => quickRescanSubscription.current?.(), []);
|
||||
|
||||
const { client } = useRspcLibraryContext();
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const os = useOperatingSystem();
|
||||
|
||||
useKeybind([os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control, 'r'], () => rescan());
|
||||
|
||||
const toolOptions = [
|
||||
parent?.type === 'Location' && {
|
||||
toolTipLabel: 'New Folder',
|
||||
icon: <FolderPlus className={TOP_BAR_ICON_STYLE} />,
|
||||
onClick: () => {
|
||||
createFolder.mutate({
|
||||
location_id: parent.location.id,
|
||||
sub_path: path || null,
|
||||
name: null
|
||||
});
|
||||
},
|
||||
individual: true,
|
||||
showAtResolution: 'xs:flex'
|
||||
},
|
||||
{
|
||||
toolTipLabel: 'Key Manager',
|
||||
icon: <Key className={TOP_BAR_ICON_STYLE} />,
|
||||
@@ -122,19 +147,7 @@ export const useExplorerTopBarOptions = () => {
|
||||
},
|
||||
parent?.type === 'Location' && {
|
||||
toolTipLabel: 'Reload',
|
||||
onClick: () => {
|
||||
quickRescanSubscription.current?.();
|
||||
quickRescanSubscription.current = client.addSubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
location_id: parent.location.id,
|
||||
sub_path: path ?? ''
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
},
|
||||
onClick: rescan,
|
||||
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
|
||||
@@ -53,8 +53,6 @@ export const EphemeralSection = () => {
|
||||
return locationIdsMap;
|
||||
}, [locations.data, volumes.data]);
|
||||
|
||||
console.log('locationIdsForVolumes', locationIdsForVolumes);
|
||||
|
||||
const items = [
|
||||
{ type: 'network' },
|
||||
home ? { type: 'home', path: home } : null,
|
||||
|
||||
@@ -60,8 +60,6 @@ export const LibrarySection = () => {
|
||||
null
|
||||
);
|
||||
|
||||
const [seeMoreLocations, setSeeMoreLocations] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const outsideClick = () => {
|
||||
document.addEventListener('click', () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Info } from '@phosphor-icons/react';
|
||||
import { getIcon, iconNames } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { stringify } from 'uuid';
|
||||
import {
|
||||
arraysEqual,
|
||||
ExplorerSettings,
|
||||
FilePathFilterArgs,
|
||||
FilePathOrder,
|
||||
@@ -10,19 +14,23 @@ import {
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
useLibrarySubscription,
|
||||
useOnlineLocations,
|
||||
useRspcLibraryContext
|
||||
} from '@sd/client';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { LocationIdParamsSchema } from '~/app/route-schemas';
|
||||
import { Folder } from '~/components';
|
||||
import { useKeyDeleteFile, useZodRouteParams } from '~/hooks';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
import { ExplorerContextProvider } from '../Explorer/Context';
|
||||
import { InfoPill } from '../Explorer/Inspector';
|
||||
import { usePathsInfiniteQuery } from '../Explorer/queries';
|
||||
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { useExplorerSearchParams } from '../Explorer/util';
|
||||
import { EmptyNotice } from '../Explorer/View';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import LocationOptions from './LocationOptions';
|
||||
|
||||
@@ -32,6 +40,14 @@ export const Component = () => {
|
||||
const location = useLibraryQuery(['locations.get', locationId]);
|
||||
const rspc = useRspcLibraryContext();
|
||||
|
||||
const onlineLocations = useOnlineLocations();
|
||||
|
||||
const locationOnline = useMemo(() => {
|
||||
const pub_id = location.data?.pub_id;
|
||||
if (!pub_id) return false;
|
||||
return onlineLocations.some((l) => arraysEqual(pub_id, l));
|
||||
}, [location.data?.pub_id, onlineLocations]);
|
||||
|
||||
const preferences = useLibraryQuery(['preferences.get']);
|
||||
const updatePreferences = useLibraryMutation('preferences.update');
|
||||
|
||||
@@ -117,6 +133,11 @@ export const Component = () => {
|
||||
? getLastSectionOfPath(path)
|
||||
: location.data?.name}
|
||||
</span>
|
||||
{!locationOnline && (
|
||||
<Tooltip label="Location is offline, you can still browse and organize.">
|
||||
<Info className="text-ink-faint" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{location.data && (
|
||||
<LocationOptions location={location.data} path={path || ''} />
|
||||
)}
|
||||
@@ -125,7 +146,15 @@ export const Component = () => {
|
||||
right={<DefaultTopBarOptions />}
|
||||
/>
|
||||
|
||||
<Explorer />
|
||||
<Explorer
|
||||
emptyNotice={
|
||||
<EmptyNotice
|
||||
loading={location.isFetching}
|
||||
icon={<img className="h-32 w-32" src={getIcon(iconNames.FolderNoSpace)} />}
|
||||
message="No files found here"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ExplorerContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
38
interface/hooks/useQuickRescan.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRspcLibraryContext } from '@sd/client';
|
||||
|
||||
import { useExplorerContext } from '../app/$libraryId/Explorer/Context';
|
||||
import { useExplorerSearchParams } from '../app/$libraryId/Explorer/util';
|
||||
import { useOperatingSystem } from './useOperatingSystem';
|
||||
|
||||
export const useQuickRescan = () => {
|
||||
// subscription so that we can cancel it if in progress
|
||||
const quickRescanSubscription = useRef<() => void | undefined>();
|
||||
|
||||
// gotta clean up any rescan subscriptions if the exist
|
||||
useEffect(() => () => quickRescanSubscription.current?.(), []);
|
||||
|
||||
const { client } = useRspcLibraryContext();
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const rescan = () => {
|
||||
if (parent?.type === 'Location') {
|
||||
quickRescanSubscription.current?.();
|
||||
quickRescanSubscription.current = client.addSubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
location_id: parent.location.id,
|
||||
sub_path: path ?? ''
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return rescan;
|
||||
};
|
||||
BIN
packages/assets/icons/Drive-AmazonS3.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
packages/assets/icons/Drive-BackBlaze.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
packages/assets/icons/Drive-Box.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
packages/assets/icons/Drive-DAV.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
packages/assets/icons/Drive-Dropbox.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
packages/assets/icons/Drive-GoogleDrive.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
packages/assets/icons/Drive-Mega.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
packages/assets/icons/Drive-OneDrive.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
packages/assets/icons/Drive-OpenStack.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
packages/assets/icons/Drive-PCloud.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
packages/assets/icons/FolderNoSpace.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
BIN
packages/assets/icons/Spacedrop-1.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -34,6 +34,16 @@ import Document_xls_Light from './Document_xls_Light.png';
|
||||
import Document_xls from './Document_xls.png';
|
||||
import Document from './Document.png';
|
||||
import Drive_Light from './Drive_Light.png';
|
||||
import DriveAmazonS3 from './Drive-AmazonS3.png';
|
||||
import DriveBackBlaze from './Drive-BackBlaze.png';
|
||||
import DriveBox from './Drive-Box.png';
|
||||
import DriveDAV from './Drive-DAV.png';
|
||||
import DriveDropbox from './Drive-Dropbox.png';
|
||||
import DriveGoogleDrive from './Drive-GoogleDrive.png';
|
||||
import DriveMega from './Drive-Mega.png';
|
||||
import DriveOneDrive from './Drive-OneDrive.png';
|
||||
import DriveOpenStack from './Drive-OpenStack.png';
|
||||
import DrivePCloud from './Drive-PCloud.png';
|
||||
import Drive from './Drive.png';
|
||||
import Dropbox from './Dropbox.png';
|
||||
import Encrypted_Light from './Encrypted_Light.png';
|
||||
@@ -49,6 +59,7 @@ import Folder_Light from './Folder_Light.png';
|
||||
import Folder from './Folder.png';
|
||||
import FolderGrey_Light from './FolderGrey_Light.png';
|
||||
import FolderGrey from './FolderGrey.png';
|
||||
import FolderNoSpace from './FolderNoSpace.png';
|
||||
import Game_Light from './Game_Light.png';
|
||||
import Game from './Game.png';
|
||||
import Globe from './Globe.png';
|
||||
@@ -91,6 +102,7 @@ import ScreenshotAlt from './ScreenshotAlt.png';
|
||||
import SD from './SD.png';
|
||||
import Server_Light from './Server_Light.png';
|
||||
import Server from './Server.png';
|
||||
import Spacedrop1 from './Spacedrop-1.png';
|
||||
import Spacedrop from './Spacedrop.png';
|
||||
import Tablet_Light from './Tablet_Light.png';
|
||||
import Tablet from './Tablet.png';
|
||||
@@ -145,6 +157,16 @@ export {
|
||||
Document_pdf_Light,
|
||||
Document_xls,
|
||||
Document_xls_Light,
|
||||
DriveAmazonS3,
|
||||
DriveBackBlaze,
|
||||
DriveBox,
|
||||
DriveDAV,
|
||||
DriveDropbox,
|
||||
DriveGoogleDrive,
|
||||
DriveMega,
|
||||
DriveOneDrive,
|
||||
DriveOpenStack,
|
||||
DrivePCloud,
|
||||
Drive,
|
||||
Drive_Light,
|
||||
Dropbox,
|
||||
@@ -160,6 +182,7 @@ export {
|
||||
Folder,
|
||||
FolderGrey,
|
||||
FolderGrey_Light,
|
||||
FolderNoSpace,
|
||||
Folder_Light,
|
||||
Game,
|
||||
Game_Light,
|
||||
@@ -203,6 +226,7 @@ export {
|
||||
ScreenshotAlt,
|
||||
Server,
|
||||
Server_Light,
|
||||
Spacedrop1,
|
||||
Spacedrop,
|
||||
Tablet,
|
||||
Tablet_Light,
|
||||
|
||||
@@ -48,6 +48,8 @@ export type Procedures = {
|
||||
{ key: "backups.restore", input: string, result: null } |
|
||||
{ key: "files.convertImage", input: LibraryArgs<ConvertImageArgs>, result: null } |
|
||||
{ key: "files.copyFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
|
||||
{ key: "files.createEphemeralFolder", input: LibraryArgs<CreateEphemeralFolderArgs>, result: string } |
|
||||
{ key: "files.createFolder", input: LibraryArgs<CreateFolderArgs>, result: string } |
|
||||
{ key: "files.cutFiles", input: LibraryArgs<FileCutterJobInit>, result: null } |
|
||||
{ key: "files.deleteFiles", input: LibraryArgs<FileDeleterJobInit>, result: null } |
|
||||
{ key: "files.duplicateFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
|
||||
@@ -136,6 +138,10 @@ export type ConvertImageArgs = { location_id: number; file_path_id: number; dele
|
||||
|
||||
export type ConvertableExtension = "bmp" | "dib" | "ff" | "gif" | "ico" | "jpg" | "jpeg" | "png" | "pnm" | "qoi" | "tga" | "icb" | "vda" | "vst" | "tiff" | "tif" | "heif" | "heifs" | "heic" | "heics" | "avif" | "avci" | "avcs" | "svg" | "svgz" | "pdf"
|
||||
|
||||
export type CreateEphemeralFolderArgs = { path: string; name: string | null }
|
||||
|
||||
export type CreateFolderArgs = { location_id: number; sub_path: string | null; name: string | null }
|
||||
|
||||
export type CreateLibraryArgs = { name: LibraryName }
|
||||
|
||||
export type CursorOrderItem<T> = { order: SortOrder; data: T }
|
||||
|
||||