development-hub/src/development_hub/project_list.py

673 lines
25 KiB
Python

"""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."
)