From 1f43e234d3eca6a1013276182dd927bd28f491d7 Mon Sep 17 00:00:00 2001 From: data Date: Tue, 10 Feb 2026 09:42:52 +0100 Subject: [PATCH] =?UTF-8?q?v1.1.0:=20Vollst=C3=A4ndiges=20Rechtsklick-Kont?= =?UTF-8?q?extmen=C3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kopieren/Ausschneiden/Einfügen (Ctrl+C, X, V) - Neue Datei mit Vorlagen (Python, JS, HTML, CSS, JSON, MD, Shell) - Neuer Ordner erstellen - Archiv-Funktionen (ZIP, TAR, TAR.GZ, TAR.BZ2, TAR.XZ) - Entpacken von Archiven (+ 7z, RAR wenn installiert) - Eigenschaften-Dialog (Größe, Berechtigungen, Zeitstempel) - Terminal hier öffnen (F4) - Hover-Farben für Themes korrigiert - README.md Dokumentation hinzugefügt Co-Authored-By: Claude Opus 4.5 --- README.md | 92 +++++++ filebrowser.desktop | 1 + main.py | 2 + src/dialogs/__init__.py | 8 +- src/dialogs/base.py | 522 ++++++++++++++++++++++++++++++++++++++- src/main_window.py | 300 ++++++++++++++++++++-- src/utils/archive.py | 260 +++++++++++++++++++ src/utils/clipboard.py | 136 ++++++++++ src/utils/themes.py | 4 +- src/widgets/file_list.py | 371 ++++++++++++++++++++++++---- 10 files changed, 1625 insertions(+), 71 deletions(-) create mode 100644 README.md create mode 100644 src/utils/archive.py create mode 100644 src/utils/clipboard.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7ae0cd --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# FileBrowser + +Ein moderner Dateimanager mit Vorschau-Funktion, geschrieben in Python mit PyQt6. + +## Version + +**v1.1.0** - Vollständiges Rechtsklick-Kontextmenü + +## Features + +### Dateioperationen +- **Kopieren/Ausschneiden/Einfügen** (Ctrl+C, Ctrl+X, Ctrl+V) +- **Neue Datei erstellen** (Ctrl+N) - mit Vorlagen für Python, JS, HTML, CSS, JSON, Markdown, Shell +- **Neuer Ordner** (Ctrl+Shift+N) +- **Umbenennen** (F2) +- **Löschen** (Delete) +- **Verschieben/Kopieren nach...** +- **Drag & Drop** Unterstützung + +### Archiv-Funktionen +- **Packen:** ZIP, TAR, TAR.GZ, TAR.BZ2, TAR.XZ +- **Entpacken:** ZIP, TAR, TAR.GZ, TAR.BZ2, TAR.XZ (+ 7z, RAR wenn installiert) + +### Vorschau +- PDF-Dokumente (mit Zoom und Mehrseiten-Ansicht) +- Bilder (PNG, JPG, GIF, etc.) +- Markdown (gerendert) +- Text und Code-Dateien +- Vorschau im Panel oder abgetrenntem Fenster + +### Weitere Funktionen +- **Terminal öffnen** (F4) +- **Eigenschaften-Dialog** (Alt+Enter) - Dateigröße, Berechtigungen, Zeitstempel +- **Mehrere Themes** - Dark, Breeze Dark, Breeze Light, System +- **Breadcrumb-Navigation** mit editierbarem Pfad + +## Tastenkürzel + +| Kürzel | Funktion | +|--------|----------| +| Ctrl+N | Neue Datei | +| Ctrl+Shift+N | Neuer Ordner | +| Ctrl+C | Kopieren | +| Ctrl+X | Ausschneiden | +| Ctrl+V | Einfügen | +| Ctrl+A | Alles auswählen | +| F2 | Umbenennen | +| F4 | Terminal öffnen | +| F5 | Aktualisieren | +| Delete | Löschen | +| Alt+Enter | Eigenschaften | +| Backspace | Ordner nach oben | + +## Installation + +```bash +# Abhängigkeiten installieren +pip install -r requirements.txt + +# Starten +python main.py +``` + +## Abhängigkeiten + +- Python 3.10+ +- PyQt6 +- PyMuPDF (fitz) - für PDF-Vorschau +- Markdown - für Markdown-Rendering + +## Rechtsklick-Kontextmenü + +### Bei Datei/Ordner-Auswahl: +- Öffnen / Öffnen mit... +- Neu → Neue Datei / Neuer Ordner +- Ausschneiden / Kopieren +- Umbenennen +- Verschieben nach... / Kopieren nach... +- Zu Archiv packen... (oder Entpacken bei Archiven) +- Löschen +- Eigenschaften + +### Bei leerem Bereich: +- Neu → Neue Datei / Neuer Ordner +- Einfügen +- Aktualisieren +- Terminal hier öffnen +- Eigenschaften + +## Lizenz + +MIT License diff --git a/filebrowser.desktop b/filebrowser.desktop index 9e81f66..492bda4 100644 --- a/filebrowser.desktop +++ b/filebrowser.desktop @@ -1,3 +1,4 @@ +#!/usr/bin/env xdg-open [Desktop Entry] Name=FileBrowser Comment=Dateimanager mit Vorschau-Funktion diff --git a/main.py b/main.py index 1b40e2d..c53b5bd 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """FileBrowser - Ein Dateimanager mit Vorschau-Funktion in PyQt6.""" +__version__ = "1.1.0" + import sys import os from PyQt6.QtWidgets import QApplication diff --git a/src/dialogs/__init__.py b/src/dialogs/__init__.py index 89fb131..2c09b0d 100644 --- a/src/dialogs/__init__.py +++ b/src/dialogs/__init__.py @@ -1,7 +1,11 @@ """Dialoge für den FileBrowser.""" -from .base import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog +from .base import ( + RenameDialog, MoveDialog, DeleteDialog, SettingsDialog, + NewFileDialog, PropertiesDialog, ArchiveDialog, ExtractDialog +) __all__ = [ - 'RenameDialog', 'MoveDialog', 'DeleteDialog', 'SettingsDialog' + 'RenameDialog', 'MoveDialog', 'DeleteDialog', 'SettingsDialog', + 'NewFileDialog', 'PropertiesDialog', 'ArchiveDialog', 'ExtractDialog' ] diff --git a/src/dialogs/base.py b/src/dialogs/base.py index f8b6d3d..4421174 100644 --- a/src/dialogs/base.py +++ b/src/dialogs/base.py @@ -1,15 +1,21 @@ """Dialoge für Dateioperationen.""" import os +import stat from pathlib import Path +from datetime import datetime from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeView, QDialogButtonBox, QMessageBox, - QFrame, QCheckBox, QGroupBox, QTabWidget, QWidget + QFrame, QCheckBox, QGroupBox, QTabWidget, QWidget, + QFormLayout, QComboBox, QRadioButton, QButtonGroup, + QProgressBar, QListWidget, QListWidgetItem ) from PyQt6.QtCore import Qt, QDir, QSettings from PyQt6.QtGui import QFileSystemModel +from ..utils.file_utils import format_file_size + class RenameDialog(QDialog): """Dialog zum Umbenennen von Dateien.""" @@ -338,3 +344,517 @@ class SettingsDialog(QDialog): 'disable_preview_panel': self.disable_preview_check.isChecked(), 'path_text_default': self.path_text_default_check.isChecked(), } + + +class NewFileDialog(QDialog): + """Dialog zum Erstellen einer neuen Datei.""" + + def __init__(self, current_folder: str, parent=None): + super().__init__(parent) + self.current_folder = current_folder + self.file_name = "" + + self.setWindowTitle("Neue Datei") + self.setModal(True) + self.setMinimumWidth(400) + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Dateiname + layout.addWidget(QLabel("Dateiname:")) + + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("datei.txt") + self.name_input.returnPressed.connect(self.accept) + layout.addWidget(self.name_input) + + # Vorlagen + layout.addWidget(QLabel("Vorlage:")) + + self.template_combo = QComboBox() + self.template_combo.addItem("Leere Datei", "") + self.template_combo.addItem("Python-Datei (.py)", ".py") + self.template_combo.addItem("JavaScript (.js)", ".js") + self.template_combo.addItem("HTML-Datei (.html)", ".html") + self.template_combo.addItem("CSS-Datei (.css)", ".css") + self.template_combo.addItem("JSON-Datei (.json)", ".json") + self.template_combo.addItem("Markdown (.md)", ".md") + self.template_combo.addItem("Text-Datei (.txt)", ".txt") + self.template_combo.addItem("Shell-Script (.sh)", ".sh") + self.template_combo.currentIndexChanged.connect(self._on_template_changed) + layout.addWidget(self.template_combo) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _on_template_changed(self, index): + """Aktualisiert die Erweiterung basierend auf der Vorlage.""" + extension = self.template_combo.currentData() + if extension: + current_name = self.name_input.text() + if current_name: + # Erweiterung ersetzen + base = Path(current_name).stem + self.name_input.setText(base + extension) + else: + self.name_input.setText("datei" + extension) + + def accept(self): + """Validiert und erstellt die Datei.""" + self.file_name = self.name_input.text().strip() + + if not self.file_name: + QMessageBox.warning(self, "Fehler", "Dateiname darf nicht leer sein.") + return + + if '/' in self.file_name or '\\' in self.file_name: + QMessageBox.warning(self, "Fehler", "Dateiname darf keine Pfadtrennzeichen enthalten.") + return + + file_path = os.path.join(self.current_folder, self.file_name) + if os.path.exists(file_path): + QMessageBox.warning(self, "Fehler", "Eine Datei mit diesem Namen existiert bereits.") + return + + super().accept() + + def get_file_name(self) -> str: + """Gibt den Dateinamen zurück.""" + return self.file_name + + def get_template_content(self) -> str: + """Gibt den Template-Inhalt zurück.""" + extension = self.template_combo.currentData() + templates = { + ".py": '#!/usr/bin/env python3\n"""Beschreibung."""\n\n\ndef main():\n pass\n\n\nif __name__ == "__main__":\n main()\n', + ".js": '// Beschreibung\n\n"use strict";\n\n', + ".html": '\n\n\n \n \n Titel\n\n\n \n\n\n', + ".css": '/* Stylesheet */\n\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n', + ".json": '{\n \n}\n', + ".md": '# Titel\n\n', + ".sh": '#!/bin/bash\n\n', + } + return templates.get(extension, "") + + +class PropertiesDialog(QDialog): + """Dialog für Datei-/Ordner-Eigenschaften.""" + + def __init__(self, paths: list[str], parent=None): + super().__init__(parent) + self.paths = paths + + self.setWindowTitle("Eigenschaften") + self.setModal(True) + self.setMinimumWidth(400) + + self._setup_ui() + self._load_properties() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Allgemeine Informationen + general_group = QGroupBox("Allgemein") + general_layout = QFormLayout(general_group) + + self.name_label = QLabel() + self.name_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + general_layout.addRow("Name:", self.name_label) + + self.type_label = QLabel() + general_layout.addRow("Typ:", self.type_label) + + self.location_label = QLabel() + self.location_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.location_label.setWordWrap(True) + general_layout.addRow("Ort:", self.location_label) + + self.size_label = QLabel() + general_layout.addRow("Größe:", self.size_label) + + self.contains_label = QLabel() + general_layout.addRow("Enthält:", self.contains_label) + + layout.addWidget(general_group) + + # Zeitstempel + time_group = QGroupBox("Zeitstempel") + time_layout = QFormLayout(time_group) + + self.created_label = QLabel() + time_layout.addRow("Erstellt:", self.created_label) + + self.modified_label = QLabel() + time_layout.addRow("Geändert:", self.modified_label) + + self.accessed_label = QLabel() + time_layout.addRow("Zugegriffen:", self.accessed_label) + + layout.addWidget(time_group) + + # Berechtigungen + perm_group = QGroupBox("Berechtigungen") + perm_layout = QFormLayout(perm_group) + + self.permissions_label = QLabel() + perm_layout.addRow("Rechte:", self.permissions_label) + + self.owner_label = QLabel() + perm_layout.addRow("Besitzer:", self.owner_label) + + layout.addWidget(perm_group) + + # Buttons + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + button_box.accepted.connect(self.accept) + layout.addWidget(button_box) + + def _load_properties(self): + """Lädt die Eigenschaften der Datei(en).""" + if len(self.paths) == 1: + self._load_single_properties(self.paths[0]) + else: + self._load_multiple_properties() + + def _load_single_properties(self, path: str): + """Lädt Eigenschaften einer einzelnen Datei/Ordner.""" + if not os.path.exists(path): + self.name_label.setText("Nicht gefunden") + return + + stat_info = os.stat(path) + name = os.path.basename(path) + + self.name_label.setText(name) + self.location_label.setText(os.path.dirname(path)) + + if os.path.isdir(path): + self.type_label.setText("Ordner") + # Ordnerinhalt zählen + try: + items = list(os.scandir(path)) + folders = sum(1 for i in items if i.is_dir()) + files = sum(1 for i in items if i.is_file()) + self.contains_label.setText(f"{folders} Ordner, {files} Dateien") + except PermissionError: + self.contains_label.setText("Zugriff verweigert") + + # Ordnergröße berechnen + total_size = self._get_folder_size(path) + self.size_label.setText(format_file_size(total_size)) + else: + # Dateityp aus Erweiterung + ext = Path(name).suffix.lower() + type_names = { + '.txt': 'Textdatei', '.py': 'Python-Datei', '.js': 'JavaScript', + '.html': 'HTML-Datei', '.css': 'CSS-Datei', '.json': 'JSON-Datei', + '.md': 'Markdown-Datei', '.pdf': 'PDF-Dokument', '.jpg': 'JPEG-Bild', + '.png': 'PNG-Bild', '.gif': 'GIF-Bild', '.mp3': 'MP3-Audio', + '.mp4': 'MP4-Video', '.zip': 'ZIP-Archiv', '.tar': 'TAR-Archiv', + } + self.type_label.setText(type_names.get(ext, f"Datei ({ext})" if ext else "Datei")) + self.size_label.setText(format_file_size(stat_info.st_size)) + self.contains_label.setText("-") + + # Zeitstempel + self.created_label.setText( + datetime.fromtimestamp(stat_info.st_ctime).strftime("%d.%m.%Y %H:%M:%S") + ) + self.modified_label.setText( + datetime.fromtimestamp(stat_info.st_mtime).strftime("%d.%m.%Y %H:%M:%S") + ) + self.accessed_label.setText( + datetime.fromtimestamp(stat_info.st_atime).strftime("%d.%m.%Y %H:%M:%S") + ) + + # Berechtigungen + mode = stat_info.st_mode + perms = "" + perms += "r" if mode & stat.S_IRUSR else "-" + perms += "w" if mode & stat.S_IWUSR else "-" + perms += "x" if mode & stat.S_IXUSR else "-" + perms += "r" if mode & stat.S_IRGRP else "-" + perms += "w" if mode & stat.S_IWGRP else "-" + perms += "x" if mode & stat.S_IXGRP else "-" + perms += "r" if mode & stat.S_IROTH else "-" + perms += "w" if mode & stat.S_IWOTH else "-" + perms += "x" if mode & stat.S_IXOTH else "-" + self.permissions_label.setText(perms) + + # Besitzer + try: + import pwd + import grp + owner = pwd.getpwuid(stat_info.st_uid).pw_name + group = grp.getgrgid(stat_info.st_gid).gr_name + self.owner_label.setText(f"{owner}:{group}") + except (KeyError, ImportError): + self.owner_label.setText(f"UID {stat_info.st_uid}") + + def _load_multiple_properties(self): + """Lädt Eigenschaften mehrerer Dateien.""" + self.name_label.setText(f"{len(self.paths)} Elemente ausgewählt") + self.location_label.setText(os.path.dirname(self.paths[0])) + + # Typen zählen + folders = sum(1 for p in self.paths if os.path.isdir(p)) + files = len(self.paths) - folders + if folders and files: + self.type_label.setText(f"{folders} Ordner, {files} Dateien") + elif folders: + self.type_label.setText(f"{folders} Ordner") + else: + self.type_label.setText(f"{files} Dateien") + + # Gesamtgröße + total_size = 0 + for path in self.paths: + if os.path.isdir(path): + total_size += self._get_folder_size(path) + elif os.path.isfile(path): + total_size += os.path.getsize(path) + self.size_label.setText(format_file_size(total_size)) + + self.contains_label.setText("-") + self.created_label.setText("-") + self.modified_label.setText("-") + self.accessed_label.setText("-") + self.permissions_label.setText("-") + self.owner_label.setText("-") + + def _get_folder_size(self, path: str) -> int: + """Berechnet die Größe eines Ordners rekursiv.""" + total = 0 + try: + for entry in os.scandir(path): + try: + if entry.is_file(follow_symlinks=False): + total += entry.stat().st_size + elif entry.is_dir(follow_symlinks=False): + total += self._get_folder_size(entry.path) + except (PermissionError, OSError): + continue + except PermissionError: + pass + return total + + +class ArchiveDialog(QDialog): + """Dialog zum Erstellen eines Archivs.""" + + def __init__(self, source_paths: list[str], parent=None): + super().__init__(parent) + self.source_paths = source_paths + self.archive_name = "" + self.archive_format = ".zip" + + self.setWindowTitle("Archiv erstellen") + self.setModal(True) + self.setMinimumWidth(450) + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Quelldateien + source_group = QGroupBox(f"Zu packende Elemente ({len(self.source_paths)})") + source_layout = QVBoxLayout(source_group) + + source_list = QListWidget() + source_list.setMaximumHeight(100) + for path in self.source_paths[:10]: # Max 10 anzeigen + source_list.addItem(os.path.basename(path)) + if len(self.source_paths) > 10: + source_list.addItem(f"... und {len(self.source_paths) - 10} weitere") + source_layout.addWidget(source_list) + + layout.addWidget(source_group) + + # Archiv-Name + name_group = QGroupBox("Archiv") + name_layout = QFormLayout(name_group) + + self.name_input = QLineEdit() + # Standard-Name aus erstem Element + if len(self.source_paths) == 1: + base_name = Path(self.source_paths[0]).stem + else: + base_name = "archiv" + self.name_input.setText(base_name + ".zip") + name_layout.addRow("Dateiname:", self.name_input) + + layout.addWidget(name_group) + + # Format-Auswahl + format_group = QGroupBox("Format") + format_layout = QVBoxLayout(format_group) + + self.format_group = QButtonGroup(self) + + formats = [ + ("ZIP (.zip)", ".zip"), + ("TAR (.tar)", ".tar"), + ("TAR.GZ (.tar.gz)", ".tar.gz"), + ("TAR.BZ2 (.tar.bz2)", ".tar.bz2"), + ("TAR.XZ (.tar.xz)", ".tar.xz"), + ] + + for i, (label, ext) in enumerate(formats): + radio = QRadioButton(label) + radio.setProperty("extension", ext) + if i == 0: + radio.setChecked(True) + radio.toggled.connect(self._on_format_changed) + self.format_group.addButton(radio, i) + format_layout.addWidget(radio) + + layout.addWidget(format_group) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _on_format_changed(self): + """Aktualisiert die Dateiendung.""" + button = self.format_group.checkedButton() + if button: + new_ext = button.property("extension") + current_name = self.name_input.text() + # Alte Erweiterung entfernen + for ext in ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar', '.zip']: + if current_name.endswith(ext): + current_name = current_name[:-len(ext)] + break + self.name_input.setText(current_name + new_ext) + self.archive_format = new_ext + + def accept(self): + """Validiert die Eingabe.""" + self.archive_name = self.name_input.text().strip() + + if not self.archive_name: + QMessageBox.warning(self, "Fehler", "Archiv-Name darf nicht leer sein.") + return + + button = self.format_group.checkedButton() + if button: + self.archive_format = button.property("extension") + + super().accept() + + def get_archive_name(self) -> str: + """Gibt den Archiv-Namen zurück.""" + return self.archive_name + + def get_archive_format(self) -> str: + """Gibt das Archiv-Format zurück.""" + return self.archive_format + + +class ExtractDialog(QDialog): + """Dialog zum Entpacken eines Archivs.""" + + def __init__(self, archive_path: str, parent=None): + super().__init__(parent) + self.archive_path = archive_path + self.target_folder = os.path.dirname(archive_path) + self.create_subfolder = True + + self.setWindowTitle("Archiv entpacken") + self.setModal(True) + self.setMinimumSize(500, 400) + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Archiv-Info + info_label = QLabel(f"Archiv: {os.path.basename(self.archive_path)}") + info_label.setWordWrap(True) + layout.addWidget(info_label) + + # Zielordner + layout.addWidget(QLabel("Entpacken nach:")) + + self.model = QFileSystemModel() + self.model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + self.model.setRootPath('') + + self.tree = QTreeView() + self.tree.setModel(self.model) + self.tree.setHeaderHidden(True) + + for i in range(1, self.model.columnCount()): + self.tree.hideColumn(i) + + # Aktuellen Ordner expandieren + current_dir = os.path.dirname(self.archive_path) + index = self.model.index(current_dir) + if index.isValid(): + self.tree.setCurrentIndex(index) + self.tree.scrollTo(index) + + layout.addWidget(self.tree) + + self.path_label = QLabel(f"Ziel: {self.target_folder}") + self.path_label.setWordWrap(True) + layout.addWidget(self.path_label) + + self.tree.clicked.connect(self._on_selection_changed) + + # Optionen + self.subfolder_check = QCheckBox("Unterordner erstellen") + self.subfolder_check.setChecked(True) + self.subfolder_check.setToolTip( + "Erstellt einen Ordner mit dem Namen des Archivs und entpackt den Inhalt darin." + ) + layout.addWidget(self.subfolder_check) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _on_selection_changed(self, index): + """Aktualisiert den ausgewählten Pfad.""" + path = self.model.filePath(index) + self.path_label.setText(f"Ziel: {path}") + self.target_folder = path + + def accept(self): + """Validiert die Auswahl.""" + if not self.target_folder or not os.path.isdir(self.target_folder): + QMessageBox.warning(self, "Fehler", "Bitte wähle einen gültigen Zielordner.") + return + + self.create_subfolder = self.subfolder_check.isChecked() + super().accept() + + def get_target_folder(self) -> str: + """Gibt den Zielordner zurück.""" + return self.target_folder + + def get_create_subfolder(self) -> bool: + """Gibt zurück ob ein Unterordner erstellt werden soll.""" + return self.create_subfolder diff --git a/src/main_window.py b/src/main_window.py index 70031fb..3c4a0d5 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -2,11 +2,12 @@ import os import shutil +import subprocess from pathlib import Path from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QMenuBar, QMenu, QToolBar, QStatusBar, QLabel, QComboBox, - QMessageBox, QFrame, QApplication + QMessageBox, QFrame, QApplication, QInputDialog ) from PyQt6.QtCore import Qt, QSettings, QTimer from PyQt6.QtGui import QAction, QKeySequence, QShortcut @@ -15,9 +16,14 @@ from .widgets.folder_tree import FolderTreeWidget from .widgets.file_list import FileListWidget from .widgets.preview_panel import PreviewPanel from .widgets.breadcrumb import BreadcrumbWidget -from .dialogs import RenameDialog, MoveDialog, DeleteDialog, SettingsDialog +from .dialogs import ( + RenameDialog, MoveDialog, DeleteDialog, SettingsDialog, + NewFileDialog, PropertiesDialog, ArchiveDialog, ExtractDialog +) from .preview_window import PreviewWindow from .utils.themes import ThemeManager +from .utils.clipboard import FileClipboard +from .utils.archive import ArchiveHandler class MainWindow(QMainWindow): @@ -29,6 +35,7 @@ class MainWindow(QMainWindow): self.theme_manager = ThemeManager() self.preview_window = None self._current_path = "" + self.clipboard = FileClipboard.instance() self.setWindowTitle("FileBrowser") self.setMinimumSize(800, 600) @@ -93,14 +100,19 @@ class MainWindow(QMainWindow): # Datei-Menü file_menu = menubar.addMenu("&Datei") - new_folder_action = QAction("Neuer Ordner", self) + new_file_action = QAction("📄 Neue Datei...", self) + new_file_action.setShortcut(QKeySequence("Ctrl+N")) + new_file_action.triggered.connect(self._create_new_file) + file_menu.addAction(new_file_action) + + new_folder_action = QAction("📁 Neuer Ordner...", self) new_folder_action.setShortcut(QKeySequence("Ctrl+Shift+N")) new_folder_action.triggered.connect(self._create_new_folder) file_menu.addAction(new_folder_action) file_menu.addSeparator() - settings_action = QAction("Einstellungen...", self) + settings_action = QAction("⚙️ Einstellungen...", self) settings_action.setShortcut(QKeySequence("Ctrl+,")) settings_action.triggered.connect(self._show_settings) file_menu.addAction(settings_action) @@ -115,27 +127,76 @@ class MainWindow(QMainWindow): # Bearbeiten-Menü edit_menu = menubar.addMenu("&Bearbeiten") - rename_action = QAction("Umbenennen", self) + cut_action = QAction("✂️ Ausschneiden", self) + cut_action.setShortcut(QKeySequence("Ctrl+X")) + cut_action.triggered.connect(self._cut_selected) + edit_menu.addAction(cut_action) + + copy_action = QAction("📋 Kopieren", self) + copy_action.setShortcut(QKeySequence("Ctrl+C")) + copy_action.triggered.connect(self._copy_selected) + edit_menu.addAction(copy_action) + + paste_action = QAction("📋 Einfügen", self) + paste_action.setShortcut(QKeySequence("Ctrl+V")) + paste_action.triggered.connect(self._paste_files) + edit_menu.addAction(paste_action) + + edit_menu.addSeparator() + + select_all_action = QAction("Alles auswählen", self) + select_all_action.setShortcut(QKeySequence("Ctrl+A")) + select_all_action.triggered.connect(self.file_list.selectAll) + edit_menu.addAction(select_all_action) + + edit_menu.addSeparator() + + rename_action = QAction("✏️ Umbenennen", self) rename_action.setShortcut(QKeySequence("F2")) rename_action.triggered.connect(self._rename_selected) edit_menu.addAction(rename_action) - move_action = QAction("Verschieben", self) + move_action = QAction("📦 Verschieben nach...", self) move_action.triggered.connect(self._move_selected) edit_menu.addAction(move_action) - delete_action = QAction("Löschen", self) + delete_action = QAction("🗑 Löschen", self) delete_action.setShortcut(QKeySequence.StandardKey.Delete) delete_action.triggered.connect(self._delete_selected) edit_menu.addAction(delete_action) edit_menu.addSeparator() - refresh_action = QAction("Aktualisieren", self) + refresh_action = QAction("🔄 Aktualisieren", self) refresh_action.setShortcut(QKeySequence("F5")) refresh_action.triggered.connect(self._refresh) edit_menu.addAction(refresh_action) + # Extras-Menü + extras_menu = menubar.addMenu("E&xtras") + + archive_action = QAction("📦 Zu Archiv packen...", self) + archive_action.triggered.connect(self._archive_selected) + extras_menu.addAction(archive_action) + + extract_action = QAction("📦 Archiv entpacken...", self) + extract_action.triggered.connect(self._extract_selected) + extras_menu.addAction(extract_action) + + extras_menu.addSeparator() + + terminal_action = QAction("💻 Terminal öffnen", self) + terminal_action.setShortcut(QKeySequence("F4")) + terminal_action.triggered.connect(lambda: self._open_terminal(self._current_path)) + extras_menu.addAction(terminal_action) + + extras_menu.addSeparator() + + properties_action = QAction("ℹ️ Eigenschaften", self) + properties_action.setShortcut(QKeySequence("Alt+Return")) + properties_action.triggered.connect(self._show_properties) + extras_menu.addAction(properties_action) + # Ansicht-Menü view_menu = menubar.addMenu("&Ansicht") @@ -246,6 +307,28 @@ class MainWindow(QMainWindow): toolbar.addSeparator() + # Neue Datei + new_file_action = QAction("📄", self) + new_file_action.setToolTip("Neue Datei (Ctrl+N)") + new_file_action.triggered.connect(self._create_new_file) + toolbar.addAction(new_file_action) + + # Neuer Ordner + new_folder_action = QAction("📁", self) + new_folder_action.setToolTip("Neuer Ordner (Ctrl+Shift+N)") + new_folder_action.triggered.connect(self._create_new_folder) + toolbar.addAction(new_folder_action) + + toolbar.addSeparator() + + # Terminal + terminal_action = QAction("💻", self) + terminal_action.setToolTip("Terminal öffnen (F4)") + terminal_action.triggered.connect(lambda: self._open_terminal(self._current_path)) + toolbar.addAction(terminal_action) + + toolbar.addSeparator() + # Theme-Auswahl toolbar.addWidget(QLabel("Theme: ")) self.theme_combo = QComboBox() @@ -262,21 +345,42 @@ class MainWindow(QMainWindow): self.path_label = QLabel() self.statusbar.addWidget(self.path_label, 1) + self.clipboard_label = QLabel() + self.statusbar.addPermanentWidget(self.clipboard_label) + self.count_label = QLabel() self.statusbar.addPermanentWidget(self.count_label) + # Clipboard-Status aktualisieren + self.clipboard.clipboard_changed.connect(self._update_clipboard_status) + + def _update_clipboard_status(self): + """Aktualisiert die Zwischenablage-Anzeige.""" + if self.clipboard.has_files(): + count = self.clipboard.get_file_count() + mode = "Kopiert" if self.clipboard.get_mode().value == "copy" else "Ausgeschnitten" + self.clipboard_label.setText(f"📋 {mode}: {count}") + else: + self.clipboard_label.setText("") + def _setup_shortcuts(self): """Richtet Tastenkürzel ein.""" # Backspace für zurück QShortcut(QKeySequence("Backspace"), self, self._go_back) + # F4 für Terminal + QShortcut(QKeySequence("F4"), self, lambda: self._open_terminal(self._current_path)) + + # Alt+Enter für Eigenschaften + QShortcut(QKeySequence("Alt+Return"), self, self._show_properties) + def _connect_signals(self): """Verbindet alle Signale.""" # Ordnerbaum self.folder_tree.folder_selected.connect(self._navigate_to) self.folder_tree.folder_double_clicked.connect(self._navigate_to) - # Dateiliste + # Dateiliste - Basis-Signale self.file_list.file_selected.connect(self._on_file_selected) self.file_list.file_double_clicked.connect(self._open_external) self.file_list.folder_entered.connect(self._navigate_to) @@ -285,6 +389,18 @@ class MainWindow(QMainWindow): self.file_list.file_move_requested.connect(self._move_file) self.file_list.files_dropped.connect(self._handle_files_dropped) + # Dateiliste - Erweiterte Signale + self.file_list.new_file_requested.connect(self._create_new_file) + self.file_list.new_folder_requested.connect(self._create_new_folder) + self.file_list.copy_requested.connect(self._copy_files) + self.file_list.cut_requested.connect(self._cut_files) + self.file_list.paste_requested.connect(self._paste_files) + self.file_list.archive_requested.connect(self._create_archive) + self.file_list.extract_requested.connect(self._extract_archive) + self.file_list.properties_requested.connect(self._show_properties_for) + self.file_list.open_terminal_requested.connect(self._open_terminal) + self.file_list.refresh_requested.connect(self._refresh) + # Breadcrumb self.breadcrumb.path_clicked.connect(self._navigate_to) self.breadcrumb.path_entered.connect(self._navigate_to) @@ -340,15 +456,8 @@ class MainWindow(QMainWindow): stylesheet = self.theme_manager.apply_theme(theme_name) self.setStyleSheet(stylesheet) - # Hover-Farbe für Dateiliste setzen - theme = self.theme_manager.THEMES.get(theme_name, self.theme_manager.THEMES['dark']) - if theme.get('is_system'): - # System-Theme: Standard-Hover-Farbe - self.file_list.set_hover_color('#e0e0e0') - else: - self.file_list.set_hover_color(theme['alternate_base']) - # Theme-Menü-Aktionen aktualisieren + theme = self.theme_manager.THEMES.get(theme_name, self.theme_manager.THEMES['dark']) for action in self.theme_actions: action.setChecked(action.data() == theme) @@ -418,7 +527,6 @@ class MainWindow(QMainWindow): def _open_external(self, path: str): """Öffnet eine Datei extern.""" - import subprocess try: subprocess.Popen(['xdg-open', path]) except Exception as e: @@ -442,6 +550,49 @@ class MainWindow(QMainWindow): self._update_count() self.statusbar.showMessage("Aktualisiert", 2000) + # ==================== Kopieren/Ausschneiden/Einfügen ==================== + + def _copy_selected(self): + """Kopiert die ausgewählten Elemente.""" + paths = self.file_list.get_selected_paths() + if paths: + self._copy_files(paths) + + def _cut_selected(self): + """Schneidet die ausgewählten Elemente aus.""" + paths = self.file_list.get_selected_paths() + if paths: + self._cut_files(paths) + + def _copy_files(self, paths: list): + """Kopiert Dateien in die Zwischenablage.""" + self.clipboard.copy(paths) + self.statusbar.showMessage(f"{len(paths)} Element(e) kopiert", 2000) + + def _cut_files(self, paths: list): + """Schneidet Dateien aus.""" + self.clipboard.cut(paths) + self.statusbar.showMessage(f"{len(paths)} Element(e) ausgeschnitten", 2000) + + def _paste_files(self): + """Fügt Dateien aus der Zwischenablage ein.""" + if not self.clipboard.has_files(): + return + + count, errors = self.clipboard.paste(self._current_path) + self._refresh() + + if errors: + QMessageBox.warning( + self, "Fehler beim Einfügen", + "Einige Dateien konnten nicht eingefügt werden:\n\n" + + "\n".join(errors[:10]) + ) + elif count > 0: + self.statusbar.showMessage(f"{count} Element(e) eingefügt", 3000) + + # ==================== Umbenennen/Verschieben/Löschen ==================== + def _rename_selected(self): """Benennt das ausgewählte Element um.""" path = self.file_list.get_selected_path() @@ -548,10 +699,26 @@ class MainWindow(QMainWindow): 3000 ) + # ==================== Neue Dateien/Ordner ==================== + + def _create_new_file(self): + """Erstellt eine neue Datei.""" + dialog = NewFileDialog(self._current_path, self) + if dialog.exec(): + file_name = dialog.get_file_name() + file_path = os.path.join(self._current_path, file_name) + content = dialog.get_template_content() + + try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + self._refresh() + self.statusbar.showMessage(f"Datei erstellt: {file_name}", 3000) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen: {e}") + def _create_new_folder(self): """Erstellt einen neuen Ordner.""" - from PyQt6.QtWidgets import QInputDialog - name, ok = QInputDialog.getText( self, "Neuer Ordner", "Ordnername:" ) @@ -564,6 +731,91 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen: {e}") + # ==================== Archiv-Funktionen ==================== + + def _archive_selected(self): + """Packt die ausgewählten Elemente.""" + paths = self.file_list.get_selected_paths() + if paths: + self._create_archive(paths) + + def _extract_selected(self): + """Entpackt das ausgewählte Archiv.""" + path = self.file_list.get_selected_path() + if path and ArchiveHandler.is_archive(path): + self._extract_archive(path) + + def _create_archive(self, paths: list): + """Erstellt ein Archiv.""" + dialog = ArchiveDialog(paths, self) + if dialog.exec(): + archive_name = dialog.get_archive_name() + archive_format = dialog.get_archive_format() + archive_path = os.path.join(self._current_path, archive_name) + + success, message = ArchiveHandler.create(paths, archive_path, archive_format) + self._refresh() + + if success: + self.statusbar.showMessage(f"Archiv erstellt: {archive_name}", 3000) + else: + QMessageBox.critical(self, "Fehler", message) + + def _extract_archive(self, path: str): + """Entpackt ein Archiv.""" + dialog = ExtractDialog(path, self) + if dialog.exec(): + target_folder = dialog.get_target_folder() + create_subfolder = dialog.get_create_subfolder() + + success, message = ArchiveHandler.extract(path, target_folder, create_subfolder) + self._refresh() + + if success: + self.statusbar.showMessage("Archiv entpackt", 3000) + else: + QMessageBox.critical(self, "Fehler", message) + + # ==================== Terminal/Eigenschaften ==================== + + def _open_terminal(self, path: str): + """Öffnet ein Terminal im angegebenen Ordner.""" + terminals = [ + ['konsole', '--workdir', path], + ['gnome-terminal', '--working-directory', path], + ['xfce4-terminal', '--working-directory', path], + ['alacritty', '--working-directory', path], + ['kitty', '--directory', path], + ['xterm', '-e', f'cd "{path}" && $SHELL'], + ] + + for terminal in terminals: + try: + subprocess.Popen(terminal) + return + except FileNotFoundError: + continue + + QMessageBox.warning( + self, "Terminal nicht gefunden", + "Es konnte kein Terminal-Emulator gefunden werden." + ) + + def _show_properties(self): + """Zeigt Eigenschaften für die ausgewählten Elemente.""" + paths = self.file_list.get_selected_paths() + if paths: + self._show_properties_for(paths) + else: + self._show_properties_for([self._current_path]) + + def _show_properties_for(self, paths: list): + """Zeigt den Eigenschaften-Dialog.""" + dialog = PropertiesDialog(paths, self) + dialog.exec() + + # ==================== Preview ==================== + def _detach_preview(self): """Öffnet die Vorschau in einem separaten Fenster.""" if not self.preview_window: @@ -616,6 +868,8 @@ class MainWindow(QMainWindow): for action in self.pdf_page_actions: action.setChecked(action.data() == settings['page_mode']) + # ==================== Einstellungen ==================== + def _show_settings(self): """Zeigt den Einstellungsdialog.""" dialog = SettingsDialog(self) @@ -651,6 +905,12 @@ class MainWindow(QMainWindow): "Über FileBrowser", "FileBrowser\n\n" "Ein einfacher Dateimanager mit Vorschau-Funktion.\n\n" + "Funktionen:\n" + "• Datei-Vorschau (PDF, Bilder, Markdown, Text)\n" + "• Kopieren, Ausschneiden, Einfügen\n" + "• Archive packen und entpacken\n" + "• Drag & Drop\n" + "• Mehrere Themes\n\n" "Erstellt mit PyQt6" ) diff --git a/src/utils/archive.py b/src/utils/archive.py new file mode 100644 index 0000000..9081de8 --- /dev/null +++ b/src/utils/archive.py @@ -0,0 +1,260 @@ +"""Archive-Handler für Packen und Entpacken.""" + +import os +import zipfile +import tarfile +import shutil +from pathlib import Path +from typing import Optional + + +class ArchiveHandler: + """Verarbeitet Archiv-Operationen.""" + + SUPPORTED_EXTENSIONS = { + '.zip': 'zip', + '.tar': 'tar', + '.tar.gz': 'tar.gz', + '.tgz': 'tar.gz', + '.tar.bz2': 'tar.bz2', + '.tbz2': 'tar.bz2', + '.tar.xz': 'tar.xz', + '.txz': 'tar.xz', + '.gz': 'gz', + '.bz2': 'bz2', + '.xz': 'xz', + '.7z': '7z', + '.rar': 'rar', + } + + ARCHIVE_FORMATS = [ + ('ZIP-Archiv', '.zip'), + ('TAR-Archiv', '.tar'), + ('TAR.GZ-Archiv', '.tar.gz'), + ('TAR.BZ2-Archiv', '.tar.bz2'), + ('TAR.XZ-Archiv', '.tar.xz'), + ] + + @classmethod + def is_archive(cls, path: str) -> bool: + """Prüft ob eine Datei ein Archiv ist.""" + lower_path = path.lower() + for ext in cls.SUPPORTED_EXTENSIONS: + if lower_path.endswith(ext): + return True + return False + + @classmethod + def get_archive_type(cls, path: str) -> Optional[str]: + """Gibt den Archiv-Typ zurück.""" + lower_path = path.lower() + # Längere Erweiterungen zuerst prüfen + for ext in sorted(cls.SUPPORTED_EXTENSIONS.keys(), key=len, reverse=True): + if lower_path.endswith(ext): + return cls.SUPPORTED_EXTENSIONS[ext] + return None + + @classmethod + def extract(cls, archive_path: str, target_folder: str, + create_subfolder: bool = True) -> tuple[bool, str]: + """ + Entpackt ein Archiv. + + Args: + archive_path: Pfad zum Archiv + target_folder: Zielordner + create_subfolder: Unterordner mit Archiv-Namen erstellen + + Returns: + Tuple (Erfolg, Nachricht/Fehlermeldung) + """ + if not os.path.exists(archive_path): + return False, "Archiv nicht gefunden" + + archive_type = cls.get_archive_type(archive_path) + if not archive_type: + return False, "Nicht unterstütztes Archiv-Format" + + # Zielordner erstellen + if create_subfolder: + # Archiv-Name ohne Erweiterung + base_name = Path(archive_path).stem + # Bei .tar.gz etc. den .tar auch entfernen + if base_name.endswith('.tar'): + base_name = base_name[:-4] + target_folder = os.path.join(target_folder, base_name) + + os.makedirs(target_folder, exist_ok=True) + + try: + if archive_type == 'zip': + with zipfile.ZipFile(archive_path, 'r') as zf: + zf.extractall(target_folder) + + elif archive_type.startswith('tar') or archive_type == 'tar': + mode = 'r' + if archive_type == 'tar.gz': + mode = 'r:gz' + elif archive_type == 'tar.bz2': + mode = 'r:bz2' + elif archive_type == 'tar.xz': + mode = 'r:xz' + + with tarfile.open(archive_path, mode) as tf: + tf.extractall(target_folder) + + elif archive_type in ('7z', 'rar'): + # Versuche externe Tools + return cls._extract_with_external_tool(archive_path, target_folder, archive_type) + + else: + return False, f"Entpacken von {archive_type} nicht unterstützt" + + return True, f"Erfolgreich entpackt nach: {target_folder}" + + except zipfile.BadZipFile: + return False, "Ungültige ZIP-Datei" + except tarfile.TarError as e: + return False, f"TAR-Fehler: {str(e)}" + except PermissionError: + return False, "Keine Berechtigung zum Entpacken" + except Exception as e: + return False, f"Fehler beim Entpacken: {str(e)}" + + @classmethod + def _extract_with_external_tool(cls, archive_path: str, target_folder: str, + archive_type: str) -> tuple[bool, str]: + """Entpackt mit externen Tools (7z, unrar).""" + import subprocess + + try: + if archive_type == '7z': + result = subprocess.run( + ['7z', 'x', archive_path, f'-o{target_folder}', '-y'], + capture_output=True, text=True + ) + elif archive_type == 'rar': + result = subprocess.run( + ['unrar', 'x', archive_path, target_folder], + capture_output=True, text=True + ) + else: + return False, f"Kein externes Tool für {archive_type}" + + if result.returncode == 0: + return True, f"Erfolgreich entpackt nach: {target_folder}" + else: + return False, result.stderr or "Unbekannter Fehler" + + except FileNotFoundError: + tool = '7z' if archive_type == '7z' else 'unrar' + return False, f"{tool} nicht installiert. Bitte installieren für {archive_type}-Unterstützung." + except Exception as e: + return False, str(e) + + @classmethod + def create(cls, source_paths: list[str], archive_path: str, + archive_format: str = '.zip') -> tuple[bool, str]: + """ + Erstellt ein Archiv. + + Args: + source_paths: Liste der zu packenden Dateien/Ordner + archive_path: Zielpfad für das Archiv + archive_format: Format (.zip, .tar, .tar.gz, .tar.bz2, .tar.xz) + + Returns: + Tuple (Erfolg, Nachricht/Fehlermeldung) + """ + if not source_paths: + return False, "Keine Dateien zum Packen angegeben" + + # Prüfen ob alle Quellen existieren + for path in source_paths: + if not os.path.exists(path): + return False, f"Nicht gefunden: {os.path.basename(path)}" + + try: + if archive_format == '.zip': + return cls._create_zip(source_paths, archive_path) + elif archive_format.startswith('.tar'): + return cls._create_tar(source_paths, archive_path, archive_format) + else: + return False, f"Nicht unterstütztes Format: {archive_format}" + + except PermissionError: + return False, "Keine Berechtigung zum Erstellen des Archivs" + except Exception as e: + return False, f"Fehler beim Erstellen: {str(e)}" + + @classmethod + def _create_zip(cls, source_paths: list[str], archive_path: str) -> tuple[bool, str]: + """Erstellt ein ZIP-Archiv.""" + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zf: + for source_path in source_paths: + if os.path.isfile(source_path): + zf.write(source_path, os.path.basename(source_path)) + elif os.path.isdir(source_path): + base_name = os.path.basename(source_path) + for root, dirs, files in os.walk(source_path): + # Relativer Pfad im Archiv + rel_root = os.path.relpath(root, os.path.dirname(source_path)) + for file in files: + file_path = os.path.join(root, file) + arc_name = os.path.join(rel_root, file) + zf.write(file_path, arc_name) + + return True, f"ZIP-Archiv erstellt: {archive_path}" + + @classmethod + def _create_tar(cls, source_paths: list[str], archive_path: str, + archive_format: str) -> tuple[bool, str]: + """Erstellt ein TAR-Archiv.""" + mode = 'w' + if archive_format == '.tar.gz': + mode = 'w:gz' + elif archive_format == '.tar.bz2': + mode = 'w:bz2' + elif archive_format == '.tar.xz': + mode = 'w:xz' + + with tarfile.open(archive_path, mode) as tf: + for source_path in source_paths: + tf.add(source_path, arcname=os.path.basename(source_path)) + + return True, f"TAR-Archiv erstellt: {archive_path}" + + @classmethod + def list_contents(cls, archive_path: str) -> tuple[bool, list[str]]: + """ + Listet den Inhalt eines Archivs auf. + + Returns: + Tuple (Erfolg, Liste der Dateien oder Fehlermeldung) + """ + archive_type = cls.get_archive_type(archive_path) + if not archive_type: + return False, ["Nicht unterstütztes Archiv-Format"] + + try: + if archive_type == 'zip': + with zipfile.ZipFile(archive_path, 'r') as zf: + return True, zf.namelist() + + elif archive_type.startswith('tar') or archive_type == 'tar': + mode = 'r' + if archive_type == 'tar.gz': + mode = 'r:gz' + elif archive_type == 'tar.bz2': + mode = 'r:bz2' + elif archive_type == 'tar.xz': + mode = 'r:xz' + + with tarfile.open(archive_path, mode) as tf: + return True, tf.getnames() + + else: + return False, [f"Auflisten für {archive_type} nicht unterstützt"] + + except Exception as e: + return False, [str(e)] diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py new file mode 100644 index 0000000..4a33bb2 --- /dev/null +++ b/src/utils/clipboard.py @@ -0,0 +1,136 @@ +"""Clipboard-Manager für Dateioperationen.""" + +import os +import shutil +from enum import Enum +from typing import Optional +from PyQt6.QtCore import QObject, pyqtSignal + + +class ClipboardMode(Enum): + """Modus für Zwischenablage-Operationen.""" + COPY = "copy" + CUT = "cut" + + +class FileClipboard(QObject): + """Singleton-Clipboard für Dateioperationen.""" + + _instance: Optional['FileClipboard'] = None + + # Signale + clipboard_changed = pyqtSignal() + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + super().__init__() + self._files: list[str] = [] + self._mode: ClipboardMode = ClipboardMode.COPY + self._initialized = True + + @classmethod + def instance(cls) -> 'FileClipboard': + """Gibt die Singleton-Instanz zurück.""" + if cls._instance is None: + cls._instance = FileClipboard() + return cls._instance + + def copy(self, paths: list[str]): + """Kopiert Dateien in die Zwischenablage.""" + self._files = [p for p in paths if os.path.exists(p)] + self._mode = ClipboardMode.COPY + self.clipboard_changed.emit() + + def cut(self, paths: list[str]): + """Schneidet Dateien aus (zum Verschieben).""" + self._files = [p for p in paths if os.path.exists(p)] + self._mode = ClipboardMode.CUT + self.clipboard_changed.emit() + + def paste(self, target_folder: str) -> tuple[int, list[str]]: + """ + Fügt Dateien im Zielordner ein. + + Returns: + Tuple (Anzahl erfolgreicher Operationen, Liste der Fehler) + """ + if not self._files or not os.path.isdir(target_folder): + return 0, ["Keine Dateien zum Einfügen oder ungültiger Zielordner"] + + success_count = 0 + errors = [] + + for source_path in self._files: + if not os.path.exists(source_path): + errors.append(f"{os.path.basename(source_path)}: Datei nicht gefunden") + continue + + name = os.path.basename(source_path) + target_path = os.path.join(target_folder, name) + + # Bei Namenskonflikt umbenennen + target_path = self._get_unique_path(target_path) + + try: + if self._mode == ClipboardMode.COPY: + if os.path.isdir(source_path): + shutil.copytree(source_path, target_path) + else: + shutil.copy2(source_path, target_path) + else: # CUT + shutil.move(source_path, target_path) + success_count += 1 + except PermissionError: + errors.append(f"{name}: Keine Berechtigung") + except shutil.Error as e: + errors.append(f"{name}: {str(e)}") + except Exception as e: + errors.append(f"{name}: {str(e)}") + + # Bei Ausschneiden die Zwischenablage leeren + if self._mode == ClipboardMode.CUT: + self.clear() + + return success_count, errors + + def _get_unique_path(self, path: str) -> str: + """Generiert einen eindeutigen Pfad bei Namenskonflikten.""" + if not os.path.exists(path): + return path + + base, ext = os.path.splitext(path) + counter = 1 + + while os.path.exists(path): + path = f"{base} ({counter}){ext}" + counter += 1 + + return path + + def clear(self): + """Leert die Zwischenablage.""" + self._files = [] + self.clipboard_changed.emit() + + def has_files(self) -> bool: + """Prüft ob Dateien in der Zwischenablage sind.""" + return len(self._files) > 0 + + def get_files(self) -> list[str]: + """Gibt die Dateien in der Zwischenablage zurück.""" + return self._files.copy() + + def get_mode(self) -> ClipboardMode: + """Gibt den aktuellen Modus zurück.""" + return self._mode + + def get_file_count(self) -> int: + """Gibt die Anzahl der Dateien zurück.""" + return len(self._files) diff --git a/src/utils/themes.py b/src/utils/themes.py index dd1f74f..621e7d5 100644 --- a/src/utils/themes.py +++ b/src/utils/themes.py @@ -140,11 +140,11 @@ class ThemeManager: color: {theme['highlight_text']}; }} - QTreeView::item:hover, QListView::item:hover {{ + QTreeView::item:hover, QListView::item:hover, QTableView::item:hover {{ background-color: {theme['alternate_base']}; }} - QTreeView::item:selected:hover, QListView::item:selected:hover {{ + QTreeView::item:selected:hover, QListView::item:selected:hover, QTableView::item:selected:hover {{ background-color: {theme['highlight']}; color: {theme['highlight_text']}; }} diff --git a/src/widgets/file_list.py b/src/widgets/file_list.py index 132f162..90949d7 100644 --- a/src/widgets/file_list.py +++ b/src/widgets/file_list.py @@ -1,10 +1,11 @@ """Dateiliste-Widget mit natürlicher Sortierung und Drag & Drop.""" import os +import subprocess from pathlib import Path from datetime import datetime from PyQt6.QtWidgets import ( - QTableView, QAbstractItemView, QMenu, QHeaderView + QTableView, QAbstractItemView, QMenu, QHeaderView, QMessageBox ) from PyQt6.QtCore import ( Qt, QModelIndex, pyqtSignal, QAbstractTableModel, QMimeData, @@ -13,6 +14,8 @@ from PyQt6.QtCore import ( from PyQt6.QtGui import QAction, QDrag, QColor, QBrush from ..utils.file_utils import get_file_icon, format_file_size, natural_sort_key +from ..utils.clipboard import FileClipboard +from ..utils.archive import ArchiveHandler @@ -73,9 +76,6 @@ class FileListModel(QAbstractTableModel): return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter elif role == Qt.ItemDataRole.ToolTipRole: return item.path - elif role == Qt.ItemDataRole.BackgroundRole: - if index.row() == self._hovered_row: - return QBrush(self._hover_color) return None @@ -171,11 +171,24 @@ class FileListWidget(QTableView): file_move_requested = pyqtSignal(str) # path files_dropped = pyqtSignal(list, str) # source_paths, target_folder + # Neue Signale für erweiterte Funktionen + new_file_requested = pyqtSignal() + new_folder_requested = pyqtSignal() + copy_requested = pyqtSignal(list) # paths + cut_requested = pyqtSignal(list) # paths + paste_requested = pyqtSignal() + archive_requested = pyqtSignal(list) # paths + extract_requested = pyqtSignal(str) # path + properties_requested = pyqtSignal(list) # paths + open_terminal_requested = pyqtSignal(str) # path + refresh_requested = pyqtSignal() + def __init__(self, parent=None): super().__init__(parent) self.list_model = FileListModel(self) self.setModel(self.list_model) + self.clipboard = FileClipboard.instance() # Einstellungen self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) @@ -232,58 +245,280 @@ class FileListWidget(QTableView): def _show_context_menu(self, position): """Zeigt das Kontextmenü.""" indexes = self.selectedIndexes() - if not indexes: - return # Eindeutige Zeilen ermitteln rows = set(idx.row() for idx in indexes) items = [self.list_model.items[row] for row in rows if row < len(self.list_model.items)] - if not items: - return - menu = QMenu(self) - if len(items) == 1: - item = items[0] - - # Öffnen - if item.is_dir: - open_action = QAction("📂 Öffnen", self) - open_action.triggered.connect(lambda: self.folder_entered.emit(item.path)) - else: - open_action = QAction("🔗 Öffnen", self) - open_action.triggered.connect(lambda: self._open_external(item.path)) - menu.addAction(open_action) - - menu.addSeparator() - - # Umbenennen - rename_action = QAction("✏️ Umbenennen (F2)", self) - rename_action.triggered.connect(lambda: self.file_rename_requested.emit(item.path)) - menu.addAction(rename_action) - - # Verschieben - move_action = QAction("📦 Verschieben", self) - move_action.triggered.connect(lambda: self.file_move_requested.emit(item.path)) - menu.addAction(move_action) - - menu.addSeparator() - - # Löschen (auch für mehrere) - delete_action = QAction(f"🗑 Löschen ({len(items)} Element{'e' if len(items) > 1 else ''})", self) - delete_action.triggered.connect(lambda: self._delete_items(items)) - menu.addAction(delete_action) + if not items: + # Kontextmenü für leeren Bereich + self._build_empty_context_menu(menu) + elif len(items) == 1: + # Kontextmenü für einzelnes Element + self._build_single_item_context_menu(menu, items[0]) + else: + # Kontextmenü für mehrere Elemente + self._build_multi_item_context_menu(menu, items) menu.exec(self.viewport().mapToGlobal(position)) + def _build_empty_context_menu(self, menu: QMenu): + """Erstellt Kontextmenü für leeren Bereich.""" + # Neu-Untermenü + new_menu = menu.addMenu("📄 Neu") + + new_file_action = QAction("📄 Neue Datei...", self) + new_file_action.setShortcut("Ctrl+N") + new_file_action.triggered.connect(self.new_file_requested.emit) + new_menu.addAction(new_file_action) + + new_folder_action = QAction("📁 Neuer Ordner...", self) + new_folder_action.setShortcut("Ctrl+Shift+N") + new_folder_action.triggered.connect(self.new_folder_requested.emit) + new_menu.addAction(new_folder_action) + + menu.addSeparator() + + # Einfügen + paste_action = QAction("📋 Einfügen", self) + paste_action.setShortcut("Ctrl+V") + paste_action.setEnabled(self.clipboard.has_files()) + if self.clipboard.has_files(): + count = self.clipboard.get_file_count() + paste_action.setText(f"📋 Einfügen ({count} Element{'e' if count > 1 else ''})") + paste_action.triggered.connect(self.paste_requested.emit) + menu.addAction(paste_action) + + menu.addSeparator() + + # Aktualisieren + refresh_action = QAction("🔄 Aktualisieren", self) + refresh_action.setShortcut("F5") + refresh_action.triggered.connect(self.refresh_requested.emit) + menu.addAction(refresh_action) + + menu.addSeparator() + + # Terminal öffnen + terminal_action = QAction("💻 Terminal hier öffnen", self) + terminal_action.triggered.connect( + lambda: self.open_terminal_requested.emit(self.list_model.current_path) + ) + menu.addAction(terminal_action) + + menu.addSeparator() + + # Eigenschaften des Ordners + props_action = QAction("ℹ️ Eigenschaften", self) + props_action.triggered.connect( + lambda: self.properties_requested.emit([self.list_model.current_path]) + ) + menu.addAction(props_action) + + def _build_single_item_context_menu(self, menu: QMenu, item: FileItem): + """Erstellt Kontextmenü für einzelnes Element.""" + # Öffnen + if item.is_dir: + open_action = QAction("📂 Öffnen", self) + open_action.triggered.connect(lambda: self.folder_entered.emit(item.path)) + menu.addAction(open_action) + + open_new_action = QAction("📂 In neuem Fenster öffnen", self) + open_new_action.triggered.connect(lambda: self._open_in_new_window(item.path)) + menu.addAction(open_new_action) + else: + open_action = QAction("🔗 Öffnen", self) + open_action.triggered.connect(lambda: self._open_external(item.path)) + menu.addAction(open_action) + + open_with_action = QAction("🔗 Öffnen mit...", self) + open_with_action.triggered.connect(lambda: self._open_with(item.path)) + menu.addAction(open_with_action) + + menu.addSeparator() + + # Neu-Untermenü (nur in Ordnern sinnvoll) + new_menu = menu.addMenu("📄 Neu") + + new_file_action = QAction("📄 Neue Datei...", self) + new_file_action.triggered.connect(self.new_file_requested.emit) + new_menu.addAction(new_file_action) + + new_folder_action = QAction("📁 Neuer Ordner...", self) + new_folder_action.triggered.connect(self.new_folder_requested.emit) + new_menu.addAction(new_folder_action) + + menu.addSeparator() + + # Ausschneiden, Kopieren + cut_action = QAction("✂️ Ausschneiden", self) + cut_action.setShortcut("Ctrl+X") + cut_action.triggered.connect(lambda: self.cut_requested.emit([item.path])) + menu.addAction(cut_action) + + copy_action = QAction("📋 Kopieren", self) + copy_action.setShortcut("Ctrl+C") + copy_action.triggered.connect(lambda: self.copy_requested.emit([item.path])) + menu.addAction(copy_action) + + # Einfügen (nur für Ordner) + if item.is_dir: + paste_action = QAction("📋 Einfügen", self) + paste_action.setShortcut("Ctrl+V") + paste_action.setEnabled(self.clipboard.has_files()) + paste_action.triggered.connect(self.paste_requested.emit) + menu.addAction(paste_action) + + menu.addSeparator() + + # Umbenennen + rename_action = QAction("✏️ Umbenennen", self) + rename_action.setShortcut("F2") + rename_action.triggered.connect(lambda: self.file_rename_requested.emit(item.path)) + menu.addAction(rename_action) + + # Verschieben + move_action = QAction("📦 Verschieben nach...", self) + move_action.triggered.connect(lambda: self.file_move_requested.emit(item.path)) + menu.addAction(move_action) + + # Kopieren nach... + copy_to_action = QAction("📋 Kopieren nach...", self) + copy_to_action.triggered.connect(lambda: self._copy_to(item.path)) + menu.addAction(copy_to_action) + + menu.addSeparator() + + # Archiv-Funktionen + if ArchiveHandler.is_archive(item.path): + # Entpacken + extract_action = QAction("📦 Hier entpacken", self) + extract_action.triggered.connect(lambda: self._extract_here(item.path)) + menu.addAction(extract_action) + + extract_to_action = QAction("📦 Entpacken nach...", self) + extract_to_action.triggered.connect(lambda: self.extract_requested.emit(item.path)) + menu.addAction(extract_to_action) + + menu.addSeparator() + else: + # Packen + archive_action = QAction("📦 Zu Archiv packen...", self) + archive_action.triggered.connect(lambda: self.archive_requested.emit([item.path])) + menu.addAction(archive_action) + + menu.addSeparator() + + # Löschen + delete_action = QAction("🗑 Löschen", self) + delete_action.setShortcut("Delete") + delete_action.triggered.connect(lambda: self._delete_items([item])) + menu.addAction(delete_action) + + menu.addSeparator() + + # Terminal öffnen (für Ordner) + if item.is_dir: + terminal_action = QAction("💻 Terminal hier öffnen", self) + terminal_action.triggered.connect( + lambda: self.open_terminal_requested.emit(item.path) + ) + menu.addAction(terminal_action) + menu.addSeparator() + + # Eigenschaften + props_action = QAction("ℹ️ Eigenschaften", self) + props_action.triggered.connect(lambda: self.properties_requested.emit([item.path])) + menu.addAction(props_action) + + def _build_multi_item_context_menu(self, menu: QMenu, items: list[FileItem]): + """Erstellt Kontextmenü für mehrere Elemente.""" + paths = [item.path for item in items] + + # Ausschneiden, Kopieren + cut_action = QAction(f"✂️ Ausschneiden ({len(items)} Elemente)", self) + cut_action.setShortcut("Ctrl+X") + cut_action.triggered.connect(lambda: self.cut_requested.emit(paths)) + menu.addAction(cut_action) + + copy_action = QAction(f"📋 Kopieren ({len(items)} Elemente)", self) + copy_action.setShortcut("Ctrl+C") + copy_action.triggered.connect(lambda: self.copy_requested.emit(paths)) + menu.addAction(copy_action) + + menu.addSeparator() + + # Archiv erstellen + archive_action = QAction(f"📦 Zu Archiv packen ({len(items)} Elemente)...", self) + archive_action.triggered.connect(lambda: self.archive_requested.emit(paths)) + menu.addAction(archive_action) + + menu.addSeparator() + + # Löschen + delete_action = QAction(f"🗑 Löschen ({len(items)} Elemente)", self) + delete_action.setShortcut("Delete") + delete_action.triggered.connect(lambda: self._delete_items(items)) + menu.addAction(delete_action) + + menu.addSeparator() + + # Eigenschaften + props_action = QAction(f"ℹ️ Eigenschaften ({len(items)} Elemente)", self) + props_action.triggered.connect(lambda: self.properties_requested.emit(paths)) + menu.addAction(props_action) + def _open_external(self, path: str): """Öffnet eine Datei mit der Standard-Anwendung.""" - import subprocess try: subprocess.Popen(['xdg-open', path]) except Exception as e: - print(f"Fehler beim Öffnen: {e}") + QMessageBox.critical(self, "Fehler", f"Konnte Datei nicht öffnen: {e}") + + def _open_with(self, path: str): + """Öffnet den 'Öffnen mit'-Dialog.""" + try: + # xdg-mime kann zum Öffnen mit Auswahl verwendet werden + subprocess.Popen(['xdg-open', '--', path]) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Konnte Dialog nicht öffnen: {e}") + + def _open_in_new_window(self, path: str): + """Öffnet einen Ordner in einem neuen Fenster.""" + try: + subprocess.Popen(['xdg-open', path]) + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Konnte Fenster nicht öffnen: {e}") + + def _copy_to(self, path: str): + """Kopiert Datei/Ordner an einen ausgewählten Ort.""" + from ..dialogs import MoveDialog + dialog = MoveDialog(path, self) + dialog.setWindowTitle("Kopieren nach") + if dialog.exec(): + import shutil + target = dialog.get_target_folder() + try: + name = os.path.basename(path) + new_path = os.path.join(target, name) + if os.path.isdir(path): + shutil.copytree(path, new_path) + else: + shutil.copy2(path, new_path) + self.refresh_requested.emit() + except Exception as e: + QMessageBox.critical(self, "Fehler", f"Kopieren fehlgeschlagen: {e}") + + def _extract_here(self, path: str): + """Entpackt ein Archiv im aktuellen Ordner.""" + target_folder = self.list_model.current_path + success, message = ArchiveHandler.extract(path, target_folder, create_subfolder=True) + if success: + self.refresh_requested.emit() + else: + QMessageBox.critical(self, "Fehler", message) def _delete_items(self, items: list): """Löscht mehrere Elemente.""" @@ -301,6 +536,10 @@ class FileListWidget(QTableView): items = self.get_selected_items() return items[0].path if items else "" + def get_selected_paths(self) -> list[str]: + """Gibt die Pfade aller ausgewählten Elemente zurück.""" + return [item.path for item in self.get_selected_items()] + def refresh(self): """Aktualisiert die Dateiliste.""" self.list_model.refresh() @@ -368,27 +607,67 @@ class FileListWidget(QTableView): def keyPressEvent(self, event): """Behandelt Tastatureingaben.""" + # Ctrl+C - Kopieren + if event.key() == Qt.Key.Key_C and event.modifiers() == Qt.KeyboardModifier.ControlModifier: + items = self.get_selected_items() + if items: + self.copy_requested.emit([item.path for item in items]) + return + + # Ctrl+X - Ausschneiden + if event.key() == Qt.Key.Key_X and event.modifiers() == Qt.KeyboardModifier.ControlModifier: + items = self.get_selected_items() + if items: + self.cut_requested.emit([item.path for item in items]) + return + + # Ctrl+V - Einfügen + if event.key() == Qt.Key.Key_V and event.modifiers() == Qt.KeyboardModifier.ControlModifier: + self.paste_requested.emit() + return + + # Ctrl+A - Alles auswählen + if event.key() == Qt.Key.Key_A and event.modifiers() == Qt.KeyboardModifier.ControlModifier: + self.selectAll() + return + + # F2 - Umbenennen if event.key() == Qt.Key.Key_F2: items = self.get_selected_items() if len(items) == 1: self.file_rename_requested.emit(items[0].path) - elif event.key() == Qt.Key.Key_Delete: + return + + # Delete - Löschen + if event.key() == Qt.Key.Key_Delete: items = self.get_selected_items() if items: self._delete_items(items) - elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + return + + # Enter - Öffnen + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: items = self.get_selected_items() if len(items) == 1: if items[0].is_dir: self.folder_entered.emit(items[0].path) else: self._open_external(items[0].path) - elif event.key() == Qt.Key.Key_Backspace: + return + + # Backspace - Zum übergeordneten Ordner + if event.key() == Qt.Key.Key_Backspace: parent = os.path.dirname(self.list_model.current_path) if parent and parent != self.list_model.current_path: self.folder_entered.emit(parent) - else: - super().keyPressEvent(event) + return + + # F5 - Aktualisieren + if event.key() == Qt.Key.Key_F5: + self.refresh_requested.emit() + return + + super().keyPressEvent(event) def mouseMoveEvent(self, event): """Verfolgt die Zeile unter dem Mauszeiger für Hover-Effekt."""