mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-19 22:19:49 -04:00
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:
committed by
GitHub
parent
f55d5bcfee
commit
3e50ebcc65
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -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"]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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[]>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
44
interface/hooks/useFileDropEventHandler.ts
Normal file
44
interface/hooks/useFileDropEventHandler.ts
Normal 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]);
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user