feat: enhance drag-and-drop functionality to support multiple file paths and URLs

This commit is contained in:
claudi 2026-03-04 13:43:21 +01:00
parent 1e848e84b2
commit c612072dc8
5 changed files with 384 additions and 480 deletions

View file

@ -2,7 +2,7 @@
import logging
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Union
from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
from PySide6.QtGui import QDrag
@ -21,14 +21,18 @@ class DragInterceptor(QWidget):
Intercepts drag events from web content, converts Azure Blob Storage URLs
to local paths, validates them, and initiates native Qt drag operations.
Supports both single and multiple file drag operations.
Signals:
drag_started: Emitted when a drag operation begins successfully
(source_urls_or_paths: str, local_paths: str - comma-separated for multiple)
drag_failed: Emitted when drag initiation fails
(source_urls_or_paths: str, error_message: str)
"""
# Signals with string parameters
drag_started = Signal(str, str) # (url_or_path, local_path)
drag_failed = Signal(str, str) # (url_or_path, error_message)
drag_started = Signal(str, str) # (source_urls_or_paths, local_paths)
drag_failed = Signal(str, str) # (source_urls_or_paths, error_message)
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor.
@ -40,83 +44,123 @@ class DragInterceptor(QWidget):
super().__init__(parent)
self.config = config
self._validator = PathValidator(
config.allowed_roots,
check_file_exists=config.check_file_exists
config.allowed_roots, check_file_exists=config.check_file_exists
)
self._url_converter = URLConverter(config)
def handle_drag(self, text: str) -> bool:
"""Handle drag event from web view.
def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool:
"""Handle drag event from web view (single or multiple files).
Determines if the text is an Azure URL or file path, converts if needed,
Determines if the text/list contains Azure URLs or file paths, converts if needed,
validates, and initiates native drag operation.
Supports:
- Single string (backward compatible)
- List of strings (multiple drag support)
Args:
text: Azure Blob Storage URL or file path from web drag
text_or_list: Azure URL/file path (str) or list of URLs/paths (List[str])
Returns:
True if native drag was initiated, False otherwise
"""
if not text or not text.strip():
# Normalize input to list
if isinstance(text_or_list, str):
text_list = [text_or_list]
elif isinstance(text_or_list, (list, tuple)):
text_list = list(text_or_list)
else:
error_msg = f"Unexpected drag data type: {type(text_or_list)}"
logger.error(error_msg)
self.drag_failed.emit("", error_msg)
return False
# Validate that we have content
if not text_list or all(not t or not str(t).strip() for t in text_list):
error_msg = "Empty drag text"
logger.warning(error_msg)
self.drag_failed.emit("", error_msg)
return False
text = text.strip()
logger.debug(f"Handling drag for text: {text}")
# Clean up text items
text_list = [str(t).strip() for t in text_list if str(t).strip()]
logger.debug(f"Handling drag for {len(text_list)} item(s)")
# Check if it's an Azure URL and convert to local path
if self._url_converter.is_azure_url(text):
local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
error_msg = "No mapping found for URL"
logger.warning(f"{error_msg}: {text}")
# Convert each text to local path
local_paths = []
source_texts = []
for text in text_list:
# Check if it's an Azure URL and convert to local path
if self._url_converter.is_azure_url(text):
local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
error_msg = f"No mapping found for URL: {text}"
logger.warning(error_msg)
self.drag_failed.emit(text, error_msg)
return False
source_texts.append(text)
else:
# Treat as direct file path
local_path = Path(text)
source_texts.append(text)
# Validate the path
try:
self._validator.validate(local_path)
except ValidationError as e:
error_msg = str(e)
logger.warning(f"Validation failed for {local_path}: {error_msg}")
self.drag_failed.emit(text, error_msg)
return False
source_text = text
else:
# Treat as direct file path
local_path = Path(text)
source_text = text
# Validate the path
try:
self._validator.validate(local_path)
except ValidationError as e:
error_msg = str(e)
logger.warning(f"Validation failed for {local_path}: {error_msg}")
self.drag_failed.emit(source_text, error_msg)
return False
local_paths.append(local_path)
logger.info(f"Initiating drag for: {local_path}")
logger.info(
f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}"
)
# Create native file drag
success = self._create_native_drag(local_path)
# Create native file drag with all paths
success = self._create_native_drag(local_paths)
if success:
self.drag_started.emit(source_text, str(local_path))
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
paths_str = (
" | ".join(str(p) for p in local_paths)
if len(local_paths) > 1
else str(local_paths[0])
)
self.drag_started.emit(source_str, paths_str)
else:
error_msg = "Failed to create native drag operation"
logger.error(error_msg)
self.drag_failed.emit(source_text, error_msg)
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
self.drag_failed.emit(source_str, error_msg)
return success
def _create_native_drag(self, file_path: Path) -> bool:
def _create_native_drag(self, file_paths: Union[Path, List[Path]]) -> bool:
"""Create a native file system drag operation.
Args:
file_path: Local file path to drag
file_paths: Single local file path or list of local file paths
Returns:
True if drag was created successfully
"""
try:
# Create MIME data with file URL
# Normalize to list
if isinstance(file_paths, Path):
paths_list = [file_paths]
else:
paths_list = list(file_paths)
# Create MIME data with file URLs
mime_data = QMimeData()
file_url = QUrl.fromLocalFile(str(file_path))
mime_data.setUrls([file_url])
file_urls = [QUrl.fromLocalFile(str(p)) for p in paths_list]
mime_data.setUrls(file_urls)
logger.debug(f"Creating drag with {len(file_urls)} file(s)")
# Create and execute drag
drag = QDrag(self)

