diff --git a/Cargo.lock b/Cargo.lock index 74acb0b82..cd5c30365 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e65b09eab..2b728d412 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ sd-prisma = { path = "../../../crates/prisma" } # Workspace dependencies axum = { workspace = true, features = ["query"] } axum-extra = { workspace = true, features = ["typed-header"] } +base64 = { workspace = true } futures = { workspace = true } http = { workspace = true } hyper = { workspace = true } @@ -32,25 +33,24 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } uuid = { workspace = true, features = ["serde"] } -base64 = { workspace = true } # Specific Desktop dependencies # WARNING: Do NOT enable default features, as that vendors dbus (see below) +drag = { git = "https://github.com/spacedriveapp/drag-rs", rev = "157b2cd9" } opener = { version = "0.7.1", features = ["reveal"], default-features = false } specta-typescript = "=0.0.7" tauri-plugin-clipboard-manager = "=2.0.1" tauri-plugin-cors-fetch = { path = "../../../crates/tauri-plugin-cors-fetch" } tauri-plugin-deep-link = "=2.0.1" tauri-plugin-dialog = "=2.0.3" +tauri-plugin-drag = "2.0.0" tauri-plugin-http = "=2.0.3" tauri-plugin-os = "=2.0.1" tauri-plugin-shell = "=2.0.2" tauri-plugin-updater = "=2.0.2" -tauri-plugin-drag = "2.0.0" -drag = "2.0.0" # memory allocator -mimalloc = { workspace = true } +mimalloc = { workspace = true } [dependencies.tauri] features = ["linux-libxdo", "macos-private-api", "native-tls-vendored", "unstable"] diff --git a/apps/desktop/src-tauri/src/drag.rs b/apps/desktop/src-tauri/src/drag.rs index e4f09b849..67c819fcb 100644 --- a/apps/desktop/src-tauri/src/drag.rs +++ b/apps/desktop/src-tauri/src/drag.rs @@ -1,4 +1,3 @@ -// Import required dependencies for drag and drop operations, serialization, and async functionality use drag::{DragItem, Image, Options}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -162,6 +161,8 @@ pub async fn start_drag( let is_completed = is_completed_clone.clone(); let cancel_flag_clone = cancel_flag.clone(); let window_for_drag = window_owned.clone(); + let drag_session = Arc::new(Mutex::new(None)); + let drag_session_clone = drag_session.clone(); // Execute drag operation on main thread app_handle_owned @@ -176,7 +177,7 @@ pub async fn start_drag( Image::File(PathBuf::from(&icon_path_for_drag)); // Start the drag operation - if let Ok(_) = drag::start_drag( + if let Ok(session) = drag::start_drag( &window_for_drag, item, preview_icon, @@ -190,9 +191,14 @@ pub async fn start_drag( is_completed.store(true, Ordering::SeqCst); TRACKING.store(false, Ordering::SeqCst); }, - Options::default(), + Options { + skip_animatation_on_cancel_or_failure: false, + mode: drag::DragMode::Move, + }, ) { println!("Drag operation started"); + // Store drag session for cancellation + *drag_session_clone.lock().unwrap() = Some(session); } } else { println!("Cursor returned to window"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 05dbdc4ed..54322ad88 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -14,6 +14,7 @@ import { createRoutes, DeeplinkEvent, ErrorPage, + FileDropEvent, KeybindEvent, PlatformProvider, SpacedriveInterfaceRoot, @@ -99,7 +100,9 @@ function useDragAndDrop() { } const file_path = - 'path' in data ? data.path : await libraryClient.query(['files.getPath', data.id]); + 'path' in data + ? data.path + : await libraryClient.query(['files.getPath', data.id]); console.log('Resolved file path:', file_path); return { @@ -134,6 +137,8 @@ function useDragAndDrop() { y: payload.cursorPos.y, screen: window.screen }); + // Refetch explorer files after successful drop + queryClient.invalidateQueries({ queryKey: ['search.paths'] }); } explorerStore.drag = null; @@ -181,10 +186,14 @@ export default function App() { if (!url) return; document.dispatchEvent(new DeeplinkEvent(url)); }); + const fileDropListener = listen('tauri://drag-drop', async (data) => { + document.dispatchEvent(new FileDropEvent((data.payload as { paths: string[] }).paths)); + }); return () => { keybindListener.then((unlisten) => unlisten()); deeplinkListener.then((unlisten) => unlisten()); + fileDropListener.then((unlisten) => unlisten()); }; }, []); @@ -379,7 +388,8 @@ function AppInner() { new Promise((res) => { startTransition(() => { setTabs((tabs) => { - const { pathname, search } = selectedTab.router.state.location; + const { pathname, search } = + selectedTab.router.state.location; const newTab = createTab({ pathname, search }); const newTabs = [...tabs, newTab]; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 2bddff35d..1f74f0bd1 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -1,5 +1,5 @@ import { FolderNotchOpen } from '@phosphor-icons/react'; -import { CSSProperties, type PropsWithChildren, type ReactNode } from 'react'; +import { CSSProperties, useEffect, type PropsWithChildren, type ReactNode } from 'react'; import { explorerLayout, useExplorerLayoutStore, @@ -87,8 +87,6 @@ export default function Explorer(props: PropsWithChildren) { explorer.settingsStore.showHiddenFiles = !explorer.settingsStore.showHiddenFiles; }); - window.useDragAndDrop(); - useKeyRevealFinder(); useExplorerDnd(); @@ -118,13 +116,18 @@ export default function Explorer(props: PropsWithChildren) { contextMenu={props.contextMenu ? props.contextMenu() : } emptyNotice={ props.emptyNotice ?? ( - + ) } listViewOptions={{ hideHeaderBorder: true }} scrollPadding={{ top: topBar.topBarHeight, - bottom: showPathBar ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) : undefined + bottom: showPathBar + ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) + : undefined }} /> @@ -144,7 +147,9 @@ export default function Explorer(props: PropsWithChildren) { )} style={{ paddingTop: topBar.topBarHeight + 12, - bottom: showPathBar ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) : 0 + bottom: showPathBar + ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) + : 0 }} /> )} diff --git a/interface/app/$libraryId/Explorer/useExplorerDnd.tsx b/interface/app/$libraryId/Explorer/useExplorerDnd.tsx index 963d0bd0e..d563e6f2a 100644 --- a/interface/app/$libraryId/Explorer/useExplorerDnd.tsx +++ b/interface/app/$libraryId/Explorer/useExplorerDnd.tsx @@ -12,7 +12,7 @@ import { useAssignItemsToTag } from '../settings/library/tags/CreateDialog'; import { useExplorerContext } from './Context'; import { explorerStore } from './store'; import { explorerDroppableSchema } from './useExplorerDroppable'; -import { useExplorerSearchParams } from './util'; +import { getPathIdsPerLocation, useExplorerSearchParams } from './util'; export const getPaths = async (items: ExplorerItem[]) => { const paths = items.map(async (item) => { @@ -27,21 +27,6 @@ export const getPaths = async (items: ExplorerItem[]) => { return (await Promise.all(paths)).filter((path): path is string => Boolean(path)); }; -const getPathIdsPerLocation = (items: ExplorerItem[]) => { - return items.reduce( - (items, item) => { - const path = getIndexedItemFilePath(item); - if (!path || path.location_id === null) return items; - - return { - ...items, - [path.location_id]: [...(items[path.location_id] ?? []), path.id] - }; - }, - {} as Record - ); -}; - export const useExplorerDnd = () => { const explorer = useExplorerContext(); diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index 7bcd80aa6..3e23100b9 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { type ExplorerItem } from '@sd/client'; +import { getIndexedItemFilePath, type ExplorerItem } from '@sd/client'; import i18n from '~/app/I18n'; import { ExplorerParamsSchema } from '~/app/route-schemas'; import { useZodSearchParams } from '~/hooks'; @@ -200,3 +200,18 @@ export function fetchAccessToken(): string { .split(';')[0] || ''; return accessToken; } + +export const getPathIdsPerLocation = (items: ExplorerItem[]) => { + return items.reduce( + (items, item) => { + const path = getIndexedItemFilePath(item); + if (!path || path.location_id === null) return items; + + return { + ...items, + [path.location_id]: [...(items[path.location_id] ?? []), path.id] + }; + }, + {} as Record + ); +}; diff --git a/interface/app/$libraryId/Layout/index.tsx b/interface/app/$libraryId/Layout/index.tsx index 12044df66..a3f644792 100644 --- a/interface/app/$libraryId/Layout/index.tsx +++ b/interface/app/$libraryId/Layout/index.tsx @@ -16,6 +16,7 @@ import { LibraryIdParamsSchema } from '~/app/route-schemas'; import ErrorFallback, { BetterErrorBoundary } from '~/ErrorFallback'; import { useDeeplinkEventHandler, + useFileDropEventHandler, useKeybindEventHandler, useOperatingSystem, useRedirectToNewLocation, @@ -42,6 +43,9 @@ const Layout = () => { useKeybindEventHandler(library?.uuid); useDeeplinkEventHandler(); + useFileDropEventHandler(library?.uuid); + + window.useDragAndDrop(); const layoutRef = useRef(null); diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index fa9720835..600f39597 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -10,6 +10,7 @@ export * from './useIsDark'; export * from './useKeyDeleteFile'; export * from './useKeybind'; export * from './useKeybindEventHandler'; +export * from './useFileDropEventHandler'; export * from './useOperatingSystem'; export * from './useScrolled'; // export * from './useSearchStore'; diff --git a/interface/hooks/useFileDropEventHandler.ts b/interface/hooks/useFileDropEventHandler.ts new file mode 100644 index 000000000..f8a29e460 --- /dev/null +++ b/interface/hooks/useFileDropEventHandler.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router'; +import { libraryClient } from '@sd/client'; +import { getPathIdsPerLocation, useExplorerSearchParams } from '~/app/$libraryId/Explorer/util'; +import { isNonEmptyObject } from '~/util'; +import { FileDropEvent } from '~/util/events'; + +import { useQuickRescan } from './useQuickRescan'; + +export const useFileDropEventHandler = (libraryId?: string) => { + const navigate = useNavigate(); + const rescan = useQuickRescan(); + const regex = new RegExp( + '/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/location/' + ); + const id = parseInt(useLocation().pathname.replace(regex, '')); + const [{ path }] = useExplorerSearchParams(); + + useEffect(() => { + const handler = async (e: FileDropEvent) => { + e.preventDefault(); + const paths = e.detail.paths; + + if (libraryId && path) { + libraryClient.mutation([ + 'ephemeralFiles.cutFiles', + { sources: paths, target_dir: path! } + ]); + } else if (libraryId) { + // Get Materialized Path using the location id + const locationId = id; + const location = await libraryClient.query(['locations.get', locationId]); + const locationPath = location!.path; + libraryClient.mutation([ + 'ephemeralFiles.cutFiles', + { sources: paths, target_dir: locationPath! } + ]); + } + }; + + document.addEventListener('filedrop', handler); + return () => document.removeEventListener('filedrop', handler); + }, [navigate, libraryId, rescan, id, path]); +}; diff --git a/interface/package.json b/interface/package.json index 13749384a..b8011684d 100644 --- a/interface/package.json +++ b/interface/package.json @@ -13,7 +13,7 @@ "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^1.7.17", "@icons-pack/react-simple-icons": "^9.1.0", - "@phosphor-icons/react": "^2.0.13", + "@phosphor-icons/react": "^2.1.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-progress": "^1.0.1", @@ -87,4 +87,4 @@ "vite": "^5.4.9", "vite-plugin-svgr": "^3.3.0" } -} \ No newline at end of file +} diff --git a/interface/util/events.ts b/interface/util/events.ts index 4b1e1a77c..691ac9567 100644 --- a/interface/util/events.ts +++ b/interface/util/events.ts @@ -2,6 +2,7 @@ declare global { interface GlobalEventHandlersEventMap { keybindexec: KeybindEvent; deeplink: DeeplinkEvent; + filedrop: FileDropEvent; } } @@ -24,3 +25,13 @@ export class DeeplinkEvent extends CustomEvent<{ url: string }> { }); } } + +export class FileDropEvent extends CustomEvent<{ paths: string[] }> { + constructor(paths: string[]) { + super('filedrop', { + detail: { + paths + } + }); + } +} diff --git a/interface/util/index.tsx b/interface/util/index.tsx index cf1391d35..4bb12e4b9 100644 --- a/interface/util/index.tsx +++ b/interface/util/index.tsx @@ -1,5 +1,5 @@ import cryptoRandomString from 'crypto-random-string'; -import { nonLibraryClient } from '@sd/client'; +import { ExplorerItem, getIndexedItemFilePath, nonLibraryClient } from '@sd/client'; // NOTE: `crypto` module is not available in RN so this can't be in client export const generatePassword = (length: number) => diff --git a/packages/ui/package.json b/packages/ui/package.json index ec7609f55..d6098abd9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,7 +21,7 @@ "dependencies": { "@fontsource/ibm-plex-sans": "^5.1.0", "@headlessui/react": "^1.7.17", - "@phosphor-icons/react": "^2.0.13", + "@phosphor-icons/react": "^2.1.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5413162a3..8dad00fb6 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