Compare commits
5 Commits
43f7deb5a6
...
de024965a0
| Author | SHA1 | Date |
|---|---|---|
|
|
de024965a0 | |
|
|
20818956b3 | |
|
|
b60af09922 | |
|
|
5742b7088b | |
|
|
14885fb567 |
119
CLAUDE.md
119
CLAUDE.md
|
|
@ -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:
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,6 +85,9 @@ class MainWindow(QMainWindow):
|
|||
new_project.triggered.connect(self._new_project)
|
||||
file_menu.addAction(new_project)
|
||||
|
||||
# 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)
|
||||
|
|
@ -91,6 +95,16 @@ class MainWindow(QMainWindow):
|
|||
|
||||
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()
|
||||
|
||||
settings_action = QAction("&Settings...", self)
|
||||
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
||||
settings_action.triggered.connect(self._show_settings)
|
||||
|
|
@ -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,7 +185,8 @@ class MainWindow(QMainWindow):
|
|||
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
|
||||
view_menu.addAction(prev_pane)
|
||||
|
||||
# Reports menu
|
||||
# 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'
|
||||
|
|
|
|||
|
|
@ -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,29 +164,36 @@ 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
|
||||
# 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()
|
||||
|
||||
# Update Documentation (AI-powered)
|
||||
# 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 - 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 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,22 +317,60 @@ 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
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
|
||||
# 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()
|
||||
|
||||
return UndoAction(
|
||||
action_type="todo_edit",
|
||||
data=(old_text, priority, was_completed, new_text, phase, notes),
|
||||
description=f"Edit: {old_text[:20]} -> {new_text[:20]}"
|
||||
)
|
||||
else:
|
||||
# Toggle or delete: field4 is milestone
|
||||
milestone = field4
|
||||
|
||||
# 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:
|
||||
if t.text == text and (phase is None or t.phase == phase):
|
||||
todo = t
|
||||
break
|
||||
|
||||
if todo:
|
||||
# Toggle undo - restore previous state
|
||||
current_completed = todo.completed
|
||||
redo_data = (text, priority, current_completed, milestone)
|
||||
redo_data = (text, priority, current_completed, milestone, phase, notes)
|
||||
|
||||
todo_list.remove_todo(todo)
|
||||
todo.completed = was_completed
|
||||
|
|
@ -350,7 +392,10 @@ class ProjectDashboard(QWidget):
|
|||
)
|
||||
else:
|
||||
# Delete undo - restore the todo
|
||||
restored = Todo(text=text, priority=priority, completed=was_completed, milestone=milestone)
|
||||
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)
|
||||
|
|
@ -360,28 +405,10 @@ 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]}"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
return UndoAction(
|
||||
action_type="todo_edit",
|
||||
data=(old_text, priority, was_completed, new_text),
|
||||
description=f"Edit: {old_text[:20]} -> {new_text[:20]}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _handle_todo_redo(self, data: tuple) -> UndoAction | 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,6 +941,8 @@ class ProjectDashboard(QWidget):
|
|||
|
||||
goals_header_layout.addStretch()
|
||||
|
||||
# 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,13 +1027,48 @@ 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)
|
||||
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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
Loading…
Reference in New Issue