Compare commits

..

5 Commits

Author SHA1 Message Date
rob de024965a0 Add dev setup script and restructure dependencies for editable installs
- Add scripts/dev-setup.sh for setting up development environment with
  editable installs of interdependent projects (cmdforge, ramble,
  artifact-editor, orchestrated-discussions)
- Restructure pyproject.toml dependencies: top-level app specifies git URLs,
  libraries use name-only deps for compatibility with editable installs
- Add artifact-editor as explicit dependency (transitive through discussions)
- Various model, parser, and widget enhancements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:40:52 -04:00
rob 20818956b3 Fix todo deletion not refreshing milestone widgets and file watcher race condition
- Add _load_milestones() call after _load_todos() in _on_todo_deleted and
  _on_todo_edited to refresh milestone widgets showing linked todos
- Replace boolean _ignoring_file_change flag with timestamp-based ignore
  window (0.5s) to handle multiple file system events from a single save
- Add _is_within_save_window() helper method for cleaner event filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:38:35 -04:00
rob b60af09922 Add tests for M4 workspace files and wizard
- test_settings.py: Add TestDocsModeSettings and TestWorkspaceExportImport
- test_paths.py: Comprehensive PathResolver tests with proper isolation
- test_wizard.py: Wizard structure and settings integration tests

All 71 tests pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:57:59 -04:00
rob 5742b7088b Update documentation for workspace files feature
- Update CLAUDE.md with new architecture, classes, features
- Add workspace files and configuration sections
- Update README.md with first-run setup and workspace files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:48:47 -04:00
rob 14885fb567 Add workspace files and enhanced first-run wizard
- Add PathResolver module for centralized path resolution from settings
- Add workspace export/import (YAML) for shareable configuration
- Replace SetupWizardDialog with multi-page wizard (Simple/Docs/Import modes)
- Add documentation mode settings (auto/standalone/project-docs)
- Implement graceful degradation (hide features when not configured)
- Add Export/Import Workspace menu items
- Update Settings dialog with documentation mode section
- Replace hardcoded paths with paths resolver throughout codebase
- Add pyyaml dependency for workspace file parsing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:40:02 -04:00
24 changed files with 3275 additions and 283 deletions

119
CLAUDE.md
View File

@ -35,8 +35,9 @@ src/development_hub/
├── terminal_display.py # Terminal rendering with scrollback
├── pty_manager.py # PTY process management
├── project_discovery.py # Auto-discover projects from paths
├── dialogs.py # New Project, Settings, Preview dialogs
├── dialogs.py # New Project, Settings, Setup Wizard dialogs
├── settings.py # JSON persistence for settings & session
├── paths.py # Centralized path resolution from settings
├── styles.py # Dark theme stylesheet
├── models/ # Data models (Goal, Todo, Milestone)
├── views/dashboard/ # Dashboard views and components
@ -61,6 +62,10 @@ src/development_hub/
| `TerminalDisplay` | terminal_display.py | Terminal rendering with scrollback buffer |
| `AutoAcceptToast` | terminal_widget.py | Countdown toast for auto-accept prompts |
| `AutoAcceptDialog` | dialogs.py | Duration selection for auto-accept |
| `ImportPlanDialog` | dialogs.py | Import implementation plans to milestones |
| `SetupWizardDialog` | dialogs.py | Multi-page first-run wizard |
| `SettingsDialog` | dialogs.py | Application settings with docs mode |
| `PathResolver` | paths.py | Centralized path resolution singleton |
| `DashboardDataStore` | views/dashboard/data_store.py | Data persistence with file watching |
| `UndoManager` | views/dashboard/undo_manager.py | Undo/redo action management |
@ -68,7 +73,7 @@ src/development_hub/
- **Project List**: Discovers projects from configurable search paths
- **Project Filtering**: Filter box to quickly find projects by name or key
- **Context Menu**: Open terminal, editor, Gitea, docs, deploy
- **Context Menu**: Open terminal, editor, git host, docs, deploy (items hidden when not configured)
- **Splittable Panes**: Horizontal/vertical splits, each pane has own tab bar
- **Cross-Pane Tab Dragging**: Drag tabs between panes to reorganize workspace
- **Terminal**: Full PTY with pyte for TUI support (vim, htop work)
@ -81,6 +86,13 @@ src/development_hub/
- **New Project Dialog**: Integrates with Ramble for voice input
- **Progress Reports**: Export weekly progress summaries from daily standups
- **Auto-accept Prompts**: Automatically accept Y/yes prompts in terminals for CLI tools (Claude, Codex, etc.)
- **Plan Import**: Import implementation plans into milestones via right-click context menu
- **Phase Grouping**: Todos can be grouped by phase within milestones using `[Phase N]` prefix
- **Milestone Discussions**: Start discussions directly from milestones for implementation planning
- **Workspace Files**: Export/import configuration via YAML for sharing setups
- **First-Run Wizard**: Multi-page setup wizard with Simple/Documentation/Import modes
- **Documentation Modes**: Standalone (local storage) or Project-Docs (Docusaurus integration)
- **Graceful Degradation**: Features hide when dependencies unavailable (CmdForge, git, docs)
### Keyboard Shortcuts
@ -95,6 +107,7 @@ src/development_hub/
| `Ctrl+Shift+P` | Close active pane |
| `Ctrl+Alt+Left/Right` | Switch panes |
| `Ctrl+B` | Toggle project panel |
| `Ctrl+G` | Global Dashboard |
| `Ctrl+N` | New project dialog |
| `Ctrl+D` | New discussion |
| `Ctrl+R` | Weekly progress report |
@ -150,6 +163,71 @@ The dashboard automatically syncs data between documentation files:
This means you can define major work items in milestone deliverable tables, and they'll automatically appear in your todo list for tracking.
### Todo Notes
Todos can have optional notes that provide additional context. Notes are displayed as tooltips when hovering over todo items and are persisted as indented blockquote lines in `todos.md`:
```markdown
- [ ] [Phase 1] Add settings module @M4
> File: settings.py - Add DEFAULTS and properties
- [ ] Task without notes @M4
```
Notes are automatically extracted when importing plans via the Import Plan dialog and preserved through save/reload cycles.
### Workspace Files
Development Hub supports exporting and importing configuration via YAML workspace files. This allows sharing setups between machines or users.
**Export/Import via menu:** File → Export Workspace... / Import Workspace...
**Workspace file format:**
```yaml
# ~/.devhub-workspace.yaml
name: "My Development Environment"
version: 1
paths:
projects_root: ~/PycharmProjects
docs_root: ~/PycharmProjects/project-docs/docs # Optional
documentation:
enabled: true
mode: project-docs # "standalone" | "project-docs"
docusaurus_path: ~/PycharmProjects/project-docs
auto_start_server: true
git_hosting:
type: gitea # "gitea" | "github" | "gitlab"
url: https://gitea.example.com
owner: username
pages_url: https://pages.example.com # Optional
features:
cmdforge_integration: true
progress_tracking: true
```
### Documentation Modes
Development Hub supports two documentation modes:
1. **Standalone Mode**: Data stored locally in `~/.local/share/development-hub/`. No Docusaurus integration. Good for simple use cases.
2. **Project-Docs Mode**: Full Docusaurus integration with centralized documentation. Supports auto-start docs server, deploy scripts, and pages hosting.
The mode is configured during first-run setup or in Settings → Documentation.
### First-Run Wizard
On first launch (when no `settings.json` exists), the setup wizard appears with three options:
1. **Simple Mode**: Quick setup with just a projects directory. Documentation features disabled.
2. **Documentation Mode**: Full setup with Docusaurus path, git hosting, and pages configuration.
3. **Import Workspace**: Load settings from a `.devhub-workspace.yaml` file.
Existing users are unaffected - the wizard only appears when `setup_completed` is false.
## CLI Scripts
### `bin/new-project`
@ -213,6 +291,43 @@ Templates use these placeholders (replaced by sed):
## Configuration
### Settings File
Settings are stored in `~/.config/development-hub/settings.json`. Key settings:
| Setting | Default | Description |
|---------|---------|-------------|
| `docs_mode` | `"auto"` | `"auto"`, `"standalone"`, or `"project-docs"` |
| `docs_root` | `""` | Override docs root path (empty = derive from mode) |
| `docusaurus_path` | `""` | Path to project-docs directory |
| `pages_url` | `""` | URL for documentation pages hosting |
| `cmdforge_path` | `""` | Override CmdForge installation path |
| `progress_dir` | `""` | Override progress log directory |
| `auto_start_docs_server` | `true` | Start Docusaurus dev server on launch |
| `git_host_type` | `""` | `"gitea"`, `"github"`, or `"gitlab"` |
| `git_host_url` | `""` | Git hosting URL |
| `git_host_owner` | `""` | Username or organization |
| `git_host_token` | `""` | API token for repo creation |
### Path Resolution
The `PathResolver` class (`paths.py`) centralizes all path lookups:
```python
from development_hub.paths import paths
paths.projects_root # Primary projects directory
paths.docs_root # Documentation root
paths.project_docs_dir # Docusaurus project directory
paths.progress_dir # Progress log directory
paths.build_script # build-public-docs.sh path
paths.cmdforge_executable # CmdForge binary path
paths.project_docs_path("myproject") # Docs for specific project
paths.git_url("owner", "repo") # Git repository URL
paths.pages_url("owner", "repo") # Pages URL for project
```
### Gitea API Token
The script needs a Gitea API token to create repositories:

View File

@ -90,13 +90,12 @@ To create manually:
### 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
- Filter box to quickly find projects
- Double-click to open project dashboard
- Right-click context menu (items shown based on configuration):
- Open Dashboard / Terminal / Editor
- View on Git Host / Documentation
- Update / Deploy Docs
### Workspace (Right Panel)
- Splittable panes (horizontal/vertical)
@ -104,6 +103,20 @@ To create manually:
- Full PTY terminals with TUI support (vim, htop, etc.)
- Drag & drop files/folders to inject paths
- Session persistence - remembers layout on restart
- Auto-accept prompts for AI CLI tools
### First-Run Setup
On first launch, a setup wizard helps configure:
- **Simple Mode**: Just a projects directory, local data storage
- **Documentation Mode**: Full Docusaurus integration with git hosting
- **Import Workspace**: Load settings from a YAML file
### Workspace Files
Export your configuration to share with others:
- File → Export Workspace... (creates `.devhub-workspace.yaml`)
- File → Import Workspace... (loads settings from file)
### Keyboard Shortcuts
@ -116,6 +129,8 @@ To create manually:
| `Ctrl+Alt+Left/Right` | Switch panes |
| `Ctrl+B` | Toggle project panel |
| `Ctrl+N` | New project dialog |
| `Ctrl+G` | Global Dashboard |
| `Ctrl+R` | Weekly progress report |
## Full Documentation

View File

@ -12,9 +12,13 @@ requires-python = ">=3.10"
dependencies = [
"PySide6>=6.4.0",
"pyte>=0.8.0",
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
"pyyaml>=6.0",
# Git dependencies - top-level app specifies where to get internal packages
# Libraries use name-only deps so editable installs work during development
"cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git",
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
"artifact-editor @ git+https://gitea.brrd.tech/rob/artifact-editor.git",
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
]
[project.optional-dependencies]

89
scripts/dev-setup.sh Executable file
View File

