Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ 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.
- **Drives & cloud storage**: no shell icon lookups on the GUI thread (generic drive/folder icons); cloud locations are discovered in the background.
- **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`)**:
Expand Down
8 changes: 8 additions & 0 deletions locales/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Нет ответа – диск не отвечает"
}
}
4 changes: 2 additions & 2 deletions releases/windowsstore/v1.0.9/release-notes.json
Original file line number Diff line number Diff line change
@@ -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."
}
5 changes: 4 additions & 1 deletion src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions src/core/platform_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
134 changes: 128 additions & 6 deletions src/gui/browser/file_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()))

Expand All @@ -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:
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/gui/sidebar/sidebar_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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():
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading