diff --git a/src/development_hub/dialogs.py b/src/development_hub/dialogs.py index ce7d46a..7e7be0f 100644 --- a/src/development_hub/dialogs.py +++ b/src/development_hub/dialogs.py @@ -18,6 +18,7 @@ from PyQt6.QtWidgets import ( QListWidget, QListWidgetItem, QTextEdit, + QPlainTextEdit, QPushButton, QLabel, QProgressBar, @@ -25,6 +26,8 @@ from PyQt6.QtWidgets import ( QGroupBox, QScrollArea, QWidget, + QTabWidget, + QSplitter, ) from development_hub.settings import Settings @@ -815,3 +818,298 @@ class StandupDialog(QDialog): f"Daily progress saved to:\n{path}" ) self.accept() + + +class DocsPreviewDialog(QDialog): + """Dialog for previewing documentation changes before applying them. + + Shows side-by-side comparison of old vs new content for each file, + with optional diff highlighting. + """ + + def __init__(self, changes: dict[str, tuple[str, str]], parent=None): + """Initialize the preview dialog. + + Args: + changes: Dict mapping filename to (old_content, new_content) tuples + parent: Parent widget + """ + super().__init__(parent) + self.changes = changes + self.accepted_changes = False + self._highlight_enabled = False + self._setup_ui() + + def _setup_ui(self): + """Set up the dialog UI.""" + self.setWindowTitle("Review Documentation Changes") + self.resize(1400, 800) + + layout = QVBoxLayout(self) + + # Header with instructions + header = QLabel( + "Review the proposed changes below. Old content is on the left, " + "new content is on the right." + ) + header.setStyleSheet("color: #b0b0b0; font-size: 13px; padding: 8px;") + layout.addWidget(header) + + # Toolbar with options + toolbar = QHBoxLayout() + + self.highlight_btn = QPushButton("Show Differences") + self.highlight_btn.setCheckable(True) + self.highlight_btn.toggled.connect(self._toggle_highlighting) + self.highlight_btn.setStyleSheet(""" + QPushButton { + padding: 6px 12px; + } + QPushButton:checked { + background-color: #3d6a99; + } + """) + toolbar.addWidget(self.highlight_btn) + + toolbar.addStretch() + + # Stats label + self.stats_label = QLabel() + self._update_stats() + toolbar.addWidget(self.stats_label) + + layout.addLayout(toolbar) + + # Tab widget for each file + self.tab_widget = QTabWidget() + self.tab_widget.setStyleSheet(""" + QTabWidget::pane { + border: 1px solid #3d3d3d; + background-color: #1e1e1e; + } + QTabBar::tab { + padding: 8px 16px; + background-color: #2d2d2d; + border: 1px solid #3d3d3d; + margin-right: 2px; + } + QTabBar::tab:selected { + background-color: #3d6a99; + } + """) + + # Create a tab for each file + self._text_widgets = {} # Store references for highlighting + for filename, (old_content, new_content) in self.changes.items(): + tab = self._create_comparison_tab(filename, old_content, new_content) + + # Determine tab label with change indicator + if old_content == new_content: + label = f"{filename} (no changes)" + elif not old_content: + label = f"{filename} (new)" + else: + label = f"{filename} (modified)" + + self.tab_widget.addTab(tab, label) + + layout.addWidget(self.tab_widget) + + # Button row + button_layout = QHBoxLayout() + button_layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + cancel_btn.setStyleSheet("padding: 8px 24px;") + button_layout.addWidget(cancel_btn) + + accept_btn = QPushButton("Accept Changes") + accept_btn.clicked.connect(self._accept_changes) + accept_btn.setStyleSheet(""" + QPushButton { + padding: 8px 24px; + background-color: #2e7d32; + color: white; + font-weight: bold; + } + QPushButton:hover { + background-color: #388e3c; + } + """) + button_layout.addWidget(accept_btn) + + layout.addLayout(button_layout) + + def _create_comparison_tab(self, filename: str, old_content: str, new_content: str) -> QWidget: + """Create a tab with side-by-side comparison.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Splitter for side-by-side + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left side - old content + left_container = QWidget() + left_layout = QVBoxLayout(left_container) + left_layout.setContentsMargins(4, 4, 4, 4) + + left_label = QLabel("Current") + left_label.setStyleSheet("font-weight: bold; color: #888888; padding: 4px;") + left_layout.addWidget(left_label) + + old_text = QPlainTextEdit() + old_text.setPlainText(old_content or "(file does not exist)") + old_text.setReadOnly(True) + old_text.setStyleSheet(""" + QPlainTextEdit { + background-color: #1a1a1a; + color: #b0b0b0; + border: none; + font-family: monospace; + font-size: 12px; + } + """) + left_layout.addWidget(old_text) + + splitter.addWidget(left_container) + + # Right side - new content + right_container = QWidget() + right_layout = QVBoxLayout(right_container) + right_layout.setContentsMargins(4, 4, 4, 4) + + right_label = QLabel("Proposed") + right_label.setStyleSheet("font-weight: bold; color: #4a9eff; padding: 4px;") + right_layout.addWidget(right_label) + + new_text = QPlainTextEdit() + new_text.setPlainText(new_content or "(no content)") + new_text.setReadOnly(True) + new_text.setStyleSheet(""" + QPlainTextEdit { + background-color: #1a1a1a; + color: #e0e0e0; + border: none; + font-family: monospace; + font-size: 12px; + } + """) + right_layout.addWidget(new_text) + + splitter.addWidget(right_container) + + # Equal split + splitter.setSizes([700, 700]) + + layout.addWidget(splitter) + + # Store references for highlighting + self._text_widgets[filename] = (old_text, new_text, old_content, new_content) + + return widget + + def _toggle_highlighting(self, enabled: bool): + """Toggle diff highlighting on/off.""" + self._highlight_enabled = enabled + + for filename, (old_widget, new_widget, old_content, new_content) in self._text_widgets.items(): + if enabled: + self._apply_diff_highlighting(old_widget, new_widget, old_content, new_content) + else: + # Reset to plain text + old_widget.setPlainText(old_content or "(file does not exist)") + new_widget.setPlainText(new_content or "(no content)") + + def _apply_diff_highlighting(self, old_widget: QPlainTextEdit, new_widget: QPlainTextEdit, + old_content: str, new_content: str): + """Apply diff highlighting to show changed lines.""" + import difflib + + old_lines = (old_content or "").splitlines(keepends=True) + new_lines = (new_content or "").splitlines(keepends=True) + + # Use difflib to find differences + matcher = difflib.SequenceMatcher(None, old_lines, new_lines) + + # Build highlighted HTML for old content + old_html_parts = [] + new_html_parts = [] + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == 'equal': + for line in old_lines[i1:i2]: + escaped = self._escape_html(line) + old_html_parts.append(f'{escaped}') + for line in new_lines[j1:j2]: + escaped = self._escape_html(line) + new_html_parts.append(f'{escaped}') + elif tag == 'delete': + for line in old_lines[i1:i2]: + escaped = self._escape_html(line) + old_html_parts.append(f'{escaped}') + elif tag == 'insert': + for line in new_lines[j1:j2]: + escaped = self._escape_html(line) + new_html_parts.append(f'{escaped}') + elif tag == 'replace': + for line in old_lines[i1:i2]: + escaped = self._escape_html(line) + old_html_parts.append(f'{escaped}') + for line in new_lines[j1:j2]: + escaped = self._escape_html(line) + new_html_parts.append(f'{escaped}') + + # Convert widgets to show HTML (need to use QTextEdit behavior) + old_html = '
' + ''.join(old_html_parts) + '
' + new_html = '
' + ''.join(new_html_parts) + '
' + + # QPlainTextEdit doesn't support HTML, so we'll just use colored markers + # Instead, let's add markers to the plain text + old_marked = [] + new_marked = [] + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == 'equal': + old_marked.extend(old_lines[i1:i2]) + new_marked.extend(new_lines[j1:j2]) + elif tag == 'delete': + for line in old_lines[i1:i2]: + old_marked.append(f"- {line.rstrip()}\n") + elif tag == 'insert': + for line in new_lines[j1:j2]: + new_marked.append(f"+ {line.rstrip()}\n") + elif tag == 'replace': + for line in old_lines[i1:i2]: + old_marked.append(f"~ {line.rstrip()}\n") + for line in new_lines[j1:j2]: + new_marked.append(f"~ {line.rstrip()}\n") + + old_widget.setPlainText(''.join(old_marked) if old_marked else "(file does not exist)") + new_widget.setPlainText(''.join(new_marked) if new_marked else "(no content)") + + def _escape_html(self, text: str) -> str: + """Escape HTML special characters.""" + return text.replace('&', '&').replace('<', '<').replace('>', '>') + + def _update_stats(self): + """Update the stats label.""" + total = len(self.changes) + new_files = sum(1 for old, new in self.changes.values() if not old) + modified = sum(1 for old, new in self.changes.values() if old and old != new) + unchanged = sum(1 for old, new in self.changes.values() if old == new) + + self.stats_label.setText( + f"{total} files: {new_files} new, {modified} modified, {unchanged} unchanged" + ) + self.stats_label.setStyleSheet("color: #888888;") + + def _accept_changes(self): + """Mark changes as accepted and close.""" + self.accepted_changes = True + self.accept() + + def was_accepted(self) -> bool: + """Return True if user accepted the changes.""" + return self.accepted_changes diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py index 3af53f3..bfdded0 100644 --- a/src/development_hub/project_list.py +++ b/src/development_hub/project_list.py @@ -19,7 +19,7 @@ from PyQt6.QtWidgets import ( QWidget, ) -from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread +from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog from development_hub.project_discovery import Project, discover_projects @@ -395,70 +395,169 @@ class ProjectListWidget(QWidget): self._rebuild_thread = None def _update_docs(self, project: Project): - """Update documentation using CmdForge update-docs tool.""" - from PyQt6.QtWidgets import QMessageBox + """Update documentation using CmdForge update-docs tool with preview.""" + from PyQt6.QtWidgets import QMessageBox, QProgressDialog + from PyQt6.QtCore import Qt + from pathlib import Path - # Ask if user wants to deploy after updating + # Confirm before starting reply = QMessageBox.question( self, "Update Documentation", - f"This will use AI to analyze {project.title} and update its documentation.\n\n" - f"The update-docs tool will:\n" - f"1. Read the project's code and current docs\n" - f"2. Generate updated overview.md\n" - f"3. Optionally deploy to Gitea Pages\n\n" - f"Deploy after updating?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel, + 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.Cancel: + if reply != QMessageBox.StandardButton.Yes: return - deploy_flag = "true" if reply == QMessageBox.StandardButton.Yes else "false" + # Determine docs path + docs_path = Path.home() / "PycharmProjects" / "project-docs" / "docs" / "projects" / project.key + doc_files = ["overview.md", "goals.md", "milestones.md", "todos.md"] - # Run the update-docs CmdForge tool - # The tool expects input on stdin (can be empty) and --project argument + # 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] = "" + + # Show progress dialog + progress = QProgressDialog( + f"Generating documentation for {project.title}...\n\nThis may take a minute.", + "Cancel", + 0, 0, + self + ) + progress.setWindowTitle("Updating Documentation") + progress.setWindowModality(Qt.WindowModality.WindowModal) + progress.setMinimumDuration(0) + progress.show() + + # Process events to show the dialog + from PyQt6.QtWidgets import QApplication + QApplication.processEvents() + + # Run the update-docs CmdForge tool (don't deploy yet) cmd = [ "python3", "-m", "cmdforge.runner", "update-docs", "--project", project.key, - "--deploy", deploy_flag + "--deploy", "false" ] try: - # Run with empty stdin, capture output result = subprocess.run( cmd, input="", capture_output=True, text=True, - timeout=120 # 2 minute timeout for AI processing + timeout=180 # 3 minute timeout for AI processing ) - if result.returncode == 0: - QMessageBox.information( - self, - "Documentation Updated", - f"Documentation for {project.title} has been updated.\n\n" - f"{result.stdout}" - ) - else: + progress.close() + + if result.returncode != 0: QMessageBox.warning( self, "Update Failed", - f"Failed to update documentation:\n\n{result.stderr or result.stdout}" + f"Failed to generate documentation:\n\n{result.stderr or result.stdout}" ) + return + except subprocess.TimeoutExpired: + progress.close() QMessageBox.warning( self, "Update Timeout", - "Documentation update timed out. Try running manually:\n\n" + "Documentation generation timed out. Try running manually:\n\n" f"update-docs --project {project.key}" ) + return except FileNotFoundError: + progress.close() 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." ) + 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 + dialog = DocsPreviewDialog(changes, self) + dialog.exec() + + if dialog.was_accepted(): + # User accepted - ask about deployment + deploy_reply = QMessageBox.question( + self, + "Deploy Documentation", + f"Changes have been saved.\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: + # Deploy using build script + build_script = Path.home() / "PycharmProjects" / "project-docs" / "scripts" / "build-public-docs.sh" + if build_script.exists(): + try: + subprocess.run( + [str(build_script), project.key, "--deploy"], + check=True, + capture_output=True + ) + QMessageBox.information( + self, + "Deployed", + f"Documentation deployed to:\nhttps://pages.brrd.tech/rob/{project.key}/" + ) + except subprocess.CalledProcessError as e: + QMessageBox.warning( + self, + "Deploy Failed", + f"Failed to deploy documentation:\n\n{e.stderr if e.stderr else str(e)}" + ) + else: + QMessageBox.information( + self, + "Changes Saved", + "Documentation changes have been saved locally.\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." + )