Better Drag & Drop into OS

Now it doesn't freeze the entire app and UI, and the drag & drop into OS system is only called when the mouse leaves the app's boundaries!

However, the event/system doesn't cancel when you re-enter the application, and is something I'm still working on.
This commit is contained in:
Arnab Chakraborty
2024-12-30 21:47:28 +03:00
parent 09cd5a6183
commit 9de0c9423b
9 changed files with 461 additions and 50 deletions

66
.zed/settings.json Normal file
View File

@@ -0,0 +1,66 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"inlay_hints": {
"enabled": false
},
"languages": {
"Rust": {
"enable_language_server": true,
"formatter": "language_server",
"inlay_hints": {
"enabled": true,
"show_type_hints": true,
"show_parameter_hints": true,
"show_other_hints": true,
"show_background": false,
"edit_debounce_ms": 700,
"scroll_debounce_ms": 50
}
},
"TOML": {
"formatter": "language_server"
}
},
"lsp": {
"rust-analyzer": {
"initialization_options": {
"procMacro": {
"enable": true
},
"diagnostics": {
"experimental": {
"enable": false
}
},
"showUnlinkedFileNotification": false
}
}
},
"file_scan_exclusions": [
"node_modules",
"**/node_modules",
"**/bower_components",
"**/*.code-search",
"**/*.contentlayer",
"**/*.next",
"**/dist",
"apps/mobile/ios/Pods",
"apps/mobile/android",
"apps/mobile/ios",
"**/.git",
"**/.svn",
"**/.hg",
"**/CVS",
"**/.DS_Store"
],
"format_on_save": "on",
"ensure_final_newline_on_save": true,
"remove_trailing_whitespace_on_save": true,
"tab_size": 4,
"hard_tabs": false,
"show_whitespaces": "selection",
"show_completion_documentation": true
}

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -32,6 +32,7 @@ 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)
@@ -45,10 +46,11 @@ 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 }
tauri-plugin-drag = "2.0.0"
[dependencies.tauri]
features = ["linux-libxdo", "macos-private-api", "native-tls-vendored", "unstable"]

View File

