Drag & Drop into/out of Spacedrive (#2849)

* Better drag & drop into the os

* Update drag.rs

* Drag & Drop into Spacedrive from OS

* Re-enable Supertokes & change Drag-rs pointer

* Autoformat
This commit is contained in:
Arnab Chakraborty
2025-01-10 15:01:44 +03:00
committed by GitHub
parent f55d5bcfee
commit 3e50ebcc65
15 changed files with 117 additions and 36 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -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"]

View File

@@ -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");

View File

@@ -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];

View File

@@ -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<Props>) {
explorer.settingsStore.showHiddenFiles = !explorer.settingsStore.showHiddenFiles;
});
window.useDragAndDrop();
useKeyRevealFinder();
useExplorerDnd();
@@ -118,13 +116,18 @@ export default function Explorer(props: PropsWithChildren<Props>) {
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
emptyNotice={
props.emptyNotice ?? (
<EmptyNotice icon={FolderNotchOpen} message="This folder is empty" />
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
/>
)
}
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
}}
/>
</div>
@@ -144,7 +147,9 @@ export default function Explorer(props: PropsWithChildren<Props>) {
)}
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
}}
/>
)}

View File

@@ -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<number, number[]>
);
};
export const useExplorerDnd = () => {
const explorer = useExplorerContext();

View File

@@ -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<number, number[]>
);
};

View File

@@ -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<HTMLDivElement>(null);

View File

@@ -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';

View File

@@ -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]);
};

View File

@@ -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"
}
}
}

View File

@@ -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
}
});
}
}

View File

@@ -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) =>

View File

@@ -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",

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.