From 00a600ad7d5072e47fa1d7737c5d95f842daead8 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 00:06:05 +0200 Subject: [PATCH 01/13] feat(sidebar): visualize free and used drive capacity --- CHANGELOG.md | 7 + README.md | 2 + README_de.md | 2 + locales/translations.json | 50 +++++++- src/core/drive_usage.py | 54 ++++++++ src/gui/sidebar/drive_capacity.py | 86 +++++++++++++ src/gui/sidebar/sidebar_main.py | 60 ++++++++- tests/test_drive_capacity.py | 206 ++++++++++++++++++++++++++++++ 8 files changed, 463 insertions(+), 4 deletions(-) create mode 100644 src/core/drive_usage.py create mode 100644 src/gui/sidebar/drive_capacity.py create mode 100644 tests/test_drive_capacity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b5d68..ecacca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### 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. +- 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..5885ab7 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. 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) [![CI](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml/badge.svg)](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml) diff --git a/locales/translations.json b/locales/translations.json index 64f25eb..c01db09 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -1862,5 +1862,53 @@ "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": "Обновить использование дисков" } -} \ No newline at end of file +} diff --git a/src/core/drive_usage.py b/src/core/drive_usage.py new file mode 100644 index 0000000..273de22 --- /dev/null +++ b/src/core/drive_usage.py @@ -0,0 +1,54 @@ +"""Read capacity without scanning files or changing the filesystem.""" + +from dataclasses import dataclass +import os +import shutil + + +@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 diff --git a/src/gui/sidebar/drive_capacity.py b/src/gui/sidebar/drive_capacity.py new file mode 100644 index 0000000..215a9b3 --- /dev/null +++ b/src/gui/sidebar/drive_capacity.py @@ -0,0 +1,86 @@ +"""Capacity widgets and independent background requests for drive rows.""" + +import atexit + +from PySide6.QtCore import QObject, QRunnable, Qt, Signal, QThreadPool +from PySide6.QtWidgets import QLabel, QProgressBar, QVBoxLayout, QWidget + +from core.drive_usage import format_capacity, read_drive_usage +from translator import t + +_shutdown_registered = False + + +def capacity_pool(): + """Finish Python runnables before interpreter/Qt teardown destroys them.""" + global _shutdown_registered + pool = QThreadPool.globalInstance() + if not _shutdown_registered: + atexit.register(pool.waitForDone) + _shutdown_registered = True + return pool + + +class UsageSignals(QObject): + ready = Signal(str, object) + + +class UsageRequest(QRunnable): + def __init__(self, path): + super().__init__() + self.path = path + self.signals = UsageSignals() + + def run(self): + try: + usage = read_drive_usage(self.path) + except (OSError, ValueError): + usage = None + self.signals.ready.emit(self.path, usage) + + +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..8d58ca3 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -10,7 +10,7 @@ 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 +19,8 @@ from modules.launcher import AppsPanel from modules.prompts import PromptsPanel from modules.sync import SyncPanel +from gui.sidebar.drive_capacity import DriveCapacityWidget, UsageRequest, capacity_pool +from translator import t class TreePanel(QWidget): @@ -28,8 +30,11 @@ class TreePanel(QWidget): def __init__(self, parent=None): super().__init__(parent) + self._drive_rows = {} + self._usage_requests = {} self._setup_ui() self._populate() + self.refresh_drive_usage() def _setup_ui(self): layout = QVBoxLayout(self) @@ -48,6 +53,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 +85,64 @@ 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()) + request = UsageRequest(path) + request.signals.ready.connect(self._on_drive_usage, Qt.ConnectionType.QueuedConnection) + self._usage_requests[path] = request + capacity_pool().start(request) + + @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) + QTimer.singleShot(0, self._resize_drive_rows) + + 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/tests/test_drive_capacity.py b/tests/test_drive_capacity.py new file mode 100644 index 0000000..2beddaf --- /dev/null +++ b/tests/test_drive_capacity.py @@ -0,0 +1,206 @@ +import os +import threading +import time +from types import SimpleNamespace + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + +import pytest +from PySide6.QtCore import QDir, QThreadPool, 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.drive_capacity.read_drive_usage', read) + return TreePanel() + + +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() + QThreadPool.globalInstance().waitForDone(4000) + 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.drive_capacity.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 + 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) + try: + wait_until(entered.is_set) + panel.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + finally: + release.set() + assert QThreadPool.globalInstance().waitForDone(4000) + 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.read_drive_usage = slow_read +drive_capacity.capacity_pool().start(drive_capacity.UsageRequest('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 From 6ab63eb7a0b7ee835c23132da76ead46719eacc9 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 00:14:09 +0200 Subject: [PATCH 02/13] fix(sidebar): bind queued row resizing to panel lifetime --- src/gui/sidebar/sidebar_main.py | 6 +++++- tests/test_drive_capacity.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index 8d58ca3..5a74695 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -32,6 +32,9 @@ 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._setup_ui() self._populate() self.refresh_drive_usage() @@ -134,8 +137,9 @@ def showEvent(self, event): def resizeEvent(self, event): super().resizeEvent(event) - QTimer.singleShot(0, self._resize_drive_rows) + 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(): diff --git a/tests/test_drive_capacity.py b/tests/test_drive_capacity.py index 2beddaf..d7bbc0f 100644 --- a/tests/test_drive_capacity.py +++ b/tests/test_drive_capacity.py @@ -204,3 +204,20 @@ def slow_read(path): 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(None, QEvent.Type.DeferredDelete) + assert not isValid(timer) + app.processEvents() + assert 'RuntimeError' not in capsys.readouterr().err From 07c1ca06dd50b413ac1b344646172a8b7a9ade73 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 00:56:10 +0200 Subject: [PATCH 03/13] fix(gui): isolate capacity workers and own browser model lifetimes --- CHANGELOG.md | 2 ++ src/gui/browser/file_browser.py | 4 +-- src/gui/sidebar/drive_capacity.py | 42 ++++++++---------------- src/gui/sidebar/sidebar_main.py | 26 ++++++++++++--- tests/test_drive_capacity.py | 29 ++++++++++------ tests/test_file_browser.py | 13 ++++++++ tests/test_store_regressions_20260928.py | 11 ++++--- 7 files changed, 76 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecacca7..1070ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - 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. - Source feature only: no EXE/MSIX release or Store submission performed. ### Repository-Lebenszyklus-Härtung & CI/CD-Parität (2026-09-30, Pfad A) diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index ca45c1f..1907fef 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -115,7 +115,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 @@ -123,7 +123,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) diff --git a/src/gui/sidebar/drive_capacity.py b/src/gui/sidebar/drive_capacity.py index 215a9b3..e473639 100644 --- a/src/gui/sidebar/drive_capacity.py +++ b/src/gui/sidebar/drive_capacity.py @@ -1,42 +1,26 @@ """Capacity widgets and independent background requests for drive rows.""" -import atexit +from concurrent.futures import ThreadPoolExecutor -from PySide6.QtCore import QObject, QRunnable, Qt, Signal, QThreadPool +from PySide6.QtCore import Qt from PySide6.QtWidgets import QLabel, QProgressBar, QVBoxLayout, QWidget -from core.drive_usage import format_capacity, read_drive_usage +from core.drive_usage import format_capacity from translator import t -_shutdown_registered = False +_executor = None -def capacity_pool(): - """Finish Python runnables before interpreter/Qt teardown destroys them.""" - global _shutdown_registered - pool = QThreadPool.globalInstance() - if not _shutdown_registered: - atexit.register(pool.waitForDone) - _shutdown_registered = True - return pool +def capacity_executor(): + """Workers only return Python data; no Qt objects cross thread boundaries. - -class UsageSignals(QObject): - ready = Signal(str, object) - - -class UsageRequest(QRunnable): - def __init__(self, path): - super().__init__() - self.path = path - self.signals = UsageSignals() - - def run(self): - try: - usage = read_drive_usage(self.path) - except (OSError, ValueError): - usage = None - self.signals.ready.emit(self.path, usage) + The standard executor joins workers before interpreter teardown, even when + the view has already been deleted and its polling timer has stopped. + """ + global _executor + if _executor is None: + _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="drive-capacity") + return _executor class DriveCapacityWidget(QWidget): diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index 5a74695..9505172 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -19,7 +19,8 @@ from modules.launcher import AppsPanel from modules.prompts import PromptsPanel from modules.sync import SyncPanel -from gui.sidebar.drive_capacity import DriveCapacityWidget, UsageRequest, capacity_pool +from core.drive_usage import read_drive_usage +from gui.sidebar.drive_capacity import DriveCapacityWidget, capacity_executor from translator import t @@ -35,6 +36,9 @@ def __init__(self, parent=None): 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() @@ -114,10 +118,22 @@ def refresh_drive_usage(self): continue capacity.set_loading() item.setSizeHint(0, capacity.sizeHint()) - request = UsageRequest(path) - request.signals.ready.connect(self._on_drive_usage, Qt.ConnectionType.QueuedConnection) - self._usage_requests[path] = request - capacity_pool().start(request) + 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 (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): diff --git a/tests/test_drive_capacity.py b/tests/test_drive_capacity.py index d7bbc0f..01eb733 100644 --- a/tests/test_drive_capacity.py +++ b/tests/test_drive_capacity.py @@ -6,7 +6,7 @@ os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') import pytest -from PySide6.QtCore import QDir, QThreadPool, Qt +from PySide6.QtCore import QDir, Qt from PySide6.QtWidgets import QApplication from PySide6.QtTest import QTest @@ -80,7 +80,7 @@ def test_binary_units(): def make_panel(monkeypatch, read): monkeypatch.setattr(QDir, 'drives', lambda: [SimpleNamespace(absolutePath=lambda: 'X:/')]) - monkeypatch.setattr('gui.sidebar.drive_capacity.read_drive_usage', read) + monkeypatch.setattr('gui.sidebar.sidebar_main.read_drive_usage', read) return TreePanel() @@ -112,7 +112,8 @@ def read(path): assert len(threads) == 2 finally: release.set() - QThreadPool.globalInstance().waitForDone(4000) + for future in panel._usage_requests.values(): + future.result(timeout=4) panel.close() @@ -126,7 +127,7 @@ def unavailable(path): 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.drive_capacity.read_drive_usage', lambda path: DriveUsage(100, 100, 0)) + 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 @@ -139,7 +140,8 @@ def unavailable(path): def test_panel_destroyed_while_query_is_running(monkeypatch): - from PySide6.QtCore import QCoreApplication, QEvent + from PySide6.QtCore import QCoreApplication, QEvent, QObject + from shiboken6 import isValid entered, release = threading.Event(), threading.Event() def read(path): @@ -148,13 +150,21 @@ def read(path): 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(None, QEvent.Type.DeferredDelete) + QCoreApplication.sendPostedEvents(panel, QEvent.Type.DeferredDelete) + assert isValid(unrelated), 'Panel cleanup must not delete unrelated Qt objects' finally: release.set() - assert QThreadPool.globalInstance().waitForDone(4000) + for future in futures: + future.result(timeout=4) + if unrelated is not None and isValid(unrelated): + QCoreApplication.sendPostedEvents(unrelated, QEvent.Type.DeferredDelete) app.processEvents() @@ -198,8 +208,7 @@ def slow_read(path): time.sleep(.2) print('capacity-job-finished', flush=True) return DriveUsage(100, 20, 80) -drive_capacity.read_drive_usage = slow_read -drive_capacity.capacity_pool().start(drive_capacity.UsageRequest('X:/')) +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 @@ -217,7 +226,7 @@ def test_destroyed_panel_cancels_queued_layout_refresh(monkeypatch, capsys): timer.start(0) assert timer.isActive() panel.deleteLater() - QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + QCoreApplication.sendPostedEvents(panel, QEvent.Type.DeferredDelete) assert not isValid(timer) app.processEvents() assert 'RuntimeError' not in capsys.readouterr().err 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_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 From 4b857707c36d9a18cb4eb62eeb6f9619cb38bbcd Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 01:20:59 +0200 Subject: [PATCH 04/13] fix(gui): collect Qt reference cycles on application thread --- CHANGELOG.md | 1 + src/core/gui_gc.py | 73 +++++++++++++++++++++ src/gui/sidebar/drive_capacity.py | 8 +++ src/main.py | 19 ++++-- tests/conftest.py | 29 +++++++++ tests/test_gui_gc.py | 103 ++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 src/core/gui_gc.py create mode 100644 tests/conftest.py create mode 100644 tests/test_gui_gc.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1070ef6..3d1aa18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - 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. - Source feature only: no EXE/MSIX release or Store submission performed. ### Repository-Lebenszyklus-Härtung & CI/CD-Parität (2026-09-30, Pfad A) diff --git a/src/core/gui_gc.py b/src/core/gui_gc.py new file mode 100644 index 0000000..637a9ed --- /dev/null +++ b/src/core/gui_gc.py @@ -0,0 +1,73 @@ +"""Keep cyclic Python garbage collection on the Qt application thread. + +PySide wrappers can be part of Python reference cycles. Automatic collection +may run in whichever thread happens to allocate, including a hashing worker; +destroying a GUI QObject there is unsafe. Reference counting is unaffected. +""" + +import gc + +from PySide6.QtCore import QObject, QThread, QTimer, Slot + + +class GuiGarbageCollector(QObject): + def __init__(self, application): + if QThread.currentThread() != application.thread(): + raise RuntimeError("Install the collector on the application thread") + super().__init__(application) + self._previously_enabled = gc.isenabled() + self._closed = False + self._ticks = 0 + self.timer = QTimer(self) + self.timer.setInterval(1000) + self.timer.timeout.connect(self.collect_if_needed) + gc.disable() + if self._previously_enabled: + self.timer.start() + + def _assert_gui_thread(self): + if QThread.currentThread() != self.thread(): + raise RuntimeError("Collect Qt reference cycles on the application thread") + + @Slot() + def collect_if_needed(self): + self._assert_gui_thread() + if self._closed: + return + self._ticks += 1 + counts = gc.get_count() + thresholds = gc.get_threshold() + # Periodically revisit older cycles even if allocation has subsided. + if self._ticks >= 30 or counts[2] >= thresholds[2]: + self.collect() + elif counts[1] >= thresholds[1]: + gc.collect(1) + elif counts[0] >= thresholds[0]: + gc.collect(0) + + def collect(self): + self._assert_gui_thread() + if not self._closed: + self._ticks = 0 + return gc.collect() + return 0 + + def close(self): + """Restore GC policy after application workers have stopped.""" + self._assert_gui_thread() + if self._closed: + return + self.timer.stop() + self.collect() + self._closed = True + if self._previously_enabled: + gc.enable() + + +def install_gui_gc(application): + """Install once; the application retains the Python wrapper and Qt owner.""" + existing = getattr(application, "_gui_garbage_collector", None) + if existing is None or existing._closed: + existing = GuiGarbageCollector(application) + application._gui_garbage_collector = existing + return existing diff --git a/src/gui/sidebar/drive_capacity.py b/src/gui/sidebar/drive_capacity.py index e473639..5fd84a4 100644 --- a/src/gui/sidebar/drive_capacity.py +++ b/src/gui/sidebar/drive_capacity.py @@ -23,6 +23,14 @@ def capacity_executor(): return _executor +def shutdown_capacity_executor(): + """Drain Python workers before restoring GC or destroying QApplication.""" + global _executor + if _executor is not None: + _executor.shutdown(wait=True, cancel_futures=True) + _executor = None + + class DriveCapacityWidget(QWidget): def __init__(self, path, parent=None): super().__init__(parent) diff --git a/src/main.py b/src/main.py index 63ea7ab..d9c54b4 100644 --- a/src/main.py +++ b/src/main.py @@ -27,6 +27,8 @@ 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: @@ -73,6 +75,7 @@ def main(): ) app = QApplication(sys.argv) + collector = install_gui_gc(app) app.setApplicationName("ExplorerPro") app.setOrganizationName("ExplorerPro") from translator import get_translator @@ -90,12 +93,16 @@ def main(): # apply_dark_theme(app) # Hauptfenster starten - explorer = ExplorerProApp() - if not icon.isNull(): - explorer.setWindowIcon(icon) - explorer.show() - - sys.exit(app.exec()) + try: + explorer = ExplorerProApp() + if not icon.isNull(): + explorer.setWindowIcon(icon) + explorer.show() + exit_code = app.exec() + finally: + shutdown_capacity_executor() + collector.close() + sys.exit(exit_code) if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6bb0ca6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Exercise the same GUI-thread collection policy as the application.""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication + +from core.gui_gc import install_gui_gc +from gui.sidebar.drive_capacity import shutdown_capacity_executor + +# Keep a strong reference and install before test modules create Qt objects. +_application = QApplication.instance() or QApplication([]) +_collector = install_gui_gc(_application) + + +@pytest.fixture(autouse=True) +def collect_gui_cycles(): + yield + _collector.collect() + + +@pytest.fixture(scope="session", autouse=True) +def gui_runtime(): + yield + shutdown_capacity_executor() + _collector.close() diff --git a/tests/test_gui_gc.py b/tests/test_gui_gc.py new file mode 100644 index 0000000..292e94c --- /dev/null +++ b/tests/test_gui_gc.py @@ -0,0 +1,103 @@ +"""Native process regressions for collection and worker shutdown.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("automatic_before", [True, False]) +@pytest.mark.parametrize("use_timer", [True, False]) +def test_qt_cycles_are_destroyed_on_gui_thread(automatic_before, use_timer): + script = f''' +import gc, sys, threading +sys.path.insert(0, 'src') +from PySide6.QtCore import QObject +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication +from core.gui_gc import install_gui_gc +app = QApplication([]) +gc.enable() if {automatic_before!r} else gc.disable() +collector = install_gui_gc(app) +assert collector is install_gui_gc(app) +assert not gc.isenabled() +gui_thread = threading.get_ident() +deleted = [] +qt_deleted = [] +errors = [] +class Probe(QObject): + def __init__(self): + super().__init__() + self.cycle = self + self.destroyed.connect(lambda: qt_deleted.append(threading.get_ident())) + def __del__(self): + deleted.append(threading.get_ident()) +probe = Probe() +del probe +def allocate(): + for _ in range(10000): + cycle = [] + cycle.append(cycle) + for operation in (collector.collect, lambda: install_gui_gc(QApplication.instance()).close()): + try: + operation() + except RuntimeError: + errors.append('wrong-thread-rejected') +thread = threading.Thread(target=allocate) +thread.start() +thread.join(timeout=10) +assert not thread.is_alive() +assert errors == ['wrong-thread-rejected', 'wrong-thread-rejected'] +assert deleted == [] and qt_deleted == [], (deleted, qt_deleted) +if {use_timer!r}: + collector.timer.setInterval(1) + collector.timer.start() + QTest.qWait(100) +else: + collector.collect() +assert deleted == [gui_thread], deleted +assert qt_deleted == [gui_thread], qt_deleted +collector.close() +collector.close() +assert gc.isenabled() is {automatic_before!r} +assert not collector.timer.isActive() +print('gui-collection-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 "gui-collection-ok" 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 From b6c6a80d9e76aba8379860bfaee6e15f2c8b6669 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 03:24:28 +0200 Subject: [PATCH 05/13] fix: bound drive queries and stop stalled capacity helpers --- CHANGELOG.md | 1 + src/core/drive_usage.py | 84 +++++++++++++++++++++++++ src/gui/sidebar/drive_capacity.py | 9 +-- src/gui/sidebar/sidebar_main.py | 2 +- src/main.py | 5 ++ tests/test_drive_query_process.py | 100 ++++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 tests/test_drive_query_process.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1aa18..7ec7d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - 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) diff --git a/src/core/drive_usage.py b/src/core/drive_usage.py index 273de22..69d8dd0 100644 --- a/src/core/drive_usage.py +++ b/src/core/drive_usage.py @@ -3,6 +3,86 @@ 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) @@ -52,3 +132,7 @@ def format_capacity(size: int) -> str: 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/sidebar/drive_capacity.py b/src/gui/sidebar/drive_capacity.py index 5fd84a4..4fb8242 100644 --- a/src/gui/sidebar/drive_capacity.py +++ b/src/gui/sidebar/drive_capacity.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QLabel, QProgressBar, QVBoxLayout, QWidget -from core.drive_usage import format_capacity +from core.drive_usage import format_capacity, start_drive_queries, stop_drive_queries from translator import t _executor = None @@ -14,19 +14,20 @@ def capacity_executor(): """Workers only return Python data; no Qt objects cross thread boundaries. - The standard executor joins workers before interpreter teardown, even when - the view has already been deleted and its polling timer has stopped. + 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(): - """Drain Python workers before restoring GC or destroying QApplication.""" + """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 diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index 9505172..c78f6f0 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -19,7 +19,7 @@ from modules.launcher import AppsPanel from modules.prompts import PromptsPanel from modules.sync import SyncPanel -from core.drive_usage import read_drive_usage +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 diff --git a/src/main.py b/src/main.py index d9c54b4..5b47c27 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 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') From 187ac9dd6dcffec845d4c4609d592c98218124f9 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 03:36:42 +0200 Subject: [PATCH 06/13] fix: keep checksum dialog alive until its native worker joins --- CHANGELOG.md | 4 + src/gui/checksum_dialog.py | 33 +++++-- ...est_bugsweep_checksum_and_diff_20260924.py | 5 +- tests/test_checksum_close_lifecycle.py | 91 +++++++++++++++++++ tests/test_checksum_dialog.py | 4 +- 5 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 tests/test_checksum_close_lifecycle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b5d68..e8511c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Prüfsummen-Dialog (2026-10-01) + +- 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. + ### 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/src/gui/checksum_dialog.py b/src/gui/checksum_dialog.py index 3b9afb6..44737f1 100644 --- a/src/gui/checksum_dialog.py +++ b/src/gui/checksum_dialog.py @@ -10,7 +10,7 @@ QLabel, QLineEdit, QPushButton, QProgressBar, QApplication, QMessageBox ) -from PySide6.QtCore import Slot +from PySide6.QtCore import Slot, QTimer from PySide6.QtGui import QFont from core.checksum_service import ChecksumWorker, verify_hash @@ -24,6 +24,10 @@ def __init__(self, filepath: str, parent=None): self.filepath = filepath self.worker = None self._calculated_hashes = {} + self._pending_close_result = None + self._close_timer = QTimer(self) + self._close_timer.setInterval(20) + self._close_timer.timeout.connect(self._finish_pending_close) filename = os.path.basename(filepath) self.setWindowTitle(f"Prüfsummen — {filename}") @@ -243,13 +247,30 @@ def _on_verify_text_changed(self, text: str): self.verify_result_label.setStyleSheet("color: #c00000; font-weight: bold;") def done(self, result: int): - if self.worker and self.worker.isRunning(): + if self.worker and not self.worker.wait(0): + # A timed wait does not make destroying a still-running QThread safe. + # Keep the dialog alive and its event loop responsive until it exits. + if self._pending_close_result is None: + self._pending_close_result = result self.worker.cancel() - self.worker.wait(1000) + self.close_btn.setEnabled(False) + self._close_timer.start() + return super().done(result) + @Slot() + def _finish_pending_close(self): + if self.worker and not self.worker.wait(0): + return + self._close_timer.stop() + result = self._pending_close_result + self._pending_close_result = None + if result is not None: + super().done(result) + def closeEvent(self, event): - if self.worker and self.worker.isRunning(): - self.worker.cancel() - self.worker.wait(1000) + if self.worker and not self.worker.wait(0): + event.ignore() + self.done(QDialog.DialogCode.Rejected) + return super().closeEvent(event) diff --git a/tests/test_bugsweep_checksum_and_diff_20260924.py b/tests/test_bugsweep_checksum_and_diff_20260924.py index efe31e9..ca9f07e 100644 --- a/tests/test_bugsweep_checksum_and_diff_20260924.py +++ b/tests/test_bugsweep_checksum_and_diff_20260924.py @@ -145,7 +145,7 @@ def test_is_binary_file_heuristic_control_chars(tmp_path: Path): assert is_binary_file(str(bin_file)) is True -def test_checksum_dialog_done_cancels_worker(tmp_path: Path): +def test_checksum_dialog_done_cancels_worker(tmp_path: Path, qtbot): """Prüft, dass ChecksumDialog.done() (via accept/reject/Schließen) den Worker abbricht.""" _ensure_app() sample = tmp_path / "big_sample.bin" @@ -158,7 +158,8 @@ def test_checksum_dialog_done_cancels_worker(tmp_path: Path): # Simuliere Schließen via accept (Button "Schließen") dlg.accept() assert worker.is_cancelled() is True - assert not worker.isRunning() + qtbot.waitUntil(lambda: not worker.isRunning(), timeout=3000) + qtbot.waitUntil(lambda: not dlg._close_timer.isActive(), timeout=3000) def test_checksum_dialog_directory_handling(tmp_path: Path): diff --git a/tests/test_checksum_close_lifecycle.py b/tests/test_checksum_close_lifecycle.py new file mode 100644 index 0000000..7c80833 --- /dev/null +++ b/tests/test_checksum_close_lifecycle.py @@ -0,0 +1,91 @@ +"""A dialog must retain its native QThread until the worker actually exits.""" + +import threading +import time +import subprocess +import sys +from pathlib import Path + +import pytest +from PySide6.QtWidgets import QDialog + +from core.checksum_service import ChecksumWorker +from gui.checksum_dialog import ChecksumDialog + + +@pytest.mark.parametrize('action,result', [ + ('accept', QDialog.DialogCode.Accepted), + ('reject', QDialog.DialogCode.Rejected), + ('close', QDialog.DialogCode.Rejected), +]) +def test_close_remains_responsive_and_keeps_running_worker(qtbot, monkeypatch, tmp_path, action, result): + entered, release = threading.Event(), threading.Event() + + def blocked_read(worker): + entered.set() + release.wait(5) + + monkeypatch.setattr(ChecksumWorker, 'run', blocked_read) + sample = tmp_path / 'sample.txt' + sample.write_text('slow storage', encoding='utf-8') + dialog = ChecksumDialog(str(sample)) + dialog.show() + completed = [] + dialog.finished.connect(completed.append) + try: + assert entered.wait(2) + started = time.monotonic() + getattr(dialog, action)() + assert time.monotonic() - started < .3 + qtbot.wait(40) + assert dialog.isVisible() + assert dialog.worker.isRunning() + assert dialog.worker.is_cancelled() + assert completed == [] + # Repeated close requests must not emit duplicate completion signals. + getattr(dialog, action)() + release.set() + qtbot.waitUntil(lambda: completed == [int(result)], timeout=3000) + assert not dialog.isVisible() + assert not dialog.worker.isRunning() + finally: + release.set() + dialog.worker.wait(2000) + dialog.close() + + +def test_finished_cleanup_does_not_destroy_running_native_thread(tmp_path): + script = ''' +import sys, threading +from pathlib import Path +sys.path.insert(0, 'src') +from PySide6.QtCore import QCoreApplication, QEvent, QTimer +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication +from core.checksum_service import ChecksumWorker +from gui.checksum_dialog import ChecksumDialog +app = QApplication([]) +app.setQuitOnLastWindowClosed(False) +entered, release = threading.Event(), threading.Event() +def read(worker): + entered.set() + release.wait(5) +ChecksumWorker.run = read +dialog = ChecksumDialog(sys.argv[1]) +dialog.show() +dialog.finished.connect(dialog.deleteLater) +assert entered.wait(2) +QTimer.singleShot(1500, release.set) +dialog.accept() +for _ in range(200): + QTest.qWait(10) +QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) +print('native-close-ok') +''' + sample = tmp_path / 'slow.txt' + sample.write_text('sample', encoding='utf-8') + result = subprocess.run([sys.executable, '-X', 'faulthandler', '-c', script, str(sample)], + cwd=Path(__file__).resolve().parents[1], capture_output=True, + text=True, timeout=15) + assert result.returncode == 0, (result.returncode, result.stderr) + assert 'native-close-ok' in result.stdout diff --git a/tests/test_checksum_dialog.py b/tests/test_checksum_dialog.py index d9fdb8d..f53f1ae 100644 --- a/tests/test_checksum_dialog.py +++ b/tests/test_checksum_dialog.py @@ -69,7 +69,7 @@ def test_checksum_dialog_non_existent_file(tmp_path: Path): dlg.close() -def test_checksum_dialog_close_cancels_worker(tmp_path: Path): +def test_checksum_dialog_close_cancels_worker(tmp_path: Path, qtbot): """Prüft, dass closeEvent den Worker sauber abbricht.""" _ensure_app() sample = tmp_path / "cancel_test.txt" @@ -78,3 +78,5 @@ def test_checksum_dialog_close_cancels_worker(tmp_path: Path): dlg = ChecksumDialog(str(sample)) dlg.closeEvent(QCloseEvent()) assert dlg.worker is not None + qtbot.waitUntil(lambda: not dlg.worker.isRunning(), timeout=3000) + qtbot.waitUntil(lambda: not dlg._close_timer.isActive(), timeout=3000) From f12bf5cb61fde6e0257c6216fc0d7e38d720d1aa Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 03:38:02 +0200 Subject: [PATCH 07/13] fix: finish pending checksum close exactly once --- src/gui/checksum_dialog.py | 4 ++++ tests/test_checksum_close_lifecycle.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/gui/checksum_dialog.py b/src/gui/checksum_dialog.py index 44737f1..7674b40 100644 --- a/src/gui/checksum_dialog.py +++ b/src/gui/checksum_dialog.py @@ -256,6 +256,10 @@ def done(self, result: int): self.close_btn.setEnabled(False) self._close_timer.start() return + self._close_timer.stop() + if self._pending_close_result is not None: + result = self._pending_close_result + self._pending_close_result = None super().done(result) @Slot() diff --git a/tests/test_checksum_close_lifecycle.py b/tests/test_checksum_close_lifecycle.py index 7c80833..14629f8 100644 --- a/tests/test_checksum_close_lifecycle.py +++ b/tests/test_checksum_close_lifecycle.py @@ -45,7 +45,13 @@ def blocked_read(worker): # Repeated close requests must not emit duplicate completion signals. getattr(dialog, action)() release.set() + assert dialog.worker.wait(2000) + # The worker can exit just before the polling timer handles its end. + # A concurrent close request must preserve the first result and emit once. + dialog.done(1 - int(result)) qtbot.waitUntil(lambda: completed == [int(result)], timeout=3000) + qtbot.wait(40) + assert completed == [int(result)] assert not dialog.isVisible() assert not dialog.worker.isRunning() finally: From 619b018b7f97f8cdc6d6cecd245cec6603e3023a Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 03:41:08 +0200 Subject: [PATCH 08/13] test: dispose checksum dialogs within their Qt test fixtures --- tests/test_bugsweep_checksum_and_diff_20260924.py | 4 +++- tests/test_checksum_close_lifecycle.py | 1 + tests/test_checksum_dialog.py | 7 +++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_bugsweep_checksum_and_diff_20260924.py b/tests/test_bugsweep_checksum_and_diff_20260924.py index ca9f07e..160d859 100644 --- a/tests/test_bugsweep_checksum_and_diff_20260924.py +++ b/tests/test_bugsweep_checksum_and_diff_20260924.py @@ -152,6 +152,7 @@ def test_checksum_dialog_done_cancels_worker(tmp_path: Path, qtbot): sample.write_bytes(b"Z" * (1024 * 1024)) dlg = ChecksumDialog(str(sample)) + qtbot.addWidget(dlg) worker = dlg.worker assert worker is not None @@ -162,13 +163,14 @@ def test_checksum_dialog_done_cancels_worker(tmp_path: Path, qtbot): qtbot.waitUntil(lambda: not dlg._close_timer.isActive(), timeout=3000) -def test_checksum_dialog_directory_handling(tmp_path: Path): +def test_checksum_dialog_directory_handling(tmp_path: Path, qtbot): """Prüft, dass ChecksumDialog bei einem Verzeichnis nicht abstürzt und klare Meldung liefert.""" _ensure_app() sub_dir = tmp_path / "some_directory" sub_dir.mkdir() dlg = ChecksumDialog(str(sub_dir)) + qtbot.addWidget(dlg) assert dlg.worker is None assert "Verzeichnis" in dlg.verify_result_label.text() dlg.close() diff --git a/tests/test_checksum_close_lifecycle.py b/tests/test_checksum_close_lifecycle.py index 14629f8..3b79e94 100644 --- a/tests/test_checksum_close_lifecycle.py +++ b/tests/test_checksum_close_lifecycle.py @@ -29,6 +29,7 @@ def blocked_read(worker): sample = tmp_path / 'sample.txt' sample.write_text('slow storage', encoding='utf-8') dialog = ChecksumDialog(str(sample)) + qtbot.addWidget(dialog) dialog.show() completed = [] dialog.finished.connect(completed.append) diff --git a/tests/test_checksum_dialog.py b/tests/test_checksum_dialog.py index f53f1ae..6ce1e6a 100644 --- a/tests/test_checksum_dialog.py +++ b/tests/test_checksum_dialog.py @@ -20,13 +20,14 @@ def _ensure_app(): return app -def test_checksum_dialog_initialization(tmp_path: Path): +def test_checksum_dialog_initialization(tmp_path: Path, qtbot): """Prüft Dialog-Aufbau und Hash-Berechnung.""" _ensure_app() sample = tmp_path / "test_dialog.txt" sample.write_text("ExplorerPro Dialog Test Content", encoding="utf-8") dlg = ChecksumDialog(str(sample)) + qtbot.addWidget(dlg) assert dlg.path_label.text() == str(sample) assert dlg.windowTitle().startswith("Prüfsummen — test_dialog.txt") assert "sha256" in dlg.hash_edits @@ -61,10 +62,11 @@ def test_checksum_dialog_initialization(tmp_path: Path): dlg.close() -def test_checksum_dialog_non_existent_file(tmp_path: Path): +def test_checksum_dialog_non_existent_file(tmp_path: Path, qtbot): """Prüft Dialog-Verhalten wenn Datei nicht existiert.""" _ensure_app() dlg = ChecksumDialog(str(tmp_path / "nicht_da.txt")) + qtbot.addWidget(dlg) assert "Fehler" in dlg.verify_result_label.text() dlg.close() @@ -76,6 +78,7 @@ def test_checksum_dialog_close_cancels_worker(tmp_path: Path, qtbot): sample.write_bytes(b"X" * (500 * 1024)) dlg = ChecksumDialog(str(sample)) + qtbot.addWidget(dlg) dlg.closeEvent(QCloseEvent()) assert dlg.worker is not None qtbot.waitUntil(lambda: not dlg.worker.isRunning(), timeout=3000) From cb2fde641d2e5e1bed7df09772608e9c48fbee69 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 03:44:20 +0200 Subject: [PATCH 09/13] ci: install Qt test fixtures for checksum lifecycle regressions --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31bc1ca..3a25463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt pytest pytest-xdist ruff Pillow + pip install -r requirements.txt pytest pytest-qt pytest-xdist ruff Pillow - name: Compile sources run: | diff --git a/pyproject.toml b/pyproject.toml index 99bb74d..9bdf87a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,11 +81,13 @@ Notice = "https://github.com/file-bricks/ExplorerPro/blob/master/NOTICE" [project.optional-dependencies] dev = [ "pytest>=9.1.1", + "pytest-qt>=4.5.0", "ruff>=0.9.0", "altgraph>=0.17.4", ] test = [ "pytest>=9.1.1", + "pytest-qt>=4.5.0", "Pillow>=10.0", ] build = [ From 2df3eb695e459028fccc713b9811d6c0e0de1289 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 1 Oct 2026 03:48:53 +0200 Subject: [PATCH 10/13] fix: collect Qt cycles on the application thread --- CHANGELOG.md | 1 + src/core/gui_gc.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ src/main.py | 17 +++++++---- tests/conftest.py | 25 +++++++++++++++ tests/test_gui_gc.py | 72 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 src/core/gui_gc.py create mode 100644 tests/conftest.py create mode 100644 tests/test_gui_gc.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e8511c7..93096f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ### Prüfsummen-Dialog (2026-10-01) - 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. ### Repository-Lebenszyklus-Härtung & CI/CD-Parität (2026-09-30, Pfad A) - **CI/CD Lifecycle Workflows & Label-Governance**: diff --git a/src/core/gui_gc.py b/src/core/gui_gc.py new file mode 100644 index 0000000..637a9ed --- /dev/null +++ b/src/core/gui_gc.py @@ -0,0 +1,73 @@ +"""Keep cyclic Python garbage collection on the Qt application thread. + +PySide wrappers can be part of Python reference cycles. Automatic collection +may run in whichever thread happens to allocate, including a hashing worker; +destroying a GUI QObject there is unsafe. Reference counting is unaffected. +""" + +import gc + +from PySide6.QtCore import QObject, QThread, QTimer, Slot + + +class GuiGarbageCollector(QObject): + def __init__(self, application): + if QThread.currentThread() != application.thread(): + raise RuntimeError("Install the collector on the application thread") + super().__init__(application) + self._previously_enabled = gc.isenabled() + self._closed = False + self._ticks = 0 + self.timer = QTimer(self) + self.timer.setInterval(1000) + self.timer.timeout.connect(self.collect_if_needed) + gc.disable() + if self._previously_enabled: + self.timer.start() + + def _assert_gui_thread(self): + if QThread.currentThread() != self.thread(): + raise RuntimeError("Collect Qt reference cycles on the application thread") + + @Slot() + def collect_if_needed(self): + self._assert_gui_thread() + if self._closed: + return + self._ticks += 1 + counts = gc.get_count() + thresholds = gc.get_threshold() + # Periodically revisit older cycles even if allocation has subsided. + if self._ticks >= 30 or counts[2] >= thresholds[2]: + self.collect() + elif counts[1] >= thresholds[1]: + gc.collect(1) + elif counts[0] >= thresholds[0]: + gc.collect(0) + + def collect(self): + self._assert_gui_thread() + if not self._closed: + self._ticks = 0 + return gc.collect() + return 0 + + def close(self): + """Restore GC policy after application workers have stopped.""" + self._assert_gui_thread() + if self._closed: + return + self.timer.stop() + self.collect() + self._closed = True + if self._previously_enabled: + gc.enable() + + +def install_gui_gc(application): + """Install once; the application retains the Python wrapper and Qt owner.""" + existing = getattr(application, "_gui_garbage_collector", None) + if existing is None or existing._closed: + existing = GuiGarbageCollector(application) + application._gui_garbage_collector = existing + return existing diff --git a/src/main.py b/src/main.py index 63ea7ab..bba4c67 100644 --- a/src/main.py +++ b/src/main.py @@ -27,6 +27,7 @@ from app import ExplorerProApp from version import __version__ +from core.gui_gc import install_gui_gc def load_app_icon() -> QIcon: @@ -73,6 +74,7 @@ def main(): ) app = QApplication(sys.argv) + collector = install_gui_gc(app) app.setApplicationName("ExplorerPro") app.setOrganizationName("ExplorerPro") from translator import get_translator @@ -90,12 +92,15 @@ def main(): # apply_dark_theme(app) # Hauptfenster starten - explorer = ExplorerProApp() - if not icon.isNull(): - explorer.setWindowIcon(icon) - explorer.show() - - sys.exit(app.exec()) + try: + explorer = ExplorerProApp() + if not icon.isNull(): + explorer.setWindowIcon(icon) + explorer.show() + exit_code = app.exec() + finally: + collector.close() + sys.exit(exit_code) if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..293ae29 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Exercise the application's GUI-thread cyclic collection policy.""" + +import os + +import pytest + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + +from PySide6.QtWidgets import QApplication +from core.gui_gc import install_gui_gc + +_application = QApplication.instance() or QApplication([]) +_collector = install_gui_gc(_application) + + +@pytest.fixture(autouse=True) +def collect_gui_cycles(): + yield + _collector.collect() + + +@pytest.fixture(scope='session', autouse=True) +def gui_runtime(): + yield + _collector.close() diff --git a/tests/test_gui_gc.py b/tests/test_gui_gc.py new file mode 100644 index 0000000..796358c --- /dev/null +++ b/tests/test_gui_gc.py @@ -0,0 +1,72 @@ +"""Native process regressions for collection and worker shutdown.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("automatic_before", [True, False]) +@pytest.mark.parametrize("use_timer", [True, False]) +def test_qt_cycles_are_destroyed_on_gui_thread(automatic_before, use_timer): + script = f''' +import gc, sys, threading +sys.path.insert(0, 'src') +from PySide6.QtCore import QObject +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication +from core.gui_gc import install_gui_gc +app = QApplication([]) +gc.enable() if {automatic_before!r} else gc.disable() +collector = install_gui_gc(app) +assert collector is install_gui_gc(app) +assert not gc.isenabled() +gui_thread = threading.get_ident() +deleted = [] +qt_deleted = [] +errors = [] +class Probe(QObject): + def __init__(self): + super().__init__() + self.cycle = self + self.destroyed.connect(lambda: qt_deleted.append(threading.get_ident())) + def __del__(self): + deleted.append(threading.get_ident()) +probe = Probe() +del probe +def allocate(): + for _ in range(10000): + cycle = [] + cycle.append(cycle) + for operation in (collector.collect, lambda: install_gui_gc(QApplication.instance()).close()): + try: + operation() + except RuntimeError: + errors.append('wrong-thread-rejected') +thread = threading.Thread(target=allocate) +thread.start() +thread.join(timeout=10) +assert not thread.is_alive() +assert errors == ['wrong-thread-rejected', 'wrong-thread-rejected'] +assert deleted == [] and qt_deleted == [], (deleted, qt_deleted) +if {use_timer!r}: + collector.timer.setInterval(1) + collector.timer.start() + QTest.qWait(100) +else: + collector.collect() +assert deleted == [gui_thread], deleted +assert qt_deleted == [gui_thread], qt_deleted +collector.close() +collector.close() +assert gc.isenabled() is {automatic_before!r} +assert not collector.timer.isActive() +print('gui-collection-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 "gui-collection-ok" in result.stdout From a25359a7abf51cb7907baf8e341754d84251ac4b Mon Sep 17 00:00:00 2001 From: lukisch <55554266+lukisch@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:00:17 +0200 Subject: [PATCH 11/13] fix(batch_rename): harden plain-text replace, negative numbering, cycle rollback resilience and dialog state [BS-11] --- CHANGELOG.md | 15 ++ src/core/batch_rename_service.py | 73 +++-- src/gui/batch_rename_dialog.py | 28 +- ...gsweep_batch_rename_resilience_20261001.py | 252 ++++++++++++++++++ 4 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 tests/test_bugsweep_batch_rename_resilience_20261001.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b5d68..81bc1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Bugsweep & Resilienz-Härtung (2026-10-01, Turnusgemäßer Bugsweep) +- **Mehrfachumbenennung (Batch Rename) Resilienz**: + - `src/core/batch_rename_service.py`: + - Nicht-reguläres Suchen & Ersetzen (`use_regex=False`, `regex_case_sensitive=False`) via `pattern.sub(lambda _: rules.replace_str, new_stem)` gegen unhandled `re.error: invalid group reference / bad escape` bei Backslashes (Pfade, Escape-Muster) im Ersetzungstext gehärtet. + - Formatierung negativer Nummerierungs-Startwerte mit korrektem Vorzeichen und Nullen-Padding (`-005` statt `-05` bei Padding 3). + - Windows-Gerätenamen-Schutz um `CLOCK$` erweitert. + - Validierung vollständig geleerter Dateinamen korrigiert (liefert leeren String statt masking mit Originalnamen). + - Zweistufige atomare Rollback-Hygiene in `execute_rename` bei Fehlern in Phase 2: Bereits erzeugte Zieldateien werden vor der Wiederherstellung zurück nach `temp_path` verschoben, wodurch zyklische Kettungen und Swaps kollisionsfrei ohne `[WinError 183]` oder verwaiste temporäre Dateien wiederhergestellt werden. + - Transaktionale Abbruch- und Wiederherstellungslogik in `rollback_rename`: Bereinigt Zwischendateien bei Fehlern in Phase 1 oder Phase 2 restlos, ohne gestrandete `.__ep_rb_tmp_*`-Dateien im Verzeichnis zu hinterlassen. + - `src/gui/batch_rename_dialog.py`: + - Synchronisation von `self.file_paths` nach `_do_rollback`: Stellt sicher, dass nach einem Klick auf "Rückgängig" die Dateipfade wieder auf die tatsächlich existierenden Originaldateien zeigen. + - Dialog-Lebenszyklus: Verhindert das automatische Schließen des modalen Dialogs bei `_do_rename`, sodass der Rollback-Button für den Benutzer erreichbar und nutzbar bleibt; `Accepted`-Status wird beim Schließen nach erfolgreicher Umbenennung an den Browser übermittelt. + - Eingabepfad-Deduplizierung unter Erhalt der Reihenfolge im Konstruktor. + - `tests/test_bugsweep_batch_rename_resilience_20261001.py`: 8 neue hermetische Regressionstests (100% grün). + ### 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/src/core/batch_rename_service.py b/src/core/batch_rename_service.py index 4fe18f5..2552821 100644 --- a/src/core/batch_rename_service.py +++ b/src/core/batch_rename_service.py @@ -24,7 +24,7 @@ # Windows-reservierte Gerätenamen (dürfen weder als Stamm noch als ganzer Name vorkommen) WINDOWS_RESERVED_NAMES = { - "CON", "PRN", "AUX", "NUL", + "CON", "PRN", "AUX", "NUL", "CLOCK$", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10)), } @@ -112,7 +112,7 @@ def compute_new_name( new_stem = new_stem.replace(rules.search_str, rules.replace_str) else: pattern = re.compile(re.escape(rules.search_str), re.IGNORECASE) - new_stem = pattern.sub(rules.replace_str, new_stem) + new_stem = pattern.sub(lambda _: rules.replace_str, new_stem) # 2. Groß-/Kleinschreibung new_stem = apply_case_mode(new_stem, rules.case_mode) @@ -120,7 +120,11 @@ def compute_new_name( # 3. Nummerierung (formt oder ersetzt den Basis-Stamm) if rules.numbering_enabled: num_val = rules.start_num + (index * rules.step_num) - formatted_num = str(num_val).zfill(max(1, rules.padding)) + pad_len = max(1, rules.padding) + if num_val < 0: + formatted_num = f"-{abs(num_val):0{pad_len}d}" + else: + formatted_num = f"{num_val:0{pad_len}d}" if rules.number_position == "prefix": new_stem = f"{formatted_num}_{new_stem}" elif rules.number_position == "replace": @@ -144,7 +148,7 @@ def compute_new_name( # 6. Validierung if not final_name or final_name.strip() in ("", ".", ".."): - return original_name, "Dateiname darf nicht leer sein" + return final_name if final_name else "", "Dateiname darf nicht leer sein" if final_name.endswith(".") or final_name.endswith(" "): return final_name, "Dateiname darf nicht mit einem Punkt oder Leerzeichen enden" @@ -347,20 +351,23 @@ def execute_rename( break if errors: - # Rückabwicklung bei Abbruch in Phase 2 - for final_dst, original_path in reversed(phase2_done): + # Rückabwicklung bei Abbruch in Phase 2: + # 1. Zunächst alle in Phase 2 bereits erzeugten dst zurück auf ihren jeweiligen temp_path verschieben + # Dadurch werden sämtliche original_path im Ordner kollisionsfrei geräumt (auch bei Swaps, Zyklen und Ketten) + for temp_path, orig_path, dst, item in reversed(temp_stage[:len(phase2_done)]): try: - if os.path.exists(final_dst): - os.rename(final_dst, original_path) + if os.path.exists(dst): + os.rename(dst, temp_path) except OSError as rb_exc: - errors.append(f"Rollback-Fehler bei {final_dst}: {rb_exc}") + errors.append(f"Rollback-Fehler bei Phase-2-Bereinigung von {dst}: {rb_exc}") - for temp_path, orig_path, dst, item in temp_stage[len(phase2_done):]: + # 2. Nun alle temporären Zwischenpfade zurück auf ihren jeweiligen original_path umbenennen + for temp_path, orig_path, dst, item in reversed(temp_stage): try: if os.path.exists(temp_path): os.rename(temp_path, orig_path) except OSError as rb_exc: - errors.append(f"Rollback-Fehler bei Phase-1-Rest {orig_path}: {rb_exc}") + errors.append(f"Rollback-Fehler bei Wiederherstellung von {orig_path}: {rb_exc}") return 0, errors, [] @@ -370,7 +377,7 @@ def execute_rename( def rollback_rename(history: List[Tuple[str, str]]) -> Tuple[int, List[str]]: """ Macht einen Batch-Rename-Vorgang anhand der Historie rückgängig. - Nutzt ebenfalls ein temporäres Zwischenverfahren, damit auch Ketten, + Nutzt ebenfalls ein zweistufiges temporäres Zwischenverfahren, damit auch Ketten, Swaps und Case-Changes im Rollback kollisionsfrei zurückgesetzt werden. Gibt (anzahl_rueckgaengig, fehlerliste) zurück. """ @@ -380,25 +387,57 @@ def rollback_rename(history: List[Tuple[str, str]]) -> Tuple[int, List[str]]: errors: List[str] = [] # Phase 1: current_path -> temp_path - temp_stage: List[Tuple[str, str]] = [] # [(temp_path, original_path)] + temp_stage: List[Tuple[str, str, str]] = [] # [(temp_path, current_path, original_path)] for current_path, original_path in reversed(history): if not os.path.exists(current_path): errors.append(f"Datei für Rollback nicht gefunden: {current_path}") - continue + break parent_dir = os.path.dirname(current_path) - temp_path = os.path.join(parent_dir, f".__ep_rb_tmp_{uuid.uuid4().hex}__") + temp_name = f".__ep_rb_tmp_{uuid.uuid4().hex}__" + temp_path = os.path.join(parent_dir, temp_name) try: os.rename(current_path, temp_path) - temp_stage.append((temp_path, original_path)) + temp_stage.append((temp_path, current_path, original_path)) except OSError as exc: errors.append(f"Fehler bei Rollback-Vorbereitung für {current_path}: {exc}") + break + + if errors: + # Phase 1 abgebrochen: Bereits verschobene temp_path zurück nach current_path + for temp_path, current_path, original_path in reversed(temp_stage): + try: + if os.path.exists(temp_path): + os.rename(temp_path, current_path) + except OSError as rb_exc: + errors.append(f"Rollback-Bereinigung fehlgeschlagen für {current_path}: {rb_exc}") + return 0, errors # Phase 2: temp_path -> original_path - for temp_path, original_path in temp_stage: + phase2_done: List[Tuple[str, str, str]] = [] # [(temp_path, current_path, original_path)] + for temp_path, current_path, original_path in temp_stage: try: os.rename(temp_path, original_path) + phase2_done.append((temp_path, current_path, original_path)) restored += 1 except OSError as exc: errors.append(f"Rollback fehlgeschlagen für {original_path}: {exc}") + break + + if errors: + # Phase 2 abgebrochen: Bereits wiederhergestellte original_path zurück nach temp_path + for temp_path, current_path, original_path in reversed(phase2_done): + try: + if os.path.exists(original_path): + os.rename(original_path, temp_path) + except OSError as rb_exc: + errors.append(f"Fehler beim Rückgängigmachen von Rollback-Schritt {original_path}: {rb_exc}") + # Alle temp_path zurück nach current_path (Ausgangszustand vor rollback_rename) + for temp_path, current_path, original_path in reversed(temp_stage): + try: + if os.path.exists(temp_path): + os.rename(temp_path, current_path) + except OSError as rb_exc: + errors.append(f"Fehler beim Wiederherstellen des Ausgangszustands für {current_path}: {rb_exc}") + return 0, errors return restored, errors diff --git a/src/gui/batch_rename_dialog.py b/src/gui/batch_rename_dialog.py index a1ca879..29597e5 100644 --- a/src/gui/batch_rename_dialog.py +++ b/src/gui/batch_rename_dialog.py @@ -35,9 +35,10 @@ class BatchRenameDialog(QDialog): def __init__(self, file_paths: List[str], parent=None): super().__init__(parent) - self.file_paths = [p for p in file_paths if os.path.exists(p)] + self.file_paths = list(dict.fromkeys(p for p in file_paths if os.path.exists(p))) self.preview_items: List[RenameItem] = [] self.history: List[tuple] = [] + self._has_renamed = False self.setWindowTitle(t("Mehrfach umbenennen")) self.resize(900, 650) @@ -50,6 +51,17 @@ def __init__(self, file_paths: List[str], parent=None): self._setup_ui() self._update_preview() + def _on_close_clicked(self): + if self._has_renamed: + self.accept() + else: + self.reject() + + def closeEvent(self, event): + if self._has_renamed: + self.setResult(QDialog.DialogCode.Accepted) + super().closeEvent(event) + def _setup_ui(self): main_layout = QVBoxLayout(self) main_layout.setSpacing(10) @@ -267,7 +279,7 @@ def _setup_ui(self): self.close_btn.setAccessibleName("Dialog schließen") self.close_btn.setToolTip("Schließt das Mehrfachumbenennungs-Fenster (Esc)") self.close_btn.setShortcut("Escape") - self.close_btn.clicked.connect(self.reject) + self.close_btn.clicked.connect(self._on_close_clicked) btn_layout.addWidget(self.close_btn) main_layout.addLayout(btn_layout) @@ -363,6 +375,7 @@ def _do_rename(self): f"{success_count} " + t("Dateien umbenannt, aber Fehler aufgetreten:\n\n") + err_text ) else: + self._has_renamed = True QMessageBox.information( self, t("Erfolg"), f"{success_count} " + t("Dateien erfolgreich umbenannt.") @@ -381,16 +394,23 @@ def _do_rename(self): self.file_paths = updated_paths self._update_preview() - if not errors: - self.accept() def _do_rollback(self): if not self.history: return + history_map = {dst: orig for dst, orig in self.history} restored, errors = rollback_rename(self.history) self.history.clear() self.rollback_btn.setEnabled(False) + # file_paths wieder auf die wiederhergestellten Originalpfade synchronisieren + self.file_paths = [ + history_map[p] if (p in history_map and os.path.exists(history_map[p])) else p + for p in self.file_paths + ] + if not errors: + self._has_renamed = False + if errors: QMessageBox.warning( self, t("Rollback unvollständig"), diff --git a/tests/test_bugsweep_batch_rename_resilience_20261001.py b/tests/test_bugsweep_batch_rename_resilience_20261001.py new file mode 100644 index 0000000..b3554c2 --- /dev/null +++ b/tests/test_bugsweep_batch_rename_resilience_20261001.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_bugsweep_batch_rename_resilience_20261001.py +Hermetische Regressionstests für den Bugsweep 2026-10-01 in ExplorerPro: +Bereich: Mehrfachumbenennung (Batch Rename) - Regelberechnung, Regex-Ersetzung, +Nummerierungs- & Padding-Resilienz, Windows-Gerätenamen-Validierung, +zweiphasige atomare Rollback-Hygiene und Dialog-Lebenszyklus +(core/batch_rename_service.py + gui/batch_rename_dialog.py). +""" + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +# Sicherstellen, dass Repo-Root und src im Pfad liegen +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_PATH = REPO_ROOT / "src" +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) +if str(SRC_PATH) not in sys.path: + sys.path.insert(0, str(SRC_PATH)) + +from PySide6.QtWidgets import QApplication + +from core.batch_rename_service import ( + RenameRules, + RenameItem, + compute_new_name, + execute_rename, + rollback_rename, +) +from gui.batch_rename_dialog import BatchRenameDialog + + +def _ensure_app(): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +def test_non_regex_case_insensitive_replace_with_backslashes(): + """ + Prüft, dass nicht-reguläres Suchen & Ersetzen ohne Beachtung der Groß-/Kleinschreibung + Backslashes und Gruppen-Escape-Muster (wie \\1, \\g, \\t) im Ersetzungstext + nicht als Regex-Backreferenz fehlinterpretiert werden und nicht unkontrolliert + mit re.error abstürzen, sondern deterministisch die Dateinamens-Validierung erreichen. + """ + rules = RenameRules( + search_str="DOC", + replace_str=r"sub\1\test", + use_regex=False, + regex_case_sensitive=False, + ) + # Zuvor: unhandled re.error: invalid group reference 1 at position 4 (Crash!) + # Jetzt: Sicher ausgeführt und von der Dateinamensprüfung gefangen + new_name, err = compute_new_name("my_doc_2026.txt", rules) + assert err is not None + assert "Ungültiges Zeichen im Dateinamen: '\\'" in err + + +def test_negative_numbering_formatting(): + """ + Prüft, dass negative Startnummern korrekt mit Vorzeichen und Padding formatiert werden + (z. B. -005 statt fehlerhaft verkürztem -05). + """ + rules = RenameRules( + numbering_enabled=True, + start_num=-5, + step_num=2, + padding=3, + number_position="suffix", + ) + name0, err0 = compute_new_name("log.txt", rules, index=0) + assert err0 is None + assert name0 == "log_-005.txt" + + name1, err1 = compute_new_name("log.txt", rules, index=1) + assert err1 is None + assert name1 == "log_-003.txt" + + +def test_windows_reserved_names_clock(): + """ + Prüft, dass auch CLOCK$ zuverlässig als Windows-Gerätename abgewiesen wird. + """ + rules = RenameRules(search_str="^.*$", replace_str="clock$", use_regex=True) + new_name, err = compute_new_name("test.txt", rules) + assert err is not None + assert "reservierter Gerätename" in err + + +def test_empty_final_name_validation(): + """ + Prüft, dass ein durch Ersetzung vollständig geleerter Dateiname + nicht den Originalnamen fälschlich als neuen Namen spiegelt, sondern + eindeutig als leer und ungültig deklariert wird. + """ + rules = RenameRules(search_str="test", replace_str="", change_extension="") + new_name, err = compute_new_name("test.txt", rules) + assert err is not None + assert "Dateiname darf nicht leer sein" in err + assert new_name == "" + + +def test_execute_rename_cycle_error_rollback_two_phase(tmp_path: Path): + """ + Prüft, dass bei einem zyklischen Batch-Rename (A->B, B->C, C->A) + ein Fehler mitten in Phase 2 durch zweistufige Bereinigung vollständig + und kollisionsfrei zurückgerollt wird, ohne WinError 183 oder verwaiste Temp-Dateien. + """ + fa = tmp_path / "A.txt" + fb = tmp_path / "B.txt" + fc = tmp_path / "C.txt" + fa.write_text("content_a", encoding="utf-8") + fb.write_text("content_b", encoding="utf-8") + fc.write_text("content_c", encoding="utf-8") + + items = [ + RenameItem(original_path=str(fa), original_name="A.txt", new_name="B.txt", new_path=str(fb), status="ok"), + RenameItem(original_path=str(fb), original_name="B.txt", new_name="C.txt", new_path=str(fc), status="ok"), + RenameItem(original_path=str(fc), original_name="C.txt", new_name="A.txt", new_path=str(fa), status="ok"), + ] + + orig_rename = os.rename + counter = {"calls": 0} + + def failing_rename(src, dst): + counter["calls"] += 1 + # 3 Calls in Phase 1 (1, 2, 3), 2 Calls in Phase 2 (4, 5), Call 6 schlägt fehl + if counter["calls"] == 6: + raise OSError("Künstlicher I/O-Fehler beim 3. Element in Phase 2") + return orig_rename(src, dst) + + with patch("os.rename", side_effect=failing_rename): + success, errors, history = execute_rename(items) + + assert success == 0 + assert len(errors) > 0 + assert "Künstlicher I/O-Fehler" in errors[0] + + # Prüfen, dass alle drei Dateien unbeschädigt und an ihren Originalorten liegen + assert fa.exists() and fa.read_text(encoding="utf-8") == "content_a" + assert fb.exists() and fb.read_text(encoding="utf-8") == "content_b" + assert fc.exists() and fc.read_text(encoding="utf-8") == "content_c" + + # Keine verwaisten temporären Dateien im Ordner + all_files = [p.name for p in tmp_path.iterdir()] + assert sorted(all_files) == ["A.txt", "B.txt", "C.txt"] + + +def test_rollback_rename_phase1_failure_cleanup(tmp_path: Path): + """ + Prüft, dass rollback_rename bei einem Fehler in Phase 1 sofort abbricht + und bereits verschobene Zwischendateien restlos zurückrollt. + """ + fa = tmp_path / "file_a_new.txt" + fb = tmp_path / "file_b_new.txt" + fa.write_text("data_a", encoding="utf-8") + fb.write_text("data_b", encoding="utf-8") + + history = [ + (str(fa), str(tmp_path / "file_a_orig.txt")), + (str(fb), str(tmp_path / "file_b_orig.txt")), + ] + + orig_rename = os.rename + counter = {"calls": 0} + + def failing_rb(src, dst): + counter["calls"] += 1 + # Erste Datei in Phase 1 gelingt, zweite schlägt fehl + if counter["calls"] == 2: + raise OSError("Fehler bei Phase 1 des Rollbacks") + return orig_rename(src, dst) + + with patch("os.rename", side_effect=failing_rb): + restored, errors = rollback_rename(history) + + assert restored == 0 + assert len(errors) > 0 + + # Beide Dateien müssen weiterhin unter ihren aktuellen Pfaden existieren (keine verwaisten __ep_rb_tmp_) + assert fa.exists() and fa.read_text(encoding="utf-8") == "data_a" + assert fb.exists() and fb.read_text(encoding="utf-8") == "data_b" + assert not any(p.name.startswith(".__ep_rb_tmp_") for p in tmp_path.iterdir()) + + +def test_batch_rename_dialog_rollback_and_path_synchronization(tmp_path: Path): + """ + Prüft, dass BatchRenameDialog nach einem Rollback self.file_paths korrekt + auf die tatsächlich existierenden Originalpfade zurücksynchronisiert, + sodass Folgeaktionen sauber funktionieren und der Dialog geöffnet bleibt. + """ + _ensure_app() + f1 = tmp_path / "report1.txt" + f2 = tmp_path / "report2.txt" + f1.write_text("r1", encoding="utf-8") + f2.write_text("r2", encoding="utf-8") + + dlg = BatchRenameDialog([str(f1), str(f2)]) + dlg.prefix_edit.setText("2026_") + dlg._update_preview() + + assert dlg.rename_btn.isEnabled() is True + + # 1. Umbenennung durchführen + with patch("PySide6.QtWidgets.QMessageBox.information"): + dlg._do_rename() + + renamed1 = tmp_path / "2026_report1.txt" + renamed2 = tmp_path / "2026_report2.txt" + assert renamed1.exists() + assert renamed2.exists() + assert dlg.rollback_btn.isEnabled() is True + assert dlg.file_paths == [str(renamed1), str(renamed2)] + + # 2. Rollback durchführen + with patch("PySide6.QtWidgets.QMessageBox.information"): + dlg._do_rollback() + + assert f1.exists() + assert f2.exists() + assert not renamed1.exists() + assert not renamed2.exists() + assert dlg.rollback_btn.isEnabled() is False + + # file_paths muss jetzt wieder auf die wiederhergestellten Dateien verweisen! + assert dlg.file_paths == [str(f1), str(f2)] + assert all(os.path.exists(p) for p in dlg.file_paths) + + # Schließen des Dialogs + dlg._on_close_clicked() + dlg.close() + + +def test_batch_rename_dialog_deduplication(tmp_path: Path): + """ + Prüft, dass mehrfach übergebene identische Dateipfade in BatchRenameDialog + ohne Verlust der Reihenfolge dedupliziert werden. + """ + _ensure_app() + f1 = tmp_path / "item.txt" + f1.write_text("content", encoding="utf-8") + + dlg = BatchRenameDialog([str(f1), str(f1), str(f1)]) + assert len(dlg.file_paths) == 1 + assert dlg.table.rowCount() == 1 + dlg.close() From 8a3989c783365676a7cd74002f678cde93af958d Mon Sep 17 00:00:00 2001 From: lukisch <55554266+lukisch@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:35:44 +0200 Subject: [PATCH 12/13] feat(browser): add properties dialog, copy path suite, and terminal integration [TW-EP-11] --- CHANGELOG.md | 22 + locales/translations.json | 984 ++++++++++++++++++++++++++ src/core/platform_utils.py | 36 + src/gui/browser/file_browser.py | 100 +++ src/gui/main_window.py | 34 + src/gui/properties_dialog.py | 374 ++++++++++ tests/test_properties_and_terminal.py | 235 ++++++ 7 files changed, 1785 insertions(+) create mode 100644 src/gui/properties_dialog.py create mode 100644 tests/test_properties_and_terminal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 81bc1dd..651afcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### 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). + - Umfassende Metadaten-Anzeige: Name, Dateityp, Pfad, Größe (formatiert und in Bytes), Zeitstempel (Erstellt, Geändert, Letzter Zugriff), Dateiattribute/Berechtigungen. + - Rekursive Ordner-Statistiken: Zählung von Unterdateien und -ordnern sowie Gesamtgröße mittels Hintergrund-Thread `FolderStatsWorker` ohne GUI-Blockade. + - On-Demand Prüfsummen-Berechnung (SHA-256, MD5) mit direktem Kopier-Button für Integritätsprüfungen. + - Zeilen-, Wort- und Zeichenzähler für gängige Text- und Codeformate. + - Tastenkürzel `Alt+Enter` bzw. `Alt+Return` im Dateibrowser und Hauptfenster. +- **Pfad-Kopier-Suite (`src/gui/browser/file_browser.py`, `src/gui/main_window.py`)**: + - Erweiterte Zwischenablage-Aktionen: "Pfad kopieren" (`Ctrl+Shift+C`) kopiert native Pfade (bei Mehrfachauswahl zeilenweise umgebrochen). + - Integration in das Kontextmenü für ausgewählte Elemente ("📋 Pfad kopieren") und Leerbereiche ("📋 Ordnerpfad kopieren"). + - Menü-Verdrahtung in "Bearbeiten -> Pfad kopieren" (`Ctrl+Shift+C`). +- **Cross-Platform Terminal-Integration (`src/core/platform_utils.py`, `src/gui/browser/file_browser.py`, `src/gui/main_window.py`)**: + - `open_terminal_in_directory(path)` & `get_terminal_command(directory)`: Plattformunabhängiges Öffnen des systemweiten Standard-Terminals im Zielverzeichnis (Windows Terminal / PowerShell / CMD auf Windows, Terminal.app auf macOS, xdg-terminal-exec / gnome-terminal / konsole / xterm auf Linux). + - Entkoppelte Ausführung via `subprocess.Popen` mit `CREATE_NEW_CONSOLE` auf Windows. + - Kontextmenü-Aktionen: "💻 Im Terminal öffnen" für Ordner bzw. Elternverzeichnisse und "💻 Terminal hier öffnen" im Leerbereich. + - Menü-Verdrahtung in "Tools -> 💻 Terminal hier öffnen" (`Ctrl+Shift+P`). +- **Lokalisierung (Tier-2 P-006)**: + - 123 neue Lokalisierungsschlüssel lückenlos über alle 6 Zielsprachen (DE, EN, ES, ZH, JA, RU) in `locales/translations.json` integriert (Katalog auf 356 Schlüssel erweitert; `manage_translations.py --check` meldet 0 fehlende Übersetzungen). +- **Test-Abdeckung (`tests/test_properties_and_terminal.py`)**: + - 17 neue automatisierte Unit- und Integrationstests für Terminal-Befehlserkennung, Größenformatierung, Ordnerstatistik-Berechnung, Dialog-Initialisierung, Pfad-Kopieren und Menü-Verdrahtung (Gesamttestsuite: 405 passed, 1 skipped). + ### Bugsweep & Resilienz-Härtung (2026-10-01, Turnusgemäßer Bugsweep) - **Mehrfachumbenennung (Batch Rename) Resilienz**: - `src/core/batch_rename_service.py`: diff --git a/locales/translations.json b/locales/translations.json index 64f25eb..ff39cfe 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -1862,5 +1862,989 @@ "zh": "浏览...", "ja": "参照...", "ru": "Обзор..." + }, + "Pfad kopieren": { + "de": "Pfad kopieren", + "en": "Copy Path", + "es": "Copiar ruta", + "zh": "复制路径", + "ja": "パスをコピー", + "ru": "Копировать путь" + }, + "📋 Pfad kopieren": { + "de": "📋 Pfad kopieren", + "en": "📋 Copy Path", + "es": "📋 Copiar ruta", + "zh": "📋 复制路径", + "ja": "📋 パスをコピー", + "ru": "📋 Копировать путь" + }, + "📋 Ordnerpfad kopieren": { + "de": "📋 Ordnerpfad kopieren", + "en": "📋 Copy Folder Path", + "es": "📋 Copiar ruta de carpeta", + "zh": "📋 复制文件夹路径", + "ja": "📋 フォルダパスをコピー", + "ru": "📋 Копировать путь к папке" + }, + "Eigenschaften...": { + "de": "Eigenschaften...", + "en": "Properties...", + "es": "Propiedades...", + "zh": "属性...", + "ja": "プロパティ...", + "ru": "Свойства..." + }, + "ℹ️ Eigenschaften...": { + "de": "ℹ️ Eigenschaften...", + "en": "ℹ️ Properties...", + "es": "ℹ️ Propiedades...", + "zh": "ℹ️ 属性...", + "ja": "ℹ️ プロパティ...", + "ru": "ℹ️ Свойства..." + }, + "💻 Im Terminal öffnen": { + "de": "💻 Im Terminal öffnen", + "en": "💻 Open in Terminal", + "es": "💻 Abrir en terminal", + "zh": "💻 在终端中打开", + "ja": "💻 ターミナルで開く", + "ru": "💻 Открыть в терминале" + }, + "💻 Terminal hier öffnen": { + "de": "💻 Terminal hier öffnen", + "en": "💻 Open Terminal Here", + "es": "💻 Abrir terminal aquí", + "zh": "💻 在此处打开终端", + "ja": "💻 ここでターミナルを開く", + "ru": "💻 Открыть терминал здесь" + }, + "Eigenschaften von {name}": { + "de": "Eigenschaften von {name}", + "en": "Properties of {name}", + "es": "Propiedades de {name}", + "zh": "{name} 的属性", + "ja": "{name} のプロパティ", + "ru": "Свойства {name}" + }, + "Datei-Eigenschaften Dialog": { + "de": "Datei-Eigenschaften Dialog", + "en": "File Properties Dialog", + "es": "Diálogo de propiedades de archivo", + "zh": "文件属性对话框", + "ja": "ファイルプロパティダイアログ", + "ru": "Диалог свойств файла" + }, + "Zeigt detaillierte Datei- und Ordnereigenschaften, Zeitstempel, Prüfsummen und Berechtigungen an.": { + "de": "Zeigt detaillierte Datei- und Ordnereigenschaften, Zeitstempel, Prüfsummen und Berechtigungen an.", + "en": "Displays detailed file and folder properties, timestamps, checksums, and permissions.", + "es": "Muestra propiedades detalladas de archivos y carpetas, marcas de tiempo, sumas de comprobación y permisos.", + "zh": "显示详细的文件和文件夹属性、时间戳、校验和及权限。", + "ja": "詳細なファイルおよびフォルダのプロパティ、タイムスタンプ、チェックサム、権限を表示します。", + "ru": "Отображает подробные свойства файлов и папок, метки времени, контрольные суммы и разрешения." + }, + "Elementname": { + "de": "Elementname", + "en": "Item Name", + "es": "Nombre del elemento", + "zh": "项目名称", + "ja": "項目名", + "ru": "Имя элемента" + }, + "Eigenschaften-Reiter": { + "de": "Eigenschaften-Reiter", + "en": "Properties Tabs", + "es": "Pestañas de propiedades", + "zh": "属性标签页", + "ja": "プロパティタブ", + "ru": "Вкладки свойств" + }, + "Prüfsummen & Details": { + "de": "Prüfsummen & Details", + "en": "Checksums & Details", + "es": "Checksums & Details", + "zh": "校验和与详情", + "ja": "チェックサムと詳細", + "ru": "Контрольные суммы и подробности" + }, + "Vollständigen Dateipfad in die Zwischenablage kopieren": { + "de": "Vollständigen Dateipfad in die Zwischenablage kopieren", + "en": "Copy full file path to clipboard", + "es": "Copiar ruta completa de archivo al portapapeles", + "zh": "复制完整文件路径到剪贴板", + "ja": "完全なファイルパスをクリップボードにコピー", + "ru": "Скопировать полный путь к файлу в буфер обмена" + }, + "📂 Im Dateimanager öffnen": { + "de": "📂 Im Dateimanager öffnen", + "en": "📂 Open in File Manager", + "es": "📂 Abrir en administrador de archivos", + "zh": "📂 在文件管理器中打开", + "ja": "📂 ファイルマネージャーで開く", + "ru": "📂 Открыть в диспетчере файлов" + }, + "Speicherort im System-Dateimanager anzeigen": { + "de": "Speicherort im System-Dateimanager anzeigen", + "en": "Show location in system file manager", + "es": "Mostrar ubicación en el administrador de archivos del sistema", + "zh": "在系统文件管理器中显示位置", + "ja": "システムのファイルマネージャーで場所を表示", + "ru": "Показать расположение в системном диспетчере файлов" + }, + "Element nicht gefunden": { + "de": "Element nicht gefunden", + "en": "Item not found", + "es": "Elemento no encontrado", + "zh": "未找到项目", + "ja": "項目が見つかりません", + "ru": "Элемент не найден" + }, + "Fehler beim Lesen": { + "de": "Fehler beim Lesen", + "en": "Error reading", + "es": "Error de lectura", + "zh": "读取错误", + "ja": "読み取りエラー", + "ru": "Ошибка чтения" + }, + "Dateiordner": { + "de": "Dateiordner", + "en": "File Folder", + "es": "Carpeta de archivos", + "zh": "文件夹", + "ja": "ファイルフォルダ", + "ru": "Папка с файлами" + }, + "Datei": { + "de": "Datei", + "en": "File", + "es": "Archivo", + "zh": "文件", + "ja": "ファイル", + "ru": "Файл" + }, + "Speicherort:": { + "de": "Speicherort:", + "en": "Location:", + "es": "Ubicación:", + "zh": "位置:", + "ja": "場所:", + "ru": "Расположение:" + }, + "Dateien": { + "de": "Dateien", + "en": "Files", + "es": "Archivos", + "zh": "文件", + "ja": "ファイル", + "ru": "Файлы" + }, + "Ordner": { + "de": "Ordner", + "en": "Folders", + "es": "Carpetas", + "zh": "文件夹", + "ja": "フォルダ", + "ru": "Папки" + }, + "Inhalt:": { + "de": "Inhalt:", + "en": "Contents:", + "es": "Contenido:", + "zh": "内容:", + "ja": "内容:", + "ru": "Содержимое:" + }, + "Unbekannt": { + "de": "Unbekannt", + "en": "Unknown", + "es": "Desconocido", + "zh": "未知", + "ja": "不明", + "ru": "Неизвестно" + }, + "Letzter Zugriff:": { + "de": "Letzter Zugriff:", + "en": "Accessed:", + "es": "Último acceso:", + "zh": "访问时间:", + "ja": "アクセス日時:", + "ru": "Открыт:" + }, + "Schreibgeschützt": { + "de": "Schreibgeschützt", + "en": "Read-only", + "es": "Solo lectura", + "zh": "只读", + "ja": "読み取り専用", + "ru": "Только чтение" + }, + "Schreibbar": { + "de": "Schreibbar", + "en": "Writable", + "es": "Escribible", + "zh": "可写", + "ja": "書き込み可能", + "ru": "Запись разрешена" + }, + "Versteckt": { + "de": "Versteckt", + "en": "Hidden", + "es": "Oculto", + "zh": "隐藏", + "ja": "隠し", + "ru": "Скрытый" + }, + "Attribute:": { + "de": "Attribute:", + "en": "Attributes:", + "es": "Atributos:", + "zh": "属性:", + "ja": "属性:", + "ru": "Атрибуты:" + }, + "Kryptografische Prüfsummen": { + "de": "Kryptografische Prüfsummen", + "en": "Cryptographic Checksums", + "es": "Sumas de comprobación criptográficas", + "zh": "加密校验和", + "ja": "暗号化チェックサム", + "ru": "Криптографические контрольные суммы" + }, + "Datei > 50 MB: Klick zum Berechnen": { + "de": "Datei > 50 MB: Klick zum Berechnen", + "en": "File > 50 MB: Click to calculate", + "es": "Archivo > 50 MB: Clic para calcular", + "zh": "文件 > 50 MB: 点击计算", + "ja": "ファイル > 50 MB: クリックして計算", + "ru": "Файл > 50 МБ: Нажмите для вычисления" + }, + "Prüfsummen jetzt berechnen": { + "de": "Prüfsummen jetzt berechnen", + "en": "Calculate checksums now", + "es": "Calcular sumas de comprobación ahora", + "zh": "立即计算校验和", + "ja": "今すぐチェックサムを計算", + "ru": "Вычислить контрольные суммы сейчас" + }, + "Prüfsummen-Fehler": { + "de": "Prüfsummen-Fehler", + "en": "Checksum Error", + "es": "Error de suma de comprobación", + "zh": "校验和错误", + "ja": "チェックサムエラー", + "ru": "Ошибка контрольной суммы" + }, + "Text-Metriken": { + "de": "Text-Metriken", + "en": "Text Metrics", + "es": "Métricas de texto", + "zh": "文本指标", + "ja": "テキスト指標", + "ru": "Метрики текста" + }, + "Zeilen:": { + "de": "Zeilen:", + "en": "Lines:", + "es": "Líneas:", + "zh": "行数:", + "ja": "行数:", + "ru": "Строк:" + }, + "Wörter:": { + "de": "Wörter:", + "en": "Words:", + "es": "Palabras:", + "zh": "字数:", + "ja": "単語数:", + "ru": "Слов:" + }, + "Zeichen:": { + "de": "Zeichen:", + "en": "Characters:", + "es": "Caracteres:", + "zh": "字符数:", + "ja": "文字数:", + "ru": "Символов:" + }, + "Ordner-Struktur": { + "de": "Ordner-Struktur", + "en": "Folder Structure", + "es": "Estructura de carpetas", + "zh": "文件夹结构", + "ja": "フォルダ構造", + "ru": "Структура папок" + }, + "Relativer Pfad:": { + "de": "Relativer Pfad:", + "en": "Relative Path:", + "es": "Ruta relativa:", + "zh": "相对路径:", + "ja": "相対パス:", + "ru": "Относительный путь:" + }, + "Pfad kopiert": { + "de": "Pfad kopiert", + "en": "Path Copied", + "es": "Ruta copiada", + "zh": "路径已复制", + "ja": "パスをコピーしました", + "ru": "Путь скопирован" + }, + "📋 Pfad kopiert": { + "de": "📋 Pfad kopiert", + "en": "📋 Path Copied", + "es": "📋 Ruta copiada", + "zh": "📋 路径已复制", + "ja": "📋 パスをコピーしました", + "ru": "📋 Путь скопирован" + }, + "Vollständiger Pfad in die Zwischenablage kopiert:": { + "de": "Vollständiger Pfad in die Zwischenablage kopiert:", + "en": "Full path copied to clipboard:", + "es": "Ruta completa copiada al portapapeles:", + "zh": "完整路径已复制到剪贴板:", + "ja": "完全なパスをクリップボードにコピーしました:", + "ru": "Полный путь скопирован в буфер обмена:" + }, + "Dateimanager öffnen": { + "de": "Dateimanager öffnen", + "en": "Open File Manager", + "es": "Abrir administrador de archivos", + "zh": "打开文件管理器", + "ja": "ファイルマネージャーを開く", + "ru": "Открыть диспетчер файлов" + }, + "Konnte Ordner nicht öffnen": { + "de": "Konnte Ordner nicht öffnen", + "en": "Could not open folder", + "es": "No se pudo abrir la carpeta", + "zh": "无法打开文件夹", + "ja": "フォルダを開けませんでした", + "ru": "Не удалось открыть папку" + }, + "Terminal öffnen": { + "de": "Terminal öffnen", + "en": "Open Terminal", + "es": "Abrir terminal", + "zh": "打开终端", + "ja": "ターミナルを開く", + "ru": "Открыть терминал" + }, + "Konnte Terminal nicht öffnen": { + "de": "Konnte Terminal nicht öffnen", + "en": "Could not open terminal", + "es": "No se pudo abrir el terminal", + "zh": "无法打开终端", + "ja": "ターミナルを開けませんでした", + "ru": "Не удалось открыть терминал" + }, + "Fehler": { + "de": "Fehler", + "en": "Error", + "es": "Error", + "zh": "错误", + "ja": "エラー", + "ru": "Ошибка" + }, + "Alle berechneten Prüfsummen (MD5, SHA-1, SHA-256, SHA-512) in die Zwischenablage kopieren": { + "de": "Alle berechneten Prüfsummen (MD5, SHA-1, SHA-256, SHA-512) in die Zwischenablage kopieren", + "en": "Copy all computed checksums (MD5, SHA-1, SHA-256, SHA-512) to clipboard", + "es": "Copiar todas las sumas calculadas (MD5, SHA-1, SHA-256, SHA-512) al portapapeles", + "zh": "复制所有计算的校验和 (MD5, SHA-1, SHA-256, SHA-512) 到剪贴板", + "ja": "計算されたすべてのチェックサム (MD5, SHA-1, SHA-256, SHA-512) をクリップボードにコピー", + "ru": "Скопировать все вычисленные контрольные суммы (MD5, SHA-1, SHA-256, SHA-512) в буфер обмена" + }, + "Apps durchsuchen": { + "de": "Apps durchsuchen", + "en": "Search apps", + "es": "Buscar aplicaciones", + "zh": "搜索应用", + "ja": "アプリを検索", + "ru": "Поиск приложений" + }, + "Arbeitsblatt der Excel-Arbeitsmappe auswählen": { + "de": "Arbeitsblatt der Excel-Arbeitsmappe auswählen", + "en": "Select worksheet of the Excel workbook", + "es": "Seleccionar hoja del libro de Excel", + "zh": "选择 Excel 工作簿的工作表", + "ja": "Excel ワークブックのワークシートを選択", + "ru": "Выбрать лист книги Excel" + }, + "Auf bestimmte Dateitypen einschränken": { + "de": "Auf bestimmte Dateitypen einschränken", + "en": "Restrict to specific file types", + "es": "Restringir a tipos de archivo específicos", + "zh": "限制为特定文件类型", + "ja": "特定のファイルタイプに制限", + "ru": "Ограничить определенными типами файлов" + }, + "Basisschriftgröße in Punkten": { + "de": "Basisschriftgröße in Punkten", + "en": "Base font size in points", + "es": "Tamaño de fuente base en puntos", + "zh": "基本字体大小(磅)", + "ja": "基本フォントサイズ(ポイント)", + "ru": "Базовый размер шрифта в пунктах" + }, + "Dateiauswahldialog öffnen": { + "de": "Dateiauswahldialog öffnen", + "en": "Open file selection dialog", + "es": "Abrir diálogo de selección de archivos", + "zh": "打开文件选择对话框", + "ja": "ファイル選択ダイアログを開く", + "ru": "Открыть диалог выбора файла" + }, + "Dateidialog zur Auswahl des Startordners öffnen": { + "de": "Dateidialog zur Auswahl des Startordners öffnen", + "en": "Open directory dialog to choose startup folder", + "es": "Abrir diálogo de directorio para elegir carpeta de inicio", + "zh": "打开目录对话框以选择启动文件夹", + "ja": "起動フォルダを選択するダイアログを開く", + "ru": "Открыть диалог для выбора стартовой папки" + }, + "Dateien fortlaufend nummerieren": { + "de": "Dateien fortlaufend nummerieren", + "en": "Number files sequentially", + "es": "Numerar archivos secuencialmente", + "zh": "连续对文件编号", + "ja": "ファイルを連番で番号付け", + "ru": "Последовательно нумеровать файлы" + }, + "Dateien über dieser Größe werden nicht indiziert": { + "de": "Dateien über dieser Größe werden nicht indiziert", + "en": "Files larger than this size will not be indexed", + "es": "Archivos mayores a este tamaño no serán indexados", + "zh": "超过此大小的文件不会被索引", + "ja": "このサイズを超えるファイルはインデックスされません", + "ru": "Файлы больше этого размера не индексируются" + }, + "Dateiendungen kommagetrennt eingeben": { + "de": "Dateiendungen kommagetrennt eingeben", + "en": "Enter comma-separated file extensions", + "es": "Ingrese extensiones de archivo separadas por coma", + "zh": "输入以逗号分隔的文件扩展名", + "ja": "カンマ区切りで拡張子を入力", + "ru": "Введите расширения файлов через запятую" + }, + "Dateinamen durchsuchen": { + "de": "Dateinamen durchsuchen", + "en": "Search file names", + "es": "Buscar nombres de archivos", + "zh": "搜索文件名", + "ja": "ファイル名を検索", + "ru": "Поиск по именам файлов" + }, + "Dateivergleich starten (Strg+Enter)": { + "de": "Dateivergleich starten (Strg+Enter)", + "en": "Start file comparison (Ctrl+Enter)", + "es": "Iniciar comparación de archivos (Ctrl+Enter)", + "zh": "启动文件比较 (Ctrl+Enter)", + "ja": "ファイル比較を開始 (Ctrl+Enter)", + "ru": "Начать сравнение файлов (Ctrl+Enter)" + }, + "Ergebnistabelle (Doppelklick oder Enter zum Öffnen)": { + "de": "Ergebnistabelle (Doppelklick oder Enter zum Öffnen)", + "en": "Results table (double-click or Enter to open)", + "es": "Tabla de resultados (doble clic o Enter para abrir)", + "zh": "结果表(双击或按 Enter 打开)", + "ja": "結果テーブル(ダブルクリックまたは Enter で開く)", + "ru": "Таблица результатов (дважды щелкните или нажмите Enter для открытия)" + }, + "Erste Seite von PDF-Dokumenten im Vorschaufenster darstellen": { + "de": "Erste Seite von PDF-Dokumenten im Vorschaufenster darstellen", + "en": "Display first page of PDF documents in preview window", + "es": "Mostrar primera página de documentos PDF en vista previa", + "zh": "在预览窗口中显示 PDF 文档的第一页", + "ja": "プレビューウィンドウに PDF ドキュメントの最初のページを表示", + "ru": "Отображать первую страницу PDF-документов в окне предпросмотра" + }, + "Exakte Schreibweise beim Suchen berücksichtigen": { + "de": "Exakte Schreibweise beim Suchen berücksichtigen", + "en": "Match exact case when searching", + "es": "Respetar mayúsculas y minúsculas al buscar", + "zh": "搜索时区分大小写", + "ja": "検索時に大文字と小文字を区別", + "ru": "Учитывать точный регистр при поиске" + }, + "Farbschema der Benutzeroberfläche": { + "de": "Farbschema der Benutzeroberfläche", + "en": "User interface color scheme", + "es": "Esquema de color de la interfaz de usuario", + "zh": "用户界面配色方案", + "ja": "ユーザーインターフェースの配色", + "ru": "Цветовая схема пользовательского интерфейса" + }, + "Fehler: Pfad ist ein Verzeichnis, keine Datei.": { + "de": "Fehler: Pfad ist ein Verzeichnis, keine Datei.", + "en": "Error: Path is a directory, not a file.", + "es": "Error: La ruta es un directorio, no un archivo.", + "zh": "错误:路径是一个目录,不是文件。", + "ja": "エラー: パスはディレクトリであり、ファイルではありません。", + "ru": "Ошибка: путь является папкой, а не файлом." + }, + "Fenstergröße und Position beim Beenden speichern": { + "de": "Fenstergröße und Position beim Beenden speichern", + "en": "Save window size and position on exit", + "es": "Guardar tamaño y posición de la ventana al salir", + "zh": "退出时保存窗口大小和位置", + "ja": "終了時にウィンドウのサイズと位置を保存", + "ru": "Сохранять размер и положение окна при выходе" + }, + "Freitextnotizen zur Datei eingeben": { + "de": "Freitextnotizen zur Datei eingeben", + "en": "Enter free-form notes for file", + "es": "Ingrese notas libres para el archivo", + "zh": "输入文件的自定义备注", + "ja": "ファイルに関するメモを入力", + "ru": "Ввести заметки к файлу" + }, + "Führt die geplante Umbenennung für alle Dateien aus (Enter)": { + "de": "Führt die geplante Umbenennung für alle Dateien aus (Enter)", + "en": "Executes planned renaming for all files (Enter)", + "es": "Ejecuta el renombrado planificado para todos los archivos (Enter)", + "zh": "执行所有文件的预定重命名 (Enter)", + "ja": "すべてのファイルの計画された名前変更を実行 (Enter)", + "ru": "Выполнить запланированное переименование всех файлов (Enter)" + }, + "Geben Sie hier einen Hash ein, um die Integrität automatisch zu überprüfen": { + "de": "Geben Sie hier einen Hash ein, um die Integrität automatisch zu überprüfen", + "en": "Enter a hash here to verify integrity automatically", + "es": "Ingrese un hash aquí para verificar la integridad automáticamente", + "zh": "在此输入哈希值以自动验证完整性", + "ja": "整合性を自動検証するためにここにハッシュを入力してください", + "ru": "Введите здесь хеш для автоматической проверки целостности" + }, + "Groß- und Kleinschreibung des Namens anpassen": { + "de": "Groß- und Kleinschreibung des Namens anpassen", + "en": "Adjust name casing", + "es": "Ajustar mayúsculas y minúsculas del nombre", + "zh": "调整名称大小写", + "ja": "名前の大文字・小文字を調整", + "ru": "Настроить регистр имени" + }, + "Groß-/Kleinschreibung bei der Mustersuche berücksichtigen": { + "de": "Groß-/Kleinschreibung bei der Mustersuche berücksichtigen", + "en": "Match case during pattern search", + "es": "Respetar mayúsculas y minúsculas en la búsqueda de patrones", + "zh": "模式搜索时区分大小写", + "ja": "パターン検索で大文字・小文字を区別", + "ru": "Учитывать регистр при поиске шаблонов" + }, + "Größe der Dateisymbole in Pixeln": { + "de": "Größe der Dateisymbole in Pixeln", + "en": "File icon size in pixels", + "es": "Tamaño de los iconos de archivo en píxeles", + "zh": "文件图标像素大小", + "ja": "ファイルアイコンのサイズ(ピクセル)", + "ru": "Размер значков файлов в пикселях" + }, + "Indexprüfung direkt beim Start von ExplorerPro durchführen": { + "de": "Indexprüfung direkt beim Start von ExplorerPro durchführen", + "en": "Run index check on ExplorerPro startup", + "es": "Comprobar índice al iniciar ExplorerPro", + "zh": "在 ExplorerPro 启动时执行索引检查", + "ja": "ExplorerPro 起動時にインデックスをチェック", + "ru": "Проверять индекс при запуске ExplorerPro" + }, + "Inkrement pro Datei": { + "de": "Inkrement pro Datei", + "en": "Increment per file", + "es": "Incremento por archivo", + "zh": "每个文件的增量", + "ja": "ファイルごとの増分", + "ru": "Шаг приращения на файл" + }, + "Kategorie auswählen oder neu eingeben": { + "de": "Kategorie auswählen oder neu eingeben", + "en": "Select or enter new category", + "es": "Seleccionar o ingresar nueva categoría", + "zh": "选择或输入新分类", + "ja": "カテゴリを選択または新規入力", + "ru": "Выбрать или ввести новую категорию" + }, + "Kategorie für die Einordnung des Prompts": { + "de": "Kategorie für die Einordnung des Prompts", + "en": "Category for classifying the prompt", + "es": "Categoría para clasificar el prompt", + "zh": "提示词分类", + "ja": "プロンプト分類用カテゴリ", + "ru": "Категория для классификации промпта" + }, + "Kommagetrennte Tags filtern": { + "de": "Kommagetrennte Tags filtern", + "en": "Filter comma-separated tags", + "es": "Filtrar etiquetas separadas por comas", + "zh": "过滤逗号分隔的标签", + "ja": "カンマ区切りタグをフィルタ", + "ru": "Фильтровать теги через запятую" + }, + "Kommagetrennte Tags für die Datei eingeben": { + "de": "Kommagetrennte Tags für die Datei eingeben", + "en": "Enter comma-separated tags for file", + "es": "Ingrese etiquetas separadas por comas para el archivo", + "zh": "输入文件的逗号分隔标签", + "ja": "ファイル用のカンマ区切りタグを入力", + "ru": "Введите теги для файла через запятую" + }, + "Kopieren hochsensibler Daten wie Passwörter und API-Keys blockieren": { + "de": "Kopieren hochsensibler Daten wie Passwörter und API-Keys blockieren", + "en": "Block copying of highly sensitive data like passwords and API keys", + "es": "Bloquear copia de datos altamente sensibles como contraseñas y claves API", + "zh": "阻止复制密码和 API 密钥等高度敏感数据", + "ja": "パスワードや API キーなどの機密データのコピーをブロック", + "ru": "Блокировать копирование конфиденциальных данных, таких как пароли и ключи API" + }, + "Macht die vorherige Umbenennungs-Aktion rückgängig": { + "de": "Macht die vorherige Umbenennungs-Aktion rückgängig", + "en": "Reverts the previous renaming action", + "es": "Deshace la acción de renombrado anterior", + "zh": "撤消之前的重命名操作", + "ja": "前の名前変更操作を元に戻す", + "ru": "Отменяет предыдущее действие переименования" + }, + "Maximale Dateigröße für generierte Vorschauen": { + "de": "Maximale Dateigröße für generierte Vorschauen", + "en": "Maximum file size for generated previews", + "es": "Tamaño máximo de archivo para vistas previas", + "zh": "生成预览的最大文件大小", + "ja": "生成されるプレビューの最大ファイルサイズ", + "ru": "Максимальный размер файла для предпросмотра" + }, + "Miniaturansichten für Grafikdateien rendern": { + "de": "Miniaturansichten für Grafikdateien rendern", + "en": "Render thumbnails for image files", + "es": "Renderizar miniaturas para archivos de imagen", + "zh": "为图像文件渲染缩略图", + "ja": "画像ファイルのサムネイルをレンダリング", + "ru": "Создавать миниатюры для графических файлов" + }, + "Neuer Text für gefundene Treffer": { + "de": "Neuer Text für gefundene Treffer", + "en": "New text for matched search terms", + "es": "Nuevo texto para coincidencias encontradas", + "zh": "匹配项的新文本", + "ja": "一致した項目の新しいテキスト", + "ru": "Новый текст для найденных совпадений" + }, + "Nur eigenständige Wörter als Treffer werten": { + "de": "Nur eigenständige Wörter als Treffer werten", + "en": "Match whole words only", + "es": "Coincidir solo palabras completas", + "zh": "仅匹配整个单词", + "ja": "単語全体のみ一致", + "ru": "Только слова целиком" + }, + "Pfad zur ausführbaren Datei": { + "de": "Pfad zur ausführbaren Datei", + "en": "Path to executable file", + "es": "Ruta al archivo ejecutable", + "zh": "可执行文件路径", + "ja": "実行可能ファイルへのパス", + "ru": "Путь к исполняемому файлу" + }, + "Position der Nummerierung im Dateinamen": { + "de": "Position der Nummerierung im Dateinamen", + "en": "Position of numbering in file name", + "es": "Posición de la numeración en el nombre del archivo", + "zh": "文件名中编号的位置", + "ja": "ファイル名内の番号の位置", + "ru": "Позиция нумерации в имени файла" + }, + "Programmsprache für Menüs und Dialoge": { + "de": "Programmsprache für Menüs und Dialoge", + "en": "Application language for menus and dialogs", + "es": "Idioma de la aplicación para menús y diálogos", + "zh": "菜单和对话框的程序语言", + "ja": "メニューとダイアログのプログラム言語", + "ru": "Язык программы для меню и диалогов" + }, + "Präfix vor den Dateinamen stellen": { + "de": "Präfix vor den Dateinamen stellen", + "en": "Prepend prefix to file name", + "es": "Anteponer prefijo al nombre de archivo", + "zh": "在文件名前添加前缀", + "ja": "ファイル名の前にプレフィックスを追加", + "ru": "Добавить префикс к имени файла" + }, + "Schlagwörter zur Filterung, kommagetrennt": { + "de": "Schlagwörter zur Filterung, kommagetrennt", + "en": "Comma-separated keywords for filtering", + "es": "Palabras clave separadas por comas para filtrar", + "zh": "用于过滤的逗号分隔关键词", + "ja": "フィルタ用のカンマ区切りキーワード", + "ru": "Ключевые слова для фильтрации через запятую" + }, + "Schließt das Dialogfenster (Esc)": { + "de": "Schließt das Dialogfenster (Esc)", + "en": "Closes the dialog window (Esc)", + "es": "Cierra la ventana de diálogo (Esc)", + "zh": "关闭对话框 (Esc)", + "ja": "ダイアログウィンドウを閉じる (Esc)", + "ru": "Закрывает диалоговое окно (Esc)" + }, + "Schließt das Mehrfachumbenennungs-Fenster (Esc)": { + "de": "Schließt das Mehrfachumbenennungs-Fenster (Esc)", + "en": "Closes the batch rename window (Esc)", + "es": "Cierra la ventana de renombrado múltiple (Esc)", + "zh": "关闭批量重命名窗口 (Esc)", + "ja": "一括名前変更ウィンドウを閉じる (Esc)", + "ru": "Закрывает окно массового переименования (Esc)" + }, + "Schließt das Prüfsummen-Fenster (Esc)": { + "de": "Schließt das Prüfsummen-Fenster (Esc)", + "en": "Closes the checksum window (Esc)", + "es": "Cierra la ventana de sumas de comprobación (Esc)", + "zh": "关闭校验和窗口 (Esc)", + "ja": "チェックサムウィンドウを閉じる (Esc)", + "ru": "Закрывает окно контрольных сумм (Esc)" + }, + "Schließt den Vergleichs-Dialog (Esc)": { + "de": "Schließt den Vergleichs-Dialog (Esc)", + "en": "Closes the diff dialog (Esc)", + "es": "Cierra el diálogo de comparación (Esc)", + "zh": "关闭比较对话框 (Esc)", + "ja": "比較ダイアログを閉じる (Esc)", + "ru": "Закрывает диалог сравнения (Esc)" + }, + "Setzt alle Kriterien und Filter auf Standardwerte zurück": { + "de": "Setzt alle Kriterien und Filter auf Standardwerte zurück", + "en": "Resets all criteria and filters to default values", + "es": "Restablece todos los criterios y filtros a valores predeterminados", + "zh": "重置所有条件和筛选器为默认值", + "ja": "すべての基準とフィルタをデフォルト値にリセット", + "ru": "Сбрасывает все критерии и фильтры на значения по умолчанию" + }, + "Sicherheitsabfrage vor dem unwiderruflichen Löschen einblenden": { + "de": "Sicherheitsabfrage vor dem unwiderruflichen Löschen einblenden", + "en": "Show confirmation dialog before permanent deletion", + "es": "Mostrar confirmación antes de la eliminación permanente", + "zh": "在永久删除前显示确认提示", + "ja": "完全削除の前に確認ダイアログを表示", + "ru": "Запрашивать подтверждение перед безвозвратным удалением" + }, + "Speichert die Anwendungseinstellungen (Enter)": { + "de": "Speichert die Anwendungseinstellungen (Enter)", + "en": "Saves application settings (Enter)", + "es": "Guarda la configuración de la aplicación (Enter)", + "zh": "保存应用程序设置 (Enter)", + "ja": "アプリケーション設定を保存 (Enter)", + "ru": "Сохраняет настройки приложения (Enter)" + }, + "Speichert die Datenschutz-Einstellungen (Enter)": { + "de": "Speichert die Datenschutz-Einstellungen (Enter)", + "en": "Saves privacy settings (Enter)", + "es": "Guarda la configuración de privacidad (Enter)", + "zh": "保存隐私设置 (Enter)", + "ja": "プライバシー設定を保存 (Enter)", + "ru": "Сохраняет настройки конфиденциальности (Enter)" + }, + "Standardverzeichnis beim Programmstart": { + "de": "Standardverzeichnis beim Programmstart", + "en": "Default directory on startup", + "es": "Directorio predeterminado al iniciar", + "zh": "启动时的默认目录", + "ja": "起動時のデフォルトディレクトリ", + "ru": "Папка по умолчанию при запуске" + }, + "Startet die erweiterte Suche (Enter)": { + "de": "Startet die erweiterte Suche (Enter)", + "en": "Starts advanced search (Enter)", + "es": "Inicia la búsqueda avanzada (Enter)", + "zh": "启动高级搜索 (Enter)", + "ja": "高度な検索を開始 (Enter)", + "ru": "Запускает расширенный поиск (Enter)" + }, + "Startnummer der Zählung": { + "de": "Startnummer der Zählung", + "en": "Start number for sequence", + "es": "Número inicial de la secuencia", + "zh": "序列起始编号", + "ja": "連番の開始番号", + "ru": "Начальный номер нумерации" + }, + "Suchbegriff als regulären Ausdruck auswerten": { + "de": "Suchbegriff als regulären Ausdruck auswerten", + "en": "Evaluate search term as regular expression", + "es": "Evaluar término de búsqueda como expresión regular", + "zh": "将搜索词作为正则表达式处理", + "ja": "検索語を正規表現として評価", + "ru": "Обрабатывать поисковый запрос как регулярное выражение" + }, + "Suchbegriff eingeben (Enter startet die Suche)": { + "de": "Suchbegriff eingeben (Enter startet die Suche)", + "en": "Enter search term (Enter starts search)", + "es": "Ingrese término de búsqueda (Enter inicia la búsqueda)", + "zh": "输入搜索词 (按 Enter 开始搜索)", + "ja": "検索語を入力 (Enter で検索開始)", + "ru": "Введите поисковый запрос (Enter запускает поиск)" + }, + "Suffix an den Dateinamen anhängen": { + "de": "Suffix an den Dateinamen anhängen", + "en": "Append suffix to file name", + "es": "Añadir sufijo al nombre de archivo", + "zh": "在文件名后添加后缀", + "ja": "ファイル名にサフィックスを追加", + "ru": "Добавить суффикс к имени файла" + }, + "Syntaxhervorhebung für Quellcode und Textdateien nutzen": { + "de": "Syntaxhervorhebung für Quellcode und Textdateien nutzen", + "en": "Enable syntax highlighting for code and text files", + "es": "Activar resaltado de sintaxis para código y texto", + "zh": "为源代码和文本文件启用语法高亮", + "ja": "ソースコードとテキストファイルのシンタックスハイライトを有効化", + "ru": "Включить подсветку синтаксиса для кода и текстовых файлов" + }, + "Versteckte Dateien und Systemordner im Dateibrowser anzeigen": { + "de": "Versteckte Dateien und Systemordner im Dateibrowser anzeigen", + "en": "Show hidden files and system folders in file browser", + "es": "Mostrar archivos ocultos y carpetas del sistema en el navegador", + "zh": "在文件浏览器中显示隐藏文件和系统文件夹", + "ja": "ファイルブラウザに隠しファイルとシステムフォルダを表示", + "ru": "Показывать скрытые файлы и системные папки в браузере" + }, + "Verwirft Änderungen (Esc)": { + "de": "Verwirft Änderungen (Esc)", + "en": "Discards changes (Esc)", + "es": "Descarta los cambios (Esc)", + "zh": "放弃更改 (Esc)", + "ja": "変更を破棄 (Esc)", + "ru": "Отменяет изменения (Esc)" + }, + "Verzeichnisse im Hintergrund automatisch für die Schnellsuche indizieren": { + "de": "Verzeichnisse im Hintergrund automatisch für die Schnellsuche indizieren", + "en": "Automatically index directories in background for quick search", + "es": "Indexar automáticamente carpetas en segundo plano para búsqueda rápida", + "zh": "在后台自动索引目录以进行快速搜索", + "ja": "クイック検索のためにバックグラウンドで自動インデックス", + "ru": "Автоматически индексировать папки в фоновом режиме для быстрого поиска" + }, + "Vollständigen Dateipfad durchsuchen": { + "de": "Vollständigen Dateipfad durchsuchen", + "en": "Search full file path", + "es": "Buscar en la ruta completa del archivo", + "zh": "搜索完整文件路径", + "ja": "完全なファイルパスを検索", + "ru": "Искать по полному пути к файлу" + }, + "Vollständiger Prompt-Text": { + "de": "Vollständiger Prompt-Text", + "en": "Full prompt text", + "es": "Texto completo del prompt", + "zh": "完整提示词文本", + "ja": "完全なプロンプトテキスト", + "ru": "Полный текст промпта" + }, + "Volltextinhalte durchsuchen": { + "de": "Volltextinhalte durchsuchen", + "en": "Search full text contents", + "es": "Buscar contenido de texto completo", + "zh": "搜索全文内容", + "ja": "フルテキストコンテンツを検索", + "ru": "Искать по полнотекстовому содержимому" + }, + "Vorschau des ausgewählten Prompts": { + "de": "Vorschau des ausgewählten Prompts", + "en": "Preview of selected prompt", + "es": "Vista previa del prompt seleccionado", + "zh": "选定提示词的预览", + "ja": "選択したプロンプトのプレビュー", + "ru": "Предпросмотр выбранного промпта" + }, + "Vorschauleiste auf der rechten Seite einblenden": { + "de": "Vorschauleiste auf der rechten Seite einblenden", + "en": "Show preview pane on the right side", + "es": "Mostrar panel de vista previa a la derecha", + "zh": "在右侧显示预览窗格", + "ja": "右側にプレビューペインを表示", + "ru": "Показывать панель предпросмотра справа" + }, + "Vorschautabelle aller zu ändernden Dateinamen": { + "de": "Vorschautabelle aller zu ändernden Dateinamen", + "en": "Preview table of all file names to be changed", + "es": "Tabla de vista previa de todos los nombres a cambiar", + "zh": "要更改的所有文件名的预览表", + "ja": "変更予定のすべてのファイル名のプレビュー表", + "ru": "Таблица предпросмотра всех изменяемых имен файлов" + }, + "Zeilenweiser Vergleich der beiden Dateien": { + "de": "Zeilenweiser Vergleich der beiden Dateien", + "en": "Line-by-line comparison of both files", + "es": "Comparación línea por línea de ambos archivos", + "zh": "两个文件的逐行比较", + "ja": "両ファイルの行ごとの比較", + "ru": "Построчное сравнение двух файлов" + }, + "Zeitraum der letzten Änderung auswählen": { + "de": "Zeitraum der letzten Änderung auswählen", + "en": "Select timeframe of last modification", + "es": "Seleccionar período de última modificación", + "zh": "选择最后修改的时间范围", + "ja": "最終変更の期間を選択", + "ru": "Выбрать временной интервал последнего изменения" + }, + "Zu ersetzender Teilstring im Dateinamen": { + "de": "Zu ersetzender Teilstring im Dateinamen", + "en": "Substring to replace in file name", + "es": "Subcadena a reemplazar en el nombre de archivo", + "zh": "要在文件名中替换的子字符串", + "ja": "ファイル名で置換する部分文字列", + "ru": "Подстрока для замены в имени файла" + }, + "Zwischenablage kontinuierlich auf schutzwürdige Muster überwachen": { + "de": "Zwischenablage kontinuierlich auf schutzwürdige Muster überwachen", + "en": "Continuously monitor clipboard for sensitive patterns", + "es": "Monitorear continuamente el portapapeles en busca de patrones sensibles", + "zh": "持续监控剪贴板以查找敏感模式", + "ja": "機密パターンがないかクリップボードを常時監視", + "ru": "Непрерывно отслеживать буфер обмена на наличие конфиденциальных шаблонов" + }, + "Öffnet den Bearbeitungsdialog für den Prompt": { + "de": "Öffnet den Bearbeitungsdialog für den Prompt", + "en": "Opens the edit dialog for the prompt", + "es": "Abre el diálogo de edición para el prompt", + "zh": "打开提示词的编辑对话框", + "ja": "プロンプトの編集ダイアログを開く", + "ru": "Открывает диалог редактирования промпта" + }, + "Öffnet den Ordner der ausgewählten Datei": { + "de": "Öffnet den Ordner der ausgewählten Datei", + "en": "Opens the folder of the selected file", + "es": "Abre la carpeta del archivo seleccionado", + "zh": "打开所选文件的文件夹", + "ja": "選択したファイルのフォルダを開く", + "ru": "Открывает папку выбранного файла" + }, + "Öffnet die Dateiauswahl für Datei 1 (Basis)": { + "de": "Öffnet die Dateiauswahl für Datei 1 (Basis)", + "en": "Opens file selection for file 1 (base)", + "es": "Abre la selección de archivos para el archivo 1 (base)", + "zh": "打开文件 1 (基础) 的文件选择", + "ja": "ファイル 1 (ベース) のファイル選択を開く", + "ru": "Открывает выбор файла для файла 1 (базовый)" + }, + "Öffnet die Dateiauswahl für Datei 2 (Vergleich)": { + "de": "Öffnet die Dateiauswahl für Datei 2 (Vergleich)", + "en": "Opens file selection for file 2 (comparison)", + "es": "Abre la selección de archivos para el archivo 2 (comparación)", + "zh": "打开文件 2 (比较) 的文件选择", + "ja": "ファイル 2 (比較) のファイル選択を開く", + "ru": "Открывает выбор файла для файла 2 (сравнение)" + }, + "Öffnet die Excel-Datei im Standardprogramm": { + "de": "Öffnet die Excel-Datei im Standardprogramm", + "en": "Opens Excel file in default application", + "es": "Abre el archivo de Excel en la aplicación predeterminada", + "zh": "在默认程序中打开 Excel 文件", + "ja": "デフォルトのアプリで Excel ファイルを開く", + "ru": "Открывает файл Excel в программе по умолчанию" + }, + "Öffnet die ausgewählte Datei": { + "de": "Öffnet die ausgewählte Datei", + "en": "Opens the selected file", + "es": "Abre el archivo seleccionado", + "zh": "打开选定的文件", + "ja": "選択したファイルを開く", + "ru": "Открывает выбранный файл" } } \ No newline at end of file diff --git a/src/core/platform_utils.py b/src/core/platform_utils.py index 030c73a..a19cee8 100644 --- a/src/core/platform_utils.py +++ b/src/core/platform_utils.py @@ -5,6 +5,9 @@ import sys +import shutil + + def get_system_open_command(path: str) -> list[str] | None: """Return the native desktop opener command for the current platform.""" if sys.platform.startswith("win"): @@ -22,3 +25,36 @@ def open_path_with_system(path: str) -> None: return # D4: timeout verhindert, dass ein haengender Shell-Opener den Prozess blockiert. subprocess.run(command, check=True, timeout=10) + + +def get_terminal_command(directory: str) -> list[str]: + """Return the platform-native terminal launcher command for a directory.""" + if sys.platform.startswith("win"): + if shutil.which("wt"): + return ["wt", "-d", directory] + if shutil.which("powershell"): + return ["powershell", "-NoExit", "-Command", f"Set-Location -LiteralPath '{directory}'"] + return ["cmd", "/K", f"cd /d {directory}"] + if sys.platform == "darwin": + return ["open", "-a", "Terminal", directory] + # Linux / BSD + for term in ["xdg-terminal-exec", "gnome-terminal", "konsole", "xfce4-terminal", "xterm"]: + if shutil.which(term): + if term == "gnome-terminal": + return ["gnome-terminal", f"--working-directory={directory}"] + if term == "konsole": + return ["konsole", "--workdir", directory] + if term == "xfce4-terminal": + return ["xfce4-terminal", f"--working-directory={directory}"] + return [term] + return ["xterm"] + + +def open_terminal_in_directory(path: str) -> subprocess.Popen: + """Launch a terminal emulator session located in the given directory or file parent.""" + target_dir = path if os.path.isdir(path) else os.path.dirname(path) + if not target_dir or not os.path.exists(target_dir): + target_dir = os.getcwd() + cmd = get_terminal_command(os.path.abspath(target_dir)) + flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) if sys.platform.startswith("win") else 0 + return subprocess.Popen(cmd, cwd=target_dir, creationflags=flags) diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index ca45c1f..6a79c87 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -72,6 +72,12 @@ def keyPressEvent(self, event): elif event.key() == Qt.Key.Key_F2: self._fb.rename_selection() event.accept() + elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and (event.modifiers() & Qt.KeyboardModifier.AltModifier): + self._fb.show_properties() + event.accept() + elif (event.modifiers() & Qt.KeyboardModifier.ControlModifier) and (event.modifiers() & Qt.KeyboardModifier.ShiftModifier) and event.key() == Qt.Key.Key_C: + self._fb.copy_path_to_clipboard() + event.accept() elif event.matches(QKeySequence.StandardKey.Copy): self._fb.copy_selection() event.accept() @@ -82,6 +88,7 @@ def keyPressEvent(self, event): super().keyPressEvent(event) + class FileBrowser(QWidget): """ Datei-Browser mit Tabellen-Ansicht @@ -393,6 +400,23 @@ def _show_context_menu(self, pos): rename_action.triggered.connect(lambda: self.rename_selection(file_path)) menu.addAction(rename_action) + menu.addSeparator() + + copy_path_action = QAction("📋 Pfad kopieren", self) + copy_path_action.setShortcut("Ctrl+Shift+C") + copy_path_action.triggered.connect(lambda: self.copy_path_to_clipboard()) + menu.addAction(copy_path_action) + + if not is_file or os.path.isdir(file_path): + term_action = QAction("💻 Im Terminal öffnen", self) + term_action.triggered.connect(lambda: self.open_terminal(file_path)) + menu.addAction(term_action) + + prop_action = QAction("ℹ️ Eigenschaften...", self) + prop_action.setShortcut("Alt+Enter") + prop_action.triggered.connect(lambda: self.show_properties(file_path)) + menu.addAction(prop_action) + else: # Leer-Bereich-Menü new_file = QAction("📄 Neue Datei...", self) @@ -409,6 +433,16 @@ def _show_context_menu(self, pos): menu.addSeparator() + copy_dir_path_action = QAction("📋 Ordnerpfad kopieren", self) + copy_dir_path_action.triggered.connect(lambda: self.copy_path_to_clipboard()) + menu.addAction(copy_dir_path_action) + + term_here_action = QAction("💻 Terminal hier öffnen", self) + term_here_action.triggered.connect(lambda: self.open_terminal()) + menu.addAction(term_here_action) + + menu.addSeparator() + paste_action = QAction("Einfügen", self) paste_action.setShortcut("Ctrl+V") paste_action.triggered.connect(self.paste_from_clipboard) @@ -416,11 +450,19 @@ def _show_context_menu(self, pos): menu.addSeparator() + prop_dir_action = QAction("ℹ️ Eigenschaften...", self) + prop_dir_action.setShortcut("Alt+Enter") + prop_dir_action.triggered.connect(lambda: self.show_properties(self._current_path)) + menu.addAction(prop_dir_action) + + menu.addSeparator() + refresh_action = QAction("Aktualisieren", self) refresh_action.setShortcut("F5") refresh_action.triggered.connect(self.refresh) menu.addAction(refresh_action) + menu.exec(QCursor.pos()) def _open_file(self, path: str): @@ -532,6 +574,64 @@ def copy_selection(self) -> bool: QApplication.clipboard().setMimeData(mime_data) return True + def copy_path_to_clipboard(self, relative: bool = False, name_only: bool = False) -> str: + """Kopiert ausgewählte Dateipfade oder den aktuellen Ordnerpfad als Reintext in die Zwischenablage.""" + paths = self.get_selected_files() + if not paths and self._current_path: + paths = [self._current_path] + if not paths: + return "" + + if name_only: + result_paths = [os.path.basename(p) for p in paths] + elif relative and self._current_path: + result_paths = [os.path.relpath(p, self._current_path) for p in paths] + else: + result_paths = paths + + text = "\n".join(result_paths) + QApplication.clipboard().setText(text) + return text + + def show_properties(self, target_path: str = None) -> bool: + """Öffnet den detaillierten Eigenschaften-Dialog für Datei oder Ordner.""" + if not target_path: + selected = self.get_selected_files() + target_path = selected[0] if selected else self._current_path + + if not target_path or not os.path.exists(target_path): + return False + + from gui.properties_dialog import FilePropertiesDialog + dialog = FilePropertiesDialog(target_path, self.window()) + dialog.exec() + return True + + def open_terminal(self, target_path: str = None) -> bool: + """Öffnet ein Terminalfenster im angegebenen Verzeichnis oder aktuellen Ordner.""" + if not target_path: + selected = self.get_selected_files() + if selected and os.path.isdir(selected[0]): + target_path = selected[0] + else: + target_path = self._current_path + + if not target_path or not os.path.exists(target_path): + return False + + from core.platform_utils import open_terminal_in_directory + try: + open_terminal_in_directory(target_path) + return True + except Exception as exc: + QMessageBox.warning( + self, + "Terminal öffnen", + f"Konnte Terminal nicht öffnen:\n{exc}" + ) + return False + + def paste_from_clipboard(self) -> bool: """Fügt Dateien/Ordner aus der Zwischenablage in den aktuellen Ordner ein.""" if not self._current_path: diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 1ac5f80..199cce5 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -292,6 +292,13 @@ def _setup_menu(self): file_menu.addSeparator() + properties_action = QAction("Eigenschaften...", self) + properties_action.setShortcut(QKeySequence("Alt+Enter")) + properties_action.triggered.connect(self._show_properties) + file_menu.addAction(properties_action) + + file_menu.addSeparator() + exit_action = QAction("Beenden", self) exit_action.setShortcut(QKeySequence("Alt+F4")) exit_action.triggered.connect(self.close) @@ -329,6 +336,12 @@ def _setup_menu(self): batch_rename_action.triggered.connect(self._batch_rename) edit_menu.addAction(batch_rename_action) + copy_path_action = QAction("Pfad kopieren", self) + copy_path_action.setShortcut(QKeySequence("Ctrl+Shift+C")) + copy_path_action.triggered.connect(self._copy_path) + edit_menu.addAction(copy_path_action) + + # ===== Ansicht-Menü ===== view_menu = menubar.addMenu("&Ansicht") @@ -398,8 +411,14 @@ def _setup_menu(self): diff_action.triggered.connect(self._compare_files) tools_menu.addAction(diff_action) + terminal_action = QAction("💻 Terminal hier öffnen", self) + terminal_action.setShortcut(QKeySequence("Ctrl+Shift+P")) + terminal_action.triggered.connect(self._open_terminal) + tools_menu.addAction(terminal_action) + tools_menu.addSeparator() + editor_action = QAction("✏️ Editor öffnen", self) editor_action.setShortcut(QKeySequence("F4")) editor_action.triggered.connect(self._open_editor) @@ -525,7 +544,22 @@ def _export_workspace(self): except Exception as e: QMessageBox.warning(self, "Export fehlgeschlagen", str(e)) + def _show_properties(self): + """Öffnet den Eigenschaften-Dialog für ausgewählte Datei oder Ordner.""" + self.file_browser.show_properties() + + def _copy_path(self): + """Kopiert den Pfad der Auswahl in die Zwischenablage.""" + copied = self.file_browser.copy_path_to_clipboard() + if copied: + self.statusBar().showMessage("📋 Pfad kopiert", 3000) + + def _open_terminal(self): + """Öffnet ein Terminal im aktuellen Verzeichnis.""" + self.file_browser.open_terminal() + def _open_folder(self): + """Öffnet einen Ordner-Dialog""" folder = QFileDialog.getExistingDirectory( self, "Ordner öffnen", diff --git a/src/gui/properties_dialog.py b/src/gui/properties_dialog.py new file mode 100644 index 0000000..547e35b --- /dev/null +++ b/src/gui/properties_dialog.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +properties_dialog.py - Detaillierter Datei- und Ordner-Eigenschaften-Dialog +======================================================================== +Zeigt Dateisystem-Attribute, Metadaten, rekursive Ordnergrößen, Zeitstempel, +Berechtigungen und kryptografische Prüfsummen (SHA-256, MD5) an. +""" + +from __future__ import annotations + +import os +import stat +import sys +from datetime import datetime +from pathlib import Path +from typing import Tuple + +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from core.checksum_service import compute_file_hashes +from core.file_icon_helper import get_file_icon +from core.platform_utils import open_path_with_system +from translator import t + + +def format_size(num_bytes: int) -> str: + """Formatiert Byte-Werte lesbar mit Tausendertrennpunkten und passender Einheit.""" + if num_bytes < 0: + return "0 Bytes" + if num_bytes < 1024: + return f"{num_bytes:,} Bytes".replace(",", ".") + for unit, factor in [("KB", 1024), ("MB", 1024**2), ("GB", 1024**3), ("TB", 1024**4)]: + if num_bytes < factor * 1024: + val = num_bytes / factor + val_str = f"{val:.2f}".replace(".", ",") + return f"{num_bytes:,} Bytes ({val_str} {unit})".replace(",", ".") + val = num_bytes / (1024**4) + val_str = f"{val:.2f}".replace(".", ",") + return f"{num_bytes:,} Bytes ({val_str} TB)".replace(",", ".") + + +def calculate_folder_stats(folder_path: str, max_files: int = 100_000) -> Tuple[int, int, int]: + """ + Berechnet rekursiv Dateianzahl, Ordneranzahl und Gesamtgröße eines Verzeichnisses. + Gibt (file_count, dir_count, total_bytes) zurück. + """ + file_count = 0 + dir_count = 0 + total_bytes = 0 + + if not os.path.exists(folder_path) or not os.path.isdir(folder_path): + return (0, 0, 0) + + try: + for root, dirs, files in os.walk(folder_path, followlinks=False): + dir_count += len(dirs) + for f in files: + file_count += 1 + fp = os.path.join(root, f) + try: + total_bytes += os.path.getsize(fp) + except (OSError, PermissionError): + pass + if file_count >= max_files: + break + if file_count >= max_files: + break + except (OSError, PermissionError): + pass + + return (file_count, dir_count, total_bytes) + + +class FilePropertiesDialog(QDialog): + """Eigenschaften-Dialog für Dateien und Ordner mit Detailreitern.""" + + def __init__(self, target_path: str, parent: QWidget | None = None): + super().__init__(parent) + self.target_path = os.path.abspath(target_path) + self.is_dir = os.path.isdir(self.target_path) + self.filename = os.path.basename(self.target_path) or self.target_path + + title_label = t("Eigenschaften von {name}").replace("{name}", self.filename) + self.setWindowTitle(title_label) + self.setMinimumWidth(520) + self.setMinimumHeight(440) + self.setModal(True) + self.setAccessibleName(t("Datei-Eigenschaften Dialog")) + self.setAccessibleDescription( + t("Zeigt detaillierte Datei- und Ordnereigenschaften, Zeitstempel, Prüfsummen und Berechtigungen an.") + ) + + self._setup_ui() + self._load_properties() + + def _setup_ui(self) -> None: + main_layout = QVBoxLayout(self) + main_layout.setSpacing(12) + + # Header: Icon + Name + header_layout = QHBoxLayout() + self.icon_label = QLabel(self) + icon = get_file_icon(self.target_path) + pixmap = icon.pixmap(QSize(48, 48)) + self.icon_label.setPixmap(pixmap) + self.icon_label.setFixedSize(52, 52) + header_layout.addWidget(self.icon_label) + + name_layout = QVBoxLayout() + self.name_edit = QLineEdit(self.filename, self) + self.name_edit.setReadOnly(True) + self.name_edit.setFont(QFont("", 11, QFont.Weight.Bold)) + self.name_edit.setAccessibleName(t("Elementname")) + name_layout.addWidget(self.name_edit) + + self.type_subtitle = QLabel(self) + self.type_subtitle.setStyleSheet("color: gray;") + name_layout.addWidget(self.type_subtitle) + + header_layout.addLayout(name_layout, 1) + main_layout.addLayout(header_layout) + + # Tabs + self.tabs = QTabWidget(self) + self.tabs.setAccessibleName(t("Eigenschaften-Reiter")) + + # Tab 1: Allgemein + self.tab_general = QWidget(self) + self.form_general = QFormLayout(self.tab_general) + self.form_general.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + self.form_general.setSpacing(8) + self.tabs.addTab(self.tab_general, t("Allgemein")) + + # Tab 2: Prüfsummen / Statistik + self.tab_checksums = QWidget(self) + self.layout_checksums = QVBoxLayout(self.tab_checksums) + self.layout_checksums.setSpacing(10) + self.tabs.addTab(self.tab_checksums, t("Prüfsummen & Details")) + + main_layout.addWidget(self.tabs, 1) + + # Bottom Buttons + btn_layout = QHBoxLayout() + + self.copy_path_btn = QPushButton(t("📋 Pfad kopieren"), self) + self.copy_path_btn.setToolTip(t("Vollständigen Dateipfad in die Zwischenablage kopieren")) + self.copy_path_btn.clicked.connect(self._copy_full_path) + btn_layout.addWidget(self.copy_path_btn) + + self.open_location_btn = QPushButton(t("📂 Im Dateimanager öffnen"), self) + self.open_location_btn.setToolTip(t("Speicherort im System-Dateimanager anzeigen")) + self.open_location_btn.clicked.connect(self._open_location) + btn_layout.addWidget(self.open_location_btn) + + btn_layout.addStretch() + + self.close_btn = QPushButton(t("Schließen"), self) + self.close_btn.setDefault(True) + self.close_btn.clicked.connect(self.accept) + btn_layout.addWidget(self.close_btn) + + main_layout.addLayout(btn_layout) + + def _load_properties(self) -> None: + """Liest Attribute vom Dateisystem und befüllt die Reiter.""" + if not os.path.exists(self.target_path): + self.type_subtitle.setText(t("Element nicht gefunden")) + return + + try: + st = os.stat(self.target_path) + except OSError as exc: + self.type_subtitle.setText(f"{t('Fehler beim Lesen')}: {exc}") + return + + # Dateityp ermitteln + ext = Path(self.target_path).suffix.lower() + if self.is_dir: + type_str = t("Dateiordner") + elif ext: + type_str = f"{ext[1:].upper()}-{t('Datei')} ({ext})" + else: + type_str = t("Datei") + self.type_subtitle.setText(type_str) + + # Tab 1: Formular befüllen + self._add_form_row(t("Dateityp:"), type_str) + + location_edit = QLineEdit(os.path.dirname(self.target_path), self) + location_edit.setReadOnly(True) + self.form_general.addRow(QLabel(t("Speicherort:")), location_edit) + + if self.is_dir: + file_count, dir_count, total_bytes = calculate_folder_stats(self.target_path) + size_str = format_size(total_bytes) + contains_str = f"{file_count:,} {t('Dateien')}, {dir_count:,} {t('Ordner')}".replace(",", ".") + self._add_form_row(t("Größe:"), size_str) + self._add_form_row(t("Inhalt:"), contains_str) + else: + self._add_form_row(t("Größe:"), format_size(st.st_size)) + + # Zeitstempel + try: + created_dt = datetime.fromtimestamp(st.st_ctime).strftime("%Y-%m-%d %H:%M:%S") + except (OSError, OverflowError, ValueError): + created_dt = t("Unbekannt") + try: + modified_dt = datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S") + except (OSError, OverflowError, ValueError): + modified_dt = t("Unbekannt") + try: + accessed_dt = datetime.fromtimestamp(st.st_atime).strftime("%Y-%m-%d %H:%M:%S") + except (OSError, OverflowError, ValueError): + accessed_dt = t("Unbekannt") + + self._add_form_row(t("Erstellt:"), created_dt) + self._add_form_row(t("Geändert:"), modified_dt) + self._add_form_row(t("Letzter Zugriff:"), accessed_dt) + + # Attribute + is_readonly = not os.access(self.target_path, os.W_OK) + is_hidden = self.filename.startswith(".") + if sys.platform.startswith("win"): + try: + attrs = getattr(st, "st_file_attributes", 0) + if attrs & stat.FILE_ATTRIBUTE_HIDDEN: + is_hidden = True + except AttributeError: + pass + + attr_parts = [] + if is_readonly: + attr_parts.append(t("Schreibgeschützt")) + else: + attr_parts.append(t("Schreibbar")) + if is_hidden: + attr_parts.append(t("Versteckt")) + mode_octal = oct(stat.S_IMODE(st.st_mode)) + attr_parts.append(f"POSIX {mode_octal}") + + self._add_form_row(t("Attribute:"), ", ".join(attr_parts)) + + # Tab 2: Prüfsummen & Details + if not self.is_dir and os.path.isfile(self.target_path): + self._setup_file_checksums(st.st_size) + else: + self._setup_folder_details() + + def _add_form_row(self, label_text: str, value_text: str) -> None: + val_lbl = QLabel(value_text, self) + val_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.form_general.addRow(QLabel(label_text), val_lbl) + + def _setup_file_checksums(self, file_size: int) -> None: + # GroupBox Prüfsummen + group_hash = QGroupBox(t("Kryptografische Prüfsummen"), self) + form_hash = QFormLayout(group_hash) + form_hash.setSpacing(8) + + # SHA-256 + self.sha256_edit = QLineEdit(self) + self.sha256_edit.setReadOnly(True) + self.sha256_edit.setFont(QFont("Courier New", 9)) + btn_copy_sha = QPushButton(t("Kopieren"), self) + btn_copy_sha.clicked.connect(lambda: self._copy_to_clip(self.sha256_edit.text())) + row_sha = QHBoxLayout() + row_sha.addWidget(self.sha256_edit, 1) + row_sha.addWidget(btn_copy_sha) + form_hash.addRow(QLabel("SHA-256:"), row_sha) + + # MD5 + self.md5_edit = QLineEdit(self) + self.md5_edit.setReadOnly(True) + self.md5_edit.setFont(QFont("Courier New", 9)) + btn_copy_md5 = QPushButton(t("Kopieren"), self) + btn_copy_md5.clicked.connect(lambda: self._copy_to_clip(self.md5_edit.text())) + row_md5 = QHBoxLayout() + row_md5.addWidget(self.md5_edit, 1) + row_md5.addWidget(btn_copy_md5) + form_hash.addRow(QLabel("MD5:"), row_md5) + + self.layout_checksums.addWidget(group_hash) + + # Hashes berechnen (falls Datei <= 50 MB direkt, sonst per Button) + if file_size <= 50 * 1024 * 1024: + try: + hashes = compute_file_hashes(self.target_path, algorithms=("sha256", "md5")) + self.sha256_edit.setText(hashes.get("sha256", "")) + self.md5_edit.setText(hashes.get("md5", "")) + except Exception as exc: + self.sha256_edit.setText(f"{t('Fehler')}: {exc}") + self.md5_edit.setText(f"{t('Fehler')}: {exc}") + else: + self.sha256_edit.setPlaceholderText(t("Datei > 50 MB: Klick zum Berechnen")) + self.md5_edit.setPlaceholderText(t("Datei > 50 MB: Klick zum Berechnen")) + calc_btn = QPushButton(t("Prüfsummen jetzt berechnen"), self) + calc_btn.clicked.connect(self._compute_large_hashes) + self.layout_checksums.addWidget(calc_btn) + + # Text-Dateistatistik + ext = Path(self.target_path).suffix.lower() + text_extensions = {".txt", ".py", ".md", ".json", ".xml", ".html", ".css", ".js", ".yaml", ".yml", ".ini", ".log"} + if ext in text_extensions and file_size <= 5 * 1024 * 1024: + self._setup_text_stats() + + self.layout_checksums.addStretch() + + def _compute_large_hashes(self) -> None: + try: + hashes = compute_file_hashes(self.target_path, algorithms=("sha256", "md5")) + self.sha256_edit.setText(hashes.get("sha256", "")) + self.md5_edit.setText(hashes.get("md5", "")) + except Exception as exc: + QMessageBox.warning(self, t("Prüfsummen-Fehler"), str(exc)) + + def _setup_text_stats(self) -> None: + try: + with open(self.target_path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + line_count = len(lines) + word_count = sum(len(line.split()) for line in lines) + char_count = sum(len(line) for line in lines) + + group_text = QGroupBox(t("Text-Metriken"), self) + form_text = QFormLayout(group_text) + form_text.addRow(QLabel(t("Zeilen:")), QLabel(f"{line_count:,}".replace(",", "."))) + form_text.addRow(QLabel(t("Wörter:")), QLabel(f"{word_count:,}".replace(",", "."))) + form_text.addRow(QLabel(t("Zeichen:")), QLabel(f"{char_count:,}".replace(",", "."))) + self.layout_checksums.addWidget(group_text) + except Exception: + pass + + def _setup_folder_details(self) -> None: + group_info = QGroupBox(t("Ordner-Struktur"), self) + form_info = QFormLayout(group_info) + rel_path = os.path.relpath(self.target_path, os.path.dirname(self.target_path)) + form_info.addRow(QLabel(t("Relativer Pfad:")), QLabel(rel_path)) + self.layout_checksums.addWidget(group_info) + self.layout_checksums.addStretch() + + def _copy_full_path(self) -> None: + self._copy_to_clip(self.target_path) + QMessageBox.information( + self, + t("Pfad kopiert"), + f"{t('Vollständiger Pfad in die Zwischenablage kopiert:')}\n\n{self.target_path}" + ) + + def _copy_to_clip(self, text: str) -> None: + if text: + QApplication.clipboard().setText(text) + + def _open_location(self) -> None: + target = os.path.dirname(self.target_path) if not self.is_dir else self.target_path + try: + open_path_with_system(target) + except Exception as exc: + QMessageBox.warning(self, t("Dateimanager öffnen"), f"{t('Konnte Ordner nicht öffnen')}:\n{exc}") diff --git a/tests/test_properties_and_terminal.py b/tests/test_properties_and_terminal.py new file mode 100644 index 0000000..38a4054 --- /dev/null +++ b/tests/test_properties_and_terminal.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_properties_and_terminal.py - Vertragstests für Eigenschaften-Dialog, +Pfad-Kopier-Funktionen und Terminal-Integration (TW-EP-11). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication + +_app = QApplication.instance() or QApplication([]) + +from core.platform_utils import get_terminal_command, open_terminal_in_directory +from gui.browser.file_browser import FileBrowser, _DnDTableView +from gui.main_window import MainWindow +from gui.properties_dialog import FilePropertiesDialog, calculate_folder_stats, format_size + + +@pytest.fixture +def clean_clipboard(): + yield + QApplication.clipboard().clear() + QApplication.processEvents() + + +class TestFormatSizeAndFolderStats: + """Formatierungs- und Ordnerstatistik-Tests.""" + + def test_format_size_units(self): + assert format_size(-5) == "0 Bytes" + assert format_size(500) == "500 Bytes" + assert "KB" in format_size(2048) + assert "MB" in format_size(5 * 1024 * 1024) + assert "GB" in format_size(3 * 1024 * 1024 * 1024) + + def test_calculate_folder_stats(self, tmp_path): + sub1 = tmp_path / "sub1" + sub1.mkdir() + sub2 = tmp_path / "sub2" + sub2.mkdir() + f1 = sub1 / "test1.txt" + f1.write_text("Hello World", encoding="utf-8") + f2 = sub2 / "test2.txt" + f2.write_text("Testing 123", encoding="utf-8") + + file_count, dir_count, total_bytes = calculate_folder_stats(str(tmp_path)) + assert file_count == 2 + assert dir_count == 2 + assert total_bytes == len("Hello World") + len("Testing 123") + + def test_calculate_folder_stats_nonexistent(self, tmp_path): + assert calculate_folder_stats(str(tmp_path / "ghost")) == (0, 0, 0) + + +class TestPlatformTerminal: + """Plattformspezifische Terminal-Befehle und Start-Logik.""" + + def test_get_terminal_command_windows(self): + with patch("sys.platform", "win32"), patch("shutil.which", return_value="C:\\Windows\\System32\\cmd.exe"): + cmd = get_terminal_command("C:\\Test") + assert isinstance(cmd, list) + assert any("cmd" in c or "powershell" in c or "wt" in c for c in cmd) + + def test_get_terminal_command_darwin(self): + with patch("sys.platform", "darwin"): + cmd = get_terminal_command("/Users/test") + assert cmd == ["open", "-a", "Terminal", "/Users/test"] + + def test_get_terminal_command_linux(self): + with patch("sys.platform", "linux"), patch("shutil.which", lambda t: "/usr/bin/xterm" if t == "xterm" else None): + cmd = get_terminal_command("/home/user") + assert cmd == ["xterm"] + + def test_open_terminal_in_directory_launches_process(self, tmp_path): + target = tmp_path / "somedir" + target.mkdir() + with patch("subprocess.Popen") as mock_popen: + open_terminal_in_directory(str(target)) + assert mock_popen.called + args, kwargs = mock_popen.call_args + assert kwargs.get("cwd") == str(target) + + +class TestCopyPathOperations: + """Pfad-Kopier-Funktionen in FileBrowser.""" + + def test_copy_single_path_to_clipboard(self, tmp_path, monkeypatch, clean_clipboard): + f = tmp_path / "datei.txt" + f.write_text("Inhalt", encoding="utf-8") + + browser = FileBrowser() + monkeypatch.setattr(browser, "get_selected_files", lambda: [str(f)]) + + res = browser.copy_path_to_clipboard() + assert res == str(f) + assert QApplication.clipboard().text() == str(f) + + def test_copy_multiple_paths_to_clipboard(self, tmp_path, monkeypatch, clean_clipboard): + f1 = tmp_path / "a.txt" + f2 = tmp_path / "b.txt" + + browser = FileBrowser() + monkeypatch.setattr(browser, "get_selected_files", lambda: [str(f1), str(f2)]) + + res = browser.copy_path_to_clipboard() + expected = f"{f1}\n{f2}" + assert res == expected + assert QApplication.clipboard().text() == expected + + def test_copy_name_only_to_clipboard(self, tmp_path, monkeypatch, clean_clipboard): + f = tmp_path / "meine_datei.pdf" + + browser = FileBrowser() + monkeypatch.setattr(browser, "get_selected_files", lambda: [str(f)]) + + res = browser.copy_path_to_clipboard(name_only=True) + assert res == "meine_datei.pdf" + assert QApplication.clipboard().text() == "meine_datei.pdf" + + def test_copy_relative_path_to_clipboard(self, tmp_path, monkeypatch, clean_clipboard): + sub = tmp_path / "sub" + f = sub / "doc.txt" + + browser = FileBrowser() + browser._current_path = str(tmp_path) + monkeypatch.setattr(browser, "get_selected_files", lambda: [str(f)]) + + res = browser.copy_path_to_clipboard(relative=True) + assert res == os.path.join("sub", "doc.txt") + + def test_copy_path_fallback_current_path(self, tmp_path, monkeypatch, clean_clipboard): + browser = FileBrowser() + browser._current_path = str(tmp_path) + monkeypatch.setattr(browser, "get_selected_files", lambda: []) + + res = browser.copy_path_to_clipboard() + assert res == str(tmp_path) + assert QApplication.clipboard().text() == str(tmp_path) + + +class TestFilePropertiesDialog: + """Initialisierung und Datenintegrität des Eigenschaften-Dialogs.""" + + def test_dialog_for_file(self, tmp_path): + f = tmp_path / "notiz.txt" + f.write_text("Zeile 1\nZeile 2\nZeile 3 mit Worten\n", encoding="utf-8") + + dlg = FilePropertiesDialog(str(f)) + assert dlg.name_edit.text() == "notiz.txt" + assert dlg.tabs.count() == 2 + assert dlg.sha256_edit.text() != "" + assert dlg.md5_edit.text() != "" + dlg.close() + + def test_dialog_for_directory(self, tmp_path): + sub = tmp_path / "projekt_ordner" + sub.mkdir() + (sub / "datei.txt").write_text("Hello", encoding="utf-8") + + dlg = FilePropertiesDialog(str(sub)) + assert dlg.name_edit.text() == "projekt_ordner" + assert dlg.is_dir is True + dlg.close() + + def test_dialog_copy_path_button(self, tmp_path, clean_clipboard): + f = tmp_path / "test.txt" + f.write_text("abc", encoding="utf-8") + dlg = FilePropertiesDialog(str(f)) + with patch("PySide6.QtWidgets.QMessageBox.information"): + dlg.copy_path_btn.click() + assert QApplication.clipboard().text() == str(f) + dlg.close() + + +class TestMainWindowAndKeyboardShortcuts: + """Verdrahtung in Menüleiste und Tastenkürzeln.""" + + def test_main_window_wiring(self, monkeypatch): + win = MainWindow() + calls = [] + + monkeypatch.setattr(win.file_browser, "show_properties", lambda: calls.append("prop") or True) + monkeypatch.setattr(win.file_browser, "copy_path_to_clipboard", lambda: calls.append("copy_path") or "ok") + monkeypatch.setattr(win.file_browser, "open_terminal", lambda: calls.append("term") or True) + + win._show_properties() + win._copy_path() + win._open_terminal() + win.close() + + assert "prop" in calls + assert "copy_path" in calls + assert "term" in calls + + def test_dnd_table_view_shortcuts(self, tmp_path, monkeypatch): + browser = FileBrowser() + table = _DnDTableView(browser) + calls = [] + + monkeypatch.setattr(browser, "show_properties", lambda: calls.append("prop") or True) + monkeypatch.setattr(browser, "copy_path_to_clipboard", lambda: calls.append("copy_path") or "ok") + + # Alt+Enter -> show_properties + event_alt_enter = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Return, + Qt.KeyboardModifier.AltModifier + ) + table.keyPressEvent(event_alt_enter) + assert "prop" in calls + + # Ctrl+Shift+C -> copy_path_to_clipboard + event_ctrl_shift_c = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_C, + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier + ) + table.keyPressEvent(event_ctrl_shift_c) + assert "copy_path" in calls From 42223ce29bc234be11eafe4dd8b948bbc3b45df6 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sun, 4 Oct 2026 07:21:02 +0200 Subject: [PATCH 13/13] fix: prevent Windows terminal command injection --- src/core/platform_utils.py | 8 +++-- src/gui/sidebar/sidebar_main.py | 4 ++- tests/test_drive_capacity.py | 17 ++++++++++ tests/test_properties_and_terminal.py | 49 +++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/core/platform_utils.py b/src/core/platform_utils.py index a19cee8..54ade03 100644 --- a/src/core/platform_utils.py +++ b/src/core/platform_utils.py @@ -31,10 +31,12 @@ def get_terminal_command(directory: str) -> list[str]: """Return the platform-native terminal launcher command for a directory.""" if sys.platform.startswith("win"): if shutil.which("wt"): - return ["wt", "-d", directory] + # Resolve the target through Popen(cwd=...), not command text. + # Windows Terminal treats semicolons as command separators. + return ["wt", "-d", "."] if shutil.which("powershell"): - return ["powershell", "-NoExit", "-Command", f"Set-Location -LiteralPath '{directory}'"] - return ["cmd", "/K", f"cd /d {directory}"] + return ["powershell", "-NoExit"] + return ["cmd", "/K"] if sys.platform == "darwin": return ["open", "-a", "Terminal", directory] # Linux / BSD diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index c78f6f0..a914721 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -5,6 +5,8 @@ Phase 5: Vollständige Integration """ +from concurrent.futures import CancelledError + from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QStackedWidget, QTreeWidget, QTreeWidgetItem, QListWidget, QListWidgetItem, @@ -129,7 +131,7 @@ def _collect_drive_usage(self): continue try: usage = future.result() - except (OSError, ValueError): + except (CancelledError, OSError, ValueError): usage = None self._on_drive_usage(path, usage) if not self._usage_requests: diff --git a/tests/test_drive_capacity.py b/tests/test_drive_capacity.py index 01eb733..ac9dbc7 100644 --- a/tests/test_drive_capacity.py +++ b/tests/test_drive_capacity.py @@ -1,6 +1,7 @@ import os import threading import time +from concurrent.futures import Future from types import SimpleNamespace os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') @@ -84,6 +85,22 @@ def make_panel(monkeypatch, 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 = [] diff --git a/tests/test_properties_and_terminal.py b/tests/test_properties_and_terminal.py index 38a4054..b04695c 100644 --- a/tests/test_properties_and_terminal.py +++ b/tests/test_properties_and_terminal.py @@ -77,6 +77,27 @@ def test_get_terminal_command_windows(self): assert isinstance(cmd, list) assert any("cmd" in c or "powershell" in c or "wt" in c for c in cmd) + def test_windows_terminal_does_not_receive_directory_as_command_text(self): + directory = r"C:\Users\test\a; new-tab -p PowerShell" + with patch("sys.platform", "win32"), patch( + "shutil.which", + side_effect=lambda executable: "wt.exe" if executable == "wt" else None, + ): + assert get_terminal_command(directory) == ["wt", "-d", "."] + + def test_windows_powershell_fallback_does_not_interpolate_directory(self): + directory = r"C:\Users\test\x'; Start-Process calc; '" + with patch("sys.platform", "win32"), patch( + "shutil.which", + side_effect=lambda executable: "powershell.exe" if executable == "powershell" else None, + ): + assert get_terminal_command(directory) == ["powershell", "-NoExit"] + + def test_windows_cmd_fallback_does_not_interpolate_directory(self): + directory = r"C:\Users\test\a&calc" + with patch("sys.platform", "win32"), patch("shutil.which", return_value=None): + assert get_terminal_command(directory) == ["cmd", "/K"] + def test_get_terminal_command_darwin(self): with patch("sys.platform", "darwin"): cmd = get_terminal_command("/Users/test") @@ -96,6 +117,34 @@ def test_open_terminal_in_directory_launches_process(self, tmp_path): args, kwargs = mock_popen.call_args assert kwargs.get("cwd") == str(target) + def test_open_terminal_uses_cwd_for_windows_metacharacter_path(self, tmp_path): + target = tmp_path / "x'&calc" + target.mkdir() + with ( + patch("sys.platform", "win32"), + patch("shutil.which", return_value=None), + patch("subprocess.Popen") as mock_popen, + ): + open_terminal_in_directory(str(target)) + + args, kwargs = mock_popen.call_args + assert args[0] == ["cmd", "/K"] + assert kwargs["cwd"] == str(target) + + def test_open_terminal_uses_cwd_with_windows_terminal(self, tmp_path): + target = tmp_path / "a; new-tab -p PowerShell" + target.mkdir() + with ( + patch("sys.platform", "win32"), + patch("shutil.which", side_effect=lambda executable: "wt.exe" if executable == "wt" else None), + patch("subprocess.Popen") as mock_popen, + ): + open_terminal_in_directory(str(target)) + + args, kwargs = mock_popen.call_args + assert args[0] == ["wt", "-d", "."] + assert kwargs["cwd"] == str(target) + class TestCopyPathOperations: """Pfad-Kopier-Funktionen in FileBrowser."""