@@ -0,0 +1,223 @@
// Import required dependencies for drag and drop operations, serialization, and async functionality
use drag::{DragItem, Image, Options};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{ipc::Channel, Manager, PhysicalPosition, State, WebviewWindow};
// DragState wraps a thread-safe boolean flag to track drag operation status
#[derive(Clone)]
pub struct DragState(pub Arc<Mutex<bool>>);
// Default implementation for DragState initializes with false
impl Default for DragState {
fn default() -> Self {
Self(Arc::new(Mutex::new(false)))
}
}
// Enum to represent the result of a drag operation (serializable for IPC)
#[derive(Serialize, Deserialize, Type, Clone)]
pub enum WrappedDragResult {
Dropped,
Cancel,
}
// Structure to hold cursor position coordinates (serializable for IPC)
#[derive(Serialize, Deserialize, Type, Clone)]
pub struct WrappedCursorPosition {
x: i32,
y: i32,
}
// Combined structure for drag operation results (serializable for IPC)
#[derive(Serialize, Deserialize, Type, Clone)]
pub struct CallbackResult {
result: WrappedDragResult,
#[serde(rename = "cursorPos")]
cursor_pos: WrappedCursorPosition,
}
// Conversion implementations for drag-rs types to our wrapped types
impl From<drag::DragResult> for WrappedDragResult {
fn from(result: drag::DragResult) -> Self {
match result {
drag::DragResult::Dropped => WrappedDragResult::Dropped,
drag::DragResult::Cancel => WrappedDragResult::Cancel,
}
}
}
impl From<drag::CursorPosition> for WrappedCursorPosition {
fn from(pos: drag::CursorPosition) -> Self {
WrappedCursorPosition { x: pos.x, y: pos.y }
}
}
// Global flag to track if position tracking is active
static TRACKING: AtomicBool = AtomicBool::new(false);
#[tauri::command(async)]
/// Initiates a drag and drop operation with cursor position tracking
///
/// # Arguments
/// * `window` - The Tauri window instance
/// * `_state` - Current drag state (unused)
/// * `files` - Vector of file paths to be dragged
/// * `icon_path` - Path to the preview icon for the drag operation
/// * `on_event` - Channel for communicating drag operation events back to the frontend
#[specta::specta]
pub async fn start_drag(
window: WebviewWindow,
_state: State<'_, DragState>,
files: Vec<String>,
icon_path: String,
on_event: Channel<CallbackResult>,
) -> Result<(), String> {
// Fast atomic swap for tracking state
match TRACKING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => {
println!("Starting position tracking");
}
Err(_) => {
// If already tracking, stop previous instance quickly
TRACKING.store(false, Ordering::SeqCst);
tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
TRACKING.store(true, Ordering::SeqCst);
println!("Restarting position tracking");
}
}
// Pre-allocate resources before spawning task
let window_handle = Arc::new(window);
let app_handle = window_handle.app_handle();
// Initialize control flags
let cancel_flag = Arc::new(AtomicBool::new(false));
let is_completed = Arc::new(AtomicBool::new(false));
// Prepare resources once with minimal cloning
let tracking_resources = Arc::new((files.clone(), icon_path.clone(), Arc::new(on_event)));
println!("Starting position tracking");
// Get handles for window and app management
let window_clone = window_handle.clone();
let app_handle_owned = app_handle.to_owned();
let window_owned = window_clone.to_owned();
// Control flags for operation state
let is_completed_clone = is_completed.clone();
// Spawn background task for cursor tracking
tokio::spawn(async move {
// Initialize tracking state
let mut last_position = (0.0, 0.0);
let mut last_message_time = Instant::now();
let threshold = 1.0; // Minimum movement threshold
let message_debounce = Duration::from_millis(32); // State update interval
let mut was_inside = false;
// Main tracking loop
while TRACKING.load(Ordering::SeqCst) && !is_completed.load(Ordering::SeqCst) {
let window_for_check = window_owned.clone();
// Skip if window is not focused
if !window_for_check.is_focused().unwrap_or(false) {
tokio::time::sleep(tokio::time::Duration::from_millis(8)).await;
continue;
}
// Get current cursor and window positions
if let (Ok(cursor_position), Ok(window_position), Ok(window_size)) = (
window_for_check.cursor_position(),
window_for_check.outer_position(),
window_for_check.inner_size(),
) {
// Calculate cursor position relative to window
let relative_position = PhysicalPosition::new(
cursor_position.x - window_position.x as f64,
cursor_position.y - window_position.y as f64,
);
// Check if cursor is inside window boundaries
let is_inside = relative_position.x >= 0.0
&& relative_position.y >= 0.0
&& relative_position.x <= window_size.width as f64
&& relative_position.y <= window_size.height as f64;
// Process state changes if cursor moved enough
if is_inside != was_inside
&& ((relative_position.x - last_position.0).abs() > threshold
|| (relative_position.y - last_position.1).abs() > threshold)
{
let now = Instant::now();
if now.duration_since(last_message_time) >= message_debounce {
// Prepare resources for drag operation
let files_for_drag = tracking_resources.0.clone();
let icon_path_for_drag = tracking_resources.1.clone();
let on_event_for_drag = tracking_resources.2.clone();
let is_completed = is_completed_clone.clone();
let cancel_flag_clone = cancel_flag.clone();
let window_for_drag = window_owned.clone();
// Execute drag operation on main thread
app_handle_owned
.run_on_main_thread(move || {
if !is_inside {
println!("Starting drag operation");
// Create drag items
let paths: Vec<PathBuf> =
files_for_drag.iter().map(PathBuf::from).collect();
let item = DragItem::Files(paths);
let preview_icon =
Image::File(PathBuf::from(&icon_path_for_drag));
// Start the drag operation
if let Ok(_) = drag::start_drag(
&window_for_drag,
item,
preview_icon,
move |result, cursor_pos| {
// Send result back to frontend
let _ = on_event_for_drag.send(CallbackResult {
result: result.into(),
cursor_pos: cursor_pos.into(),
});
// Mark operation as completed
is_completed.store(true, Ordering::SeqCst);
TRACKING.store(false, Ordering::SeqCst);
},
Options::default(),
) {
println!("Drag operation started");
}
} else {
println!("Cursor returned to window");
cancel_flag_clone.store(true, Ordering::SeqCst);
// We have this for now, but technically, it doesn't do anything.
// I'm still trying to figure out how to cancel mid-drag without the user having to cancel the dragging on the frontend too.
// - @Rocky43007
}
})
.unwrap_or_default();
// Update tracking state
last_message_time = now;
was_inside = is_inside;
last_position = (relative_position.x, relative_position.y);
}
}
}
// Prevent excessive CPU usage
tokio::time::sleep(tokio::time::Duration::from_millis(8)).await;
}
println!("Tracking instance stopped");
});
Ok(())
}