@ -0,0 +1,89 @@
#!/bin/bash
# Development setup script for development-hub
#
# This script installs all interdependent projects as editable packages,
# allowing you to develop them in parallel without reinstalling.
#
# Usage:
# ./scripts/dev-setup.sh # Full setup with venv creation
# ./scripts/dev-setup.sh --deps # Just reinstall editable deps (faster)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PROJECTS_ROOT="$(dirname "$PROJECT_DIR")"
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log() {
echo -e "${BLUE}==>${NC} $1"
}
success() {
echo -e "${GREEN}==>${NC} $1"
}
# Check if we're just updating deps
DEPS_ONLY=false
if [ "$1" = "--deps" ]; then
DEPS_ONLY=true
fi
cd "$PROJECT_DIR"
if [ "$DEPS_ONLY" = false ]; then
# Create venv if it doesn't exist
if [ ! -d ".venv" ]; then
log "Creating virtual environment..."
python3 -m venv .venv
fi
fi
# Activate venv
log "Activating virtual environment..."
source .venv/bin/activate
if [ "$DEPS_ONLY" = false ]; then
# Upgrade pip
log "Upgrading pip..."
pip install --upgrade pip
fi
# Install editable packages in dependency order (base packages first)
# Using --no-deps to avoid git URL conflicts, then installing other deps
log "Installing cmdforge (base layer)..."
pip install -e "$PROJECTS_ROOT/CmdForge" --no-deps
pip install PyYAML requests PySide6 NodeGraphQt setuptools
log "Installing ramble..."
pip install -e "$PROJECTS_ROOT/ramble" --no-deps
log "Installing artifact-editor..."
pip install -e "$PROJECTS_ROOT/artifact-editor" --no-deps
pip install QScintilla
log "Installing orchestrated-discussions[gui]..."
pip install -e "$PROJECTS_ROOT/orchestrated-discussions[gui]" --no-deps
pip install dearpygui sounddevice numpy urwid
log "Installing development-hub..."
pip install -e "$PROJECT_DIR" --no-deps
pip install pyte
if [ "$DEPS_ONLY" = false ]; then
# Install dev dependencies
log "Installing dev dependencies..."
pip install pytest pytest-qt pytest-cov
fi
success "Development environment ready!"
echo ""
echo "Installed packages (editable):"
pip list | grep -E "(cmdforge|ramble|artifact-editor|orchestrated-discussions|development-hub)" | sed 's/^/ /'
echo ""
echo "To activate: source .venv/bin/activate"
echo "To run: development-hub"

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from development_hub.paths import paths
from development_hub.project_discovery import Project
from development_hub.project_list import ProjectListWidget
from development_hub.workspace import WorkspaceManager
@ -84,10 +85,23 @@ class MainWindow(QMainWindow):
new_project.triggered.connect(self._new_project)
file_menu.addAction(new_project)
new_discussion = QAction("New &Discussion...", self)
new_discussion.setShortcut(QKeySequence("Ctrl+D"))
new_discussion.triggered.connect(self._launch_global_discussion)
file_menu.addAction(new_discussion)
# New Discussion - only show if discussions is available
import shutil
if shutil.which("discussions"):
new_discussion = QAction("New &Discussion...", self)
new_discussion.setShortcut(QKeySequence("Ctrl+D"))
new_discussion.triggered.connect(self._launch_global_discussion)
file_menu.addAction(new_discussion)
file_menu.addSeparator()
export_workspace = QAction("Export &Workspace...", self)
export_workspace.triggered.connect(self._export_workspace)
file_menu.addAction(export_workspace)
import_workspace = QAction("&Import Workspace...", self)
import_workspace.triggered.connect(self._import_workspace)
file_menu.addAction(import_workspace)
file_menu.addSeparator()
@ -135,6 +149,11 @@ class MainWindow(QMainWindow):
toggle_projects.triggered.connect(self._toggle_project_panel)
view_menu.addAction(toggle_projects)
global_dashboard = QAction("&Global Dashboard", self)
global_dashboard.setShortcut(QKeySequence("Ctrl+G"))
global_dashboard.triggered.connect(self._open_global_dashboard)
view_menu.addAction(global_dashboard)
view_menu.addSeparator()
split_h = QAction("Split &Horizontal", self)
@ -166,13 +185,14 @@ class MainWindow(QMainWindow):
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
view_menu.addAction(prev_pane)
# Reports menu
reports_menu = menubar.addMenu("&Reports")
# Reports menu - only show if docs/progress tracking is enabled
if paths.is_docs_enabled:
reports_menu = menubar.addMenu("&Reports")
weekly_report = QAction("&Weekly Progress Report...", self)
weekly_report.setShortcut(QKeySequence("Ctrl+R"))
weekly_report.triggered.connect(self._show_weekly_report)
reports_menu.addAction(weekly_report)
weekly_report = QAction("&Weekly Progress Report...", self)
weekly_report.setShortcut(QKeySequence("Ctrl+R"))
weekly_report.triggered.connect(self._show_weekly_report)
reports_menu.addAction(weekly_report)
# Terminal menu
terminal_menu = menubar.addMenu("&Terminal")
@ -275,7 +295,7 @@ class MainWindow(QMainWindow):
"""Launch orchestrated-discussions UI in the root projects directory with new dialog."""
from PySide6.QtWidgets import QMessageBox
projects_root = Path.home() / "PycharmProjects"
projects_root = paths.projects_root
try:
subprocess.Popen(
@ -326,6 +346,25 @@ class MainWindow(QMainWindow):
else:
self.project_list.show()
def _open_global_dashboard(self):
"""Open the global dashboard in the active pane."""
from development_hub.views.dashboard.global_dashboard import GlobalDashboard
pane = self.workspace.get_active_pane()
if not pane:
return
# Check if global dashboard already exists in this pane
for i in range(pane.tab_widget.count()):
widget = pane.tab_widget.widget(i)
if isinstance(widget, GlobalDashboard):
pane.tab_widget.setCurrentIndex(i)
return
# Create new global dashboard
self.workspace.add_global_dashboard()
self._update_status()
def _split_horizontal(self):
"""Split the active pane horizontally (creates left/right panes)."""
self.workspace.split_horizontal()
@ -356,6 +395,68 @@ class MainWindow(QMainWindow):
dialog = SettingsDialog(self)
dialog.exec()
def _export_workspace(self):
"""Export current settings to a workspace file."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
# Suggest filename
default_name = "devhub-workspace.yaml"
path, _ = QFileDialog.getSaveFileName(
self,
"Export Workspace",
str(Path.home() / default_name),
"Workspace Files (*.yaml *.yml);;All Files (*)"
)
if path:
try:
self.settings.export_workspace(Path(path))
QMessageBox.information(
self,
"Export Complete",
f"Workspace exported to:\n{path}"
)
except Exception as e:
QMessageBox.critical(
self,
"Export Failed",
f"Failed to export workspace:\n{e}"
)
def _import_workspace(self):
"""Import settings from a workspace file."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
path, _ = QFileDialog.getOpenFileName(
self,
"Import Workspace",
str(Path.home()),
"Workspace Files (*.yaml *.yml);;All Files (*)"
)
if path:
try:
results = self.settings.import_workspace(Path(path))
imported = results.get("imported", [])
warnings = results.get("warnings", [])
msg = f"Successfully imported: {', '.join(imported)}"
if warnings:
msg += f"\n\nWarnings:\n" + "\n".join(warnings)
QMessageBox.information(self, "Import Complete", msg)
# Refresh project list with new settings
self.project_list.refresh()
self._update_status()
except Exception as e:
QMessageBox.critical(
self,
"Import Failed",
f"Failed to import workspace:\n{e}"
)
def _show_about(self):
"""Show about dialog."""
from PySide6.QtWidgets import QMessageBox
@ -371,6 +472,7 @@ class MainWindow(QMainWindow):
"<ul>"
"<li><b>Ctrl+Z</b> - Undo (dashboard)</li>"
"<li><b>Ctrl+Shift+Z</b> - Redo (dashboard)</li>"
"<li><b>Ctrl+G</b> - Global Dashboard</li>"
"<li><b>Ctrl+Shift+T</b> - New terminal tab</li>"
"<li><b>Ctrl+Shift+W</b> - Close current tab</li>"
"<li><b>Ctrl+Shift+D</b> - Split pane horizontal</li>"
@ -417,8 +519,8 @@ class MainWindow(QMainWindow):
if not self.settings.auto_start_docs_server:
return
project_docs = Path.home() / "PycharmProjects" / "project-docs"
if not project_docs.exists():
project_docs = paths.project_docs_dir
if not project_docs or not project_docs.exists():
return
# Kill any existing docusaurus process first

View File

@ -45,6 +45,23 @@ class Deliverable:
return cls(name=name, status=status)
@dataclass
class LinkedDocument:
"""A document linked to a milestone (plan, spec, notes, etc.)."""
path: str # Relative path to document
title: str = "" # Display title (derived from path if empty)
doc_type: str = "plan" # "plan", "spec", "notes", "discussion"
@property
def display_title(self) -> str:
"""Get display title, falling back to filename."""
if self.title:
return self.title
# Extract filename without extension
from pathlib import Path
return Path(self.path).stem.replace("-", " ").replace("_", " ").title()
@dataclass
class Milestone:
"""A milestone with deliverables and progress tracking."""
@ -56,6 +73,8 @@ class Milestone:
deliverables: list[Deliverable] = field(default_factory=list)
notes: str = ""
description: str = "" # Free-form description text
plan_path: str | None = None # Path to linked plan document
documents: list[LinkedDocument] = field(default_factory=list) # Additional linked docs
@property
def is_complete(self) -> bool:

View File

@ -15,6 +15,8 @@ class Todo:
tags: list[str] = field(default_factory=list) # from #tag in text
completed_date: str | None = None
blocker_reason: str | None = None # For blocked items
phase: str | None = None # from [Phase 1] prefix or #phase-1 tag
notes: str | None = None # Additional context, shown as tooltip
@property
def priority_order(self) -> int:
@ -33,9 +35,21 @@ class Todo:
self.blocker_reason = None
def to_markdown(self) -> str:
"""Convert to markdown checkbox format."""
"""Convert to markdown checkbox format.
Returns a string that may be multiple lines if notes are present:
- [ ] Task text @M4
> Notes about this task
"""
checkbox = "[x]" if self.completed else "[ ]"
parts = [f"- {checkbox} {self.text}"]
# Include phase prefix if present
if self.phase:
text_part = f"[{self.phase}] {self.text}"
else:
text_part = self.text
parts = [f"- {checkbox} {text_part}"]
if self.milestone:
parts.append(f"@{self.milestone}")
@ -51,7 +65,13 @@ class Todo:
if self.blocker_reason:
parts.append(f"- {self.blocker_reason}")
return " ".join(parts)
result = " ".join(parts)
# Add notes as indented blockquote line
if self.notes:
result += f"\n > {self.notes}"
return result
@dataclass

View File

@ -251,6 +251,24 @@ class BaseParser:
return date, text.strip()
return None, text
@staticmethod
def extract_phase(text: str) -> tuple[str | None, str]:
"""Extract [Phase N] or [Phase Name] prefix from text.
Args:
text: Text potentially starting with [Phase 1] or similar
Returns:
Tuple of (phase string or None, text without phase prefix)
"""
# Match [Phase X] at the start of the text
match = re.match(r"^\[([^\]]+)\]\s*", text)
if match:
phase = match.group(1)
text = text[match.end():].strip()
return phase, text
return None, text
@staticmethod
def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
"""Parse a markdown table.

View File

@ -9,6 +9,7 @@ from development_hub.models.goal import (
GoalList,
Milestone,
Deliverable,
LinkedDocument,
MilestoneStatus,
DeliverableStatus,
)
@ -211,6 +212,8 @@ class MilestonesParser(BaseParser):
deliverables = []
notes = ""
description_lines = []
plan_path = None
documents = []
lines = content.split("\n")
table_lines = []
@ -242,6 +245,21 @@ class MilestonesParser(BaseParser):
notes = notes_match.group(1).strip()
continue
# Parse **Plan**: path/to/file.md
plan_match = re.match(r"\*\*Plan\*\*:\s*(.+)", line_stripped)
if plan_match:
plan_path = plan_match.group(1).strip()
continue
# Parse **Documents**: path1, path2, ... (optional multi-doc field)
docs_match = re.match(r"\*\*Documents\*\*:\s*(.+)", line_stripped)
if docs_match:
doc_paths = [p.strip() for p in docs_match.group(1).split(",")]
for path in doc_paths:
if path:
documents.append(LinkedDocument(path=path))
continue
# Parse deliverables table
if line_stripped.startswith("|"):
in_table = True
@ -270,6 +288,8 @@ class MilestonesParser(BaseParser):
deliverables=deliverables,
notes=notes,
description=" ".join(description_lines),
plan_path=plan_path,
documents=documents,
)
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
@ -407,6 +427,15 @@ class MilestonesParser(BaseParser):
if milestone.notes:
lines.append(f"**Notes**: {milestone.notes}")
# Plan path
if milestone.plan_path:
lines.append(f"**Plan**: {milestone.plan_path}")
# Additional documents
if milestone.documents:
doc_paths = ", ".join(d.path for d in milestone.documents)
lines.append(f"**Documents**: {doc_paths}")
# Description (after fields, before table)
if milestone.description:
lines.append("")

View File

@ -16,6 +16,7 @@ class TodosParser(BaseParser):
Expected format:
## Active Tasks / High Priority / Medium Priority / Low Priority
- [ ] Task description @project #tag
> Optional notes on indented line
## Completed
- [x] Done task (2026-01-06)
@ -38,10 +39,25 @@ class TodosParser(BaseParser):
current_priority = "medium"
table_lines = []
in_table = False
pending_todo = None # Track todo waiting for possible notes
for line in self.body.split("\n"):
lines = self.body.split("\n")
for line in lines:
line_stripped = line.strip()
# Check for indented note line (follows a todo)
if pending_todo and line.startswith(" ") and line_stripped.startswith(">"):
# Extract note text (remove leading > and whitespace)
note_text = line_stripped[1:].strip()
pending_todo.notes = note_text
todo_list.add_todo(pending_todo)
pending_todo = None
continue
elif pending_todo:
# Previous line was a todo but this isn't a note - add the todo
todo_list.add_todo(pending_todo)
pending_todo = None
# Detect section headers
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
# Save any pending table
@ -93,7 +109,12 @@ class TodosParser(BaseParser):
if line_stripped.startswith("- ["):
todo = self._parse_todo_line(line_stripped, current_priority, current_section)
if todo:
todo_list.add_todo(todo)
# Don't add yet - wait to see if next line has notes
pending_todo = todo
# Handle any remaining pending todo
if pending_todo:
todo_list.add_todo(pending_todo)
# Handle any remaining table
if in_table and table_lines:
@ -105,7 +126,7 @@ class TodosParser(BaseParser):
"""Parse a single todo line.
Args:
line: Line like "- [ ] Task @M1 @project #tag"
line: Line like "- [ ] [Phase 1] Task @M1 @project #tag"
priority: Current priority level
section: Current section name
@ -117,6 +138,9 @@ class TodosParser(BaseParser):
if not text:
return None
# Extract phase prefix first (e.g., [Phase 1])
phase, text = self.extract_phase(text)
# Extract metadata (milestone first, then project)
milestone, text = self.extract_milestone_tag(text)
project, text = self.extract_project_tag(text)
@ -156,6 +180,7 @@ class TodosParser(BaseParser):
tags=tags,
completed_date=date,
blocker_reason=blocker_reason if section == "blocked" else None,
phase=phase,
)
# Handle blocked items

