[ENG-816, ENG-821] Re-implement reveal in finder + ContextMenu overhaul (#1029)

* mostly there

* native opening working

* more

* cleanup

* reorganise

* remove unnecessary import

* uncomment some stuff

* spacing

* store quickview ref inside provider

* fix linting

* clippy

---------

Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com>
Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
This commit is contained in:
Brendan Allan 2023-06-27 17:34:53 +02:00 committed by GitHub
parent f2629a9f9a
commit 06de379169
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1693 additions and 1220 deletions

25
Cargo.lock generated
View file

@ -1763,6 +1763,17 @@ dependencies = [
"regex",
]
[[package]]
name = "dbus"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b"
dependencies = [
"libc",
"libdbus-sys",
"winapi",
]
[[package]]
name = "deps-generator"
version = "0.0.0"
@ -3837,6 +3848,16 @@ version = "0.2.146"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
[[package]]
name = "libdbus-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "libheif-rs"
version = "0.19.2"
@ -5192,7 +5213,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c62dcb6174f9cb326eac248f07e955d5d559c272730b6c03e396b443b562788"
dependencies = [
"bstr",
"dbus",
"normpath",
"url",
"winapi",
]
@ -7794,12 +7817,14 @@ dependencies = [
"httpz 0.0.3",
"opener",
"percent-encoding",
"prisma-client-rust",
"rand 0.8.5",
"rspc",
"sd-core",
"sd-desktop-linux",
"sd-desktop-macos",
"sd-desktop-windows",
"sd-prisma",
"serde",
"specta",
"tauri",

View file

@ -26,11 +26,14 @@ tracing = "0.1.37"
serde = "1.0.163"
percent-encoding = "2.2.0"
http = "0.2.9"
opener = "0.6.1"
opener = { version = "0.6.1", features = ["reveal"] }
specta = { workspace = true }
tauri-specta = { workspace = true, features = ["typescript"] }
uuid = { version = "1.3.3", features = ["serde"] }
prisma-client-rust = { workspace = true }
sd-prisma = { path = "../../../crates/prisma" }
[target.'cfg(target_os = "linux")'.dependencies]
axum = { version = "0.6.18", features = ["headers", "query"] }
rand = "0.8.5"

View file

@ -1,10 +1,18 @@
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{BTreeSet, HashMap},
sync::Arc,
};
use sd_core::Node;
use sd_core::{
prisma::{file_path, location},
Node,
};
use serde::Serialize;
use specta::Type;
use tracing::error;
type NodeState<'a> = tauri::State<'a, Arc<Node>>;
#[derive(Serialize, Type)]
#[serde(tag = "t", content = "c")]
pub enum OpenFilePathResult {
@ -17,7 +25,7 @@ pub enum OpenFilePathResult {
#[tauri::command(async)]
#[specta::specta]
pub async fn open_file_path(
pub async fn open_file_paths(
library: uuid::Uuid,
ids: Vec<i32>,
node: tauri::State<'_, Arc<Node>>,
@ -64,7 +72,7 @@ pub struct OpenWithApplication {
pub async fn get_file_path_open_with_apps(
library: uuid::Uuid,
ids: Vec<i32>,
node: tauri::State<'_, Arc<Node>>,
node: NodeState<'_>,
) -> Result<Vec<OpenWithApplication>, ()> {
let Some(library) = node.library_manager.get_library(library).await
else {
@ -210,7 +218,7 @@ type FileIdAndUrl = (i32, String);
pub async fn open_file_path_with(
library: uuid::Uuid,
file_ids_and_urls: Vec<FileIdAndUrl>,
node: tauri::State<'_, Arc<Node>>,
node: NodeState<'_>,
) -> Result<(), ()> {
let Some(library) = node.library_manager.get_library(library).await
else {
@ -272,3 +280,71 @@ pub async fn open_file_path_with(
.map(|_| ())
})
}
#[derive(specta::Type, serde::Deserialize)]
pub enum RevealItem {
Location { id: location::id::Type },
FilePath { id: file_path::id::Type },
}
#[tauri::command(async)]
#[specta::specta]
pub async fn reveal_items(
library: uuid::Uuid,
items: Vec<RevealItem>,
node: NodeState<'_>,
) -> Result<(), ()> {
let Some(library) = node.library_manager.get_library(library).await
else {
return Err(())
};
let (paths, locations): (Vec<_>, Vec<_>) =
items
.into_iter()
.fold((vec![], vec![]), |(mut paths, mut locations), item| {
match item {
RevealItem::FilePath { id } => paths.push(id),
RevealItem::Location { id } => locations.push(id),
}
(paths, locations)
});
let mut paths_to_open = BTreeSet::new();
if !paths.is_empty() {
paths_to_open.extend(
library
.get_file_paths(paths)
.await
.unwrap_or_default()
.into_values()
.flatten(),
);
}
if !locations.is_empty() {
paths_to_open.extend(
library
.db
.location()
.find_many(vec![
location::node_id::equals(Some(library.node_local_id)),
location::id::in_vec(locations),
])
.select(location::select!({ path }))
.exec()
.await
.unwrap_or_default()
.into_iter()
.flat_map(|location| location.path.map(Into::into)),
);
}
for path in paths_to_open {
opener::reveal(path).ok();
}
Ok(())
}

View file

@ -167,9 +167,10 @@ async fn main() -> tauri::Result<()> {
app_ready,
reset_spacedrive,
open_logs_dir,
file::open_file_path,
file::open_file_paths,
file::get_file_path_open_with_apps,
file::open_file_path_with,
file::reveal_items,
theme::lock_app_theme
])
.build(tauri::generate_context!())?;

View file

@ -17,14 +17,7 @@ import {
} from '@sd/interface';
import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
import '@sd/ui/style';
import {
appReady,
getFilePathOpenWithApps,
lockAppTheme,
openFilePath,
openFilePathWith,
openLogsDir
} from './commands';
import * as commands from './commands';
// TODO: Bring this back once upstream is fixed up.
// const client = hooks.createClient({
@ -81,12 +74,7 @@ const platform: Platform = {
openFilePickerDialog: () => dialog.open(),
saveFilePickerDialog: () => dialog.save(),
showDevtools: () => invoke('show_devtools'),
openPath: (path) => shell.open(path),
openLogsDir,
openFilePath,
getFilePathOpenWithApps,
openFilePathWith,
lockAppTheme
...commands
};
const queryClient = new QueryClient();
@ -96,7 +84,7 @@ const router = createBrowserRouter(routes);
export default function App() {
useEffect(() => {
// This tells Tauri to show the current window because it's finished loading
appReady();
commands.appReady();
}, []);
useEffect(() => {

View file

@ -22,8 +22,8 @@ export function openLogsDir() {
return invoke()<null>("open_logs_dir")
}
export function openFilePath(library: string, ids: number[]) {
return invoke()<OpenFilePathResult[]>("open_file_path", { library,ids })
export function openFilePaths(library: string, ids: number[]) {
return invoke()<OpenFilePathResult[]>("open_file_paths", { library,ids })
}
export function getFilePathOpenWithApps(library: string, ids: number[]) {
@ -34,10 +34,15 @@ export function openFilePathWith(library: string, fileIdsAndUrls: ([number, stri
return invoke()<null>("open_file_path_with", { library,fileIdsAndUrls })
}
export function revealItems(library: string, items: RevealItem[]) {
return invoke()<null>("reveal_items", { library,items })
}
export function lockAppTheme(themeType: AppThemeType) {
return invoke()<null>("lock_app_theme", { themeType })
}
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string }
export type RevealItem = { Location: { id: number } } | { FilePath: { id: number } }
export type OpenWithApplication = { id: number; name: string; url: string }
export type AppThemeType = "Auto" | "Light" | "Dark"
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string }

View file

@ -6,7 +6,7 @@ datasource db {
generator client {
provider = "cargo prisma"
output = "../../crates/prisma/src/prisma.rs"
module_path = "crate::prisma"
module_path = "sd_prisma::prisma"
}
generator sync {

View file

@ -0,0 +1,35 @@
import { createContext, useContext } from 'react';
import { FilePath, Location, NodeState, Tag } from '@sd/client';
export type ExplorerParent =
| {
type: 'Location';
location: Location;
subPath?: FilePath;
}
| {
type: 'Tag';
tag: Tag;
}
| {
type: 'Node';
node: NodeState;
};
interface ExplorerContext {
parent?: ExplorerParent;
}
/**
* Context that must wrap anything to do with the explorer.
* This includes explorer views, the inspector, and top bar items.
*/
export const ExplorerContext = createContext<ExplorerContext | null>(null);
export const useExplorerContext = () => {
const ctx = useContext(ExplorerContext);
if (ctx === null) throw new Error('ExplorerContext.Provider not found!');
return ctx;
};

View file

@ -1,200 +0,0 @@
import { Clipboard, FileX, Image, Plus, Repeat, Share, ShieldCheck } from 'phosphor-react';
import { PropsWithChildren, useMemo } from 'react';
import { useBridgeQuery, useLibraryContext, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { ContextMenu as CM, ModifierKeys } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { getExplorerStore, useExplorerStore, useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { keybindForOs } from '~/util/keybinds';
import { useExplorerSearchParams } from './util';
export const OpenInNativeExplorer = () => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const osFileBrowserName = useMemo(() => {
if (os === 'macOS') {
return 'Finder';
} else if (os === 'windows') {
return 'Explorer';
} else {
return 'file manager';
}
}, [os]);
const { openPath } = usePlatform();
let { locationId } = useExplorerStore();
if (locationId == null) locationId = -1;
const { library } = useLibraryContext();
const { data: node } = useBridgeQuery(['nodeState']);
const { data: location } = useLibraryQuery(['locations.get', locationId]);
const [{ path: subPath }] = useExplorerSearchParams();
// Disable for remote nodes, as opening directories in a remote node is a more complex task
if (!(openPath && location?.path && node?.id && library.config.node_id === node.id))
return null;
const path = location.path + (subPath ? subPath : '');
return (
<>
<CM.Item
label={`Open in ${osFileBrowserName}`}
keybind={keybind([ModifierKeys.Control], ['Y'])}
onClick={() => openPath(path)}
/>
<CM.Separator />
</>
);
};
export default (props: PropsWithChildren) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const [{ path: currentPath }] = useExplorerSearchParams();
const { locationId, cutCopyState } = useExplorerStore();
const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation');
const objectValidator = useLibraryMutation('jobs.objectValidator');
const rescanLocation = useLibraryMutation('locations.fullRescan');
const copyFiles = useLibraryMutation('files.copyFiles');
const cutFiles = useLibraryMutation('files.cutFiles');
return (
<CM.Root trigger={props.children}>
<OpenInNativeExplorer />
<CM.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
disabled
/>
<CM.Separator />
{locationId && (
<>
<CM.Item
onClick={async () => {
try {
await rescanLocation.mutateAsync(locationId);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to re-index location, due to an error: ${error}`
});
}
}}
label="Re-index"
icon={Repeat}
/>
<CM.Item
label="Paste"
keybind={keybind([ModifierKeys.Control], ['V'])}
hidden={!cutCopyState.active}
onClick={async () => {
const path = currentPath ?? '/';
const { actionType, sourcePathId, sourceParentPath, sourceLocationId } =
cutCopyState;
const sameLocation =
sourceLocationId === locationId && sourceParentPath === path;
try {
if (actionType == 'Copy') {
await copyFiles.mutateAsync({
source_location_id: sourceLocationId,
sources_file_path_ids: [sourcePathId],
target_location_id: locationId,
target_location_relative_directory_path: path,
target_file_name_suffix: sameLocation ? ' copy' : null
});
} else if (sameLocation) {
showAlertDialog({
title: 'Error',
value: `File already exists in this location`
});
} else {
await cutFiles.mutateAsync({
source_location_id: sourceLocationId,
sources_file_path_ids: [sourcePathId],
target_location_id: locationId,
target_location_relative_directory_path: path
});
}
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to ${actionType.toLowerCase()} file, due to an error: ${error}`
});
}
}}
icon={Clipboard}
/>
</>
)}
<CM.Item
label="Deselect"
hidden={!cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...cutCopyState,
active: false
};
}}
icon={FileX}
/>
{locationId && (
<CM.SubMenu label="More actions..." icon={Plus}>
<CM.Item
onClick={async () => {
try {
await generateThumbsForLocation.mutateAsync({
id: locationId,
path: currentPath ?? '/'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to generate thumbanails, due to an error: ${error}`
});
}
}}
label="Regen Thumbnails"
icon={Image}
/>
<CM.Item
onClick={async () => {
try {
objectValidator.mutateAsync({
id: locationId,
path: currentPath ?? '/'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to generate checksum, due to an error: ${error}`
});
}
}}
label="Generate Checksums"
icon={ShieldCheck}
/>
</CM.SubMenu>
)}
</CM.Root>
);
};

View file

@ -0,0 +1,74 @@
import { Copy, Scissors } from 'phosphor-react';
import { FilePath, useLibraryMutation } from '@sd/client';
import { ContextMenu, ModifierKeys } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
import { getExplorerStore } from '../../store';
import { useExplorerSearchParams } from '../../util';
interface Props {
locationId: number;
filePath: FilePath;
}
export const CutCopyItems = ({ locationId, filePath }: Props) => {
const keybind = useKeybindFactory();
const [{ path }] = useExplorerSearchParams();
const copyFiles = useLibraryMutation('files.copyFiles');
return (
<>
<ContextMenu.Item
label="Cut"
keybind={keybind([ModifierKeys.Control], ['X'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: path ?? '/',
sourceLocationId: locationId,
sourcePathId: filePath.id,
actionType: 'Cut',
active: true
};
}}
icon={Scissors}
/>
<ContextMenu.Item
label="Copy"
keybind={keybind([ModifierKeys.Control], ['C'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: path ?? '/',
sourceLocationId: locationId,
sourcePathId: filePath.id,
actionType: 'Copy',
active: true
};
}}
icon={Copy}
/>
<ContextMenu.Item
label="Duplicate"
keybind={keybind([ModifierKeys.Control], ['D'])}
onClick={async () => {
try {
await copyFiles.mutateAsync({
source_location_id: locationId,
sources_file_path_ids: [filePath.id],
target_location_id: locationId,
target_location_relative_directory_path: path ?? '/',
target_file_name_suffix: ' copy'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to duplcate file, due to an error: ${error}`
});
}
}}
/>
</>
);
};

View file

@ -0,0 +1,210 @@
import { Image, Package, Trash, TrashSimple } from 'phosphor-react';
import { FilePath, useLibraryContext, useLibraryMutation } from '@sd/client';
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
import { usePlatform } from '~/util/Platform';
import DeleteDialog from '../../FilePath/DeleteDialog';
import EraseDialog from '../../FilePath/EraseDialog';
import OpenWith from './OpenWith';
export * from './CutCopyItems';
interface FilePathProps {
filePath: FilePath;
}
export const Delete = ({ filePath }: FilePathProps) => {
const keybind = useKeybindFactory();
const locationId = filePath.location_id;
return (
<>
{locationId != null && (
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind={keybind([ModifierKeys.Control], ['Delete'])}
onClick={() =>
dialogManager.create((dp) => (
<DeleteDialog {...dp} location_id={locationId} path_id={filePath.id} />
))
}
/>
)}
</>
);
};
export const Compress = (_: FilePathProps) => {
const keybind = useKeybindFactory();
return (
<ContextMenu.Item
label="Compress"
icon={Package}
keybind={keybind([ModifierKeys.Control], ['B'])}
disabled
/>
);
};
export const Crypto = (_: FilePathProps) => {
return (
<>
{/* <ContextMenu.Item
label="Encrypt"
icon={LockSimple}
keybind="⌘E"
onClick={() => {
if (keyManagerUnlocked && hasMountedKeys) {
dialogManager.create((dp) => (
<EncryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else if (!keyManagerUnlocked) {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
} else if (!hasMountedKeys) {
showAlertDialog({
title: 'No mounted keys',
value: 'No mounted keys were found. Please mount a key and try again.'
});
}
}}
/> */}
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
{/* <ContextMenu.Item
label="Decrypt"
icon={LockSimpleOpen}
keybind="⌘D"
onClick={() => {
if (keyManagerUnlocked) {
dialogManager.create((dp) => (
<DecryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
}
}}
/> */}
</>
);
};
export const SecureDelete = ({ filePath }: FilePathProps) => {
const locationId = filePath.location_id;
return (
<>
{locationId && (
<ContextMenu.Item
variant="danger"
label="Secure delete"
icon={TrashSimple}
onClick={() =>
dialogManager.create((dp) => (
<EraseDialog {...dp} location_id={locationId} path_id={filePath.id} />
))
}
disabled
/>
)}
</>
);
};
export const ParentFolderActions = ({
filePath,
locationId
}: FilePathProps & { locationId: number }) => {
const fullRescan = useLibraryMutation('locations.fullRescan');
const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation');
return (
<>
<ContextMenu.Item
onClick={async () => {
try {
await fullRescan.mutateAsync(locationId);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to rescan location, due to an error: ${error}`
});
}
}}
label="Rescan Directory"
icon={Package}
/>
<ContextMenu.Item
onClick={async () => {
try {
await generateThumbnails.mutateAsync({
id: locationId,
path: filePath.materialized_path ?? '/'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to generate thumbnails, due to an error: ${error}`
});
}
}}
label="Regen Thumbnails"
icon={Image}
/>
</>
);
};
export const OpenOrDownload = ({ filePath }: { filePath: FilePath }) => {
const keybind = useKeybindFactory();
const { platform, openFilePaths: openFilePath } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const { library } = useLibraryContext();
if (platform === 'web') return <ContextMenu.Item label="Download" />;
else
return (
<>
{openFilePath && (
<ContextMenu.Item
label="Open"
keybind={keybind([ModifierKeys.Control], ['O'])}
onClick={async () => {
if (filePath.object_id)
updateAccessTime
.mutateAsync(filePath.object_id)
.catch(console.error);
try {
await openFilePath(library.uuid, [filePath.id]);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to open file, due to an error: ${error}`
});
}
}}
/>
)}
<OpenWith filePath={filePath} />
</>
);
};

View file

@ -10,6 +10,7 @@ export default (props: { filePath: FilePath }) => {
if (!getFilePathOpenWithApps || !openFilePathWith) return null;
if (props.filePath.is_dir) return null;
return (
<ContextMenu.SubMenu label="Open with">
<Suspense>

View file

@ -0,0 +1,77 @@
import { Plus } from 'phosphor-react';
import { ExplorerItem } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { useExplorerContext } from '../../Context';
import { FilePathItems, ObjectItems, SharedItems } from '../../ContextMenu';
interface Props {
data: Extract<ExplorerItem, { type: 'Path' }>;
}
export default ({ data }: Props) => {
const filePath = data.item;
const { object } = filePath;
const { parent } = useExplorerContext();
// const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false;
// const mountedKeys = useLibraryQuery(['keys.listMounted']);
// const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0;
return (
<>
<FilePathItems.OpenOrDownload filePath={filePath} />
<SharedItems.OpenQuickView item={data} />
<ContextMenu.Separator />
<SharedItems.Details />
<ContextMenu.Separator />
<SharedItems.RevealInNativeExplorer
filePath={filePath}
/>
<SharedItems.Rename />
{object && <ObjectItems.RemoveFromRecents object={object} />}
{parent?.type === 'Location' && (
<FilePathItems.CutCopyItems locationId={parent.location.id} filePath={filePath} />
)}
<SharedItems.Deselect />
<ContextMenu.Separator />
<SharedItems.Share />
<ContextMenu.Separator />
{object && <ObjectItems.AssignTag object={object} />}
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<FilePathItems.Crypto filePath={filePath} />
<FilePathItems.Compress filePath={filePath} />
{object && <ObjectItems.ConvertObject filePath={filePath} object={object} />}
{parent?.type === 'Location' && (
<FilePathItems.ParentFolderActions
filePath={filePath}
locationId={parent.location.id}
/>
)}
<FilePathItems.SecureDelete filePath={filePath} />
</ContextMenu.SubMenu>
<ContextMenu.Separator />
<FilePathItems.Delete filePath={filePath} />
</>
);
};

View file

@ -0,0 +1,29 @@
import { ExplorerItem } from "@sd/client";
import { ContextMenu } from '@sd/ui';
import { SharedItems } from "..";
interface Props {
data: Extract<ExplorerItem, { type: 'Location' }>;
}
export default ({ data }: Props) => {
const location = data.item;
return <>
<SharedItems.OpenQuickView item={data} />
<ContextMenu.Separator />
<SharedItems.Details />
<ContextMenu.Separator />
<SharedItems.RevealInNativeExplorer
locationId={location.id}
/>
<ContextMenu.Separator />
<SharedItems.Share />
</>
}

View file

@ -0,0 +1,56 @@
import { ArrowBendUpRight, TagSimple } from 'phosphor-react';
import { FilePath, ObjectKind, Object as ObjectType, useLibraryMutation } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { showAlertDialog } from '~/components';
import AssignTagMenuItems from '../../AssignTagMenuItems';
export const RemoveFromRecents = ({ object }: { object: ObjectType }) => {
const removeFromRecents = useLibraryMutation('files.removeAccessTime');
return (
<>
{object.date_accessed !== null && (
<ContextMenu.Item
label="Remove from recents"
onClick={async () => {
try {
await removeFromRecents.mutateAsync([object.id]);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to remove file from recents, due to an error: ${error}`
});
}
}}
/>
)}
</>
);
};
export const AssignTag = ({ object }: { object: ObjectType }) => (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={object.id} />
</ContextMenu.SubMenu>
);
const ObjectConversions: Record<number, string[]> = {
[ObjectKind.Image]: ['PNG', 'WebP', 'Gif'],
[ObjectKind.Video]: ['MP4', 'MOV', 'AVI']
};
export const ConvertObject = ({ object, filePath }: { object: ObjectType; filePath: FilePath }) => {
const { kind } = object;
return (
<>
{kind !== null && [ObjectKind.Image, ObjectKind.Video].includes(kind as ObjectKind) && (
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
{ObjectConversions[kind]?.map((ext) => (
<ContextMenu.Item key={ext} label={ext} disabled />
))}
</ContextMenu.SubMenu>
)}
</>
);
};

View file

@ -0,0 +1,58 @@
import { Plus } from 'phosphor-react';
import { ExplorerItem } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { FilePathItems, ObjectItems, SharedItems } from '..';
interface Props {
data: Extract<ExplorerItem, { type: 'Object' }>;
}
export default ({ data }: Props) => {
const object = data.item;
const filePath = data.item.file_paths[0];
return (
<>
{filePath && <FilePathItems.OpenOrDownload filePath={filePath} />}
<SharedItems.OpenQuickView item={data} />
<ContextMenu.Separator />
<SharedItems.Details />
<ContextMenu.Separator />
{filePath && (
<SharedItems.RevealInNativeExplorer
filePath={filePath}
/>
)}
<SharedItems.Rename />
<ContextMenu.Separator />
<SharedItems.Share />
{(object || filePath) && <ContextMenu.Separator />}
{object && <ObjectItems.AssignTag object={object} />}
{filePath && (
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<FilePathItems.Crypto filePath={filePath} />
<FilePathItems.Compress filePath={filePath} />
<ObjectItems.ConvertObject filePath={filePath} object={object} />
</ContextMenu.SubMenu>
)}
{filePath && (
<>
<ContextMenu.Separator />
<FilePathItems.Delete filePath={filePath} />
</>
)}
</>
);
};

View file

@ -0,0 +1,130 @@
import { FileX, Share as ShareIcon } from 'phosphor-react';
import { useMemo } from 'react';
import { ExplorerItem, FilePath, useLibraryContext } from '@sd/client';
import { ContextMenu, ModifierKeys } from '@sd/ui';
import { useOperatingSystem } from '~/hooks';
import { useKeybindFactory } from '~/hooks/useKeybindFactory';
import { usePlatform } from '~/util/Platform';
import { useExplorerViewContext } from '../ViewContext';
import { getExplorerStore, useExplorerStore } from '../store';
export const OpenQuickView = ({ item }: { item: ExplorerItem }) => {
const keybind = useKeybindFactory();
return (
<ContextMenu.Item
label="Quick view"
keybind={keybind([], [' '])}
onClick={() => (getExplorerStore().quickViewObject = item)}
/>
);
};
export const Details = () => {
const { showInspector } = useExplorerStore();
const keybind = useKeybindFactory();
return (
<>
{!showInspector && (
<ContextMenu.Item
label="Details"
keybind={keybind([ModifierKeys.Control], ['I'])}
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
)}
</>
);
};
export const Rename = () => {
const explorerStore = useExplorerStore();
const keybind = useKeybindFactory();
const explorerView = useExplorerViewContext();
return (
<>
{explorerStore.layoutMode !== 'media' && (
<ContextMenu.Item
label="Rename"
keybind={keybind([], ['Enter'])}
onClick={() => explorerView.setIsRenaming(true)}
/>
)}
</>
);
};
export const RevealInNativeExplorer = (props: { locationId: number } | { filePath: FilePath }) => {
const os = useOperatingSystem();
const keybind = useKeybindFactory();
const { revealItems } = usePlatform();
const { library } = useLibraryContext();
const osFileBrowserName = useMemo(() => {
const lookup: Record<string, string> = {
macOS: 'Finder',
windows: 'Explorer'
};
return lookup[os] ?? 'file manager';
}, [os]);
return (
<>
{revealItems && <ContextMenu.Item
label={`Reveal in ${osFileBrowserName}`}
keybind={keybind([ModifierKeys.Control], ['Y'])}
onClick={() => (console.log(props), revealItems(library.uuid, ['filePath' in props ? {
FilePath: {
id: props.filePath.id,
}
} : {
Location: {
id: props.locationId
}
}]))}
/>}
</>
);
};
export const Deselect = () => {
const { cutCopyState } = useExplorerStore();
return (
<ContextMenu.Item
label="Deselect"
hidden={!cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...cutCopyState,
active: false
};
}}
icon={FileX}
/>
);
};
export const Share = () => {
return (
<>
<ContextMenu.Item
label="Share"
icon={ShareIcon}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
disabled
/>
</>
);
};

View file

@ -0,0 +1,19 @@
export * as SharedItems from './SharedItems';
export * as FilePathItems from './FilePath/Items';
export * as ObjectItems from './Object/Items';
import { ExplorerItem } from '@sd/client';
import FilePathCM from "./FilePath"
import ObjectCM from "./Object"
import LocationCM from "./Location"
export default ({ item }: { item: ExplorerItem }) => {
switch (item.type) {
case "Path":
return <FilePathCM data={item} />
case "Object":
return <ObjectCM data={item} />
case "Location":
return <LocationCM data={item} />
}
}

View file

@ -10,7 +10,7 @@ import { ReactNode } from 'react';
import DismissibleNotice from '~/components/DismissibleNotice';
import { useIsDark } from '~/hooks';
import { dismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
import { ExplorerLayoutMode, useExplorerStore } from '~/hooks/useExplorerStore';
import { ExplorerLayoutMode, useExplorerStore } from './store';
const MediaViewIcon = () => {
const isDark = useIsDark();

View file

@ -1,407 +0,0 @@
import {
ArrowBendUpRight,
Copy,
FileX,
Image,
Package,
Plus,
Scissors,
Share,
TagSimple,
Trash,
TrashSimple
} from 'phosphor-react';
import { useLocation } from 'react-router-dom';
import {
ExplorerItem,
ObjectKind,
getItemFilePath,
getItemObject,
useLibraryContext,
useLibraryMutation
} from '@sd/client';
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { getExplorerStore, useExplorerStore, useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { keybindForOs } from '~/util/keybinds';
import AssignTagMenuItems from '../AssignTagMenuItems';
import { OpenInNativeExplorer } from '../ContextMenu';
import { useExplorerViewContext } from '../ViewContext';
import { useExplorerSearchParams } from '../util';
import OpenWith from './ContextMenu/OpenWith';
// import DecryptDialog from './DecryptDialog';
import DeleteDialog from './DeleteDialog';
// import EncryptDialog from './EncryptDialog';
import EraseDialog from './EraseDialog';
interface Props {
data?: ExplorerItem;
}
export default ({ data }: Props) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const location = useLocation();
const objectData = data ? getItemObject(data) : null;
const explorerView = useExplorerViewContext();
const explorerStore = useExplorerStore();
const [{ path: currentPath }] = useExplorerSearchParams();
const { cutCopyState, showInspector, ...store } = useExplorerStore();
const isLocation =
location.pathname.includes('/location/') && explorerStore.layoutMode !== 'media';
// const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false;
// const mountedKeys = useLibraryQuery(['keys.listMounted']);
// const hasMountedKeys = mountedKeys.data?.length ?? 0 > 0;
const copyFiles = useLibraryMutation('files.copyFiles');
const fullRescan = useLibraryMutation('locations.fullRescan');
const removeFromRecents = useLibraryMutation('files.removeAccessTime');
const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation');
if (!data) return null;
const objectId = data.type == 'Path' ? data.item.object_id : null;
const locationId = store.locationId ?? getItemFilePath(data)?.location_id;
const objectDateAccessed =
data.type == 'Path' ? data.item.object && data.item.object.date_accessed : null;
return (
<>
<OpenOrDownloadOptions data={data} />
<ContextMenu.Separator />
{!showInspector && (
<>
<ContextMenu.Item
label="Details"
keybind={keybind([ModifierKeys.Control], ['I'])}
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
<ContextMenu.Separator />
</>
)}
<OpenInNativeExplorer />
<ContextMenu.Item
hidden={explorerStore.layoutMode === 'media'}
label="Rename"
keybind={keybind([], ['Enter'])}
onClick={() => explorerView.setIsRenaming(true)}
/>
{objectId && objectDateAccessed && (
<ContextMenu.Item
label="Remove from recents"
onClick={async () => {
try {
await removeFromRecents.mutateAsync([objectId]);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to remove file from recents, due to an error: ${error}`
});
}
}}
/>
)}
{locationId && (
<>
<ContextMenu.Item
hidden={!isLocation}
label="Cut"
keybind={keybind([ModifierKeys.Control], ['X'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: currentPath ?? '/',
sourceLocationId: locationId,
sourcePathId: data.item.id,
actionType: 'Cut',
active: true
};
}}
icon={Scissors}
/>
<ContextMenu.Item
hidden={!isLocation}
label="Copy"
keybind={keybind([ModifierKeys.Control], ['C'])}
onClick={() => {
getExplorerStore().cutCopyState = {
sourceParentPath: currentPath ?? '/',
sourceLocationId: locationId,
sourcePathId: data.item.id,
actionType: 'Copy',
active: true
};
}}
icon={Copy}
/>
<ContextMenu.Item
hidden={!isLocation}
label="Duplicate"
keybind={keybind([ModifierKeys.Control], ['D'])}
onClick={async () => {
try {
await copyFiles.mutateAsync({
source_location_id: locationId,
sources_file_path_ids: [data.item.id],
target_location_id: locationId,
target_location_relative_directory_path: currentPath ?? '/',
target_file_name_suffix: ' copy'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to duplcate file, due to an error: ${error}`
});
}
}}
/>
</>
)}
<ContextMenu.Item
label="Deselect"
hidden={!(cutCopyState.active && isLocation)}
onClick={() => {
getExplorerStore().cutCopyState = {
...cutCopyState,
active: false
};
}}
icon={FileX}
/>
<ContextMenu.Separator />
<ContextMenu.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
disabled
/>
<ContextMenu.Separator />
{objectData && (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={objectData.id} />
</ContextMenu.SubMenu>
)}
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
{/* <ContextMenu.Item
label="Encrypt"
icon={LockSimple}
keybind="⌘E"
onClick={() => {
if (keyManagerUnlocked && hasMountedKeys) {
dialogManager.create((dp) => (
<EncryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else if (!keyManagerUnlocked) {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
} else if (!hasMountedKeys) {
showAlertDialog({
title: 'No mounted keys',
value: 'No mounted keys were found. Please mount a key and try again.'
});
}
}}
/> */}
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
{/* <ContextMenu.Item
label="Decrypt"
icon={LockSimpleOpen}
keybind="⌘D"
onClick={() => {
if (keyManagerUnlocked) {
dialogManager.create((dp) => (
<DecryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
}
}}
/> */}
<ContextMenu.Item
label="Compress"
icon={Package}
keybind={keybind([ModifierKeys.Control], ['B'])}
disabled
/>
{[ObjectKind.Image, ObjectKind.Video].includes(objectData?.kind as ObjectKind) && (
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
{(() => {
switch (objectData?.kind) {
case ObjectKind.Image:
return (
<>
<ContextMenu.Item label="PNG" disabled />
<ContextMenu.Item label="WebP" disabled />
<ContextMenu.Item label="Gif" disabled />
</>
);
case ObjectKind.Video:
return (
<>
<ContextMenu.Item label="MP4" disabled />
<ContextMenu.Item label="MOV" disabled />
<ContextMenu.Item label="AVI" disabled />
</>
);
}
})()}
</ContextMenu.SubMenu>
)}
{locationId != null && (
<>
<ContextMenu.Item
hidden={!isLocation}
onClick={async () => {
try {
await fullRescan.mutateAsync(locationId);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to rescan location, due to an error: ${error}`
});
}
}}
label="Rescan Directory"
icon={Package}
/>
<ContextMenu.Item
onClick={async () => {
try {
await generateThumbnails.mutateAsync({
id: locationId,
path: currentPath ?? '/'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to generate thumbnails, due to an error: ${error}`
});
}
}}
label="Regen Thumbnails"
icon={Image}
/>
<ContextMenu.Item
variant="danger"
label="Secure delete"
icon={TrashSimple}
onClick={() =>
dialogManager.create((dp) => (
<EraseDialog
{...dp}
location_id={locationId}
path_id={data.item.id}
/>
))
}
disabled
/>
</>
)}
</ContextMenu.SubMenu>
<ContextMenu.Separator />
{locationId != null && (
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind={keybind([ModifierKeys.Control], ['Delete'])}
onClick={() =>
dialogManager.create((dp) => (
<DeleteDialog {...dp} location_id={locationId} path_id={data.item.id} />
))
}
/>
)}
</>
);
};
const OpenOrDownloadOptions = (props: { data: ExplorerItem }) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const { openFilePath } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(props.data);
const { library } = useLibraryContext();
if (os === 'browser') return <ContextMenu.Item label="Download" />;
else
return (
<>
{filePath && (
<>
{openFilePath && (
<ContextMenu.Item
label="Open"
keybind={keybind([ModifierKeys.Control], ['O'])}
onClick={async () => {
if (props.data.type === 'Path' && props.data.item.object_id)
updateAccessTime
.mutateAsync(props.data.item.object_id)
.catch(console.error);
try {
await openFilePath(library.uuid, [filePath.id]);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to open file, due to an error: ${error}`
});
}
}}
/>
)}
<OpenWith filePath={filePath} />
</>
)}
<ContextMenu.Item
label="Quick view"
keybind={keybind([], [' '])}
onClick={() => (getExplorerStore().quickViewObject = props.data)}
/>
</>
);
};

View file

@ -3,15 +3,12 @@ import clsx from 'clsx';
import { ImgHTMLAttributes, memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ExplorerItem, getItemLocation, useLibraryContext } from '@sd/client';
import { PDFViewer } from '~/components';
import {
getExplorerStore,
useCallbackToWatchResize,
useExplorerItemData,
useExplorerStore,
useIsDark
} from '~/hooks';
import { useCallbackToWatchResize, useIsDark } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { pdfViewerEnabled } from '~/util/pdfViewer';
import { useExplorerContext } from '../Context';
import { getExplorerStore } from '../store';
import { useExplorerItemData } from '../util';
import classes from './Thumb.module.scss';
interface ThumbnailProps {
@ -123,7 +120,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
const [src, setSrc] = useState<null | string>(null);
const [loaded, setLoaded] = useState<boolean>(false);
const [thumbType, setThumbType] = useState(ThumbType.Icon);
const { locationId: explorerLocationId } = useExplorerStore();
const { parent } = useExplorerContext();
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
// thus avoiding improper thumb types changes
@ -152,7 +149,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
locationId: itemLocationId,
thumbnailKey
} = itemData;
const locationId = itemLocationId ?? explorerLocationId;
const locationId =
itemLocationId ?? (parent?.type === 'Location' ? parent.location.id : null);
switch (thumbType) {
case ThumbType.Original:
if (locationId) {
@ -183,15 +182,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
if (isDir !== null) setSrc(getIcon(kind, isDark, extension, isDir));
break;
}
}, [
props.data.item.id,
isDark,
library.uuid,
itemData,
platform,
thumbType,
explorerLocationId
]);
}, [props.data.item.id, isDark, library.uuid, itemData, platform, thumbType, parent]);
const onLoad = () => setLoaded(true);

View file

@ -17,9 +17,9 @@ import {
useLibraryQuery
} from '@sd/client';
import { Button, Divider, DropdownMenu, Tooltip, tw } from '@sd/ui';
import { useExplorerStore, useIsDark } from '~/hooks';
import { useIsDark } from '~/hooks';
import AssignTagMenuItems from '../AssignTagMenuItems';
import FileThumb from '../File/Thumb';
import FileThumb from '../FilePath/Thumb';
import FavoriteButton from './FavoriteButton';
import Note from './Note';
@ -47,7 +47,6 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro
const isDark = useIsDark();
const objectData = data ? getItemObject(data) : null;
const filePathData = data ? getItemFilePath(data) : null;
const explorerStore = useExplorerStore();
const isDir = data?.type === 'Path' ? data.item.is_dir : false;

View file

@ -1,12 +1,7 @@
import { RadixCheckbox, Select, SelectOption, Slider, tw } from '@sd/ui';
import { type SortOrder, SortOrderSchema } from '~/app/route-schemas';
import {
FilePathSearchOrderingKeys,
getExplorerConfigStore,
getExplorerStore,
useExplorerConfigStore,
useExplorerStore
} from '~/hooks';
import { getExplorerConfigStore, useExplorerConfigStore } from './config';
import { FilePathSearchOrderingKeys, getExplorerStore, useExplorerStore } from './store';
const Heading = tw.div`text-ink-dull text-xs font-semibold`;
const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`;

View file

@ -0,0 +1,165 @@
import { Clipboard, FileX, Image, Plus, Repeat, Share, ShieldCheck } from 'phosphor-react';
import { PropsWithChildren } from 'react';
import { useLibraryMutation } from '@sd/client';
import { ContextMenu as CM, ModifierKeys } from '@sd/ui';
import { showAlertDialog } from '~/components';
import { useOperatingSystem } from '~/hooks';
import { keybindForOs } from '~/util/keybinds';
import { useExplorerContext } from './Context';
import { SharedItems } from './ContextMenu';
import { getExplorerStore, useExplorerStore } from './store';
import { useExplorerSearchParams } from './util';
export default (props: PropsWithChildren) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const [{ path: currentPath }] = useExplorerSearchParams();
const { cutCopyState } = useExplorerStore();
const { parent } = useExplorerContext();
const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation');
const objectValidator = useLibraryMutation('jobs.objectValidator');
const rescanLocation = useLibraryMutation('locations.fullRescan');
const copyFiles = useLibraryMutation('files.copyFiles');
const cutFiles = useLibraryMutation('files.cutFiles');
return (
<CM.Root trigger={props.children}>
{parent?.type === 'Location' && (
<SharedItems.RevealInNativeExplorer locationId={parent.location.id} />
)}
<CM.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
disabled
/>
<CM.Separator />
{parent?.type === 'Location' && (
<>
<CM.Item
onClick={async () => {
try {
await rescanLocation.mutateAsync(parent.location.id);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to re-index location, due to an error: ${error}`
});
}
}}
label="Re-index"
icon={Repeat}
/>
<CM.Item
label="Paste"
keybind={keybind([ModifierKeys.Control], ['V'])}
hidden={!cutCopyState.active}
onClick={async () => {
const path = currentPath ?? '/';
const { actionType, sourcePathId, sourceParentPath, sourceLocationId } =
cutCopyState;
const sameLocation =
sourceLocationId === parent.location.id &&
sourceParentPath === path;
try {
if (actionType == 'Copy') {
await copyFiles.mutateAsync({
source_location_id: sourceLocationId,
sources_file_path_ids: [sourcePathId],
target_location_id: parent.location.id,
target_location_relative_directory_path: path,
target_file_name_suffix: sameLocation ? ' copy' : null
});
} else if (sameLocation) {
showAlertDialog({
title: 'Error',
value: `File already exists in this location`
});
} else {
await cutFiles.mutateAsync({
source_location_id: sourceLocationId,
sources_file_path_ids: [sourcePathId],
target_location_id: parent.location.id,
target_location_relative_directory_path: path
});
}
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to ${actionType.toLowerCase()} file, due to an error: ${error}`
});
}
}}
icon={Clipboard}
/>
</>
)}
<CM.Item
label="Deselect"
hidden={!cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...cutCopyState,
active: false
};
}}
icon={FileX}
/>
{parent?.type === 'Location' && (
<CM.SubMenu label="More actions..." icon={Plus}>
<CM.Item
onClick={async () => {
try {
await generateThumbsForLocation.mutateAsync({
id: parent.location.id,
path: currentPath ?? '/'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to generate thumbanails, due to an error: ${error}`
});
}
}}
label="Regen Thumbnails"
icon={Image}
/>
<CM.Item
onClick={async () => {
try {
objectValidator.mutateAsync({
id: parent.location.id,
path: currentPath ?? '/'
});
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Failed to generate checksum, due to an error: ${error}`
});
}
}}
label="Generate Checksums"
icon={ShieldCheck}
/>
</CM.SubMenu>
)}
</CM.Root>
);
};

View file

@ -0,0 +1,26 @@
import { PropsWithChildren, RefObject, createContext, useContext, useRef } from 'react';
interface QuickPreviewContext {
ref: RefObject<HTMLDivElement>;
}
const QuickPreviewContext = createContext<QuickPreviewContext | null>(null);
export const QuickPreviewContextProvider = ({ children }: PropsWithChildren) => {
const ref = useRef<HTMLDivElement>(null);
return (
<QuickPreviewContext.Provider value={{ ref }}>
{children}
<div ref={ref} />
</QuickPreviewContext.Provider>
);
};
export const useQuickPreviewContext = () => {
const context = useContext(QuickPreviewContext);
if (!context) throw new Error('QuickPreviewContext.Provider not found!');
return context;
};

View file

@ -5,8 +5,8 @@ import { useEffect, useRef, useState } from 'react';
import { subscribeKey } from 'valtio/utils';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import { getExplorerStore } from '~/hooks';
import FileThumb from './File/Thumb';
import FileThumb from '../FilePath/Thumb';
import { getExplorerStore } from '../store';
const AnimatedDialogOverlay = animated(Dialog.Overlay);
const AnimatedDialogContent = animated(Dialog.Content);

View file

@ -0,0 +1,131 @@
import * as Dialog from '@radix-ui/react-dialog';
import { animated, useTransition } from '@react-spring/web';
import { X } from 'phosphor-react';
import { useEffect, useRef, useState } from 'react';
import { subscribeKey } from 'valtio/utils';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import FileThumb from '../FilePath/Thumb';
import { getExplorerStore } from '../store';
const AnimatedDialogOverlay = animated(Dialog.Overlay);
const AnimatedDialogContent = animated(Dialog.Content);
export interface QuickPreviewProps extends Dialog.DialogProps {
transformOrigin?: string;
}
export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
const explorerItem = useRef<null | ExplorerItem>(null);
const explorerStore = getExplorerStore();
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* The useEffect hook with subscribe is used here, instead of useExplorerStore, because when
* explorerStore.quickViewObject is set to null the component will not close immediately.
* Instead, it will enter the beginning of the close transition and it must continue to display
* content for a few more seconds due to the ongoing animation. To handle this, the open state
* is decoupled from the store state, by assigning references to the required store properties
* to render the component in the subscribe callback.
*/
useEffect(
() =>
subscribeKey(explorerStore, 'quickViewObject', () => {
const { quickViewObject } = explorerStore;
if (quickViewObject != null) {
setIsOpen(true);
explorerItem.current = quickViewObject;
} else {
setIsOpen(false);
}
}),
[explorerStore]
);
const transitions = useTransition(isOpen, {
from: {
opacity: 0,
transform: `translateY(20px) scale(0.9)`,
transformOrigin: transformOrigin || 'center top'
},
enter: { opacity: 1, transform: `translateY(0px) scale(1)` },
leave: { opacity: 0, transform: `translateY(40px) scale(0.9)` },
config: { mass: 0.2, tension: 300, friction: 20, bounce: 0 }
});
return (
<>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) explorerStore.quickViewObject = null;
}}
>
{transitions((styles, show) => {
if (!show || explorerItem.current == null) return null;
const { item } = explorerItem.current;
return (
<>
<Dialog.Portal forceMount>
<AnimatedDialogOverlay
style={{
opacity: styles.opacity
}}
className="z-49 absolute inset-0 m-[1px] grid place-items-center overflow-y-auto rounded-xl bg-app/50"
/>
<AnimatedDialogContent
style={styles}
className="!pointer-events-none absolute inset-0 z-50 grid h-screen place-items-center"
>
<div className="!pointer-events-auto flex h-5/6 max-h-screen w-11/12 flex-col rounded-md border border-app-line bg-app-box text-ink shadow-app-shade">
<nav className="relative flex w-full flex-row">
<Dialog.Close
asChild
className="absolute m-2"
aria-label="Close"
>
<Button
size="icon"
variant="outline"
className="flex flex-row"
>
<X
weight="bold"
className=" h-3 w-3 text-ink-faint"
/>
<span className="ml-1 text-tiny font-medium text-ink-faint">
ESC
</span>
</Button>
</Dialog.Close>
<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
: 'Unkown Object'}
</span>
</Dialog.Title>
</nav>
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
<FileThumb
size={0}
data={explorerItem.current}
className="w-full"
loadOriginal
mediaControls
/>
</div>
</div>
</AnimatedDialogContent>
</Dialog.Portal>
</>
);
})}
</Dialog.Root>
</>
);
}

View file

@ -11,15 +11,16 @@ import {
} from 'phosphor-react';
import { useEffect, useRef } from 'react';
import { useRspcLibraryContext } from '@sd/client';
import OptionsPanel from '~/app/$libraryId/Explorer/OptionsPanel';
import { KeyManager } from '~/app/$libraryId/KeyManager';
import { TOP_BAR_ICON_STYLE, ToolOption } from '~/app/$libraryId/TopBar/TopBarOptions';
import { getExplorerStore, useExplorerStore } from './useExplorerStore';
import { KeyManager } from '../KeyManager';
import TopBarOptions, { TOP_BAR_ICON_STYLE, ToolOption } from '../TopBar/TopBarOptions';
import { useExplorerContext } from './Context';
import OptionsPanel from './OptionsPanel';
import { getExplorerStore, useExplorerStore } from './store';
export const useExplorerTopBarOptions = () => {
const explorerStore = useExplorerStore();
const explorerViewOptions: ToolOption[] = [
const viewOptions: ToolOption[] = [
{
toolTipLabel: 'Grid view',
icon: <SquaresFour className={TOP_BAR_ICON_STYLE} />,
@ -50,7 +51,7 @@ export const useExplorerTopBarOptions = () => {
}
];
const explorerControlOptions: ToolOption[] = [
const controlOptions: ToolOption[] = [
{
toolTipLabel: 'Explorer display',
icon: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
@ -81,7 +82,9 @@ export const useExplorerTopBarOptions = () => {
const { client } = useRspcLibraryContext();
const explorerToolOptions: ToolOption[] = [
const { parent } = useExplorerContext();
const toolOptions = [
{
toolTipLabel: 'Key Manager',
icon: <Key className={TOP_BAR_ICON_STYLE} />,
@ -102,28 +105,40 @@ export const useExplorerTopBarOptions = () => {
individual: true,
showAtResolution: 'xl:flex'
},
{
parent?.type === 'Location' && {
toolTipLabel: 'Reload',
onClick: () => {
if (explorerStore.locationId) {
quickRescanSubscription.current?.();
quickRescanSubscription.current = client.addSubscription(
[
'locations.quickRescan',
{
location_id: explorerStore.locationId,
sub_path: ''
}
],
{ onData() {} }
);
}
quickRescanSubscription.current?.();
quickRescanSubscription.current = client.addSubscription(
[
'locations.quickRescan',
{
location_id: parent.location.id,
sub_path: ''
}
],
{ onData() {} }
);
},
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
individual: true,
showAtResolution: 'xl:flex'
}
];
].filter(Boolean) as ToolOption[];
return { explorerViewOptions, explorerControlOptions, explorerToolOptions };
return {
viewOptions,
controlOptions,
toolOptions
};
};
export const DefaultTopBarOptions = () => {
const options = useExplorerTopBarOptions();
return (
<TopBarOptions
options={[options.viewOptions, options.toolOptions, options.controlOptions]}
/>
);
};

View file

@ -3,10 +3,10 @@ import clsx from 'clsx';
import { memo } from 'react';
import { ExplorerItem, bytesToNumber, getItemFilePath, getItemLocation } from '@sd/client';
import GridList from '~/components/GridList';
import { isCut, useExplorerStore } from '~/hooks';
import { ViewItem } from '.';
import FileThumb from '../File/Thumb';
import FileThumb from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';
import { isCut, useExplorerStore } from '../store';
import RenamableItemText from './RenamableItemText';
interface GridViewItemProps {

View file

@ -26,17 +26,12 @@ import {
getItemObject,
isPath
} from '@sd/client';
import {
FilePathSearchOrderingKeys,
getExplorerStore,
isCut,
useExplorerStore,
useScrolled
} from '~/hooks';
import { useScrolled } from '~/hooks';
import { ViewItem } from '.';
import FileThumb from '../File/Thumb';
import FileThumb from '../FilePath/Thumb';
import { InfoPill } from '../Inspector';
import { useExplorerViewContext } from '../ViewContext';
import { FilePathSearchOrderingKeys, getExplorerStore, isCut, useExplorerStore } from '../store';
import RenamableItemText from './RenamableItemText';
interface ListViewItemProps {

View file

@ -4,10 +4,10 @@ import { memo } from 'react';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import GridList from '~/components/GridList';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { ViewItem } from '.';
import FileThumb from '../File/Thumb';
import FileThumb from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';
import { getExplorerStore, useExplorerStore } from '../store';
interface MediaViewItemProps {
data: ExplorerItem;

View file

@ -1,8 +1,8 @@
/* eslint-disable no-case-declarations */
import clsx from 'clsx';
import { ExplorerItem, getItemFilePath, getItemLocation } from '@sd/client';
import { useExplorerStore } from '~/hooks';
import { RenameLocationTextBox, RenamePathTextBox } from '../File/RenameTextBox';
import { RenameLocationTextBox, RenamePathTextBox } from '../FilePath/RenameTextBox';
import { useExplorerStore } from '../store';
export default function RenamableItemText(props: {
item: ExplorerItem;

View file

@ -1,309 +1,301 @@
import clsx from 'clsx';
import { Columns, GridFour, Icon, MonitorPlay, Rows } from 'phosphor-react';
import {
HTMLAttributes,
PropsWithChildren,
ReactNode,
isValidElement,
memo,
useCallback,
useEffect,
useMemo,
useState
HTMLAttributes,
PropsWithChildren,
ReactNode,
isValidElement,
memo,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { createSearchParams, useNavigate } from 'react-router-dom';
import {
ExplorerItem,
getExplorerItemData,
getItemFilePath,
getItemLocation,
isPath,
useLibraryContext,
useLibraryMutation
ExplorerItem,
getExplorerItemData,
getItemFilePath,
getItemLocation,
isPath,
useLibraryContext,
useLibraryMutation
} from '@sd/client';
import { ContextMenu, ModifierKeys, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components';
import {
ExplorerLayoutMode,
getExplorerStore,
useExplorerConfigStore,
useOperatingSystem
} from '~/hooks';
import { useOperatingSystem } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import CreateDialog from '../../settings/library/tags/CreateDialog';
import {
ExplorerViewContext,
ExplorerViewSelection,
ExplorerViewSelectionChange,
ViewContext,
useExplorerViewContext
ExplorerViewContext,
ExplorerViewSelection,
ExplorerViewSelectionChange,
ViewContext,
useExplorerViewContext
} from '../ViewContext';
import { useExplorerConfigStore } from '../config';
import { ExplorerLayoutMode, getExplorerStore } from '../store';
import GridView from './GridView';
import ListView from './ListView';
import MediaView from './MediaView';
interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
data: ExplorerItem;
}
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
const explorerView = useExplorerViewContext();
const { library } = useLibraryContext();
const navigate = useNavigate();
const explorerView = useExplorerViewContext();
const { library } = useLibraryContext();
const navigate = useNavigate();
const { openFilePath } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(data);
const location = getItemLocation(data);
const { openFilePaths } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(data);
const location = getItemLocation(data);
const explorerConfig = useExplorerConfigStore();
const explorerConfig = useExplorerConfigStore();
const onDoubleClick = () => {
if (location) {
navigate({
pathname: `/${library.uuid}/location/${location.id}`,
search: createSearchParams({
path: `/`
}).toString()
});
} else if (isPath(data) && data.item.is_dir) {
navigate({
pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`,
search: createSearchParams({
path: `${data.item.materialized_path}${data.item.name}/`
}).toString()
});
} else if (
openFilePath &&
filePath &&
explorerConfig.openOnDoubleClick &&
!explorerView.isRenaming
) {
if (data.type === 'Path' && data.item.object_id) {
updateAccessTime.mutate(data.item.object_id);
}
const onDoubleClick = () => {
if (location) {
navigate({
pathname: `/${library.uuid}/location/${location.id}`,
search: createSearchParams({
path: `/`
}).toString()
});
} else if (isPath(data) && data.item.is_dir) {
navigate({
pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`,
search: createSearchParams({
path: `${data.item.materialized_path}${data.item.name}/`
}).toString()
});
} else if (
openFilePaths &&
filePath &&
explorerConfig.openOnDoubleClick &&
!explorerView.isRenaming
) {
if (data.type === 'Path' && data.item.object_id) {
updateAccessTime.mutate(data.item.object_id);
}
openFilePath(library.uuid, [filePath.id]);
} else {
const { kind } = getExplorerItemData(data);
openFilePaths(library.uuid, [filePath.id]);
} else {
const { kind } = getExplorerItemData(data);
if (['Video', 'Image', 'Audio'].includes(kind)) {
getExplorerStore().quickViewObject = data;
}
}
};
if (['Video', 'Image', 'Audio'].includes(kind)) {
getExplorerStore().quickViewObject = data;
}
}
};
return (
<ContextMenu.Root
trigger={
<div onDoubleClick={onDoubleClick} {...props}>
{children}
</div>
}
onOpenChange={explorerView.setIsContextMenuOpen}
disabled={!explorerView.contextMenu}
asChild={false}
>
{explorerView.contextMenu}
</ContextMenu.Root>
);
return (
<ContextMenu.Root
trigger={
<div onDoubleClick={onDoubleClick} {...props}>
{children}
</div>
}
onOpenChange={explorerView.setIsContextMenuOpen}
disabled={!explorerView.contextMenu}
asChild={false}
>
{explorerView.contextMenu}
</ContextMenu.Root>
);
};
export interface ExplorerViewProps<T extends ExplorerViewSelection = ExplorerViewSelection>
extends Omit<
ExplorerViewContext<T>,
'multiSelect' | 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen'
> {
layout: ExplorerLayoutMode;
className?: string;
emptyNotice?: JSX.Element | { icon?: Icon | ReactNode; message?: ReactNode } | null;
extends Omit<
ExplorerViewContext<T>,
'multiSelect' | 'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen'
> {
layout: ExplorerLayoutMode;
className?: string;
emptyNotice?: JSX.Element | { icon?: Icon | ReactNode; message?: ReactNode } | null;
}
export default memo(
<T extends ExplorerViewSelection>({
layout,
className,
emptyNotice,
...contextProps
}: ExplorerViewProps<T>) => {
const os = useOperatingSystem();
const { library } = useLibraryContext();
const { openFilePath } = usePlatform();
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const selectedItem = useMemo(
() =>
contextProps.items?.find(
(item) =>
item.item.id ===
(Array.isArray(contextProps.selected)
? contextProps.selected[0]
: contextProps.selected)
),
[contextProps.items, contextProps.selected]
);
const itemPath = selectedItem ? getItemFilePath(selectedItem) : null;
<T extends ExplorerViewSelection>({
layout,
className,
emptyNotice,
...contextProps
}: ExplorerViewProps<T>) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const handleNewTag = useCallback(
async (event: KeyboardEvent) => {
if (
itemPath == null ||
event.key.toUpperCase() !== 'N' ||
!event.getModifierState(
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
)
)
return;
const emptyNoticeIcon = (icon?: Icon) => {
const Icon =
icon ??
{
grid: GridFour,
media: MonitorPlay,
columns: Columns,
rows: Rows
}[layout];
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={itemPath.id} />);
},
[os, itemPath]
);
return <Icon size={100} opacity={0.3} />;
};
const handleOpenShortcut = useCallback(
async (event: KeyboardEvent) => {
if (
itemPath == null ||
openFilePath == null ||
event.key.toUpperCase() !== 'O' ||
!event.getModifierState(
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
)
)
return;
useKeyDownHandlers({
items: contextProps.items,
selected: contextProps.selected,
isRenaming
});
try {
await openFilePath(library.uuid, [itemPath.id]);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Couldn't open file, due to an error: ${error}`
});
}
},
[os, itemPath, library.uuid, openFilePath]
);
return (
<div
className={clsx('h-full w-full', className)}
onMouseDown={() =>
contextProps.onSelectedChange?.(
(Array.isArray(contextProps.selected)
? []
: undefined) as ExplorerViewSelectionChange<T>
)
}
>
{contextProps.items === null ||
(contextProps.items && contextProps.items.length > 0) ? (
<ViewContext.Provider
value={
{
...contextProps,
multiSelect: Array.isArray(contextProps.selected),
selectable: !isContextMenuOpen,
setIsContextMenuOpen,
isRenaming,
setIsRenaming
} as ExplorerViewContext
}
>
{layout === 'grid' && <GridView />}
{layout === 'rows' && <ListView />}
{layout === 'media' && <MediaView />}
</ViewContext.Provider>
) : emptyNotice === null ? null : isValidElement(emptyNotice) ? (
emptyNotice
) : (
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
{emptyNotice && 'icon' in emptyNotice
? isValidElement(emptyNotice.icon)
? emptyNotice.icon
: emptyNoticeIcon(emptyNotice.icon as Icon)
: emptyNoticeIcon()}
const handleOpenQuickPreview = useCallback(
async (event: KeyboardEvent) => {
if (event.key !== ' ') return;
if (!getExplorerStore().quickViewObject) {
if (selectedItem) {
getExplorerStore().quickViewObject = selectedItem;
}
} else {
getExplorerStore().quickViewObject = null;
}
},
[selectedItem]
);
const handleExplorerShortcut = useCallback(
(event: KeyboardEvent) => {
if (
event.key.toUpperCase() !== 'I' ||
!event.getModifierState(
os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control
)
)
return;
getExplorerStore().showInspector = !getExplorerStore().showInspector;
},
[os]
);
useEffect(() => {
const handlers = [
handleNewTag,
handleOpenShortcut,
handleOpenQuickPreview,
handleExplorerShortcut
];
const handler = (event: KeyboardEvent) => {
if (isRenaming) return;
for (const handler of handlers) handler(event);
};
document.body.addEventListener('keydown', handler);
return () => document.body.removeEventListener('keydown', handler);
}, [
isRenaming,
handleNewTag,
handleOpenShortcut,
handleOpenQuickPreview,
handleExplorerShortcut
]);
const emptyNoticeIcon = (icon?: Icon) => {
let Icon = icon;
if (!Icon) {
switch (layout) {
case 'grid':
Icon = GridFour;
break;
case 'media':
Icon = MonitorPlay;
break;
case 'columns':
Icon = Columns;
break;
case 'rows':
Icon = Rows;
break;
}
}
return <Icon size={100} opacity={0.3} />;
};
return (
<div
className={clsx('h-full w-full', className)}
onMouseDown={() =>
contextProps.onSelectedChange?.(
(Array.isArray(contextProps.selected)
? []
: undefined) as ExplorerViewSelectionChange<T>
)
}
>
{contextProps.items === null ||
(contextProps.items && contextProps.items.length > 0) ? (
<ViewContext.Provider
value={
{
...contextProps,
multiSelect: Array.isArray(contextProps.selected),
selectable: !isContextMenuOpen,
setIsContextMenuOpen,
isRenaming,
setIsRenaming
} as ExplorerViewContext
}
>
{layout === 'grid' && <GridView />}
{layout === 'rows' && <ListView />}
{layout === 'media' && <MediaView />}
</ViewContext.Provider>
) : emptyNotice === null ? null : isValidElement(emptyNotice) ? (
emptyNotice
) : (
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
{emptyNotice && 'icon' in emptyNotice
? isValidElement(emptyNotice.icon)
? emptyNotice.icon
: emptyNoticeIcon(emptyNotice.icon as Icon)
: emptyNoticeIcon()}
<p className="mt-5 text-xs">
{emptyNotice && 'message' in emptyNotice
? emptyNotice.message
: 'This list is empty'}
</p>
</div>
)}
</div>
);
}
<p className="mt-5 text-xs">
{emptyNotice && 'message' in emptyNotice
? emptyNotice.message
: 'This list is empty'}
</p>
</div>
)}
</div>
);
}
) as <T extends ExplorerViewSelection>(props: ExplorerViewProps<T>) => JSX.Element;
const useKeyDownHandlers = ({
items,
selected,
isRenaming
}: Pick<ExplorerViewProps, 'items' | 'selected'> & { isRenaming: boolean }) => {
const os = useOperatingSystem();
const { library } = useLibraryContext();
const { openFilePaths } = usePlatform();
const selectedItem = useMemo(
() =>
items?.find(
(item) => item.item.id === (Array.isArray(selected) ? selected[0] : selected)
),
[items, selected]
);
const itemPath = selectedItem ? getItemFilePath(selectedItem) : null;
const handleNewTag = useCallback(
async (event: KeyboardEvent) => {
if (
itemPath == null ||
event.key.toUpperCase() !== 'N' ||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
)
return;
dialogManager.create((dp) => <CreateDialog {...dp} assignToObject={itemPath.id} />);
},
[os, itemPath]
);
const handleOpenShortcut = useCallback(
async (event: KeyboardEvent) => {
if (
itemPath == null ||
openFilePaths == null ||
event.key.toUpperCase() !== 'O' ||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
)
return;
try {
await openFilePaths(library.uuid, [itemPath.id]);
} catch (error) {
showAlertDialog({
title: 'Error',
value: `Couldn't open file, due to an error: ${error}`
});
}
},
[os, itemPath, library.uuid, openFilePaths]
);
const handleOpenQuickPreview = useCallback(
async (event: KeyboardEvent) => {
if (event.key !== ' ') return;
if (!getExplorerStore().quickViewObject) {
if (selectedItem) {
getExplorerStore().quickViewObject = selectedItem;
}
} else {
getExplorerStore().quickViewObject = null;
}
},
[selectedItem]
);
const handleExplorerShortcut = useCallback(
(event: KeyboardEvent) => {
if (
event.key.toUpperCase() !== 'I' ||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
)
return;
getExplorerStore().showInspector = !getExplorerStore().showInspector;
},
[os]
);
useEffect(() => {
const handlers = [
handleNewTag,
handleOpenShortcut,
handleOpenQuickPreview,
handleExplorerShortcut
];
const handler = (event: KeyboardEvent) => {
if (isRenaming) return;
for (const handler of handlers) handler(event);
};
document.body.addEventListener('keydown', handler);
return () => document.body.removeEventListener('keydown', handler);
}, [
isRenaming,
handleNewTag,
handleOpenShortcut,
handleOpenQuickPreview,
handleExplorerShortcut
]);
};