View File

@@ -22,6 +22,7 @@ use tokio::task::block_in_place;
use tokio::time::sleep;
use tracing::{debug, error};
mod drag;
mod file;
mod menu;
mod tauri_plugins;
@@ -200,6 +201,7 @@ async fn main() -> tauri::Result<()> {
set_menu_bar_item_state,
request_fda_macos,
open_trash_in_os_explorer,
drag::start_drag,
file::open_file_paths,
file::open_ephemeral_files,
file::get_file_path_open_with_apps,
@@ -358,11 +360,11 @@ async fn main() -> tauri::Result<()> {
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_drag::init())
// TODO: Bring back Tauri Plugin Window State - it was buggy so we removed it.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(updater::plugin())
.manage(updater::State::default())
.manage(drag::DragState::default())
.build(tauri::generate_context!())?
.run(|_, _| {});

View File

@@ -3,7 +3,13 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { PropsWithChildren, startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { RspcProvider, useBridgeMutation } from '@sd/client';
import {
getItemFilePath,
libraryClient,
RspcProvider,
useBridgeMutation,
useSelector
} from '@sd/client';
import {
createRoutes,
DeeplinkEvent,
@@ -18,12 +24,13 @@ import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle';
import '@sd/ui/style';
import { startDrag } from '@crabnebula/tauri-plugin-drag';
import { Channel, invoke } from '@tauri-apps/api/core';
import SuperTokens from 'supertokens-web-js';
import EmailPassword from 'supertokens-web-js/recipe/emailpassword';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
import Session from 'supertokens-web-js/recipe/session';
import ThirdParty from 'supertokens-web-js/recipe/thirdparty';
import { explorerStore } from '@sd/interface/app/$libraryId/Explorer/store';
// TODO: Bring this back once upstream is fixed up.
// const client = hooks.createClient({
// links: [
@@ -38,6 +45,7 @@ import getWindowHandler from '@sd/interface/app/$libraryId/settings/client/accou
import { useLocale } from '@sd/interface/hooks';
import { AUTH_SERVER_URL, getTokens } from '@sd/interface/util';
import { Transparent } from '../../../packages/assets/images';
import { commands } from './commands';
import { platform } from './platform';
import { queryClient } from './query';
@@ -47,7 +55,7 @@ import { createUpdater } from './updater';
declare global {
interface Window {
enableCORSFetch: (enable: boolean) => void;
startDrag: typeof startDrag;
useDragAndDrop: () => void;
}
}
@@ -69,12 +77,89 @@ SuperTokens.init({
const startupError = (window as any).__SD_ERROR__ as string | undefined;
function useDragAndDrop() {
const dragState = useSelector(explorerStore, (s) => s.drag);
useEffect(() => {
console.log('Drag effect triggered:', {
dragStateType: dragState?.type,
itemCount: dragState?.type === 'dragging' ? dragState?.items?.length : undefined
});
(async () => {
if (dragState?.type === 'dragging' && dragState.items.length > 0) {
console.log('Starting drag operation with items:', dragState.items);
const items = await Promise.all(
dragState.items.map(async (item) => {
const data = getItemFilePath(item);
if (!data) {
console.log('No file path data for item:', item);
return;
}
const file_path =
'path' in data ? data.path : await libraryClient.query(['files.getPath', data.id]);
console.log('Resolved file path:', file_path);
return {
type: 'explorer-item',
file_path: file_path
};
})
);
const image = Transparent.split('/@fs')[1]!;
console.log('Using preview image:', image);
const validFiles = items.filter(Boolean).map((item) => item?.file_path);
console.log('Invoking start_drag with files:', validFiles);
try {
const channel = new Channel<{
result: 'Dropped' | 'Cancelled';
cursorPos: { x: number; y: number };
}>();
channel.onmessage = (payload) => {
console.log('Drag completed:', {
result: payload.result,
position: payload.cursorPos,
timestamp: new Date().toISOString()
});
if (payload.result === 'Dropped') {
console.log('Drop location:', {
x: payload.cursorPos.x,
y: payload.cursorPos.y,
screen: window.screen
});
}
explorerStore.drag = null;
};
await invoke('start_drag', {
files: validFiles,
iconPath: image,
onEvent: channel
});
console.log('start_drag invoked successfully');
} catch (error) {
console.error('Failed to start drag:', error);
explorerStore.drag = null;
}
}
})();
}, [dragState]);
}
export default function App() {
useEffect(() => {
// This tells Tauri to show the current window because it's finished loading
commands.appReady();
window.enableCORSFetch(true);
window.startDrag = startDrag;
window.useDragAndDrop = useDragAndDrop;
// .then(() => {
// if (import.meta.env.PROD) window.fetch = fetch;
// });
@@ -209,6 +294,43 @@ function AppInner() {
};
}, [selectedTab.element]);
const SizeDisplay = () => {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div
style={{
position: 'fixed',
bottom: 10,
right: 10,
background: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '5px 10px',
borderRadius: '5px',
fontSize: '12px',
zIndex: 9999
}}
>
{size.width} x {size.height}
</div>
);
};
return (
<RouteTitleContext.Provider
value={useMemo(
@@ -257,8 +379,7 @@ 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];
@@ -308,6 +429,7 @@ function AppInner() {
tab.element
)
)}
<SizeDisplay />
<div ref={ref} />
</SpacedriveInterfaceRoot>
</PlatformUpdaterProvider>

View File

@@ -49,6 +49,31 @@ export const commands = {
else return { status: 'error', error: e as any };
}
},
/**
* Initiates a drag and drop operation with cursor position tracking
*
* # Arguments
* * `window` - The Tauri window instance
* * `_state` - Current drag state (unused)
* * `files` - Vector of file paths to be dragged
* * `icon_path` - Path to the preview icon for the drag operation
* * `on_event` - Channel for communicating drag operation events back to the frontend
*/
async startDrag(
files: string[],
iconPath: string,
onEvent: TAURI_CHANNEL<CallbackResult>
): Promise<Result<null, string>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('start_drag', { files, iconPath, onEvent })
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async openFilePaths(
library: string,
ids: number[]
@@ -162,6 +187,7 @@ export const events = __makeEvents__<{
/** user-defined types **/
export type AppThemeType = 'Auto' | 'Light' | 'Dark';
export type CallbackResult = { result: WrappedDragResult; cursorPos: WrappedCursorPosition };
export type DragAndDropEvent =
| { type: 'Hovered'; paths: string[]; x: number; y: number }
| { type: 'Dropped'; paths: string[]; x: number; y: number }
@@ -199,6 +225,8 @@ export type RevealItem =
| { FilePath: { id: number } }
| { Ephemeral: { path: string } };
export type Update = { version: string };
export type WrappedCursorPosition = { x: number; y: number };
export type WrappedDragResult = 'Dropped' | 'Cancel';
type __EventObj__<T> = {
listen: (cb: TAURI_API_EVENT.EventCallback<T>) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;

View File

@@ -111,38 +111,6 @@ const ItemMetadata = memo(() => {
const item = useGridViewItemContext();
const { isDroppable } = useExplorerDroppableContext();
const explorerLayout = useExplorerLayoutStore();
const dragState = useSelector(explorerStore, (s) => s.drag);
useEffect(() => {
(async () => {
if (dragState?.type === 'dragging' && dragState.items.length > 0) {
const items = await Promise.all(
dragState.items.map(async (item) => {
const data = getItemFilePath(item);
if (!data) return;
const file_path =
'path' in data
? data.path
: await libraryClient.query(['files.getPath', data.id]);
return {
type: 'explorer-item',
file_path: file_path
};
})
);
// get image src from Transparent
const image = Transparent.split('/@fs')[1];
(window as any).startDrag({
item: items.filter(Boolean).map((item) => item?.file_path),
icon: image
});
}
})();
}, [dragState]);
const isRenaming = useSelector(explorerStore, (s) => s.isRenaming && item.selected);

View File

@@ -34,6 +34,11 @@ interface Props {
contextMenu?: () => ReactNode;
}
declare global {
interface Window {
useDragAndDrop: () => void;
}
}
/**
* This component is used in a few routes and acts as the reference demonstration of how to combine
* all the elements of the explorer except for the context, which must be used in the parent component.
@@ -82,6 +87,8 @@ export default function Explorer(props: PropsWithChildren<Props>) {
explorer.settingsStore.showHiddenFiles = !explorer.settingsStore.showHiddenFiles;
});
window.useDragAndDrop();
useKeyRevealFinder();
useExplorerDnd();
@@ -111,18 +118,13 @@ 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>
@@ -142,9 +144,7 @@ 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
}}
/>
)}