"""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, QMessageBox ) from PyQt6.QtCore import ( Qt, QModelIndex, pyqtSignal, QAbstractTableModel, QMimeData, QUrl ) 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 class FileItem: """Repräsentiert einen Dateieintrag.""" def __init__(self, name: str, path: str, is_dir: bool = False, size: int = 0, modified: datetime = None): self.name = name self.path = path self.is_dir = is_dir self.size = size self.modified = modified or datetime.now() self.icon = get_file_icon(name, is_dir) class FileListModel(QAbstractTableModel): """Model für die Dateiliste.""" HEADERS = ['📄', 'Name', 'Größe', 'Geändert'] def __init__(self, parent=None): super().__init__(parent) self.items: list[FileItem] = [] self.current_path = "" self._hovered_row = -1 self._hover_color = QColor("#334155") # Default, wird vom Theme überschrieben def rowCount(self, parent=None): return len(self.items) def columnCount(self, parent=None): return len(self.HEADERS) def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole): if not index.isValid() or index.row() >= len(self.items): return None item = self.items[index.row()] col = index.column() if role == Qt.ItemDataRole.DisplayRole: if col == 0: return item.icon elif col == 1: return item.name elif col == 2: return "" if item.is_dir else format_file_size(item.size) elif col == 3: return item.modified.strftime("%d.%m.%Y %H:%M") elif role == Qt.ItemDataRole.UserRole: return item elif role == Qt.ItemDataRole.TextAlignmentRole: if col == 0: return Qt.AlignmentFlag.AlignCenter elif col == 2: return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter elif role == Qt.ItemDataRole.ToolTipRole: return item.path return None def set_hovered_row(self, row: int): """Setzt die aktuell gehoverte Zeile.""" if self._hovered_row != row: old_row = self._hovered_row self._hovered_row = row # Alte und neue Zeile aktualisieren if old_row >= 0 and old_row < len(self.items): self.dataChanged.emit( self.index(old_row, 0), self.index(old_row, self.columnCount() - 1) ) if row >= 0 and row < len(self.items): self.dataChanged.emit( self.index(row, 0), self.index(row, self.columnCount() - 1) ) def set_hover_color(self, color: QColor): """Setzt die Hover-Farbe.""" self._hover_color = color def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: return self.HEADERS[section] return None def set_path(self, path: str): """Lädt den Inhalt eines Ordners.""" self.beginResetModel() self.items.clear() self.current_path = path if not os.path.exists(path): self.endResetModel() return try: # Verzeichnis-Cache invalidieren try: fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) os.close(fd) except OSError: pass folders = [] files = [] for name in os.listdir(path): entry_path = os.path.join(path, name) try: stat = os.stat(entry_path) is_dir = os.path.isdir(entry_path) item = FileItem( name=name, path=entry_path, is_dir=is_dir, size=stat.st_size if not is_dir else 0, modified=datetime.fromtimestamp(stat.st_mtime) ) if is_dir: folders.append(item) else: files.append(item) except (PermissionError, OSError, FileNotFoundError): continue # Natürliche Sortierung folders.sort(key=lambda x: natural_sort_key(x.name)) files.sort(key=lambda x: natural_sort_key(x.name)) self.items = folders + files except PermissionError: pass self.endResetModel() def get_item(self, index: QModelIndex) -> FileItem: """Gibt das FileItem für einen Index zurück.""" if index.isValid() and index.row() < len(self.items): return self.items[index.row()] return None def refresh(self): """Aktualisiert die Dateiliste.""" if self.current_path: self.set_path(self.current_path) def remove_paths(self, paths: list[str]): """Entfernt Einträge mit den angegebenen Pfaden aus dem Model.""" paths_set = set(paths) # Finde Indizes der zu löschenden Items (von hinten nach vorne) indices_to_remove = [i for i, item in enumerate(self.items) if item.path in paths_set] indices_to_remove.sort(reverse=True) for idx in indices_to_remove: self.beginRemoveRows(QModelIndex(), idx, idx) del self.items[idx] self.endRemoveRows() class FileListWidget(QTableView): """Dateiliste mit Kontextmenü und Drag & Drop.""" file_selected = pyqtSignal(str, str) # path, name file_double_clicked = pyqtSignal(str) # path folder_entered = pyqtSignal(str) # path file_rename_requested = pyqtSignal(str) # path file_delete_requested = pyqtSignal(str) # path (einzelne Datei) files_delete_requested = pyqtSignal(object) # paths (mehrere Dateien) 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) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setShowGrid(False) self.setAlternatingRowColors(False) self.verticalHeader().setVisible(False) self.setWordWrap(False) self.setMouseTracking(True) # Spaltenbreiten header = self.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) self.setColumnWidth(0, 36) self.setColumnWidth(2, 80) self.setColumnWidth(3, 130) # Drag & Drop self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) # Signale self.clicked.connect(self._on_clicked) self.doubleClicked.connect(self._on_double_clicked) # Kontextmenü self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) def set_path(self, path: str): """Lädt einen Ordner.""" self.list_model.set_path(path) def _on_clicked(self, index: QModelIndex): """Behandelt Klicks auf Dateien.""" item = self.list_model.get_item(index) if item and not item.is_dir: self.file_selected.emit(item.path, item.name) def _on_double_clicked(self, index: QModelIndex): """Behandelt Doppelklicks.""" item = self.list_model.get_item(index) if item: if item.is_dir: self.folder_entered.emit(item.path) else: self.file_double_clicked.emit(item.path) def _show_context_menu(self, position): """Zeigt das Kontextmenü.""" indexes = self.selectedIndexes() # 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)] menu = QMenu(self) 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) menu.addSeparator() # Aktualisieren refresh_action = QAction("🔄 Aktualisieren", self) refresh_action.setShortcut("F5") refresh_action.triggered.connect(self.refresh_requested.emit) menu.addAction(refresh_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) menu.addSeparator() # Aktualisieren refresh_action = QAction("🔄 Aktualisieren", self) refresh_action.setShortcut("F5") refresh_action.triggered.connect(self.refresh_requested.emit) menu.addAction(refresh_action) def _open_external(self, path: str): """Öffnet eine Datei mit der Standard-Anwendung.""" try: subprocess.Popen(['xdg-open', path]) except Exception as 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.""" paths = [item.path for item in items] self.files_delete_requested.emit(paths) def get_selected_items(self) -> list[FileItem]: """Gibt die ausgewählten Elemente zurück.""" indexes = self.selectedIndexes() rows = set(idx.row() for idx in indexes) return [self.list_model.items[row] for row in rows if row < len(self.list_model.items)] def get_selected_path(self) -> str: """Gibt den Pfad des ersten ausgewählten Elements zurück.""" 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.clearSelection() self.list_model.refresh() def remove_paths(self, paths: list[str]): """Entfernt Einträge mit den angegebenen Pfaden aus der Liste.""" self.clearSelection() self.list_model.remove_paths(paths) # Drag & Drop def startDrag(self, supportedActions): """Startet einen Drag-Vorgang.""" items = self.get_selected_items() if not items: return drag = QDrag(self) mime_data = QMimeData() urls = [QUrl.fromLocalFile(item.path) for item in items] mime_data.setUrls(urls) drag.setMimeData(mime_data) drag.exec(Qt.DropAction.MoveAction) def dragEnterEvent(self, event): """Akzeptiert Drag-Events mit URLs.""" if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dragMoveEvent(self, event): """Markiert Zielordner beim Drag.""" if event.mimeData().hasUrls(): index = self.indexAt(event.position().toPoint()) if index.isValid(): item = self.list_model.get_item(index) if item and item.is_dir: event.acceptProposedAction() return event.acceptProposedAction() else: event.ignore() def dropEvent(self, event): """Verarbeitet Drop-Events.""" if not event.mimeData().hasUrls(): event.ignore() return urls = event.mimeData().urls() source_paths = [url.toLocalFile() for url in urls] # Zielordner ermitteln index = self.indexAt(event.position().toPoint()) if index.isValid(): item = self.list_model.get_item(index) if item and item.is_dir: target_folder = item.path else: target_folder = self.list_model.current_path else: target_folder = self.list_model.current_path if source_paths and target_folder: self.files_dropped.emit(source_paths, target_folder) event.acceptProposedAction() 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) return # Delete - Löschen if event.key() == Qt.Key.Key_Delete: items = self.get_selected_items() if items: self._delete_items(items) 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) 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) 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.""" index = self.indexAt(event.pos()) row = index.row() if index.isValid() else -1 self.list_model.set_hovered_row(row) super().mouseMoveEvent(event) def leaveEvent(self, event): """Entfernt den Hover-Effekt wenn die Maus das Widget verlässt.""" self.list_model.set_hovered_row(-1) super().leaveEvent(event) def set_hover_color(self, color: str): """Setzt die Hover-Farbe (für Theme-Unterstützung).""" self.list_model.set_hover_color(QColor(color))