qt.filebrowser/src/widgets/file_list.py
data 044e9a848d Fix: Gelöschte Dateien verschwinden sofort aus der Liste
- Gelöschte Einträge werden direkt aus dem Model entfernt statt Verzeichnis neu zu laden
- Verzeichnis-Cache wird vor dem Einlesen invalidiert (os.listdir statt os.scandir)
- Selektion wird beim Refresh/Löschen zurückgesetzt
- PDF Preview: Darkmode Toggle hinzugefügt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 17:12:24 +01:00

729 lines
26 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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