""" 文件浏览器组件 """ import os from typing import Optional, List from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTreeView, QTableView, QHeaderView, QMenu, QAction, QMessageBox, QApplication, QSplitter, QLabel, QPushButton, QProgressBar ) from PyQt5.QtCore import ( Qt, QSortFilterProxyModel, QModelIndex, QThread, pyqtSignal, QAbstractTableModel ) from PyQt5.QtGui import QFont, QColor, QIcon, QCursor from file_scanner import FileScanner, FileItem, ScanProgress from utils import format_size, safe_delete class FileTableModel(QAbstractTableModel): """文件表格数据模型""" HEADERS = ["名称", "大小", "修改时间", "权限", "所有者"] def __init__(self, parent=None): super().__init__(parent) self.files: List[FileItem] = [] self.current_path = "" def rowCount(self, parent=QModelIndex()): return len(self.files) def columnCount(self, parent=QModelIndex()): return len(self.HEADERS) def data(self, index, role=Qt.DisplayRole): if not index.isValid() or index.row() >= len(self.files): return None file_item = self.files[index.row()] column = index.column() if role == Qt.DisplayRole: if column == 0: return f"{file_item.icon} {file_item.name}" elif column == 1: return file_item.size_formatted elif column == 2: return file_item.modified_time_formatted elif column == 3: return file_item.permissions elif column == 4: return f"{file_item.owner}:{file_item.group}" elif role == Qt.UserRole: # 返回原始数据用于排序 if column == 0: return file_item.name elif column == 1: return file_item.size elif column == 2: return file_item.modified_time elif column == 3: return file_item.permissions elif column == 4: return file_item.owner elif role == Qt.TextAlignmentRole: if column == 1: return Qt.AlignRight | Qt.AlignVCenter elif role == Qt.ForegroundRole: if column == 1: if file_item.size > 1024 * 1024 * 1024: # > 1GB return QColor(255, 0, 0) elif file_item.size > 100 * 1024 * 1024: # > 100MB return QColor(255, 165, 0) return None def headerData(self, section, orientation, role=Qt.DisplayRole): if orientation == Qt.Horizontal and role == Qt.DisplayRole: return self.HEADERS[section] return None def set_files(self, files: List[FileItem]): """设置文件列表""" self.beginResetModel() self.files = files self.endResetModel() def get_file_at(self, row: int) -> Optional[FileItem]: """获取指定行的文件信息""" if 0 <= row < len(self.files): return self.files[row] return None class SortProxyModel(QSortFilterProxyModel): """排序代理模型""" def __init__(self, parent=None): super().__init__(parent) self.setSortRole(Qt.UserRole) def lessThan(self, left, right): """自定义排序逻辑""" left_data = self.sourceModel().data(left, Qt.UserRole) right_data = self.sourceModel().data(right, Qt.UserRole) if left_data is None: return True if right_data is None: return False # 目录始终排在前面 left_item = self.sourceModel().get_file_at(left.row()) right_item = self.sourceModel().get_file_at(right.row()) if left_item and right_item: if left_item.is_dir != right_item.is_dir: return left_item.is_dir try: return left_data < right_data except TypeError: return str(left_data) < str(right_data) class DirectoryLoadThread(QThread): """目录加载线程""" finished = pyqtSignal(list, str) error = pyqtSignal(str) def __init__(self, scanner: FileScanner, path: str): super().__init__() self.scanner = scanner self.path = path def run(self): try: files = self.scanner.list_directory(self.path) self.finished.emit(files, self.path) except Exception as e: self.error.emit(str(e)) class FileBrowser(QWidget): """文件浏览器组件""" # 信号 file_selected = pyqtSignal(FileItem) # 文件被选中 directory_changed = pyqtSignal(str) # 目录改变 files_deleted = pyqtSignal(int) # 文件被删除(释放的空间) def __init__(self, scanner: FileScanner, sudo_password: Optional[str] = None, parent=None): super().__init__(parent) self.scanner = scanner self.sudo_password = sudo_password self.current_path = "" self.path_history = [] self.history_index = -1 self.init_ui() def init_ui(self): """初始化界面""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(5) # 工具栏 toolbar_layout = QHBoxLayout() toolbar_layout.setSpacing(5) # 后退按钮 self.back_button = QPushButton("◀") self.back_button.setFixedSize(30, 30) self.back_button.setToolTip("后退") self.back_button.clicked.connect(self.go_back) self.back_button.setEnabled(False) toolbar_layout.addWidget(self.back_button) # 前进按钮 self.forward_button = QPushButton("▶") self.forward_button.setFixedSize(30, 30) self.forward_button.setToolTip("前进") self.forward_button.clicked.connect(self.go_forward) self.forward_button.setEnabled(False) toolbar_layout.addWidget(self.forward_button) # 上级目录按钮 self.up_button = QPushButton("⬆") self.up_button.setFixedSize(30, 30) self.up_button.setToolTip("上级目录") self.up_button.clicked.connect(self.go_up) toolbar_layout.addWidget(self.up_button) # 刷新按钮 self.refresh_button = QPushButton("🔄") self.refresh_button.setFixedSize(30, 30) self.refresh_button.setToolTip("刷新") self.refresh_button.clicked.connect(self.refresh) toolbar_layout.addWidget(self.refresh_button) # 当前路径显示 self.path_label = QLabel() self.path_label.setStyleSheet( "QLabel { background-color: #f0f0f0; padding: 5px; border: 1px solid #ccc; }" ) toolbar_layout.addWidget(self.path_label, 1) # 加载状态 self.loading_label = QLabel("⏳") self.loading_label.setFixedSize(30, 30) self.loading_label.setAlignment(Qt.AlignCenter) self.loading_label.hide() toolbar_layout.addWidget(self.loading_label) layout.addLayout(toolbar_layout) # 文件表格 self.table_view = QTableView() self.table_view.setSelectionBehavior(QTableView.SelectRows) self.table_view.setSelectionMode(QTableView.ExtendedSelection) self.table_view.setEditTriggers(QTableView.NoEditTriggers) self.table_view.setSortingEnabled(True) self.table_view.setContextMenuPolicy(Qt.CustomContextMenu) self.table_view.customContextMenuRequested.connect(self.show_context_menu) self.table_view.doubleClicked.connect(self.on_item_double_clicked) self.table_view.clicked.connect(self.on_item_clicked) # 设置数据模型 self.model = FileTableModel() self.proxy_model = SortProxyModel() self.proxy_model.setSourceModel(self.model) self.table_view.setModel(self.proxy_model) # 设置列宽 header = self.table_view.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # 默认按大小降序排序 self.table_view.sortByColumn(1, Qt.DescendingOrder) layout.addWidget(self.table_view) # 状态栏 status_layout = QHBoxLayout() self.status_label = QLabel("就绪") status_layout.addWidget(self.status_label) status_layout.addStretch() self.item_count_label = QLabel() status_layout.addWidget(self.item_count_label) layout.addLayout(status_layout) def load_directory(self, path: str): """加载目录内容""" if not os.path.isdir(path): QMessageBox.warning(self, "错误", f"目录不存在:{path}") return self.current_path = path # 更新路径历史 if self.history_index < len(self.path_history) - 1: self.path_history = self.path_history[:self.history_index + 1] if not self.path_history or self.path_history[-1] != path: self.path_history.append(path) self.history_index = len(self.path_history) - 1 # 更新UI self.update_navigation_buttons() self.path_label.setText(path) self.loading_label.show() self.status_label.setText(f"正在加载 {path}...") # 发送目录改变信号 self.directory_changed.emit(path) # 在后台线程加载 self.load_thread = DirectoryLoadThread(self.scanner, path) self.load_thread.finished.connect(self.on_directory_loaded) self.load_thread.error.connect(self.on_load_error) self.load_thread.start() def on_directory_loaded(self, files: List[FileItem], path: str): """目录加载完成""" if path != self.current_path: return # 已经切换到其他目录 self.loading_label.hide() self.model.set_files(files) # 更新状态栏 total_size = sum(f.size for f in files) dir_count = sum(1 for f in files if f.is_dir) file_count = len(files) - dir_count self.status_label.setText( f"共 {dir_count} 个目录, {file_count} 个文件, " f"总计 {format_size(total_size)}" ) self.item_count_label.setText(f"{len(files)} 个项目") def on_load_error(self, error_message: str): """加载出错""" self.loading_label.hide() self.status_label.setText(f"加载失败:{error_message}") QMessageBox.critical(self, "错误", f"加载目录失败:\n{error_message}") def on_item_clicked(self, index: QModelIndex): """点击项目""" source_index = self.proxy_model.mapToSource(index) file_item = self.model.get_file_at(source_index.row()) if file_item: self.file_selected.emit(file_item) def on_item_double_clicked(self, index: QModelIndex): """双击项目""" source_index = self.proxy_model.mapToSource(index) file_item = self.model.get_file_at(source_index.row()) if file_item and file_item.is_dir: self.load_directory(file_item.path) def show_context_menu(self, position): """显示右键菜单""" # 获取选中的项目 selected_indexes = self.table_view.selectedIndexes() if not selected_indexes: return # 获取唯一的行号 rows = set() for index in selected_indexes: rows.add(index.row()) # 获取文件信息 selected_files = [] for row in rows: source_index = self.proxy_model.mapToSource( self.proxy_model.index(row, 0) ) file_item = self.model.get_file_at(source_index.row()) if file_item: selected_files.append(file_item) if not selected_files: return # 创建菜单 menu = QMenu(self) # 删除动作 delete_action = QAction(f"🗑️ 删除 ({len(selected_files)} 个项目)", self) delete_action.triggered.connect(lambda: self.delete_files(selected_files)) menu.addAction(delete_action) # 如果是单个目录,添加进入目录动作 if len(selected_files) == 1 and selected_files[0].is_dir: menu.addSeparator() enter_action = QAction("📂 进入目录", self) enter_action.triggered.connect( lambda: self.load_directory(selected_files[0].path) ) menu.addAction(enter_action) # 显示菜单 menu.exec_(QCursor.pos()) def delete_files(self, files: List[FileItem]): """删除文件""" from gui.confirm_dialog import DeleteConfirmDialog # 计算总大小 total_size = sum(f.size for f in files) # 检查是否包含目录 has_dir = any(f.is_dir for f in files) # 显示确认对话框 paths = [f.path for f in files] dialog = DeleteConfirmDialog(paths, total_size, has_dir, self) if dialog.exec_() == DeleteConfirmDialog.Accepted and dialog.is_confirmed(): # 执行删除 deleted_size = 0 errors = [] for file_item in files: success, message = safe_delete( file_item.path, use_sudo=True, sudo_password=self.sudo_password ) if success: deleted_size += file_item.size else: errors.append(f"{file_item.name}: {message}") # 显示结果 if errors: QMessageBox.warning( self, "删除完成(有错误)", f"成功删除 {len(files) - len(errors)} 个项目,\n" f"释放 {format_size(deleted_size)} 空间。\n\n" f"以下项目删除失败:\n" + "\n".join(errors[:10]) ) else: QMessageBox.information( self, "删除成功", f"成功删除 {len(files)} 个项目,\n" f"释放 {format_size(deleted_size)} 空间。" ) # 发送删除信号 self.files_deleted.emit(deleted_size) # 刷新当前目录 self.refresh() def go_back(self): """后退""" if self.history_index > 0: self.history_index -= 1 path = self.path_history[self.history_index] self.load_directory(path) def go_forward(self): """前进""" if self.history_index < len(self.path_history) - 1: self.history_index += 1 path = self.path_history[self.history_index] self.load_directory(path) def go_up(self): """上级目录""" if self.current_path: parent_path = os.path.dirname(self.current_path) if parent_path != self.current_path: self.load_directory(parent_path) def refresh(self): """刷新当前目录""" if self.current_path: self.load_directory(self.current_path) def update_navigation_buttons(self): """更新导航按钮状态""" self.back_button.setEnabled(self.history_index > 0) self.forward_button.setEnabled( self.history_index < len(self.path_history) - 1 ) self.up_button.setEnabled( self.current_path and self.current_path != '/' ) def get_current_path(self) -> str: """获取当前路径""" return self.current_path