View File

@ -0,0 +1,159 @@
"""Centralized path resolution for Development Hub.
This module provides a singleton PathResolver that resolves all paths
from settings, making it easy to use configurable paths throughout
the application.
"""
import shutil
from pathlib import Path
class PathResolver:
"""Resolves all application paths from settings.
This singleton class provides a centralized way to access all
configurable paths in the application. It reads from Settings
and provides derived paths for documentation, projects, etc.
"""
_instance = None
def __new__(cls):
"""Singleton pattern."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@property
def _settings(self):
"""Get settings instance (lazy import to avoid circular imports)."""
from development_hub.settings import Settings
return Settings()
@property
def projects_root(self) -> Path:
"""Get the primary projects root directory."""
paths = self._settings.project_search_paths
if paths:
return Path(paths[0]).expanduser()
return Path.home() / "Projects"
@property
def docs_root(self) -> Path:
"""Get the documentation root path."""
return self._settings.docs_root
@property
def project_docs_dir(self) -> Path | None:
"""Get the project-docs directory (Docusaurus root)."""
return self._settings.docusaurus_path
@property
def progress_dir(self) -> Path:
"""Get the progress log directory."""
return self._settings.progress_dir
@property
def build_script(self) -> Path | None:
"""Get the build-public-docs.sh script path."""
if self.project_docs_dir:
script = self.project_docs_dir / "scripts" / "build-public-docs.sh"
if script.exists():
return script
return None
@property
def cmdforge_path(self) -> Path | None:
"""Get the CmdForge installation path."""
return self._settings.cmdforge_path
@property
def cmdforge_executable(self) -> Path | None:
"""Get the CmdForge executable path."""
# Check explicit path first
if self.cmdforge_path:
venv_cmdforge = self.cmdforge_path / ".venv" / "bin" / "cmdforge"
if venv_cmdforge.exists():
return venv_cmdforge
# Check PATH
which_result = shutil.which("cmdforge")
if which_result:
return Path(which_result)
return None
@property
def is_docs_enabled(self) -> bool:
"""Check if documentation features are enabled."""
return self._settings.is_docs_enabled
@property
def is_cmdforge_available(self) -> bool:
"""Check if CmdForge is available."""
return self._settings.is_cmdforge_available
@property
def is_git_configured(self) -> bool:
"""Check if git hosting is configured."""
return self._settings.is_git_configured
@property
def effective_docs_mode(self) -> str:
"""Get the effective documentation mode."""
return self._settings.effective_docs_mode
def project_docs_path(self, project_key: str) -> Path:
"""Get the documentation path for a specific project.
Args:
project_key: The project key (e.g., 'cmdforge', 'ramble')
Returns:
Path to the project's documentation directory
"""
return self.docs_root / "projects" / project_key
def git_url(self, owner: str | None = None, repo: str | None = None) -> str:
"""Get the git repository URL for a project.
Args:
owner: Repository owner (default: configured owner)
repo: Repository name
Returns:
Full git repository URL or empty string if not configured
"""
settings = self._settings
if not settings.git_host_url:
return ""
owner = owner or settings.git_host_owner
if not owner:
return ""
if repo:
return f"{settings.git_host_url}/{owner}/{repo}"
return f"{settings.git_host_url}/{owner}"
def pages_url(self, owner: str | None = None, repo: str | None = None) -> str:
"""Get the documentation pages URL for a project.
Args:
owner: Repository owner (default: configured owner)
repo: Repository name
Returns:
Full pages URL or empty string if not configured
"""
settings = self._settings
pages_base = settings.pages_url
if not pages_base:
return ""
owner = owner or settings.git_host_owner
if not owner:
return ""
if repo:
return f"{pages_base}/{owner}/{repo}/"
return f"{pages_base}/{owner}/"
# Singleton instance for easy import
paths = PathResolver()

View File

@ -3,6 +3,10 @@
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from development_hub.paths import PathResolver
@dataclass
@ -35,15 +39,25 @@ class Project:
@property
def gitea_url(self) -> str:
"""URL to Gitea repository."""
"""URL to git repository."""
from development_hub.paths import paths
if self.owner and self.repo:
url = paths.git_url(self.owner, self.repo)
if url:
return url
# Fallback to hardcoded URL for backwards compatibility
return f"https://gitea.brrd.tech/{self.owner}/{self.repo}"
return ""
@property
def docs_url(self) -> str:
"""URL to public documentation."""
from development_hub.paths import paths
if self.owner and self.repo:
url = paths.pages_url(self.owner, self.repo)
if url:
return url
# Fallback to hardcoded URL for backwards compatibility
return f"https://pages.brrd.tech/{self.owner}/{self.repo}/"
return ""
@ -120,10 +134,11 @@ def _load_project_config() -> dict:
Returns:
Dictionary mapping project keys to their configuration.
"""
build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
from development_hub.paths import paths
build_script = paths.build_script
config = {}
if not build_script.exists():
if not build_script or not build_script.exists():
return config
pattern = r'PROJECT_CONFIG\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'

View File

@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
)
from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog, UpdateDocsThread
from development_hub.paths import paths
from development_hub.project_discovery import Project, discover_projects
@ -163,32 +164,39 @@ class ProjectListWidget(QWidget):
menu.addSeparator()
# View on Gitea
view_gitea = QAction("View on Gitea", self)
view_gitea.triggered.connect(lambda: webbrowser.open(project.gitea_url))
menu.addAction(view_gitea)
# Git hosting items - only show if git is configured and project has URL
if paths.is_git_configured and project.gitea_url:
view_git = QAction("View on Git Host", self)
view_git.triggered.connect(lambda: webbrowser.open(project.gitea_url))
menu.addAction(view_git)
# View Documentation
view_docs = QAction("View Documentation", self)
view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url))
menu.addAction(view_docs)
# Documentation items - only show if docs are enabled
if paths.is_docs_enabled:
# View Documentation - only if project has docs URL
if project.docs_url:
view_docs = QAction("View Documentation", self)
view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url))
menu.addAction(view_docs)
menu.addSeparator()
menu.addSeparator()
# Update Documentation (AI-powered)
update_docs = QAction("Update Documentation...", self)
update_docs.triggered.connect(lambda: self._update_docs(project))
menu.addAction(update_docs)
# Update Documentation (AI-powered) - only if cmdforge is available
if paths.is_cmdforge_available:
update_docs = QAction("Update Documentation...", self)
update_docs.triggered.connect(lambda: self._update_docs(project))
menu.addAction(update_docs)
# Deploy Docs
deploy_docs = QAction("Deploy Docs", self)
deploy_docs.triggered.connect(lambda: self._deploy_docs(project))
menu.addAction(deploy_docs)
# Deploy Docs - only if build script exists
if paths.build_script:
deploy_docs = QAction("Deploy Docs", self)
deploy_docs.triggered.connect(lambda: self._deploy_docs(project))
menu.addAction(deploy_docs)
# Rebuild Main Docs
rebuild_docs = QAction("Rebuild Main Docs", self)
rebuild_docs.triggered.connect(self._rebuild_main_docs)
menu.addAction(rebuild_docs)
# Rebuild Main Docs - only if project-docs mode
if paths.effective_docs_mode == "project-docs":
rebuild_docs = QAction("Rebuild Main Docs", self)
rebuild_docs.triggered.connect(self._rebuild_main_docs)
menu.addAction(rebuild_docs)
menu.exec(self.list_widget.mapToGlobal(position))
@ -242,12 +250,13 @@ class ProjectListWidget(QWidget):
def _deploy_docs(self, project: Project):
"""Deploy documentation for project asynchronously."""
build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
if not build_script.exists():
build_script = paths.build_script
if not build_script or not build_script.exists():
QMessageBox.warning(
self,
"Deploy Failed",
"Build script not found:\n\n" + str(build_script)
"Build script not found. Documentation features may not be configured.\n\n"
"Check Settings > Documentation Mode."
)
return
@ -472,7 +481,7 @@ class ProjectListWidget(QWidget):
return
# Determine docs path
docs_path = Path.home() / "PycharmProjects" / "project-docs" / "docs" / "projects" / project.key
docs_path = paths.project_docs_path(project.key)
doc_files = ["overview.md", "goals.md", "milestones.md", "todos.md"]
# Read existing docs as backup

View File

@ -3,6 +3,7 @@
from pathlib import Path
from development_hub.models.health import ProjectHealth, EcosystemHealth, GitInfo
from development_hub.paths import paths
from development_hub.services.git_service import GitService
from development_hub.parsers.todos_parser import TodosParser
from development_hub.parsers.goals_parser import GoalsParser
@ -16,11 +17,11 @@ class HealthChecker:
"""Initialize health checker.
Args:
projects_root: Root path for projects (default: ~/PycharmProjects)
docs_root: Root path for docs (default: ~/PycharmProjects/project-docs/docs)
projects_root: Root path for projects (default: from settings)
docs_root: Root path for docs (default: from settings)
"""
self.projects_root = projects_root or Path.home() / "PycharmProjects"
self.docs_root = docs_root or self.projects_root / "project-docs" / "docs"
self.projects_root = projects_root or paths.projects_root
self.docs_root = docs_root or paths.docs_root
def check_project(self, project: Project) -> ProjectHealth:
"""Check health of a single project.

View File

@ -3,6 +3,8 @@
from datetime import date
from pathlib import Path
from development_hub.paths import paths
class ProgressWriter:
"""Writes daily progress log entries to markdown files."""
@ -11,11 +13,9 @@ class ProgressWriter:
"""Initialize progress writer.
Args:
progress_dir: Directory for progress files. Defaults to project-docs/docs/progress.
progress_dir: Directory for progress files. Defaults to settings value.
"""
self._progress_dir = progress_dir or (
Path.home() / "PycharmProjects" / "project-docs" / "docs" / "progress"
)
self._progress_dir = progress_dir or paths.progress_dir
def get_today_path(self) -> Path:
"""Get path to today's progress file."""