View file

@ -11,7 +11,7 @@
console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;');
var currentDragUrl = null;
var currentDragUrls = []; // Array to support multiple URLs
var angularDragHandlers = [];
var originalAddEventListener = EventTarget.prototype.addEventListener;
var listenerPatchActive = true;
@ -60,8 +60,14 @@
DataTransfer.prototype.setData = function(format, data) {
if (format === 'text/plain' || format === 'text/uri-list') {
currentDragUrl = data;
console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80));
// text/uri-list contains newline-separated URLs
// text/plain may be single URL or multiple newline-separated URLs
currentDragUrls = data.trim().split('\n').filter(function(url) {
return url.trim().length > 0;
}).map(function(url) {
return url.trim();
});
console.log('%c[Intercept] Captured ' + currentDragUrls.length + ' URL(s)', 'color: #4CAF50; font-weight: bold;', currentDragUrls[0].substring(0, 60));
}
return originalSetData.call(this, format, data);
};
@ -94,7 +100,7 @@
// Register OUR handler in capture phase
originalAddEventListener.call(document, 'dragstart', function(e) {
currentDragUrl = null; // Reset
currentDragUrls = []; // Reset
// Call Angular's handlers first to let them set the data
var handled = 0;
@ -111,33 +117,41 @@
}
}
console.log('[Intercept] Called', handled, 'Angular handlers, URL:', currentDragUrl ? currentDragUrl.substring(0, 60) : 'none');
console.log('[Intercept] Called', handled, 'Angular handlers, URLs:', currentDragUrls.length, 'URL(s)', currentDragUrls.length > 0 ? currentDragUrls[0].substring(0, 60) : 'none');
// NOW check if we should intercept
// Intercept any drag with a URL that matches our configured mappings
if (currentDragUrl) {
// Intercept any drag with URLs that match our configured mappings
if (currentDragUrls.length > 0) {
var shouldIntercept = false;
// Check against configured URL mappings
// Check each URL against configured URL mappings
// Intercept if ANY URL matches
if (window.webdropConfig && window.webdropConfig.urlMappings) {
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
var mapping = window.webdropConfig.urlMappings[j];
if (currentDragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
shouldIntercept = true;
console.log('[Intercept] URL matches mapping for:', mapping.local_path);
break;
for (var k = 0; k < currentDragUrls.length; k++) {
var dragUrl = currentDragUrls[k];
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
var mapping = window.webdropConfig.urlMappings[j];
if (dragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
shouldIntercept = true;
console.log('[Intercept] URL #' + (k+1) + ' matches mapping for:', mapping.local_path);
break;
}
}
if (shouldIntercept) break;
}
} else {
// Fallback: Check for legacy Z: drive pattern if no config available
shouldIntercept = /^z:/i.test(currentDragUrl);
if (shouldIntercept) {
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
for (var k = 0; k < currentDragUrls.length; k++) {
if (/^z:/i.test(currentDragUrls[k])) {
shouldIntercept = true;
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
break;
}
}
}
if (shouldIntercept) {
console.log('%c[Intercept] PREVENTING browser drag, using Qt',
console.log('%c[Intercept] PREVENTING browser drag, using Qt for ' + currentDragUrls.length + ' file(s)',
'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
e.preventDefault();
@ -145,14 +159,15 @@
ensureChannel(function() {
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;');
window.bridge.start_file_drag(currentDragUrl);
console.log('%c[Intercept] → Qt: start_file_drag with ' + currentDragUrls.length + ' file(s)', 'color: #9C27B0; font-weight: bold;');
// Pass as JSON string to avoid Qt WebChannel array conversion issues
window.bridge.start_file_drag(JSON.stringify(currentDragUrls));
} else {
console.error('[Intercept] bridge.start_file_drag not available!');
}
});
currentDragUrl = null;
currentDragUrls = [];
return false;
}
}

View file

@ -7,7 +7,7 @@ import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, Union
from PySide6.QtCore import (
QEvent,
@ -312,21 +312,34 @@ class _DragBridge(QObject):
self.window = window
@Slot(str)
def start_file_drag(self, path_text: str) -> None:
"""Start a native file drag for the given path or Azure URL.
def start_file_drag(self, paths_text: str) -> None:
"""Start a native file drag for the given path(s) or Azure URL(s).
Called from JavaScript when user drags item(s).
Accepts either:
- Single file path string or Azure URL
- JSON array string of file paths or Azure URLs (multiple drag support)
Called from JavaScript when user drags an item.
Accepts either local file paths or Azure Blob Storage URLs.
Defers execution to avoid Qt drag manager state issues.
Args:
path_text: File path string or Azure URL to drag
paths_text: String (single path/URL) or JSON array string (multiple paths/URLs)
"""
logger.debug(f"Bridge: start_file_drag called for {path_text}")
logger.debug(f"Bridge: start_file_drag called with {len(paths_text)} chars")
# Defer to avoid drag manager state issues
# handle_drag() handles URL conversion and validation internally
QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(path_text))
# Try to parse as JSON array first (for multiple-drag support)
paths_list: Union[str, list] = paths_text
if paths_text.startswith("["):
try:
parsed = json.loads(paths_text)
if isinstance(parsed, list):
paths_list = parsed
logger.debug(f"Parsed JSON array with {len(parsed)} item(s)")
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Failed to parse JSON array: {e}, treating as single string")
# Handle both single string and list
QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(paths_list))
@Slot(str)
def debug_log(self, message: str) -> None: