From 3304e8f6ce4add9828783c994ee07cfbb4fb3ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Vasconcellos?= Date: Thu, 6 Apr 2023 01:15:13 -0300 Subject: [PATCH] QuickPreview Component (Needs test on MacOS) (#665) * Add QuickPreview Component - Improve the handling of Range requests - Implement logic to answer HEAD and OPTIONS methods - Handle CORS pre-flight requests - Expand accepted file types - Improve error handling of invalid Range requests * Fix linter errors - Add `use std::cmp::min` to custom_uri (Required on MacOS & Windows) - Improve logic for retrieving file information in QuickPreview.tsx * More linter errors * Simplify `QuickPreview` by extracting the logic for choosing the file preview tag to a `FilePreview` component - Fix the typo in `QuickPreview` props name - Remove the unused `handleMedia` ref - Move the remaining `QuickPreview` logic to the `transitions` callback - Simplify the `cors` return type in `custom_uri.rs` * Refactor range handling in `handle_file` function - Move range handling logic to the initialization of the `range` variable - Replace `if let` with `match` to reduce code duplication - Don't export FilePreview - Export QuickPreviewProps * Fix typo in `RangeNotSatisfiable` error message - Remove redundant variables * Fixing cas_id generation on watcher Some improvements on watcher file creation * Rust fmt --------- Co-authored-by: Ericson Soares Co-authored-by: Jamie Pine --- Cargo.lock | Bin 229412 -> 229401 bytes apps/desktop/src-tauri/Cargo.toml | 1 - core/src/custom_uri.rs | 298 ++++++++++++------ core/src/location/file_path_helper.rs | 5 +- core/src/location/manager/watcher/utils.rs | 47 ++- .../$libraryId/Explorer/File/ContextMenu.tsx | 20 +- .../app/$libraryId/Explorer/QuickPreview.tsx | 197 ++++++++++++ interface/app/$libraryId/Explorer/index.tsx | 2 + interface/hooks/useExplorerStore.tsx | 4 +- 9 files changed, 447 insertions(+), 127 deletions(-) create mode 100644 interface/app/$libraryId/Explorer/QuickPreview.tsx diff --git a/Cargo.lock b/Cargo.lock index 90a89b38a2d41009aa269dea1a738b475a0770e4..4bc7e8d98bb5937d2acadbcd1c019152788ef025 100644 GIT binary patch delta 19 bcmZ3|z&Epjuc3u;3scFA?e#O5mOcjnPWlLa delta 26 icmbQ)z_+A, req: Request) -> Result>, Han .collect::>(); match path.first() { - Some(&"thumbnail") => handle_thumbnail(&node, &path).await, + Some(&"thumbnail") => handle_thumbnail(&node, &path, &req).await, Some(&"file") => handle_file(&node, &path, &req).await, _ => Err(HandleCustomUriError::BadRequest("Invalid operation!")), } } +async fn read_file(mut file: File, length: u64, start: Option) -> io::Result> { + let mut buf = Vec::with_capacity(length as usize); + if let Some(start) = start { + file.seek(SeekFrom::Start(start)).await?; + file.take(length).read_to_end(&mut buf).await?; + } else { + file.read_to_end(&mut buf).await?; + } + + Ok(buf) +} + +fn cors( + method: &Method, + builder: &mut Builder, +) -> Option>, httpz::http::Error>> { + *builder = take(builder).header("Access-Control-Allow-Origin", "*"); + if method == Method::OPTIONS { + Some( + take(builder) + .header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS") + .header("Access-Control-Allow-Headers", "*") + .header("Access-Control-Max-Age", "86400") + .status(StatusCode::OK) + .body(vec![]), + ) + } else { + None + } +} + async fn handle_thumbnail( node: &Node, path: &[&str], + req: &Request, ) -> Result>, HandleCustomUriError> { + let method = req.method(); + let mut builder = Response::builder(); + if let Some(response) = cors(method, &mut builder) { + return Ok(response?); + } + let file_cas_id = path .get(1) .ok_or_else(|| HandleCustomUriError::BadRequest("Invalid number of parameters!"))?; + let filename = node .config .data_directory() @@ -64,7 +106,7 @@ async fn handle_thumbnail( .join(file_cas_id) .with_extension("webp"); - let buf = fs::read(&filename).await.map_err(|err| { + let file = File::open(filename).await.map_err(|err| { if err.kind() == io::ErrorKind::NotFound { HandleCustomUriError::NotFound("file") } else { @@ -72,10 +114,17 @@ async fn handle_thumbnail( } })?; - Ok(Response::builder() + let content_lenght = file.metadata().await?.len(); + + Ok(builder .header("Content-Type", "image/webp") + .header("Content-Length", content_lenght) .status(StatusCode::OK) - .body(buf)?) + .body(if method == Method::HEAD { + vec![] + } else { + read_file(file, content_lenght, None).await? + })?) } async fn handle_file( @@ -83,6 +132,12 @@ async fn handle_file( path: &[&str], req: &Request, ) -> Result>, HandleCustomUriError> { + let method = req.method(); + let mut builder = Response::builder(); + if let Some(response) = cors(method, &mut builder) { + return Ok(response?); + } + let library_id = path .get(1) .and_then(|id| Uuid::from_str(id).ok()) @@ -136,7 +191,7 @@ async fn handle_file( lru_entry }; - let mut file = File::open(file_path_materialized_path) + let file = File::open(file_path_materialized_path) .await .map_err(|err| { if err.kind() == io::ErrorKind::NotFound { @@ -146,21 +201,63 @@ async fn handle_file( } })?; - let metadata = file.metadata().await?; - // TODO: This should be determined from magic bytes when the file is indexed and stored it in the DB on the file path - let (mime_type, is_video) = match extension.as_str() { - "mp4" => ("video/mp4", true), - "webm" => ("video/webm", true), - "mkv" => ("video/x-matroska", true), - "avi" => ("video/x-msvideo", true), - "mov" => ("video/quicktime", true), - "png" => ("image/png", false), - "jpg" => ("image/jpeg", false), - "jpeg" => ("image/jpeg", false), - "gif" => ("image/gif", false), - "webp" => ("image/webp", false), - "svg" => ("image/svg+xml", false), + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + let mime_type = match extension.as_str() { + // AAC audio + "aac" => "audio/aac", + // Musical Instrument Digital Interface (MIDI) + "mid" | "midi" => "audio/midi, audio/x-midi", + // MP3 audio + "mp3" => "audio/mpeg", + // MP4 audio + "m4a" => "audio/mp4", + // OGG audio + "oga" => "audio/ogg", + // Opus audio + "opus" => "audio/opus", + // Waveform Audio Format + "wav" => "audio/wav", + // WEBM audio + "weba" => "audio/webm", + // AVI: Audio Video Interleave + "avi" => "video/x-msvideo", + // MP4 video + "mp4" | "m4v" => "video/mp4", + // MPEG Video + "mpeg" => "video/mpeg", + // OGG video + "ogv" => "video/ogg", + // MPEG transport stream + "ts" => "video/mp2t", + // WEBM video + "webm" => "video/webm", + // 3GPP audio/video container (TODO: audio/3gpp if it doesn't contain video) + "3gp" => "video/3gpp", + // 3GPP2 audio/video container (TODO: audio/3gpp2 if it doesn't contain video) + "3g2" => "video/3gpp2", + // Quicktime movies + "mov" => "video/quicktime", + // AVIF image + "avif" => "image/avif", + // Windows OS/2 Bitmap Graphics + "bmp" => "image/bmp", + // Graphics Interchange Format (GIF) + "gif" => "image/gif", + // Icon format + "ico" => "image/vnd.microsoft.icon", + // JPEG images + "jpeg" | "jpg" => "image/jpeg", + // Portable Network Graphics + "png" => "image/png", + // Scalable Vector Graphics (SVG) + "svg" => "image/svg+xml", + // Tagged Image File Format (TIFF) + "tif" | "tiff" => "image/tiff", + // WEBP image + "webp" => "image/webp", + // PDF document + "pdf" => "application/pdf", _ => { return Err(HandleCustomUriError::BadRequest( "TODO: This filetype is not supported because of the missing mime type!", @@ -168,84 +265,93 @@ async fn handle_file( } }; - if is_video { - let mut response = Response::builder(); - let mut status_code = 200; + let mut content_lenght = file.metadata().await?.len(); + // GET is the only method for which range handling is defined, according to the spec + // https://httpwg.org/specs/rfc9110.html#field.range + let range = if method == Method::GET { + if let Some(range) = req.headers().get("range") { + range + .to_str() + .ok() + .and_then(|range| HttpRange::parse(range, content_lenght).ok()) + .ok_or_else(|| { + HandleCustomUriError::RangeNotSatisfiable("Error decoding range header!") + }) + .and_then(|range| { + // Let's support only 1 range for now + if range.len() > 1 { + Err(HandleCustomUriError::RangeNotSatisfiable( + "Multiple ranges are not supported!", + )) + } else { + Ok(range.first().cloned()) + } + })? + } else { + None + } + } else { + None + }; - // if the webview sent a range header, we need to send a 206 in return - let buf = if let Some(range) = req.headers().get("range") { - let mut buf = Vec::new(); - let file_size = metadata.len(); - let range = HttpRange::parse( - range - .to_str() - .map_err(|_| HandleCustomUriError::BadRequest("Error passing range header!"))?, - file_size, - ) - .map_err(|_| HandleCustomUriError::BadRequest("Error passing range!"))?; - // let support only 1 range for now - let first_range = range.first(); - if let Some(range) = first_range { - let mut real_length = range.length; + let mut status_code = 200; + let buf = match range { + Some(range) => { + let file_size = content_lenght; + content_lenght = range.length; - // prevent max_length; - // specially on webview2 - if range.length > file_size / 3 { - // max size sent (400kb / request) - // as it's local file system we can afford to read more often - real_length = min(file_size - range.start, 1024 * 400); - } - - // last byte we are reading, the length of the range include the last byte - // who should be skipped on the header - let last_byte = range.start + real_length - 1; - status_code = 206; - - // Only macOS and Windows are supported, if you set headers in linux they are ignored - response = response - .header("Connection", "Keep-Alive") - .header("Accept-Ranges", "bytes") - .header("Content-Length", real_length) - .header( - "Content-Range", - format!("bytes {}-{}/{}", range.start, last_byte, file_size), - ); - - // FIXME: Add ETag support (caching on the webview) - - file.seek(SeekFrom::Start(range.start)).await?; - file.take(real_length).read_to_end(&mut buf).await?; - } else { - file.read_to_end(&mut buf).await?; + // TODO: For some reason webkit2gtk doesn't like this at all. + // It causes it to only stream random pieces of any given audio file. + #[cfg(not(target_os = "linux"))] + // prevent max_length; + // specially on webview2 + if range.length > file_size / 3 { + // max size sent (400kb / request) + // as it's local file system we can afford to read more often + content_lenght = min(file_size - range.start, 1024 * 400); } - buf - } else { - // Linux is mega cringe and doesn't support streaming so we just load the whole file into memory and return it - let mut buf = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut buf).await?; - buf - }; + // last byte we are reading, the length of the range include the last byte + // who should be skipped on the header + let last_byte = range.start + content_lenght - 1; - Ok(response - .header("Content-type", mime_type) - .status(status_code) - .body(buf)?) - } else { - let mut buf = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut buf).await?; - Ok(Response::builder() - .header("Content-Type", mime_type) - .status(StatusCode::OK) - .body(buf)?) - } + // if the webview sent a range header, we need to send a 206 in return + status_code = 206; + + // macOS and Windows supports audio and video, linux only supports audio + builder = builder + .header("Connection", "Keep-Alive") + .header("Accept-Ranges", "bytes") + .header( + "Content-Range", + format!("bytes {}-{}/{}", range.start, last_byte, file_size), + ); + + // FIXME: Add ETag support (caching on the webview) + + read_file(file, content_lenght, Some(range.start)).await? + } + _ if method == Method::HEAD => vec![], + _ => read_file(file, content_lenght, None).await?, + }; + + Ok(builder + .header("Accept-Ranges", "bytes") + .header("Content-type", mime_type) + .header("Content-Length", content_lenght) + .status(status_code) + .body(buf)?) } pub fn create_custom_uri_endpoint(node: Arc) -> Endpoint { - GenericEndpoint::new("/*any", [Method::GET, Method::POST], move |req: Request| { - let node = node.clone(); - async move { handler(node, req).await.unwrap_or_else(Into::into) } - }) + GenericEndpoint::new( + "/*any", + [Method::HEAD, Method::OPTIONS, Method::GET, Method::POST], + move |req: Request| { + let node = node.clone(); + async move { handler(node, req).await.unwrap_or_else(Into::into) } + }, + ) } #[derive(Error, Debug)] @@ -258,6 +364,8 @@ pub enum HandleCustomUriError { QueryError(#[from] QueryError), #[error("{0}")] BadRequest(&'static str), + #[error("Range is not valid: {0}")] + RangeNotSatisfiable(&'static str), #[error("resource '{0}' not found")] NotFound(&'static str), } @@ -291,6 +399,12 @@ impl From for Response> { .status(StatusCode::BAD_REQUEST) .body(msg.as_bytes().to_vec()) } + HandleCustomUriError::RangeNotSatisfiable(msg) => { + error!("Invalid Range header in request: {}", msg); + builder + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .body(msg.as_bytes().to_vec()) + } HandleCustomUriError::NotFound(resource) => builder.status(StatusCode::NOT_FOUND).body( format!("Resource '{resource}' not found") .as_bytes() diff --git a/core/src/location/file_path_helper.rs b/core/src/location/file_path_helper.rs index ea0f3a4d3..1b826f9b0 100644 --- a/core/src/location/file_path_helper.rs +++ b/core/src/location/file_path_helper.rs @@ -317,6 +317,7 @@ impl LastFilePathIdManager { extension, }: MaterializedPath<'_>, parent_id: Option, + cas_id: Option, inode: u64, device: u64, ) -> Result { @@ -343,6 +344,7 @@ impl LastFilePathIdManager { let next_id = *last_id_ref + 1; let params = [ + ("cas_id", json!(cas_id)), ("materialized_path", json!(materialized_path)), ("name", json!(name)), ("extension", json!(extension)), @@ -368,7 +370,7 @@ impl LastFilePathIdManager { let created_path = sync .write_op( - &db, + db, sync.unique_shared_create( sync::file_path::SyncId { location: sync::location::SyncId { @@ -387,6 +389,7 @@ impl LastFilePathIdManager { inode.to_le_bytes().into(), device.to_le_bytes().into(), vec![ + file_path::cas_id::set(cas_id), file_path::parent_id::set(parent_id), file_path::is_dir::set(is_dir), ], diff --git a/core/src/location/manager/watcher/utils.rs b/core/src/location/manager/watcher/utils.rs index 43cc8a531..dab2cb877 100644 --- a/core/src/location/manager/watcher/utils.rs +++ b/core/src/location/manager/watcher/utils.rs @@ -45,7 +45,7 @@ use notify::{Event, EventKind}; use prisma_client_rust::{raw, PrismaValue}; use serde_json::json; use tokio::{fs, io::ErrorKind}; -use tracing::{error, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; use super::INodeAndDevice; @@ -109,9 +109,10 @@ pub(super) async fn create_dir( let created_path = library .last_file_path_id_manager .create_file_path( - &library, + library, materialized_path, Some(parent_directory.id), + None, inode, device, ) @@ -167,12 +168,20 @@ pub(super) async fn create_file( return Ok(()) }; + // generate provisional object + let FileMetadata { + cas_id, + kind, + fs_metadata, + } = FileMetadata::new(&location_path, &materialized_path).await?; + let created_file = library .last_file_path_id_manager .create_file_path( library, materialized_path, Some(parent_directory.id), + Some(cas_id.clone()), inode, device, ) @@ -180,21 +189,11 @@ pub(super) async fn create_file( info!("Created path: {}", created_file.materialized_path); - // generate provisional object - let FileMetadata { - cas_id, - kind, - fs_metadata, - } = FileMetadata::new( - &location_path, - &MaterializedPath::from((location_id, &created_file.materialized_path)), - ) - .await?; - let existing_object = db .object() .find_first(vec![object::file_paths::some(vec![ file_path::cas_id::equals(Some(cas_id.clone())), + file_path::id::not(created_file.id), ])]) .select(object_just_id_has_thumbnail::select()) .exec() @@ -227,7 +226,12 @@ pub(super) async fn create_file( .await?; if !object.has_thumbnail && !created_file.extension.is_empty() { - generate_thumbnail(&created_file.extension, &cas_id, path, library).await; + // Running in a detached task as thumbnail generation can take a while and we don't want to block the watcher + let path = path.to_path_buf(); + let library = library.clone(); + tokio::spawn(async move { + generate_thumbnail(&created_file.extension, &cas_id, path, &library).await; + }); } invalidate_query!(library, "locations.getExplorerData"); @@ -663,6 +667,21 @@ async fn generate_thumbnail( .join(cas_id) .with_extension("webp"); + if let Err(e) = fs::metadata(&output_path).await { + if e.kind() != ErrorKind::NotFound { + error!( + "Failed to check if thumbnail exists, but we will try to generate it anyway: {e}" + ); + } + // Otherwise we good, thumbnail doesn't exist so we can generate it + } else { + debug!( + "Skipping thumbnail generation for {} because it already exists", + path.display() + ); + return; + } + if let Ok(extension) = ImageExtension::from_str(extension) { if can_generate_thumbnail_for_image(&extension) { if let Err(e) = generate_image_thumbnail(path, &output_path).await { diff --git a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx index b3b4e7cb0..8401ac9b9 100644 --- a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx @@ -13,18 +13,11 @@ import { TrashSimple } from 'phosphor-react'; import { PropsWithChildren } from 'react'; -import { - ExplorerItem, - isObject, - useLibraryContext, - useLibraryMutation, - useLibraryQuery -} from '@sd/client'; +import { ExplorerItem, isObject, useLibraryMutation, useLibraryQuery } from '@sd/client'; import { ContextMenu, dialogManager } from '@sd/ui'; import { useExplorerParams } from '~/app/$libraryId/location/$id'; import { showAlertDialog } from '~/components/AlertDialog'; import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; -import { usePlatform } from '~/util/Platform'; import AssignTagMenuItems from '../AssignTagMenuItems'; import { OpenInNativeExplorer } from '../ContextMenu'; import DecryptDialog from './DecryptDialog'; @@ -37,10 +30,8 @@ interface Props extends PropsWithChildren { } export default ({ data, ...props }: Props) => { - const { library } = useLibraryContext(); const store = useExplorerStore(); const params = useExplorerParams(); - const platform = usePlatform(); const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false; @@ -55,14 +46,7 @@ export default ({ data, ...props }: Props) => { { - // TODO: Replace this with a proper UI - window.location.href = platform.getFileUrl( - library.uuid, - store.locationId!, - data.item.id - ); - }} + onClick={() => (getExplorerStore().quickViewObject = data)} icon={Copy} /> diff --git a/interface/app/$libraryId/Explorer/QuickPreview.tsx b/interface/app/$libraryId/Explorer/QuickPreview.tsx new file mode 100644 index 000000000..27775e74a --- /dev/null +++ b/interface/app/$libraryId/Explorer/QuickPreview.tsx @@ -0,0 +1,197 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import clsx from 'clsx'; +import { XCircle } from 'phosphor-react'; +import { useEffect, useRef, useState } from 'react'; +import { useTransition } from 'react-spring'; +import { animated } from 'react-spring'; +import { subscribeKey } from 'valtio/utils'; +import { ExplorerItem } from '~/../packages/client/src'; +import { showAlertDialog } from '~/components/AlertDialog'; +import { getExplorerStore } from '~/hooks/useExplorerStore'; +import { usePlatform } from '~/util/Platform'; +import FileThumb from './File/Thumb'; +import { getExplorerItemData } from './util'; + +const AnimatedDialogOverlay = animated(Dialog.Overlay); +const AnimatedDialogContent = animated(Dialog.Content); + +export interface QuickPreviewProps extends Dialog.DialogProps { + libraryUuid: string; + transformOrigin?: string; +} +interface FilePreviewProps { + src: string; + kind: null | string; + onError: () => void; + explorerItem: ExplorerItem; +} + +function FilePreview({ explorerItem, kind, src, onError }: FilePreviewProps) { + const className = clsx('relative inset-y-2/4 max-h-full max-w-full translate-y-[-50%]'); + const fileThumb = ; + switch (kind) { + case 'PDF': + return ; + case 'Image': + return ( + File preview + ); + case 'Audio': + return ( + <> + {fileThumb} + + + ); + case 'Video': + return ( + + ); + default: + return fileThumb; + } +} + +export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps) { + const platform = usePlatform(); + const explorerItem = useRef(null); + const explorerStore = getExplorerStore(); + const [isOpen, setIsOpen] = useState(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 assinging 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; + } + }), + [explorerStore] + ); + + const onPreviewError = () => { + setIsOpen(false); + explorerStore.quickViewObject = null; + showAlertDialog({ + title: 'Error', + value: 'Could not load file preview.' + }); + }; + + const transitions = useTransition(isOpen, { + from: { + opacity: 0, + transform: `translateY(20px)`, + transformOrigin: transformOrigin || 'bottom' + }, + enter: { opacity: 1, transform: `translateY(0px)` }, + leave: { opacity: 0, transform: `translateY(20px)` }, + config: { mass: 0.4, tension: 200, friction: 10, bounce: 0 } + }); + + return ( + <> + { + setIsOpen(open); + if (!open) explorerStore.quickViewObject = null; + }} + > + {transitions((styles, show) => { + if (!show || explorerItem.current == null) return null; + + const { item } = explorerItem.current; + const locationId = 'location_id' in item ? item.location_id : explorerStore.locationId; + if (locationId == null) { + onPreviewError(); + return null; + } + + const { kind, extension } = getExplorerItemData(explorerItem.current); + const preview = ( + + ); + + return ( + <> + + +
+ +
+ {preview} +
+
+
+ + ); + })} +
+ + ); +} diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index c8e603518..e2fa19317 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -3,6 +3,7 @@ import { ExplorerData, rspc, useLibraryContext } from '@sd/client'; import { useExplorerStore } from '~/hooks/useExplorerStore'; import { Inspector } from '../Explorer/Inspector'; import ExplorerContextMenu from './ContextMenu'; +import { QuickPreview } from './QuickPreview'; import TopBar from './TopBar'; import { VirtualizedList } from './VirtualizedList'; @@ -76,6 +77,7 @@ export default function Explorer(props: Props) { + ); } diff --git a/interface/hooks/useExplorerStore.tsx b/interface/hooks/useExplorerStore.tsx index fe6631808..dc368db4e 100644 --- a/interface/hooks/useExplorerStore.tsx +++ b/interface/hooks/useExplorerStore.tsx @@ -1,4 +1,5 @@ import { proxy, useSnapshot } from 'valtio'; +import { ExplorerItem } from '@sd/client'; import { resetStore } from '@sd/client/src/stores/util'; export type ExplorerLayoutMode = 'rows' | 'grid' | 'columns' | 'media'; @@ -29,7 +30,8 @@ const state = { sourcePathId: 0, actionType: 'Cut', active: false - } + }, + quickViewObject: null as ExplorerItem | null }; // Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.