feat: add OpenDropZone widget for opening files with default applications via drag-and-drop
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions

This commit is contained in:
claudi 2026-02-25 16:16:52 +01:00
parent f7111896b5
commit 530e7f92a3
2 changed files with 130 additions and 3 deletions

View file

@ -200,6 +200,101 @@ DEFAULT_WELCOME_PAGE = """
"""
class OpenDropZone(QWidget):
"""Drop target widget that opens dragged files with their system default application.
Displays an 'open folder' icon in the navigation toolbar. When a file is
dragged from the web view and dropped here, the file's URL is passed to
``QDesktopServices.openUrl()`` so the OS opens it with the associated
programme exactly the same way double-clicking a file in Explorer/Finder
would behave.
Visual feedback is provided on drag-enter (green border highlight) so the
user can see the drop target is active.
Signals:
file_opened (str): Emitted with the local file path when successfully opened.
file_open_failed (str, str): Emitted with (path, error_message) on failure.
"""
file_opened = Signal(str)
file_open_failed = Signal(str, str)
_NORMAL_STYLE = "QLabel { padding: 4px; border: 2px solid transparent; border-radius: 4px; }"
_HOVER_STYLE = (
"QLabel { padding: 4px; border: 2px solid #4CAF50; border-radius: 4px;"
" background: #E8F5E9; }"
)
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Initialize the OpenDropZone widget.
Args:
parent: Parent widget.
"""
super().__init__(parent)
self.setAcceptDrops(True)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._icon_label = QLabel(self)
icon = self.style().standardIcon(self.style().StandardPixmap.SP_DirOpenIcon)
pixmap = icon.pixmap(QSize(32, 32))
self._icon_label.setPixmap(pixmap)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._icon_label.setStyleSheet(self._NORMAL_STYLE)
self._icon_label.setToolTip("Drop a file here to open it with its default application")
layout.addWidget(self._icon_label)
self.setMinimumSize(QSize(44, 44))
self.setMaximumSize(QSize(48, 48))
# ------------------------------------------------------------------
# Drop handling
# ------------------------------------------------------------------
def dragEnterEvent(self, event) -> None: # type: ignore[override]
"""Accept drag events that carry file URLs."""
if event.mimeData().hasUrls():
event.acceptProposedAction()
self._icon_label.setStyleSheet(self._HOVER_STYLE)
else:
event.ignore()
def dragLeaveEvent(self, event) -> None: # type: ignore[override]
"""Reset appearance when drag leaves the widget."""
self._icon_label.setStyleSheet(self._NORMAL_STYLE)
super().dragLeaveEvent(event)
def dropEvent(self, event) -> None: # type: ignore[override]
"""Open each dropped file with the system default application.
Accepts the drop action so that the originating ``QDrag`` reports
success (preserving normal drag-started accounting), then immediately
opens the file via ``QDesktopServices.openUrl()``.
"""
self._icon_label.setStyleSheet(self._NORMAL_STYLE)
mime = event.mimeData()
if not mime.hasUrls():
event.ignore()
return
event.acceptProposedAction()
for url in mime.urls():
if url.isLocalFile():
file_path = url.toLocalFile()
logger.info(f"OpenDropZone: opening '{file_path}' with system default app")
if QDesktopServices.openUrl(url):
self.file_opened.emit(file_path)
else:
msg = "OS could not open the file"
logger.warning(f"OpenDropZone: {msg}: {file_path}")
self.file_open_failed.emit(file_path, msg)
else:
logger.debug(f"OpenDropZone: skipping non-local URL {url.toString()}")
class _DragBridge(QObject):
"""JavaScript bridge for drag operations via QWebChannel.
@ -899,6 +994,30 @@ class MainWindow(QMainWindow):
f"Could not complete the drag-and-drop operation.\n\nError: {error}",
)
def _on_file_opened_via_drop(self, file_path: str) -> None:
"""Handle a file successfully opened via the OpenDropZone.
Args:
file_path: Local file path that was opened.
"""
logger.info(f"Opened via drop zone: {file_path}")
self.statusBar().showMessage(f"Opened: {Path(file_path).name}", 4000)
def _on_file_open_failed_via_drop(self, file_path: str, error: str) -> None:
"""Handle a failure to open a file dropped on the OpenDropZone.
Args:
file_path: Local file path that could not be opened.
error: Error description.
"""
logger.warning(f"Failed to open via drop zone '{file_path}': {error}")
QMessageBox.warning(
self,
"Open File Error",
f"Could not open the file with its default application.\n\n"
f"File: {file_path}\nError: {error}",
)
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
"""Handle download requests from the embedded web view.
@ -1102,6 +1221,14 @@ class MainWindow(QMainWindow):
refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload)
toolbar.addAction(refresh_action)
# Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone()
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_via_drop)
open_drop_action = QWidgetAction(toolbar)
open_drop_action.setDefaultWidget(self._open_drop_zone)
toolbar.addAction(open_drop_action)
# Add stretch spacer to push help buttons to the right
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)

View file

@ -204,7 +204,7 @@ class RestrictedWebEngineView(QWebEngineView):
- Exact domain matches: example.com
- Wildcard patterns: *.example.com
- Localhost variations: localhost, 127.0.0.1
- File URLs: file://...
- Internal/local URLs: file://, data:, about:, blob:, qrc:
Args:
url: QUrl to check
@ -216,8 +216,8 @@ class RestrictedWebEngineView(QWebEngineView):
host = url.host()
scheme = url.scheme()
# Allow file:// URLs (local webapp)
if scheme == "file":
# Allow internal browser/Qt schemes (never send these to the OS)
if scheme in ("file", "data", "about", "blob", "qrc"):
return True
# If no whitelist, allow all URLs