feat: Add preview dialog for documentation updates

- New DocsPreviewDialog shows side-by-side comparison before applying changes
- Tabs for each doc file (overview, goals, milestones, todos)
- "Show Differences" toggle marks changed lines with +/- prefixes
- Stats show count of new/modified/unchanged files
- Accept saves changes, Cancel restores original files from backup
- Deployment is now a separate step after accepting changes

This makes update-docs much safer - you can review all AI-generated
content before it overwrites your carefully formatted documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-07 19:33:34 -04:00
parent fe6a4d4429
commit ac24c56534
2 changed files with 425 additions and 28 deletions

View File

@ -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'<span style="color: #888888;">{escaped}</span>')
for line in new_lines[j1:j2]:
escaped = self._escape_html(line)
new_html_parts.append(f'<span style="color: #b0b0b0;">{escaped}</span>')
elif tag == 'delete':
for line in old_lines[i1:i2]:
escaped = self._escape_html(line)
old_html_parts.append(f'<span style="background-color: #4a2020; color: #ff8888;">{escaped}</span>')
elif tag == 'insert':
for line in new_lines[j1:j2]:
escaped = self._escape_html(line)
new_html_parts.append(f'<span style="background-color: #1a3a1a; color: #88ff88;">{escaped}</span>')
elif tag == 'replace':
for line in old_lines[i1:i2]:
escaped = self._escape_html(line)
old_html_parts.append(f'<span style="background-color: #4a3a20; color: #ffcc88;">{escaped}</span>')
for line in new_lines[j1:j2]:
escaped = self._escape_html(line)
new_html_parts.append(f'<span style="background-color: #1a3a3a; color: #88ccff;">{escaped}</span>')
# Convert widgets to show HTML (need to use QTextEdit behavior)
old_html = '<pre style="font-family: monospace; font-size: 12px; margin: 0;">' + ''.join(old_html_parts) + '</pre>'
new_html = '<pre style="font-family: monospace; font-size: 12px; margin: 0;">' + ''.join(new_html_parts) + '</pre>'
# 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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

View File

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