diff --git a/CHANGELOG.md b/CHANGELOG.md
index b96c3ef..2aafd4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
## [Unreleased]
+### Sicheres Löschen
+- Die Löschbestätigung lässt sich über das Menü **Bearbeiten** oder die Einstellungen ein- und ausschalten.
+- Wenn die Bestätigung ausgeschaltet ist, verschiebt ExplorerPro ausgewählte Elemente in den System-Papierkorb. Schlägt das Verschieben fehl, bleibt das Element erhalten und ExplorerPro zeigt den Fehler an.
+- Die Windows-Fehler-5-Behandlung entfernt bei Bedarf das Read-only-Attribut regulärer Einzel-Link-Dateien vor einem einzelnen Löschversuch und stellt es bei Fehlschlag wieder her.
+- Die rekursive Löschbehandlung verwendet ab Python 3.12 den `onexc`-Callback und bei älteren unterstützten Python-Versionen `onerror`.
+
### Datei-/Ordner-Eigenschaften, Pfad-Kopier-Suite & Terminal-Integration (2026-10-03, TW-EP-11)
- **Datei- & Ordner-Eigenschaften Dialog (`src/gui/properties_dialog.py`)**:
- Neuer standardkonformer Eigenschafts-Dialog mit Tabs für "Allgemein" und "Prüfsummen" (bzw. "Inhalt" für Textdateien).
@@ -46,6 +52,17 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
- Das Schließen fordert den Abbruch an und hält den Dialog bis zum bestätigten Thread-Ende offen. Die Oberfläche bleibt dabei bedienbar; die Freigabe eines noch laufenden nativen Workers wird verhindert.
- Automatische zyklische Python-Bereinigung wird während des GUI-Betriebs im Anwendungsthread ausgeführt, damit Hintergrundthreads keine Qt-Objekte zerstören.
+### Laufwerksbelegung / Drive capacity (2026-09-30)
+
+- Die Ordner-Seitenleiste zeigt für jedes Laufwerk einen Belegungsbalken, den belegten Anteil in Prozent und freien, belegten sowie gesamten Speicher in binären Größen (GiB/TiB).
+- Kapazitäten werden unabhängig im Hintergrund abgefragt, beim Öffnen und über „Laufwerksbelegung aktualisieren“ erneuert. Nicht verfügbare Laufwerke erhalten einen erklärenden Text statt falscher Nullwerte.
+- Text und Balken sind auch in schmalen Seitenleisten lesbar; Pfadnavigation bleibt erhalten. Alle neuen Texte sind in DE/EN/ES/ZH/JA/RU vorhanden.
+- Hintergrundabfragen liefern reine Python-Daten; ein Timer des Panels übernimmt fertige Ergebnisse im GUI-Thread. Dateisystem- und Sortiermodell gehören jetzt ihrem Dateibrowser und werden mit ihm gelöscht.
+- Die Löschprüfungen bereinigen ausschließlich ihre eigenen Qt-Objekte; die Navigation wartet mit einem begrenzten Qt-Ereignislauf auf tatsächlich geladene Dateizeilen.
+- Zyklische Python-Objekte werden während des GUI-Betriebs im Anwendungsthread bereinigt. Hintergrundabfragen werden vor dem Beenden abgewartet; Prüfsummen-Threads lösen keine automatische Löschung von Qt-GUI-Objekten mehr aus.
+- Laufwerksabfragen laufen in eigenen Hilfsprozessen mit zehn Sekunden Zeitlimit. Hängende Datenträger werden als nicht verfügbar angezeigt; beim Beenden werden laufende Hilfsprozesse beendet und abgeholt.
+- Source feature only: no EXE/MSIX release or Store submission performed.
+
### Repository-Lebenszyklus-Härtung & CI/CD-Parität (2026-09-30, Pfad A)
- **CI/CD Lifecycle Workflows & Label-Governance**:
- `.github/workflows/auto-assign.yml`: Automatisches Zuweisen von Pull Requests an Maintainer via `actions/github-script@v7`, `timeout-minutes: 5`, least-privilege permissions (`issues: write`, `pull-requests: write`) und Concurrency `cancel-in-progress: true`.
diff --git a/README.md b/README.md
index 339f5bb..97ce8aa 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,8 @@
## 1. Features & Core Capabilities
+The source version includes per-drive capacity bars in the folder sidebar, with used percentage and free/used/total space in GiB/TiB. Capacity requests run in the background; use **Refresh drive usage** to update them. Unavailable devices show an explanatory message. This addition is not yet part of a newly packaged release.
+
Standard operating system file managers are built for casual browsing and lack the heavy-lifting tools developers, researchers, and power users require daily. ExplorerPro addresses this gap by packaging pro-grade productivity utilities into a cohesive, responsive desktop interface with zero telemetry and 100% Local-First data isolation:
- **Unified Multi-Tab Experience:** Browse multiple directories concurrently with tab pinning, breadcrumb navigation, drag-and-drop, and intelligent context menus.
@@ -568,7 +570,7 @@ ExplorerPro provides comprehensive keyboard control designed for high-efficiency
| Ctrl + Tab | Browser | Cycle through open directory tabs |
| Ctrl + F | Global | Focus search bar and trigger FTS5 search |
| F2 | Browser | Rename selected file or folder |
-| Delete | Browser | Delete selected items (with confirmation dialog) |
+| Delete | Browser | Delete selected items (with confirmation; without confirmation they go to the system trash) |
| Ctrl + C | Browser | Copy selected files/folders to clipboard |
| Ctrl + V | Browser | Paste files from clipboard (with collision-free auto-suffix) |
| Ctrl + Shift + N | Browser | Create a new folder in current directory |
diff --git a/README_de.md b/README_de.md
index dc9d3c6..ce9d3e0 100644
--- a/README_de.md
+++ b/README_de.md
@@ -2,6 +2,8 @@
# ExplorerPro Suite
+Der aktuelle Quellcode zeigt in der Ordner-Seitenleiste die Speicherbelegung je Laufwerk: Balken, Prozentanteil und freier/belegter/gesamter Speicher in GiB/TiB. Die Abfrage läuft im Hintergrund; **Laufwerksbelegung aktualisieren** erneuert die Werte. Nicht verfügbare Geräte werden verständlich gekennzeichnet. Diese Ergänzung ist noch nicht Bestandteil eines neu gebauten Releases.
+
[English](README.md) | **[Deutsch](README_de.md)** | [Maschinenlesbarer Kontext (llms.txt)](llms.txt)
[](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml)
diff --git a/locales/translations.json b/locales/translations.json
index ff39cfe..5f3a860 100644
--- a/locales/translations.json
+++ b/locales/translations.json
@@ -2846,5 +2846,69 @@
"zh": "打开选定的文件",
"ja": "選択したファイルを開く",
"ru": "Открывает выбранный файл"
+ },
+ "Speicherbelegung": {
+ "de": "Speicherbelegung",
+ "en": "Storage usage",
+ "es": "Uso del almacenamiento",
+ "zh": "存储使用情况",
+ "ja": "ストレージ使用量",
+ "ru": "Использование хранилища"
+ },
+ "Speicherbelegung wird ermittelt …": {
+ "de": "Speicherbelegung wird ermittelt …",
+ "en": "Checking storage usage …",
+ "es": "Calculando el uso del almacenamiento …",
+ "zh": "正在查询存储使用情况 …",
+ "ja": "ストレージ使用量を確認中 …",
+ "ru": "Проверка использования хранилища …"
+ },
+ "Speicherbelegung nicht verfügbar": {
+ "de": "Speicherbelegung nicht verfügbar",
+ "en": "Storage usage unavailable",
+ "es": "Uso del almacenamiento no disponible",
+ "zh": "存储使用情况不可用",
+ "ja": "ストレージ使用量を取得できません",
+ "ru": "Данные об использовании хранилища недоступны"
+ },
+ "{free} frei · {used} belegt · {total} gesamt": {
+ "de": "{free} frei · {used} belegt · {total} gesamt",
+ "en": "{free} free · {used} used · {total} total",
+ "es": "{free} libre · {used} usado · {total} total",
+ "zh": "{free} 可用 · {used} 已用 · {total} 总计",
+ "ja": "{free} 空き · {used} 使用中 · {total} 合計",
+ "ru": "{free} свободно · {used} занято · {total} всего"
+ },
+ "{percent}% belegt": {
+ "de": "{percent}% belegt",
+ "en": "{percent}% used",
+ "es": "{percent}% usado",
+ "zh": "已用 {percent}%",
+ "ja": "{percent}% 使用中",
+ "ru": "{percent}% занято"
+ },
+ "Laufwerksbelegung aktualisieren": {
+ "de": "Laufwerksbelegung aktualisieren",
+ "en": "Refresh drive usage",
+ "es": "Actualizar el uso de las unidades",
+ "zh": "刷新驱动器使用情况",
+ "ja": "ドライブ使用量を更新",
+ "ru": "Обновить использование дисков"
+ },
+ "Wenn deaktiviert, werden Elemente ohne Rückfrage in den Papierkorb verschoben.": {
+ "de": "Wenn deaktiviert, werden Elemente ohne Rückfrage in den Papierkorb verschoben.",
+ "en": "When disabled, items are moved to the Recycle Bin without asking.",
+ "es": "Si se desactiva, los elementos se mueven a la papelera sin pedir confirmación.",
+ "zh": "禁用后,项目会直接移至回收站,不再询问。",
+ "ja": "無効にすると、確認なしで項目をごみ箱に移動します。",
+ "ru": "Если отключить, элементы будут перемещаться в корзину без запроса подтверждения."
+ },
+ "Das Element konnte nicht in den Papierkorb verschoben werden.": {
+ "de": "Das Element konnte nicht in den Papierkorb verschoben werden.",
+ "en": "The item could not be moved to the Recycle Bin.",
+ "es": "No se pudo mover el elemento a la papelera.",
+ "zh": "无法将项目移至回收站。",
+ "ja": "項目をごみ箱に移動できませんでした。",
+ "ru": "Не удалось переместить элемент в корзину."
}
-}
\ No newline at end of file
+}
diff --git a/src/core/delete_service.py b/src/core/delete_service.py
new file mode 100644
index 0000000..3bc7720
--- /dev/null
+++ b/src/core/delete_service.py
@@ -0,0 +1,87 @@
+"""Permanent deletion with a Windows read-only retry for single-link files."""
+
+import os
+import shutil
+import stat
+import sys
+
+from translator import t
+
+
+def _retry_readonly_file(operation, path, error):
+ """Retry a failed file deletion once; never relax directory permissions."""
+ if (
+ sys.platform != "win32"
+ or getattr(error, "winerror", None) != 5
+ or operation not in (os.remove, os.unlink)
+ ):
+ raise error
+ try:
+ original = os.lstat(path)
+ except OSError:
+ raise error
+ if not (
+ stat.S_ISREG(original.st_mode)
+ and original.st_file_attributes & stat.FILE_ATTRIBUTE_READONLY
+ and original.st_nlink == 1
+ ):
+ # Attributes belong to the file, not its name. Clearing read-only on
+ # a hardlink would also modify aliases outside the selected tree.
+ raise error
+
+ os.chmod(path, original.st_mode | stat.S_IWRITE)
+ try:
+ operation(path)
+ except OSError:
+ # An ACL or open handle can still prevent deletion. Restore the user's
+ # read-only bit on the same file, and propagate the retry error.
+ try:
+ current = os.lstat(path)
+ if (
+ stat.S_ISREG(current.st_mode)
+ and (current.st_dev, current.st_ino) == (original.st_dev, original.st_ino)
+ ):
+ os.chmod(path, original.st_mode)
+ except OSError:
+ pass
+ raise
+
+
+def _rmtree_error(operation, path, exc_info):
+ _retry_readonly_file(operation, path, exc_info[1])
+
+
+def _rmtree_exception(operation, path, error):
+ _retry_readonly_file(operation, path, error)
+
+
+def _remove_tree(path):
+ if sys.version_info >= (3, 12):
+ shutil.rmtree(path, onexc=_rmtree_exception)
+ else:
+ shutil.rmtree(path, onerror=_rmtree_error)
+
+
+def delete_path(path):
+ """Delete one selected entry, without traversing symlink/junction targets."""
+ entry = os.lstat(path)
+ if stat.S_ISLNK(entry.st_mode):
+ os.unlink(path)
+ elif getattr(entry, "st_reparse_tag", None) == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003):
+ os.rmdir(path)
+ elif stat.S_ISDIR(entry.st_mode):
+ # Python 3.8+ also avoids descending into nested Windows junctions.
+ _remove_tree(path)
+ else:
+ try:
+ os.remove(path)
+ except OSError as error:
+ _retry_readonly_file(os.remove, path, error)
+
+
+def move_to_trash(path):
+ """Move one selected item to the operating system trash/recycle bin."""
+ from PySide6.QtCore import QFile
+
+ if not QFile.moveToTrash(path):
+ raise OSError(t("Das Element konnte nicht in den Papierkorb verschoben werden."))
diff --git a/src/core/drive_usage.py b/src/core/drive_usage.py
new file mode 100644
index 0000000..69d8dd0
--- /dev/null
+++ b/src/core/drive_usage.py
@@ -0,0 +1,138 @@
+"""Read capacity without scanning files or changing the filesystem."""
+
+from dataclasses import dataclass
+import os
+import shutil
+import json
+from pathlib import Path
+import subprocess
+import sys
+import threading
+import tempfile
+
+QUERY_TIMEOUT_SECONDS = 10
+_query_lock = threading.Lock()
+_query_processes = set()
+_queries_stopped = False
+
+
+def start_drive_queries():
+ global _queries_stopped
+ with _query_lock:
+ _queries_stopped = False
+
+
+def stop_drive_queries():
+ """Stop only our capacity helpers, including reads stuck inside the OS."""
+ global _queries_stopped
+ with _query_lock:
+ _queries_stopped = True
+ for process in tuple(_query_processes):
+ if process.poll() is None:
+ process.kill()
+
+
+def _query_command(path, result_path):
+ if getattr(sys, 'frozen', False):
+ return [sys.executable, '--drive-capacity-query', path, result_path]
+ return [sys.executable, str(Path(__file__).resolve()), path, result_path]
+
+
+def read_drive_usage_bounded(path):
+ """Isolate disk_usage so stalled devices cannot retain pool slots forever."""
+ with tempfile.TemporaryDirectory(prefix='explorerpro-capacity-') as directory:
+ return _read_drive_usage_process(path, Path(directory) / 'result.json')
+
+
+def _read_drive_usage_process(path, result_path):
+ with _query_lock:
+ if _queries_stopped:
+ raise OSError('Capacity queries stopped')
+ process = subprocess.Popen(
+ _query_command(path, str(result_path)),
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0,
+ )
+ _query_processes.add(process)
+ try:
+ try:
+ process.wait(timeout=QUERY_TIMEOUT_SECONDS)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ raise OSError('Capacity query timed out') from None
+ if process.returncode:
+ raise OSError('Capacity query unavailable')
+ try:
+ values = json.loads(result_path.read_text(encoding='utf-8'))
+ return DriveUsage(**values)
+ except (OSError, ValueError, TypeError):
+ raise OSError('Invalid capacity query response') from None
+ finally:
+ with _query_lock:
+ _query_processes.discard(process)
+
+
+def capacity_query_main(path, result_path):
+ """Read-only measurement; write its result to the parent's private temp file."""
+ try:
+ usage = read_drive_usage(path)
+ except (OSError, ValueError):
+ return 1
+ # Windowed frozen executables may have no stdout, even with redirected pipes.
+ with open(result_path, 'x', encoding='utf-8') as result:
+ json.dump({'total': usage.total, 'used': usage.used, 'free': usage.free}, result)
+ return 0
+
+
+@dataclass(frozen=True)
+class DriveUsage:
+ total: int
+ used: int
+ free: int
+
+ def __post_init__(self):
+ if self.total <= 0 or not 0 <= self.used <= self.total or not 0 <= self.free <= self.total:
+ raise ValueError("Invalid drive capacity")
+
+ @property
+ def used_percent(self) -> float:
+ return self.used / self.total * 100
+
+
+def read_drive_usage(path: str) -> DriveUsage:
+ if os.name == "nt":
+ # Empty removable drives must return an error rather than opening an
+ # operating-system critical-error dialog in this background thread.
+ import ctypes
+ from ctypes import wintypes
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
+ set_mode = kernel.SetThreadErrorMode
+ set_mode.argtypes = (wintypes.DWORD, ctypes.POINTER(wintypes.DWORD))
+ set_mode.restype = wintypes.BOOL
+ get_mode = kernel.GetThreadErrorMode
+ get_mode.argtypes = ()
+ get_mode.restype = wintypes.DWORD
+ previous = wintypes.DWORD()
+ if not set_mode(get_mode() | 0x0001, ctypes.byref(previous)): # SEM_FAILCRITICALERRORS
+ raise ctypes.WinError(ctypes.get_last_error())
+ try:
+ usage = shutil.disk_usage(path)
+ finally:
+ set_mode(previous.value, None)
+ else:
+ usage = shutil.disk_usage(path)
+ return DriveUsage(usage.total, usage.used, usage.free)
+
+
+def format_capacity(size: int) -> str:
+ """Use explicit binary units, retaining useful precision for small drives."""
+ value = float(size)
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
+ if value < 1024 or unit == "PiB":
+ return f"{int(value)} B" if unit == "B" else f"{value:.1f} {unit}"
+ value /= 1024
+
+
+if __name__ == '__main__':
+ sys.exit(capacity_query_main(*sys.argv[1:]) if len(sys.argv) == 3 else 2)
diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py
index 6a79c87..0ce0004 100644
--- a/src/gui/browser/file_browser.py
+++ b/src/gui/browser/file_browser.py
@@ -122,7 +122,7 @@ def _setup_ui(self):
layout.setContentsMargins(0, 0, 0, 0)
# Datei-System-Model
- self.model = QFileSystemModel()
+ self.model = QFileSystemModel(self)
self.model.setFilter(
QDir.Filter.AllEntries |
QDir.Filter.NoDotAndDotDot
@@ -130,7 +130,7 @@ def _setup_ui(self):
self.model.directoryLoaded.connect(self._on_directory_loaded)
# Sortier-Proxy
- self.proxy = QSortFilterProxyModel()
+ self.proxy = QSortFilterProxyModel(self)
self.proxy.setSourceModel(self.model)
self.proxy.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
@@ -775,7 +775,10 @@ def _show_diff(self, file1: str = "", file2: str = ""):
dialog.exec()
def delete_selection(self, target_paths: list = None) -> bool:
- """Löscht ausgewählte Dateien oder Ordner nach Bestätigung."""
+ """Delete selected entries, using the trash when confirmation is disabled."""
+ from core.delete_service import delete_path, move_to_trash
+ from core.settings_manager import SettingsManager
+
if not target_paths:
target_paths = self.get_selected_files()
if not target_paths:
@@ -790,26 +793,25 @@ def delete_selection(self, target_paths: list = None) -> bool:
preview += f"\n... und {count - 5} weitere"
msg = f"Möchten Sie diese {count} Elemente wirklich unwiderruflich löschen?\n\n{preview}"
- reply = QMessageBox.question(
- self,
- "Löschen bestätigen",
- msg,
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No
- )
-
- if reply != QMessageBox.StandardButton.Yes:
- return False
+ confirm_delete = SettingsManager.instance().get("general", "confirm_delete", True) is not False
+ if confirm_delete:
+ reply = QMessageBox.question(
+ self,
+ "Löschen bestätigen",
+ msg,
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return False
errors = []
+ delete_item = delete_path if confirm_delete else move_to_trash
for path in target_paths:
- if not os.path.exists(path):
+ if not os.path.lexists(path):
continue
try:
- if os.path.isdir(path):
- shutil.rmtree(path)
- else:
- os.remove(path)
+ delete_item(path)
except OSError as exc:
errors.append(f"{os.path.basename(path)}: {exc}")
diff --git a/src/gui/main_window.py b/src/gui/main_window.py
index 199cce5..3bf6db8 100644
--- a/src/gui/main_window.py
+++ b/src/gui/main_window.py
@@ -22,6 +22,7 @@
from .preview.preview_panel import PreviewPanel
from .status_bar import StatusBarWidget
from version import __version__
+from translator import t
class SearchToolBar(QToolBar):
@@ -341,6 +342,16 @@ def _setup_menu(self):
copy_path_action.triggered.connect(self._copy_path)
edit_menu.addAction(copy_path_action)
+ edit_menu.addSeparator()
+ self.confirm_delete_action = QAction(t("Vor dem Löschen nachfragen"), self)
+ self.confirm_delete_action.setCheckable(True)
+ self.confirm_delete_action.setToolTip(
+ t("Wenn deaktiviert, werden Elemente ohne Rückfrage in den Papierkorb verschoben.")
+ )
+ self._sync_delete_confirmation()
+ self.confirm_delete_action.triggered.connect(self._set_delete_confirmation)
+ edit_menu.addAction(self.confirm_delete_action)
+ edit_menu.aboutToShow.connect(self._sync_delete_confirmation)
# ===== Ansicht-Menü =====
view_menu = menubar.addMenu("&Ansicht")
@@ -792,11 +803,26 @@ def _show_settings(self):
self._apply_settings()
self.statusBar().showMessage("Einstellungen gespeichert", 3000)
+ def _sync_delete_confirmation(self):
+ from core.settings_manager import SettingsManager
+
+ self.confirm_delete_action.setChecked(
+ SettingsManager.instance().get("general", "confirm_delete", True) is not False
+ )
+
+ def _set_delete_confirmation(self, checked):
+ from core.settings_manager import SettingsManager
+
+ settings = SettingsManager.instance()
+ settings.set("general", "confirm_delete", checked)
+ settings.save()
+
def _apply_settings(self):
"""Wendet die gespeicherten Einstellungen auf das laufende Fenster an."""
from core.settings_manager import SettingsManager
settings = SettingsManager.instance()
+ self._sync_delete_confirmation()
show_hidden = bool(settings.get("general", "show_hidden_files", False))
if hasattr(self.file_browser, "set_show_hidden_files"):
diff --git a/src/gui/settings_dialog.py b/src/gui/settings_dialog.py
index 3a1a64b..4e4f4d7 100644
--- a/src/gui/settings_dialog.py
+++ b/src/gui/settings_dialog.py
@@ -15,7 +15,7 @@
)
from core.settings_manager import SettingsManager
-from translator import TranslationSystem
+from translator import TranslationSystem, t
class SettingsDialog(QDialog):
@@ -102,9 +102,11 @@ def _build_general_tab(self) -> QWidget:
self.show_hidden_cb.setToolTip("Versteckte Dateien und Systemordner im Dateibrowser anzeigen")
form.addRow(self.show_hidden_cb)
- self.confirm_delete_cb = QCheckBox("Vor dem Löschen nachfragen")
- self.confirm_delete_cb.setAccessibleName("Vor dem Löschen nachfragen")
- self.confirm_delete_cb.setToolTip("Sicherheitsabfrage vor dem unwiderruflichen Löschen einblenden")
+ self.confirm_delete_cb = QCheckBox(t("Vor dem Löschen nachfragen"))
+ self.confirm_delete_cb.setAccessibleName(t("Vor dem Löschen nachfragen"))
+ self.confirm_delete_cb.setToolTip(
+ t("Wenn deaktiviert, werden Elemente ohne Rückfrage in den Papierkorb verschoben.")
+ )
form.addRow(self.confirm_delete_cb)
self.remember_size_cb = QCheckBox("Fenstergröße merken")
@@ -239,7 +241,7 @@ def _load_settings(self):
self.start_folder_edit.setText(get("general", "start_folder", "") or "")
self.show_hidden_cb.setChecked(bool(get("general", "show_hidden_files", False)))
- self.confirm_delete_cb.setChecked(bool(get("general", "confirm_delete", True)))
+ self.confirm_delete_cb.setChecked(get("general", "confirm_delete", True) is not False)
self.remember_size_cb.setChecked(bool(get("general", "remember_window_size", True)))
self.auto_index_cb.setChecked(bool(get("index", "auto_index", True)))
diff --git a/src/gui/sidebar/drive_capacity.py b/src/gui/sidebar/drive_capacity.py
new file mode 100644
index 0000000..4fb8242
--- /dev/null
+++ b/src/gui/sidebar/drive_capacity.py
@@ -0,0 +1,79 @@
+"""Capacity widgets and independent background requests for drive rows."""
+
+from concurrent.futures import ThreadPoolExecutor
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QLabel, QProgressBar, QVBoxLayout, QWidget
+
+from core.drive_usage import format_capacity, start_drive_queries, stop_drive_queries
+from translator import t
+
+_executor = None
+
+
+def capacity_executor():
+ """Workers only return Python data; no Qt objects cross thread boundaries.
+
+ OS reads run in bounded helpers, so failed drives release worker slots.
+ """
+ global _executor
+ if _executor is None:
+ start_drive_queries()
+ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="drive-capacity")
+ return _executor
+
+
+def shutdown_capacity_executor():
+ """Stop capacity helpers, then drain Python workers before Qt teardown."""
+ global _executor
+ if _executor is not None:
+ stop_drive_queries()
+ _executor.shutdown(wait=True, cancel_futures=True)
+ _executor = None
+
+
+class DriveCapacityWidget(QWidget):
+ def __init__(self, path, parent=None):
+ super().__init__(parent)
+ self.path = path
+ # Tree selection and double-click navigation still receive mouse events.
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(2, 3, 2, 3)
+ layout.setSpacing(2)
+ self.title = QLabel(path)
+ self.details = QLabel()
+ self.details.setWordWrap(True)
+ self.bar = QProgressBar()
+ self.bar.setRange(0, 1000)
+ self.bar.setFixedHeight(18)
+ self.bar.setAccessibleName(t("Speicherbelegung"))
+ layout.addWidget(self.title)
+ layout.addWidget(self.details)
+ layout.addWidget(self.bar)
+ self.set_loading()
+
+ def set_loading(self):
+ self.details.setText(t("Speicherbelegung wird ermittelt …"))
+ self.bar.hide()
+ self.setAccessibleName(self.path)
+ self.setAccessibleDescription(self.details.text())
+
+ def set_usage(self, usage):
+ if usage is None:
+ self.details.setText(t("Speicherbelegung nicht verfügbar"))
+ self.bar.hide()
+ self.bar.setValue(0)
+ else:
+ self.details.setText(t("{free} frei · {used} belegt · {total} gesamt").format(
+ free=format_capacity(usage.free), used=format_capacity(usage.used),
+ total=format_capacity(usage.total)))
+ self.bar.setValue(round(usage.used_percent * 10))
+ self.bar.setFormat(t("{percent}% belegt").format(percent=f"{usage.used_percent:.1f}"))
+ self.bar.show()
+ description = self.details.text()
+ if usage is not None:
+ description += " · " + self.bar.format()
+ self.setToolTip(self.path + "\n" + description)
+ self.setAccessibleDescription(description)
+ self.bar.setAccessibleDescription(description)
diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py
index daf285d..a914721 100644
--- a/src/gui/sidebar/sidebar_main.py
+++ b/src/gui/sidebar/sidebar_main.py
@@ -5,12 +5,14 @@
Phase 5: Vollständige Integration
"""
+from concurrent.futures import CancelledError
+
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QStackedWidget,
QTreeWidget, QTreeWidgetItem, QListWidget, QListWidgetItem,
QPushButton, QLabel, QFrame, QToolButton, QButtonGroup
)
-from PySide6.QtCore import Qt, Signal, QDir, QStandardPaths
+from PySide6.QtCore import Qt, Signal, Slot, QDir, QStandardPaths, QSize, QTimer
import os
# Module importieren - absolute Imports
@@ -19,6 +21,9 @@
from modules.launcher import AppsPanel
from modules.prompts import PromptsPanel
from modules.sync import SyncPanel
+from core.drive_usage import read_drive_usage_bounded as read_drive_usage
+from gui.sidebar.drive_capacity import DriveCapacityWidget, capacity_executor
+from translator import t
class TreePanel(QWidget):
@@ -28,8 +33,17 @@ class TreePanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
+ self._drive_rows = {}
+ self._usage_requests = {}
+ self._resize_timer = QTimer(self)
+ self._resize_timer.setSingleShot(True)
+ self._resize_timer.timeout.connect(self._resize_drive_rows)
+ self._usage_timer = QTimer(self)
+ self._usage_timer.setInterval(50)
+ self._usage_timer.timeout.connect(self._collect_drive_usage)
self._setup_ui()
self._populate()
+ self.refresh_drive_usage()
def _setup_ui(self):
layout = QVBoxLayout(self)
@@ -48,6 +62,9 @@ def _setup_ui(self):
self.tree.itemExpanded.connect(self._on_item_expanded)
layout.addWidget(self.tree)
+ self.refresh_drives_button = QPushButton(t("Laufwerksbelegung aktualisieren"))
+ self.refresh_drives_button.clicked.connect(self.refresh_drive_usage)
+ layout.addWidget(self.refresh_drives_button)
def _populate(self):
"""Füllt den Baum mit Laufwerken und Schnellzugriff"""
@@ -77,18 +94,77 @@ def _populate(self):
# Laufwerke
drives_item = QTreeWidgetItem(["💾 Laufwerke"])
drives_item.setFlags(drives_item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
+ self.tree.addTopLevelItem(drives_item)
for drive in QDir.drives():
path = drive.absolutePath()
- item = QTreeWidgetItem([path])
+ # The embedded widget paints the title; duplicate tree text would
+ # otherwise show through between its labels and progress bar.
+ item = QTreeWidgetItem([""])
item.setData(0, Qt.ItemDataRole.UserRole, path)
+ item.setData(0, Qt.ItemDataRole.AccessibleTextRole, path)
item.setIcon(0, get_file_icon(path))
item.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator)
drives_item.addChild(item)
+ capacity = DriveCapacityWidget(path)
+ self.tree.setItemWidget(item, 0, capacity)
+ item.setSizeHint(0, capacity.sizeHint())
+ self._drive_rows[path] = (item, capacity)
- self.tree.addTopLevelItem(drives_item)
drives_item.setExpanded(True)
+ def refresh_drive_usage(self):
+ """One request per drive; repeat clicks cannot queue duplicate queries."""
+ for path, (item, capacity) in self._drive_rows.items():
+ if path in self._usage_requests:
+ continue
+ capacity.set_loading()
+ item.setSizeHint(0, capacity.sizeHint())
+ self._usage_requests[path] = capacity_executor().submit(read_drive_usage, path)
+ if self._usage_requests:
+ self._usage_timer.start()
+
+ @Slot()
+ def _collect_drive_usage(self):
+ for path, future in list(self._usage_requests.items()):
+ if not future.done():
+ continue
+ try:
+ usage = future.result()
+ except (CancelledError, OSError, ValueError):
+ usage = None
+ self._on_drive_usage(path, usage)
+ if not self._usage_requests:
+ self._usage_timer.stop()
+
+ @Slot(str, object)
+ def _on_drive_usage(self, path, usage):
+ self._usage_requests.pop(path, None)
+ row = self._drive_rows.get(path)
+ if row is not None:
+ item, capacity = row
+ capacity.set_usage(usage)
+ item.setToolTip(0, capacity.toolTip())
+ item.setData(0, Qt.ItemDataRole.AccessibleDescriptionRole, capacity.accessibleDescription())
+ item.setSizeHint(0, capacity.sizeHint())
+ self._resize_drive_rows()
+
+ def showEvent(self, event):
+ super().showEvent(event)
+ self.refresh_drive_usage()
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self._resize_timer.start(0)
+
+ @Slot()
+ def _resize_drive_rows(self):
+ """Wrapped labels need taller rows when the sidebar becomes narrow."""
+ for item, capacity in self._drive_rows.values():
+ height = capacity.layout().totalHeightForWidth(capacity.width())
+ item.setSizeHint(0, QSize(0, max(height, capacity.sizeHint().height())))
+ self.tree.doItemsLayout()
+
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
path = item.data(0, Qt.ItemDataRole.UserRole)
if path:
diff --git a/src/main.py b/src/main.py
index 931193e..660f258 100644
--- a/src/main.py
+++ b/src/main.py
@@ -21,6 +21,11 @@
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+# Frozen helpers must finish before importing Qt or constructing the application.
+if __name__ == '__main__' and len(sys.argv) == 4 and sys.argv[1] == '--drive-capacity-query':
+ from core.drive_usage import capacity_query_main
+ sys.exit(capacity_query_main(sys.argv[2], sys.argv[3]))
+
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt, QTranslator, QLibraryInfo
from PySide6.QtGui import QIcon
@@ -28,6 +33,7 @@
from app import ExplorerProApp
from version import __version__
from core.gui_gc import install_gui_gc
+from gui.sidebar.drive_capacity import shutdown_capacity_executor
def load_app_icon() -> QIcon:
@@ -99,7 +105,10 @@ def main():
explorer.show()
exit_code = app.exec()
finally:
- collector.close()
+ try:
+ shutdown_capacity_executor()
+ finally:
+ collector.close()
sys.exit(exit_code)
diff --git a/tests/conftest.py b/tests/conftest.py
index 293ae29..9f2243b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,6 +8,7 @@
from PySide6.QtWidgets import QApplication
from core.gui_gc import install_gui_gc
+from gui.sidebar.drive_capacity import shutdown_capacity_executor
_application = QApplication.instance() or QApplication([])
_collector = install_gui_gc(_application)
@@ -22,4 +23,7 @@ def collect_gui_cycles():
@pytest.fixture(scope='session', autouse=True)
def gui_runtime():
yield
- _collector.close()
+ try:
+ shutdown_capacity_executor()
+ finally:
+ _collector.close()
diff --git a/tests/test_delete_confirmation.py b/tests/test_delete_confirmation.py
new file mode 100644
index 0000000..0672ca6
--- /dev/null
+++ b/tests/test_delete_confirmation.py
@@ -0,0 +1,203 @@
+"""Menu, settings persistence and all browser deletion entry points."""
+
+import json
+import os
+from pathlib import Path
+from unittest.mock import Mock
+
+import pytest
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from PySide6.QtCore import QEvent, Qt
+from PySide6.QtGui import QKeyEvent
+from PySide6.QtTest import QTest
+from PySide6.QtWidgets import QApplication, QMenu, QMessageBox
+
+from core import delete_service
+from core.settings_manager import SettingsManager
+import gui.browser.file_browser as browser_module
+from gui.main_window import MainWindow
+from gui.settings_dialog import SettingsDialog
+
+_app = QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def settings(tmp_path, monkeypatch):
+ monkeypatch.setattr(SettingsManager, "_instance", None)
+ monkeypatch.setattr(SettingsManager, "_get_config_path", lambda self: tmp_path / "settings.json")
+ return SettingsManager.instance()
+
+
+@pytest.fixture
+def window(settings):
+ win = MainWindow()
+ yield win
+ win.close()
+ win.deleteLater()
+ _app.sendPostedEvents(None, QEvent.Type.DeferredDelete)
+
+
+def test_menu_checkbox_default_and_json_roundtrip(window, settings):
+ action = window.confirm_delete_action
+ assert action.isCheckable() and action.isChecked()
+ action.trigger()
+ assert settings.get("general", "confirm_delete") is False
+ assert json.loads(settings._config_path.read_text())["general"]["confirm_delete"] is False
+ settings._settings = {}
+ settings._load_settings()
+ assert settings.get("general", "confirm_delete") is False
+ second = MainWindow()
+ try:
+ assert not second.confirm_delete_action.isChecked()
+ action.trigger()
+ second.menuBar().actions()[1].menu().aboutToShow.emit()
+ assert second.confirm_delete_action.isChecked()
+ assert json.loads(settings._config_path.read_text())["general"]["confirm_delete"] is True
+ finally:
+ second.close()
+ second.deleteLater()
+
+
+def test_settings_dialog_and_menu_share_confirmation(window, settings, monkeypatch):
+ window.confirm_delete_action.trigger()
+
+ def accept(dialog):
+ assert not dialog.confirm_delete_cb.isChecked()
+ dialog.confirm_delete_cb.setChecked(True)
+ dialog.apply_to_settings()
+ return SettingsDialog.DialogCode.Accepted
+
+ monkeypatch.setattr(SettingsDialog, "exec", accept)
+ window._show_settings()
+ assert window.confirm_delete_action.isChecked()
+ assert settings.get("general", "confirm_delete") is True
+ assert json.loads(settings._config_path.read_text())["general"]["confirm_delete"] is True
+
+
+@pytest.mark.parametrize("invalid", [None, 0, "", "false", [], {}])
+def test_only_explicit_json_false_disables_confirmation(window, settings, tmp_path, monkeypatch, invalid):
+ settings._config_path.write_text(json.dumps({"general": {"confirm_delete": invalid}}))
+ settings._load_settings()
+ window._apply_settings()
+ assert window.confirm_delete_action.isChecked()
+ dialog = SettingsDialog(window)
+ assert dialog.confirm_delete_cb.isChecked()
+ target = tmp_path / "keep.txt"
+ target.write_text("keep")
+ question = Mock(return_value=QMessageBox.StandardButton.No)
+ monkeypatch.setattr(QMessageBox, "question", question)
+ assert not window.file_browser.delete_selection([str(target)])
+ question.assert_called_once()
+ assert target.exists()
+ dialog.deleteLater()
+
+
+def _context_delete(browser, target, monkeypatch):
+ for _ in range(100):
+ _app.processEvents()
+ index = browser.proxy.mapFromSource(browser.model.index(str(target)))
+ if index.isValid():
+ break
+ QTest.qWait(10)
+ assert index.isValid()
+ browser.table.selectRow(index.row())
+
+ class CapturingMenu(QMenu):
+ def exec(self, *args):
+ next(a for a in self.actions() if a.text() == "Löschen").trigger()
+
+ monkeypatch.setattr(browser_module, "QMenu", CapturingMenu)
+ browser._show_context_menu(browser.table.visualRect(index).center())
+
+
+@pytest.mark.parametrize("entry", ["direct", "keyboard", "context"])
+@pytest.mark.parametrize("confirm", [True, False])
+def test_all_entry_points_use_current_setting(window, settings, tmp_path, monkeypatch, entry, confirm):
+ target = tmp_path / "selected.txt"
+ target.write_text("delete")
+ browser = window.file_browser
+ browser.navigate_to(str(tmp_path))
+ if not confirm:
+ window.confirm_delete_action.trigger()
+ question = Mock(return_value=QMessageBox.StandardButton.Yes)
+ monkeypatch.setattr(QMessageBox, "question", question)
+ trash = Mock(side_effect=lambda path: Path(path).unlink())
+ monkeypatch.setattr(delete_service, "move_to_trash", trash)
+ if entry == "direct":
+ assert browser.delete_selection([str(target)])
+ elif entry == "keyboard":
+ monkeypatch.setattr(browser, "get_selected_files", lambda: [str(target)])
+ browser.table.keyPressEvent(
+ QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier)
+ )
+ else:
+ _context_delete(browser, target, monkeypatch)
+ assert not target.exists()
+ assert question.call_count == int(confirm)
+ assert trash.call_count == int(not confirm)
+ if confirm:
+ assert question.call_args.args[-1] == QMessageBox.StandardButton.No
+
+
+def test_reenabled_confirmation_cancellation_preserves_file(window, tmp_path, monkeypatch):
+ target = tmp_path / "keep.txt"
+ target.write_text("keep")
+ window.confirm_delete_action.trigger()
+ window.confirm_delete_action.trigger()
+ monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.StandardButton.No)
+ assert not window.file_browser.delete_selection([str(target)])
+ assert target.read_text() == "keep"
+
+
+def test_partial_failure_warns_and_continues(window, tmp_path, monkeypatch):
+ blocked = tmp_path / "blocked.txt"
+ blocked.write_text("keep")
+ good = tmp_path / "good.txt"
+ good.write_text("delete")
+ window.confirm_delete_action.trigger()
+ def trash(path):
+ if path == str(blocked):
+ raise PermissionError("blocked fixture")
+ Path(path).unlink()
+
+ monkeypatch.setattr(delete_service, "move_to_trash", trash)
+ warning = Mock()
+ monkeypatch.setattr(QMessageBox, "warning", warning)
+ assert not window.file_browser.delete_selection([str(blocked), str(good)])
+ assert blocked.read_text() == "keep"
+ assert not good.exists()
+ assert "blocked.txt" in warning.call_args.args[2]
+ warning.assert_called_once()
+
+
+def test_disabled_confirmation_uses_trash_and_never_permanent_delete(window, tmp_path, monkeypatch):
+ target = tmp_path / "recoverable.txt"
+ target.write_text("keep recoverable")
+ window.confirm_delete_action.trigger()
+ trash = Mock()
+ monkeypatch.setattr(delete_service, "move_to_trash", trash)
+ permanent_delete = Mock(side_effect=AssertionError("must not permanently delete"))
+ monkeypatch.setattr(delete_service, "delete_path", permanent_delete)
+ monkeypatch.setattr(QMessageBox, "question", Mock(side_effect=AssertionError("must not ask")))
+
+ assert window.file_browser.delete_selection([str(target)])
+ trash.assert_called_once_with(str(target))
+ permanent_delete.assert_not_called()
+ assert target.exists()
+
+
+def test_disabled_confirmation_preserves_item_when_trash_fails(window, tmp_path, monkeypatch):
+ target = tmp_path / "keep.txt"
+ target.write_text("keep")
+ window.confirm_delete_action.trigger()
+ monkeypatch.setattr(
+ delete_service, "move_to_trash", Mock(side_effect=OSError("Papierkorb nicht verfügbar"))
+ )
+ warning = Mock()
+ monkeypatch.setattr(QMessageBox, "warning", warning)
+
+ assert not window.file_browser.delete_selection([str(target)])
+ assert target.read_text() == "keep"
+ warning.assert_called_once()
diff --git a/tests/test_delete_service.py b/tests/test_delete_service.py
new file mode 100644
index 0000000..b750847
--- /dev/null
+++ b/tests/test_delete_service.py
@@ -0,0 +1,250 @@
+"""Deletion must handle Windows read-only files without following links."""
+
+import os
+import stat
+import subprocess
+import sys
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from core import delete_service
+
+
+def test_delete_regular_file_and_nested_folder(tmp_path):
+ folder = tmp_path / "selected"
+ (folder / "nested").mkdir(parents=True)
+ (folder / "nested" / "child.txt").write_text("child")
+ direct = tmp_path / "direct.txt"
+ direct.write_text("direct")
+ delete_service.delete_path(str(direct))
+ delete_service.delete_path(str(folder))
+ assert not direct.exists()
+ assert not folder.exists()
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows file attributes")
+@pytest.mark.parametrize("nested", [False, True])
+def test_native_readonly_file_deletion(tmp_path, nested):
+ folder = tmp_path / "selected"
+ folder.mkdir()
+ child = folder / "readonly.txt"
+ child.write_text("read-only fixture")
+ os.chmod(child, stat.S_IREAD)
+ try:
+ delete_service.delete_path(str(folder if nested else child))
+ assert not child.exists()
+ if nested:
+ assert not folder.exists()
+ finally:
+ if child.exists():
+ os.chmod(child, stat.S_IWRITE)
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows read-only hardlink attributes")
+@pytest.mark.parametrize("nested", [False, True])
+def test_readonly_hardlink_does_not_change_surviving_alias(tmp_path, nested):
+ outside = tmp_path / "outside.txt"
+ outside.write_text("unchanged bytes")
+ selected = tmp_path / "selected"
+ selected.mkdir()
+ alias = selected / "alias.txt"
+ os.link(outside, alias)
+ os.chmod(alias, stat.S_IREAD)
+ original_attributes = outside.stat().st_file_attributes
+ try:
+ with pytest.raises(PermissionError) as caught:
+ delete_service.delete_path(str(selected if nested else alias))
+ assert caught.value.winerror == 5
+ assert alias.exists() and outside.exists()
+ assert outside.read_text() == "unchanged bytes"
+ assert alias.read_text() == "unchanged bytes"
+ assert outside.stat().st_file_attributes == original_attributes
+ assert alias.stat().st_file_attributes == original_attributes
+ finally:
+ os.chmod(outside, stat.S_IWRITE)
+
+
+def test_writable_hardlink_deletes_selected_name_only(tmp_path):
+ outside = tmp_path / "outside.txt"
+ outside.write_text("unchanged bytes")
+ alias = tmp_path / "selected.txt"
+ os.link(outside, alias)
+ delete_service.delete_path(str(alias))
+ assert not alias.exists()
+ assert outside.read_text() == "unchanged bytes"
+
+
+def _windows_error(code):
+ error = PermissionError(13, "access denied")
+ error.winerror = code
+ return error
+
+
+@pytest.mark.parametrize("code", [5, 32])
+def test_permissions_and_sharing_failures_do_not_chmod_normal_files(tmp_path, monkeypatch, code):
+ path = tmp_path / "normal.txt"
+ path.write_text("keep")
+ error = _windows_error(code)
+ remove = Mock(side_effect=error)
+ chmod = Mock()
+ with monkeypatch.context() as patch:
+ patch.setattr(delete_service.os, "remove", remove)
+ patch.setattr(delete_service.os, "chmod", chmod)
+ with pytest.raises(PermissionError) as caught:
+ delete_service.delete_path(str(path))
+ assert caught.value is error
+ remove.assert_called_once_with(str(path))
+ chmod.assert_not_called()
+ assert path.read_text() == "keep"
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows file attributes")
+def test_failed_readonly_retry_restores_attribute_and_propagates_error(tmp_path, monkeypatch):
+ path = tmp_path / "readonly.txt"
+ path.write_text("keep")
+ os.chmod(path, stat.S_IREAD)
+ native_remove = os.remove
+ calls = []
+ retry_error = _windows_error(32)
+
+ def remove(name):
+ calls.append(name)
+ if len(calls) == 1:
+ native_remove(name) # Real WinError 5 from the read-only attribute.
+ raise retry_error
+
+ try:
+ with monkeypatch.context() as patch:
+ patch.setattr(delete_service.os, "remove", remove)
+ with pytest.raises(PermissionError) as caught:
+ delete_service.delete_path(str(path))
+ assert caught.value is retry_error
+ assert len(calls) == 2
+ assert path.stat().st_file_attributes & stat.FILE_ATTRIBUTE_READONLY
+ assert path.read_text() == "keep"
+ finally:
+ os.chmod(path, stat.S_IWRITE)
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows file attributes")
+def test_sharing_failure_does_not_clear_readonly_attribute(tmp_path, monkeypatch):
+ path = tmp_path / "readonly.txt"
+ path.write_text("keep")
+ os.chmod(path, stat.S_IREAD)
+ try:
+ error = _windows_error(32)
+ with monkeypatch.context() as patch:
+ patch.setattr(delete_service.os, "remove", Mock(side_effect=error))
+ chmod = Mock()
+ patch.setattr(delete_service.os, "chmod", chmod)
+ with pytest.raises(PermissionError) as caught:
+ delete_service.delete_path(str(path))
+ assert caught.value is error
+ chmod.assert_not_called()
+ assert path.stat().st_file_attributes & stat.FILE_ATTRIBUTE_READONLY
+ finally:
+ os.chmod(path, stat.S_IWRITE)
+
+
+def test_directory_error_does_not_retry_or_chmod(tmp_path, monkeypatch):
+ error = _windows_error(5)
+ chmod = Mock()
+ monkeypatch.setattr(delete_service.os, "chmod", chmod)
+ with pytest.raises(PermissionError) as caught:
+ delete_service._rmtree_error(os.rmdir, str(tmp_path), (PermissionError, error, None))
+ assert caught.value is error
+ chmod.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ ("version", "callback_name"),
+ [((3, 10), "onerror"), ((3, 11), "onerror"), ((3, 12), "onexc")],
+)
+def test_remove_tree_selects_python_compatible_callback(monkeypatch, version, callback_name):
+ rmtree = Mock()
+ monkeypatch.setattr(delete_service.shutil, "rmtree", rmtree)
+ monkeypatch.setattr(delete_service, "sys", SimpleNamespace(version_info=version))
+
+ delete_service._remove_tree("folder")
+
+ assert rmtree.call_count == 1
+ assert rmtree.call_args.args == ("folder",)
+ assert callback_name in rmtree.call_args.kwargs
+
+
+def test_move_to_trash_uses_qt_trash_api(monkeypatch, tmp_path):
+ from PySide6.QtCore import QFile
+
+ target = tmp_path / "recoverable.txt"
+ target.write_text("keep recoverable")
+ move = Mock(return_value=True)
+ monkeypatch.setattr(QFile, "moveToTrash", move)
+
+ delete_service.move_to_trash(str(target))
+
+ move.assert_called_once_with(str(target))
+ assert target.read_text() == "keep recoverable"
+
+
+def test_move_to_trash_failure_does_not_fall_back_to_permanent_delete(monkeypatch, tmp_path):
+ from PySide6.QtCore import QFile
+
+ target = tmp_path / "keep.txt"
+ target.write_text("keep")
+ monkeypatch.setattr(QFile, "moveToTrash", Mock(return_value=False))
+ permanent_delete = Mock(side_effect=AssertionError("must not permanently delete"))
+ monkeypatch.setattr(delete_service.os, "remove", permanent_delete)
+
+ with pytest.raises(OSError, match="Papierkorb"):
+ delete_service.move_to_trash(str(target))
+
+ permanent_delete.assert_not_called()
+ assert target.read_text() == "keep"
+
+
+@pytest.mark.parametrize("kind", ["file", "directory", "broken"])
+@pytest.mark.parametrize("nested", [False, True])
+def test_symlink_deletes_only_link(tmp_path, kind, nested):
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ sentinel = outside / "keep.txt"
+ sentinel.write_text("keep")
+ target = outside if kind == "directory" else sentinel
+ if kind == "broken":
+ target = outside / "missing"
+ selected = tmp_path / "selected"
+ selected.mkdir()
+ link = selected / "link"
+ try:
+ link.symlink_to(target, target_is_directory=kind == "directory")
+ except OSError as exc:
+ pytest.skip(f"Symlinks unavailable: {exc}")
+ delete_service.delete_path(str(selected if nested else link))
+ assert not os.path.lexists(link)
+ assert sentinel.read_text() == "keep"
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="Windows junction")
+@pytest.mark.parametrize("nested", [False, True])
+def test_junction_deletes_only_link(tmp_path, nested):
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ sentinel = outside / "keep.txt"
+ sentinel.write_text("keep")
+ selected = tmp_path / "selected"
+ selected.mkdir()
+ junction = selected / "junction"
+ result = subprocess.run(
+ ["cmd", "/c", "mklink", "/J", str(junction), str(outside)],
+ capture_output=True, text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ try:
+ delete_service.delete_path(str(selected if nested else junction))
+ assert not os.path.lexists(junction)
+ assert sentinel.read_text() == "keep"
+ finally:
+ if os.path.lexists(junction):
+ os.rmdir(junction)
diff --git a/tests/test_drive_capacity.py b/tests/test_drive_capacity.py
new file mode 100644
index 0000000..ac9dbc7
--- /dev/null
+++ b/tests/test_drive_capacity.py
@@ -0,0 +1,249 @@
+import os
+import threading
+import time
+from concurrent.futures import Future
+from types import SimpleNamespace
+
+os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
+
+import pytest
+from PySide6.QtCore import QDir, Qt
+from PySide6.QtWidgets import QApplication
+from PySide6.QtTest import QTest
+
+from core.drive_usage import DriveUsage, format_capacity, read_drive_usage
+from gui.sidebar.drive_capacity import DriveCapacityWidget
+from gui.sidebar.sidebar_main import TreePanel
+from translator import get_translator
+
+app = QApplication.instance() or QApplication([])
+
+
+def wait_until(predicate):
+ deadline = time.monotonic() + 4
+ while not predicate():
+ assert time.monotonic() < deadline, 'Background result did not arrive'
+ app.processEvents()
+ QTest.qWait(5)
+
+
+@pytest.fixture(autouse=True)
+def german():
+ tr = get_translator()
+ old = tr.get_language()
+ tr.set_language('de')
+ yield
+ tr.set_language(old)
+
+
+@pytest.mark.parametrize('used,free,percent', [(0, 1000, 0), (1, 999, .1), (500, 500, 50), (1000, 0, 100)])
+def test_usage_values_and_bar(used, free, percent):
+ widget = DriveCapacityWidget('X:/')
+ widget.set_usage(DriveUsage(1000, used, free))
+ assert widget.bar.value() == round(percent * 10)
+ assert widget.bar.format() == f'{percent:.1f}% belegt'
+ assert f'{free} B frei' in widget.details.text()
+ assert f'{used} B belegt' in widget.details.text()
+ assert '1000 B gesamt' in widget.details.text()
+ assert not widget.bar.isHidden()
+ assert widget.bar.format() in widget.accessibleDescription()
+
+
+@pytest.mark.parametrize('values', [(0, 0, 0), (100, -1, 20), (100, 101, 0), (100, 30, 101)])
+def test_invalid_capacity_not_shown_as_real_data(values):
+ with pytest.raises(ValueError):
+ DriveUsage(*values)
+
+
+def test_unavailable_discards_previous_measurement():
+ widget = DriveCapacityWidget('X:/')
+ widget.set_usage(DriveUsage(100, 80, 20))
+ widget.set_usage(None)
+ assert widget.details.text() == 'Speicherbelegung nicht verfügbar'
+ assert widget.bar.isHidden()
+ assert '80' not in widget.accessibleDescription()
+
+
+def test_real_operating_system_capacity(tmp_path):
+ import shutil
+ expected = shutil.disk_usage(tmp_path)
+ actual = read_drive_usage(str(tmp_path))
+ assert actual.total == expected.total
+ # Free space can change between OS calls; compare accounting and valid range.
+ assert actual.used + actual.free == actual.total if os.name == 'nt' else actual.used + actual.free <= actual.total
+ assert 0 <= actual.used_percent <= 100
+
+
+def test_binary_units():
+ assert format_capacity(1024) == '1.0 KiB'
+ assert format_capacity(1024 ** 3) == '1.0 GiB'
+
+
+def make_panel(monkeypatch, read):
+ monkeypatch.setattr(QDir, 'drives', lambda: [SimpleNamespace(absolutePath=lambda: 'X:/')])
+ monkeypatch.setattr('gui.sidebar.sidebar_main.read_drive_usage', read)
+ return TreePanel()
+
+
+def test_cancelled_drive_query_is_reported_as_unavailable(monkeypatch):
+ panel = make_panel(monkeypatch, lambda _: DriveUsage(100, 20, 80))
+ try:
+ wait_until(lambda: not panel._usage_requests)
+ cancelled = Future()
+ cancelled.cancel()
+ panel._usage_requests['X:/'] = cancelled
+
+ panel._collect_drive_usage()
+
+ assert not panel._usage_requests
+ assert panel._drive_rows['X:/'][1].details.text() == 'Speicherbelegung nicht verfügbar'
+ finally:
+ panel.close()
+
+
+def test_slow_query_keeps_gui_responsive_and_deduplicates(monkeypatch):
+ entered, release = threading.Event(), threading.Event()
+ threads = []
+ main_thread = threading.get_ident()
+
+ def read(path):
+ threads.append(threading.get_ident())
+ entered.set()
+ assert release.wait(4)
+ return DriveUsage(1000, 250, 750)
+
+ panel = make_panel(monkeypatch, read)
+ try:
+ wait_until(entered.is_set)
+ for _ in range(5):
+ panel.refresh_drive_usage()
+ app.processEvents()
+ assert len(threads) == 1
+ assert threads[0] != main_thread
+ assert panel._drive_rows['X:/'][1].bar.isHidden()
+ release.set()
+ wait_until(lambda: not panel._usage_requests)
+ assert panel._drive_rows['X:/'][1].bar.value() == 250
+ panel.refresh_drives_button.click()
+ wait_until(lambda: not panel._usage_requests)
+ assert len(threads) == 2
+ finally:
+ release.set()
+ for future in panel._usage_requests.values():
+ future.result(timeout=4)
+ panel.close()
+
+
+def test_failed_query_and_recovery(monkeypatch):
+ def unavailable(path):
+ raise OSError('Device not ready')
+
+ panel = make_panel(monkeypatch, unavailable)
+ wait_until(lambda: not panel._usage_requests)
+ item, widget = panel._drive_rows['X:/']
+ assert item.text(0) == '' # embedded title must not overlap tree text
+ assert item.data(0, Qt.ItemDataRole.AccessibleTextRole) == 'X:/'
+ assert 'nicht verfügbar' in widget.details.text()
+ monkeypatch.setattr('gui.sidebar.sidebar_main.read_drive_usage', lambda path: DriveUsage(100, 100, 0))
+ panel.refresh_drive_usage()
+ wait_until(lambda: not panel._usage_requests)
+ assert widget.bar.value() == 1000
+ assert item.data(0, Qt.ItemDataRole.UserRole) == 'X:/'
+ selected = []
+ panel.folder_selected.connect(selected.append)
+ panel._on_item_clicked(item, 0)
+ assert selected == ['X:/']
+ panel.close()
+
+
+def test_panel_destroyed_while_query_is_running(monkeypatch):
+ from PySide6.QtCore import QCoreApplication, QEvent, QObject
+ from shiboken6 import isValid
+ entered, release = threading.Event(), threading.Event()
+
+ def read(path):
+ entered.set()
+ assert release.wait(4)
+ return DriveUsage(100, 20, 80)
+
+ panel = make_panel(monkeypatch, read)
+ futures = list(panel._usage_requests.values())
+ unrelated = None
+ try:
+ wait_until(entered.is_set)
+ unrelated = QObject()
+ unrelated.deleteLater()
+ panel.deleteLater()
+ QCoreApplication.sendPostedEvents(panel, QEvent.Type.DeferredDelete)
+ assert isValid(unrelated), 'Panel cleanup must not delete unrelated Qt objects'
+ finally:
+ release.set()
+ for future in futures:
+ future.result(timeout=4)
+ if unrelated is not None and isValid(unrelated):
+ QCoreApplication.sendPostedEvents(unrelated, QEvent.Type.DeferredDelete)
+ app.processEvents()
+
+
+def test_capacity_text_placeholders_in_every_language():
+ tr = get_translator()
+ for lang in tr.get_supported_languages():
+ tr.set_language(lang)
+ widget = DriveCapacityWidget('X:/')
+ widget.set_usage(DriveUsage(1000, 500, 500))
+ assert '500 B' in widget.details.text() and '1000 B' in widget.details.text()
+ assert '{' not in widget.details.text()
+ assert '50.0' in widget.bar.format()
+
+
+def test_narrow_sidebar_preserves_wrapped_capacity_text(monkeypatch):
+ panel = make_panel(monkeypatch, lambda path: DriveUsage(1024 ** 4, 1024 ** 3, 1024 ** 4 - 1024 ** 3))
+ panel.resize(200, 600)
+ panel.show()
+ wait_until(lambda: not panel._usage_requests)
+ app.processEvents()
+ item, widget = panel._drive_rows['X:/']
+ required = widget.layout().totalHeightForWidth(widget.width())
+ assert item.sizeHint(0).height() >= required
+ assert widget.height() >= required
+ panel.close()
+
+
+def test_pending_capacity_job_finishes_before_interpreter_exit():
+ import subprocess
+ import sys
+ from pathlib import Path
+ root = Path(__file__).resolve().parents[1]
+ script = '''
+import sys, time
+sys.path.insert(0, 'src')
+from PySide6.QtWidgets import QApplication
+from core.drive_usage import DriveUsage
+from gui.sidebar import drive_capacity
+app = QApplication([])
+def slow_read(path):
+ time.sleep(.2)
+ print('capacity-job-finished', flush=True)
+ return DriveUsage(100, 20, 80)
+drive_capacity.capacity_executor().submit(slow_read, 'X:/')
+'''
+ result = subprocess.run([sys.executable, '-c', script], cwd=root, capture_output=True, text=True, timeout=20)
+ assert result.returncode == 0, result.stderr
+ assert 'capacity-job-finished' in result.stdout
+
+
+def test_destroyed_panel_cancels_queued_layout_refresh(monkeypatch, capsys):
+ from PySide6.QtCore import QCoreApplication, QEvent
+ from shiboken6 import isValid
+ panel = make_panel(monkeypatch, lambda path: DriveUsage(100, 20, 80))
+ wait_until(lambda: not panel._usage_requests)
+ panel.resize(200, 600)
+ panel.show()
+ timer = panel._resize_timer
+ timer.start(0)
+ assert timer.isActive()
+ panel.deleteLater()
+ QCoreApplication.sendPostedEvents(panel, QEvent.Type.DeferredDelete)
+ assert not isValid(timer)
+ app.processEvents()
+ assert 'RuntimeError' not in capsys.readouterr().err
diff --git a/tests/test_drive_query_process.py b/tests/test_drive_query_process.py
new file mode 100644
index 0000000..86b8d2a
--- /dev/null
+++ b/tests/test_drive_query_process.py
@@ -0,0 +1,100 @@
+"""Exercise real helper termination rather than substituting completed futures."""
+
+import subprocess
+import sys
+import time
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import pytest
+
+from core import drive_usage
+from gui.sidebar import drive_capacity
+
+
+@pytest.fixture(autouse=True)
+def query_lifecycle():
+ drive_usage.start_drive_queries()
+ yield
+ drive_capacity.shutdown_capacity_executor()
+ drive_usage.stop_drive_queries()
+
+
+def sleeper_command(path, result_path):
+ return [sys.executable, '-c', 'import time; time.sleep(60)']
+
+
+def test_real_helper_matches_os_capacity(tmp_path):
+ expected = drive_usage.read_drive_usage(str(tmp_path))
+ actual = drive_usage.read_drive_usage_bounded(str(tmp_path))
+ assert actual.total == expected.total
+ assert 0 <= actual.used_percent <= 100
+ assert not drive_usage._query_processes
+
+
+def test_timed_out_helper_is_reaped(monkeypatch):
+ monkeypatch.setattr(drive_usage, '_query_command', sleeper_command)
+ monkeypatch.setattr(drive_usage, 'QUERY_TIMEOUT_SECONDS', .2)
+ processes = []
+ real_popen = subprocess.Popen
+
+ def record(*args, **kwargs):
+ process = real_popen(*args, **kwargs)
+ processes.append(process)
+ return process
+
+ monkeypatch.setattr(drive_usage.subprocess, 'Popen', record)
+ with pytest.raises(OSError, match='timed out'):
+ drive_usage.read_drive_usage_bounded('blocked')
+ assert processes[0].poll() is not None
+ assert not drive_usage._query_processes
+
+
+def test_four_stalled_reads_release_slot_for_healthy_fifth(monkeypatch, tmp_path):
+ real_command = drive_usage._query_command
+ monkeypatch.setattr(drive_usage, '_query_command',
+ lambda path, result: sleeper_command(path, result) if path == 'blocked' else real_command(path, result))
+ monkeypatch.setattr(drive_usage, 'QUERY_TIMEOUT_SECONDS', 1)
+ with ThreadPoolExecutor(max_workers=4) as executor:
+ stalled = [executor.submit(drive_usage.read_drive_usage_bounded, 'blocked') for _ in range(4)]
+ healthy = executor.submit(drive_usage.read_drive_usage_bounded, str(tmp_path))
+ assert healthy.result(timeout=6).total > 0
+ for future in stalled:
+ with pytest.raises(OSError, match='timed out'):
+ future.result()
+ assert not drive_usage._query_processes
+
+
+def test_shutdown_kills_running_helpers_and_cancels_queued(monkeypatch):
+ monkeypatch.setattr(drive_usage, '_query_command', sleeper_command)
+ monkeypatch.setattr(drive_usage, 'QUERY_TIMEOUT_SECONDS', 60)
+ executor = drive_capacity.capacity_executor()
+ futures = [executor.submit(drive_usage.read_drive_usage_bounded, 'blocked') for _ in range(8)]
+ deadline = time.monotonic() + 5
+ while len(drive_usage._query_processes) != 4:
+ assert time.monotonic() < deadline
+ time.sleep(.01)
+ processes = tuple(drive_usage._query_processes)
+ started = time.monotonic()
+ drive_capacity.shutdown_capacity_executor()
+ assert time.monotonic() - started < 3
+ assert all(process.poll() is not None for process in processes)
+ assert all(future.done() for future in futures)
+ assert not drive_usage._query_processes
+
+
+def test_packaged_entrypoint_dispatches_before_qt(tmp_path):
+ root = Path(__file__).resolve().parents[1]
+ output = tmp_path / 'result.json'
+ result = subprocess.run([sys.executable, str(root / 'src/main.py'),
+ '--drive-capacity-query', str(tmp_path), str(output)],
+ capture_output=True, text=True, timeout=10)
+ assert result.returncode == 0, result.stderr
+ assert '"total"' in output.read_text(encoding='utf-8')
+
+
+def test_helper_works_without_stdout(monkeypatch, tmp_path):
+ monkeypatch.setattr(sys, 'stdout', None)
+ output = tmp_path / 'result.json'
+ assert drive_usage.capacity_query_main(str(tmp_path), str(output)) == 0
+ assert '"total"' in output.read_text(encoding='utf-8')
diff --git a/tests/test_file_browser.py b/tests/test_file_browser.py
index 2be2b0c..3da0658 100644
--- a/tests/test_file_browser.py
+++ b/tests/test_file_browser.py
@@ -32,6 +32,19 @@ def test_dnd_flags_enabled():
assert browser.table.dragEnabled(), "table.dragEnabled() muss True sein"
+def test_browser_destruction_disposes_its_models():
+ from PySide6.QtCore import QCoreApplication, QEvent
+ from shiboken6 import isValid
+ _ensure_app()
+ browser = FileBrowser()
+ model, proxy = browser.model, browser.proxy
+ browser.deleteLater()
+ QCoreApplication.sendPostedEvents(browser, QEvent.Type.DeferredDelete)
+ assert not isValid(browser)
+ assert not isValid(model), 'Filesystem gatherer must not outlive its browser'
+ assert not isValid(proxy), 'Sort model must not outlive its browser'
+
+
def test_do_file_drop_copies_file(tmp_path):
"""_do_file_drop kopiert eine Quelldatei ohne das Original zu löschen."""
_ensure_app()
diff --git a/tests/test_gui_gc.py b/tests/test_gui_gc.py
index e121632..eb0e946 100644
--- a/tests/test_gui_gc.py
+++ b/tests/test_gui_gc.py
@@ -208,3 +208,34 @@ def fail_setup(app, language):
)
assert result.returncode == 0, result.stderr
assert 'main-setup-close-restored' in result.stdout
+
+
+def test_capacity_shutdown_drains_workers_before_restoring_gc():
+ script = '''
+import gc, sys, threading, time
+sys.path.insert(0, 'src')
+from PySide6.QtWidgets import QApplication
+from core.gui_gc import install_gui_gc
+from gui.sidebar.drive_capacity import capacity_executor, shutdown_capacity_executor
+app = QApplication([])
+collector = install_gui_gc(app)
+entered = threading.Event()
+def worker():
+ entered.set()
+ time.sleep(.1)
+ assert not gc.isenabled()
+ return 'finished'
+future = capacity_executor().submit(worker)
+assert entered.wait(5)
+shutdown_capacity_executor()
+assert future.done() and future.result() == 'finished'
+collector.close()
+assert gc.isenabled()
+print('shutdown-ok')
+'''
+ result = subprocess.run(
+ [sys.executable, "-c", script], cwd=Path(__file__).resolve().parents[1],
+ capture_output=True, text=True, timeout=25,
+ )
+ assert result.returncode == 0, result.stderr
+ assert "shutdown-ok" in result.stdout
diff --git a/tests/test_store_regressions_20260928.py b/tests/test_store_regressions_20260928.py
index a7fe322..39b834e 100644
--- a/tests/test_store_regressions_20260928.py
+++ b/tests/test_store_regressions_20260928.py
@@ -11,6 +11,7 @@
from PySide6.QtCore import QItemSelectionModel
from PySide6.QtGui import QContextMenuEvent
from PySide6.QtWidgets import QApplication, QInputDialog, QMenu
+from PySide6.QtTest import QTest
import gui.browser.file_browser as browser_module
from gui.browser.file_browser import FileBrowser
@@ -102,11 +103,11 @@ def test_navigation_loads_files_and_selection_reaches_preview(tmp_path):
browser.resize(800, 500)
browser.show()
browser.navigate_to(str(tmp_path))
- for _ in range(100):
- _app().processEvents()
- if browser.proxy.rowCount(browser.table.rootIndex()) > 0:
- break
- time.sleep(.01)
+ # QFileSystemModel gathers rows asynchronously. Pump the actual Qt event
+ # loop with a bounded deadline instead of assuming a one-second OS scan.
+ deadline = time.monotonic() + 5
+ while browser.proxy.rowCount(browser.table.rootIndex()) == 0 and time.monotonic() < deadline:
+ QTest.qWait(10)
root = browser.proxy.mapToSource(browser.table.rootIndex())
assert Path(browser.model.filePath(root)) == tmp_path
assert browser.proxy.rowCount(browser.table.rootIndex()) > 0