From ff3d6f29e9a0f85ef3c362097136be9c666a5691 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 10 Oct 2026 23:59:42 +0200 Subject: [PATCH] fix(browser): unreachable drive shows an empty page, never the old folder; re-check known network drives Pending/unreachable/timeout targets hide the table (empty page) with a centered status text; history/back keep working and a click re-checks. Known paths on removable/network drives show the kept state and are re-validated in the background; failure drops them and shows the empty page. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LYHP4Js5ggpzQ78JXWRe5E --- CHANGELOG.md | 2 + locales/translations.json | 8 ++ .../windowsstore/v1.0.9/release-notes.json | 4 +- src/gui/browser/file_browser.py | 126 +++++++++++++----- tests/test_drive_switch.py | 85 +++++++++++- 5 files changed, 180 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f3b8e..8ec138a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - **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. +- **Nicht erreichbares Laufwerk (de)**: Der Wechsel zeigt sofort eine leere Seite mit dem Ziel in der Adressleiste und dem Hinweis „wird geladen …“ bzw. „Laufwerk nicht erreichbar“ / „Keine Antwort – Laufwerk reagiert nicht“; der vorige Ordner bleibt nicht stehen und der Hinweis liegt nie über Einträgen. „Zurück“ funktioniert weiter, ein erneuter Klick prüft erneut. Bereits bekannte Wechsel-/Netzlaufwerke zeigen den vorgehaltenen Stand sofort und werden im Hintergrund nachgeprüft; schlägt das fehl, erscheint die leere Seite. ### 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. @@ -22,6 +23,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - **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. +- **Unreachable drive (en)**: switching shows an empty page at once with the target in the address bar and the hint "loading …" / "Drive not reachable" / "No response – drive is not answering"; the previous folder no longer stays and the hint never covers entries. Back keeps working, another click checks again. Known removable/network drives show the kept state at once and are re-checked in the background; if that fails the empty page appears. ### 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 d903b50..956c2e7 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -9438,5 +9438,13 @@ "zh": "无响应 – 驱动器未应答", "ja": "応答がありません – ドライブが応答しません", "ru": "Нет ответа – диск не отвечает" + }, + "Laufwerk nicht erreichbar": { + "de": "Laufwerk nicht erreichbar", + "en": "Drive not reachable", + "es": "Unidad no accesible", + "zh": "驱动器无法访问", + "ja": "ドライブにアクセスできません", + "ru": "Диск недоступен" } } diff --git a/releases/windowsstore/v1.0.9/release-notes.json b/releases/windowsstore/v1.0.9/release-notes.json index 7d2c8dd..71554fb 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• 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." + "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• Ist ein Laufwerk nicht erreichbar, zeigt ExplorerPro sofort eine leere Seite mit Hinweis statt des vorigen Ordners.\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• If a drive is not reachable, ExplorerPro shows an empty page with a hint at once instead of the previous folder.\n• New: create, browse, verify and safely extract ZIP archives." } diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index 87594fd..27e63e4 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -163,11 +163,16 @@ def __init__(self, parent=None): self._preload_queue = [] self._preload_busy = False self._preload_seq = 0 + self._pending_path = None + self._nav_record = True self._fs = AsyncFs(self) self._fs.finished.connect(self._on_fs_result) self._setup_ui() - self._busy = QLabel(t("wird geladen …"), self.table.viewport()) + # Status text for an EMPTY page only; it is hidden as soon as rows exist. + self._busy = QLabel(t("wird geladen …"), self) + self._busy.setAlignment(Qt.AlignmentFlag.AlignCenter) self._busy.hide() + self.proxy.rowsInserted.connect(self._on_rows_inserted) # Startverzeichnis (Einstellung "Startordner", sonst Benutzerordner) self._navigate_sync(self.default_start_path()) @@ -315,26 +320,83 @@ def navigate_to(self, path: str): """Navigiert zu einem Pfad; unbekannte Pfade werden im Hintergrund geprüft.""" if not path: return - path = normalize_user_path(path) + self._switch(normalize_user_path(path), True) + + def _switch(self, path: str, record: bool): if _norm(path) in self._known_dirs: - self._go(path) + self._go(path, record) # last known state at once ... + if self.ASYNC_VALIDATE and drive_kind(path) >= 1: + # ... but removable/network drives are re-checked in the background. + self._nav_token += 1 + self._submit(self._nav_token, "recheck", path, _classify, self.NAV_TIMEOUT_MS) elif not self.ASYNC_VALIDATE: - self._navigate_sync(path) + if record: + self._navigate_sync(path) + else: + self._go(path, False) else: self._nav_token += 1 - self._set_busy(t("wird geladen …")) + self._nav_record = record + self._enter_pending(path, record) 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._center_busy() self._busy.show() self._busy.raise_() else: self._busy.hide() + def _center_busy(self): + area = self.table.geometry() + self._busy.move(area.x() + max(0, (area.width() - self._busy.width()) // 2), + area.y() + max(0, (area.height() - self._busy.height()) // 3)) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._center_busy() + + def _on_rows_inserted(self, parent, *_): + if not self.table.isHidden() and parent == self.table.rootIndex(): + self._busy.hide() # never a status text over entries + + def _push_history(self, path: str): + if self._current_path and self._current_path != path: + self._history = self._history[:self._history_index + 1] + self._history.append(path) + self._history_index = len(self._history) - 1 + elif not self._history: + self._history.append(path) + self._history_index = 0 + + def _enter_pending(self, path: str, record: bool): + """Show the target at once as an EMPTY page (no old entries) until it is confirmed.""" + if record: + self._push_history(path) + self._current_path = path + self._pending_path = path + self._file_count = 0 + self.table.hide() # empty page: no entries at all, only the status text + self._set_busy(t("wird geladen …")) + self.folder_changed.emit(path) + self.path_changed.emit(path) + + def _show_unreachable(self, path: str, text=None): + """The empty page stays; history/back keep working and another click checks again.""" + self._forget(path) + self._set_busy(text or t("Laufwerk nicht erreichbar")) + if not self.table.isHidden(): + self._enter_pending(path, False) + self._set_busy(text or t("Laufwerk nicht erreichbar")) + + def _forget(self, path: str): + """The path (and everything below it) is no longer trusted; other drives stay known.""" + key = _norm(path) + self._known_dirs = {k for k in self._known_dirs if k != key and not k.startswith(key.rstrip(r"\/") + os.sep)} + def _submit(self, token, kind, path, fn, timeout_ms): self._tokens[token] = (kind, path) self._fs.submit(token, fn, path) @@ -354,16 +416,20 @@ def _on_fs_result(self, token, result, error): self.model.fetchMore(index) # warm the model for an instant first view QTimer.singleShot(self.PRELOAD_GAP_MS, self._preload_next) return + if kind == "recheck": + if (error is not None or result is None) and token == self._nav_token \ + and _norm(self._current_path) == _norm(path): + self._show_unreachable(path) # the shown last state was stale + return if error is not None or result is None: if token == self._nav_token: - self._set_busy(None) + self._show_unreachable(path) 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) + self._go(folder, self._nav_record) if result == "file": self.file_selected.emit(path) @@ -375,7 +441,7 @@ def _on_timeout(self, token): 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")) + self._show_unreachable(entry[1], 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.""" @@ -406,22 +472,22 @@ def _navigate_sync(self, path: str): return self._go(path) - def _go(self, path: str): + def _go(self, path: str, record: bool = True): """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: - # Vorwärts-History löschen - self._history = self._history[:self._history_index + 1] - self._history.append(path) - self._history_index = len(self._history) - 1 - elif not self._history: - self._history.append(path) - self._history_index = 0 + if record: + pending = self._pending_path + if pending and self._history and self._history[self._history_index] == pending and path != pending: + self._history[self._history_index] = path # e.g. a file path resolved to its folder + self._current_path = path + else: + self._push_history(path) self._current_path = path + self._pending_path = None + self.table.show() # QFileSystemModel loads directories asynchronously. index(path) alone # can point at a valid directory while its rows remain empty forever. @@ -430,7 +496,7 @@ def _go(self, path: str): self.table.setRootIndex(proxy_index) self._update_file_count() - # Nothing cached yet: say so until the (asynchronous) listing arrives. + # Nothing cached yet: say so (on the empty page) until the listing arrives. self._set_busy(t("wird geladen …") if self.model.rowCount(source_index) == 0 else None) # Signale senden @@ -441,27 +507,13 @@ def go_back(self): """Geht einen Schritt zurück""" if self._history_index > 0: self._history_index -= 1 - path = self._history[self._history_index] - self._current_path = path - source_index = self.model.setRootPath(path) - proxy_index = self.proxy.mapFromSource(source_index) - self.table.setRootIndex(proxy_index) - self._update_file_count() - self.folder_changed.emit(path) - self.path_changed.emit(path) + self._switch(self._history[self._history_index], False) def go_forward(self): """Geht einen Schritt vorwärts""" if self._history_index < len(self._history) - 1: self._history_index += 1 - path = self._history[self._history_index] - self._current_path = path - source_index = self.model.setRootPath(path) - proxy_index = self.proxy.mapFromSource(source_index) - self.table.setRootIndex(proxy_index) - self._update_file_count() - self.folder_changed.emit(path) - self.path_changed.emit(path) + self._switch(self._history[self._history_index], False) def go_up(self): """Geht zum übergeordneten Ordner""" diff --git a/tests/test_drive_switch.py b/tests/test_drive_switch.py index b8b3f28..44bc0f6 100644 --- a/tests/test_drive_switch.py +++ b/tests/test_drive_switch.py @@ -77,7 +77,10 @@ def browser(): b.resize(800, 500) b.show() pump(lambda: False, timeout=1.5) # let the initial real folder settle first - return b + yield b + b.close() + b.deleteLater() + QApplication.processEvents() def test_switch_to_slow_drive_does_not_block_event_loop(browser, slow_drive): @@ -90,20 +93,18 @@ def test_switch_to_slow_drive_does_not_block_event_loop(browser, slow_drive): 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) + assert browser.current_path == SLOW and browser.table.isHidden() # target shown, empty page + assert pump(lambda: not browser.table.isHidden()) # confirmed: the page opens 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 + assert browser.current_path == SLOW and browser.table.isHidden() # empty page stays, late answer ignored def test_newer_navigation_wins_over_slow_one(browser, slow_drive, tmp_path): @@ -146,3 +147,75 @@ def probe(path): 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 + + +# ---- unreachable targets show an empty page, never the old entries -------------------- + +UNREACHABLE = os.path.join(os.path.abspath(os.sep), "ep_unreachable_drive_marker") + + +def rows(browser): + return browser.proxy.rowCount(browser.table.rootIndex()) + + +@pytest.fixture +def filled(browser, tmp_path): + for name in ("a.txt", "b.txt", "c.txt"): + (tmp_path / name).write_text("x") + browser.navigate_to(str(tmp_path)) + assert pump(lambda: browser.current_path == str(tmp_path) and rows(browser) >= 3, timeout=12), (browser.current_path, browser.table.isHidden(), browser.model.rootPath(), browser.model.rowCount(browser.model.index(str(tmp_path))), browser.model.canFetchMore(browser.model.index(str(tmp_path))), browser._tokens, browser.table.rootIndex().isValid()) + return browser + + +@pytest.mark.parametrize("failure", ["none", "error"]) +def test_unreachable_target_shows_empty_page_with_status_not_old_entries(filled, monkeypatch, failure): + def classify(path): + if failure == "error": + raise OSError("drive not ready") + return None + + monkeypatch.setattr(fb_mod, "_classify", classify) + filled.navigate_to(UNREACHABLE) + assert filled.current_path == UNREACHABLE # address bar shows the target at once + assert filled.table.isHidden() # empty page at once: the old entries are gone + assert pump(lambda: "nicht erreichbar" in filled._busy.text() or "not reachable" in filled._busy.text()) + assert filled.table.isHidden() and not filled._busy.isHidden() # status only on the empty page + + +def test_back_and_retry_work_after_unreachable(filled, monkeypatch, tmp_path): + answers = [None] + real = fb_mod._classify + monkeypatch.setattr(fb_mod, "_classify", lambda p: answers[0] if p == UNREACHABLE else real(p)) + filled.navigate_to(UNREACHABLE) + assert pump(lambda: "nicht erreichbar" in filled._busy.text() or "not reachable" in filled._busy.text()) + filled.go_back() + assert filled.current_path == str(tmp_path) + assert pump(lambda: rows(filled) >= 3) + assert filled._busy.isHidden() + answers[0] = "dir" # drive is back: a new click checks again + filled.navigate_to(UNREACHABLE) + assert pump(lambda: not filled.table.isHidden()) + assert filled.current_path == UNREACHABLE + + +def test_timeout_keeps_empty_page(filled, monkeypatch): + monkeypatch.setattr(fb_mod, "_classify", lambda p: (time.sleep(DELAY), "dir")[1]) + filled.NAV_TIMEOUT_MS = 100 + filled.navigate_to(UNREACHABLE) + assert filled.table.isHidden() and filled.current_path == UNREACHABLE + assert pump(lambda: "Keine Antwort" in filled._busy.text() or "No response" in filled._busy.text(), timeout=2) + pump(lambda: False, timeout=DELAY + 0.5) # the late answer is ignored + assert filled.table.isHidden() + + +def test_known_network_path_is_rechecked_and_dropped_when_gone(filled, monkeypatch, tmp_path): + target = tmp_path / "net" + target.mkdir() + (target / "f.txt").write_text("x") + monkeypatch.setattr(fb_mod, "drive_kind", lambda p: 2) # pretend: network drive + filled._known_dirs.add(fb_mod._norm(str(target))) + monkeypatch.setattr(fb_mod, "_classify", lambda p: None) # it went away + filled.navigate_to(str(target)) + assert filled.current_path == str(target) and not filled.table.isHidden() # last state at once + assert pump(lambda: filled.table.isHidden()) # re-check failed: empty page + assert fb_mod._norm(str(target)) not in filled._known_dirs