View File

@ -24,6 +24,14 @@ class Settings:
"git_host_url": "", # e.g., "https://gitea.example.com" or "https://github.com"
"git_host_owner": "", # username or organization
"git_host_token": "", # API token (stored in settings, not ideal but simple)
# Documentation settings
"docs_mode": "auto", # "auto" | "standalone" | "project-docs"
"docs_root": "", # Empty = derive from mode
"docusaurus_path": "", # Path to project-docs
"pages_url": "", # Separate from git_host_url
# Integration paths
"cmdforge_path": "", # Override cmdforge location
"progress_dir": "", # Override progress directory
}
# Available editor choices with display names
@ -173,6 +181,234 @@ class Settings:
"""Check if git hosting is configured."""
return bool(self.git_host_type and self.git_host_url and self.git_host_owner)
@property
def docs_mode(self) -> str:
"""Documentation mode (auto, standalone, project-docs)."""
return self.get("docs_mode", "auto")
@docs_mode.setter
def docs_mode(self, value: str):
self.set("docs_mode", value)
@property
def effective_docs_mode(self) -> str:
"""Get effective docs mode, resolving 'auto' to actual mode."""
mode = self.docs_mode
if mode == "auto":
# Check if project-docs exists in default location
project_docs = Path(self.project_search_paths[0]) / "project-docs" if self.project_search_paths else None
if project_docs and project_docs.exists():
return "project-docs"
return "standalone"
return mode
@property
def docs_root(self) -> Path:
"""Get the documentation root path."""
explicit = self.get("docs_root", "")
if explicit:
return Path(explicit).expanduser()
# Derive from mode
if self.effective_docs_mode == "project-docs":
return self.docusaurus_path / "docs" if self.docusaurus_path else Path.home() / ".local" / "share" / "development-hub" / "docs"
return Path.home() / ".local" / "share" / "development-hub" / "docs"
@docs_root.setter
def docs_root(self, value: Path | str):
self.set("docs_root", str(value) if value else "")
@property
def docusaurus_path(self) -> Path | None:
"""Get the path to the docusaurus project."""
explicit = self.get("docusaurus_path", "")
if explicit:
return Path(explicit).expanduser()
# Default location
if self.project_search_paths:
default = Path(self.project_search_paths[0]) / "project-docs"
if default.exists():
return default
return None
@docusaurus_path.setter
def docusaurus_path(self, value: Path | str | None):
self.set("docusaurus_path", str(value) if value else "")
@property
def pages_url(self) -> str:
"""Get the pages URL for documentation hosting."""
explicit = self.get("pages_url", "")
if explicit:
return explicit
# Derive from git_host_url for gitea
if self.git_host_type == "gitea" and self.git_host_url:
# https://gitea.example.com -> https://pages.example.com
import re
match = re.match(r"https?://gitea\.(.+)", self.git_host_url)
if match:
return f"https://pages.{match.group(1)}"
return ""
@pages_url.setter
def pages_url(self, value: str):
self.set("pages_url", value)
@property
def cmdforge_path(self) -> Path | None:
"""Get the CmdForge path."""
explicit = self.get("cmdforge_path", "")
if explicit:
return Path(explicit).expanduser()
# Default locations
if self.project_search_paths:
default = Path(self.project_search_paths[0]) / "CmdForge"
if default.exists():
return default
return None
@cmdforge_path.setter
def cmdforge_path(self, value: Path | str | None):
self.set("cmdforge_path", str(value) if value else "")
@property
def progress_dir(self) -> Path:
"""Get the progress log directory."""
explicit = self.get("progress_dir", "")
if explicit:
return Path(explicit).expanduser()
# Default: under docs_root
return self.docs_root / "progress"
@progress_dir.setter
def progress_dir(self, value: Path | str):
self.set("progress_dir", str(value) if value else "")
@property
def is_docs_enabled(self) -> bool:
"""Check if documentation features are available."""
mode = self.effective_docs_mode
if mode == "project-docs":
return self.docusaurus_path is not None and self.docusaurus_path.exists()
return True # standalone mode always works
@property
def is_cmdforge_available(self) -> bool:
"""Check if CmdForge is available."""
import shutil
# Check explicit path
if self.cmdforge_path and (self.cmdforge_path / ".venv" / "bin" / "cmdforge").exists():
return True
# Check PATH
return shutil.which("cmdforge") is not None
def export_workspace(self, path: Path) -> None:
"""Export current settings to a workspace file.
Args:
path: Path to write the workspace YAML file
"""
import yaml
workspace = {
"name": f"{self.git_host_owner}'s Development Environment" if self.git_host_owner else "Development Environment",
"version": 1,
"paths": {
"projects_root": self.project_search_paths[0] if self.project_search_paths else str(Path.home() / "Projects"),
},
"documentation": {
"enabled": self.is_docs_enabled,
"mode": self.docs_mode,
"auto_start_server": self.auto_start_docs_server,
},
}
# Add docs_root if explicit
if self.get("docs_root"):
workspace["paths"]["docs_root"] = self.get("docs_root")
# Add docusaurus_path if in project-docs mode
if self.effective_docs_mode == "project-docs" and self.docusaurus_path:
workspace["documentation"]["docusaurus_path"] = str(self.docusaurus_path)
# Add git hosting if configured
if self.is_git_configured:
workspace["git_hosting"] = {
"type": self.git_host_type,
"url": self.git_host_url,
"owner": self.git_host_owner,
}
if self.pages_url:
workspace["git_hosting"]["pages_url"] = self.pages_url
# Add feature flags
workspace["features"] = {
"cmdforge_integration": self.is_cmdforge_available,
"progress_tracking": True,
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
yaml.dump(workspace, f, default_flow_style=False, sort_keys=False)
def import_workspace(self, path: Path) -> dict:
"""Import settings from a workspace file.
Args:
path: Path to the workspace YAML file
Returns:
Dict with import results including any warnings
"""
import yaml
with open(path) as f:
workspace = yaml.safe_load(f)
results = {"imported": [], "warnings": []}
# Import paths
if "paths" in workspace:
paths = workspace["paths"]
if "projects_root" in paths:
projects_root = str(Path(paths["projects_root"]).expanduser())
self.project_search_paths = [projects_root]
self.default_project_path = Path(projects_root)
results["imported"].append("projects_root")
if "docs_root" in paths:
self.docs_root = paths["docs_root"]
results["imported"].append("docs_root")
# Import documentation settings
if "documentation" in workspace:
docs = workspace["documentation"]
if "mode" in docs:
self.docs_mode = docs["mode"]
results["imported"].append("docs_mode")
if "docusaurus_path" in docs:
self.docusaurus_path = docs["docusaurus_path"]
results["imported"].append("docusaurus_path")
if "auto_start_server" in docs:
self.auto_start_docs_server = docs["auto_start_server"]
results["imported"].append("auto_start_docs_server")
# Import git hosting
if "git_hosting" in workspace:
git = workspace["git_hosting"]
if "type" in git:
self.git_host_type = git["type"]
if "url" in git:
self.git_host_url = git["url"]
if "owner" in git:
self.git_host_owner = git["owner"]
if "pages_url" in git:
self.pages_url = git["pages_url"]
results["imported"].append("git_hosting")
# Mark setup as completed
self.set("setup_completed", True)
return results
def save_session(self, state: dict):
"""Save session state to file."""
self._session_file.parent.mkdir(parents=True, exist_ok=True)

View File

@ -1,10 +1,13 @@
"""Background worker for running goals audit."""
import json
import subprocess
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from development_hub.paths import paths
class AuditWorker(QObject):
"""Background worker for running goals audit."""
@ -12,19 +15,37 @@ class AuditWorker(QObject):
finished = Signal(str, bool) # output, success
error = Signal(str)
def __init__(self, project_key: str, project_path: Path | None = None):
def __init__(
self,
project_name: str,
goals_path: Path,
milestones_path: Path | None = None,
project_dir: Path | None = None,
):
super().__init__()
self.project_key = project_key # e.g. "development-hub" or "global"
self.project_path = project_path
self.project_name = project_name
self.goals_path = goals_path
self.milestones_path = milestones_path
self.project_dir = project_dir
self._process: subprocess.Popen | None = None
self._cancelled = False
def run(self):
"""Execute the audit command."""
cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge"
if not cmdforge_path.exists():
cmdforge_path = paths.cmdforge_executable
if not cmdforge_path:
cmdforge_path = Path("cmdforge")
# Build JSON input for the tool
input_data = {
"project_name": self.project_name,
"goals_path": str(self.goals_path),
}
if self.milestones_path:
input_data["milestones_path"] = str(self.milestones_path)
if self.project_dir:
input_data["project_dir"] = str(self.project_dir)
try:
self._process = subprocess.Popen(
[str(cmdforge_path), "run", "audit-goals"],
@ -32,11 +53,11 @@ class AuditWorker(QObject):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(self.project_path) if self.project_path and self.project_path.exists() else None,
cwd=str(self.project_dir) if self.project_dir and self.project_dir.exists() else None,
)
# Pass project key to stdin (tool expects project name, not file content)
stdout, stderr = self._process.communicate(input=self.project_key)
# Pass JSON input to tool
stdout, stderr = self._process.communicate(input=json.dumps(input_data))
if self._cancelled:
return

View File

@ -1,11 +1,13 @@
"""Data storage and file management for the dashboard."""
import time
from datetime import date
from pathlib import Path
from typing import Any
from PySide6.QtCore import QObject, Signal, QFileSystemWatcher, QTimer
from development_hub.paths import paths
from development_hub.project_discovery import Project
from development_hub.parsers.todos_parser import TodosParser
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
@ -57,7 +59,7 @@ class DashboardDataStore(QObject):
super().__init__(parent)
self._project = project
self._is_global = project is None
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
self._docs_root = paths.docs_root
# File paths
self._todos_path: Path | None = None
@ -77,7 +79,9 @@ class DashboardDataStore(QObject):
self._ideas_list: list[Goal] | None = None
# File watching
self._ignoring_file_change = False
# Use timestamp to ignore file events shortly after saving (handles multiple events)
self._last_save_time: float = 0
self._save_ignore_window = 0.5 # seconds to ignore file events after save
self._file_watcher = QFileSystemWatcher(self)
self._file_watcher.fileChanged.connect(self._on_file_changed)
self._file_watcher.directoryChanged.connect(self._on_directory_changed)
@ -261,14 +265,14 @@ class DashboardDataStore(QObject):
def save_todos(self) -> None:
"""Save todos with file watcher temporarily disabled."""
if self._todos_parser and self._todo_list:
self._ignoring_file_change = True
self._last_save_time = time.time()
self._todos_parser.save(self._todo_list)
self._ensure_file_watched()
def save_goals(self) -> None:
"""Save goals with file watcher temporarily disabled."""
if self._goals_parser and self._goal_list:
self._ignoring_file_change = True
self._last_save_time = time.time()
saver = GoalsSaver(self._goals_path, self._goals_parser.frontmatter)
saver.save(self._goal_list)
self._ensure_file_watched()
@ -276,7 +280,7 @@ class DashboardDataStore(QObject):
def save_milestones(self) -> None:
"""Save milestones with file watcher temporarily disabled."""
if self._milestones_parser and self._milestones_list:
self._ignoring_file_change = True
self._last_save_time = time.time()
self._milestones_parser.save(self._milestones_list)
self._ensure_file_watched()
@ -285,7 +289,7 @@ class DashboardDataStore(QObject):
if self._is_global or not self._ideas_path:
return
self._ignoring_file_change = True
self._last_save_time = time.time()
lines = []
lines.append("---")
@ -313,7 +317,7 @@ class DashboardDataStore(QObject):
def toggle_todo(
self, todo: Todo, completed: bool
) -> tuple[str, str, str, bool, str | None]:
) -> tuple[str, str, bool, str | None, str | None, str | None]:
"""Toggle a todo's completion state.
Args:
@ -321,17 +325,19 @@ class DashboardDataStore(QObject):
completed: New completed state
Returns:
Undo data tuple: (text, priority, was_completed, milestone)
Undo data tuple: (text, priority, was_completed, milestone, phase, notes)
"""
if not self._todo_list:
return (todo.text, todo.priority, not completed, todo.milestone)
return (todo.text, todo.priority, not completed, todo.milestone,
todo.phase, todo.notes)
was_completed = not completed
undo_data = (todo.text, todo.priority, was_completed, todo.milestone)
undo_data = (todo.text, todo.priority, was_completed, todo.milestone,
todo.phase, todo.notes)
# Find and update the todo
for t in self._todo_list.all_todos:
if t.text == todo.text and t.priority == todo.priority:
if t.text == todo.text and t.priority == todo.priority and t.phase == todo.phase:
self._todo_list.remove_todo(t)
if completed:
t.mark_complete()
@ -349,16 +355,17 @@ class DashboardDataStore(QObject):
return undo_data
def delete_todo(self, todo: Todo) -> tuple[str, str, bool, str | None]:
def delete_todo(self, todo: Todo) -> tuple[str, str, bool, str | None, str | None, str | None]:
"""Delete a todo.
Args:
todo: The todo to delete
Returns:
Undo data tuple: (text, priority, was_completed, milestone)
Undo data tuple: (text, priority, was_completed, milestone, phase, notes)
"""
undo_data = (todo.text, todo.priority, todo.completed, todo.milestone)
undo_data = (todo.text, todo.priority, todo.completed, todo.milestone,
todo.phase, todo.notes)
if self._todo_list:
self._todo_list.remove_todo(todo)
@ -369,7 +376,7 @@ class DashboardDataStore(QObject):
def edit_todo(
self, todo: Todo, old_text: str, new_text: str
) -> tuple[str, str, bool, str]:
) -> tuple[str, str, bool, str, str | None, str | None]:
"""Edit a todo's text.
Args:
@ -378,13 +385,14 @@ class DashboardDataStore(QObject):
new_text: New text
Returns:
Undo data tuple: (old_text, priority, was_completed, new_text)
Undo data tuple: (old_text, priority, was_completed, new_text, phase, notes)
"""
undo_data = (old_text, todo.priority, todo.completed, new_text)
undo_data = (old_text, todo.priority, todo.completed, new_text,
todo.phase, todo.notes)
if self._todo_list:
for t in self._todo_list.all_todos:
if t.text == old_text and t.priority == todo.priority:
if t.text == old_text and t.priority == todo.priority and t.phase == todo.phase:
t.text = new_text
break
todo.text = new_text
@ -393,18 +401,26 @@ class DashboardDataStore(QObject):
return undo_data
def add_todo(self, text: str, priority: str, milestone: str | None = None) -> None:
def add_todo(
self, text: str, priority: str, milestone: str | None = None,
phase: str | None = None, notes: str | None = None
) -> None:
"""Add a new todo.
Args:
text: Todo text
priority: Priority level (high, medium, low)
milestone: Optional milestone ID to link to
phase: Optional phase label for grouping
notes: Optional additional context
"""
if not self._todo_list:
return
todo = Todo(text=text, completed=False, priority=priority, milestone=milestone)
todo = Todo(
text=text, completed=False, priority=priority,
milestone=milestone, phase=phase, notes=notes
)
self._todo_list.add_todo(todo)
self.save_todos()
self.todos_changed.emit()
@ -851,10 +867,13 @@ class DashboardDataStore(QObject):
if file_path.parent.exists() and parent not in watched_dirs:
self._file_watcher.addPath(parent)
def _is_within_save_window(self) -> bool:
"""Check if we're within the ignore window after a save."""
return (time.time() - self._last_save_time) < self._save_ignore_window
def _on_file_changed(self, path: str) -> None:
"""Handle file modification events."""
if self._ignoring_file_change:
self._ignoring_file_change = False
if self._is_within_save_window():
self._ensure_file_watched()
return
@ -872,8 +891,7 @@ class DashboardDataStore(QObject):
def _on_directory_changed(self, path: str) -> None:
"""Handle directory changes."""
if self._ignoring_file_change:
self._ignoring_file_change = False
if self._is_within_save_window():
self._ensure_file_watched()
return

