|
|
@@ -1,9 +1,12 @@
|
|
|
"""
|
|
|
File Selector component for UI.
|
|
|
|
|
|
-Provides a widget for selecting and previewing files for translation.
|
|
|
+Provides file and folder selection dialogs with validation for Story 7.8.
|
|
|
"""
|
|
|
|
|
|
+from pathlib import Path
|
|
|
+from typing import List, Optional, Callable, Tuple
|
|
|
+
|
|
|
from PyQt6.QtWidgets import (
|
|
|
QWidget,
|
|
|
QVBoxLayout,
|
|
|
@@ -14,31 +17,104 @@ from PyQt6.QtWidgets import (
|
|
|
QFileDialog,
|
|
|
QGroupBox,
|
|
|
QSizePolicy,
|
|
|
+ QTableWidget,
|
|
|
+ QTableWidgetItem,
|
|
|
+ QHeaderView,
|
|
|
+ QProgressDialog,
|
|
|
+ QMessageBox,
|
|
|
+ QDialog,
|
|
|
+ QDialogButtonBox,
|
|
|
)
|
|
|
-from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher
|
|
|
+from PyQt6.QtCore import Qt, pyqtSignal, QThread, QFileSystemWatcher
|
|
|
from PyQt6.QtGui import QFont
|
|
|
-from pathlib import Path
|
|
|
-from typing import Optional
|
|
|
+
|
|
|
+from .models import FileItem, FileStatus
|
|
|
+
|
|
|
+
|
|
|
+class FileScanner(QThread):
|
|
|
+ """
|
|
|
+ Worker thread for scanning files in a folder (Story 7.8 requirement).
|
|
|
+ """
|
|
|
+
|
|
|
+ progress = pyqtSignal(int, int) # current, total
|
|
|
+ file_found = pyqtSignal(object) # FileItem
|
|
|
+ finished = pyqtSignal()
|
|
|
+
|
|
|
+ def __init__(self, path: Path, extensions: List[str]) -> None:
|
|
|
+ """Initialize the file scanner."""
|
|
|
+ super().__init__()
|
|
|
+ self._path = path
|
|
|
+ self._extensions = extensions
|
|
|
+ self._is_running = True
|
|
|
+
|
|
|
+ def run(self) -> None:
|
|
|
+ """Scan the folder for supported files."""
|
|
|
+ files = list(self._path.rglob("*"))
|
|
|
+ total = len(files)
|
|
|
+
|
|
|
+ for i, file_path in enumerate(files):
|
|
|
+ if not self._is_running:
|
|
|
+ break
|
|
|
+
|
|
|
+ if file_path.is_file() and self._is_supported_file(file_path):
|
|
|
+ item = self._create_file_item(file_path)
|
|
|
+ self.file_found.emit(item)
|
|
|
+
|
|
|
+ self.progress.emit(i + 1, total)
|
|
|
+
|
|
|
+ self.finished.emit()
|
|
|
+
|
|
|
+ def stop(self) -> None:
|
|
|
+ """Stop the scanning process."""
|
|
|
+ self._is_running = False
|
|
|
+
|
|
|
+ def _is_supported_file(self, path: Path) -> bool:
|
|
|
+ """Check if file is supported."""
|
|
|
+ return path.suffix.lower() in self._extensions
|
|
|
+
|
|
|
+ def _create_file_item(self, path: Path) -> FileItem:
|
|
|
+ """Create a FileItem from a file path."""
|
|
|
+ try:
|
|
|
+ size = path.stat().st_size
|
|
|
+ except OSError:
|
|
|
+ size = 0
|
|
|
+
|
|
|
+ return FileItem(
|
|
|
+ path=path,
|
|
|
+ name=path.name,
|
|
|
+ size=size,
|
|
|
+ status=FileStatus.PENDING,
|
|
|
+ )
|
|
|
|
|
|
|
|
|
class FileSelector(QWidget):
|
|
|
"""
|
|
|
- File selection widget with preview.
|
|
|
+ File selection widget with multi-file support (Story 7.8).
|
|
|
|
|
|
Features:
|
|
|
- - File browser button with .txt filtering
|
|
|
- - File path display
|
|
|
- - File information preview (size, line count)
|
|
|
+ - File browser with .txt, .md, .html filtering
|
|
|
+ - Multi-file selection support (Ctrl+click)
|
|
|
+ - Folder import with recursive scan
|
|
|
+ - File validation
|
|
|
+ - File information preview
|
|
|
- Change notification via file system watcher
|
|
|
"""
|
|
|
|
|
|
# Signals
|
|
|
file_selected = pyqtSignal(object) # Path
|
|
|
+ files_selected = pyqtSignal(list) # List[Path]
|
|
|
file_changed = pyqtSignal(object) # Path
|
|
|
selection_cleared = pyqtSignal()
|
|
|
|
|
|
- # Constants
|
|
|
- FILE_FILTER = "Text Files (*.txt);;All Files (*)"
|
|
|
+ # Constants - Story 7.8: Support .txt, .md, .html files
|
|
|
+ SUPPORTED_EXTENSIONS = [".txt", ".md", ".html", ".htm"]
|
|
|
+ FILE_FILTER = (
|
|
|
+ "Supported Files (*.txt *.md *.html *.htm);;"
|
|
|
+ "Text Files (*.txt);;"
|
|
|
+ "Markdown Files (*.md);;"
|
|
|
+ "HTML Files (*.html *.htm);;"
|
|
|
+ "All Files (*)"
|
|
|
+ )
|
|
|
DEFAULT_PLACEHOLDER = "No file selected"
|
|
|
SIZE_FORMAT_UNITS = ["B", "KB", "MB", "GB"]
|
|
|
|
|
|
@@ -46,8 +122,9 @@ class FileSelector(QWidget):
|
|
|
"""Initialize the file selector."""
|
|
|
super().__init__(parent)
|
|
|
|
|
|
- self._current_path: Optional[Path] = None
|
|
|
+ self._current_paths: List[Path] = []
|
|
|
self._file_watcher = QFileSystemWatcher(self)
|
|
|
+ self._last_directory = str(Path.home())
|
|
|
|
|
|
self._setup_ui()
|
|
|
self._connect_signals()
|
|
|
@@ -59,22 +136,27 @@ class FileSelector(QWidget):
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
# File path input group
|
|
|
- path_group = QGroupBox("Source File")
|
|
|
+ path_group = QGroupBox("Source Files")
|
|
|
path_layout = QVBoxLayout(path_group)
|
|
|
path_layout.setSpacing(4)
|
|
|
|
|
|
- # Path display
|
|
|
+ # Path display (shows count or single file)
|
|
|
path_input_layout = QHBoxLayout()
|
|
|
self._path_display = QLineEdit(self.DEFAULT_PLACEHOLDER)
|
|
|
self._path_display.setReadOnly(True)
|
|
|
self._path_display.setMinimumWidth(300)
|
|
|
path_input_layout.addWidget(self._path_display)
|
|
|
|
|
|
- # Browse button
|
|
|
- self._browse_btn = QPushButton("Browse...")
|
|
|
+ # Browse button (for single/multi file selection)
|
|
|
+ self._browse_btn = QPushButton("Add Files...")
|
|
|
self._browse_btn.setMinimumWidth(100)
|
|
|
path_input_layout.addWidget(self._browse_btn)
|
|
|
|
|
|
+ # Add folder button (Story 7.8: folder import)
|
|
|
+ self._folder_btn = QPushButton("Add Folder...")
|
|
|
+ self._folder_btn.setMinimumWidth(100)
|
|
|
+ path_input_layout.addWidget(self._folder_btn)
|
|
|
+
|
|
|
path_layout.addLayout(path_input_layout)
|
|
|
|
|
|
# Clear button
|
|
|
@@ -88,57 +170,47 @@ class FileSelector(QWidget):
|
|
|
layout.addWidget(path_group)
|
|
|
|
|
|
# File info preview group
|
|
|
- info_group = QGroupBox("File Information")
|
|
|
+ info_group = QGroupBox("Selection Information")
|
|
|
info_layout = QVBoxLayout(info_group)
|
|
|
info_layout.setSpacing(4)
|
|
|
|
|
|
- # File name
|
|
|
- name_layout = QHBoxLayout()
|
|
|
- name_label = QLabel("Name:")
|
|
|
- name_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
- name_label.setMinimumWidth(80)
|
|
|
- name_layout.addWidget(name_label)
|
|
|
- self._name_value = QLabel("-")
|
|
|
- name_layout.addWidget(self._name_value, 1)
|
|
|
- info_layout.addLayout(name_layout)
|
|
|
-
|
|
|
- # File size
|
|
|
+ # File count
|
|
|
+ count_layout = QHBoxLayout()
|
|
|
+ count_label = QLabel("Files:")
|
|
|
+ count_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
+ count_label.setMinimumWidth(100)
|
|
|
+ count_layout.addWidget(count_label)
|
|
|
+ self._count_value = QLabel("0")
|
|
|
+ count_layout.addWidget(self._count_value, 1)
|
|
|
+ info_layout.addLayout(count_layout)
|
|
|
+
|
|
|
+ # Total size
|
|
|
size_layout = QHBoxLayout()
|
|
|
- size_label = QLabel("Size:")
|
|
|
+ size_label = QLabel("Total Size:")
|
|
|
size_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
- size_label.setMinimumWidth(80)
|
|
|
+ size_label.setMinimumWidth(100)
|
|
|
size_layout.addWidget(size_label)
|
|
|
self._size_value = QLabel("-")
|
|
|
size_layout.addWidget(self._size_value, 1)
|
|
|
info_layout.addLayout(size_layout)
|
|
|
|
|
|
- # Line count
|
|
|
- lines_layout = QHBoxLayout()
|
|
|
- lines_label = QLabel("Lines:")
|
|
|
- lines_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
- lines_label.setMinimumWidth(80)
|
|
|
- lines_layout.addWidget(lines_label)
|
|
|
- self._lines_value = QLabel("-")
|
|
|
- lines_layout.addWidget(self._lines_value, 1)
|
|
|
- info_layout.addLayout(lines_layout)
|
|
|
-
|
|
|
- # Estimated words (rough estimate)
|
|
|
- words_layout = QHBoxLayout()
|
|
|
- words_label = QLabel("Est. Words:")
|
|
|
- words_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
- words_label.setMinimumWidth(80)
|
|
|
- words_layout.addWidget(words_label)
|
|
|
- self._words_value = QLabel("-")
|
|
|
- words_layout.addWidget(self._words_value, 1)
|
|
|
- info_layout.addLayout(words_layout)
|
|
|
+ # First file name (when multiple selected)
|
|
|
+ name_layout = QHBoxLayout()
|
|
|
+ name_label = QLabel("First File:")
|
|
|
+ name_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
+ name_label.setMinimumWidth(100)
|
|
|
+ size_layout.addWidget(name_label)
|
|
|
+ self._name_value = QLabel("-")
|
|
|
+ name_layout.addWidget(self._name_value, 1)
|
|
|
+ info_layout.addLayout(name_layout)
|
|
|
|
|
|
# Status indicator
|
|
|
status_layout = QHBoxLayout()
|
|
|
status_label = QLabel("Status:")
|
|
|
status_label.setFont(QFont("", 9, QFont.Weight.Bold))
|
|
|
- status_label.setMinimumWidth(80)
|
|
|
+ status_label.setMinimumWidth(100)
|
|
|
status_layout.addWidget(status_label)
|
|
|
- self._status_value = QLabel("No file selected")
|
|
|
+ self._status_value = QLabel("No files selected")
|
|
|
self._status_value.setStyleSheet("color: gray;")
|
|
|
status_layout.addWidget(self._status_value, 1)
|
|
|
info_layout.addLayout(status_layout)
|
|
|
@@ -154,21 +226,86 @@ class FileSelector(QWidget):
|
|
|
|
|
|
def _connect_signals(self) -> None:
|
|
|
"""Connect internal signals."""
|
|
|
- self._browse_btn.clicked.connect(self._on_browse)
|
|
|
+ self._browse_btn.clicked.connect(self._on_browse_files)
|
|
|
+ self._folder_btn.clicked.connect(self._on_browse_folder)
|
|
|
self._clear_btn.clicked.connect(self._on_clear)
|
|
|
self._file_watcher.fileChanged.connect(self._on_file_changed)
|
|
|
|
|
|
- def _on_browse(self) -> None:
|
|
|
- """Handle browse button click."""
|
|
|
- file_path, _ = QFileDialog.getOpenFileName(
|
|
|
- self,
|
|
|
- "Select Text File",
|
|
|
- str(Path.home()),
|
|
|
- self.FILE_FILTER
|
|
|
+ def _on_browse_files(self) -> None:
|
|
|
+ """Handle browse button click - multi-file selection (Story 7.8)."""
|
|
|
+ dialog = QFileDialog(self)
|
|
|
+ dialog.setFileMode(QFileDialog.FileMode.ExistingFiles)
|
|
|
+ dialog.setNameFilter(self.FILE_FILTER)
|
|
|
+ dialog.setDirectory(self._last_directory)
|
|
|
+
|
|
|
+ if dialog.exec():
|
|
|
+ paths = [Path(path) for path in dialog.selectedFiles()]
|
|
|
+ if paths:
|
|
|
+ self._last_directory = str(paths[0].parent)
|
|
|
+ self.add_files(paths)
|
|
|
+
|
|
|
+ def _on_browse_folder(self) -> None:
|
|
|
+ """Handle folder button click - folder import (Story 7.8)."""
|
|
|
+ dialog = QFileDialog(self)
|
|
|
+ dialog.setFileMode(QFileDialog.FileMode.Directory)
|
|
|
+ dialog.setDirectory(self._last_directory)
|
|
|
+
|
|
|
+ if dialog.exec():
|
|
|
+ folder = Path(dialog.selectedFiles()[0])
|
|
|
+ self._last_directory = str(folder)
|
|
|
+ self._scan_folder(folder)
|
|
|
+
|
|
|
+ def _scan_folder(self, folder: Path) -> None:
|
|
|
+ """
|
|
|
+ Scan folder for supported files (Story 7.8).
|
|
|
+
|
|
|
+ Shows progress dialog and adds found files.
|
|
|
+ """
|
|
|
+ # Create progress dialog
|
|
|
+ progress = QProgressDialog(
|
|
|
+ f"Scanning folder: {folder.name}",
|
|
|
+ "Cancel",
|
|
|
+ 0, 100,
|
|
|
+ self
|
|
|
)
|
|
|
+ progress.setWindowTitle("Import Files from Folder")
|
|
|
+ progress.setWindowModality(Qt.WindowModality.WindowModal)
|
|
|
+ progress.show()
|
|
|
+
|
|
|
+ found_files: List[FileItem] = []
|
|
|
+
|
|
|
+ # Create scanner thread
|
|
|
+ def on_file_found(item: FileItem) -> None:
|
|
|
+ found_files.append(item)
|
|
|
+
|
|
|
+ def on_progress(current: int, total: int) -> None:
|
|
|
+ if total > 0:
|
|
|
+ progress.setValue(int(current / total * 100))
|
|
|
+ progress.setLabelText(f"Scanning... {current} files checked")
|
|
|
+
|
|
|
+ def on_finished() -> None:
|
|
|
+ progress.close()
|
|
|
+ if found_files:
|
|
|
+ paths = [item.path for item in found_files]
|
|
|
+ self.add_files(paths)
|
|
|
+ self._set_status(f"Added {len(found_files)} file(s)", is_error=False)
|
|
|
+ else:
|
|
|
+ QMessageBox.information(
|
|
|
+ self,
|
|
|
+ "No Files Found",
|
|
|
+ f"No supported files ({', '.join(self.SUPPORTED_EXTENSIONS)}) "
|
|
|
+ f"found in:\n{folder}"
|
|
|
+ )
|
|
|
|
|
|
- if file_path:
|
|
|
- self.set_file(Path(file_path))
|
|
|
+ scanner = FileScanner(folder, self.SUPPORTED_EXTENSIONS)
|
|
|
+ scanner.file_found.connect(on_file_found)
|
|
|
+ scanner.progress.connect(on_progress)
|
|
|
+ scanner.finished.connect(on_finished)
|
|
|
+
|
|
|
+ # Connect cancel button
|
|
|
+ progress.canceled.connect(scanner.stop)
|
|
|
+
|
|
|
+ scanner.start()
|
|
|
|
|
|
def _on_clear(self) -> None:
|
|
|
"""Handle clear button click."""
|
|
|
@@ -176,68 +313,52 @@ class FileSelector(QWidget):
|
|
|
|
|
|
def _on_file_changed(self, path: str) -> None:
|
|
|
"""Handle file system change notification."""
|
|
|
- if self._current_path and str(self._current_path) == path:
|
|
|
- # Re-check file existence and update info
|
|
|
- if self._current_path.exists():
|
|
|
- self._update_file_info()
|
|
|
- self.file_changed.emit(self._current_path)
|
|
|
- else:
|
|
|
- # File was deleted
|
|
|
- self.clear()
|
|
|
+ for current_path in self._current_paths:
|
|
|
+ if str(current_path) == path:
|
|
|
+ if current_path.exists():
|
|
|
+ self._update_file_info()
|
|
|
+ self.file_changed.emit(current_path)
|
|
|
+ else:
|
|
|
+ # File was deleted - remove from list
|
|
|
+ self._current_paths.remove(current_path)
|
|
|
+ self._update_file_info()
|
|
|
|
|
|
def _update_file_info(self) -> None:
|
|
|
"""Update file information display."""
|
|
|
- if not self._current_path:
|
|
|
+ if not self._current_paths:
|
|
|
self._clear_info()
|
|
|
return
|
|
|
|
|
|
- try:
|
|
|
- # Update path display
|
|
|
- self._path_display.setText(str(self._current_path))
|
|
|
+ count = len(self._current_paths)
|
|
|
+ total_size = 0
|
|
|
|
|
|
- # Update name
|
|
|
- self._name_value.setText(self._current_path.name)
|
|
|
+ for path in self._current_paths:
|
|
|
+ if path.exists():
|
|
|
+ try:
|
|
|
+ total_size += path.stat().st_size
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
|
|
|
- # Update size
|
|
|
- size = self._current_path.stat().st_size
|
|
|
- self._size_value.setText(self._format_size(size))
|
|
|
+ # Update display
|
|
|
+ if count == 1:
|
|
|
+ self._path_display.setText(str(self._current_paths[0]))
|
|
|
+ self._name_value.setText(self._current_paths[0].name)
|
|
|
+ else:
|
|
|
+ self._path_display.setText(f"{count} files selected")
|
|
|
+ self._name_value.setText(self._current_paths[0].name + " (+ more)")
|
|
|
|
|
|
- # Count lines
|
|
|
- try:
|
|
|
- with open(self._current_path, 'r', encoding='utf-8') as f:
|
|
|
- line_count = sum(1 for _ in f)
|
|
|
- self._lines_value.setText(f"{line_count:,}")
|
|
|
-
|
|
|
- # Estimate words (rough: assume ~50 Chinese characters per line on average)
|
|
|
- # This is a rough estimate for display purposes
|
|
|
- est_words = line_count * 50
|
|
|
- self._words_value.setText(f"~{est_words:,}")
|
|
|
-
|
|
|
- except (UnicodeDecodeError, IOError) as e:
|
|
|
- self._lines_value.setText("Error reading file")
|
|
|
- self._words_value.setText("-")
|
|
|
- self._set_status(f"Read error: {e}", is_error=True)
|
|
|
- return
|
|
|
-
|
|
|
- # Update status
|
|
|
- self._set_status("File ready", is_error=False)
|
|
|
- self._clear_btn.setEnabled(True)
|
|
|
-
|
|
|
- except FileNotFoundError:
|
|
|
- self._set_status("File not found", is_error=True)
|
|
|
- self._clear_btn.setEnabled(False)
|
|
|
- except Exception as e:
|
|
|
- self._set_status(f"Error: {e}", is_error=True)
|
|
|
- self._clear_btn.setEnabled(False)
|
|
|
+ self._count_value.setText(str(count))
|
|
|
+ self._size_value.setText(self._format_size(total_size))
|
|
|
+ self._set_status(f"{count} file(s) ready", is_error=False)
|
|
|
+ self._clear_btn.setEnabled(True)
|
|
|
|
|
|
def _clear_info(self) -> None:
|
|
|
"""Clear all information display."""
|
|
|
self._path_display.setText(self.DEFAULT_PLACEHOLDER)
|
|
|
- self._name_value.setText("-")
|
|
|
+ self._count_value.setText("0")
|
|
|
self._size_value.setText("-")
|
|
|
- self._lines_value.setText("-")
|
|
|
- self._words_value.setText("-")
|
|
|
- self._set_status("No file selected", is_error=False)
|
|
|
+ self._name_value.setText("-")
|
|
|
+ self._set_status("No files selected", is_error=False)
|
|
|
self._clear_btn.setEnabled(False)
|
|
|
|
|
|
def _set_status(self, message: str, is_error: bool) -> None:
|
|
|
@@ -265,53 +386,210 @@ class FileSelector(QWidget):
|
|
|
size /= 1024.0
|
|
|
return f"{size:.1f} TB"
|
|
|
|
|
|
- def set_file(self, path: Path) -> None:
|
|
|
+ def add_files(self, paths: List[Path]) -> None:
|
|
|
"""
|
|
|
- Set the selected file.
|
|
|
+ Add files to selection with validation (Story 7.8).
|
|
|
|
|
|
Args:
|
|
|
- path: Path to the selected file
|
|
|
+ paths: List of file paths to add
|
|
|
"""
|
|
|
- # Remove old path from watcher
|
|
|
- if self._current_path:
|
|
|
- self._file_watcher.removePath(str(self._current_path))
|
|
|
+ valid_paths, errors = self._validate_files(paths)
|
|
|
+
|
|
|
+ # Show errors if any
|
|
|
+ if errors:
|
|
|
+ QMessageBox.warning(
|
|
|
+ self,
|
|
|
+ "File Validation Errors",
|
|
|
+ "Some files could not be added:\n\n" + "\n".join(errors[:5]) +
|
|
|
+ ("\n...and " + str(len(errors) - 5) + " more" if len(errors) > 5 else "")
|
|
|
+ )
|
|
|
+
|
|
|
+ # Add valid files
|
|
|
+ for path in valid_paths:
|
|
|
+ if path not in self._current_paths:
|
|
|
+ self._current_paths.append(path)
|
|
|
+ # Add to watcher
|
|
|
+ if path.exists():
|
|
|
+ self._file_watcher.addPath(str(path))
|
|
|
+
|
|
|
+ self._update_file_info()
|
|
|
|
|
|
- self._current_path = path
|
|
|
+ # Emit signals
|
|
|
+ if valid_paths:
|
|
|
+ if len(valid_paths) == 1:
|
|
|
+ self.file_selected.emit(valid_paths[0])
|
|
|
+ self.files_selected.emit(self._current_paths)
|
|
|
|
|
|
- # Add new path to watcher
|
|
|
- if path.exists():
|
|
|
- self._file_watcher.addPath(str(path))
|
|
|
+ def _validate_files(self, paths: List[Path]) -> Tuple[List[Path], List[str]]:
|
|
|
+ """
|
|
|
+ Validate file paths (Story 7.8 requirement).
|
|
|
|
|
|
- self._update_file_info()
|
|
|
- self.file_selected.emit(path)
|
|
|
+ Returns:
|
|
|
+ Tuple of (valid_paths, error_messages).
|
|
|
+ """
|
|
|
+ valid = []
|
|
|
+ errors = []
|
|
|
+
|
|
|
+ for path in paths:
|
|
|
+ if not path.exists():
|
|
|
+ errors.append(f"File not found: {path.name}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if not path.is_file():
|
|
|
+ errors.append(f"Not a file: {path.name}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
|
|
|
+ errors.append(f"Unsupported file type: {path.name}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Check file is readable
|
|
|
+ try:
|
|
|
+ with open(path, "rb") as f:
|
|
|
+ f.read(1)
|
|
|
+ except Exception as e:
|
|
|
+ errors.append(f"Cannot read file: {path.name} ({e})")
|
|
|
+ continue
|
|
|
+
|
|
|
+ valid.append(path)
|
|
|
+
|
|
|
+ return valid, errors
|
|
|
+
|
|
|
+ def set_file(self, path: Path) -> None:
|
|
|
+ """
|
|
|
+ Set a single file selection (for backward compatibility).
|
|
|
+
|
|
|
+ Args:
|
|
|
+ path: Path to the selected file
|
|
|
+ """
|
|
|
+ self.clear()
|
|
|
+ self.add_files([path])
|
|
|
|
|
|
def clear(self) -> None:
|
|
|
"""Clear the file selection."""
|
|
|
- # Remove path from watcher
|
|
|
- if self._current_path:
|
|
|
- self._file_watcher.removePath(str(self._current_path))
|
|
|
+ # Remove paths from watcher
|
|
|
+ for path in self._current_paths:
|
|
|
+ self._file_watcher.removePath(str(path))
|
|
|
|
|
|
- self._current_path = None
|
|
|
+ self._current_paths.clear()
|
|
|
self._clear_info()
|
|
|
self.selection_cleared.emit()
|
|
|
|
|
|
+ @property
|
|
|
+ def current_paths(self) -> List[Path]:
|
|
|
+ """Get the list of currently selected file paths."""
|
|
|
+ return self._current_paths.copy()
|
|
|
+
|
|
|
@property
|
|
|
def current_path(self) -> Optional[Path]:
|
|
|
- """Get the currently selected file path."""
|
|
|
- return self._current_path
|
|
|
+ """Get the first selected file path (for backward compatibility)."""
|
|
|
+ return self._current_paths[0] if self._current_paths else None
|
|
|
|
|
|
@property
|
|
|
def is_valid(self) -> bool:
|
|
|
- """Check if a valid file is selected."""
|
|
|
- return self._current_path is not None and self._current_path.exists()
|
|
|
+ """Check if valid files are selected."""
|
|
|
+ return len(self._current_paths) > 0 and all(
|
|
|
+ p.exists() for p in self._current_paths
|
|
|
+ )
|
|
|
|
|
|
@property
|
|
|
- def line_count(self) -> Optional[int]:
|
|
|
- """Get the line count of the selected file."""
|
|
|
- if not self.is_valid:
|
|
|
- return None
|
|
|
- try:
|
|
|
- with open(self._current_path, 'r', encoding='utf-8') as f:
|
|
|
- return sum(1 for _ in f)
|
|
|
- except (UnicodeDecodeError, IOError):
|
|
|
- return None
|
|
|
+ def file_count(self) -> int:
|
|
|
+ """Get the number of selected files."""
|
|
|
+ return len(self._current_paths)
|
|
|
+
|
|
|
+
|
|
|
+class FileListDialog(QDialog):
|
|
|
+ """
|
|
|
+ Dialog for showing and managing the list of selected files.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ files: List[FileItem],
|
|
|
+ parent: Optional[QWidget] = None,
|
|
|
+ ) -> None:
|
|
|
+ """Initialize the dialog."""
|
|
|
+ super().__init__(parent)
|
|
|
+ self._files = files.copy()
|
|
|
+ self._removed_files: List[FileItem] = []
|
|
|
+
|
|
|
+ self._setup_ui()
|
|
|
+
|
|
|
+ def _setup_ui(self) -> None:
|
|
|
+ """Set up the dialog UI."""
|
|
|
+ self.setWindowTitle("Selected Files")
|
|
|
+ self.setMinimumSize(600, 400)
|
|
|
+
|
|
|
+ layout = QVBoxLayout(self)
|
|
|
+
|
|
|
+ # File table
|
|
|
+ self._file_table = QTableWidget()
|
|
|
+ self._file_table.setColumnCount(4)
|
|
|
+ self._file_table.setHorizontalHeaderLabels([
|
|
|
+ "Name", "Size", "Status", "Remove"
|
|
|
+ ])
|
|
|
+ self._file_table.horizontalHeader().setSectionResizeMode(
|
|
|
+ 0, QHeaderView.ResizeMode.Stretch
|
|
|
+ )
|
|
|
+ self._file_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
|
+ self._file_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
|
+ layout.addWidget(self._file_table)
|
|
|
+
|
|
|
+ # Populate table
|
|
|
+ self._populate_table()
|
|
|
+
|
|
|
+ # Buttons
|
|
|
+ button_layout = QHBoxLayout()
|
|
|
+ button_layout.addStretch()
|
|
|
+
|
|
|
+ self._remove_btn = QPushButton("Remove Selected")
|
|
|
+ self._remove_btn.clicked.connect(self._on_remove_selected)
|
|
|
+ button_layout.addWidget(self._remove_btn)
|
|
|
+
|
|
|
+ self._close_btn = QPushButton("Close")
|
|
|
+ self._close_btn.clicked.connect(self.accept)
|
|
|
+ button_layout.addWidget(self._close_btn)
|
|
|
+
|
|
|
+ layout.addLayout(button_layout)
|
|
|
+
|
|
|
+ def _populate_table(self) -> None:
|
|
|
+ """Populate the file table."""
|
|
|
+ self._file_table.setRowCount(0)
|
|
|
+
|
|
|
+ for i, file_item in enumerate(self._files):
|
|
|
+ self._file_table.insertRow(i)
|
|
|
+
|
|
|
+ # Name
|
|
|
+ self._file_table.setItem(i, 0, QTableWidgetItem(file_item.name))
|
|
|
+ # Size
|
|
|
+ self._file_table.setItem(i, 1, QTableWidgetItem(file_item.size_formatted))
|
|
|
+ # Status
|
|
|
+ self._file_table.setItem(i, 2, QTableWidgetItem(file_item.status.value.capitalize()))
|
|
|
+ # Remove button cell
|
|
|
+ remove_btn = QPushButton("Remove")
|
|
|
+ remove_btn.clicked.connect(lambda checked, idx=i: self._remove_row(idx))
|
|
|
+ self._file_table.setCellWidget(i, 3, remove_btn)
|
|
|
+
|
|
|
+ def _remove_row(self, row: int) -> None:
|
|
|
+ """Remove a specific row."""
|
|
|
+ if 0 <= row < len(self._files):
|
|
|
+ removed = self._files.pop(row)
|
|
|
+ self._removed_files.append(removed)
|
|
|
+ self._file_table.removeRow(row)
|
|
|
+
|
|
|
+ def _on_remove_selected(self) -> None:
|
|
|
+ """Remove selected rows."""
|
|
|
+ selected_rows = sorted(
|
|
|
+ set(index.row() for index in self._file_table.selectedIndexes()),
|
|
|
+ reverse=True
|
|
|
+ )
|
|
|
+ for row in selected_rows:
|
|
|
+ self._remove_row(row)
|
|
|
+
|
|
|
+ def get_files(self) -> List[FileItem]:
|
|
|
+ """Get the current file list."""
|
|
|
+ return self._files.copy()
|
|
|
+
|
|
|
+ def get_removed_files(self) -> List[FileItem]:
|
|
|
+ """Get the removed file list."""
|
|
|
+ return self._removed_files.copy()
|