From d0031530f87ce6e40addceb06a97a759d16898d4 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sat, 26 Sep 2026 13:38:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(gui):=20Kontextmen=C3=BC-Tags/Metadaten,=20?= =?UTF-8?q?Editor-Dirty-Flag,=20deutsche=20Dialogbuttons=20(T-20260926-912?= =?UTF-8?q?169808)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tags/Notizen im Metadaten-Panel wurden nie gespeichert: FileIndex erhält get/set_tags und get/set_note (vorhandene Tabellen tags/file_tags/notes), MetadataPanel lädt/speichert sie; index_file nutzt UPSERT statt INSERT OR REPLACE, damit files.id und damit Tags beim Reindex erhalten bleiben. - "Metadaten anzeigen"/"Tags bearbeiten" blendeten nichts ein, wenn das Vorschaupanel ausgeblendet war: neue MainWindow.show_file_metadata(). - QuickEditor meldete nach bloßem Öffnen Änderungen: der verzögerte Syntax-Highlighter löste textChanged aus. Dirty-Flag folgt jetzt QTextDocument.modificationChanged. - Qt-Standardbuttons (Speichern/Verwerfen/Abbrechen, Ja/Nein) waren englisch: main.py installiert qtbase_.qm per QTranslator. - Quellstart: translator.py lag nicht auf sys.path (Mehrfach-Umbenennen, Diff, Einstellungen -> ModuleNotFoundError); Spec bündelt jetzt translator.py und locales/. - Regressionstests: tests/test_context_menu_actions.py (7 Tests). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PTbvD41MCVmnQWaobvHfCk --- ExplorerPro.spec | 4 +- src/app.py | 6 +- src/core/file_index.py | 84 ++++++++++++++- src/gui/browser/file_browser.py | 12 ++- src/gui/main_window.py | 10 ++ src/gui/preview/preview_panel.py | 34 ++++++ src/main.py | 18 +++- src/modules/editor/quick_editor.py | 18 ++-- tests/test_context_menu_actions.py | 168 +++++++++++++++++++++++++++++ 9 files changed, 340 insertions(+), 14 deletions(-) create mode 100644 tests/test_context_menu_actions.py diff --git a/ExplorerPro.spec b/ExplorerPro.spec index b90a422..0fccbe9 100644 --- a/ExplorerPro.spec +++ b/ExplorerPro.spec @@ -8,11 +8,13 @@ icon_file = project_root / 'ExplorerPro.ico' a = Analysis( [str(src_dir / 'main.py')], - pathex=[str(src_dir)], + # project_root: translator.py liegt neben src/ (gui/batch_rename_dialog, diff_dialog, settings_dialog) + pathex=[str(src_dir), str(project_root)], binaries=[], datas=[ (str(icon_file), '.'), (str(project_root / 'assets'), 'assets'), + (str(project_root / 'locales'), 'locales'), (str(project_root / 'LICENSE'), '.'), (str(project_root / 'THIRD_PARTY_LICENSES.txt'), '.'), (str(project_root / 'PRIVACY_POLICY.md'), '.'), diff --git a/src/app.py b/src/app.py index 682e0ed..a94a52d 100644 --- a/src/app.py +++ b/src/app.py @@ -68,8 +68,9 @@ def _init_file_index(self): db_path = config_dir / "fileindex.db" self.file_index = FileIndex(str(db_path)) - # Index an Sidebar weitergeben + # Index an Sidebar und Metadaten-Panel (Tags/Notizen) weitergeben self.sidebar.set_file_index(self.file_index) + self.preview_panel.metadata_panel.set_file_index(self.file_index) logging.info("FileIndex initialisiert") @@ -235,6 +236,9 @@ def closeEvent(self, event): sp.search_worker.cancel() sp.search_worker.wait(3000) + # Offene Tag-/Notiz-Eingaben sichern + self.preview_panel.metadata_panel.save_user_data() + # Einstellungen speichern settings = QSettings() settings.setValue("window/geometry", self.saveGeometry()) diff --git a/src/core/file_index.py b/src/core/file_index.py index 5785c84..818bac0 100644 --- a/src/core/file_index.py +++ b/src/core/file_index.py @@ -269,9 +269,15 @@ def index_file(self, filepath: str, calculate_hash: bool = True) -> bool: cursor = conn.cursor() cursor.execute(''' - INSERT OR REPLACE INTO files + INSERT INTO files (path, filename, extension, size, modified, created, hash, category, text_content, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + filename = excluded.filename, extension = excluded.extension, + size = excluded.size, modified = excluded.modified, + created = excluded.created, hash = excluded.hash, + category = excluded.category, text_content = excluded.text_content, + indexed_at = excluded.indexed_at ''', ( filepath, filename, @@ -305,6 +311,82 @@ def remove_file(self, filepath: str) -> bool: finally: conn.close() + def _ensure_file_id(self, cursor, filepath: str) -> int: + """Liefert die files.id eines Pfads und legt bei Bedarf einen Minimaleintrag an.""" + filepath = os.path.normpath(filepath) + filename = os.path.basename(filepath) + cursor.execute( + 'INSERT INTO files (path, filename, extension, category) VALUES (?, ?, ?, ?) ' + 'ON CONFLICT(path) DO NOTHING', + (filepath, filename, os.path.splitext(filename)[1].lower(), self.get_category(filename)), + ) + cursor.execute('SELECT id FROM files WHERE path = ?', (filepath,)) + return cursor.fetchone()[0] + + def get_tags(self, filepath: str) -> List[str]: + """Gibt die Tags einer Datei alphabetisch zurück.""" + filepath = os.path.normpath(filepath) # Browser liefert Slash, os.walk Backslash + conn = sqlite3.connect(self.db_path) + try: + rows = conn.execute( + 'SELECT t.name FROM tags t JOIN file_tags ft ON ft.tag_id = t.id ' + 'JOIN files f ON f.id = ft.file_id WHERE f.path = ? ORDER BY t.name', + (filepath,), + ).fetchall() + return [r[0] for r in rows] + finally: + conn.close() + + def set_tags(self, filepath: str, tags: List[str]) -> None: + """Ersetzt die Tags einer Datei (leere Liste entfernt alle Tags).""" + clean = sorted({t.strip() for t in tags if t and t.strip()}) + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.cursor() + file_id = self._ensure_file_id(cursor, filepath) + cursor.execute('DELETE FROM file_tags WHERE file_id = ?', (file_id,)) + for name in clean: + cursor.execute('INSERT OR IGNORE INTO tags (name) VALUES (?)', (name,)) + cursor.execute( + 'INSERT OR IGNORE INTO file_tags (file_id, tag_id) ' + 'SELECT ?, id FROM tags WHERE name = ?', + (file_id, name), + ) + conn.commit() + finally: + conn.close() + + def get_note(self, filepath: str) -> str: + """Gibt die Notiz zu einer Datei zurück (leer, wenn keine existiert).""" + filepath = os.path.normpath(filepath) + conn = sqlite3.connect(self.db_path) + try: + row = conn.execute( + 'SELECT n.content FROM notes n JOIN files f ON f.id = n.file_id WHERE f.path = ?', + (filepath,), + ).fetchone() + return (row[0] or "") if row else "" + finally: + conn.close() + + def set_note(self, filepath: str, content: str) -> None: + """Speichert die Notiz zu einer Datei; leerer Text löscht sie.""" + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.cursor() + file_id = self._ensure_file_id(cursor, filepath) + if content.strip(): + cursor.execute( + 'INSERT INTO notes (file_id, content) VALUES (?, ?) ON CONFLICT(file_id) ' + 'DO UPDATE SET content = excluded.content, updated_at = CURRENT_TIMESTAMP', + (file_id, content), + ) + else: + cursor.execute('DELETE FROM notes WHERE file_id = ?', (file_id,)) + conn.commit() + finally: + conn.close() + def get_file(self, filepath: str) -> Optional[Dict]: """Gibt Informationen zu einer indizierten Datei zurück oder None""" conn = sqlite3.connect(self.db_path) diff --git a/src/gui/browser/file_browser.py b/src/gui/browser/file_browser.py index acbb91b..66c993b 100644 --- a/src/gui/browser/file_browser.py +++ b/src/gui/browser/file_browser.py @@ -334,11 +334,11 @@ def _show_context_menu(self, pos): menu.addAction(index_action) meta_action = QAction("📊 Metadaten anzeigen", self) - meta_action.triggered.connect(lambda: self.file_selected.emit(file_path)) + meta_action.triggered.connect(lambda: self._show_metadata(file_path)) menu.addAction(meta_action) tags_action = QAction("🏷️ Tags bearbeiten", self) - tags_action.triggered.connect(lambda: self.file_selected.emit(file_path)) + tags_action.triggered.connect(lambda: self._show_metadata(file_path, focus_tags=True)) menu.addAction(tags_action) menu.addSeparator() @@ -444,6 +444,14 @@ def _edit_file(self, path: str): editor = QuickEditorDialog(path, self.window()) editor.exec() + def _show_metadata(self, path: str, focus_tags: bool = False): + """Zeigt Metadaten/Tags im (ggf. ausgeblendeten) Vorschau-Panel des Hauptfensters.""" + main_win = self.window() + if hasattr(main_win, "show_file_metadata"): + main_win.show_file_metadata(path, focus_tags=focus_tags) + else: + self.file_selected.emit(path) + def _show_checksums(self, path: str): """Öffnet den Prüfsummen-Dialog für eine Datei""" if not os.path.isfile(path): diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 630f385..50be397 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -545,6 +545,16 @@ def _toggle_preview(self): """Preview ein-/ausblenden""" self.preview_panel.setVisible(self.toggle_preview.isChecked()) + 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.preview_panel.show_preview(path) + if focus_tags: + tags_edit = self.preview_panel.metadata_panel.tags_edit + tags_edit.setFocus() + tags_edit.selectAll() + def _go_home(self): """Zum Home-Verzeichnis""" home = QStandardPaths.writableLocation( diff --git a/src/gui/preview/preview_panel.py b/src/gui/preview/preview_panel.py index 1c47f51..f0e760d 100644 --- a/src/gui/preview/preview_panel.py +++ b/src/gui/preview/preview_panel.py @@ -276,8 +276,38 @@ class MetadataPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) + self._current_path = None + self._file_index = None + self._loaded_user_data = ("", "") self._setup_ui() + def set_file_index(self, file_index): + """Verbindet das Panel mit dem Index, in dem Tags und Notizen gespeichert werden.""" + self._file_index = file_index + + def _user_data(self) -> tuple: + return (self.tags_edit.text().strip(), self.notes_edit.toPlainText()) + + def save_user_data(self): + """Speichert geänderte Tags/Notizen der angezeigten Datei im Index.""" + if not self._file_index or not self._current_path: + return + tags, notes = self._user_data() + if (tags, notes) == self._loaded_user_data: + return + self._file_index.set_tags(self._current_path, tags.split(",")) + self._file_index.set_note(self._current_path, notes) + self._loaded_user_data = (tags, notes) + + def _load_user_data(self, path: str): + tags, notes = "", "" + if self._file_index: + tags = ", ".join(self._file_index.get_tags(path)) + notes = self._file_index.get_note(path) + self.tags_edit.setText(tags) + self.notes_edit.setPlainText(notes) + self._loaded_user_data = self._user_data() + def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) @@ -318,6 +348,7 @@ def _setup_ui(self): self.tags_edit.setPlaceholderText("Tags hinzufügen (kommagetrennt)") self.tags_edit.setAccessibleName("Metadaten-Tags") self.tags_edit.setToolTip("Kommagetrennte Tags für die Datei eingeben") + self.tags_edit.editingFinished.connect(self.save_user_data) tags_layout.addWidget(self.tags_edit) layout.addWidget(tags_group) @@ -339,6 +370,7 @@ def _setup_ui(self): def clear_metadata(self): """Setzt die Metadaten-Anzeige vollständig zurück.""" + self.save_user_data() self.name_label.setText("-") self.type_label.setText("-") self.size_label.setText("-") @@ -352,6 +384,7 @@ def clear_metadata(self): def show_metadata(self, path: str): """Zeigt Metadaten einer Datei""" + self.save_user_data() if not path or not os.path.exists(path): self.clear_metadata() return @@ -402,6 +435,7 @@ def show_metadata(self, path: str): self.created_label.setText("-") self._current_path = path + self._load_user_data(path) if hasattr(self, "checksum_btn"): self.checksum_btn.setEnabled(os.path.isfile(path)) diff --git a/src/main.py b/src/main.py index 066e8a4..6ebacc5 100644 --- a/src/main.py +++ b/src/main.py @@ -18,11 +18,12 @@ if hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(encoding='utf-8') -# Pfad hinzufügen +# Pfad hinzufügen (src/ und Projektwurzel mit translator.py) +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__))) from PySide6.QtWidgets import QApplication -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTranslator, QLibraryInfo from PySide6.QtGui import QIcon from app import ExplorerProApp @@ -49,6 +50,17 @@ def load_app_icon() -> QIcon: return QIcon() +def install_qt_translations(app: QApplication, lang: str): + """Lädt Qts eigene Übersetzung (qtbase_.qm), damit Standard-Buttons + wie Ja/Nein, Speichern/Verwerfen/Abbrechen in der UI-Sprache erscheinen.""" + translator = QTranslator(app) + path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + if translator.load(f"qtbase_{lang}", path): + app.installTranslator(translator) + return translator + return None + + def main(): """Haupteinstiegspunkt für ExplorerPro""" # High DPI Support @@ -59,6 +71,8 @@ def main(): app = QApplication(sys.argv) app.setApplicationName("ExplorerPro") app.setOrganizationName("ExplorerPro") + from translator import get_translator + install_qt_translations(app, get_translator().get_language()) app.setApplicationVersion("0.1.0") icon = load_app_icon() if not icon.isNull(): diff --git a/src/modules/editor/quick_editor.py b/src/modules/editor/quick_editor.py index d2e68e5..8e15eff 100644 --- a/src/modules/editor/quick_editor.py +++ b/src/modules/editor/quick_editor.py @@ -331,7 +331,10 @@ def _setup_ui(self): self.editor.setAccessibleDescription( "Mehrzeiliger Quelltext-Editor mit Zeilennummern und Syntax-Hervorhebung." ) - self.editor.textChanged.connect(self._on_text_changed) + # modificationChanged statt textChanged: der Syntax-Highlighter formatiert + # verzögert nach dem Laden und löst dabei textChanged aus, ohne den Text + # zu ändern -> sonst wäre jede frisch geöffnete Datei "ungespeichert". + self.editor.document().modificationChanged.connect(self._on_modification_changed) splitter.addWidget(self.editor) # Output Panel @@ -401,6 +404,7 @@ def _load_file(self, filepath: str): self.file_label.setText(path.name) self.setWindowTitle(f"Quick Editor - {path.name}") + self.editor.document().setModified(False) self._modified = False self.modified_label.setText("") @@ -427,6 +431,7 @@ def _save_file(self): with open(self.filepath, 'w', encoding='utf-8') as f: f.write(self.editor.toPlainText()) + self.editor.document().setModified(False) self._modified = False self.modified_label.setText("") self.file_label.setText(Path(self.filepath).name) @@ -460,11 +465,10 @@ def _validate_file(self): color = "#4EC9B0" if ok else "#F14C4C" self._add_output(msg + "\n", color) - def _on_text_changed(self): - """Handler für Textänderungen""" - if not self._modified: - self._modified = True - self.modified_label.setText("●") + def _on_modification_changed(self, modified: bool): + """Handler für den Änderungsstatus des Dokuments""" + self._modified = modified + self.modified_label.setText("●" if modified else "") def _update_cursor_position(self): """Aktualisiert die Cursor-Position in der Statusbar""" @@ -590,7 +594,7 @@ def closeEvent(self, event): if self._modified: reply = QMessageBox.question( self, "Ungespeicherte Änderungen", - "Es gibt ungespeicherte Änderungen.\nTrotzdem schließen?", + "Es gibt ungespeicherte Änderungen.\nMöchten Sie die Änderungen speichern?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel diff --git a/tests/test_context_menu_actions.py b/tests/test_context_menu_actions.py new file mode 100644 index 0000000..66f36b4 --- /dev/null +++ b/tests/test_context_menu_actions.py @@ -0,0 +1,168 @@ +"""Regressionstests T-20260926-912169808: Kontextmenü-Aktionen, Editor-Dirty-Flag, Qt-Übersetzung.""" +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from PySide6.QtWidgets import QApplication, QInputDialog, QMenu, QMessageBox + +import gui.browser.file_browser as file_browser_module +from gui.browser.file_browser import FileBrowser +from core.file_index import FileIndex + + +def _ensure_app() -> QApplication: + return QApplication.instance() or QApplication([]) + + +def _context_actions(browser: FileBrowser, path: Path, monkeypatch) -> dict: + """Öffnet das Kontextmenü auf `path` und gibt dessen Aktionen nach Text zurück.""" + app = _ensure_app() + browser.navigate_to(str(path.parent)) + for _ in range(100): + app.processEvents() + index = browser.proxy.mapFromSource(browser.model.index(str(path))) + if index.isValid(): + break + time.sleep(0.02) + assert index.isValid() + browser.table.selectRow(index.row()) + + captured = {} + + class _CapturingMenu(QMenu): + def exec(self, *args): + captured["menu"] = self + + monkeypatch.setattr(file_browser_module, "QMenu", _CapturingMenu) + browser._show_context_menu(browser.table.visualRect(index).center()) + return {a.text(): a for a in captured["menu"].actions() if a.text()} + + +def test_context_menu_rename_and_delete(tmp_path, monkeypatch): + _ensure_app() + target = tmp_path / "alt.txt" + target.write_text("x", encoding="utf-8") + browser = FileBrowser() + browser.resize(800, 600) + + monkeypatch.setattr(QInputDialog, "getText", lambda *a, **k: ("neu.txt", True)) + _context_actions(browser, target, monkeypatch)["Umbenennen"].trigger() + assert (tmp_path / "neu.txt").exists() and not target.exists() + + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.StandardButton.Yes) + _context_actions(browser, tmp_path / "neu.txt", monkeypatch)["Löschen"].trigger() + assert not (tmp_path / "neu.txt").exists() + + +def test_metadata_and_tags_actions_show_hidden_panel_and_persist_tags(tmp_path, monkeypatch): + _ensure_app() + from gui.main_window import MainWindow + + target = tmp_path / "doku.txt" + target.write_text("x", encoding="utf-8") + index = FileIndex(str(tmp_path / "index.db")) + + win = MainWindow() + win.preview_panel.metadata_panel.set_file_index(index) + win.toggle_preview.setChecked(False) + win._toggle_preview() + assert win.preview_panel.isHidden() + + actions = _context_actions(win.file_browser, target, monkeypatch) + actions["📊 Metadaten anzeigen"].trigger() + assert not win.preview_panel.isHidden() + panel = win.preview_panel.metadata_panel + assert panel.name_label.text() == "doku.txt" + + actions["🏷️ Tags bearbeiten"].trigger() + panel.tags_edit.setText("wichtig, projekt") + panel.notes_edit.setPlainText("Notiz") + panel.tags_edit.editingFinished.emit() + assert index.get_tags(str(target)) == ["projekt", "wichtig"] + assert index.get_note(str(target)) == "Notiz" + + # Neu laden zeigt die gespeicherten Werte wieder an + panel.clear_metadata() + panel.show_metadata(str(target)) + assert panel.tags_edit.text() == "projekt, wichtig" + assert panel.notes_edit.toPlainText() == "Notiz" + win.deleteLater() + + +def test_reindex_keeps_tags(tmp_path): + target = tmp_path / "a.txt" + target.write_text("inhalt", encoding="utf-8") + index = FileIndex(str(tmp_path / "index.db")) + index.set_tags(str(target), ["rot"]) + assert index.index_file(str(target), calculate_hash=False) + assert index.get_tags(str(target)) == ["rot"] + assert [r["path"] for r in index.advanced_search(tags=["rot"])] == [str(target)] + + +def test_quick_editor_not_modified_after_open(tmp_path): + app = _ensure_app() + from modules.editor.quick_editor import QuickEditorDialog + + target = tmp_path / "script.py" + target.write_text("def f():\n return 1\n", encoding="utf-8") + editor = QuickEditorDialog(str(target)) + for _ in range(5): # verzögertes Highlighting abarbeiten lassen + app.processEvents() + assert editor._modified is False + assert editor.modified_label.text() == "" + + editor.editor.insertPlainText("# x\n") + assert editor._modified is True + editor.editor.document().undo() + assert editor._modified is False + editor.deleteLater() + + +def test_qt_standard_buttons_are_german(): + app = _ensure_app() + from main import install_qt_translations + + translator = install_qt_translations(app, "de") + try: + assert translator is not None + box = QMessageBox( + QMessageBox.Icon.Question, "t", "t", + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + ) + assert box.button(QMessageBox.StandardButton.Save).text() == "Speichern" + assert box.button(QMessageBox.StandardButton.Cancel).text() == "Abbrechen" + finally: + app.removeTranslator(translator) + + +def test_translator_importable_from_source_start(): + """main.py muss die Projektwurzel (translator.py) auf sys.path legen.""" + code = ( + "import runpy, sys; runpy.run_path(sys.argv[1], run_name='probe'); " + "import gui.batch_rename_dialog, gui.diff_dialog, gui.settings_dialog" + ) + env = dict(os.environ, QT_QPA_PLATFORM="offscreen", PYTHONPATH="") + result = subprocess.run( + [sys.executable, "-c", code, str(SRC_DIR / "main.py")], + cwd=str(ROOT.parent), env=env, capture_output=True, text=True, timeout=120, + ) + assert result.returncode == 0, result.stderr + + +def test_spec_bundles_translator_and_locales(): + spec = (ROOT / "ExplorerPro.spec").read_text(encoding="utf-8") + assert "pathex=[str(src_dir), str(project_root)]" in spec + assert "(str(project_root / 'locales'), 'locales')" in spec