"""Project list widget showing all discovered projects.""" import subprocess import webbrowser from pathlib import Path from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QDialog, QLabel, QListWidget, QListWidgetItem, QMenu, QMessageBox, QProgressBar, QPushButton, QTextEdit, QVBoxLayout, QWidget, ) from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog, UpdateDocsThread from development_hub.project_discovery import Project, discover_projects class ProjectListWidget(QWidget): """Widget displaying the list of projects with context menu actions.""" project_selected = Signal(Project) open_terminal_requested = Signal(Project) open_dashboard_requested = Signal(Project) def __init__(self, parent=None): super().__init__(parent) self._projects: list[Project] = [] self._deploy_thread: DeployDocsThread | None = None self._rebuild_thread: RebuildMainDocsThread | None = None self._update_docs_thread: UpdateDocsThread | None = None self._progress_dialog: QDialog | None = None self._stashed_project: Project | None = None self._update_docs_state: dict | None = None # Stores backup, project, etc during async update self._setup_ui() self.refresh() def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Header header = QLabel("PROJECTS") header.setObjectName("header") layout.addWidget(header) # Project list self.list_widget = QListWidget() self.list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.list_widget.customContextMenuRequested.connect(self._show_context_menu) self.list_widget.itemClicked.connect(self._on_item_clicked) self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) layout.addWidget(self.list_widget) def refresh(self): """Refresh the project list from the build configuration.""" self.list_widget.clear() self._projects = discover_projects() for project in self._projects: item = QListWidgetItem(project.title) item.setData(Qt.ItemDataRole.UserRole, project) # Add tooltip with project info tooltip = f"{project.tagline}\n\nPath: {project.path}" if not project.exists: tooltip += "\n\n(Directory not found)" item.setForeground(Qt.GlobalColor.darkGray) item.setToolTip(tooltip) self.list_widget.addItem(item) def _get_selected_project(self) -> Project | None: """Get the currently selected project.""" item = self.list_widget.currentItem() if item: return item.data(Qt.ItemDataRole.UserRole) return None def _on_item_clicked(self, item: QListWidgetItem): """Handle single click on project.""" project = item.data(Qt.ItemDataRole.UserRole) self.project_selected.emit(project) def _on_item_double_clicked(self, item: QListWidgetItem): """Handle double-click - open project dashboard.""" project = item.data(Qt.ItemDataRole.UserRole) if project.exists: self.open_dashboard_requested.emit(project) def _show_context_menu(self, position): """Show context menu for project actions.""" item = self.list_widget.itemAt(position) if not item: return project = item.data(Qt.ItemDataRole.UserRole) menu = QMenu(self) # Open Dashboard (top item) open_dashboard = QAction("Open Dashboard", self) open_dashboard.triggered.connect(lambda: self.open_dashboard_requested.emit(project)) open_dashboard.setEnabled(project.exists) menu.addAction(open_dashboard) # Open Terminal open_terminal = QAction("Open Terminal", self) open_terminal.triggered.connect(lambda: self.open_terminal_requested.emit(project)) open_terminal.setEnabled(project.exists) menu.addAction(open_terminal) # Open in Editor open_editor = QAction("Open in Editor", self) open_editor.triggered.connect(lambda: self._open_in_editor(project)) open_editor.setEnabled(project.exists) menu.addAction(open_editor) menu.addSeparator() # View on Gitea view_gitea = QAction("View on Gitea", self) view_gitea.triggered.connect(lambda: webbrowser.open(project.gitea_url)) menu.addAction(view_gitea) # View Documentation view_docs = QAction("View Documentation", self) view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url)) menu.addAction(view_docs) menu.addSeparator() # Update Documentation (AI-powered) update_docs = QAction("Update Documentation...", self) update_docs.triggered.connect(lambda: self._update_docs(project)) menu.addAction(update_docs) # Deploy Docs deploy_docs = QAction("Deploy Docs", self) deploy_docs.triggered.connect(lambda: self._deploy_docs(project)) menu.addAction(deploy_docs) # Rebuild Main Docs rebuild_docs = QAction("Rebuild Main Docs", self) rebuild_docs.triggered.connect(self._rebuild_main_docs) menu.addAction(rebuild_docs) menu.exec(self.list_widget.mapToGlobal(position)) def _open_in_editor(self, project: Project): """Open project in the default editor.""" import os import shutil # Try common editors in order of preference editors = [ os.environ.get("EDITOR"), "pycharm", "code", # VS Code "subl", # Sublime Text "gedit", "xdg-open", ] for editor in editors: if editor and shutil.which(editor): subprocess.Popen([editor, str(project.path)]) return def _create_progress_dialog(self, title: str) -> QDialog: """Create a progress dialog with output display.""" dialog = QDialog(self) dialog.setWindowTitle(title) dialog.setMinimumWidth(500) dialog.setMinimumHeight(300) layout = QVBoxLayout(dialog) output_text = QTextEdit() output_text.setReadOnly(True) output_text.setObjectName("output_text") layout.addWidget(output_text) progress_bar = QProgressBar() progress_bar.setRange(0, 0) # Indeterminate progress_bar.setObjectName("progress_bar") layout.addWidget(progress_bar) # OK button (hidden until complete) ok_button = QPushButton("OK") ok_button.setObjectName("ok_button") ok_button.clicked.connect(dialog.accept) ok_button.hide() layout.addWidget(ok_button) return dialog def _deploy_docs(self, project: Project): """Deploy documentation for project asynchronously.""" build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh" if not build_script.exists(): QMessageBox.warning( self, "Deploy Failed", "Build script not found:\n\n" + str(build_script) ) return # Check for uncommitted changes in project repo stashed = False if project.exists: try: result = subprocess.run( ["git", "status", "--porcelain"], cwd=project.path, capture_output=True, text=True, ) has_changes = bool(result.stdout.strip()) if has_changes: # Show dialog asking what to do dialog = QMessageBox(self) dialog.setWindowTitle("Uncommitted Changes") dialog.setText( f"{project.title} has uncommitted changes.\n\n" "The deploy script needs to switch branches, which requires " "a clean working directory." ) dialog.setInformativeText("What would you like to do?") stash_btn = dialog.addButton("Stash Changes", QMessageBox.ButtonRole.AcceptRole) commit_btn = dialog.addButton("Commit Changes", QMessageBox.ButtonRole.AcceptRole) cancel_btn = dialog.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) dialog.setDefaultButton(stash_btn) dialog.exec() clicked = dialog.clickedButton() if clicked == cancel_btn: return if clicked == stash_btn: # Stash changes result = subprocess.run( ["git", "stash", "push", "-m", "Auto-stash before docs deploy"], cwd=project.path, capture_output=True, text=True, ) if result.returncode != 0: QMessageBox.warning( self, "Stash Failed", f"Failed to stash changes:\n\n{result.stderr}" ) return stashed = True elif clicked == commit_btn: # Show commit dialog from PySide6.QtWidgets import QInputDialog message, ok = QInputDialog.getText( self, "Commit Message", "Enter commit message:", text="Update before docs deploy" ) if not ok or not message.strip(): return # Stage all and commit subprocess.run( ["git", "add", "-A"], cwd=project.path, capture_output=True, ) result = subprocess.run( ["git", "commit", "-m", message.strip()], cwd=project.path, capture_output=True, text=True, ) if result.returncode != 0: QMessageBox.warning( self, "Commit Failed", f"Failed to commit changes:\n\n{result.stderr}" ) return except Exception as e: QMessageBox.warning( self, "Git Error", f"Error checking git status:\n\n{e}" ) return # Store stash state for restoration after deploy self._stashed_project = project if stashed else None # Create and show progress dialog self._progress_dialog = self._create_progress_dialog(f"Deploying {project.title} Docs") self._progress_dialog.show() # Start async deploy self._deploy_thread = DeployDocsThread(project.key, project.title, project.docs_url) self._deploy_thread.output.connect(self._on_deploy_output) self._deploy_thread.finished.connect(self._on_deploy_finished) self._deploy_thread.start() def _on_deploy_output(self, line: str): """Handle output from deploy thread.""" if self._progress_dialog: output_text = self._progress_dialog.findChild(QTextEdit, "output_text") if output_text: output_text.append(line) scrollbar = output_text.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) def _on_deploy_finished(self, success: bool, message: str): """Handle deploy completion.""" # Restore stashed changes if any if self._stashed_project: subprocess.run( ["git", "stash", "pop"], cwd=self._stashed_project.path, capture_output=True, ) self._stashed_project = None if self._progress_dialog: # Hide progress bar progress_bar = self._progress_dialog.findChild(QProgressBar, "progress_bar") if progress_bar: progress_bar.hide() # Add result to output output_text = self._progress_dialog.findChild(QTextEdit, "output_text") if output_text: if success: output_text.append(f"\n✓ {message}") else: output_text.append(f"\n✗ {message}") # Change dialog to be closeable self._progress_dialog.setWindowTitle( "Deploy Complete" if success else "Deploy Failed" ) # Show OK button ok_button = self._progress_dialog.findChild(QPushButton, "ok_button") if ok_button: ok_button.show() self._deploy_thread = None def _rebuild_main_docs(self): """Rebuild the main documentation site asynchronously.""" # Create and show progress dialog self._progress_dialog = self._create_progress_dialog("Rebuilding Main Docs") self._progress_dialog.show() # Start async rebuild self._rebuild_thread = RebuildMainDocsThread() self._rebuild_thread.output.connect(self._on_rebuild_output) self._rebuild_thread.finished.connect(self._on_rebuild_finished) self._rebuild_thread.start() def _on_rebuild_output(self, line: str): """Handle output from rebuild thread.""" if self._progress_dialog: output_text = self._progress_dialog.findChild(QTextEdit, "output_text") if output_text: output_text.append(line) scrollbar = output_text.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) def _on_rebuild_finished(self, success: bool, message: str): """Handle rebuild completion.""" if self._progress_dialog: # Hide progress bar progress_bar = self._progress_dialog.findChild(QProgressBar, "progress_bar") if progress_bar: progress_bar.hide() # Add result to output output_text = self._progress_dialog.findChild(QTextEdit, "output_text") if output_text: if success: output_text.append(f"\n✓ {message}") else: output_text.append(f"\n✗ {message}") # Change dialog title self._progress_dialog.setWindowTitle( "Rebuild Complete" if success else "Rebuild Failed" ) # Show OK button ok_button = self._progress_dialog.findChild(QPushButton, "ok_button") if ok_button: ok_button.show() self._rebuild_thread = None def _update_docs(self, project: Project): """Update documentation using CmdForge update-docs tool with preview.""" from PySide6.QtWidgets import QMessageBox, QProgressDialog from PySide6.QtCore import Qt, QTimer from pathlib import Path # Confirm before starting reply = QMessageBox.question( self, "Update Documentation", f"This will use AI to analyze {project.title} and generate updated documentation.\n\n" f"You will be able to review all changes before they are applied.\n\n" f"Continue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes ) if reply != QMessageBox.StandardButton.Yes: return # Determine docs path docs_path = Path.home() / "PycharmProjects" / "project-docs" / "docs" / "projects" / project.key doc_files = ["overview.md", "goals.md", "milestones.md", "todos.md"] # Read existing docs as backup backup = {} for filename in doc_files: file_path = docs_path / filename if file_path.exists(): backup[filename] = file_path.read_text() else: backup[filename] = "" # Store state for callback self._update_docs_state = { 'project': project, 'docs_path': docs_path, 'doc_files': doc_files, 'backup': backup, 'dot_count': 0, } # Show progress dialog with animated dots on separate line base_text = f"Generating documentation for {project.title}" self._progress_dialog = QProgressDialog( f"{base_text}\n.", "Cancel", 0, 0, self ) self._progress_dialog.setWindowTitle("Updating Documentation") self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) self._progress_dialog.setMinimumDuration(0) self._progress_dialog.setMinimumWidth(400) self._progress_dialog.setAutoClose(False) # Don't auto-close self._progress_dialog.setAutoReset(False) # Don't auto-reset # Prevent closing via X button or ESC - only Cancel button works self._progress_dialog.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, False) self._progress_dialog.canceled.connect(self._on_update_docs_canceled) self._progress_dialog.show() # Set up timer for animated dots (on separate line to avoid text shifting) def update_dots(): if self._update_docs_state: self._update_docs_state['dot_count'] = (self._update_docs_state['dot_count'] % 20) + 1 dots = '.' * self._update_docs_state['dot_count'] if self._progress_dialog: self._progress_dialog.setLabelText(f"{base_text}\n{dots}") self._dot_timer = QTimer() self._dot_timer.timeout.connect(update_dots) self._dot_timer.start(250) # Update every 250ms # Start async thread self._update_docs_thread = UpdateDocsThread(project.key) self._update_docs_thread.finished.connect(self._on_update_docs_finished) self._update_docs_thread.start() def _on_update_docs_canceled(self): """Handle user canceling the update docs progress.""" if self._dot_timer: self._dot_timer.stop() if self._update_docs_thread: self._update_docs_thread.terminate() self._update_docs_thread = None # Store backup before clearing state so we can restore if needed if self._update_docs_state: backup = self._update_docs_state.get('backup', {}) docs_path = self._update_docs_state.get('docs_path') # Restore backups since we're canceling if docs_path and backup: from pathlib import Path for filename, content in backup.items(): file_path = Path(docs_path) / filename if content: file_path.write_text(content) self._update_docs_state = None def _on_update_docs_finished(self, success: bool, message: str, detail: str): """Handle update docs thread completion.""" from PySide6.QtWidgets import QMessageBox from pathlib import Path # Stop timer if hasattr(self, '_dot_timer') and self._dot_timer: self._dot_timer.stop() # Close progress dialog - disconnect canceled signal first to prevent it from clearing state if self._progress_dialog: try: self._progress_dialog.canceled.disconnect(self._on_update_docs_canceled) except TypeError: pass # Already disconnected self._progress_dialog.close() self._progress_dialog = None # Check if we still have state (not canceled by user before thread finished) if not self._update_docs_state: return state = self._update_docs_state project = state['project'] docs_path = state['docs_path'] doc_files = state['doc_files'] backup = state['backup'] self._update_docs_state = None self._update_docs_thread = None if not success: if message == "timeout": QMessageBox.warning( self, "Update Timeout", f"Documentation generation timed out. Try running manually:\n\n" f"update-docs --project {project.key}" ) elif message == "not_found": QMessageBox.warning( self, "Tool Not Found", "CmdForge update-docs tool not found.\n\n" "Make sure CmdForge is installed and the update-docs tool exists." ) elif message == "error": QMessageBox.warning( self, "Update Error", f"Error running update-docs:\n\n{detail}" ) else: # Show the actual error output error_text = message if message else "Unknown error (no output)" QMessageBox.warning( self, "Update Failed", f"Failed to generate documentation:\n\n{error_text[:1000]}" ) return # Read the newly generated docs new_docs = {} for filename in doc_files: file_path = docs_path / filename if file_path.exists(): new_docs[filename] = file_path.read_text() else: new_docs[filename] = "" # Build changes dict: filename -> (old_content, new_content) changes = {} for filename in doc_files: changes[filename] = (backup[filename], new_docs.get(filename, "")) # Show preview dialog try: dialog = DocsPreviewDialog(changes, self) dialog.exec() except Exception as e: QMessageBox.critical( self, "Dialog Error", f"Error showing preview dialog:\n\n{e}" ) # Restore backups on error for filename, content in backup.items(): file_path = docs_path / filename if content: file_path.write_text(content) return if dialog.was_accepted(): # Get only the selected (and potentially edited) changes selected_changes = dialog.get_selected_changes() if not selected_changes: QMessageBox.information( self, "No Changes", "No files were selected for update." ) # Restore all backups since nothing was selected for filename, content in backup.items(): file_path = docs_path / filename if content: file_path.write_text(content) return # Write only the selected files (with user's edits if any) for filename, new_content in selected_changes.items(): file_path = docs_path / filename file_path.write_text(new_content) # Restore backups for files that were NOT selected for filename in doc_files: if filename not in selected_changes: file_path = docs_path / filename if backup[filename]: file_path.write_text(backup[filename]) # Ask about deployment deploy_reply = QMessageBox.question( self, "Deploy Documentation", f"Changes have been saved ({len(selected_changes)} file(s) updated).\n\n" f"Would you like to deploy the documentation to Gitea Pages?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if deploy_reply == QMessageBox.StandardButton.Yes: # Use async deploy (reuse existing deploy infrastructure) self._deploy_docs(project) else: QMessageBox.information( self, "Changes Saved", f"Documentation changes have been saved locally ({len(selected_changes)} file(s)).\n\n" "You can deploy later using the 'Deploy Docs' option." ) else: # User rejected - restore backup for filename, content in backup.items(): file_path = docs_path / filename if content: file_path.write_text(content) elif file_path.exists(): # File was created but user rejected - delete it file_path.unlink() QMessageBox.information( self, "Changes Discarded", "Documentation changes have been discarded.\n\n" "Original files have been restored." )