Compare commits
3 Commits
ac24c56534
...
b084e9e30a
| Author | SHA1 | Date |
|---|---|---|
|
|
b084e9e30a | |
|
|
69ec7df308 | |
|
|
5c45f7e3f0 |
|
|
@ -0,0 +1,136 @@
|
|||
Metadata-Version: 2.4
|
||||
Name: development-hub
|
||||
Version: 0.1.0
|
||||
Summary: Central project orchestration GUI for multi-project development
|
||||
License: MIT
|
||||
Requires-Python: >=3.10
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: PyQt6>=6.5.0
|
||||
Requires-Dist: pyte>=0.8.0
|
||||
Requires-Dist: orchestrated-discussions@ file:///home/rob/PycharmProjects/orchestrated-discussions
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=7.0; extra == "dev"
|
||||
Requires-Dist: pytest-qt>=4.0; extra == "dev"
|
||||
|
||||
# Development Hub
|
||||
|
||||
Central orchestration project for managing Rob's multi-project development ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
Development Hub provides:
|
||||
|
||||
- **GUI Application** - PyQt6 workspace with project list, splittable terminal panes, and session persistence
|
||||
- **CLI Tools** - Scripts to create and manage projects with consistent patterns
|
||||
- **Centralized Documentation** - Docusaurus-based docs with Gitea Pages deployment
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Run the GUI
|
||||
|
||||
```bash
|
||||
cd ~/PycharmProjects/development-hub
|
||||
source .venv/bin/activate
|
||||
python -m development_hub
|
||||
```
|
||||
|
||||
### Install Dependencies (first time)
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Create a New Project (CLI)
|
||||
|
||||
```bash
|
||||
# Add to PATH (one-time)
|
||||
export PATH="$PATH:$HOME/PycharmProjects/development-hub/bin"
|
||||
|
||||
# Create a new project
|
||||
new-project my-awesome-tool --title "My Awesome Tool" --tagline "Does awesome things"
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Create a Gitea repository at `gitea.brrd.tech/rob/my-awesome-tool`
|
||||
2. Set up local project at `~/PycharmProjects/my-awesome-tool/`
|
||||
3. Generate starter files (CLAUDE.md, README.md, pyproject.toml, .gitignore)
|
||||
4. Configure documentation symlink and build scripts
|
||||
5. Create initial commit and push
|
||||
|
||||
### View Full Options
|
||||
|
||||
```bash
|
||||
new-project --help
|
||||
```
|
||||
|
||||
## What Gets Created
|
||||
|
||||
For each new project:
|
||||
|
||||
```
|
||||
~/PycharmProjects/my-project/
|
||||
├── src/my_project/ # (you create this)
|
||||
├── tests/ # (you create this)
|
||||
├── docs/ # Symlink to centralized docs
|
||||
├── CLAUDE.md # AI assistant context
|
||||
├── README.md # Project readme
|
||||
├── pyproject.toml # Python packaging
|
||||
└── .gitignore # Standard Python ignores
|
||||
```
|
||||
|
||||
## Documentation System
|
||||
|
||||
All projects use centralized documentation:
|
||||
|
||||
- **Private hub**: `~/PycharmProjects/project-docs/` (view with `npm start`)
|
||||
- **Public sites**: `https://pages.brrd.tech/rob/{project}/`
|
||||
|
||||
The `docs/` symlink in each project points to its section in project-docs.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Gitea Token
|
||||
|
||||
The script needs a Gitea API token. It will prompt you the first time and save it to `~/.config/development-hub/gitea-token`.
|
||||
|
||||
To create manually:
|
||||
1. Go to https://gitea.brrd.tech/user/settings/applications
|
||||
2. Generate token with 'repo' scope
|
||||
3. Set `GITEA_TOKEN` env var or let the script save it
|
||||
|
||||
## GUI Features
|
||||
|
||||
### Project List (Left Panel)
|
||||
- Auto-discovers projects from build configuration
|
||||
- Double-click to open terminal at project root
|
||||
- Right-click context menu:
|
||||
- Open Terminal
|
||||
- Open in Editor
|
||||
- View on Gitea
|
||||
- View Documentation
|
||||
- Deploy Docs
|
||||
|
||||
### Workspace (Right Panel)
|
||||
- Splittable panes (horizontal/vertical)
|
||||
- Each pane has its own tab bar
|
||||
- Full PTY terminals with TUI support (vim, htop, etc.)
|
||||
- Drag & drop files/folders to inject paths
|
||||
- Session persistence - remembers layout on restart
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+Shift+T` | New terminal tab |
|
||||
| `Ctrl+Shift+W` | Close current tab |
|
||||
| `Ctrl+Shift+D` | Split horizontal |
|
||||
| `Ctrl+Shift+E` | Split vertical |
|
||||
| `Ctrl+Alt+Left/Right` | Switch panes |
|
||||
| `Ctrl+B` | Toggle project panel |
|
||||
| `Ctrl+N` | New project dialog |
|
||||
|
||||
## Full Documentation
|
||||
|
||||
https://pages.brrd.tech/rob/development-hub/
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
README.md
|
||||
pyproject.toml
|
||||
src/development_hub/__init__.py
|
||||
src/development_hub/__main__.py
|
||||
src/development_hub/app.py
|
||||
src/development_hub/dialogs.py
|
||||
src/development_hub/main_window.py
|
||||
src/development_hub/project_discovery.py
|
||||
src/development_hub/project_list.py
|
||||
src/development_hub/settings.py
|
||||
src/development_hub/styles.py
|
||||
src/development_hub/terminal_widget.py
|
||||
src/development_hub/workspace.py
|
||||
src/development_hub.egg-info/PKG-INFO
|
||||
src/development_hub.egg-info/SOURCES.txt
|
||||
src/development_hub.egg-info/dependency_links.txt
|
||||
src/development_hub.egg-info/entry_points.txt
|
||||
src/development_hub.egg-info/requires.txt
|
||||
src/development_hub.egg-info/top_level.txt
|
||||
src/development_hub/models/__init__.py
|
||||
src/development_hub/models/goal.py
|
||||
src/development_hub/models/health.py
|
||||
src/development_hub/models/todo.py
|
||||
src/development_hub/parsers/__init__.py
|
||||
src/development_hub/parsers/base.py
|
||||
src/development_hub/parsers/goals_parser.py
|
||||
src/development_hub/parsers/progress_parser.py
|
||||
src/development_hub/parsers/todos_parser.py
|
||||
src/development_hub/services/__init__.py
|
||||
src/development_hub/services/git_service.py
|
||||
src/development_hub/services/health_checker.py
|
||||
src/development_hub/services/progress_writer.py
|
||||
src/development_hub/views/__init__.py
|
||||
src/development_hub/views/dashboard.py
|
||||
src/development_hub/views/global_dashboard.py
|
||||
src/development_hub/widgets/__init__.py
|
||||
src/development_hub/widgets/action_menu.py
|
||||
src/development_hub/widgets/collapsible_section.py
|
||||
src/development_hub/widgets/health_card.py
|
||||
src/development_hub/widgets/progress_bar.py
|
||||
src/development_hub/widgets/stat_card.py
|
||||
src/development_hub/widgets/toast.py
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
[console_scripts]
|
||||
development-hub = development_hub.app:main
|
||||
|
||||
[gui_scripts]
|
||||
development-hub-gui = development_hub.app:main
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
PyQt6>=6.5.0
|
||||
pyte>=0.8.0
|
||||
orchestrated-discussions@ file:///home/rob/PycharmProjects/orchestrated-discussions
|
||||
|
||||
[dev]
|
||||
pytest>=7.0
|
||||
pytest-qt>=4.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
development_hub
|
||||
|
|
@ -195,6 +195,45 @@ class DeployDocsThread(QThread):
|
|||
self.finished.emit(False, f"Error: {e}")
|
||||
|
||||
|
||||
class UpdateDocsThread(QThread):
|
||||
"""Thread to run update-docs CmdForge tool."""
|
||||
|
||||
finished = pyqtSignal(bool, str, str) # success, message, stderr
|
||||
|
||||
def __init__(self, project_key: str):
|
||||
super().__init__()
|
||||
self.project_key = project_key
|
||||
|
||||
def run(self):
|
||||
"""Run the update-docs tool."""
|
||||
cmd = [
|
||||
"python3", "-m", "cmdforge.runner", "update-docs",
|
||||
"--project", self.project_key,
|
||||
"--deploy", "false"
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180 # 3 minute timeout for AI processing
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.finished.emit(True, "Documentation generated successfully", "")
|
||||
else:
|
||||
self.finished.emit(False, result.stderr or result.stdout, "")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.finished.emit(False, "timeout", "Documentation generation timed out")
|
||||
except FileNotFoundError:
|
||||
self.finished.emit(False, "not_found", "CmdForge update-docs tool not found")
|
||||
except Exception as e:
|
||||
self.finished.emit(False, "error", str(e))
|
||||
|
||||
|
||||
class RebuildMainDocsThread(QThread):
|
||||
"""Thread to refresh the main documentation site."""
|
||||
|
||||
|
|
@ -824,7 +863,7 @@ 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.
|
||||
with optional diff highlighting. Allows editing proposed content.
|
||||
"""
|
||||
|
||||
def __init__(self, changes: dict[str, tuple[str, str]], parent=None):
|
||||
|
|
@ -838,6 +877,8 @@ class DocsPreviewDialog(QDialog):
|
|||
self.changes = changes
|
||||
self.accepted_changes = False
|
||||
self._highlight_enabled = False
|
||||
self._file_checkboxes = {} # filename -> QCheckBox
|
||||
self._text_widgets = {} # filename -> (old_widget, new_widget, old_content)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
|
|
@ -850,7 +891,7 @@ class DocsPreviewDialog(QDialog):
|
|||
# Header with instructions
|
||||
header = QLabel(
|
||||
"Review the proposed changes below. Old content is on the left, "
|
||||
"new content is on the right."
|
||||
"new content is on the right. You can edit the proposed content."
|
||||
)
|
||||
header.setStyleSheet("color: #b0b0b0; font-size: 13px; padding: 8px;")
|
||||
layout.addWidget(header)
|
||||
|
|
@ -858,26 +899,38 @@ class DocsPreviewDialog(QDialog):
|
|||
# Toolbar with options
|
||||
toolbar = QHBoxLayout()
|
||||
|
||||
self.highlight_btn = QPushButton("Show Differences")
|
||||
# Stats label on the left
|
||||
self.stats_label = QLabel()
|
||||
self._update_stats()
|
||||
toolbar.addWidget(self.stats_label)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
# Toggle button on the right - more visible toggle styling
|
||||
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;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #555555;
|
||||
border-radius: 4px;
|
||||
background-color: #2d2d2d;
|
||||
color: #aaaaaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
border-color: #777777;
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #3d6a99;
|
||||
background-color: #2e5d2e;
|
||||
border-color: #4a9e4a;
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
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
|
||||
|
|
@ -899,7 +952,6 @@ class DocsPreviewDialog(QDialog):
|
|||
""")
|
||||
|
||||
# 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)
|
||||
|
||||
|
|
@ -924,9 +976,9 @@ class DocsPreviewDialog(QDialog):
|
|||
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("""
|
||||
self.accept_btn = QPushButton("Accept Selected Changes")
|
||||
self.accept_btn.clicked.connect(self._accept_changes)
|
||||
self.accept_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
padding: 8px 24px;
|
||||
background-color: #2e7d32;
|
||||
|
|
@ -936,8 +988,12 @@ class DocsPreviewDialog(QDialog):
|
|||
QPushButton:hover {
|
||||
background-color: #388e3c;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #555555;
|
||||
color: #888888;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(accept_btn)
|
||||
button_layout.addWidget(self.accept_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
|
@ -945,12 +1001,23 @@ class DocsPreviewDialog(QDialog):
|
|||
"""Create a tab with side-by-side comparison."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
# Checkbox row at top
|
||||
checkbox_row = QHBoxLayout()
|
||||
checkbox = QCheckBox(f"Include {filename} in changes")
|
||||
checkbox.setChecked(True)
|
||||
checkbox.setStyleSheet("color: #e0e0e0; font-size: 13px;")
|
||||
checkbox.stateChanged.connect(self._update_accept_button)
|
||||
self._file_checkboxes[filename] = checkbox
|
||||
checkbox_row.addWidget(checkbox)
|
||||
checkbox_row.addStretch()
|
||||
layout.addLayout(checkbox_row)
|
||||
|
||||
# Splitter for side-by-side
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# Left side - old content
|
||||
# Left side - old content (read-only)
|
||||
left_container = QWidget()
|
||||
left_layout = QVBoxLayout(left_container)
|
||||
left_layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
|
@ -959,11 +1026,11 @@ class DocsPreviewDialog(QDialog):
|
|||
left_label.setStyleSheet("font-weight: bold; color: #888888; padding: 4px;")
|
||||
left_layout.addWidget(left_label)
|
||||
|
||||
old_text = QPlainTextEdit()
|
||||
old_text = QTextEdit()
|
||||
old_text.setPlainText(old_content or "(file does not exist)")
|
||||
old_text.setReadOnly(True)
|
||||
old_text.setStyleSheet("""
|
||||
QPlainTextEdit {
|
||||
QTextEdit {
|
||||
background-color: #1a1a1a;
|
||||
color: #b0b0b0;
|
||||
border: none;
|
||||
|
|
@ -975,26 +1042,29 @@ class DocsPreviewDialog(QDialog):
|
|||
|
||||
splitter.addWidget(left_container)
|
||||
|
||||
# Right side - new content
|
||||
# Right side - new content (editable)
|
||||
right_container = QWidget()
|
||||
right_layout = QVBoxLayout(right_container)
|
||||
right_layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
right_label = QLabel("Proposed")
|
||||
right_label = QLabel("Proposed (editable)")
|
||||
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 = QTextEdit()
|
||||
new_text.setPlainText(new_content or "")
|
||||
new_text.setReadOnly(False) # Editable!
|
||||
new_text.setStyleSheet("""
|
||||
QPlainTextEdit {
|
||||
QTextEdit {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
border: 1px solid #3d3d3d;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
QTextEdit:focus {
|
||||
border: 1px solid #4a9eff;
|
||||
}
|
||||
""")
|
||||
right_layout.addWidget(new_text)
|
||||
|
||||
|
|
@ -1005,8 +1075,8 @@ class DocsPreviewDialog(QDialog):
|
|||
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Store references for highlighting
|
||||
self._text_widgets[filename] = (old_text, new_text, old_content, new_content)
|
||||
# Store references for highlighting and getting edited content
|
||||
self._text_widgets[filename] = (old_text, new_text, old_content)
|
||||
|
||||
return widget
|
||||
|
||||
|
|
@ -1014,17 +1084,28 @@ class DocsPreviewDialog(QDialog):
|
|||
"""Toggle diff highlighting on/off."""
|
||||
self._highlight_enabled = enabled
|
||||
|
||||
for filename, (old_widget, new_widget, old_content, new_content) in self._text_widgets.items():
|
||||
# Update button text to show state
|
||||
if enabled:
|
||||
self._apply_diff_highlighting(old_widget, new_widget, old_content, new_content)
|
||||
self.highlight_btn.setText("[x] Show Differences")
|
||||
else:
|
||||
# Reset to plain text
|
||||
old_widget.setPlainText(old_content or "(file does not exist)")
|
||||
new_widget.setPlainText(new_content or "(no content)")
|
||||
self.highlight_btn.setText("[ ] Show Differences")
|
||||
|
||||
def _apply_diff_highlighting(self, old_widget: QPlainTextEdit, new_widget: QPlainTextEdit,
|
||||
old_content: str, new_content: str):
|
||||
"""Apply diff highlighting to show changed lines."""
|
||||
for filename, (old_widget, new_widget, old_content) in self._text_widgets.items():
|
||||
# Get current new content (may have been edited)
|
||||
new_content = new_widget.toPlainText()
|
||||
|
||||
if enabled:
|
||||
self._apply_diff_highlighting(old_widget, old_content, new_content)
|
||||
else:
|
||||
# Reset to plain text - clear any HTML formatting
|
||||
old_widget.setPlainText(old_content or "(file does not exist)")
|
||||
|
||||
def _apply_diff_highlighting(self, old_widget: QTextEdit, old_content: str, new_content: str):
|
||||
"""Apply diff highlighting with colored backgrounds to the old (left) widget.
|
||||
|
||||
Shows deleted lines in red and modified lines in orange on the left side.
|
||||
The right side stays plain text to remain editable.
|
||||
"""
|
||||
import difflib
|
||||
|
||||
old_lines = (old_content or "").splitlines(keepends=True)
|
||||
|
|
@ -1034,64 +1115,33 @@ class DocsPreviewDialog(QDialog):
|
|||
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||
|
||||
# Build highlighted HTML for old content
|
||||
old_html_parts = []
|
||||
new_html_parts = []
|
||||
old_html_parts = ['<pre style="font-family: monospace; font-size: 12px; margin: 0; white-space: pre-wrap;">']
|
||||
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == 'equal':
|
||||
# Unchanged lines - normal gray
|
||||
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':
|
||||
# Lines that will be removed - red background
|
||||
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':
|
||||
# Lines that will be changed - orange background
|
||||
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>')
|
||||
# 'insert' tag has no corresponding old lines, so nothing to highlight on left
|
||||
|
||||
# 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>'
|
||||
old_html_parts.append('</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)")
|
||||
old_widget.setHtml(''.join(old_html_parts))
|
||||
|
||||
def _escape_html(self, text: str) -> str:
|
||||
"""Escape HTML special characters."""
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('\n', '<br>')
|
||||
|
||||
def _update_stats(self):
|
||||
"""Update the stats label."""
|
||||
|
|
@ -1105,6 +1155,11 @@ class DocsPreviewDialog(QDialog):
|
|||
)
|
||||
self.stats_label.setStyleSheet("color: #888888;")
|
||||
|
||||
def _update_accept_button(self):
|
||||
"""Enable/disable accept button based on checkbox state."""
|
||||
any_checked = any(cb.isChecked() for cb in self._file_checkboxes.values())
|
||||
self.accept_btn.setEnabled(any_checked)
|
||||
|
||||
def _accept_changes(self):
|
||||
"""Mark changes as accepted and close."""
|
||||
self.accepted_changes = True
|
||||
|
|
@ -1113,3 +1168,17 @@ class DocsPreviewDialog(QDialog):
|
|||
def was_accepted(self) -> bool:
|
||||
"""Return True if user accepted the changes."""
|
||||
return self.accepted_changes
|
||||
|
||||
def get_selected_changes(self) -> dict[str, str]:
|
||||
"""Get the changes that were selected (checked) and potentially edited.
|
||||
|
||||
Returns:
|
||||
Dict mapping filename to new content (edited version from right panel)
|
||||
"""
|
||||
result = {}
|
||||
for filename, checkbox in self._file_checkboxes.items():
|
||||
if checkbox.isChecked():
|
||||
# Get the (possibly edited) content from the right panel
|
||||
_, new_widget, _ = self._text_widgets[filename]
|
||||
result[filename] = new_widget.toPlainText()
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class Milestone:
|
|||
progress: int = 0 # 0-100
|
||||
deliverables: list[Deliverable] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
description: str = "" # Free-form description text
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
|
|
@ -80,9 +81,16 @@ class Milestone:
|
|||
|
||||
@dataclass
|
||||
class Goal:
|
||||
"""A goal item with priority and status."""
|
||||
"""A goal item with priority and status.
|
||||
|
||||
Goals support three states:
|
||||
- Not achieved: completed=False, partial=False
|
||||
- Partially achieved: completed=False, partial=True
|
||||
- Achieved: completed=True, partial=False
|
||||
"""
|
||||
text: str
|
||||
completed: bool = False
|
||||
partial: bool = False # Partially achieved (orange with half-moon)
|
||||
priority: str = "medium" # "high", "medium", "low"
|
||||
tags: list[str] = field(default_factory=list)
|
||||
project: str | None = None
|
||||
|
|
@ -99,7 +107,7 @@ class GoalList:
|
|||
"""Collection of goals organized by status."""
|
||||
active: list[Goal] = field(default_factory=list)
|
||||
future: list[Goal] = field(default_factory=list)
|
||||
non_goals: list[str] = field(default_factory=list)
|
||||
non_goals: list[Goal] = field(default_factory=list)
|
||||
project: str | None = None
|
||||
updated: str | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ class Todo:
|
|||
parts.append(f"@{self.project}")
|
||||
for tag in self.tags:
|
||||
parts.append(f"#{tag}")
|
||||
# For completed items, add priority tag so it's preserved when unchecking
|
||||
if self.completed and self.priority in ("high", "medium", "low"):
|
||||
parts.append(f"#{self.priority}")
|
||||
if self.completed_date:
|
||||
parts.append(f"({self.completed_date})")
|
||||
if self.blocker_reason:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,45 @@
|
|||
"""Base parser class with common utilities."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: str) -> None:
|
||||
"""Write content to file atomically.
|
||||
|
||||
Writes to a temp file first, then renames to target.
|
||||
This prevents data loss if write is interrupted.
|
||||
|
||||
Args:
|
||||
path: Target file path
|
||||
content: Content to write
|
||||
"""
|
||||
# Ensure parent directory exists
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to temp file in same directory (for atomic rename)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(content)
|
||||
# Atomic rename
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
# Clean up temp file on failure
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
||||
"""Parse YAML frontmatter from markdown content.
|
||||
|
||||
|
|
@ -127,6 +162,29 @@ class BaseParser:
|
|||
return checked, text
|
||||
return False, line.strip()
|
||||
|
||||
@staticmethod
|
||||
def parse_checkbox_triple(line: str) -> tuple[bool, bool, str]:
|
||||
"""Parse a checkbox line with three states.
|
||||
|
||||
Args:
|
||||
line: Line like "- [ ] Task", "- [~] Partial", or "- [x] Done task"
|
||||
|
||||
Returns:
|
||||
Tuple of (is_completed, is_partial, task_text)
|
||||
- [ ] -> (False, False, text)
|
||||
- [~] -> (False, True, text)
|
||||
- [x] -> (True, False, text)
|
||||
"""
|
||||
# Match checkbox pattern with three states
|
||||
match = re.match(r"^-\s*\[([ xX~])\]\s*(.+)$", line.strip())
|
||||
if match:
|
||||
marker = match.group(1).lower()
|
||||
text = match.group(2).strip()
|
||||
completed = marker == "x"
|
||||
partial = marker == "~"
|
||||
return completed, partial, text
|
||||
return False, False, line.strip()
|
||||
|
||||
@staticmethod
|
||||
def extract_milestone_tag(text: str) -> tuple[str | None, str]:
|
||||
"""Extract @M1, @M2, etc. milestone tag from text.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from development_hub.parsers.base import BaseParser
|
||||
from development_hub.parsers.base import BaseParser, atomic_write
|
||||
from development_hub.models.goal import (
|
||||
Goal,
|
||||
GoalList,
|
||||
|
|
@ -63,27 +63,35 @@ class GoalsParser(BaseParser):
|
|||
current_section = "non-goals"
|
||||
continue
|
||||
|
||||
# Handle checkbox items (Active and Future)
|
||||
if line_stripped.startswith("- [") and current_section in ("active", "future"):
|
||||
# Handle checkbox items (Active, Future, and Non-Goals)
|
||||
if line_stripped.startswith("- ["):
|
||||
goal = self._parse_goal_line(line_stripped)
|
||||
if goal:
|
||||
if current_section == "active":
|
||||
goal_list.active.append(goal)
|
||||
else:
|
||||
elif current_section == "future":
|
||||
goal_list.future.append(goal)
|
||||
continue
|
||||
|
||||
# Handle non-goals (simple bullets)
|
||||
if current_section == "non-goals" and line_stripped.startswith("- "):
|
||||
else: # non-goals
|
||||
goal_list.non_goals.append(goal)
|
||||
# Handle plain bullet items in non-goals section (backwards compatibility)
|
||||
elif current_section == "non-goals" and line_stripped.startswith("- "):
|
||||
text = line_stripped[2:].strip()
|
||||
if text:
|
||||
goal_list.non_goals.append(text)
|
||||
# Extract priority from hashtags
|
||||
priority = "medium"
|
||||
tags, text = self.extract_hashtags(text)
|
||||
for tag in tags:
|
||||
if tag in ("high", "medium", "low"):
|
||||
priority = tag
|
||||
break
|
||||
goal = Goal(text=text, completed=False, priority=priority, tags=tags)
|
||||
goal_list.non_goals.append(goal)
|
||||
|
||||
return goal_list
|
||||
|
||||
def _parse_goal_line(self, line: str) -> Goal | None:
|
||||
"""Parse a single goal line."""
|
||||
completed, text = self.parse_checkbox(line)
|
||||
"""Parse a single goal line with three states."""
|
||||
completed, partial, text = self.parse_checkbox_triple(line)
|
||||
|
||||
if not text:
|
||||
return None
|
||||
|
|
@ -103,6 +111,7 @@ class GoalsParser(BaseParser):
|
|||
return Goal(
|
||||
text=text,
|
||||
completed=completed,
|
||||
partial=partial,
|
||||
priority=priority,
|
||||
tags=tags,
|
||||
completed_date=date,
|
||||
|
|
@ -115,14 +124,18 @@ class MilestonesParser(BaseParser):
|
|||
def parse(self) -> list[Milestone]:
|
||||
"""Parse milestones.md file.
|
||||
|
||||
Expected format:
|
||||
Expected format (flat list, no section headers):
|
||||
#### M1: Milestone Name
|
||||
**Target**: January 2026
|
||||
**Status**: In Progress (80%)
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Item 1 | Done |
|
||||
Description text here...
|
||||
|
||||
---
|
||||
|
||||
#### M2: Another Milestone
|
||||
**Target**: February 2026
|
||||
**Status**: Completed (100%)
|
||||
|
||||
Returns:
|
||||
List of Milestone instances
|
||||
|
|
@ -132,32 +145,50 @@ class MilestonesParser(BaseParser):
|
|||
if not self.body:
|
||||
return milestones
|
||||
|
||||
# Split by milestone headers (#### M#:)
|
||||
milestone_pattern = r"####\s+(M\d+):\s*(.+)"
|
||||
parts = re.split(f"({milestone_pattern})", self.body)
|
||||
milestone_pattern = r"####\s+(M[\d.]+):\s*(.+)"
|
||||
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
part = parts[i]
|
||||
lines = self.body.split("\n")
|
||||
current_milestone_lines = []
|
||||
current_milestone_id = None
|
||||
current_milestone_name = None
|
||||
|
||||
# Check for milestone header match
|
||||
match = re.match(milestone_pattern, part)
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Skip section headers (legacy support)
|
||||
if line_stripped.startswith("## "):
|
||||
continue
|
||||
|
||||
# Check for milestone header
|
||||
match = re.match(milestone_pattern, line_stripped)
|
||||
if match:
|
||||
milestone_id = match.group(1)
|
||||
milestone_name = match.group(2).strip()
|
||||
|
||||
# Get content until next milestone or section
|
||||
content = ""
|
||||
if i + 1 < len(parts):
|
||||
content = parts[i + 1]
|
||||
|
||||
# Save previous milestone if exists
|
||||
if current_milestone_id:
|
||||
milestone = self._parse_milestone_content(
|
||||
milestone_id, milestone_name, content
|
||||
current_milestone_id,
|
||||
current_milestone_name,
|
||||
"\n".join(current_milestone_lines),
|
||||
)
|
||||
if milestone:
|
||||
milestones.append(milestone)
|
||||
|
||||
i += 1
|
||||
# Start new milestone
|
||||
current_milestone_id = match.group(1)
|
||||
current_milestone_name = match.group(2).strip()
|
||||
current_milestone_lines = []
|
||||
elif current_milestone_id:
|
||||
# Accumulate lines for current milestone
|
||||
current_milestone_lines.append(line)
|
||||
|
||||
# Don't forget the last milestone
|
||||
if current_milestone_id:
|
||||
milestone = self._parse_milestone_content(
|
||||
current_milestone_id,
|
||||
current_milestone_name,
|
||||
"\n".join(current_milestone_lines),
|
||||
)
|
||||
if milestone:
|
||||
milestones.append(milestone)
|
||||
|
||||
return milestones
|
||||
|
||||
|
|
@ -179,6 +210,7 @@ class MilestonesParser(BaseParser):
|
|||
progress = 0
|
||||
deliverables = []
|
||||
notes = ""
|
||||
description_lines = []
|
||||
|
||||
lines = content.split("\n")
|
||||
table_lines = []
|
||||
|
|
@ -187,6 +219,10 @@ class MilestonesParser(BaseParser):
|
|||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Skip separators
|
||||
if line_stripped == "---":
|
||||
continue
|
||||
|
||||
# Parse **Target**: value
|
||||
target_match = re.match(r"\*\*Target\*\*:\s*(.+)", line_stripped)
|
||||
if target_match:
|
||||
|
|
@ -210,15 +246,16 @@ class MilestonesParser(BaseParser):
|
|||
if line_stripped.startswith("|"):
|
||||
in_table = True
|
||||
table_lines.append(line_stripped)
|
||||
continue
|
||||
elif in_table and not line_stripped.startswith("|"):
|
||||
# Table ended
|
||||
deliverables = self._parse_deliverables_table(table_lines)
|
||||
table_lines = []
|
||||
in_table = False
|
||||
|
||||
# Stop at next section marker
|
||||
if line_stripped.startswith("---") or line_stripped.startswith("## "):
|
||||
break
|
||||
# Collect description lines (non-empty, non-field lines)
|
||||
if line_stripped and not in_table:
|
||||
description_lines.append(line_stripped)
|
||||
|
||||
# Handle any remaining table
|
||||
if table_lines:
|
||||
|
|
@ -232,6 +269,7 @@ class MilestonesParser(BaseParser):
|
|||
progress=progress,
|
||||
deliverables=deliverables,
|
||||
notes=notes,
|
||||
description=" ".join(description_lines),
|
||||
)
|
||||
|
||||
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
||||
|
|
@ -323,21 +361,18 @@ class MilestonesParser(BaseParser):
|
|||
# Write active milestones
|
||||
lines.append("## Active")
|
||||
lines.append("")
|
||||
|
||||
for milestone in active:
|
||||
lines.extend(self._format_milestone(milestone))
|
||||
lines.append("")
|
||||
|
||||
# Write completed milestones
|
||||
if completed:
|
||||
lines.append("## Completed")
|
||||
lines.append("")
|
||||
|
||||
for milestone in completed:
|
||||
lines.extend(self._format_milestone(milestone))
|
||||
lines.append("")
|
||||
|
||||
self.path.write_text("\n".join(lines))
|
||||
atomic_write(self.file_path, "\n".join(lines))
|
||||
|
||||
def _format_milestone(self, milestone: Milestone) -> list[str]:
|
||||
"""Format a single milestone as markdown lines.
|
||||
|
|
@ -360,7 +395,7 @@ class MilestonesParser(BaseParser):
|
|||
# Status with progress
|
||||
progress = milestone.calculate_progress()
|
||||
if milestone.status == MilestoneStatus.COMPLETE:
|
||||
lines.append("**Status**: Complete")
|
||||
lines.append(f"**Status**: Completed ({progress}%)")
|
||||
elif milestone.status == MilestoneStatus.IN_PROGRESS:
|
||||
lines.append(f"**Status**: In Progress ({progress}%)")
|
||||
elif milestone.status == MilestoneStatus.PLANNING:
|
||||
|
|
@ -372,10 +407,14 @@ class MilestonesParser(BaseParser):
|
|||
if milestone.notes:
|
||||
lines.append(f"**Notes**: {milestone.notes}")
|
||||
|
||||
# Description (after fields, before table)
|
||||
if milestone.description:
|
||||
lines.append("")
|
||||
lines.append(milestone.description)
|
||||
|
||||
# Deliverables table
|
||||
if milestone.deliverables:
|
||||
lines.append("")
|
||||
lines.append("| Deliverable | Status |")
|
||||
lines.append("|-------------|--------|")
|
||||
|
||||
|
|
@ -389,7 +428,12 @@ class MilestonesParser(BaseParser):
|
|||
|
||||
|
||||
class GoalsSaver:
|
||||
"""Save goals back to goals.md file."""
|
||||
"""Save goals back to goals.md file.
|
||||
|
||||
This saver preserves prose content (Vision, Principles, etc.) that appears
|
||||
before the goal sections. Only the ## Active, ## Future, and ## Non-Goals
|
||||
sections are rewritten with updated checkbox states.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, frontmatter: dict):
|
||||
"""Initialize saver.
|
||||
|
|
@ -402,11 +446,19 @@ class GoalsSaver:
|
|||
self.frontmatter = frontmatter
|
||||
|
||||
def save(self, goal_list: GoalList):
|
||||
"""Save goals back to file.
|
||||
"""Save goals back to file, preserving prose content.
|
||||
|
||||
Args:
|
||||
goal_list: GoalList to save
|
||||
"""
|
||||
# Read existing file to preserve prose content
|
||||
existing_content = ""
|
||||
if self.path.exists():
|
||||
existing_content = self.path.read_text()
|
||||
|
||||
# Extract prose content (everything before first goal section)
|
||||
prose_content = self._extract_prose_content(existing_content)
|
||||
|
||||
lines = []
|
||||
|
||||
# Write frontmatter
|
||||
|
|
@ -415,14 +467,19 @@ class GoalsSaver:
|
|||
lines.append(f"{key}: {value}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("# Goals")
|
||||
|
||||
# Add preserved prose content (without frontmatter, it's already added)
|
||||
if prose_content:
|
||||
lines.append(prose_content)
|
||||
# Ensure there's a blank line before goal sections
|
||||
if not prose_content.endswith("\n\n"):
|
||||
lines.append("")
|
||||
|
||||
# Active goals
|
||||
lines.append("## Active")
|
||||
lines.append("")
|
||||
for goal in goal_list.active:
|
||||
checkbox = "[x]" if goal.completed else "[ ]"
|
||||
checkbox = self._get_checkbox(goal)
|
||||
priority_tag = f" #{goal.priority}" if goal.priority else ""
|
||||
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
|
||||
lines.append("")
|
||||
|
|
@ -432,16 +489,83 @@ class GoalsSaver:
|
|||
lines.append("## Future")
|
||||
lines.append("")
|
||||
for goal in goal_list.future:
|
||||
checkbox = "[x]" if goal.completed else "[ ]"
|
||||
lines.append(f"- {checkbox} {goal.text}")
|
||||
checkbox = self._get_checkbox(goal)
|
||||
priority_tag = f" #{goal.priority}" if goal.priority else ""
|
||||
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
|
||||
lines.append("")
|
||||
|
||||
# Non-goals
|
||||
# Non-goals (also with checkboxes and priority)
|
||||
if goal_list.non_goals:
|
||||
lines.append("## Non-Goals")
|
||||
lines.append("")
|
||||
for non_goal in goal_list.non_goals:
|
||||
lines.append(f"- {non_goal}")
|
||||
for goal in goal_list.non_goals:
|
||||
checkbox = self._get_checkbox(goal)
|
||||
priority_tag = f" #{goal.priority}" if goal.priority else ""
|
||||
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
|
||||
lines.append("")
|
||||
|
||||
self.path.write_text("\n".join(lines))
|
||||
atomic_write(self.path, "\n".join(lines))
|
||||
|
||||
def _extract_prose_content(self, content: str) -> str:
|
||||
"""Extract prose content before goal sections.
|
||||
|
||||
Preserves everything between the frontmatter and the first goal section
|
||||
(## Active, ## Future, or ## Non-Goals).
|
||||
|
||||
Args:
|
||||
content: Full file content
|
||||
|
||||
Returns:
|
||||
Prose content without frontmatter, or empty string
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
# Remove frontmatter
|
||||
lines = content.split("\n")
|
||||
start_idx = 0
|
||||
|
||||
# Skip frontmatter (between --- markers)
|
||||
if lines and lines[0].strip() == "---":
|
||||
for i, line in enumerate(lines[1:], 1):
|
||||
if line.strip() == "---":
|
||||
start_idx = i + 1
|
||||
break
|
||||
|
||||
# Find where goal sections start
|
||||
goal_section_headers = ["## active", "## future", "## non-goal", "## non goal"]
|
||||
end_idx = len(lines)
|
||||
|
||||
for i, line in enumerate(lines[start_idx:], start_idx):
|
||||
line_lower = line.strip().lower()
|
||||
if any(line_lower.startswith(header) for header in goal_section_headers):
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
# Extract prose content
|
||||
prose_lines = lines[start_idx:end_idx]
|
||||
|
||||
# Strip leading/trailing empty lines but preserve internal structure
|
||||
while prose_lines and not prose_lines[0].strip():
|
||||
prose_lines.pop(0)
|
||||
while prose_lines and not prose_lines[-1].strip():
|
||||
prose_lines.pop()
|
||||
|
||||
return "\n".join(prose_lines)
|
||||
|
||||
@staticmethod
|
||||
def _get_checkbox(goal: Goal) -> str:
|
||||
"""Get the checkbox marker for a goal's state.
|
||||
|
||||
Args:
|
||||
goal: Goal to get checkbox for
|
||||
|
||||
Returns:
|
||||
"[x]" for completed, "[~]" for partial, "[ ]" for not achieved
|
||||
"""
|
||||
if goal.completed:
|
||||
return "[x]"
|
||||
elif getattr(goal, 'partial', False):
|
||||
return "[~]"
|
||||
else:
|
||||
return "[ ]"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from development_hub.parsers.base import BaseParser
|
||||
from development_hub.parsers.base import BaseParser, atomic_write
|
||||
from development_hub.models.todo import Todo, TodoList
|
||||
|
||||
|
||||
|
|
@ -134,10 +134,23 @@ class TodosParser(BaseParser):
|
|||
if section == "completed":
|
||||
completed = True
|
||||
|
||||
# For completed items, check if priority is stored in tags
|
||||
# This preserves priority when items are unchecked
|
||||
effective_priority = priority
|
||||
if section == "completed":
|
||||
for tag in tags:
|
||||
if tag in ("high", "medium", "low"):
|
||||
effective_priority = tag
|
||||
tags.remove(tag)
|
||||
break
|
||||
else:
|
||||
# Default to medium if no priority tag found
|
||||
effective_priority = "medium"
|
||||
|
||||
todo = Todo(
|
||||
text=text,
|
||||
completed=completed,
|
||||
priority=priority if not completed else "completed",
|
||||
priority=effective_priority,
|
||||
project=project,
|
||||
milestone=milestone,
|
||||
tags=tags,
|
||||
|
|
@ -262,4 +275,4 @@ class TodosParser(BaseParser):
|
|||
todo_list: TodoList to save
|
||||
"""
|
||||
content = self.write(todo_list)
|
||||
self.file_path.write_text(content)
|
||||
atomic_write(self.file_path, content)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from PyQt6.QtWidgets import (
|
|||
QWidget,
|
||||
)
|
||||
|
||||
from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog
|
||||
from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog, UpdateDocsThread
|
||||
from development_hub.project_discovery import Project, discover_projects
|
||||
|
||||
|
||||
|
|
@ -35,8 +35,10 @@ class ProjectListWidget(QWidget):
|
|||
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()
|
||||
|
||||
|
|
@ -191,6 +193,13 @@ class ProjectListWidget(QWidget):
|
|||
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):
|
||||
|
|
@ -348,6 +357,11 @@ class ProjectListWidget(QWidget):
|
|||
"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):
|
||||
|
|
@ -392,12 +406,17 @@ class ProjectListWidget(QWidget):
|
|||
"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 PyQt6.QtWidgets import QMessageBox, QProgressDialog
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from pathlib import Path
|
||||
|
||||
# Confirm before starting
|
||||
|
|
@ -427,65 +446,131 @@ class ProjectListWidget(QWidget):
|
|||
else:
|
||||
backup[filename] = ""
|
||||
|
||||
# Show progress dialog
|
||||
progress = QProgressDialog(
|
||||
f"Generating documentation for {project.title}...\n\nThis may take a minute.",
|
||||
# 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
|
||||
)
|
||||
progress.setWindowTitle("Updating Documentation")
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.show()
|
||||
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()
|
||||
|
||||
# Process events to show the dialog
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
QApplication.processEvents()
|
||||
# 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}")
|
||||
|
||||
# Run the update-docs CmdForge tool (don't deploy yet)
|
||||
cmd = [
|
||||
"python3", "-m", "cmdforge.runner", "update-docs",
|
||||
"--project", project.key,
|
||||
"--deploy", "false"
|
||||
]
|
||||
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 PyQt6.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:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180 # 3 minute timeout for AI processing
|
||||
)
|
||||
self._progress_dialog.canceled.disconnect(self._on_update_docs_canceled)
|
||||
except TypeError:
|
||||
pass # Already disconnected
|
||||
self._progress_dialog.close()
|
||||
self._progress_dialog = None
|
||||
|
||||
progress.close()
|
||||
|
||||
if result.returncode != 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update Failed",
|
||||
f"Failed to generate documentation:\n\n{result.stderr or result.stdout}"
|
||||
)
|
||||
# Check if we still have state (not canceled by user before thread finished)
|
||||
if not self._update_docs_state:
|
||||
return
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
progress.close()
|
||||
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",
|
||||
"Documentation generation timed out. Try running manually:\n\n"
|
||||
f"Documentation generation timed out. Try running manually:\n\n"
|
||||
f"update-docs --project {project.key}"
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
progress.close()
|
||||
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
|
||||
|
|
@ -503,46 +588,69 @@ class ProjectListWidget(QWidget):
|
|||
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():
|
||||
# User accepted - ask about deployment
|
||||
# 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.\n\n"
|
||||
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:
|
||||
# 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)}"
|
||||
)
|
||||
# Use async deploy (reuse existing deploy infrastructure)
|
||||
self._deploy_docs(project)
|
||||
else:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Changes Saved",
|
||||
"Documentation changes have been saved locally.\n\n"
|
||||
f"Documentation changes have been saved locally ({len(selected_changes)} file(s)).\n\n"
|
||||
"You can deploy later using the 'Deploy Docs' option."
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,284 +1,36 @@
|
|||
"""Global dashboard view showing cross-project status."""
|
||||
"""Global dashboard view - wrapper around ProjectDashboard in global mode."""
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
)
|
||||
|
||||
from development_hub.services.health_checker import HealthChecker
|
||||
from development_hub.parsers.goals_parser import MilestonesParser
|
||||
from development_hub.parsers.progress_parser import ProgressLogManager
|
||||
from development_hub.widgets.progress_bar import MilestoneProgressBar
|
||||
from development_hub.widgets.stat_card import StatCardRow
|
||||
from development_hub.widgets.health_card import HealthCardCompact
|
||||
from development_hub.views.dashboard import ProjectDashboard
|
||||
|
||||
|
||||
class GlobalDashboard(QWidget):
|
||||
"""Global dashboard showing status across all projects.
|
||||
|
||||
Displays:
|
||||
- Current milestone progress (global)
|
||||
- Project health summary
|
||||
- Projects needing attention
|
||||
- Today's progress
|
||||
This is a thin wrapper around ProjectDashboard in global mode.
|
||||
"""
|
||||
|
||||
project_selected = pyqtSignal(str) # Emits project_key
|
||||
standup_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
"""Initialize global dashboard.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
"""Initialize global dashboard."""
|
||||
super().__init__(parent)
|
||||
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
|
||||
self._setup_ui()
|
||||
self._load_data()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
# Main scroll area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
scroll.setStyleSheet("QScrollArea { background-color: #1e1e1e; border: none; }")
|
||||
# Create ProjectDashboard in global mode
|
||||
self._dashboard = ProjectDashboard(project=None, parent=self)
|
||||
|
||||
# Content widget
|
||||
content = QWidget()
|
||||
content.setStyleSheet("background-color: #1e1e1e;")
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(20)
|
||||
# Forward signals
|
||||
self._dashboard.project_selected.connect(self.project_selected.emit)
|
||||
self._dashboard.standup_requested.connect(self.standup_requested.emit)
|
||||
|
||||
# Header
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
title = QLabel("Development Hub")
|
||||
title.setStyleSheet("font-size: 24px; font-weight: bold; color: #e0e0e0;")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
# Daily standup button
|
||||
standup_btn = QPushButton("Daily Standup")
|
||||
standup_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3d6a99;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4a7ab0;
|
||||
}
|
||||
""")
|
||||
standup_btn.clicked.connect(self.standup_requested.emit)
|
||||
header_layout.addWidget(standup_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# Milestone progress
|
||||
milestone_section = QLabel("CURRENT MILESTONE")
|
||||
milestone_section.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888;")
|
||||
layout.addWidget(milestone_section)
|
||||
|
||||
self.milestone_progress = MilestoneProgressBar()
|
||||
layout.addWidget(self.milestone_progress)
|
||||
|
||||
# Stats row
|
||||
self.stats_row = StatCardRow()
|
||||
self.stats_row.add_stat("healthy", 0, "Healthy", "#4caf50")
|
||||
self.stats_row.add_stat("warning", 0, "Warning", "#ff9800")
|
||||
self.stats_row.add_stat("total_todos", 0, "Total TODOs", "#e0e0e0")
|
||||
layout.addWidget(self.stats_row)
|
||||
|
||||
# Action buttons
|
||||
actions_layout = QHBoxLayout()
|
||||
actions_layout.setSpacing(8)
|
||||
|
||||
view_todos_btn = QPushButton("View Global TODOs")
|
||||
view_todos_btn.setStyleSheet(self._button_style())
|
||||
view_todos_btn.clicked.connect(self._open_todos)
|
||||
actions_layout.addWidget(view_todos_btn)
|
||||
|
||||
view_milestones_btn = QPushButton("View Milestones")
|
||||
view_milestones_btn.setStyleSheet(self._button_style())
|
||||
view_milestones_btn.clicked.connect(self._open_milestones)
|
||||
actions_layout.addWidget(view_milestones_btn)
|
||||
|
||||
actions_layout.addStretch()
|
||||
layout.addLayout(actions_layout)
|
||||
|
||||
# Project health section
|
||||
health_label = QLabel("PROJECT HEALTH")
|
||||
health_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
|
||||
layout.addWidget(health_label)
|
||||
|
||||
self.health_container = QVBoxLayout()
|
||||
self.health_container.setSpacing(4)
|
||||
layout.addLayout(self.health_container)
|
||||
|
||||
# Needs attention section
|
||||
attention_label = QLabel("NEEDS ATTENTION")
|
||||
attention_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
|
||||
layout.addWidget(attention_label)
|
||||
|
||||
self.attention_list = QLabel()
|
||||
self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;")
|
||||
self.attention_list.setWordWrap(True)
|
||||
layout.addWidget(self.attention_list)
|
||||
|
||||
# Today's progress section
|
||||
progress_label = QLabel("TODAY'S PROGRESS")
|
||||
progress_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
|
||||
layout.addWidget(progress_label)
|
||||
|
||||
self.progress_list = QLabel()
|
||||
self.progress_list.setStyleSheet("color: #b0b0b0; line-height: 1.6;")
|
||||
self.progress_list.setWordWrap(True)
|
||||
layout.addWidget(self.progress_list)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(content)
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
def _load_data(self):
|
||||
"""Load data and update display."""
|
||||
# Get ecosystem health
|
||||
checker = HealthChecker()
|
||||
ecosystem = checker.check_all_projects()
|
||||
|
||||
# Update stats
|
||||
self.stats_row.update_stat("healthy", ecosystem.healthy_count)
|
||||
self.stats_row.update_stat("warning", ecosystem.warning_count + ecosystem.critical_count)
|
||||
self.stats_row.update_stat("total_todos", ecosystem.total_active_todos)
|
||||
|
||||
# Load milestone
|
||||
milestones_path = self._docs_root / "goals" / "milestones.md"
|
||||
if milestones_path.exists():
|
||||
parser = MilestonesParser(milestones_path)
|
||||
current = parser.get_current_milestone()
|
||||
if current:
|
||||
self.milestone_progress.set_milestone(
|
||||
f"{current.id}: {current.name}",
|
||||
current.progress,
|
||||
current.target,
|
||||
)
|
||||
else:
|
||||
self.milestone_progress.clear()
|
||||
else:
|
||||
self.milestone_progress.clear()
|
||||
|
||||
# Clear and rebuild health cards
|
||||
while self.health_container.count():
|
||||
item = self.health_container.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
for health in ecosystem.projects:
|
||||
card = HealthCardCompact()
|
||||
card.set_project(
|
||||
health.project_key,
|
||||
health.project_name,
|
||||
health.status_icon,
|
||||
health.git_info.time_since_commit,
|
||||
health.active_todos,
|
||||
)
|
||||
card.clicked.connect(self._on_project_clicked)
|
||||
self.health_container.addWidget(card)
|
||||
|
||||
# Needs attention
|
||||
needing = ecosystem.get_needing_attention()
|
||||
if needing:
|
||||
attention_html = "<br>".join(
|
||||
f"⚠ {h.project_name}: {', '.join(h.attention_reasons[:2])}"
|
||||
for h in needing[:5]
|
||||
)
|
||||
self.attention_list.setText(attention_html)
|
||||
else:
|
||||
self.attention_list.setText("All projects healthy!")
|
||||
self.attention_list.setStyleSheet("color: #4caf50; line-height: 1.6;")
|
||||
|
||||
# Today's progress
|
||||
progress_dir = self._docs_root / "progress"
|
||||
if progress_dir.exists():
|
||||
manager = ProgressLogManager(progress_dir)
|
||||
today = manager.get_today()
|
||||
if today:
|
||||
items = []
|
||||
for task in today.completed[:5]:
|
||||
items.append(f"☑ {task}")
|
||||
for task in today.in_progress[:3]:
|
||||
items.append(f"☐ {task} (in progress)")
|
||||
if items:
|
||||
self.progress_list.setText("<br>".join(items))
|
||||
else:
|
||||
self.progress_list.setText("No progress logged yet today")
|
||||
else:
|
||||
self.progress_list.setText("No progress log for today")
|
||||
else:
|
||||
self.progress_list.setText("Progress directory not found")
|
||||
|
||||
def _on_project_clicked(self, project_key: str):
|
||||
"""Handle project card click."""
|
||||
self.project_selected.emit(project_key)
|
||||
# Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self._dashboard)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the dashboard data."""
|
||||
self._load_data()
|
||||
|
||||
def _button_style(self) -> str:
|
||||
"""Get button stylesheet."""
|
||||
return """
|
||||
QPushButton {
|
||||
background-color: #3d3d3d;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #4d4d4d;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
"""
|
||||
|
||||
def _open_file_in_editor(self, path: Path):
|
||||
"""Open a file in the default editor."""
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
editors = ["pycharm", "code", "subl", "gedit", "xdg-open"]
|
||||
for editor in editors:
|
||||
if shutil.which(editor):
|
||||
subprocess.Popen([editor, str(path)])
|
||||
return
|
||||
|
||||
def _open_todos(self):
|
||||
"""Open the global todos.md file."""
|
||||
todos_path = self._docs_root / "todos.md"
|
||||
self._open_file_in_editor(todos_path)
|
||||
|
||||
def _open_milestones(self):
|
||||
"""Open the milestones.md file."""
|
||||
milestones_path = self._docs_root / "goals" / "milestones.md"
|
||||
self._open_file_in_editor(milestones_path)
|
||||
self._dashboard._load_data()
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ class TodoItemWidget(QWidget):
|
|||
toggled = pyqtSignal(object, bool) # Emits (todo, completed)
|
||||
deleted = pyqtSignal(object) # Emits todo
|
||||
start_discussion = pyqtSignal(object) # Emits todo
|
||||
edited = pyqtSignal(object, str, str) # Emits (todo, old_text, new_text)
|
||||
|
||||
def __init__(self, todo, parent: QWidget | None = None, show_priority: bool = False):
|
||||
"""Initialize todo item widget.
|
||||
|
|
@ -194,6 +195,8 @@ class TodoItemWidget(QWidget):
|
|||
super().__init__(parent)
|
||||
self.todo = todo
|
||||
self._show_priority = show_priority
|
||||
self._editing = False
|
||||
self._edit_widget = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
|
|
@ -210,9 +213,11 @@ class TodoItemWidget(QWidget):
|
|||
self.checkbox.setFixedWidth(20)
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
# Text
|
||||
# Text (double-click to edit)
|
||||
self.text_label = QLabel(self.todo.text)
|
||||
self.text_label.setWordWrap(True)
|
||||
self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
|
||||
self.text_label.mouseDoubleClickEvent = self._start_editing
|
||||
self._update_text_style()
|
||||
layout.addWidget(self.text_label, 1)
|
||||
|
||||
|
|
@ -294,6 +299,91 @@ class TodoItemWidget(QWidget):
|
|||
self.delete_btn.hide()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _start_editing(self, event):
|
||||
"""Start inline editing of the todo text."""
|
||||
if self._editing:
|
||||
return
|
||||
|
||||
self._editing = True
|
||||
self._original_text = self.todo.text
|
||||
|
||||
# Create edit widget in place of label
|
||||
self._edit_widget = QLineEdit(self.todo.text)
|
||||
self._edit_widget.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
""")
|
||||
|
||||
# Connect signals - Enter to save, event filter handles Escape and focus out
|
||||
self._edit_widget.returnPressed.connect(self._finish_editing)
|
||||
self._edit_widget.installEventFilter(self)
|
||||
|
||||
# Replace label with edit widget
|
||||
layout = self.layout()
|
||||
label_index = layout.indexOf(self.text_label)
|
||||
self.text_label.hide()
|
||||
layout.insertWidget(label_index, self._edit_widget)
|
||||
|
||||
# Focus and select all
|
||||
self._edit_widget.setFocus()
|
||||
self._edit_widget.selectAll()
|
||||
|
||||
def _finish_editing(self):
|
||||
"""Finish editing and save changes."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
new_text = self._edit_widget.text().strip()
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again
|
||||
self.text_label.show()
|
||||
|
||||
# Only emit if text changed and not empty
|
||||
if new_text and new_text != self._original_text:
|
||||
self.text_label.setText(new_text)
|
||||
self.edited.emit(self.todo, self._original_text, new_text)
|
||||
|
||||
def _cancel_editing(self):
|
||||
"""Cancel editing and restore original text."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again (with original text)
|
||||
self.text_label.show()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle Escape key to cancel editing, focus out to save."""
|
||||
from PyQt6.QtCore import QEvent
|
||||
|
||||
if obj == self._edit_widget:
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self._cancel_editing()
|
||||
return True
|
||||
elif event.type() == QEvent.Type.FocusOut:
|
||||
# Save on focus loss
|
||||
self._finish_editing()
|
||||
return False
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Show context menu on right-click."""
|
||||
menu = QMenu(self)
|
||||
|
|
@ -326,18 +416,23 @@ class TodoItemWidget(QWidget):
|
|||
class GoalItemWidget(QWidget):
|
||||
"""Widget for displaying a single goal item with checkbox."""
|
||||
|
||||
toggled = pyqtSignal(object, bool) # Emits (goal, completed)
|
||||
toggled = pyqtSignal(object, bool, bool, bool) # Emits (goal, new_completed, was_completed, was_partial)
|
||||
deleted = pyqtSignal(object) # Emits goal
|
||||
edited = pyqtSignal(object, str, str) # Emits (goal, old_text, new_text)
|
||||
|
||||
def __init__(self, goal, parent: QWidget | None = None):
|
||||
def __init__(self, goal, parent: QWidget | None = None, is_non_goal: bool = False):
|
||||
"""Initialize goal item widget.
|
||||
|
||||
Args:
|
||||
goal: Goal model instance
|
||||
parent: Parent widget
|
||||
is_non_goal: If True, show green when checked instead of strikethrough
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.goal = goal
|
||||
self._is_non_goal = is_non_goal
|
||||
self._editing = False
|
||||
self._edit_widget = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
|
|
@ -354,9 +449,11 @@ class GoalItemWidget(QWidget):
|
|||
self.checkbox.setFixedWidth(20)
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
# Text
|
||||
# Text (double-click to edit)
|
||||
self.text_label = QLabel(self.goal.text)
|
||||
self.text_label.setWordWrap(True)
|
||||
self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
|
||||
self.text_label.mouseDoubleClickEvent = self._start_editing
|
||||
self._update_text_style()
|
||||
layout.addWidget(self.text_label, 1)
|
||||
|
||||
|
|
@ -394,29 +491,56 @@ class GoalItemWidget(QWidget):
|
|||
layout.addWidget(self.delete_btn)
|
||||
|
||||
def _update_checkbox(self):
|
||||
"""Update checkbox display."""
|
||||
"""Update checkbox display for three states."""
|
||||
if self.goal.completed:
|
||||
self.checkbox.setText("☑")
|
||||
self.checkbox.setStyleSheet("color: #4caf50; font-size: 16px;")
|
||||
elif getattr(self.goal, 'partial', False):
|
||||
self.checkbox.setText("◐")
|
||||
self.checkbox.setStyleSheet("color: #ff9800; font-size: 16px;")
|
||||
else:
|
||||
self.checkbox.setText("☐")
|
||||
self.checkbox.setStyleSheet("color: #888888; font-size: 16px;")
|
||||
|
||||
def _update_text_style(self):
|
||||
"""Update text style based on completion."""
|
||||
"""Update text style based on goal state.
|
||||
|
||||
Goals use green when achieved (no strikethrough) since they represent
|
||||
ongoing commitments that require continued focus even after achievement.
|
||||
"""
|
||||
if self.goal.completed:
|
||||
self.text_label.setStyleSheet(
|
||||
"color: #666666; text-decoration: line-through; font-size: 13px;"
|
||||
)
|
||||
# Achieved: green text, no strikethrough
|
||||
self.text_label.setStyleSheet("color: #4caf50; font-size: 13px;")
|
||||
elif getattr(self.goal, 'partial', False):
|
||||
# Partially achieved: orange text
|
||||
self.text_label.setStyleSheet("color: #ff9800; font-size: 13px;")
|
||||
else:
|
||||
# Not achieved: white text
|
||||
self.text_label.setStyleSheet("color: #e0e0e0; font-size: 13px;")
|
||||
|
||||
def _on_checkbox_clicked(self, event):
|
||||
"""Handle checkbox click."""
|
||||
self.goal.completed = not self.goal.completed
|
||||
"""Handle checkbox click - cycle through states.
|
||||
|
||||
Cycle: Not achieved -> Partial -> Achieved -> Not achieved
|
||||
"""
|
||||
# Capture previous state before modifying
|
||||
was_completed = self.goal.completed
|
||||
was_partial = getattr(self.goal, 'partial', False)
|
||||
|
||||
if self.goal.completed:
|
||||
# Achieved -> Not achieved
|
||||
self.goal.completed = False
|
||||
self.goal.partial = False
|
||||
elif getattr(self.goal, 'partial', False):
|
||||
# Partial -> Achieved
|
||||
self.goal.completed = True
|
||||
self.goal.partial = False
|
||||
else:
|
||||
# Not achieved -> Partial
|
||||
self.goal.partial = True
|
||||
self._update_checkbox()
|
||||
self._update_text_style()
|
||||
self.toggled.emit(self.goal, self.goal.completed)
|
||||
self.toggled.emit(self.goal, self.goal.completed, was_completed, was_partial)
|
||||
|
||||
def enterEvent(self, event):
|
||||
"""Show delete button on hover."""
|
||||
|
|
@ -428,6 +552,91 @@ class GoalItemWidget(QWidget):
|
|||
self.delete_btn.hide()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _start_editing(self, event):
|
||||
"""Start inline editing of the goal text."""
|
||||
if self._editing:
|
||||
return
|
||||
|
||||
self._editing = True
|
||||
self._original_text = self.goal.text
|
||||
|
||||
# Create edit widget in place of label
|
||||
self._edit_widget = QLineEdit(self.goal.text)
|
||||
self._edit_widget.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
""")
|
||||
|
||||
# Connect signals - Enter to save, event filter handles Escape and focus out
|
||||
self._edit_widget.returnPressed.connect(self._finish_editing)
|
||||
self._edit_widget.installEventFilter(self)
|
||||
|
||||
# Replace label with edit widget
|
||||
layout = self.layout()
|
||||
label_index = layout.indexOf(self.text_label)
|
||||
self.text_label.hide()
|
||||
layout.insertWidget(label_index, self._edit_widget)
|
||||
|
||||
# Focus and select all
|
||||
self._edit_widget.setFocus()
|
||||
self._edit_widget.selectAll()
|
||||
|
||||
def _finish_editing(self):
|
||||
"""Finish editing and save changes."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
new_text = self._edit_widget.text().strip()
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again
|
||||
self.text_label.show()
|
||||
|
||||
# Only emit if text changed and not empty
|
||||
if new_text and new_text != self._original_text:
|
||||
self.text_label.setText(new_text)
|
||||
self.edited.emit(self.goal, self._original_text, new_text)
|
||||
|
||||
def _cancel_editing(self):
|
||||
"""Cancel editing and restore original text."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again (with original text)
|
||||
self.text_label.show()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle Escape key to cancel editing, focus out to save."""
|
||||
from PyQt6.QtCore import QEvent
|
||||
|
||||
if obj == self._edit_widget:
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self._cancel_editing()
|
||||
return True
|
||||
elif event.type() == QEvent.Type.FocusOut:
|
||||
# Save on focus loss
|
||||
self._finish_editing()
|
||||
return False
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
|
||||
class DeliverableItemWidget(QWidget):
|
||||
"""Widget for displaying a single deliverable item with checkbox."""
|
||||
|
|
@ -551,6 +760,7 @@ class MilestoneWidget(QFrame):
|
|||
todo_deleted = pyqtSignal(object) # (todo) - for linked todos mode
|
||||
todo_added = pyqtSignal(str, str, str) # (text, priority, milestone_id) - for adding new todos
|
||||
todo_start_discussion = pyqtSignal(object) # (todo) - for starting discussion from todo
|
||||
todo_edited = pyqtSignal(object, str, str) # (todo, old_text, new_text) - for inline editing
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -607,6 +817,10 @@ class MilestoneWidget(QFrame):
|
|||
self.title_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 13px;")
|
||||
header_layout.addWidget(self.title_label)
|
||||
|
||||
# Set tooltip with description if available
|
||||
if self.milestone.description:
|
||||
self.header.setToolTip(self.milestone.description)
|
||||
|
||||
# Progress bar right after label
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setMinimum(0)
|
||||
|
|
@ -734,12 +948,20 @@ class MilestoneWidget(QFrame):
|
|||
self.arrow_label.setStyleSheet("color: #888888; font-size: 10px;")
|
||||
|
||||
def _update_status_icon(self):
|
||||
"""Update status icon based on milestone status."""
|
||||
"""Update status icon based on milestone status or linked todos progress."""
|
||||
from development_hub.models.goal import MilestoneStatus
|
||||
if self.milestone.status == MilestoneStatus.COMPLETE:
|
||||
|
||||
# Check if complete - either from milestone status or all linked todos done
|
||||
is_complete = self.milestone.status == MilestoneStatus.COMPLETE
|
||||
if self._todos:
|
||||
total = len(self._todos)
|
||||
done = sum(1 for t in self._todos if t.completed)
|
||||
is_complete = is_complete or (total > 0 and done == total)
|
||||
|
||||
if is_complete:
|
||||
self.status_icon.setText("✓")
|
||||
self.status_icon.setStyleSheet("color: #4caf50; font-size: 14px;")
|
||||
elif self.milestone.status == MilestoneStatus.IN_PROGRESS:
|
||||
elif self.milestone.status == MilestoneStatus.IN_PROGRESS or (self._todos and any(t.completed for t in self._todos)):
|
||||
self.status_icon.setText("●")
|
||||
self.status_icon.setStyleSheet("color: #4a9eff; font-size: 14px;")
|
||||
else:
|
||||
|
|
@ -798,6 +1020,7 @@ class MilestoneWidget(QFrame):
|
|||
widget.toggled.connect(self._on_todo_toggled_internal)
|
||||
widget.deleted.connect(self._on_todo_deleted_internal)
|
||||
widget.start_discussion.connect(self.todo_start_discussion.emit)
|
||||
widget.edited.connect(self.todo_edited.emit)
|
||||
self.deliverables_layout.addWidget(widget)
|
||||
else:
|
||||
# Legacy: Add deliverables
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Project health card widget."""
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel
|
||||
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar
|
||||
|
||||
|
||||
class HealthCard(QFrame):
|
||||
|
|
@ -158,7 +158,35 @@ class HealthCardCompact(QFrame):
|
|||
# Name
|
||||
self.name_label = QLabel()
|
||||
self.name_label.setStyleSheet("color: #e0e0e0;")
|
||||
layout.addWidget(self.name_label, stretch=1)
|
||||
self.name_label.setMinimumWidth(120)
|
||||
layout.addWidget(self.name_label)
|
||||
|
||||
# Milestone progress bar
|
||||
self.milestone_progress = QProgressBar()
|
||||
self.milestone_progress.setMaximumHeight(8)
|
||||
self.milestone_progress.setMinimumWidth(80)
|
||||
self.milestone_progress.setMaximumWidth(120)
|
||||
self.milestone_progress.setTextVisible(False)
|
||||
self.milestone_progress.setStyleSheet("""
|
||||
QProgressBar {
|
||||
background-color: #2d2d2d;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #4a90d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.milestone_progress)
|
||||
|
||||
# Progress percentage label
|
||||
self.progress_label = QLabel()
|
||||
self.progress_label.setStyleSheet("color: #888888; font-size: 10px;")
|
||||
self.progress_label.setMinimumWidth(30)
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Time
|
||||
self.time_label = QLabel()
|
||||
|
|
@ -177,6 +205,7 @@ class HealthCardCompact(QFrame):
|
|||
status_icon: str,
|
||||
time_since_commit: str,
|
||||
active_todos: int,
|
||||
milestone_progress: int = 0,
|
||||
):
|
||||
"""Set project data."""
|
||||
self._project_key = project_key
|
||||
|
|
@ -185,6 +214,31 @@ class HealthCardCompact(QFrame):
|
|||
self.time_label.setText(time_since_commit)
|
||||
self.todos_label.setText(f"📊 {active_todos}")
|
||||
|
||||
# Set milestone progress
|
||||
self.milestone_progress.setValue(milestone_progress)
|
||||
if milestone_progress > 0:
|
||||
self.progress_label.setText(f"{milestone_progress}%")
|
||||
# Color based on progress
|
||||
if milestone_progress >= 80:
|
||||
color = "#4caf50" # Green
|
||||
elif milestone_progress >= 50:
|
||||
color = "#4a90d9" # Blue
|
||||
else:
|
||||
color = "#ff9800" # Orange
|
||||
self.milestone_progress.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background-color: #2d2d2d;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {color};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
else:
|
||||
self.progress_label.setText("")
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press."""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
|
|
|
|||
|
|
@ -307,7 +307,17 @@ class PaneWidget(QFrame):
|
|||
"title": title,
|
||||
"cwd": str(widget.cwd),
|
||||
})
|
||||
# Skip welcome tab and other non-terminal widgets
|
||||
elif isinstance(widget, ProjectDashboard):
|
||||
tabs.append({
|
||||
"type": "dashboard",
|
||||
"project_key": widget.project.key,
|
||||
"state": widget.get_state(),
|
||||
})
|
||||
elif isinstance(widget, GlobalDashboard):
|
||||
tabs.append({
|
||||
"type": "global_dashboard",
|
||||
})
|
||||
# Skip welcome tab and other non-saveable widgets
|
||||
|
||||
return {
|
||||
"tabs": tabs,
|
||||
|
|
@ -316,15 +326,34 @@ class PaneWidget(QFrame):
|
|||
|
||||
def restore_tabs(self, state: dict):
|
||||
"""Restore tabs from session state."""
|
||||
from development_hub.project_discovery import discover_projects
|
||||
|
||||
tabs = state.get("tabs", [])
|
||||
current_tab = state.get("current_tab", 0)
|
||||
|
||||
# Cache discovered projects for dashboard restoration
|
||||
projects_by_key = {p.key: p for p in discover_projects()}
|
||||
|
||||
for tab_info in tabs:
|
||||
if tab_info.get("type") == "terminal":
|
||||
tab_type = tab_info.get("type")
|
||||
|
||||
if tab_type == "terminal":
|
||||
cwd = Path(tab_info.get("cwd", str(Path.home())))
|
||||
title = tab_info.get("title", "Terminal")
|
||||
self.add_terminal(cwd, title)
|
||||
|
||||
elif tab_type == "dashboard":
|
||||
project_key = tab_info.get("project_key")
|
||||
if project_key and project_key in projects_by_key:
|
||||
project = projects_by_key[project_key]
|
||||
dashboard = self.add_dashboard(project)
|
||||
# Restore dashboard state
|
||||
if dashboard and "state" in tab_info:
|
||||
dashboard.restore_state(tab_info["state"])
|
||||
|
||||
elif tab_type == "global_dashboard":
|
||||
self.add_global_dashboard()
|
||||
|
||||
# Restore current tab selection
|
||||
if 0 <= current_tab < self.tab_widget.count():
|
||||
self.tab_widget.setCurrentIndex(current_tab)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,395 @@
|
|||
"""Round-trip tests for parsers.
|
||||
|
||||
These tests verify that parse -> save -> parse produces identical data.
|
||||
This ensures no data loss during save operations.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from development_hub.parsers.todos_parser import TodosParser
|
||||
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
|
||||
from development_hub.models.todo import Todo, TodoList
|
||||
from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable, MilestoneStatus, DeliverableStatus
|
||||
|
||||
|
||||
class TestTodosRoundtrip:
|
||||
"""Test TodosParser round-trip preservation."""
|
||||
|
||||
def test_simple_todos_roundtrip(self, tmp_path):
|
||||
"""Parse -> save -> parse should produce identical data."""
|
||||
todos_file = tmp_path / "todos.md"
|
||||
original_content = """---
|
||||
type: todos
|
||||
project: test-project
|
||||
updated: 2026-01-08
|
||||
---
|
||||
|
||||
# TODOs
|
||||
|
||||
## Active Tasks
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Important task #high
|
||||
- [ ] Another important task #high @M1
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] Medium task #medium
|
||||
- [x] Completed medium task #medium (2026-01-07)
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] Low priority task #low
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Done task #high (2026-01-06)
|
||||
- [x] Another done #medium (2026-01-05)
|
||||
"""
|
||||
todos_file.write_text(original_content)
|
||||
|
||||
# First parse
|
||||
parser1 = TodosParser(todos_file)
|
||||
todo_list1 = parser1.parse()
|
||||
|
||||
# Save
|
||||
parser1.save(todo_list1)
|
||||
|
||||
# Second parse
|
||||
parser2 = TodosParser(todos_file)
|
||||
todo_list2 = parser2.parse()
|
||||
|
||||
# Compare
|
||||
assert len(todo_list1.all_todos) == len(todo_list2.all_todos)
|
||||
assert len(todo_list1.completed) == len(todo_list2.completed)
|
||||
|
||||
# Compare individual todos
|
||||
for t1, t2 in zip(todo_list1.all_todos, todo_list2.all_todos):
|
||||
assert t1.text == t2.text
|
||||
assert t1.priority == t2.priority
|
||||
assert t1.completed == t2.completed
|
||||
assert t1.milestone == t2.milestone
|
||||
|
||||
def test_todos_with_milestones_roundtrip(self, tmp_path):
|
||||
"""Milestone tags should be preserved."""
|
||||
todos_file = tmp_path / "todos.md"
|
||||
original_content = """---
|
||||
type: todos
|
||||
project: test
|
||||
---
|
||||
|
||||
# TODOs
|
||||
|
||||
## Active Tasks
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Task with milestone @M1 #high
|
||||
- [ ] Task with M2 @M2 #high
|
||||
|
||||
### Medium Priority
|
||||
|
||||
### Low Priority
|
||||
|
||||
## Completed
|
||||
"""
|
||||
todos_file.write_text(original_content)
|
||||
|
||||
parser1 = TodosParser(todos_file)
|
||||
todo_list1 = parser1.parse()
|
||||
|
||||
# Verify milestones parsed
|
||||
high_todos = [t for t in todo_list1.all_todos if t.priority == "high"]
|
||||
assert any(t.milestone == "M1" for t in high_todos)
|
||||
assert any(t.milestone == "M2" for t in high_todos)
|
||||
|
||||
# Save and reparse
|
||||
parser1.save(todo_list1)
|
||||
parser2 = TodosParser(todos_file)
|
||||
todo_list2 = parser2.parse()
|
||||
|
||||
# Verify milestones preserved
|
||||
high_todos2 = [t for t in todo_list2.all_todos if t.priority == "high"]
|
||||
assert any(t.milestone == "M1" for t in high_todos2)
|
||||
assert any(t.milestone == "M2" for t in high_todos2)
|
||||
|
||||
|
||||
class TestGoalsRoundtrip:
|
||||
"""Test GoalsParser round-trip preservation."""
|
||||
|
||||
def test_goals_roundtrip(self, tmp_path):
|
||||
"""Goals should be preserved across parse -> save -> parse."""
|
||||
goals_file = tmp_path / "goals.md"
|
||||
original_content = """---
|
||||
type: goals
|
||||
project: test-project
|
||||
updated: 2026-01-08
|
||||
---
|
||||
|
||||
# Goals
|
||||
|
||||
## Active
|
||||
|
||||
- [ ] Incomplete goal #high
|
||||
- [~] Partial goal #medium
|
||||
- [x] Completed goal #low
|
||||
|
||||
## Future
|
||||
|
||||
- [ ] Future goal 1 #medium
|
||||
- [ ] Future goal 2 #low
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- [ ] Not doing this #medium
|
||||
- [x] Confirmed non-goal #high
|
||||
"""
|
||||
goals_file.write_text(original_content)
|
||||
|
||||
# First parse
|
||||
parser1 = GoalsParser(goals_file)
|
||||
goal_list1 = parser1.parse()
|
||||
|
||||
# Save using GoalsSaver
|
||||
saver = GoalsSaver(goals_file, parser1.frontmatter)
|
||||
saver.save(goal_list1)
|
||||
|
||||
# Second parse
|
||||
parser2 = GoalsParser(goals_file)
|
||||
goal_list2 = parser2.parse()
|
||||
|
||||
# Compare counts
|
||||
assert len(goal_list1.active) == len(goal_list2.active)
|
||||
assert len(goal_list1.future) == len(goal_list2.future)
|
||||
assert len(goal_list1.non_goals) == len(goal_list2.non_goals)
|
||||
|
||||
# Compare active goals
|
||||
for g1, g2 in zip(goal_list1.active, goal_list2.active):
|
||||
assert g1.text == g2.text
|
||||
assert g1.completed == g2.completed
|
||||
assert g1.partial == g2.partial
|
||||
assert g1.priority == g2.priority
|
||||
|
||||
def test_three_state_goals_roundtrip(self, tmp_path):
|
||||
"""Three-state goals ([ ], [~], [x]) should be preserved."""
|
||||
goals_file = tmp_path / "goals.md"
|
||||
original_content = """---
|
||||
type: goals
|
||||
project: test
|
||||
---
|
||||
|
||||
# Goals
|
||||
|
||||
## Active
|
||||
|
||||
- [ ] Not started #medium
|
||||
- [~] In progress #high
|
||||
- [x] Done #low
|
||||
"""
|
||||
goals_file.write_text(original_content)
|
||||
|
||||
parser1 = GoalsParser(goals_file)
|
||||
goal_list1 = parser1.parse()
|
||||
|
||||
# Verify states parsed correctly
|
||||
assert goal_list1.active[0].completed == False
|
||||
assert goal_list1.active[0].partial == False
|
||||
assert goal_list1.active[1].completed == False
|
||||
assert goal_list1.active[1].partial == True
|
||||
assert goal_list1.active[2].completed == True
|
||||
assert goal_list1.active[2].partial == False
|
||||
|
||||
# Save and reparse
|
||||
saver = GoalsSaver(goals_file, parser1.frontmatter)
|
||||
saver.save(goal_list1)
|
||||
|
||||
parser2 = GoalsParser(goals_file)
|
||||
goal_list2 = parser2.parse()
|
||||
|
||||
# Verify states preserved
|
||||
assert goal_list2.active[0].completed == False
|
||||
assert goal_list2.active[0].partial == False
|
||||
assert goal_list2.active[1].completed == False
|
||||
assert goal_list2.active[1].partial == True
|
||||
assert goal_list2.active[2].completed == True
|
||||
assert goal_list2.active[2].partial == False
|
||||
|
||||
|
||||
class TestMilestonesRoundtrip:
|
||||
"""Test MilestonesParser round-trip preservation."""
|
||||
|
||||
def test_milestones_roundtrip(self, tmp_path):
|
||||
"""Milestones should be preserved across parse -> save -> parse."""
|
||||
milestones_file = tmp_path / "milestones.md"
|
||||
original_content = """---
|
||||
type: milestones
|
||||
project: test-project
|
||||
updated: 2026-01-08
|
||||
---
|
||||
|
||||
# Milestones
|
||||
|
||||
#### M1: First Milestone
|
||||
**Target**: January 2026
|
||||
**Status**: In Progress (50%)
|
||||
|
||||
First milestone description.
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Task A | Done |
|
||||
| Task B | In Progress |
|
||||
| Task C | Not Started |
|
||||
|
||||
---
|
||||
|
||||
#### M2: Second Milestone
|
||||
**Target**: February 2026
|
||||
**Status**: Not Started
|
||||
|
||||
Second milestone description.
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Feature X | Not Started |
|
||||
| Feature Y | Not Started |
|
||||
|
||||
---
|
||||
"""
|
||||
milestones_file.write_text(original_content)
|
||||
|
||||
# First parse
|
||||
parser1 = MilestonesParser(milestones_file)
|
||||
milestones1 = parser1.parse()
|
||||
|
||||
# Save
|
||||
parser1.save(milestones1)
|
||||
|
||||
# Second parse
|
||||
parser2 = MilestonesParser(milestones_file)
|
||||
milestones2 = parser2.parse()
|
||||
|
||||
# Compare counts
|
||||
assert len(milestones1) == len(milestones2)
|
||||
|
||||
# Compare individual milestones
|
||||
for m1, m2 in zip(milestones1, milestones2):
|
||||
assert m1.id == m2.id
|
||||
assert m1.name == m2.name
|
||||
assert m1.target == m2.target
|
||||
assert m1.status == m2.status
|
||||
assert len(m1.deliverables) == len(m2.deliverables)
|
||||
|
||||
# Compare deliverables
|
||||
for d1, d2 in zip(m1.deliverables, m2.deliverables):
|
||||
assert d1.name == d2.name
|
||||
assert d1.status == d2.status
|
||||
|
||||
def test_milestone_status_roundtrip(self, tmp_path):
|
||||
"""Milestone statuses should be preserved."""
|
||||
milestones_file = tmp_path / "milestones.md"
|
||||
original_content = """---
|
||||
type: milestones
|
||||
project: test
|
||||
---
|
||||
|
||||
# Milestones
|
||||
|
||||
#### M1: Complete
|
||||
**Target**: Dec 2025
|
||||
**Status**: Completed (100%)
|
||||
|
||||
---
|
||||
|
||||
#### M2: In Progress
|
||||
**Target**: Jan 2026
|
||||
**Status**: In Progress (75%)
|
||||
|
||||
---
|
||||
|
||||
#### M3: Planning
|
||||
**Target**: Feb 2026
|
||||
**Status**: Planning (10%)
|
||||
|
||||
---
|
||||
|
||||
#### M4: Not Started
|
||||
**Target**: Q2 2026
|
||||
**Status**: Not Started
|
||||
|
||||
---
|
||||
"""
|
||||
milestones_file.write_text(original_content)
|
||||
|
||||
parser1 = MilestonesParser(milestones_file)
|
||||
milestones1 = parser1.parse()
|
||||
|
||||
# Create lookup by ID
|
||||
by_id1 = {m.id: m for m in milestones1}
|
||||
|
||||
# Verify statuses parsed
|
||||
assert by_id1["M1"].status == MilestoneStatus.COMPLETE
|
||||
assert by_id1["M2"].status == MilestoneStatus.IN_PROGRESS
|
||||
assert by_id1["M3"].status == MilestoneStatus.PLANNING
|
||||
assert by_id1["M4"].status == MilestoneStatus.NOT_STARTED
|
||||
|
||||
# Save and reparse
|
||||
parser1.save(milestones1)
|
||||
parser2 = MilestonesParser(milestones_file)
|
||||
milestones2 = parser2.parse()
|
||||
|
||||
# Create lookup by ID
|
||||
by_id2 = {m.id: m for m in milestones2}
|
||||
|
||||
# Verify statuses preserved (order may change due to Active/Completed sections)
|
||||
assert by_id2["M1"].status == MilestoneStatus.COMPLETE
|
||||
assert by_id2["M2"].status == MilestoneStatus.IN_PROGRESS
|
||||
assert by_id2["M3"].status == MilestoneStatus.PLANNING
|
||||
assert by_id2["M4"].status == MilestoneStatus.NOT_STARTED
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
"""Test atomic write functionality."""
|
||||
|
||||
def test_atomic_write_creates_file(self, tmp_path):
|
||||
"""atomic_write should create file if it doesn't exist."""
|
||||
from development_hub.parsers.base import atomic_write
|
||||
|
||||
test_file = tmp_path / "new_file.md"
|
||||
content = "# Test Content\n\nSome text here."
|
||||
|
||||
atomic_write(test_file, content)
|
||||
|
||||
assert test_file.exists()
|
||||
assert test_file.read_text() == content
|
||||
|
||||
def test_atomic_write_overwrites_file(self, tmp_path):
|
||||
"""atomic_write should safely overwrite existing file."""
|
||||
from development_hub.parsers.base import atomic_write
|
||||
|
||||
test_file = tmp_path / "existing.md"
|
||||
test_file.write_text("Original content")
|
||||
|
||||
new_content = "New content here"
|
||||
atomic_write(test_file, new_content)
|
||||
|
||||
assert test_file.read_text() == new_content
|
||||
|
||||
def test_atomic_write_no_temp_files_left(self, tmp_path):
|
||||
"""atomic_write should not leave temp files after success."""
|
||||
from development_hub.parsers.base import atomic_write
|
||||
|
||||
test_file = tmp_path / "test.md"
|
||||
atomic_write(test_file, "content")
|
||||
|
||||
# Check no temp files remain
|
||||
temp_files = list(tmp_path.glob(".test.md.*"))
|
||||
assert len(temp_files) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Loading…
Reference in New Issue