View File

@ -1,6 +1,8 @@
"""Project dashboard view."""
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import Qt, Signal, QTimer, QThread
@ -20,6 +22,7 @@ from PySide6.QtWidgets import (
QApplication,
)
from development_hub.paths import paths
from development_hub.project_discovery import Project
from development_hub.services.health_checker import HealthChecker
from development_hub.parsers.progress_parser import ProgressLogManager
@ -31,6 +34,7 @@ from development_hub.views.dashboard.undo_manager import UndoAction, UndoManager
from development_hub.services.git_service import GitService
from development_hub.models.health import HealthStatus
from development_hub.parsers.base import atomic_write
from development_hub.parsers.todos_parser import TodosParser
from development_hub.models.todo import Todo, TodoList
from development_hub.parsers.goals_parser import MilestonesParser, GoalsParser
@ -77,7 +81,7 @@ class ProjectDashboard(QWidget):
super().__init__(parent)
self.project = project
self.is_global = project is None
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
self._docs_root = paths.docs_root
# Data store manages all data loading/saving and file watching
self._data_store = DashboardDataStore(project, self)
@ -302,9 +306,9 @@ class ProjectDashboard(QWidget):
action_type = data[0] if isinstance(data[0], str) and data[0].startswith("todo_") else None
# Data format depends on action type (from push_action)
# For toggle: (text, priority, was_completed, milestone)
# For delete: (text, priority, was_completed, milestone)
# For edit: (old_text, priority, was_completed, new_text)
# For toggle: (text, priority, was_completed, milestone, phase, notes)
# For delete: (text, priority, was_completed, milestone, phase, notes)
# For edit: (old_text, priority, was_completed, new_text, phase, notes)
# Reload fresh data
self._data_store.load_todos()
@ -313,74 +317,97 @@ class ProjectDashboard(QWidget):
if not todo_list:
return None
# Determine action type from the undo action that was pushed
# The data was stored when the action was performed
if len(data) == 4 and isinstance(data[3], (str, type(None))):
# Could be toggle (text, priority, was_completed, milestone) or delete
text, priority, was_completed, milestone = data
# Try to find the todo - if found, it's a toggle undo
todo = None
for t in todo_list.all_todos:
if t.text == text:
todo = t
break
# Handle both old 4-element tuples (backward compat) and new 6-element tuples
if len(data) >= 4:
# Extract common fields
text = data[0]
priority = data[1]
was_completed = data[2]
field4 = data[3]
# Extract phase and notes if present (new format)
phase = data[4] if len(data) > 4 else None
notes = data[5] if len(data) > 5 else None
if todo:
# Toggle undo - restore previous state
current_completed = todo.completed
redo_data = (text, priority, current_completed, milestone)
# Determine if this is toggle/delete or edit based on field4
# For edit: field4 is new_text (the current text after edit)
# For toggle/delete: field4 is milestone (can be None or "M1" etc)
todo_list.remove_todo(todo)
todo.completed = was_completed
todo.priority = priority
if was_completed:
todo.mark_complete()
else:
todo.completed_date = None
todo_list.add_todo(todo)
# Try to find a todo with the text - if found and field4 looks like new_text, it's edit
is_edit = False
if isinstance(field4, str) and not (field4 is None or field4.startswith("M")):
# field4 is likely new_text (edit case)
is_edit = True
old_text = text
new_text = field4
milestone = None # Edit doesn't track milestone changes
if is_edit:
# Edit undo: (old_text, priority, was_completed, new_text, phase, notes)
for todo in todo_list.all_todos:
if todo.text == new_text and todo.priority == priority:
todo.text = old_text
break
self._data_store.save_todos()
self._load_todos()
self._load_milestones()
return UndoAction(
action_type="todo_toggle",
data=redo_data,
description=f"Toggle: {text[:40]}"
action_type="todo_edit",
data=(old_text, priority, was_completed, new_text, phase, notes),
description=f"Edit: {old_text[:20]} -> {new_text[:20]}"
)
else:
# Delete undo - restore the todo
restored = Todo(text=text, priority=priority, completed=was_completed, milestone=milestone)
if was_completed:
restored.mark_complete()
todo_list.add_todo(restored)
# Toggle or delete: field4 is milestone
milestone = field4
self._data_store.save_todos()
self._load_todos()
# Try to find the todo - if found, it's a toggle undo
todo = None
for t in todo_list.all_todos:
if t.text == text and (phase is None or t.phase == phase):
todo = t
break
return UndoAction(
action_type="todo_delete",
data=(text, priority, was_completed, milestone),
description=f"Delete: {text[:40]}"
)
if todo:
# Toggle undo - restore previous state
current_completed = todo.completed
redo_data = (text, priority, current_completed, milestone, phase, notes)
elif len(data) == 4 and isinstance(data[3], str):
# Edit undo: (old_text, priority, was_completed, new_text)
old_text, priority, was_completed, new_text = data
todo_list.remove_todo(todo)
todo.completed = was_completed
todo.priority = priority
if was_completed:
todo.mark_complete()
else:
todo.completed_date = None
todo_list.add_todo(todo)
for todo in todo_list.all_todos:
if todo.text == new_text and todo.priority == priority:
todo.text = old_text
break
self._data_store.save_todos()
self._load_todos()
self._load_milestones()
self._data_store.save_todos()
self._load_todos()
return UndoAction(
action_type="todo_toggle",
data=redo_data,
description=f"Toggle: {text[:40]}"
)
else:
# Delete undo - restore the todo
restored = Todo(
text=text, priority=priority, completed=was_completed,
milestone=milestone, phase=phase, notes=notes
)
if was_completed:
restored.mark_complete()
todo_list.add_todo(restored)
return UndoAction(
action_type="todo_edit",
data=(old_text, priority, was_completed, new_text),
description=f"Edit: {old_text[:20]} -> {new_text[:20]}"
)
self._data_store.save_todos()
self._load_todos()
return UndoAction(
action_type="todo_delete",
data=(text, priority, was_completed, milestone, phase, notes),
description=f"Delete: {text[:40]}"
)
return None
@ -399,13 +426,23 @@ class ProjectDashboard(QWidget):
if not todo_list:
return None
if len(data) == 4:
text, priority, was_completed, milestone_or_new_text = data
# Handle both old 4-element tuples (backward compat) and new 6-element tuples
if len(data) >= 4:
text = data[0]
priority = data[1]
was_completed = data[2]
field4 = data[3]
# Extract phase and notes if present (new format)
phase = data[4] if len(data) > 4 else None
notes = data[5] if len(data) > 5 else None
if isinstance(milestone_or_new_text, str) and not (milestone_or_new_text is None or milestone_or_new_text.startswith("M")):
# Edit redo: (old_text, priority, was_completed, new_text)
# Determine if this is edit or toggle/delete
is_edit = isinstance(field4, str) and not (field4 is None or field4.startswith("M"))
if is_edit:
# Edit redo: (old_text, priority, was_completed, new_text, phase, notes)
old_text = text
new_text = milestone_or_new_text
new_text = field4
for todo in todo_list.all_todos:
if todo.text == old_text and todo.priority == priority:
@ -417,22 +454,22 @@ class ProjectDashboard(QWidget):
return UndoAction(
action_type="todo_edit",
data=(old_text, priority, was_completed, new_text),
data=(old_text, priority, was_completed, new_text, phase, notes),
description=f"Edit: {old_text[:20]} -> {new_text[:20]}"
)
else:
# Toggle or delete redo
milestone = milestone_or_new_text
milestone = field4
todo = None
for t in todo_list.all_todos:
if t.text == text:
if t.text == text and (phase is None or t.phase == phase):
todo = t
break
if todo:
# Toggle redo
current_completed = todo.completed
undo_data = (text, priority, current_completed, milestone)
undo_data = (text, priority, current_completed, milestone, phase, notes)
todo_list.remove_todo(todo)
todo.completed = was_completed
@ -455,7 +492,7 @@ class ProjectDashboard(QWidget):
else:
# Delete redo - delete the todo again
for t in todo_list.all_todos[:]:
if t.text == text:
if t.text == text and (phase is None or t.phase == phase):
todo_list.remove_todo(t)
break
@ -464,7 +501,7 @@ class ProjectDashboard(QWidget):
return UndoAction(
action_type="todo_delete",
data=(text, priority, was_completed, milestone),
data=(text, priority, was_completed, milestone, phase, notes),
description=f"Delete: {text[:40]}"
)
@ -904,15 +941,17 @@ class ProjectDashboard(QWidget):
goals_header_layout.addStretch()
audit_goals_btn = QPushButton("Audit")
audit_goals_btn.setStyleSheet(self._button_style())
audit_goals_btn.clicked.connect(self._audit_goals)
goals_header_layout.addWidget(audit_goals_btn)
# Only show AI-powered buttons if CmdForge is available
if paths.is_cmdforge_available:
audit_goals_btn = QPushButton("Audit")
audit_goals_btn.setStyleSheet(self._button_style())
audit_goals_btn.clicked.connect(self._audit_goals)
goals_header_layout.addWidget(audit_goals_btn)
realign_goals_btn = QPushButton("Re-align")
realign_goals_btn.setStyleSheet(self._button_style())
realign_goals_btn.clicked.connect(self._realign_goals)
goals_header_layout.addWidget(realign_goals_btn)
realign_goals_btn = QPushButton("Re-align")
realign_goals_btn.setStyleSheet(self._button_style())
realign_goals_btn.clicked.connect(self._realign_goals)
goals_header_layout.addWidget(realign_goals_btn)
edit_goals_btn = QPushButton("Edit")
edit_goals_btn.setStyleSheet(self._button_style())
@ -1690,6 +1729,10 @@ class ProjectDashboard(QWidget):
widget.todo_added.connect(self._on_milestone_todo_added)
widget.todo_start_discussion.connect(self._on_todo_start_discussion)
widget.todo_edited.connect(self._on_todo_edited)
# Milestone-level actions
widget.milestone_start_discussion.connect(self._on_milestone_start_discussion)
widget.milestone_import_plan.connect(self._on_milestone_import_plan)
widget.milestone_view_plan.connect(self._on_milestone_view_plan)
# Keep legacy signals for deliverables mode (fallback)
widget.deliverable_toggled.connect(self._on_deliverable_toggled)
widget.deliverable_added.connect(self._on_deliverable_added)
@ -2039,6 +2082,7 @@ class ProjectDashboard(QWidget):
# Refresh UI
self._load_todos()
self._load_milestones()
# Show toast
self.toast.show_message(
@ -2065,6 +2109,7 @@ class ProjectDashboard(QWidget):
# Refresh UI
self._load_todos()
self._load_milestones()
# Show toast
self.toast.show_message(
@ -2129,6 +2174,168 @@ class ProjectDashboard(QWidget):
"pip install -e ~/PycharmProjects/orchestrated-discussions"
)
def _on_milestone_start_discussion(self, milestone):
"""Handle starting a discussion for a milestone."""
from PySide6.QtWidgets import QMessageBox
# Cannot start discussion in global mode (no project context)
if self.is_global:
QMessageBox.warning(
self,
"Not Available",
"Cannot start discussion in global view.\n\n"
"Open a project dashboard to start discussions."
)
return
# Generate title from milestone
title = f"{milestone.id.lower()}-{milestone.name.lower().replace(' ', '-')}"
title = "".join(c for c in title if c.isalnum() or c == "-")
# Get linked todos for context
todo_list = self._data_store.todo_list
linked_todos = todo_list.get_by_milestone(milestone.id) if todo_list else []
todos_text = ", ".join(f'"{t.text}"' for t in linked_todos[:5])
if len(linked_todos) > 5:
todos_text += f" and {len(linked_todos) - 5} more"
# Build context description
context = (
f"This is an open discussion about planning the implementation of "
f"milestone {milestone.id}: {milestone.name} for the {self.project.title} project. "
f"Target: {milestone.target}. "
)
if linked_todos:
context += f"Current tasks: {todos_text}. "
if milestone.description:
context += f"Description: {milestone.description}. "
context += (
f"The goal of this discussion is to produce a detailed implementation plan "
f"with actionable steps that can be imported as todos."
)
template = "general"
participants = "architect,pragmatist"
try:
subprocess.Popen(
[
"discussions", "ui",
"--new",
"--title", title,
"--template", template,
"--participants", participants,
"--context", context,
],
cwd=self.project.path,
start_new_session=True,
)
except FileNotFoundError:
QMessageBox.warning(
self,
"Discussions Not Found",
"The 'discussions' command was not found.\n\n"
"Install orchestrated-discussions:\n"
"pip install -e ~/PycharmProjects/orchestrated-discussions"
)
def _on_milestone_import_plan(self, milestone):
"""Handle importing a plan into a milestone."""
from development_hub.dialogs import ImportPlanDialog
# Cannot import in global mode
if self.is_global:
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(
self,
"Not Available",
"Cannot import plan in global view.\n\n"
"Open a project dashboard to import plans."
)
return
dialog = ImportPlanDialog(
milestone,
project_path=self.project.path if self.project else None,
parent=self
)
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get selected tasks
tasks = dialog.get_selected_tasks()
# Add todos for each selected task
for task in tasks:
self._data_store.add_todo(
text=task["text"],
priority=task["priority"],
milestone=milestone.id,
phase=task.get("phase"),
notes=task.get("notes"),
)
# Save plan to file if requested
if dialog.should_save_plan() and self.project:
plan_text = dialog.get_plan_text()
plans_dir = Path(self.project.path) / "docs" / "plans"
plans_dir.mkdir(parents=True, exist_ok=True)
plan_filename = f"{milestone.id.lower()}-{milestone.name.lower().replace(' ', '-')}.md"
plan_path = plans_dir / plan_filename
plan_path.write_text(f"# {milestone.id}: {milestone.name}\n\n{plan_text}")
# Update milestone plan_path
milestone.plan_path = f"docs/plans/{plan_filename}"
self._data_store.save_milestones()
# Update milestone description if requested
if dialog.should_update_description():
overview = dialog.get_overview()
if overview:
milestone.description = overview
self._data_store.save_milestones()
# Refresh display
self._load_todos()
self._load_milestones()
# Show toast
self.toast.show_message(
f"Imported {len(tasks)} tasks to {milestone.id}",
can_undo=False,
can_redo=False,
)
self._position_toast()
def _on_milestone_view_plan(self, milestone, plan_path: str):
"""Handle viewing a milestone's linked plan document."""
if not self.project:
return
full_path = Path(self.project.path) / plan_path
if full_path.exists():
# Open in default editor
import subprocess
try:
subprocess.Popen(["xdg-open", str(full_path)])
except FileNotFoundError:
# Try other methods
try:
subprocess.Popen(["open", str(full_path)]) # macOS
except FileNotFoundError:
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(
self,
"Plan Location",
f"Plan file located at:\n{full_path}"
)
else:
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(
self,
"Plan Not Found",
f"Plan file not found:\n{plan_path}"
)
def _on_filter_changed(self, index):
"""Handle filter dropdown change."""
self._current_filter = self.todo_filter.currentData()
@ -2470,13 +2677,15 @@ class ProjectDashboard(QWidget):
if confirm != QMessageBox.StandardButton.Ok:
return
# Determine project key for the audit tool
# Determine paths for the audit tool
if self.is_global:
project_key = "global"
project_root = None
project_name = "Global Goals"
milestones_path = self._docs_root / "goals" / "milestones.md"
project_dir = None
else:
project_key = self.project.key
project_root = Path(self.project.path) if self.project.path else None
project_name = self.project.key
milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md"
project_dir = Path(self.project.path) if self.project.path else None
# Create progress dialog
self._audit_dialog = QDialog(self)
@ -2518,7 +2727,12 @@ class ProjectDashboard(QWidget):
# Create worker and thread
self._audit_thread = QThread()
self._audit_worker = AuditWorker(project_key, project_root)
self._audit_worker = AuditWorker(
project_name=project_name,
goals_path=goals_path,
milestones_path=milestones_path if milestones_path.exists() else None,
project_dir=project_dir,
)
self._audit_worker.moveToThread(self._audit_thread)
# Connect signals

View File

@ -761,6 +761,9 @@ class MilestoneWidget(QFrame):
todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos
todo_start_discussion = Signal(object) # (todo) - for starting discussion from todo
todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing
milestone_start_discussion = Signal(object) # (milestone) - for starting discussion from milestone
milestone_import_plan = Signal(object) # (milestone) - for importing a plan to this milestone
milestone_view_plan = Signal(object, str) # (milestone, plan_path) - for viewing linked plan
def __init__(
self,
@ -848,6 +851,14 @@ class MilestoneWidget(QFrame):
self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
content_layout.addWidget(self.target_label)
# Plan link (if set)
if self.milestone.plan_path:
self.plan_link = QLabel(f'📄 <a href="#" style="color: #4a9eff;">View Implementation Plan</a>')
self.plan_link.setStyleSheet("font-size: 11px;")
self.plan_link.setOpenExternalLinks(False)
self.plan_link.linkActivated.connect(self._on_view_plan_clicked)
content_layout.addWidget(self.plan_link)
# Deliverables list
self.deliverables_container = QWidget()
self.deliverables_layout = QVBoxLayout(self.deliverables_container)
@ -1005,7 +1016,9 @@ class MilestoneWidget(QFrame):
""")
def _load_deliverables(self):
"""Load deliverable/todo widgets."""
"""Load deliverable/todo widgets, grouped by phase if applicable."""
import re
# Clear existing
while self.deliverables_layout.count():
item = self.deliverables_layout.takeAt(0)
@ -1014,14 +1027,49 @@ class MilestoneWidget(QFrame):
# Show todos if available (preferred mode), otherwise show deliverables
if self._todos:
# Group todos by phase
phases = {}
for todo in self._todos:
# Show priority badge instead of milestone (since milestone is obvious)
widget = TodoItemWidget(todo, show_priority=True)
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)
phase_key = todo.phase or "_ungrouped"
phases.setdefault(phase_key, []).append(todo)
# Sort phase names: "Phase 1" before "Phase 2", then alphabetic
def phase_sort_key(name):
if name == "_ungrouped":
return (999, "") # Ungrouped at end
match = re.search(r'\d+', name)
return (int(match.group()) if match else 500, name)
sorted_phases = sorted(phases.keys(), key=phase_sort_key)
# If there's only one phase (or no phases), don't show phase headers
show_phase_headers = len(sorted_phases) > 1 or (
len(sorted_phases) == 1 and sorted_phases[0] != "_ungrouped"
)
for phase_name in sorted_phases:
phase_todos = phases[phase_name]
# Add phase header if needed
if show_phase_headers and phase_name != "_ungrouped":
phase_header = QLabel(f"{phase_name}")
phase_header.setStyleSheet(
"color: #888888; font-size: 11px; font-weight: bold; "
"padding: 4px 0px 2px 0px;"
)
self.deliverables_layout.addWidget(phase_header)
# Add todos for this phase
for todo in phase_todos:
widget = TodoItemWidget(todo, show_priority=True)
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)
# Set tooltip with notes if available
if todo.notes:
widget.setToolTip(todo.notes)
self.deliverables_layout.addWidget(widget)
else:
# Legacy: Add deliverables
for deliverable in self.milestone.deliverables:
@ -1078,3 +1126,48 @@ class MilestoneWidget(QFrame):
self._update_progress()
self._update_status_icon()
self._load_deliverables()
def _on_view_plan_clicked(self, link):
"""Handle view plan link click."""
if self.milestone.plan_path:
self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path)
def contextMenuEvent(self, event):
"""Show context menu on right-click."""
menu = QMenu(self)
menu.setStyleSheet("""
QMenu {
background-color: #2d2d2d;
border: 1px solid #3d3d3d;
padding: 4px;
}
QMenu::item {
padding: 8px 24px;
color: #e0e0e0;
}
QMenu::item:selected {
background-color: #3d6a99;
}
""")
# Start Discussion action
discuss_action = menu.addAction("Start Discussion...")
discuss_action.triggered.connect(
lambda: self.milestone_start_discussion.emit(self.milestone)
)
# Import Plan action
import_action = menu.addAction("Import Plan...")
import_action.triggered.connect(
lambda: self.milestone_import_plan.emit(self.milestone)
)
# View Plan action (if plan exists)
if self.milestone.plan_path:
menu.addSeparator()
view_action = menu.addAction("View Plan")
view_action.triggered.connect(
lambda: self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path)
)
menu.exec(event.globalPos())

308
tests/test_paths.py Normal file
View File

@ -0,0 +1,308 @@
"""Tests for the PathResolver module."""
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from development_hub.paths import PathResolver, paths
from development_hub.settings import Settings
class TestPathResolver:
"""Test PathResolver path resolution."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create isolated settings for testing."""
Settings._instance = None
PathResolver._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
yield settings
Settings._instance = None
PathResolver._instance = None
def test_singleton_pattern(self):
"""PathResolver is a singleton."""
PathResolver._instance = None
resolver1 = PathResolver()
resolver2 = PathResolver()
assert resolver1 is resolver2
PathResolver._instance = None
def test_projects_root_from_settings(self, isolated_settings, tmp_path):
"""Projects root comes from settings."""
isolated_settings.project_search_paths = [str(tmp_path / "my-projects")]
resolver = PathResolver()
assert resolver.projects_root == tmp_path / "my-projects"
def test_projects_root_default(self, isolated_settings, tmp_path):
"""Projects root defaults to ~/Projects when not set."""
isolated_settings.project_search_paths = []
resolver = PathResolver()
assert resolver.projects_root == Path.home() / "Projects"
def test_docs_root_from_settings(self, isolated_settings, tmp_path):
"""Docs root comes from settings."""
isolated_settings.docs_mode = "standalone"
resolver = PathResolver()
docs_root = resolver.docs_root
assert "development-hub" in str(docs_root)
def test_project_docs_dir(self, isolated_settings, tmp_path):
"""Project docs dir returns docusaurus path."""
project_docs = tmp_path / "project-docs"
project_docs.mkdir()
isolated_settings.project_search_paths = [str(tmp_path)]
resolver = PathResolver()
assert resolver.project_docs_dir == project_docs
def test_project_docs_dir_none_when_not_exists(self, isolated_settings, tmp_path):
"""Project docs dir is None when not configured."""
isolated_settings.project_search_paths = [str(tmp_path)]
# No project-docs folder
resolver = PathResolver()
assert resolver.project_docs_dir is None
def test_progress_dir(self, isolated_settings):
"""Progress dir comes from settings."""
resolver = PathResolver()
progress = resolver.progress_dir
assert isinstance(progress, Path)
def test_build_script_when_exists(self, isolated_settings, tmp_path):
"""Build script returns path when it exists."""
project_docs = tmp_path / "project-docs"
scripts_dir = project_docs / "scripts"
scripts_dir.mkdir(parents=True)
build_script = scripts_dir / "build-public-docs.sh"
build_script.touch()
isolated_settings.project_search_paths = [str(tmp_path)]
resolver = PathResolver()
assert resolver.build_script == build_script
def test_build_script_none_when_not_exists(self, isolated_settings, tmp_path):
"""Build script is None when it doesn't exist."""
isolated_settings.project_search_paths = [str(tmp_path)]
resolver = PathResolver()
assert resolver.build_script is None
def test_project_docs_path(self, isolated_settings):
"""project_docs_path returns correct path for project."""
resolver = PathResolver()
path = resolver.project_docs_path("my-project")
assert path.name == "my-project"
assert "projects" in str(path)
def test_git_url_not_configured(self, isolated_settings):
"""git_url returns empty string when not configured."""
resolver = PathResolver()
assert resolver.git_url() == ""
assert resolver.git_url("owner", "repo") == ""
def test_git_url_configured(self, isolated_settings):
"""git_url returns correct URL when configured."""
isolated_settings.git_host_type = "github"
isolated_settings.git_host_url = "https://github.com"
isolated_settings.git_host_owner = "testowner"
resolver = PathResolver()
assert resolver.git_url() == "https://github.com/testowner"
assert resolver.git_url("testowner", "testrepo") == "https://github.com/testowner/testrepo"
def test_git_url_custom_owner(self, isolated_settings):
"""git_url can use custom owner."""
isolated_settings.git_host_type = "github"
isolated_settings.git_host_url = "https://github.com"
isolated_settings.git_host_owner = "default"
resolver = PathResolver()
assert resolver.git_url("custom", "repo") == "https://github.com/custom/repo"
def test_pages_url_not_configured(self, isolated_settings):
"""pages_url returns empty string when not configured."""
resolver = PathResolver()
assert resolver.pages_url() == ""
def test_pages_url_configured(self, isolated_settings):
"""pages_url returns correct URL when configured."""
isolated_settings.git_host_owner = "testowner"
isolated_settings.pages_url = "https://pages.example.com"
resolver = PathResolver()
assert "pages.example.com" in resolver.pages_url()
assert "testowner" in resolver.pages_url()
def test_is_docs_enabled(self, isolated_settings):
"""is_docs_enabled reflects settings."""
isolated_settings.docs_mode = "standalone"
resolver = PathResolver()
assert resolver.is_docs_enabled == True
def test_is_git_configured(self, isolated_settings):
"""is_git_configured reflects settings."""
resolver = PathResolver()
assert resolver.is_git_configured == False
isolated_settings.git_host_type = "github"
isolated_settings.git_host_url = "https://github.com"
isolated_settings.git_host_owner = "user"
assert resolver.is_git_configured == True
def test_effective_docs_mode(self, isolated_settings, tmp_path):
"""effective_docs_mode reflects settings."""
isolated_settings.project_search_paths = [str(tmp_path)]
isolated_settings.docs_mode = "auto"
resolver = PathResolver()
# No project-docs, so standalone
assert resolver.effective_docs_mode == "standalone"
# Create project-docs
(tmp_path / "project-docs").mkdir()
assert resolver.effective_docs_mode == "project-docs"
class TestCmdForgeAvailability:
"""Test CmdForge availability checks."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create isolated settings for testing."""
Settings._instance = None
PathResolver._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
# Use isolated paths to prevent finding real CmdForge
settings.project_search_paths = [str(tmp_path)]
yield settings
Settings._instance = None
PathResolver._instance = None
def test_cmdforge_path_explicit(self, isolated_settings, tmp_path):
"""CmdForge path from explicit setting."""
cmdforge_dir = tmp_path / "CmdForge"
cmdforge_dir.mkdir()
isolated_settings.cmdforge_path = cmdforge_dir
resolver = PathResolver()
assert resolver.cmdforge_path == cmdforge_dir
def test_cmdforge_executable_from_venv(self, isolated_settings, tmp_path):
"""CmdForge executable found in venv."""
cmdforge_dir = tmp_path / "CmdForge"
venv_bin = cmdforge_dir / ".venv" / "bin"
venv_bin.mkdir(parents=True)
cmdforge_exe = venv_bin / "cmdforge"
cmdforge_exe.touch()
isolated_settings.cmdforge_path = cmdforge_dir
resolver = PathResolver()
assert resolver.cmdforge_executable == cmdforge_exe
@patch("development_hub.paths.shutil.which")
def test_cmdforge_executable_from_path(self, mock_which, isolated_settings, tmp_path):
"""CmdForge executable found in PATH when not in explicit location."""
mock_which.return_value = "/usr/local/bin/cmdforge"
# Ensure no explicit cmdforge path is set
isolated_settings.cmdforge_path = None
resolver = PathResolver()
exe = resolver.cmdforge_executable
assert exe == Path("/usr/local/bin/cmdforge")
@patch("development_hub.paths.shutil.which")
def test_cmdforge_executable_not_found(self, mock_which, isolated_settings, tmp_path):
"""CmdForge executable None when not found."""
mock_which.return_value = None
# Ensure no explicit cmdforge path is set
isolated_settings.cmdforge_path = None
resolver = PathResolver()
assert resolver.cmdforge_executable is None
@patch("development_hub.paths.shutil.which")
def test_is_cmdforge_available_false(self, mock_which, isolated_settings, tmp_path):
"""is_cmdforge_available is False when not found."""
mock_which.return_value = None
isolated_settings.cmdforge_path = None
resolver = PathResolver()
assert resolver.is_cmdforge_available == False
@patch("development_hub.paths.shutil.which")
def test_is_cmdforge_available_true(self, mock_which, isolated_settings, tmp_path):
"""is_cmdforge_available is True when found in PATH."""
mock_which.return_value = "/usr/bin/cmdforge"
isolated_settings.cmdforge_path = None
resolver = PathResolver()
assert resolver.is_cmdforge_available == True
class TestGlobalPathsInstance:
"""Test the global paths singleton instance."""
def test_paths_is_path_resolver(self):
"""Global paths is a PathResolver instance."""
assert isinstance(paths, PathResolver)
def test_paths_has_expected_properties(self):
"""Global paths has expected properties."""
assert hasattr(paths, "projects_root")
assert hasattr(paths, "docs_root")
assert hasattr(paths, "project_docs_dir")
assert hasattr(paths, "progress_dir")
assert hasattr(paths, "build_script")
assert hasattr(paths, "cmdforge_path")
assert hasattr(paths, "cmdforge_executable")
assert hasattr(paths, "is_docs_enabled")
assert hasattr(paths, "is_cmdforge_available")
assert hasattr(paths, "is_git_configured")
def test_paths_has_expected_methods(self):
"""Global paths has expected methods."""
assert callable(paths.project_docs_path)
assert callable(paths.git_url)
assert callable(paths.pages_url)
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -245,5 +245,291 @@ class TestGitHostSettings:
assert settings.is_git_configured == False
class TestDocsModeSettings:
"""Test documentation mode configuration."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create an isolated Settings instance."""
Settings._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
yield settings
Settings._instance = None
def test_default_docs_mode(self, isolated_settings):
"""Default docs mode is auto."""
settings = isolated_settings
assert settings.docs_mode == "auto"
def test_set_docs_mode(self, isolated_settings):
"""Can set docs mode."""
settings = isolated_settings
settings.docs_mode = "standalone"
assert settings.docs_mode == "standalone"
settings.docs_mode = "project-docs"
assert settings.docs_mode == "project-docs"
def test_effective_docs_mode_standalone_when_no_project_docs(self, isolated_settings, tmp_path):
"""Effective mode is standalone when project-docs doesn't exist."""
settings = isolated_settings
settings.project_search_paths = [str(tmp_path)]
# No project-docs folder exists
assert settings.effective_docs_mode == "standalone"
def test_effective_docs_mode_project_docs_when_exists(self, isolated_settings, tmp_path):
"""Effective mode is project-docs when folder exists."""
settings = isolated_settings
settings.project_search_paths = [str(tmp_path)]
# Create project-docs folder
(tmp_path / "project-docs").mkdir()
assert settings.effective_docs_mode == "project-docs"
def test_explicit_mode_overrides_auto(self, isolated_settings, tmp_path):
"""Explicit mode setting overrides auto-detection."""
settings = isolated_settings
settings.project_search_paths = [str(tmp_path)]
# Create project-docs (would trigger project-docs mode in auto)
(tmp_path / "project-docs").mkdir()
# But explicitly set to standalone
settings.docs_mode = "standalone"
assert settings.effective_docs_mode == "standalone"
def test_docs_root_standalone_mode(self, isolated_settings):
"""Docs root in standalone mode uses local share."""
settings = isolated_settings
settings.docs_mode = "standalone"
docs_root = settings.docs_root
assert ".local/share/development-hub" in str(docs_root)
def test_docusaurus_path_property(self, isolated_settings, tmp_path):
"""Can set and get docusaurus path."""
settings = isolated_settings
settings.docusaurus_path = tmp_path / "my-docs"
assert settings.docusaurus_path == tmp_path / "my-docs"
def test_pages_url_property(self, isolated_settings):
"""Can set and get pages URL."""
settings = isolated_settings
settings.pages_url = "https://pages.example.com"
assert settings.pages_url == "https://pages.example.com"
def test_pages_url_derived_from_gitea(self, isolated_settings):
"""Pages URL can be derived from gitea URL."""
settings = isolated_settings
settings.git_host_type = "gitea"
settings.git_host_url = "https://gitea.example.com"
# When not explicitly set, should derive
assert "pages.example.com" in settings.pages_url
def test_is_docs_enabled_standalone(self, isolated_settings):
"""Docs are always enabled in standalone mode."""
settings = isolated_settings
settings.docs_mode = "standalone"
assert settings.is_docs_enabled == True
def test_cmdforge_path_property(self, isolated_settings, tmp_path):
"""Can set and get cmdforge path."""
settings = isolated_settings
settings.cmdforge_path = tmp_path / "CmdForge"
assert settings.cmdforge_path == tmp_path / "CmdForge"
def test_progress_dir_property(self, isolated_settings, tmp_path):
"""Can set and get progress directory."""
settings = isolated_settings
settings.progress_dir = tmp_path / "progress"
assert settings.progress_dir == tmp_path / "progress"
class TestWorkspaceExportImport:
"""Test workspace file export/import."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create an isolated Settings instance."""
Settings._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
yield settings
Settings._instance = None
def test_export_workspace_creates_file(self, isolated_settings, tmp_path):
"""Export creates a YAML workspace file."""
settings = isolated_settings
workspace_path = tmp_path / "workspace.yaml"
settings.export_workspace(workspace_path)
assert workspace_path.exists()
def test_export_workspace_contains_required_fields(self, isolated_settings, tmp_path):
"""Exported workspace contains required fields."""
import yaml
settings = isolated_settings
settings.project_search_paths = [str(tmp_path / "projects")]
workspace_path = tmp_path / "workspace.yaml"
settings.export_workspace(workspace_path)
with open(workspace_path) as f:
workspace = yaml.safe_load(f)
assert "name" in workspace
assert "version" in workspace
assert workspace["version"] == 1
assert "paths" in workspace
assert "projects_root" in workspace["paths"]
assert "documentation" in workspace
assert "features" in workspace
def test_export_includes_git_hosting_when_configured(self, isolated_settings, tmp_path):
"""Exported workspace includes git hosting when configured."""
import yaml
settings = isolated_settings
settings.git_host_type = "github"
settings.git_host_url = "https://github.com"
settings.git_host_owner = "testuser"
workspace_path = tmp_path / "workspace.yaml"
settings.export_workspace(workspace_path)
with open(workspace_path) as f:
workspace = yaml.safe_load(f)
assert "git_hosting" in workspace
assert workspace["git_hosting"]["type"] == "github"
assert workspace["git_hosting"]["url"] == "https://github.com"
assert workspace["git_hosting"]["owner"] == "testuser"
def test_import_workspace_sets_values(self, isolated_settings, tmp_path):
"""Import workspace sets settings values."""
import yaml
settings = isolated_settings
workspace = {
"name": "Test Workspace",
"version": 1,
"paths": {
"projects_root": str(tmp_path / "my-projects")
},
"documentation": {
"mode": "standalone"
}
}
workspace_path = tmp_path / "workspace.yaml"
with open(workspace_path, "w") as f:
yaml.dump(workspace, f)
results = settings.import_workspace(workspace_path)
assert "projects_root" in results["imported"]
assert settings.project_search_paths == [str(tmp_path / "my-projects")]
assert settings.docs_mode == "standalone"
def test_import_workspace_sets_git_hosting(self, isolated_settings, tmp_path):
"""Import workspace sets git hosting values."""
import yaml
settings = isolated_settings
workspace = {
"name": "Test",
"version": 1,
"paths": {"projects_root": str(tmp_path)},
"git_hosting": {
"type": "gitea",
"url": "https://git.example.com",
"owner": "myorg",
"pages_url": "https://pages.example.com"
}
}
workspace_path = tmp_path / "workspace.yaml"
with open(workspace_path, "w") as f:
yaml.dump(workspace, f)
settings.import_workspace(workspace_path)
assert settings.git_host_type == "gitea"
assert settings.git_host_url == "https://git.example.com"
assert settings.git_host_owner == "myorg"
assert settings.pages_url == "https://pages.example.com"
def test_import_marks_setup_completed(self, isolated_settings, tmp_path):
"""Import workspace marks setup as completed."""
import yaml
settings = isolated_settings
workspace = {
"name": "Test",
"version": 1,
"paths": {"projects_root": str(tmp_path)}
}
workspace_path = tmp_path / "workspace.yaml"
with open(workspace_path, "w") as f:
yaml.dump(workspace, f)
settings.import_workspace(workspace_path)
assert settings.get("setup_completed") == True
def test_export_import_round_trip(self, isolated_settings, tmp_path):
"""Export and import preserves settings."""
settings = isolated_settings
# Configure settings
settings.project_search_paths = [str(tmp_path / "projects")]
settings.docs_mode = "project-docs"
settings.git_host_type = "gitlab"
settings.git_host_url = "https://gitlab.com"
settings.git_host_owner = "myuser"
# Export
workspace_path = tmp_path / "workspace.yaml"
settings.export_workspace(workspace_path)
# Reset settings
Settings._instance = None
new_settings = Settings()
# Import
new_settings.import_workspace(workspace_path)
assert new_settings.project_search_paths == [str(tmp_path / "projects")]
assert new_settings.docs_mode == "project-docs"
assert new_settings.git_host_type == "gitlab"
assert new_settings.git_host_url == "https://gitlab.com"
assert new_settings.git_host_owner == "myuser"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

227
tests/test_wizard.py Normal file
View File

@ -0,0 +1,227 @@
"""Tests for the SetupWizardDialog."""
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from development_hub.settings import Settings
class TestSetupWizardStructure:
"""Test SetupWizardDialog structure without Qt app."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create isolated settings for testing."""
Settings._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
yield settings
Settings._instance = None
def test_wizard_import_succeeds(self):
"""SetupWizardDialog can be imported."""
from development_hub.dialogs import SetupWizardDialog
assert SetupWizardDialog is not None
def test_wizard_has_expected_methods(self):
"""SetupWizardDialog has expected methods."""
from development_hub.dialogs import SetupWizardDialog
# Check for key methods
assert hasattr(SetupWizardDialog, "_create_welcome_page")
assert hasattr(SetupWizardDialog, "_create_simple_mode_page")
assert hasattr(SetupWizardDialog, "_create_docs_mode_page")
assert hasattr(SetupWizardDialog, "_create_import_page")
assert hasattr(SetupWizardDialog, "_finish_simple_mode")
assert hasattr(SetupWizardDialog, "_finish_docs_mode")
assert hasattr(SetupWizardDialog, "_finish_import")
class TestWizardSettingsIntegration:
"""Test wizard settings integration logic."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create isolated settings for testing."""
Settings._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
yield settings
Settings._instance = None
def test_simple_mode_sets_standalone_docs(self, isolated_settings, tmp_path):
"""Simple mode configuration sets standalone docs mode."""
settings = isolated_settings
# Simulate what _finish_simple_mode does
projects_path = tmp_path / "Projects"
projects_path.mkdir()
settings.default_project_path = projects_path
settings.project_search_paths = [str(projects_path)]
settings.docs_mode = "standalone"
settings.set("setup_completed", True)
assert settings.docs_mode == "standalone"
assert settings.get("setup_completed") == True
def test_docs_mode_sets_project_docs(self, isolated_settings, tmp_path):
"""Documentation mode configuration sets project-docs mode."""
settings = isolated_settings
# Simulate what _finish_docs_mode does
projects_path = tmp_path / "PycharmProjects"
projects_path.mkdir()
docusaurus_path = projects_path / "project-docs"
docusaurus_path.mkdir()
settings.default_project_path = projects_path
settings.project_search_paths = [str(projects_path)]
settings.docs_mode = "project-docs"
settings.docusaurus_path = docusaurus_path
settings.auto_start_docs_server = True
settings.git_host_type = "gitea"
settings.git_host_url = "https://gitea.example.com"
settings.git_host_owner = "testuser"
settings.set("setup_completed", True)
assert settings.docs_mode == "project-docs"
assert settings.docusaurus_path == docusaurus_path
assert settings.auto_start_docs_server == True
assert settings.is_git_configured == True
assert settings.get("setup_completed") == True
def test_import_workspace_integration(self, isolated_settings, tmp_path):
"""Import workspace correctly sets all values."""
import yaml
settings = isolated_settings
# Create a workspace file
workspace = {
"name": "Test Workspace",
"version": 1,
"paths": {
"projects_root": str(tmp_path / "projects")
},
"documentation": {
"mode": "project-docs",
"docusaurus_path": str(tmp_path / "docs"),
"auto_start_server": False
},
"git_hosting": {
"type": "github",
"url": "https://github.com",
"owner": "myorg"
}
}
workspace_path = tmp_path / "test-workspace.yaml"
with open(workspace_path, "w") as f:
yaml.dump(workspace, f)
# Import (simulates _finish_import)
results = settings.import_workspace(workspace_path)
assert settings.project_search_paths == [str(tmp_path / "projects")]
assert settings.docs_mode == "project-docs"
assert settings.docusaurus_path == tmp_path / "docs"
assert settings.auto_start_docs_server == False
assert settings.git_host_type == "github"
assert settings.git_host_owner == "myorg"
assert settings.get("setup_completed") == True
class TestWizardModeSelection:
"""Test wizard mode selection logic."""
def test_mode_values(self):
"""Test expected mode values."""
modes = ["simple", "docs", "import"]
# These are the modes used in the wizard
for mode in modes:
assert isinstance(mode, str)
def test_docs_mode_settings_values(self):
"""Test valid docs_mode setting values."""
valid_modes = ["auto", "standalone", "project-docs"]
for mode in valid_modes:
assert isinstance(mode, str)
class TestWizardGitTypeHandling:
"""Test git type handling in wizard."""
@pytest.fixture
def isolated_settings(self, tmp_path, monkeypatch):
"""Create isolated settings."""
Settings._instance = None
monkeypatch.setattr(Settings, "_settings_file", tmp_path / "settings.json")
monkeypatch.setattr(Settings, "_session_file", tmp_path / "session.json")
settings = Settings()
yield settings
Settings._instance = None
def test_github_configuration(self, isolated_settings):
"""GitHub configuration sets correct values."""
settings = isolated_settings
settings.git_host_type = "github"
settings.git_host_url = "https://github.com"
settings.git_host_owner = "testuser"
assert settings.is_git_configured == True
assert settings.git_host_type == "github"
def test_gitlab_configuration(self, isolated_settings):
"""GitLab configuration sets correct values."""
settings = isolated_settings
settings.git_host_type = "gitlab"
settings.git_host_url = "https://gitlab.com"
settings.git_host_owner = "testuser"
assert settings.is_git_configured == True
assert settings.git_host_type == "gitlab"
def test_gitea_configuration(self, isolated_settings):
"""Gitea configuration sets correct values."""
settings = isolated_settings
settings.git_host_type = "gitea"
settings.git_host_url = "https://gitea.example.com"
settings.git_host_owner = "testuser"
assert settings.is_git_configured == True
assert settings.git_host_type == "gitea"
def test_optional_git_in_simple_mode(self, isolated_settings, tmp_path):
"""Git is optional in simple mode."""
settings = isolated_settings
# Simple mode without git
settings.docs_mode = "standalone"
settings.project_search_paths = [str(tmp_path)]
settings.set("setup_completed", True)
# Git not configured is OK
assert settings.is_git_configured == False
assert settings.get("setup_completed") == True
if __name__ == "__main__":
pytest.main([__file__, "-v"])