v1.1.0: Vollständiges Rechtsklick-Kontextmenü

- 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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-10 09:42:52 +01:00
parent a9761ec33b
commit 1f43e234d3
10 changed files with 1625 additions and 71 deletions

92
README.md Normal file
View file

@ -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

View file

@ -1,3 +1,4 @@
#!/usr/bin/env xdg-open
[Desktop Entry]
Name=FileBrowser
Comment=Dateimanager mit Vorschau-Funktion

View file

@ -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

View file

@ -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'
]

View file

@ -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": '<!DOCTYPE html>\n<html lang="de">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>Titel</title>\n</head>\n<body>\n \n</body>\n</html>\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

View file

@ -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"
)

260
src/utils/archive.py Normal file
View file

@ -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)]

136
src/utils/clipboard.py Normal file
View file

@ -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)

View file

@ -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']};
}}

View file

@ -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."""