| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- """
- File Selector component for UI.
- Provides file and folder selection dialogs with validation for Story 7.8.
- Drag and drop support added in Story 7.14.
- """
- from pathlib import Path
- from typing import List, Optional, Callable, Tuple
- from PyQt6.QtWidgets import (
- QWidget,
- QVBoxLayout,
- QHBoxLayout,
- QPushButton,
- QLabel,
- QLineEdit,
- QFileDialog,
- QGroupBox,
- QSizePolicy,
- QTableWidget,
- QTableWidgetItem,
- QHeaderView,
- QProgressDialog,
- QMessageBox,
- QDialog,
- QDialogButtonBox,
- )
- from PyQt6.QtCore import Qt, pyqtSignal, QThread, QFileSystemWatcher, QMimeData
- from PyQt6.QtGui import QFont, QDragEnterEvent, QDragMoveEvent, QDropEvent
- 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 multi-file support (Story 7.8).
- Features:
- - 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
- - Drag and drop support for files and folders (Story 7.14)
- """
- # Signals
- file_selected = pyqtSignal(object) # Path
- files_selected = pyqtSignal(list) # List[Path]
- file_changed = pyqtSignal(object) # Path
- selection_cleared = pyqtSignal()
- files_dropped = pyqtSignal(list) # List[Path] - Story 7.14
- # 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 - Drag & drop files here"
- SIZE_FORMAT_UNITS = ["B", "KB", "MB", "GB"]
- # Drag and drop styling (Story 7.14)
- DROP_ZONE_STYLE_NORMAL = ""
- DROP_ZONE_STYLE_HOVER = """
- FileSelector {
- border: 2px dashed #3498db;
- background-color: rgba(52, 152, 219, 0.1);
- border-radius: 8px;
- }
- """
- def __init__(self, parent: Optional[QWidget] = None) -> None:
- """Initialize the file selector."""
- super().__init__(parent)
- self._current_paths: List[Path] = []
- self._file_watcher = QFileSystemWatcher(self)
- self._last_directory = str(Path.home())
- # Story 7.14: Enable drag and drop
- self.setAcceptDrops(True)
- self._is_dragging = False
- self._setup_ui()
- self._connect_signals()
- def _setup_ui(self) -> None:
- """Set up the UI components."""
- layout = QVBoxLayout(self)
- layout.setContentsMargins(8, 8, 8, 8)
- layout.setSpacing(8)
- # File path input group
- path_group = QGroupBox("Source Files")
- path_layout = QVBoxLayout(path_group)
- path_layout.setSpacing(4)
- # 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 (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
- clear_layout = QHBoxLayout()
- clear_layout.addStretch()
- self._clear_btn = QPushButton("Clear Selection")
- self._clear_btn.setEnabled(False)
- clear_layout.addWidget(self._clear_btn)
- path_layout.addLayout(clear_layout)
- layout.addWidget(path_group)
- # File info preview group
- info_group = QGroupBox("Selection Information")
- info_layout = QVBoxLayout(info_group)
- info_layout.setSpacing(4)
- # 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("Total Size:")
- size_label.setFont(QFont("", 9, QFont.Weight.Bold))
- 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)
- # 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(100)
- status_layout.addWidget(status_label)
- 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)
- layout.addWidget(info_group)
- layout.addStretch()
- # Set size policy
- self.setSizePolicy(
- QSizePolicy.Policy.Preferred,
- QSizePolicy.Policy.Fixed
- )
- def _connect_signals(self) -> None:
- """Connect internal signals."""
- 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_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}"
- )
- 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."""
- self.clear()
- def _on_file_changed(self, path: str) -> None:
- """Handle file system change notification."""
- 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_paths:
- self._clear_info()
- return
- count = len(self._current_paths)
- total_size = 0
- for path in self._current_paths:
- if path.exists():
- try:
- total_size += path.stat().st_size
- except OSError:
- pass
- # 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)")
- 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._count_value.setText("0")
- self._size_value.setText("-")
- 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:
- """Set status message with appropriate styling."""
- self._status_value.setText(message)
- if is_error:
- self._status_value.setStyleSheet("color: #c0392b;") # Red
- else:
- self._status_value.setStyleSheet("color: #27ae60;") # Green
- def _format_size(self, size_bytes: int) -> str:
- """
- Format file size for display.
- Args:
- size_bytes: Size in bytes
- Returns:
- Formatted size string
- """
- size = float(size_bytes)
- for unit in self.SIZE_FORMAT_UNITS:
- if size < 1024.0:
- return f"{size:.1f} {unit}"
- size /= 1024.0
- return f"{size:.1f} TB"
- def add_files(self, paths: List[Path]) -> None:
- """
- Add files to selection with validation (Story 7.8).
- Args:
- paths: List of file paths to add
- """
- 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()
- # Emit signals
- if valid_paths:
- if len(valid_paths) == 1:
- self.file_selected.emit(valid_paths[0])
- self.files_selected.emit(self._current_paths)
- def _validate_files(self, paths: List[Path]) -> Tuple[List[Path], List[str]]:
- """
- Validate file paths (Story 7.8 requirement).
- 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 paths from watcher
- for path in self._current_paths:
- self._file_watcher.removePath(str(path))
- 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 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 valid files are selected."""
- return len(self._current_paths) > 0 and all(
- p.exists() for p in self._current_paths
- )
- @property
- def file_count(self) -> int:
- """Get the number of selected files."""
- return len(self._current_paths)
- # Story 7.14: Drag and drop event handlers
- def dragEnterEvent(self, event: QDragEnterEvent) -> None:
- """
- Handle drag enter event (Story 7.14).
- Accepts the event if it contains URLs (files or folders).
- """
- if event.mimeData().hasUrls():
- event.acceptProposedAction()
- self._is_dragging = True
- self.setStyleSheet(self.DROP_ZONE_STYLE_HOVER)
- def dragMoveEvent(self, event: QDragMoveEvent) -> None:
- """
- Handle drag move event (Story 7.14).
- Keeps the drag operation alive.
- """
- if event.mimeData().hasUrls():
- event.acceptProposedAction()
- def dragLeaveEvent(self, event) -> None:
- """
- Handle drag leave event (Story 7.14).
- Removes the visual drag indication.
- """
- self._is_dragging = False
- self.setStyleSheet(self.DROP_ZONE_STYLE_NORMAL)
- def dropEvent(self, event: QDropEvent) -> None:
- """
- Handle drop event (Story 7.14).
- Processes dropped files and folders.
- """
- self._is_dragging = False
- self.setStyleSheet(self.DROP_ZONE_STYLE_NORMAL)
- mime_data = event.mimeData()
- if not mime_data.hasUrls():
- return
- urls = mime_data.urls()
- paths: List[Path] = []
- folders: List[Path] = []
- # Separate files and folders
- for url in urls:
- if url.isLocalFile():
- path = Path(url.toLocalFile())
- if path.is_file():
- paths.append(path)
- elif path.is_dir():
- folders.append(path)
- # Process files directly
- if paths:
- self.add_files(paths)
- self.files_dropped.emit(paths)
- # Scan folders for supported files
- if folders:
- for folder in folders:
- self._scan_folder(folder)
- event.acceptProposedAction()
- def _add_drag_drop_hint(self) -> None:
- """
- Add visual hint for drag and drop (Story 7.14).
- Updates the placeholder text to indicate drag and drop support.
- """
- if not self._current_paths:
- self._path_display.setPlaceholderText(
- "Drag & drop files here or click 'Add Files'"
- )
- 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()
|