file_selector.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. """
  2. File Selector component for UI.
  3. Provides file and folder selection dialogs with validation for Story 7.8.
  4. Drag and drop support added in Story 7.14.
  5. """
  6. from pathlib import Path
  7. from typing import List, Optional, Callable, Tuple
  8. from PyQt6.QtWidgets import (
  9. QWidget,
  10. QVBoxLayout,
  11. QHBoxLayout,
  12. QPushButton,
  13. QLabel,
  14. QLineEdit,
  15. QFileDialog,
  16. QGroupBox,
  17. QSizePolicy,
  18. QTableWidget,
  19. QTableWidgetItem,
  20. QHeaderView,
  21. QProgressDialog,
  22. QMessageBox,
  23. QDialog,
  24. QDialogButtonBox,
  25. )
  26. from PyQt6.QtCore import Qt, pyqtSignal, QThread, QFileSystemWatcher, QMimeData
  27. from PyQt6.QtGui import QFont, QDragEnterEvent, QDragMoveEvent, QDropEvent
  28. from .models import FileItem, FileStatus
  29. class FileScanner(QThread):
  30. """
  31. Worker thread for scanning files in a folder (Story 7.8 requirement).
  32. """
  33. progress = pyqtSignal(int, int) # current, total
  34. file_found = pyqtSignal(object) # FileItem
  35. finished = pyqtSignal()
  36. def __init__(self, path: Path, extensions: List[str]) -> None:
  37. """Initialize the file scanner."""
  38. super().__init__()
  39. self._path = path
  40. self._extensions = extensions
  41. self._is_running = True
  42. def run(self) -> None:
  43. """Scan the folder for supported files."""
  44. files = list(self._path.rglob("*"))
  45. total = len(files)
  46. for i, file_path in enumerate(files):
  47. if not self._is_running:
  48. break
  49. if file_path.is_file() and self._is_supported_file(file_path):
  50. item = self._create_file_item(file_path)
  51. self.file_found.emit(item)
  52. self.progress.emit(i + 1, total)
  53. self.finished.emit()
  54. def stop(self) -> None:
  55. """Stop the scanning process."""
  56. self._is_running = False
  57. def _is_supported_file(self, path: Path) -> bool:
  58. """Check if file is supported."""
  59. return path.suffix.lower() in self._extensions
  60. def _create_file_item(self, path: Path) -> FileItem:
  61. """Create a FileItem from a file path."""
  62. try:
  63. size = path.stat().st_size
  64. except OSError:
  65. size = 0
  66. return FileItem(
  67. path=path,
  68. name=path.name,
  69. size=size,
  70. status=FileStatus.PENDING,
  71. )
  72. class FileSelector(QWidget):
  73. """
  74. File selection widget with multi-file support (Story 7.8).
  75. Features:
  76. - File browser with .txt, .md, .html filtering
  77. - Multi-file selection support (Ctrl+click)
  78. - Folder import with recursive scan
  79. - File validation
  80. - File information preview
  81. - Change notification via file system watcher
  82. - Drag and drop support for files and folders (Story 7.14)
  83. """
  84. # Signals
  85. file_selected = pyqtSignal(object) # Path
  86. files_selected = pyqtSignal(list) # List[Path]
  87. file_changed = pyqtSignal(object) # Path
  88. selection_cleared = pyqtSignal()
  89. files_dropped = pyqtSignal(list) # List[Path] - Story 7.14
  90. # Constants - Story 7.8: Support .txt, .md, .html files
  91. SUPPORTED_EXTENSIONS = [".txt", ".md", ".html", ".htm"]
  92. FILE_FILTER = (
  93. "Supported Files (*.txt *.md *.html *.htm);;"
  94. "Text Files (*.txt);;"
  95. "Markdown Files (*.md);;"
  96. "HTML Files (*.html *.htm);;"
  97. "All Files (*)"
  98. )
  99. DEFAULT_PLACEHOLDER = "No file selected - Drag & drop files here"
  100. SIZE_FORMAT_UNITS = ["B", "KB", "MB", "GB"]
  101. # Drag and drop styling (Story 7.14)
  102. DROP_ZONE_STYLE_NORMAL = ""
  103. DROP_ZONE_STYLE_HOVER = """
  104. FileSelector {
  105. border: 2px dashed #3498db;
  106. background-color: rgba(52, 152, 219, 0.1);
  107. border-radius: 8px;
  108. }
  109. """
  110. def __init__(self, parent: Optional[QWidget] = None) -> None:
  111. """Initialize the file selector."""
  112. super().__init__(parent)
  113. self._current_paths: List[Path] = []
  114. self._file_watcher = QFileSystemWatcher(self)
  115. self._last_directory = str(Path.home())
  116. # Story 7.14: Enable drag and drop
  117. self.setAcceptDrops(True)
  118. self._is_dragging = False
  119. self._setup_ui()
  120. self._connect_signals()
  121. def _setup_ui(self) -> None:
  122. """Set up the UI components."""
  123. layout = QVBoxLayout(self)
  124. layout.setContentsMargins(8, 8, 8, 8)
  125. layout.setSpacing(8)
  126. # File path input group
  127. path_group = QGroupBox("Source Files")
  128. path_layout = QVBoxLayout(path_group)
  129. path_layout.setSpacing(4)
  130. # Path display (shows count or single file)
  131. path_input_layout = QHBoxLayout()
  132. self._path_display = QLineEdit(self.DEFAULT_PLACEHOLDER)
  133. self._path_display.setReadOnly(True)
  134. self._path_display.setMinimumWidth(300)
  135. path_input_layout.addWidget(self._path_display)
  136. # Browse button (for single/multi file selection)
  137. self._browse_btn = QPushButton("Add Files...")
  138. self._browse_btn.setMinimumWidth(100)
  139. path_input_layout.addWidget(self._browse_btn)
  140. # Add folder button (Story 7.8: folder import)
  141. self._folder_btn = QPushButton("Add Folder...")
  142. self._folder_btn.setMinimumWidth(100)
  143. path_input_layout.addWidget(self._folder_btn)
  144. path_layout.addLayout(path_input_layout)
  145. # Clear button
  146. clear_layout = QHBoxLayout()
  147. clear_layout.addStretch()
  148. self._clear_btn = QPushButton("Clear Selection")
  149. self._clear_btn.setEnabled(False)
  150. clear_layout.addWidget(self._clear_btn)
  151. path_layout.addLayout(clear_layout)
  152. layout.addWidget(path_group)
  153. # File info preview group
  154. info_group = QGroupBox("Selection Information")
  155. info_layout = QVBoxLayout(info_group)
  156. info_layout.setSpacing(4)
  157. # File count
  158. count_layout = QHBoxLayout()
  159. count_label = QLabel("Files:")
  160. count_label.setFont(QFont("", 9, QFont.Weight.Bold))
  161. count_label.setMinimumWidth(100)
  162. count_layout.addWidget(count_label)
  163. self._count_value = QLabel("0")
  164. count_layout.addWidget(self._count_value, 1)
  165. info_layout.addLayout(count_layout)
  166. # Total size
  167. size_layout = QHBoxLayout()
  168. size_label = QLabel("Total Size:")
  169. size_label.setFont(QFont("", 9, QFont.Weight.Bold))
  170. size_label.setMinimumWidth(100)
  171. size_layout.addWidget(size_label)
  172. self._size_value = QLabel("-")
  173. size_layout.addWidget(self._size_value, 1)
  174. info_layout.addLayout(size_layout)
  175. # First file name (when multiple selected)
  176. name_layout = QHBoxLayout()
  177. name_label = QLabel("First File:")
  178. name_label.setFont(QFont("", 9, QFont.Weight.Bold))
  179. name_label.setMinimumWidth(100)
  180. size_layout.addWidget(name_label)
  181. self._name_value = QLabel("-")
  182. name_layout.addWidget(self._name_value, 1)
  183. info_layout.addLayout(name_layout)
  184. # Status indicator
  185. status_layout = QHBoxLayout()
  186. status_label = QLabel("Status:")
  187. status_label.setFont(QFont("", 9, QFont.Weight.Bold))
  188. status_label.setMinimumWidth(100)
  189. status_layout.addWidget(status_label)
  190. self._status_value = QLabel("No files selected")
  191. self._status_value.setStyleSheet("color: gray;")
  192. status_layout.addWidget(self._status_value, 1)
  193. info_layout.addLayout(status_layout)
  194. layout.addWidget(info_group)
  195. layout.addStretch()
  196. # Set size policy
  197. self.setSizePolicy(
  198. QSizePolicy.Policy.Preferred,
  199. QSizePolicy.Policy.Fixed
  200. )
  201. def _connect_signals(self) -> None:
  202. """Connect internal signals."""
  203. self._browse_btn.clicked.connect(self._on_browse_files)
  204. self._folder_btn.clicked.connect(self._on_browse_folder)
  205. self._clear_btn.clicked.connect(self._on_clear)
  206. self._file_watcher.fileChanged.connect(self._on_file_changed)
  207. def _on_browse_files(self) -> None:
  208. """Handle browse button click - multi-file selection (Story 7.8)."""
  209. dialog = QFileDialog(self)
  210. dialog.setFileMode(QFileDialog.FileMode.ExistingFiles)
  211. dialog.setNameFilter(self.FILE_FILTER)
  212. dialog.setDirectory(self._last_directory)
  213. if dialog.exec():
  214. paths = [Path(path) for path in dialog.selectedFiles()]
  215. if paths:
  216. self._last_directory = str(paths[0].parent)
  217. self.add_files(paths)
  218. def _on_browse_folder(self) -> None:
  219. """Handle folder button click - folder import (Story 7.8)."""
  220. dialog = QFileDialog(self)
  221. dialog.setFileMode(QFileDialog.FileMode.Directory)
  222. dialog.setDirectory(self._last_directory)
  223. if dialog.exec():
  224. folder = Path(dialog.selectedFiles()[0])
  225. self._last_directory = str(folder)
  226. self._scan_folder(folder)
  227. def _scan_folder(self, folder: Path) -> None:
  228. """
  229. Scan folder for supported files (Story 7.8).
  230. Shows progress dialog and adds found files.
  231. """
  232. # Create progress dialog
  233. progress = QProgressDialog(
  234. f"Scanning folder: {folder.name}",
  235. "Cancel",
  236. 0, 100,
  237. self
  238. )
  239. progress.setWindowTitle("Import Files from Folder")
  240. progress.setWindowModality(Qt.WindowModality.WindowModal)
  241. progress.show()
  242. found_files: List[FileItem] = []
  243. # Create scanner thread
  244. def on_file_found(item: FileItem) -> None:
  245. found_files.append(item)
  246. def on_progress(current: int, total: int) -> None:
  247. if total > 0:
  248. progress.setValue(int(current / total * 100))
  249. progress.setLabelText(f"Scanning... {current} files checked")
  250. def on_finished() -> None:
  251. progress.close()
  252. if found_files:
  253. paths = [item.path for item in found_files]
  254. self.add_files(paths)
  255. self._set_status(f"Added {len(found_files)} file(s)", is_error=False)
  256. else:
  257. QMessageBox.information(
  258. self,
  259. "No Files Found",
  260. f"No supported files ({', '.join(self.SUPPORTED_EXTENSIONS)}) "
  261. f"found in:\n{folder}"
  262. )
  263. scanner = FileScanner(folder, self.SUPPORTED_EXTENSIONS)
  264. scanner.file_found.connect(on_file_found)
  265. scanner.progress.connect(on_progress)
  266. scanner.finished.connect(on_finished)
  267. # Connect cancel button
  268. progress.canceled.connect(scanner.stop)
  269. scanner.start()
  270. def _on_clear(self) -> None:
  271. """Handle clear button click."""
  272. self.clear()
  273. def _on_file_changed(self, path: str) -> None:
  274. """Handle file system change notification."""
  275. for current_path in self._current_paths:
  276. if str(current_path) == path:
  277. if current_path.exists():
  278. self._update_file_info()
  279. self.file_changed.emit(current_path)
  280. else:
  281. # File was deleted - remove from list
  282. self._current_paths.remove(current_path)
  283. self._update_file_info()
  284. def _update_file_info(self) -> None:
  285. """Update file information display."""
  286. if not self._current_paths:
  287. self._clear_info()
  288. return
  289. count = len(self._current_paths)
  290. total_size = 0
  291. for path in self._current_paths:
  292. if path.exists():
  293. try:
  294. total_size += path.stat().st_size
  295. except OSError:
  296. pass
  297. # Update display
  298. if count == 1:
  299. self._path_display.setText(str(self._current_paths[0]))
  300. self._name_value.setText(self._current_paths[0].name)
  301. else:
  302. self._path_display.setText(f"{count} files selected")
  303. self._name_value.setText(self._current_paths[0].name + " (+ more)")
  304. self._count_value.setText(str(count))
  305. self._size_value.setText(self._format_size(total_size))
  306. self._set_status(f"{count} file(s) ready", is_error=False)
  307. self._clear_btn.setEnabled(True)
  308. def _clear_info(self) -> None:
  309. """Clear all information display."""
  310. self._path_display.setText(self.DEFAULT_PLACEHOLDER)
  311. self._count_value.setText("0")
  312. self._size_value.setText("-")
  313. self._name_value.setText("-")
  314. self._set_status("No files selected", is_error=False)
  315. self._clear_btn.setEnabled(False)
  316. def _set_status(self, message: str, is_error: bool) -> None:
  317. """Set status message with appropriate styling."""
  318. self._status_value.setText(message)
  319. if is_error:
  320. self._status_value.setStyleSheet("color: #c0392b;") # Red
  321. else:
  322. self._status_value.setStyleSheet("color: #27ae60;") # Green
  323. def _format_size(self, size_bytes: int) -> str:
  324. """
  325. Format file size for display.
  326. Args:
  327. size_bytes: Size in bytes
  328. Returns:
  329. Formatted size string
  330. """
  331. size = float(size_bytes)
  332. for unit in self.SIZE_FORMAT_UNITS:
  333. if size < 1024.0:
  334. return f"{size:.1f} {unit}"
  335. size /= 1024.0
  336. return f"{size:.1f} TB"
  337. def add_files(self, paths: List[Path]) -> None:
  338. """
  339. Add files to selection with validation (Story 7.8).
  340. Args:
  341. paths: List of file paths to add
  342. """
  343. valid_paths, errors = self._validate_files(paths)
  344. # Show errors if any
  345. if errors:
  346. QMessageBox.warning(
  347. self,
  348. "File Validation Errors",
  349. "Some files could not be added:\n\n" + "\n".join(errors[:5]) +
  350. ("\n...and " + str(len(errors) - 5) + " more" if len(errors) > 5 else "")
  351. )
  352. # Add valid files
  353. for path in valid_paths:
  354. if path not in self._current_paths:
  355. self._current_paths.append(path)
  356. # Add to watcher
  357. if path.exists():
  358. self._file_watcher.addPath(str(path))
  359. self._update_file_info()
  360. # Emit signals
  361. if valid_paths:
  362. if len(valid_paths) == 1:
  363. self.file_selected.emit(valid_paths[0])
  364. self.files_selected.emit(self._current_paths)
  365. def _validate_files(self, paths: List[Path]) -> Tuple[List[Path], List[str]]:
  366. """
  367. Validate file paths (Story 7.8 requirement).
  368. Returns:
  369. Tuple of (valid_paths, error_messages).
  370. """
  371. valid = []
  372. errors = []
  373. for path in paths:
  374. if not path.exists():
  375. errors.append(f"File not found: {path.name}")
  376. continue
  377. if not path.is_file():
  378. errors.append(f"Not a file: {path.name}")
  379. continue
  380. if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
  381. errors.append(f"Unsupported file type: {path.name}")
  382. continue
  383. # Check file is readable
  384. try:
  385. with open(path, "rb") as f:
  386. f.read(1)
  387. except Exception as e:
  388. errors.append(f"Cannot read file: {path.name} ({e})")
  389. continue
  390. valid.append(path)
  391. return valid, errors
  392. def set_file(self, path: Path) -> None:
  393. """
  394. Set a single file selection (for backward compatibility).
  395. Args:
  396. path: Path to the selected file
  397. """
  398. self.clear()
  399. self.add_files([path])
  400. def clear(self) -> None:
  401. """Clear the file selection."""
  402. # Remove paths from watcher
  403. for path in self._current_paths:
  404. self._file_watcher.removePath(str(path))
  405. self._current_paths.clear()
  406. self._clear_info()
  407. self.selection_cleared.emit()
  408. @property
  409. def current_paths(self) -> List[Path]:
  410. """Get the list of currently selected file paths."""
  411. return self._current_paths.copy()
  412. @property
  413. def current_path(self) -> Optional[Path]:
  414. """Get the first selected file path (for backward compatibility)."""
  415. return self._current_paths[0] if self._current_paths else None
  416. @property
  417. def is_valid(self) -> bool:
  418. """Check if valid files are selected."""
  419. return len(self._current_paths) > 0 and all(
  420. p.exists() for p in self._current_paths
  421. )
  422. @property
  423. def file_count(self) -> int:
  424. """Get the number of selected files."""
  425. return len(self._current_paths)
  426. # Story 7.14: Drag and drop event handlers
  427. def dragEnterEvent(self, event: QDragEnterEvent) -> None:
  428. """
  429. Handle drag enter event (Story 7.14).
  430. Accepts the event if it contains URLs (files or folders).
  431. """
  432. if event.mimeData().hasUrls():
  433. event.acceptProposedAction()
  434. self._is_dragging = True
  435. self.setStyleSheet(self.DROP_ZONE_STYLE_HOVER)
  436. def dragMoveEvent(self, event: QDragMoveEvent) -> None:
  437. """
  438. Handle drag move event (Story 7.14).
  439. Keeps the drag operation alive.
  440. """
  441. if event.mimeData().hasUrls():
  442. event.acceptProposedAction()
  443. def dragLeaveEvent(self, event) -> None:
  444. """
  445. Handle drag leave event (Story 7.14).
  446. Removes the visual drag indication.
  447. """
  448. self._is_dragging = False
  449. self.setStyleSheet(self.DROP_ZONE_STYLE_NORMAL)
  450. def dropEvent(self, event: QDropEvent) -> None:
  451. """
  452. Handle drop event (Story 7.14).
  453. Processes dropped files and folders.
  454. """
  455. self._is_dragging = False
  456. self.setStyleSheet(self.DROP_ZONE_STYLE_NORMAL)
  457. mime_data = event.mimeData()
  458. if not mime_data.hasUrls():
  459. return
  460. urls = mime_data.urls()
  461. paths: List[Path] = []
  462. folders: List[Path] = []
  463. # Separate files and folders
  464. for url in urls:
  465. if url.isLocalFile():
  466. path = Path(url.toLocalFile())
  467. if path.is_file():
  468. paths.append(path)
  469. elif path.is_dir():
  470. folders.append(path)
  471. # Process files directly
  472. if paths:
  473. self.add_files(paths)
  474. self.files_dropped.emit(paths)
  475. # Scan folders for supported files
  476. if folders:
  477. for folder in folders:
  478. self._scan_folder(folder)
  479. event.acceptProposedAction()
  480. def _add_drag_drop_hint(self) -> None:
  481. """
  482. Add visual hint for drag and drop (Story 7.14).
  483. Updates the placeholder text to indicate drag and drop support.
  484. """
  485. if not self._current_paths:
  486. self._path_display.setPlaceholderText(
  487. "Drag & drop files here or click 'Add Files'"
  488. )
  489. class FileListDialog(QDialog):
  490. """
  491. Dialog for showing and managing the list of selected files.
  492. """
  493. def __init__(
  494. self,
  495. files: List[FileItem],
  496. parent: Optional[QWidget] = None,
  497. ) -> None:
  498. """Initialize the dialog."""
  499. super().__init__(parent)
  500. self._files = files.copy()
  501. self._removed_files: List[FileItem] = []
  502. self._setup_ui()
  503. def _setup_ui(self) -> None:
  504. """Set up the dialog UI."""
  505. self.setWindowTitle("Selected Files")
  506. self.setMinimumSize(600, 400)
  507. layout = QVBoxLayout(self)
  508. # File table
  509. self._file_table = QTableWidget()
  510. self._file_table.setColumnCount(4)
  511. self._file_table.setHorizontalHeaderLabels([
  512. "Name", "Size", "Status", "Remove"
  513. ])
  514. self._file_table.horizontalHeader().setSectionResizeMode(
  515. 0, QHeaderView.ResizeMode.Stretch
  516. )
  517. self._file_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
  518. self._file_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
  519. layout.addWidget(self._file_table)
  520. # Populate table
  521. self._populate_table()
  522. # Buttons
  523. button_layout = QHBoxLayout()
  524. button_layout.addStretch()
  525. self._remove_btn = QPushButton("Remove Selected")
  526. self._remove_btn.clicked.connect(self._on_remove_selected)
  527. button_layout.addWidget(self._remove_btn)
  528. self._close_btn = QPushButton("Close")
  529. self._close_btn.clicked.connect(self.accept)
  530. button_layout.addWidget(self._close_btn)
  531. layout.addLayout(button_layout)
  532. def _populate_table(self) -> None:
  533. """Populate the file table."""
  534. self._file_table.setRowCount(0)
  535. for i, file_item in enumerate(self._files):
  536. self._file_table.insertRow(i)
  537. # Name
  538. self._file_table.setItem(i, 0, QTableWidgetItem(file_item.name))
  539. # Size
  540. self._file_table.setItem(i, 1, QTableWidgetItem(file_item.size_formatted))
  541. # Status
  542. self._file_table.setItem(i, 2, QTableWidgetItem(file_item.status.value.capitalize()))
  543. # Remove button cell
  544. remove_btn = QPushButton("Remove")
  545. remove_btn.clicked.connect(lambda checked, idx=i: self._remove_row(idx))
  546. self._file_table.setCellWidget(i, 3, remove_btn)
  547. def _remove_row(self, row: int) -> None:
  548. """Remove a specific row."""
  549. if 0 <= row < len(self._files):
  550. removed = self._files.pop(row)
  551. self._removed_files.append(removed)
  552. self._file_table.removeRow(row)
  553. def _on_remove_selected(self) -> None:
  554. """Remove selected rows."""
  555. selected_rows = sorted(
  556. set(index.row() for index in self._file_table.selectedIndexes()),
  557. reverse=True
  558. )
  559. for row in selected_rows:
  560. self._remove_row(row)
  561. def get_files(self) -> List[FileItem]:
  562. """Get the current file list."""
  563. return self._files.copy()
  564. def get_removed_files(self) -> List[FileItem]:
  565. """Get the removed file list."""
  566. return self._removed_files.copy()