development-hub/src/development_hub/dialogs.py

373 lines
12 KiB
Python

"""Dialogs for Development Hub."""
import json
import subprocess
import shutil
from pathlib import Path
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QProcess
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLineEdit,
QTextEdit,
QPushButton,
QLabel,
QProgressBar,
QMessageBox,
QGroupBox,
)
from development_hub.settings import Settings
class RambleThread(QThread):
"""Thread to run Ramble subprocess."""
finished = pyqtSignal(dict) # Emits parsed result
error = pyqtSignal(str) # Emits error message
def __init__(self, prompt: str, fields: list[str], criteria: dict[str, str] = None):
super().__init__()
self.prompt = prompt
self.fields = fields
self.criteria = criteria or {}
def run(self):
"""Run Ramble and parse output."""
# Check if ramble is available
ramble_path = shutil.which("ramble")
if not ramble_path:
self.error.emit("Ramble is not installed or not in PATH.\nInstall with: pip install ramble")
return
# Build command
cmd = [
ramble_path,
"--prompt", self.prompt,
"--fields", *self.fields,
]
if self.criteria:
cmd.extend(["--criteria", json.dumps(self.criteria)])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
if result.returncode != 0:
# User cancelled or error
if "cancelled" in result.stderr.lower():
self.finished.emit({})
else:
self.error.emit(f"Ramble error: {result.stderr}")
return
# Parse JSON output
try:
data = json.loads(result.stdout)
self.finished.emit(data)
except json.JSONDecodeError:
# Ramble might output the dialog result differently
# Try to find JSON in the output
self.finished.emit({})
except subprocess.TimeoutExpired:
self.error.emit("Ramble timed out after 5 minutes")
except Exception as e:
self.error.emit(f"Error running Ramble: {e}")
class NewProjectThread(QThread):
"""Thread to run new-project script."""
output = pyqtSignal(str)
finished = pyqtSignal(bool, str) # success, message
def __init__(self, name: str, title: str, tagline: str, deploy: bool):
super().__init__()
self.name = name
self.title = title
self.tagline = tagline
self.deploy = deploy
def run(self):
"""Run the new-project script."""
script = Path.home() / "PycharmProjects" / "development-hub" / "bin" / "new-project"
if not script.exists():
self.finished.emit(False, f"Script not found: {script}")
return
cmd = [
str(script),
self.name,
"--title", self.title,
"--tagline", self.tagline,
]
if self.deploy:
cmd.append("--deploy")
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
# Stream output
for line in process.stdout:
self.output.emit(line.rstrip())
process.wait()
if process.returncode == 0:
self.finished.emit(True, f"Project '{self.name}' created successfully!")
else:
self.finished.emit(False, f"Project creation failed with code {process.returncode}")
except Exception as e:
self.finished.emit(False, f"Error: {e}")
class NewProjectDialog(QDialog):
"""Dialog for creating a new project."""
project_created = pyqtSignal(str) # Emits project name
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("New Project")
self.setMinimumWidth(500)
self.settings = Settings()
self._ramble_thread = None
self._create_thread = None
self._setup_ui()
def _setup_ui(self):
"""Set up the dialog UI."""
layout = QVBoxLayout(self)
# Form fields
form_group = QGroupBox("Project Details")
form_layout = QFormLayout(form_group)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("my-project (lowercase, hyphens)")
self.name_input.textChanged.connect(self._validate)
form_layout.addRow("Name:", self.name_input)
self.title_input = QLineEdit()
self.title_input.setPlaceholderText("My Project")
form_layout.addRow("Title:", self.title_input)
self.tagline_input = QLineEdit()
self.tagline_input.setPlaceholderText("A short description of the project")
form_layout.addRow("Tagline:", self.tagline_input)
layout.addWidget(form_group)
# Ramble button
ramble_layout = QHBoxLayout()
ramble_layout.addStretch()
self.ramble_btn = QPushButton("🎤 Ramble...")
self.ramble_btn.setToolTip("Describe your project idea and let AI fill in the fields")
self.ramble_btn.clicked.connect(self._start_ramble)
ramble_layout.addWidget(self.ramble_btn)
layout.addLayout(ramble_layout)
# Output area (hidden initially)
self.output_group = QGroupBox("Output")
output_layout = QVBoxLayout(self.output_group)
self.output_text = QTextEdit()
self.output_text.setReadOnly(True)
self.output_text.setMaximumHeight(150)
output_layout.addWidget(self.output_text)
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 0) # Indeterminate
self.progress_bar.hide()
output_layout.addWidget(self.progress_bar)
self.output_group.hide()
layout.addWidget(self.output_group)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)
self.create_btn = QPushButton("Create Project")
self.create_btn.setDefault(True)
self.create_btn.setEnabled(False)
self.create_btn.clicked.connect(self._create_project)
button_layout.addWidget(self.create_btn)
layout.addLayout(button_layout)
def _validate(self):
"""Validate form and enable/disable create button."""
name = self.name_input.text().strip()
# Basic validation: name must be non-empty and valid
valid = bool(name) and name.replace("-", "").replace("_", "").isalnum()
self.create_btn.setEnabled(valid)
def _start_ramble(self):
"""Launch Ramble to fill in fields."""
self.ramble_btn.setEnabled(False)
self.ramble_btn.setText("🎤 Running Ramble...")
self._ramble_thread = RambleThread(
prompt="Describe the project you want to create. What does it do? What problem does it solve?",
fields=["Name", "Title", "Tagline"],
criteria={
"Name": "lowercase, use hyphens instead of spaces, no special characters",
"Title": "Title case, human readable",
"Tagline": "One sentence description",
}
)
self._ramble_thread.finished.connect(self._on_ramble_finished)
self._ramble_thread.error.connect(self._on_ramble_error)
self._ramble_thread.start()
def _on_ramble_finished(self, result: dict):
"""Handle Ramble completion."""
self.ramble_btn.setEnabled(True)
self.ramble_btn.setText("🎤 Ramble...")
if result and "fields" in result:
fields = result["fields"]
if "Name" in fields:
self.name_input.setText(fields["Name"])
if "Title" in fields:
self.title_input.setText(fields["Title"])
if "Tagline" in fields:
self.tagline_input.setText(fields["Tagline"])
def _on_ramble_error(self, error: str):
"""Handle Ramble error."""
self.ramble_btn.setEnabled(True)
self.ramble_btn.setText("🎤 Ramble...")
QMessageBox.warning(self, "Ramble Error", error)
def _create_project(self):
"""Start project creation."""
name = self.name_input.text().strip()
title = self.title_input.text().strip() or name.replace("-", " ").title()
tagline = self.tagline_input.text().strip() or f"The {title} project"
# Show output area
self.output_group.show()
self.output_text.clear()
self.progress_bar.show()
# Disable inputs
self.name_input.setEnabled(False)
self.title_input.setEnabled(False)
self.tagline_input.setEnabled(False)
self.ramble_btn.setEnabled(False)
self.create_btn.setEnabled(False)
# Start creation thread
self._create_thread = NewProjectThread(
name=name,
title=title,
tagline=tagline,
deploy=self.settings.deploy_docs_after_creation,
)
self._create_thread.output.connect(self._on_create_output)
self._create_thread.finished.connect(self._on_create_finished)
self._create_thread.start()
def _on_create_output(self, line: str):
"""Handle output from creation script."""
self.output_text.append(line)
# Auto-scroll to bottom
scrollbar = self.output_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def _on_create_finished(self, success: bool, message: str):
"""Handle project creation completion."""
self.progress_bar.hide()
if success:
self.output_text.append(f"\n{message}")
self.project_created.emit(self.name_input.text().strip())
# Change cancel to close
self.cancel_btn.setText("Close")
self.create_btn.hide()
else:
self.output_text.append(f"\n{message}")
# Re-enable inputs for retry
self.name_input.setEnabled(True)
self.title_input.setEnabled(True)
self.tagline_input.setEnabled(True)
self.ramble_btn.setEnabled(True)
self.create_btn.setEnabled(True)
class SettingsDialog(QDialog):
"""Dialog for application settings."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Settings")
self.setMinimumWidth(400)
self.settings = Settings()
self._setup_ui()
def _setup_ui(self):
"""Set up the dialog UI."""
layout = QVBoxLayout(self)
# Project creation settings
project_group = QGroupBox("Project Creation")
project_layout = QVBoxLayout(project_group)
from PyQt6.QtWidgets import QCheckBox
self.deploy_checkbox = QCheckBox("Deploy docs after creating new project")
self.deploy_checkbox.setChecked(self.settings.deploy_docs_after_creation)
project_layout.addWidget(self.deploy_checkbox)
layout.addWidget(project_group)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setDefault(True)
save_btn.clicked.connect(self._save)
button_layout.addWidget(save_btn)
layout.addLayout(button_layout)
def _save(self):
"""Save settings and close."""
self.settings.deploy_docs_after_creation = self.deploy_checkbox.isChecked()
self.accept()