View file

@ -1,13 +1,18 @@
import { FolderNotchOpen } from 'phosphor-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ExplorerItem, useLibrarySubscription } from '@sd/client';
import { useExplorerStore, useKeyDeleteFile } from '~/hooks';
import { useKeyDeleteFile } from '~/hooks';
import { TOP_BAR_HEIGHT } from '../TopBar';
import ExplorerContextMenu from './ContextMenu';
import { useExplorerContext } from './Context';
import ContextMenu from './ContextMenu';
import DismissibleNotice from './DismissibleNotice';
import ContextMenu from './File/ContextMenu';
import { Inspector } from './Inspector';
import ExplorerContextMenu from './ParentContextMenu';
import { QuickPreview } from './QuickPreview';
import { useQuickPreviewContext } from './QuickPreview/Context';
import View, { ExplorerViewProps } from './View';
import { useExplorerStore } from './store';
import { useExplorerSearchParams } from './util';
interface Props {
@ -47,10 +52,17 @@ export default function Explorer(props: Props) {
}
});
useKeyDeleteFile(selectedItem || null, explorerStore.locationId);
const ctx = useExplorerContext();
useKeyDeleteFile(
selectedItem || null,
ctx.parent?.type === 'Location' ? ctx.parent.location.id : null
);
useEffect(() => setSelectedItemId(undefined), [path]);
const quickPreviewCtx = useQuickPreviewContext();
return (
<>
<ExplorerContextMenu>
@ -72,7 +84,7 @@ export default function Explorer(props: Props) {
rowsBeforeLoadMore={5}
selected={selectedItemId}
onSelectedChange={setSelectedItemId}
contextMenu={<ContextMenu data={selectedItem} />}
contextMenu={selectedItem && <ContextMenu item={selectedItem} />}
emptyNotice={
props.emptyNotice || {
icon: FolderNotchOpen,
@ -84,6 +96,9 @@ export default function Explorer(props: Props) {
</div>
</ExplorerContextMenu>
{quickPreviewCtx.ref.current &&
createPortal(<QuickPreview />, quickPreviewCtx.ref.current)}
{explorerStore.showInspector && (
<Inspector
data={selectedItem}

View file

@ -0,0 +1,86 @@
import { proxy, useSnapshot } from 'valtio';
import { proxySet } from 'valtio/utils';
import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering, resetStore } from '@sd/client';
import { SortOrder } from '~/app/route-schemas';
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never
: never;
type Leaves<T> = T extends object ? { [K in keyof T]-?: Join<K, Leaves<T[K]>> }[keyof T] : '';
type UnionKeys<T> = T extends any ? Leaves<T> : never;
export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media';
export enum ExplorerKind {
Location,
Tag,
Space
}
export type CutCopyType = 'Cut' | 'Copy';
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
const state = {
layoutMode: 'grid' as ExplorerLayoutMode,
gridItemSize: 110,
listItemSize: 40,
showBytesInGridView: true,
tagAssignMode: false,
showInspector: false,
mediaPlayerVolume: 0.7,
multiSelectIndexes: [] as number[],
newThumbnails: proxySet() as Set<string>,
cutCopyState: {
sourceParentPath: '', // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
sourceLocationId: 0,
sourcePathId: 0,
actionType: 'Cut',
active: false
},
quickViewObject: null as ExplorerItem | null,
mediaColumns: 8,
mediaAspectSquare: false,
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
orderByDirection: 'Desc' as SortOrder,
groupBy: 'none'
};
export function flattenThumbnailKey(thumbKey: string[]) {
return thumbKey.join('/');
}
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
const explorerStore = proxy({
...state,
reset: () => resetStore(explorerStore, state),
addNewThumbnail: (thumbKey: string[]) => {
explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey));
},
// this should be done when the explorer query is refreshed
// prevents memory leak
resetNewThumbnails: () => {
explorerStore.newThumbnails.clear();
}
});
export function useExplorerStore() {
return useSnapshot(explorerStore);
}
export function getExplorerStore() {
return explorerStore;
}
export function isCut(id: number) {
return (
explorerStore.cutCopyState.active &&
explorerStore.cutCopyState.actionType === 'Cut' &&
explorerStore.cutCopyState.sourcePathId === id
);
}

View file

@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { FilePathSearchOrdering } from '@sd/client';
import { ExplorerItem, FilePathSearchOrdering, getExplorerItemData } from '@sd/client';
import { ExplorerParamsSchema } from '~/app/route-schemas';
import { useExplorerStore, useZodSearchParams } from '~/hooks';
import { useZodSearchParams } from '~/hooks';
import { flattenThumbnailKey, useExplorerStore } from './store';
export function useExplorerOrder(): FilePathSearchOrdering | undefined {
const explorerStore = useExplorerStore();
@ -27,3 +28,22 @@ export function useExplorerOrder(): FilePathSearchOrdering | undefined {
export function useExplorerSearchParams() {
return useZodSearchParams(ExplorerParamsSchema);
}
export function useExplorerItemData(explorerItem: ExplorerItem) {
const explorerStore = useExplorerStore();
const newThumbnail = !!(
explorerItem.thumbnail_key &&
explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
);
return useMemo(() => {
const itemData = getExplorerItemData(explorerItem);
if (!itemData.hasLocalThumbnail) {
itemData.hasLocalThumbnail = newThumbnail;
}
return itemData;
}, [explorerItem, newThumbnail]);
}

View file

@ -11,7 +11,7 @@ import {
import { LibraryIdParamsSchema } from '~/app/route-schemas';
import { useOperatingSystem, useZodRouteParams } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { QuickPreview } from '../Explorer/QuickPreview';
import { QuickPreviewContextProvider } from '../Explorer/QuickPreview/Context';
import Sidebar from './Sidebar';
import Toasts from './Toasts';
@ -50,12 +50,13 @@ const Layout = () => {
<Sidebar />
<div className="relative flex w-full overflow-hidden bg-app">
{library ? (
<LibraryContextProvider library={library}>
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
<Outlet />
</Suspense>
<QuickPreview />
</LibraryContextProvider>
<QuickPreviewContextProvider>
<LibraryContextProvider library={library}>
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
<Outlet />
</Suspense>
</LibraryContextProvider>
</QuickPreviewContextProvider>
) : (
<h1 className="p-4 text-white">
Please select or create a library in the sidebar.

View file

@ -1,5 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import {
useLibraryContext,
useLibraryQuery,
@ -8,48 +8,45 @@ import {
} from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Folder } from '~/components';
import {
getExplorerStore,
useExplorerStore,
useExplorerTopBarOptions,
useZodRouteParams,
useZodSearchParams
} from '~/hooks';
import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
import { useExplorerOrder, useExplorerSearchParams } from '../Explorer/util';
import { TopBarPortal } from '../TopBar/Portal';
import TopBarOptions from '../TopBar/TopBarOptions';
import LocationOptions from './LocationOptions';
export const Component = () => {
const [{ path }] = useExplorerSearchParams();
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
useExplorerTopBarOptions();
const { data: location } = useLibraryQuery(['locations.get', locationId]);
const location = useLibraryQuery(['locations.get', locationId]);
useLibrarySubscription(
[
'locations.quickRescan',
{
sub_path: location?.path ?? '',
sub_path: location.data?.path ?? '',
location_id: locationId
}
],
{ onData() {} }
);
const explorerStore = getExplorerStore();
useEffect(() => {
explorerStore.locationId = locationId;
}, [explorerStore, locationId]);
const { items, loadMore } = useItems({ locationId });
return (
<>
<ExplorerContext.Provider
value={{
parent: location.data
? {
type: 'Location',
location: location.data
}
: undefined
}}
>
<TopBarPortal
left={
<div className="group flex flex-row items-center space-x-2">
@ -58,21 +55,19 @@ export const Component = () => {
<span className="max-w-[100px] truncate text-sm font-medium">
{path && path?.length > 1
? getLastSectionOfPath(path)
: location?.name}
: location.data?.name}
</span>
</span>
{location && <LocationOptions location={location} path={path || ''} />}
{location.data && (
<LocationOptions location={location.data} path={path || ''} />
)}
</div>
}
right={
<TopBarOptions
options={[explorerViewOptions, explorerToolOptions, explorerControlOptions]}
/>
}
right={<DefaultTopBarOptions />}
/>
<Explorer items={items} onLoadMore={loadMore} />
</>
</ExplorerContext.Provider>
);
};

View file

@ -19,11 +19,8 @@ const OptionButton = tw(TopBarButton)`w-full gap-1 !px-1.5 !py-1`;
export default function LocationOptions({ location, path }: { location: Location; path: string }) {
const navigate = useNavigate();
const _scanLocation = useLibraryMutation('locations.fullRescan');
const scanLocation = () => _scanLocation.mutate(location.id);
const _regenThumbs = useLibraryMutation('jobs.generateThumbsForLocation');
const regenThumbs = () => _regenThumbs.mutate({ id: location.id, path });
const scanLocation = useLibraryMutation('locations.fullRescan');
const regenThumbs = useLibraryMutation('jobs.generateThumbsForLocation');
const archiveLocation = () => alert('Not implemented');
@ -76,11 +73,13 @@ export default function LocationOptions({ location, path }: { location: Location
</PopoverSection>
<PopoverDivider />
<PopoverSection>
<OptionButton onClick={scanLocation}>
<OptionButton onClick={() => scanLocation.mutate(location.id)}>
<FolderDotted />
Re-index
</OptionButton>
<OptionButton onClick={regenThumbs}>
<OptionButton
onClick={() => regenThumbs.mutate({ id: location.id, path })}
>
<Image />
Regenerate Thumbs
</OptionButton>

View file

@ -1,23 +1,30 @@
import { Laptop, Node } from '@sd/assets/icons';
import { Laptop } from '@sd/assets/icons';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import Explorer from '~/app/$libraryId/Explorer';
import { TopBarPortal } from '~/app/$libraryId/TopBar/Portal';
import TopBarOptions from '~/app/$libraryId/TopBar/TopBarOptions';
import { NodeIdParamsSchema } from '~/app/route-schemas';
import { useExplorerTopBarOptions, useZodRouteParams } from '~/hooks';
import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => {
const { id: nodeId } = useZodRouteParams(NodeIdParamsSchema);
const locations = useLibraryQuery(['nodes.listLocations', nodeId]);
const query = useLibraryQuery(['nodes.listLocations', nodeId]);
const nodeState = useBridgeQuery(['nodeState']);
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
useExplorerTopBarOptions();
return (
<>
<ExplorerContext.Provider
value={{
parent: nodeState.data
? {
type: 'Node',
node: nodeState.data
}
: undefined
}}
>
<TopBarPortal
left={
<div className="group flex flex-row items-center space-x-2">
@ -32,14 +39,10 @@ export const Component = () => {
</span>
</div>
}
right={
<TopBarOptions
options={[explorerViewOptions, explorerToolOptions, explorerControlOptions]}
/>
}
right={<DefaultTopBarOptions />}
/>
{locations.data && <Explorer items={locations.data} />}
</>
<Explorer items={query.data || []} />
</ExplorerContext.Provider>
);
};

View file

@ -2,7 +2,7 @@ import { iconNames } from '@sd/assets/util';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Category, useLibraryContext, useRspcLibraryContext } from '@sd/client';
import { getExplorerStore, useExplorerStore } from '~/hooks';
import { getExplorerStore, useExplorerStore } from '../Explorer/store';
export const IconForCategory: Partial<Record<Category, string>> = {
Recents: iconNames.Collection,

View file

@ -1,13 +1,14 @@
import { useMemo, useState } from 'react';
import 'react-loading-skeleton/dist/skeleton.css';
import { Category } from '@sd/client';
import { useExplorerStore, useExplorerTopBarOptions } from '~/hooks';
import ContextMenu from '../Explorer/File/ContextMenu';
import { ExplorerContext } from '../Explorer/Context';
// import ContextMenu from '../Explorer/FilePath/ContextMenu';
import { Inspector } from '../Explorer/Inspector';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import View from '../Explorer/View';
import { useExplorerStore } from '../Explorer/store';
import { usePageLayout } from '../PageLayout';
import { TopBarPortal } from '../TopBar/Portal';
import TopBarOptions from '../TopBar/TopBarOptions';
import Statistics from '../overview/Statistics';
import { Categories } from './Categories';
import { useItems } from './data';
@ -17,9 +18,6 @@ export const Component = () => {
const page = usePageLayout();
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
useExplorerTopBarOptions();
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
const { items, query, loadMore } = useItems(selectedCategory);
@ -32,14 +30,8 @@ export const Component = () => {
);
return (
<>
<TopBarPortal
right={
<TopBarOptions
options={[explorerViewOptions, explorerToolOptions, explorerControlOptions]}
/>
}
/>
<ExplorerContext.Provider value={{}}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<div>
<Statistics />
@ -58,7 +50,7 @@ export const Component = () => {
onSelectedChange={setSelectedItemId}
top={68}
className={explorerStore.layoutMode === 'rows' ? 'min-w-0' : undefined}
contextMenu={<ContextMenu data={selectedItem} />}
// contextMenu={<ContextMenu data={selectedItem as any} />}
emptyNotice={null}
/>
@ -71,6 +63,6 @@ export const Component = () => {
)}
</div>
</div>
</>
</ExplorerContext.Provider>
);
};

View file

@ -1,21 +1,16 @@
import { MagnifyingGlass } from 'phosphor-react';
import { Suspense, memo, useDeferredValue, useEffect, useMemo } from 'react';
import { Suspense, memo, useDeferredValue, useMemo } from 'react';
import { getExplorerItemData, useLibraryQuery } from '@sd/client';
import { SearchParams, SearchParamsSchema } from '~/app/route-schemas';
import {
getExplorerStore,
useExplorerStore,
useExplorerTopBarOptions,
useZodSearchParams
} from '~/hooks';
import { useZodSearchParams } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContext } from './Explorer/Context';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { getExplorerStore, useExplorerStore } from './Explorer/store';
import { TopBarPortal } from './TopBar/Portal';
import TopBarOptions from './TopBar/TopBarOptions';
const SearchExplorer = memo((props: { args: SearchParams }) => {
const explorerStore = useExplorerStore();
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
useExplorerTopBarOptions();
const { search, ...args } = props.args;
@ -36,27 +31,13 @@ const SearchExplorer = memo((props: { args: SearchParams }) => {
});
}, [query.data, explorerStore.layoutMode]);
useEffect(() => {
getExplorerStore().selectedRowIndex = null;
}, [search]);
return (
<>
{items && items.length > 0 ? (
<>
<TopBarPortal
right={
<TopBarOptions
options={[
explorerViewOptions,
explorerToolOptions,
explorerControlOptions
]}
/>
}
/>
<ExplorerContext.Provider value={{}}>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer items={items} />
</>
</ExplorerContext.Provider>
) : (
<div className="flex flex-1 flex-col items-center justify-center">
{!search && (

View file

@ -1,39 +1,38 @@
import { Tag } from 'phosphor-react';
import { useLoaderData } from 'react-router';
import { useLibraryQuery } from '@sd/client';
import Explorer from '~/app/$libraryId/Explorer';
import { TopBarPortal } from '~/app/$libraryId/TopBar/Portal';
import TopBarOptions from '~/app/$libraryId/TopBar/TopBarOptions';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { useExplorerTopBarOptions, useZodRouteParams } from '~/hooks';
import { useZodRouteParams } from '~/hooks';
import Explorer from '../Explorer';
import { ExplorerContext } from '../Explorer/Context';
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
import { TopBarPortal } from '../TopBar/Portal';
export const Component = () => {
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
const topBarOptions = useExplorerTopBarOptions();
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
const explorerData = useLibraryQuery([
'search.objects',
{
filter: {
tags: [locationId]
tags: [tagId]
}
}
]);
const tag = useLibraryQuery(['tags.get', tagId], { suspense: true });
return (
<>
<TopBarPortal
right={
<TopBarOptions
options={[
topBarOptions.explorerViewOptions,
topBarOptions.explorerToolOptions,
topBarOptions.explorerControlOptions
]}
/>
}
/>
<ExplorerContext.Provider
value={{
parent: tag.data
? {
type: 'Tag',
tag: tag.data
}
: undefined
}}
>
<TopBarPortal right={<DefaultTopBarOptions />} />
<Explorer
items={explorerData.data?.items || null}
emptyNotice={{
@ -41,6 +40,6 @@ export const Component = () => {
message: 'No items assigned to this tag'
}}
/>
</>
</ExplorerContext.Provider>
);
};

View file

@ -5,10 +5,6 @@ export * from './useCounter';
export * from './useDebouncedForm';
export * from './useDismissibleNoticeStore';
export * from './useDragSelect';
export * from './useExplorerConfigStore';
export * from './useExplorerItemData';
export * from './useExplorerStore';
export * from './useExplorerTopBarOptions';
export * from './useFocusState';
export * from './useInputState';
export * from './useIsDark';

View file

@ -1,22 +0,0 @@
import { useMemo } from 'react';
import { ExplorerItem, getExplorerItemData } from '@sd/client';
import { flattenThumbnailKey, useExplorerStore } from './useExplorerStore';
export function useExplorerItemData(explorerItem: ExplorerItem) {
const explorerStore = useExplorerStore();
const newThumbnail = !!(
explorerItem.thumbnail_key &&
explorerStore.newThumbnails.has(flattenThumbnailKey(explorerItem.thumbnail_key))
);
return useMemo(() => {
const itemData = getExplorerItemData(explorerItem);
if (!itemData.hasLocalThumbnail) {
itemData.hasLocalThumbnail = newThumbnail;
}
return itemData;
}, [explorerItem, newThumbnail]);
}

View file

@ -1,88 +0,0 @@
import { proxy, useSnapshot } from 'valtio';
import { proxySet } from 'valtio/utils';
import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering, resetStore } from '@sd/client';
import { SortOrder } from '~/app/route-schemas';
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${'' extends P ? '' : '.'}${P}`
: never
: never;
type Leaves<T> = T extends object ? { [K in keyof T]-?: Join<K, Leaves<T[K]>> }[keyof T] : '';
type UnionKeys<T> = T extends any ? Leaves<T> : never;
export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media';
export enum ExplorerKind {
Location,
Tag,
Space
}
export type CutCopyType = 'Cut' | 'Copy';
export type FilePathSearchOrderingKeys = UnionKeys<FilePathSearchOrdering> | 'none';
export type ObjectSearchOrderingKeys = UnionKeys<ObjectSearchOrdering> | 'none';
const state = {
locationId: null as number | null,
layoutMode: 'grid' as ExplorerLayoutMode,
gridItemSize: 110,
listItemSize: 40,
selectedRowIndex: 1 as number | null,
showBytesInGridView: true,
tagAssignMode: false,
showInspector: false,
mediaPlayerVolume: 0.7,
multiSelectIndexes: [] as number[],
newThumbnails: proxySet() as Set<string>,
cutCopyState: {
sourceParentPath: '', // this is used solely for preventing copy/cutting to the same path (as that will truncate the file)
sourceLocationId: 0,
sourcePathId: 0,
actionType: 'Cut',
active: false
},
quickViewObject: null as ExplorerItem | null,
mediaColumns: 8,
mediaAspectSquare: false,
orderBy: 'dateCreated' as FilePathSearchOrderingKeys,
orderByDirection: 'Desc' as SortOrder,
groupBy: 'none'
};
export function flattenThumbnailKey(thumbKey: string[]) {
return thumbKey.join('/');
}
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
const explorerStore = proxy({
...state,
reset: () => resetStore(explorerStore, state),
addNewThumbnail: (thumbKey: string[]) => {
explorerStore.newThumbnails.add(flattenThumbnailKey(thumbKey));
},
// this should be done when the explorer query is refreshed
// prevents memory leak
resetNewThumbnails: () => {
explorerStore.newThumbnails.clear();
}
});
export function useExplorerStore() {
return useSnapshot(explorerStore);
}
export function getExplorerStore() {
return explorerStore;
}
export function isCut(id: number) {
return (
explorerStore.cutCopyState.active &&
explorerStore.cutCopyState.actionType === 'Cut' &&
explorerStore.cutCopyState.sourcePathId === id
);
}

View file

@ -1,7 +1,7 @@
import { useKey } from 'rooks';
import { ExplorerItem } from '@sd/client';
import { dialogManager } from '@sd/ui';
import DeleteDialog from '~/app/$libraryId/Explorer/File/DeleteDialog';
import DeleteDialog from '~/app/$libraryId/Explorer/FilePath/DeleteDialog';
export const useKeyDeleteFile = (selectedItem: ExplorerItem | null, location_id: number | null) => {
return useKey('Delete', (e) => {

View file

@ -0,0 +1,4 @@
import { keybindForOs } from '~/util/keybinds';
import { useOperatingSystem } from './useOperatingSystem';
export const useKeybindFactory = () => keybindForOs(useOperatingSystem());

View file

@ -1,5 +1,4 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import { useTheme } from '../hooks';
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
@ -24,7 +23,11 @@ export type Platform = {
openPath?(path: string): void;
openLogsDir?(): void;
// Opens a file path with a given ID
openFilePath?(library: string, ids: number[]): any;
openFilePaths?(library: string, ids: number[]): any;
revealItems?(
library: string,
items: ({ Location: { id: number } } | { FilePath: { id: number } })[]
): Promise<unknown>;
getFilePathOpenWithApps?(library: string, ids: number[]): Promise<unknown>;
openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise<unknown>;
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any;