diff --git a/CHANGELOG.md b/CHANGELOG.md index 772d0fe..83f3b8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - **Speicherbelegung**: Ergebnis kommt per Signal statt per 50-ms-Polling; automatische Aktualisierung höchstens alle 60 s je Laufwerk (Button erzwingt sie). Der letzte Stand bleibt sichtbar, bis der neue vorliegt, und wird in `~/.explorerpro/drive_usage_cache.json` gespeichert, damit auch der Start sofort Werte zeigt. Fehler (auch unerwartete) lassen eine Zeile nicht mehr dauerhaft auf „wird ermittelt“ hängen. - **Ordner-Vorschau**: Verzeichnis wird im Hintergrund gelesen (Platzhalter bzw. letzter Stand sofort sichtbar). - 10 neue Tests (`tests/test_gui_freeze.py`) messen die Blockadezeit der Ereignisschleife bei künstlich verzögerten Laufwerken. +- **Laufwerkswechsel ohne Hänger (de)**: Seitenleiste, Adressleiste, Tastatur und Favoriten wechseln über denselben Weg. Unbekannte Pfade werden im Hintergrund auf Erreichbarkeit geprüft (Hinweis „wird geladen …“, alte Ansicht bleibt bedienbar, nach 15 s „Keine Antwort – Laufwerk reagiert nicht“, neuere Navigation hat Vorrang). Die Startseite jedes Laufwerks wird nach dem Start im Hintergrund vorgeladen und vorgehalten (gedrosselt, lokale Platten zuerst, dann Wechsel- und Netzlaufwerke, mit Timeout); ein Klick darauf zeigt sie sofort. ### Fix: GUI no longer freezes on slow drives (en) - **Folder tree**: expanding a folder lists subfolders in the background. First time shows "loading …", afterwards the last known state at once; the fresh list replaces it only when complete. Answers for collapsed nodes are discarded; a folder silent for 15 s shows a hint instead of hanging. @@ -20,6 +21,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - **Drive capacity**: results arrive by signal instead of 50 ms polling; automatic refresh at most every 60 s per drive (the button forces it). The last value stays visible until the new one arrives and is persisted in `~/.explorerpro/drive_usage_cache.json` so startup shows values immediately. Any error now releases the row instead of leaving it stuck on "checking". - **Folder preview**: directory is read in the background (placeholder or last state shown at once). - 10 new tests (`tests/test_gui_freeze.py`) measure event-loop blocking with artificially delayed drives. +- **Drive switching without hangs (en)**: sidebar, address bar, keyboard and favorites share one path. Unknown paths are checked for reachability in the background ("loading …" hint, previous view stays usable, after 15 s "No response – drive is not answering", newer navigation wins). Each drive's start page is preloaded and kept ready in the background after startup (throttled, local disks first, then removable and network drives, with timeout); a click shows it at once. ### Native ZIP- & Archiv-Suite (2026-10-08, TW-EP-12) - **Core-Archiv-Service (`src/core/archive_service.py`)**: diff --git a/locales/translations.json b/locales/translations.json index 84f1a01..d903b50 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -9430,5 +9430,13 @@ "zh": "无响应 – 请稍后再次展开", "ja": "応答がありません – 後でもう一度展開してください", "ru": "Нет ответа – разверните позже ещё раз" + }, + "Keine Antwort – Laufwerk reagiert nicht": { + "de": "Keine Antwort – Laufwerk reagiert nicht", + "en": "No response – drive is not answering", + "es": "Sin respuesta: la unidad no responde", + "zh": "无响应 – 驱动器未应答", + "ja": "応答がありません – ドライブが応答しません", + "ru": "Нет ответа – диск не отвечает" } } diff --git a/releases/windowsstore/v1.0.9/release-notes.json b/releases/windowsstore/v1.0.9/release-notes.json index c42c239..7d2c8dd 100644 --- a/releases/windowsstore/v1.0.9/release-notes.json +++ b/releases/windowsstore/v1.0.9/release-notes.json @@ -1,4 +1,4 @@ { - "de-de": "ExplorerPro 1.0.9\n• Die Oberfläche friert bei langsamen Laufwerken (Netzwerk, Google Drive, OneDrive-Platzhalter, leere Kartenleser) nicht mehr ein: Ordner im Baum und die Ordner-Vorschau laden im Hintergrund.\n• Es erscheint sofort der zuletzt bekannte Stand (auch die Speicherbelegung beim Start); neue Werte ersetzen ihn, sobald sie vollständig vorliegen.\n• Die Speicherbelegung wird seltener automatisch geprüft; die Schaltfläche aktualisiert auf Wunsch.\n• Neu: ZIP-Archive erstellen, durchsuchen, auf Integrität prüfen und sicher entpacken.", - "en-us": "ExplorerPro 1.0.9\n• The window no longer freezes on slow drives (network, Google Drive, OneDrive placeholders, empty card readers): folders in the tree and the folder preview load in the background.\n• The last known state (including drive capacity at startup) is shown immediately; new values replace it once they are complete.\n• Drive capacity is checked automatically less often; the button refreshes on demand.\n• New: create, browse, verify and safely extract ZIP archives." + "de-de": "ExplorerPro 1.0.9\n• Die Oberfläche friert bei langsamen Laufwerken (Netzwerk, Google Drive, OneDrive-Platzhalter, leere Kartenleser) nicht mehr ein: Ordner im Baum und die Ordner-Vorschau laden im Hintergrund.\n• Es erscheint sofort der zuletzt bekannte Stand (auch die Speicherbelegung beim Start); neue Werte ersetzen ihn, sobald sie vollständig vorliegen.\n• Die Speicherbelegung wird seltener automatisch geprüft; die Schaltfläche aktualisiert auf Wunsch.\n• Der Wechsel zwischen Laufwerken hängt nicht mehr: Die Startseite jedes Laufwerks wird im Hintergrund vorgeladen und beim Klick sofort angezeigt.\n• Neu: ZIP-Archive erstellen, durchsuchen, auf Integrität prüfen und sicher entpacken.", + "en-us": "ExplorerPro 1.0.9\n• The window no longer freezes on slow drives (network, Google Drive, OneDrive placeholders, empty card readers): folders in the tree and the folder preview load in the background.\n• The last known state (including drive capacity at startup) is shown immediately; new values replace it once they are complete.\n• Drive capacity is checked automatically less often; the button refreshes on demand.\n• Switching between drives no longer hangs: each drive's start page is preloaded in the background and shown instantly on click.\n• New: create, browse, verify and safely extract ZIP archives." } diff --git a/src/app.py b/src/app.py index 115c2a1..a2b1fba 100644 --- a/src/app.py +++ b/src/app.py @@ -9,7 +9,7 @@ from pathlib import Path from PySide6.QtWidgets import QMessageBox -from PySide6.QtCore import QSettings +from PySide6.QtCore import QSettings, QTimer from gui.main_window import MainWindow from modules.privacy.privacy_monitor import PrivacyMonitor @@ -46,6 +46,9 @@ def __init__(self): self._setup_connections() self._apply_settings(startup=True) + # Keep every drive's start page ready, shortly after the window is up. + QTimer.singleShot(800, lambda: self.file_browser.preload_roots(self.sidebar.drive_paths())) + logging.info("ExplorerPro gestartet") def _init_privacy_monitor(self): diff --git a/src/core/platform_utils.py b/src/core/platform_utils.py index 547bb9f..80db3be 100644 --- a/src/core/platform_utils.py +++ b/src/core/platform_utils.py @@ -62,6 +62,21 @@ def open_terminal_in_directory(path: str) -> subprocess.Popen: return subprocess.Popen(cmd, cwd=target_dir, creationflags=flags) +def drive_kind(path: str) -> int: + """Preload order: 0 = local fixed disk, 1 = removable/optical/unknown, 2 = network. + + GetDriveTypeW reads no medium, so it cannot hang. Off Windows everything is 0. + """ + if not sys.platform.startswith("win"): + return 0 + try: + import ctypes + kind = ctypes.windll.kernel32.GetDriveTypeW(os.path.splitdrive(path)[0] + "\\") + except Exception: + return 1 + return {3: 0, 4: 2}.get(kind, 1) + + def normalize_user_path(path: str) -> str: """Bereinigt einen vom Nutzer eingegebenen oder eingefügten Pfad. diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index b11968d..87594fd 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -7,11 +7,11 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QTableView, QHeaderView, QMenu, QAbstractItemView, QMessageBox, QFileSystemModel, - QApplication, QInputDialog, QDialog + QApplication, QInputDialog, QDialog, QLabel ) from PySide6.QtCore import ( Qt, Signal, QDir, QModelIndex, QSortFilterProxyModel, - QStandardPaths, QUrl, QMimeData, QSize + QStandardPaths, QUrl, QMimeData, QSize, QTimer ) from PySide6.QtGui import QAction, QCursor, QDrag, QKeySequence import os @@ -20,7 +20,8 @@ import sys from pathlib import Path -from core.platform_utils import open_path_with_system, normalize_user_path +from core.async_fs import AsyncFs +from core.platform_utils import open_path_with_system, normalize_user_path, drive_kind from translator import t # Editor-Extensions @@ -48,6 +49,23 @@ def base_entry_filters() -> QDir.Filter: return filters +def _norm(path: str) -> str: + return os.path.normcase(os.path.normpath(path)) + + +def _classify(path: str): + """Blocking stat calls (worker): 'file', 'dir' or None.""" + if os.path.isfile(path): + return "file" + return "dir" if os.path.isdir(path) else None + + +def _probe(path: str) -> bool: + """A listing proves the drive answers right now (worker).""" + os.listdir(path) + return True + + class _DnDTableView(QTableView): """QTableView-Unterklasse: delegiert DnD-Events und Tastatur-Shortcuts an den FileBrowser. @@ -126,16 +144,33 @@ class FileBrowser(QWidget): file_double_clicked = Signal(str) edit_requested = Signal(str) # Datei im Editor öffnen + # Switching to a path nobody has proven reachable is validated in a worker, so a + # dead/slow drive never blocks the GUI. Paths in _known_dirs go straight in. + ASYNC_VALIDATE = True + NAV_TIMEOUT_MS = 15000 + PRELOAD_TIMEOUT_MS = 15000 + PRELOAD_GAP_MS = 300 # throttle between two drive preloads + def __init__(self, parent=None): super().__init__(parent) self._current_path = "" self._history = [] self._history_index = -1 self._file_count = 0 + self._known_dirs = set() + self._tokens = {} # token -> (kind, path) + self._nav_token = 0 + self._preload_queue = [] + self._preload_busy = False + self._preload_seq = 0 + self._fs = AsyncFs(self) + self._fs.finished.connect(self._on_fs_result) self._setup_ui() + self._busy = QLabel(t("wird geladen …"), self.table.viewport()) + self._busy.hide() # Startverzeichnis (Einstellung "Startordner", sonst Benutzerordner) - self.navigate_to(self.default_start_path()) + self._navigate_sync(self.default_start_path()) @staticmethod def default_start_path() -> str: @@ -254,6 +289,7 @@ def _ensure_name_column_visible(self): def _on_directory_loaded(self, path: str): """Handler wenn Verzeichnis geladen wurde""" if os.path.normcase(os.path.normpath(path)) == os.path.normcase(os.path.normpath(self._current_path or "")): + self._busy.hide() self._update_file_count() self.selection_changed.emit(len(self.get_selected_files())) @@ -276,20 +312,104 @@ def _on_selection_changed(self): self.selection_changed.emit(selected) def navigate_to(self, path: str): - """Navigiert zu einem Pfad""" + """Navigiert zu einem Pfad; unbekannte Pfade werden im Hintergrund geprüft.""" if not path: return path = normalize_user_path(path) + if _norm(path) in self._known_dirs: + self._go(path) + elif not self.ASYNC_VALIDATE: + self._navigate_sync(path) + else: + self._nav_token += 1 + self._set_busy(t("wird geladen …")) + self._submit(self._nav_token, "nav", path, _classify, self.NAV_TIMEOUT_MS) + + def _set_busy(self, text): + if text: + self._busy.setText(text) + self._busy.adjustSize() + self._busy.move(12, 40) + self._busy.show() + self._busy.raise_() + else: + self._busy.hide() + + def _submit(self, token, kind, path, fn, timeout_ms): + self._tokens[token] = (kind, path) + self._fs.submit(token, fn, path) + QTimer.singleShot(timeout_ms, lambda: self._on_timeout(token)) + + def _on_fs_result(self, token, result, error): + entry = self._tokens.pop(token, None) + if entry is None: # timed out: a late answer is dropped + return + kind, path = entry + if kind == "preload": + self._preload_busy = False + if error is None and result: + self._known_dirs.add(_norm(path)) + index = self.model.index(path) # just proved reachable: cheap now + if index.isValid(): + self.model.fetchMore(index) # warm the model for an instant first view + QTimer.singleShot(self.PRELOAD_GAP_MS, self._preload_next) + return + if error is not None or result is None: + if token == self._nav_token: + self._set_busy(None) + return + folder = path if result == "dir" else os.path.dirname(path) + self._known_dirs.add(_norm(folder)) + if token != self._nav_token: # the user went elsewhere meanwhile + return + self._set_busy(None) + self._go(folder) + if result == "file": + self.file_selected.emit(path) + + def _on_timeout(self, token): + entry = self._tokens.pop(token, None) + if entry is None: + return + if entry[0] == "preload": + self._preload_busy = False + QTimer.singleShot(self.PRELOAD_GAP_MS, self._preload_next) + elif token == self._nav_token: + self._set_busy(t("Keine Antwort – Laufwerk reagiert nicht")) + + def preload_roots(self, paths): + """Warm the start page of each drive in the background: local disks first, one at a time.""" + queued = [p for p in self._preload_queue] + for path in sorted(paths, key=drive_kind): + if path not in queued and _norm(path) not in self._known_dirs: + self._preload_queue.append(path) + self._preload_next() + + def _preload_next(self): + if self._preload_busy or not self._preload_queue: + return + path = self._preload_queue.pop(0) + self._preload_busy = True + self._preload_seq -= 1 # negative tokens: never confused with user navigations + self._submit(self._preload_seq, "preload", path, _probe, self.PRELOAD_TIMEOUT_MS) + + def _navigate_sync(self, path: str): if os.path.isfile(path): # Datei-Pfad (z. B. aus Suche oder Adresszeile): Ordner öffnen # und die Datei anschließend markieren. folder = os.path.dirname(path) if folder and os.path.isdir(folder): - self.navigate_to(folder) + self._navigate_sync(folder) self.file_selected.emit(path) return if not os.path.isdir(path): return + self._go(path) + + def _go(self, path: str): + """Switch the view; callers have established that *path* is a reachable folder.""" + for folder in (Path(path), *Path(path).parents): # ancestors exist too: "up" is instant + self._known_dirs.add(_norm(str(folder))) # History aktualisieren if self._current_path and self._current_path != path: @@ -310,6 +430,8 @@ def navigate_to(self, path: str): self.table.setRootIndex(proxy_index) self._update_file_count() + # Nothing cached yet: say so until the (asynchronous) listing arrives. + self._set_busy(t("wird geladen …") if self.model.rowCount(source_index) == 0 else None) # Signale senden self.folder_changed.emit(path) diff --git a/src/gui/sidebar/sidebar_main.py b/src/gui/sidebar/sidebar_main.py index 07374a4..9b10985 100644 --- a/src/gui/sidebar/sidebar_main.py +++ b/src/gui/sidebar/sidebar_main.py @@ -147,6 +147,9 @@ def _populate(self): drives_item.setExpanded(True) + def drive_paths(self): + return list(self._drive_rows) + def refresh_drive_usage(self, force=False): """One request per drive; automatic calls are throttled, the button forces.""" now = time.monotonic() @@ -490,6 +493,9 @@ def set_file_index(self, file_index): """Setzt den Datei-Index für die Suche""" self.search_panel.set_index(file_index) + def drive_paths(self): + return self.tree_panel.drive_paths() + def switch_to_tab(self, index: int): """Wechselt zum angegebenen Tab""" if 0 <= index < self.stack.count(): diff --git a/tests/conftest.py b/tests/conftest.py index c57234e..2573a3f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,6 +53,13 @@ def isolated_drive_usage_cache(tmp_path, monkeypatch): monkeypatch.setattr('core.drive_usage.cache_path', lambda: tmp_path / 'drive_usage_cache.json') +@pytest.fixture(autouse=True) +def synchronous_navigation(monkeypatch): + """Legacy tests expect navigate_to() to take effect at once; tests/test_drive_switch.py covers the async path.""" + from gui.browser.file_browser import FileBrowser + monkeypatch.setattr(FileBrowser, 'ASYNC_VALIDATE', False, raising=False) + + @pytest.fixture(scope='session', autouse=True) def gui_runtime(): yield diff --git a/tests/test_drive_switch.py b/tests/test_drive_switch.py new file mode 100644 index 0000000..b8b3f28 --- /dev/null +++ b/tests/test_drive_switch.py @@ -0,0 +1,148 @@ +"""Switching drives must not hang the main area; drive start pages are preloaded. + +A slow/dead drive is simulated by delaying os.path.isdir/isfile/os.listdir for a +marker path; a 20 ms QTimer measures how long the event loop was blocked. +""" +import os +import time + +import pytest +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication + +from gui.browser import file_browser as fb_mod +from gui.browser.file_browser import FileBrowser + +SLOW = os.path.join(os.path.abspath(os.sep), "ep_slow_drive_marker") +DELAY = 2.0 +_isdir, _isfile, _listdir = os.path.isdir, os.path.isfile, os.listdir + + +def pump(predicate, timeout=6.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + QApplication.processEvents() + if predicate(): + return True + time.sleep(0.005) + return predicate() + + +class TickMeter: + def __init__(self): + self.last = time.monotonic() + self.worst = 0.0 + self.timer = QTimer() + self.timer.setInterval(20) + self.timer.timeout.connect(self._tick) + self.timer.start() + + def _tick(self): + now = time.monotonic() + self.worst = max(self.worst, now - self.last) + self.last = now + + def stop(self): + self.timer.stop() + self.worst = max(self.worst, time.monotonic() - self.last) + + +@pytest.fixture(autouse=True) +def async_navigation(monkeypatch): + monkeypatch.setattr(FileBrowser, "ASYNC_VALIDATE", True, raising=False) + + +@pytest.fixture +def slow_drive(monkeypatch): + calls = {"stat": 0} + + def slow(real): + def wrapper(path): + if os.fspath(path).startswith(SLOW): + calls["stat"] += 1 + time.sleep(DELAY) + return True + return real(path) + return wrapper + + monkeypatch.setattr(os.path, "isdir", slow(_isdir)) + monkeypatch.setattr(os.path, "isfile", lambda p: False if os.fspath(p).startswith(SLOW) else _isfile(p)) + monkeypatch.setattr(os, "listdir", lambda p=".": [] if os.fspath(p) == SLOW else _listdir(p)) + return calls + + +@pytest.fixture +def browser(): + b = FileBrowser() + b.resize(800, 500) + b.show() + pump(lambda: False, timeout=1.5) # let the initial real folder settle first + return b + + +def test_switch_to_slow_drive_does_not_block_event_loop(browser, slow_drive): + meter = TickMeter() + browser.navigate_to(SLOW) + assert pump(lambda: browser.current_path == SLOW) + meter.stop() + print(f"MEASURED drive switch worst tick gap: {meter.worst:.3f}s (stat takes {DELAY}s)") + assert meter.worst < 1.0, f"GUI blocked for {meter.worst:.2f}s" + + +def test_busy_hint_while_waiting(browser, slow_drive): + start = browser.current_path + browser.navigate_to(SLOW) + assert not browser._busy.isHidden() # "wird geladen …" instead of a hang + assert browser.current_path == start # old view stays usable meanwhile + assert pump(lambda: browser.current_path == SLOW) + + +def test_hanging_drive_times_out_with_hint_and_late_answer_is_ignored(browser, slow_drive): + browser.NAV_TIMEOUT_MS = 100 + start = browser.current_path + browser.navigate_to(SLOW) + assert pump(lambda: "Keine Antwort" in browser._busy.text(), timeout=2) + pump(lambda: False, timeout=DELAY + 0.5) + assert browser.current_path == start + + +def test_newer_navigation_wins_over_slow_one(browser, slow_drive, tmp_path): + browser.navigate_to(SLOW) + browser.navigate_to(str(tmp_path)) + assert pump(lambda: browser.current_path == str(tmp_path)) + pump(lambda: False, timeout=DELAY + 0.5) + assert browser.current_path == str(tmp_path) + + +def test_preloaded_drive_is_shown_at_once_without_any_stat(browser, slow_drive): + browser.PRELOAD_GAP_MS = 1 + browser.preload_roots([SLOW]) + assert pump(lambda: fb_mod._norm(SLOW) in browser._known_dirs) + slow_drive["stat"] = 0 + began = time.monotonic() + browser.navigate_to(SLOW) + assert browser.current_path == SLOW # immediately, no pumping + assert time.monotonic() - began < 0.5 + assert slow_drive["stat"] == 0 + + +def test_preload_order_local_first_one_at_a_time_and_hanging_drive_does_not_stall(browser, monkeypatch): + kinds = {"J:\\": 2, "H:\\": 0, "K:\\": 1, "I:\\": 0} + seen = [] + + def probe(path): + seen.append(path) + if path == "H:\\": + time.sleep(0.6) # hangs past the timeout + return True + + monkeypatch.setattr(fb_mod, "drive_kind", lambda p: kinds[p]) + monkeypatch.setattr(fb_mod, "_probe", probe) + browser.PRELOAD_GAP_MS = 1 + browser.PRELOAD_TIMEOUT_MS = 100 + browser.preload_roots(list(kinds)) + pump(lambda: len(seen) == 4, timeout=4) + assert len(seen) == 4 + assert seen == ["H:\\", "I:\\", "K:\\", "J:\\"] # local disks first, then removable, then network + assert fb_mod._norm("H:\\") not in browser._known_dirs # timed out: not trusted + assert fb_mod._norm("I:\\") in browser._known_dirs