From 403f1757923af94d01617a51852ead4c50fcdb93 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Mon, 28 Sep 2026 01:23:50 +0200 Subject: [PATCH 1/4] fix(store): restore file navigation and panels in packaged ExplorerPro --- STORE_ISSUES.md | 18 +++ src/app.py | 33 +++-- src/gui/browser/file_browser.py | 19 +-- src/gui/main_window.py | 27 +++- src/modules/editor/quick_editor.py | 36 ++++- tests/test_store_regressions_20260928.py | 178 +++++++++++++++++++++++ 6 files changed, 289 insertions(+), 22 deletions(-) create mode 100644 STORE_ISSUES.md create mode 100644 tests/test_store_regressions_20260928.py diff --git a/STORE_ISSUES.md b/STORE_ISSUES.md new file mode 100644 index 0000000..640c02e --- /dev/null +++ b/STORE_ISSUES.md @@ -0,0 +1,18 @@ +# ExplorerPro – Store-Fehler und Versionsstand + +## Installierte Store-Version: 1.0.0.0 + +Lokaler Paketbefund vom 28.09.2026: `Geiger.ExplorerPro_1.0.0.0_neutral__9jp3kxz2tnb3w`; die enthaltene `ExplorerPro.exe` stammt vom 30.08.2026. Das lokal gebaute Paket `releases/ExplorerPro.msix` hat dagegen die Identity `1.0.6.0` und eine andere EXE. Ein neuer Store-Release ist mit diesem Befund nicht belegt. + +## Offene Fehler + +| Ticket | Schwere | Beschreibung | Fundort | Status | +|---|---|---|---|---| +| T-20260928-288887892 | P1 | Sidebar und Vorschau können nach einem Neustart trotz aktivierter Ansicht auf Breite null bleiben. | `src/app.py`, `src/gui/main_window.py` | IN ARBEIT | +| T-20260928-288887892 | P1 | Ein Ordnerwechsel aktualisiert den Pfad, lädt aber keine Dateizeilen; dadurch bleiben Vorschau, Umbenennen und Dateiaktionen unerreichbar. | `src/gui/browser/file_browser.py` | IN ARBEIT | +| T-20260928-288887892 | P1 | Umbenennen und Kontextmenü-Aktionen müssen am installierten Paket und am neuen Build tatsächlich ausgelöst werden; der installierte Binärstand enthält den Fix vom 26.09.2026 nicht. | `src/gui/browser/file_browser.py`, Paketversion | IN ARBEIT | +| T-20260928-288887892 | P1 | „Python-Skript ausführen“ startet im gefrorenen Build erneut ExplorerPro statt eines Python-Interpreters. | `src/modules/editor/quick_editor.py` | IN ARBEIT | + +## Geplanter nächster Build: 1.0.7.0 + +Der lokale Build und der Test der entpackten Anwendung gehören zum Ticket. Eine Store-Einreichung ist nicht beauftragt. Vor einer späteren Einreichung sind die Icon-Collage und das Sicht-OK des Nutzers erforderlich. diff --git a/src/app.py b/src/app.py index a94a52d..4aca4d2 100644 --- a/src/app.py +++ b/src/app.py @@ -87,13 +87,23 @@ def _load_settings(self): self.center_on_screen() # Splitter-Größen - main_sizes = settings.value("splitter/main") - if main_sizes: - self.main_splitter.setSizes([int(s) for s in main_sizes]) - - right_sizes = settings.value("splitter/right") - if right_sizes: - self.right_splitter.setSizes([int(s) for s in right_sizes]) + for key, splitter in ( + ("splitter/main", self.main_splitter), + ("splitter/right", self.right_splitter), + ): + saved = settings.value(key) + try: + sizes = [int(size) for size in saved] + except (TypeError, ValueError): + continue + # Qt saves a hidden splitter child as size 0. Restoring that value + # collapses the panel even though its View action remains checked. + if len(sizes) == splitter.count() and all(size > 0 for size in sizes): + splitter.setSizes(sizes) + + # Older sessions saved zero-width panels while their menu actions stayed checked. + self._toggle_sidebar() + self._toggle_preview() def _setup_connections(self): """Verbindet Signale und Slots""" @@ -242,8 +252,13 @@ def closeEvent(self, event): # Einstellungen speichern settings = QSettings() settings.setValue("window/geometry", self.saveGeometry()) - settings.setValue("splitter/main", self.main_splitter.sizes()) - settings.setValue("splitter/right", self.right_splitter.sizes()) + for key, splitter in ( + ("splitter/main", self.main_splitter), + ("splitter/right", self.right_splitter), + ): + sizes = splitter.sizes() + if all(size > 0 for size in sizes): + settings.setValue(key, sizes) logging.info("ExplorerPro beendet") super().closeEvent(event) diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index 66c993b..ca45c1f 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -116,7 +116,6 @@ def _setup_ui(self): # Datei-System-Model self.model = QFileSystemModel() - self.model.setRootPath("") self.model.setFilter( QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot @@ -131,9 +130,6 @@ def _setup_ui(self): # Tabellen-View (DnD-fähige Unterklasse für startDrag-Override) self.table = _DnDTableView(self) self.table.setModel(self.proxy) - self.table.setRootIndex(self.proxy.mapFromSource( - self.model.index(QDir.rootPath()) - )) # Spalten konfigurieren self.table.setSelectionBehavior( @@ -219,7 +215,9 @@ def navigate_to(self, path: str): self._current_path = path - source_index = self.model.index(path) + # QFileSystemModel loads directories asynchronously. index(path) alone + # can point at a valid directory while its rows remain empty forever. + source_index = self.model.setRootPath(path) proxy_index = self.proxy.mapFromSource(source_index) self.table.setRootIndex(proxy_index) @@ -235,7 +233,7 @@ def go_back(self): self._history_index -= 1 path = self._history[self._history_index] self._current_path = path - source_index = self.model.index(path) + source_index = self.model.setRootPath(path) proxy_index = self.proxy.mapFromSource(source_index) self.table.setRootIndex(proxy_index) self._update_file_count() @@ -248,7 +246,7 @@ def go_forward(self): self._history_index += 1 path = self._history[self._history_index] self._current_path = path - source_index = self.model.index(path) + source_index = self.model.setRootPath(path) proxy_index = self.proxy.mapFromSource(source_index) self.table.setRootIndex(proxy_index) self._update_file_count() @@ -265,8 +263,8 @@ def go_up(self): def refresh(self): """Aktualisiert die Ansicht""" if self._current_path: - self.model.setRootPath("") - self.model.setRootPath(self._current_path) + source_index = self.model.setRootPath(self._current_path) + self.table.setRootIndex(self.proxy.mapFromSource(source_index)) self._update_file_count() def set_show_hidden_files(self, show: bool): @@ -305,6 +303,9 @@ def _show_context_menu(self, pos): menu = QMenu(self) if index.isValid(): + # A right click on another row must act on that row, not on an old selection. + if not self.table.selectionModel().isRowSelected(index.row(), index.parent()): + self.table.selectRow(index.row()) source_index = self.proxy.mapToSource(index) file_path = self.model.filePath(source_index) is_file = os.path.isfile(file_path) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 50be397..8e5474a 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -539,16 +539,37 @@ def _create_new_folder(self): def _toggle_sidebar(self): """Sidebar ein-/ausblenden""" - self.sidebar.setVisible(self.toggle_sidebar.isChecked()) + self._set_splitter_panel_visible( + self.main_splitter, self.sidebar, self.toggle_sidebar.isChecked(), 250 + ) def _toggle_preview(self): """Preview ein-/ausblenden""" - self.preview_panel.setVisible(self.toggle_preview.isChecked()) + self._set_splitter_panel_visible( + self.right_splitter, self.preview_panel, self.toggle_preview.isChecked(), 350 + ) + + @staticmethod + def _set_splitter_panel_visible(splitter, panel, visible: bool, preferred_width: int): + """Restore a panel collapsed to zero by a persisted splitter state.""" + panel.setVisible(visible) + if not visible: + return + sizes = splitter.sizes() + index = splitter.indexOf(panel) + if index < 0 or sizes[index] > 0: + return + other = 1 - index + total = sum(sizes) or splitter.width() or preferred_width * 3 + panel_width = min(preferred_width, max(1, total // 3)) + sizes[index] = panel_width + sizes[other] = max(1, total - panel_width) + splitter.setSizes(sizes) def show_file_metadata(self, path: str, focus_tags: bool = False): """Blendet das Vorschau-/Metadaten-Panel ein und zeigt die Datei dort an.""" self.toggle_preview.setChecked(True) - self.preview_panel.setVisible(True) + self._toggle_preview() self.preview_panel.show_preview(path) if focus_tags: tags_edit = self.preview_panel.metadata_panel.tags_edit diff --git a/src/modules/editor/quick_editor.py b/src/modules/editor/quick_editor.py index 8e15eff..5a01f66 100644 --- a/src/modules/editor/quick_editor.py +++ b/src/modules/editor/quick_editor.py @@ -6,6 +6,8 @@ """ import json +import os +import shutil import sys from pathlib import Path from typing import Optional @@ -24,6 +26,31 @@ from .syntax_highlighter import get_lexer_for_extension +def find_python_interpreter() -> str | None: + """Return a real Python executable, never the frozen ExplorerPro executable.""" + if not getattr(sys, "frozen", False): + return sys.executable + + configured = os.environ.get("EXPLORERPRO_PYTHON", "").strip().strip('"') + candidates = [configured] if configured else [shutil.which(name) for name in ("py", "python", "python3")] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate) + if not path.is_absolute(): + resolved = shutil.which(candidate) + if not resolved: + continue + path = Path(resolved) + try: + if (path.is_file() and path.stat().st_size > 0 + and path.resolve() != Path(sys.executable).resolve()): + return str(path) + except OSError: + continue + return None + + def _validate_json(text: str) -> tuple: """Validiert JSON-Text. Gibt (ok: bool, nachricht: str) zurück.""" try: @@ -500,7 +527,14 @@ def _run_code(self): self._process.finished.connect(self._process_finished) if ext == '.py': - self._process.start(sys.executable, [self.filepath]) + python = find_python_interpreter() + if python is None: + message = ("Kein Python-Interpreter gefunden. Installieren Sie Python oder " + "setzen Sie EXPLORERPRO_PYTHON auf die echte python.exe.") + self._add_output(message + "\n", "#F14C4C") + QMessageBox.warning(self, "Python-Skript ausführen", message) + return + self._process.start(python, [self.filepath]) elif ext == '.js': self._process.start('node', [self.filepath]) else: diff --git a/tests/test_store_regressions_20260928.py b/tests/test_store_regressions_20260928.py new file mode 100644 index 0000000..8d67607 --- /dev/null +++ b/tests/test_store_regressions_20260928.py @@ -0,0 +1,178 @@ +"""User-visible regressions from ticket T-20260928-288887892.""" +import os +import sys +import time +from pathlib import Path +from unittest.mock import Mock + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from PySide6.QtCore import Qt +from PySide6.QtGui import QContextMenuEvent +from PySide6.QtWidgets import QApplication, QInputDialog, QMenu + +import gui.browser.file_browser as browser_module +from gui.browser.file_browser import FileBrowser +from gui.main_window import MainWindow +from gui.preview.preview_panel import PreviewPanel +from modules.editor import quick_editor + + +def _app(): + return QApplication.instance() or QApplication([]) + + +def _actions_from_real_context_event(browser, path, monkeypatch): + browser.navigate_to(str(path.parent)) + browser.show() + for _ in range(100): + _app().processEvents() + index = browser.proxy.mapFromSource(browser.model.index(str(path))) + if index.isValid(): + break + time.sleep(.01) + assert index.isValid() + menus = [] + + class CapturingMenu(QMenu): + def exec(self, *args): + menus.append(self) + + monkeypatch.setattr(browser_module, "QMenu", CapturingMenu) + pos = browser.table.visualRect(index).center() + event = QContextMenuEvent( + QContextMenuEvent.Reason.Mouse, pos, browser.table.viewport().mapToGlobal(pos) + ) + QApplication.sendEvent(browser.table.viewport(), event) + assert len(menus) == 1, "The viewport must deliver the real context menu event" + return {action.text(): action for action in menus[0].actions() if action.text()} + + +def test_collapsed_sidebar_and_preview_restore_and_render(tmp_path, monkeypatch): + _app() + win = MainWindow() + win.resize(1400, 900) + win.show() + _app().processEvents() + win.main_splitter.setSizes([0, 1200]) + win.right_splitter.setSizes([1200, 0]) + assert win.main_splitter.sizes()[0] == 0 + assert win.right_splitter.sizes()[1] == 0 + + win._toggle_sidebar() + win._toggle_preview() + assert win.main_splitter.sizes()[0] > 0 + assert win.right_splitter.sizes()[1] > 0 + assert win.sidebar.stack.count() >= 6 + assert win.preview_panel.preview_stack.count() >= 6 + + target = tmp_path / "Äpfel.txt" + target.write_text("Vorschau mit Umlauten: Äpfel", encoding="utf-8") + win.show_file_metadata(str(target)) + assert "Äpfel" in win.preview_panel.text_preview.toPlainText() + assert win.preview_panel.isVisible() + win.close() + + +def test_navigation_loads_files_and_selection_reaches_preview(tmp_path): + _app() + target = tmp_path / "Äpfel.txt" + target.write_text("Sichtbare Vorschau: Äpfel", encoding="utf-8") + browser = FileBrowser() + preview = PreviewPanel() + browser.file_selected.connect(preview.show_preview) + 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) + root = browser.proxy.mapToSource(browser.table.rootIndex()) + assert Path(browser.model.filePath(root)) == tmp_path + assert browser.proxy.rowCount(browser.table.rootIndex()) > 0 + item = browser.proxy.mapFromSource(browser.model.index(str(target))) + assert item.isValid() + browser._on_item_clicked(item) + assert "Äpfel" in preview.text_preview.toPlainText() + browser.close() + preview.close() + + +def test_real_context_event_executes_each_file_action_and_rename(tmp_path, monkeypatch): + _app() + target = tmp_path / "alt.txt" + target.write_text("x", encoding="utf-8") + browser = FileBrowser() + browser.resize(800, 500) + actions = _actions_from_real_context_event(browser, target, monkeypatch) + calls = {} + bindings = { + "📂 Öffnen": "_open_file", + "✏️ In Editor öffnen": "_edit_file", + "🔑 Prüfsummen berechnen...": "_show_checksums", + "🔍 In Index suchen": "_search_in_index", + "📊 Metadaten anzeigen": "_show_metadata", + "🏷️ Tags bearbeiten": "_show_metadata", + "🔄 Synchronisieren": "_sync_path", + "📋 Pfad als Prompt speichern": "_save_path_as_prompt", + "🛡️ Datenschutz prüfen": "_check_privacy", + "🔴 Zur Blacklist hinzufügen": "_add_to_blacklist", + "Kopieren": "copy_selection", + "Löschen": "delete_selection", + } + for method in set(bindings.values()): + calls[method] = Mock() + monkeypatch.setattr(browser, method, calls[method]) + for label, method in bindings.items(): + assert label in actions + actions[label].trigger() + assert calls[method].called, label + calls[method].reset_mock() + monkeypatch.setattr(QInputDialog, "getText", lambda *a, **k: ("neu.txt", True)) + actions["Umbenennen"].trigger() + assert not target.exists() + assert (tmp_path / "neu.txt").exists() + browser.close() + + +def test_frozen_editor_uses_real_python_and_rejects_alias(tmp_path, monkeypatch): + script = tmp_path / "script.py" + script.write_text("print('script ran')", encoding="utf-8") + fake_app = tmp_path / "ExplorerPro.exe" + fake_app.write_bytes(b"app") + alias = tmp_path / "python.exe" + alias.touch() + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(fake_app)) + monkeypatch.setattr(quick_editor.shutil, "which", lambda name: str(alias)) + assert quick_editor.find_python_interpreter() is None + + real_python = tmp_path / "real-python.exe" + real_python.write_bytes(b"python") + monkeypatch.setenv("EXPLORERPRO_PYTHON", str(real_python)) + assert quick_editor.find_python_interpreter() == str(real_python) + monkeypatch.setenv("EXPLORERPRO_PYTHON", str(fake_app)) + assert quick_editor.find_python_interpreter() is None + + +def test_frozen_run_button_starts_interpreter_not_explorerpro(tmp_path, monkeypatch): + _app() + script = tmp_path / "run.py" + script.write_text("print('script ran')", encoding="utf-8") + fake_app = tmp_path / "ExplorerPro.exe" + fake_app.write_bytes(b"app") + interpreter = tmp_path / "python.exe" + interpreter.write_bytes(b"python") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(fake_app)) + monkeypatch.setenv("EXPLORERPRO_PYTHON", str(interpreter)) + + launched = [] + monkeypatch.setattr(quick_editor.QProcess, "start", lambda self, program, args: launched.append((program, args))) + editor = quick_editor.QuickEditorDialog(str(script)) + editor._run_code() + assert launched == [(str(interpreter), [str(script)])] + editor.close() From f94b3322f750fb940784030981a247c62b1d6420 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Mon, 28 Sep 2026 01:30:32 +0200 Subject: [PATCH 2/4] test(store): exercise all context menu variants --- tests/test_store_regressions_20260928.py | 76 +++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/test_store_regressions_20260928.py b/tests/test_store_regressions_20260928.py index 8d67607..2ccdf46 100644 --- a/tests/test_store_regressions_20260928.py +++ b/tests/test_store_regressions_20260928.py @@ -8,7 +8,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from PySide6.QtCore import Qt +from PySide6.QtCore import QItemSelectionModel from PySide6.QtGui import QContextMenuEvent from PySide6.QtWidgets import QApplication, QInputDialog, QMenu @@ -49,6 +49,23 @@ def exec(self, *args): return {action.text(): action for action in menus[0].actions() if action.text()} +def _blank_actions_from_real_context_event(browser, monkeypatch): + menus = [] + + class CapturingMenu(QMenu): + def exec(self, *args): + menus.append(self) + + monkeypatch.setattr(browser_module, "QMenu", CapturingMenu) + pos = browser.table.viewport().rect().bottomRight() + event = QContextMenuEvent( + QContextMenuEvent.Reason.Mouse, pos, browser.table.viewport().mapToGlobal(pos) + ) + QApplication.sendEvent(browser.table.viewport(), event) + assert len(menus) == 1 + return {action.text(): action for action in menus[0].actions() if action.text()} + + def test_collapsed_sidebar_and_preview_restore_and_render(tmp_path, monkeypatch): _app() win = MainWindow() @@ -138,6 +155,63 @@ def test_real_context_event_executes_each_file_action_and_rename(tmp_path, monke browser.close() +def test_real_context_event_executes_multi_selection_actions(tmp_path, monkeypatch): + _app() + first = tmp_path / "a.txt" + second = tmp_path / "b.txt" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + browser = FileBrowser() + 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()) == 2: + break + time.sleep(.01) + for path in (first, second): + index = browser.proxy.mapFromSource(browser.model.index(str(path))) + browser.table.selectionModel().select( + index, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows + ) + batch = Mock() + diff = Mock() + monkeypatch.setattr(browser, "_show_batch_rename", batch) + monkeypatch.setattr(browser, "_show_diff", diff) + actions = _actions_from_real_context_event(browser, first, monkeypatch) + actions["✏️ Mehrfach umbenennen..."].trigger() + actions["⚖️ Dateien vergleichen (Diff)..."].trigger() + batch.assert_called_once() + diff.assert_called_once() + browser.close() + + +def test_real_context_event_executes_blank_area_actions(tmp_path, monkeypatch): + _app() + browser = FileBrowser() + browser.resize(800, 500) + browser.show() + browser.navigate_to(str(tmp_path)) + methods = { + "📄 Neue Datei...": "create_new_file", + "📁 Neuer Ordner": "create_new_folder", + "⚖️ Dateien vergleichen...": "_show_diff", + "Einfügen": "paste_from_clipboard", + "Aktualisieren": "refresh", + } + calls = {} + for method in set(methods.values()): + calls[method] = Mock() + monkeypatch.setattr(browser, method, calls[method]) + actions = _blank_actions_from_real_context_event(browser, monkeypatch) + for label, method in methods.items(): + assert label in actions + actions[label].trigger() + calls[method].assert_called_once() + browser.close() + + def test_frozen_editor_uses_real_python_and_rejects_alias(tmp_path, monkeypatch): script = tmp_path / "script.py" script.write_text("print('script ran')", encoding="utf-8") From 041c2e0b0d2abb8fce2c6c2e11a1c5aa35c5f81a Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Mon, 28 Sep 2026 01:31:33 +0200 Subject: [PATCH 3/4] test(gui): exercise all context menu branches via Qt events --- tests/test_store_regressions_20260928.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_store_regressions_20260928.py b/tests/test_store_regressions_20260928.py index 2ccdf46..a7fe322 100644 --- a/tests/test_store_regressions_20260928.py +++ b/tests/test_store_regressions_20260928.py @@ -185,6 +185,8 @@ def test_real_context_event_executes_multi_selection_actions(tmp_path, monkeypat batch.assert_called_once() diff.assert_called_once() browser.close() + browser.deleteLater() + _app().processEvents() def test_real_context_event_executes_blank_area_actions(tmp_path, monkeypatch): @@ -210,6 +212,8 @@ def test_real_context_event_executes_blank_area_actions(tmp_path, monkeypatch): actions[label].trigger() calls[method].assert_called_once() browser.close() + browser.deleteLater() + _app().processEvents() def test_frozen_editor_uses_real_python_and_rejects_alias(tmp_path, monkeypatch): From 47eb6f30eef9e2c764ff82385f1726f7a41736d8 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Mon, 28 Sep 2026 01:35:27 +0200 Subject: [PATCH 4/4] ci: isolate Qt test workers to avoid teardown crash --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5499d6b..31bc1ca 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 ruff Pillow + pip install -r requirements.txt pytest pytest-xdist ruff Pillow - name: Compile sources run: | @@ -59,4 +59,4 @@ jobs: PYTHONPATH: ${{ github.workspace }} QT_QPA_PLATFORM: offscreen run: | - python -m pytest -ra -v + python -m pytest -ra -v -n 2