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
|
├── terminal_display.py # Terminal rendering with scrollback
|
||||||
├── pty_manager.py # PTY process management
|
├── pty_manager.py # PTY process management
|
||||||
├── project_discovery.py # Auto-discover projects from paths
|
├── 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
|
├── settings.py # JSON persistence for settings & session
|
||||||
|
├── paths.py # Centralized path resolution from settings
|
||||||
├── styles.py # Dark theme stylesheet
|
├── styles.py # Dark theme stylesheet
|
||||||
├── models/ # Data models (Goal, Todo, Milestone)
|
├── models/ # Data models (Goal, Todo, Milestone)
|
||||||
├── views/dashboard/ # Dashboard views and components
|
├── views/dashboard/ # Dashboard views and components
|
||||||
|
|
@ -61,6 +62,10 @@ src/development_hub/
|
||||||
| `TerminalDisplay` | terminal_display.py | Terminal rendering with scrollback buffer |
|
| `TerminalDisplay` | terminal_display.py | Terminal rendering with scrollback buffer |
|
||||||
| `AutoAcceptToast` | terminal_widget.py | Countdown toast for auto-accept prompts |
|
| `AutoAcceptToast` | terminal_widget.py | Countdown toast for auto-accept prompts |
|
||||||
| `AutoAcceptDialog` | dialogs.py | Duration selection for auto-accept |
|
| `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 |
|
| `DashboardDataStore` | views/dashboard/data_store.py | Data persistence with file watching |
|
||||||
| `UndoManager` | views/dashboard/undo_manager.py | Undo/redo action management |
|
| `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 List**: Discovers projects from configurable search paths
|
||||||
- **Project Filtering**: Filter box to quickly find projects by name or key
|
- **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
|
- **Splittable Panes**: Horizontal/vertical splits, each pane has own tab bar
|
||||||
- **Cross-Pane Tab Dragging**: Drag tabs between panes to reorganize workspace
|
- **Cross-Pane Tab Dragging**: Drag tabs between panes to reorganize workspace
|
||||||
- **Terminal**: Full PTY with pyte for TUI support (vim, htop work)
|
- **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
|
- **New Project Dialog**: Integrates with Ramble for voice input
|
||||||
- **Progress Reports**: Export weekly progress summaries from daily standups
|
- **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.)
|
- **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
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
|
@ -95,6 +107,7 @@ src/development_hub/
|
||||||
| `Ctrl+Shift+P` | Close active pane |
|
| `Ctrl+Shift+P` | Close active pane |
|
||||||
| `Ctrl+Alt+Left/Right` | Switch panes |
|
| `Ctrl+Alt+Left/Right` | Switch panes |
|
||||||
| `Ctrl+B` | Toggle project panel |
|
| `Ctrl+B` | Toggle project panel |
|
||||||
|
| `Ctrl+G` | Global Dashboard |
|
||||||
| `Ctrl+N` | New project dialog |
|
| `Ctrl+N` | New project dialog |
|
||||||
| `Ctrl+D` | New discussion |
|
| `Ctrl+D` | New discussion |
|
||||||
| `Ctrl+R` | Weekly progress report |
|
| `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.
|
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
|
## CLI Scripts
|
||||||
|
|
||||||
### `bin/new-project`
|
### `bin/new-project`
|
||||||
|
|
@ -213,6 +291,43 @@ Templates use these placeholders (replaced by sed):
|
||||||
|
|
||||||
## Configuration
|
## 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
|
### Gitea API Token
|
||||||
|
|
||||||
The script needs a Gitea API token to create repositories:
|
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)
|
### Project List (Left Panel)
|
||||||
- Auto-discovers projects from build configuration
|
- Auto-discovers projects from build configuration
|
||||||
- Double-click to open terminal at project root
|
- Filter box to quickly find projects
|
||||||
- Right-click context menu:
|
- Double-click to open project dashboard
|
||||||
- Open Terminal
|
- Right-click context menu (items shown based on configuration):
|
||||||
- Open in Editor
|
- Open Dashboard / Terminal / Editor
|
||||||
- View on Gitea
|
- View on Git Host / Documentation
|
||||||
- View Documentation
|
- Update / Deploy Docs
|
||||||
- Deploy Docs
|
|
||||||
|
|
||||||
### Workspace (Right Panel)
|
### Workspace (Right Panel)
|
||||||
- Splittable panes (horizontal/vertical)
|
- Splittable panes (horizontal/vertical)
|
||||||
|
|
@ -104,6 +103,20 @@ To create manually:
|
||||||
- Full PTY terminals with TUI support (vim, htop, etc.)
|
- Full PTY terminals with TUI support (vim, htop, etc.)
|
||||||
- Drag & drop files/folders to inject paths
|
- Drag & drop files/folders to inject paths
|
||||||
- Session persistence - remembers layout on restart
|
- 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
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
|
@ -116,6 +129,8 @@ To create manually:
|
||||||
| `Ctrl+Alt+Left/Right` | Switch panes |
|
| `Ctrl+Alt+Left/Right` | Switch panes |
|
||||||
| `Ctrl+B` | Toggle project panel |
|
| `Ctrl+B` | Toggle project panel |
|
||||||
| `Ctrl+N` | New project dialog |
|
| `Ctrl+N` | New project dialog |
|
||||||
|
| `Ctrl+G` | Global Dashboard |
|
||||||
|
| `Ctrl+R` | Weekly progress report |
|
||||||
|
|
||||||
## Full Documentation
|
## Full Documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,13 @@ requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PySide6>=6.4.0",
|
"PySide6>=6.4.0",
|
||||||
"pyte>=0.8.0",
|
"pyte>=0.8.0",
|
||||||
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
|
"pyyaml>=6.0",
|
||||||
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
|
# 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",
|
"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]
|
[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,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from development_hub.paths import paths
|
||||||
from development_hub.project_discovery import Project
|
from development_hub.project_discovery import Project
|
||||||
from development_hub.project_list import ProjectListWidget
|
from development_hub.project_list import ProjectListWidget
|
||||||
from development_hub.workspace import WorkspaceManager
|
from development_hub.workspace import WorkspaceManager
|
||||||
|
|
@ -84,6 +85,9 @@ class MainWindow(QMainWindow):
|
||||||
new_project.triggered.connect(self._new_project)
|
new_project.triggered.connect(self._new_project)
|
||||||
file_menu.addAction(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 = QAction("New &Discussion...", self)
|
||||||
new_discussion.setShortcut(QKeySequence("Ctrl+D"))
|
new_discussion.setShortcut(QKeySequence("Ctrl+D"))
|
||||||
new_discussion.triggered.connect(self._launch_global_discussion)
|
new_discussion.triggered.connect(self._launch_global_discussion)
|
||||||
|
|
@ -91,6 +95,16 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
file_menu.addSeparator()
|
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 = QAction("&Settings...", self)
|
||||||
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
||||||
settings_action.triggered.connect(self._show_settings)
|
settings_action.triggered.connect(self._show_settings)
|
||||||
|
|
@ -135,6 +149,11 @@ class MainWindow(QMainWindow):
|
||||||
toggle_projects.triggered.connect(self._toggle_project_panel)
|
toggle_projects.triggered.connect(self._toggle_project_panel)
|
||||||
view_menu.addAction(toggle_projects)
|
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()
|
view_menu.addSeparator()
|
||||||
|
|
||||||
split_h = QAction("Split &Horizontal", self)
|
split_h = QAction("Split &Horizontal", self)
|
||||||
|
|
@ -166,7 +185,8 @@ class MainWindow(QMainWindow):
|
||||||
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
|
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
|
||||||
view_menu.addAction(prev_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")
|
reports_menu = menubar.addMenu("&Reports")
|
||||||
|
|
||||||
weekly_report = QAction("&Weekly Progress Report...", self)
|
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."""
|
"""Launch orchestrated-discussions UI in the root projects directory with new dialog."""
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
projects_root = Path.home() / "PycharmProjects"
|
projects_root = paths.projects_root
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
|
|
@ -326,6 +346,25 @@ class MainWindow(QMainWindow):
|
||||||
else:
|
else:
|
||||||
self.project_list.show()
|
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):
|
def _split_horizontal(self):
|
||||||
"""Split the active pane horizontally (creates left/right panes)."""
|
"""Split the active pane horizontally (creates left/right panes)."""
|
||||||
self.workspace.split_horizontal()
|
self.workspace.split_horizontal()
|
||||||
|
|
@ -356,6 +395,68 @@ class MainWindow(QMainWindow):
|
||||||
dialog = SettingsDialog(self)
|
dialog = SettingsDialog(self)
|
||||||
dialog.exec()
|
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):
|
def _show_about(self):
|
||||||
"""Show about dialog."""
|
"""Show about dialog."""
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
@ -371,6 +472,7 @@ class MainWindow(QMainWindow):
|
||||||
"<ul>"
|
"<ul>"
|
||||||
"<li><b>Ctrl+Z</b> - Undo (dashboard)</li>"
|
"<li><b>Ctrl+Z</b> - Undo (dashboard)</li>"
|
||||||
"<li><b>Ctrl+Shift+Z</b> - Redo (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+T</b> - New terminal tab</li>"
|
||||||
"<li><b>Ctrl+Shift+W</b> - Close current tab</li>"
|
"<li><b>Ctrl+Shift+W</b> - Close current tab</li>"
|
||||||
"<li><b>Ctrl+Shift+D</b> - Split pane horizontal</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:
|
if not self.settings.auto_start_docs_server:
|
||||||
return
|
return
|
||||||
|
|
||||||
project_docs = Path.home() / "PycharmProjects" / "project-docs"
|
project_docs = paths.project_docs_dir
|
||||||
if not project_docs.exists():
|
if not project_docs or not project_docs.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Kill any existing docusaurus process first
|
# Kill any existing docusaurus process first
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,23 @@ class Deliverable:
|
||||||
return cls(name=name, status=status)
|
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
|
@dataclass
|
||||||
class Milestone:
|
class Milestone:
|
||||||
"""A milestone with deliverables and progress tracking."""
|
"""A milestone with deliverables and progress tracking."""
|
||||||
|
|
@ -56,6 +73,8 @@ class Milestone:
|
||||||
deliverables: list[Deliverable] = field(default_factory=list)
|
deliverables: list[Deliverable] = field(default_factory=list)
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
description: str = "" # Free-form description text
|
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
|
@property
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class Todo:
|
||||||
tags: list[str] = field(default_factory=list) # from #tag in text
|
tags: list[str] = field(default_factory=list) # from #tag in text
|
||||||
completed_date: str | None = None
|
completed_date: str | None = None
|
||||||
blocker_reason: str | None = None # For blocked items
|
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
|
@property
|
||||||
def priority_order(self) -> int:
|
def priority_order(self) -> int:
|
||||||
|
|
@ -33,9 +35,21 @@ class Todo:
|
||||||
self.blocker_reason = None
|
self.blocker_reason = None
|
||||||
|
|
||||||
def to_markdown(self) -> str:
|
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 "[ ]"
|
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:
|
if self.milestone:
|
||||||
parts.append(f"@{self.milestone}")
|
parts.append(f"@{self.milestone}")
|
||||||
|
|
@ -51,7 +65,13 @@ class Todo:
|
||||||
if self.blocker_reason:
|
if self.blocker_reason:
|
||||||
parts.append(f"- {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
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,24 @@ class BaseParser:
|
||||||
return date, text.strip()
|
return date, text.strip()
|
||||||
return None, text
|
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
|
@staticmethod
|
||||||
def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
|
def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
|
||||||
"""Parse a markdown table.
|
"""Parse a markdown table.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from development_hub.models.goal import (
|
||||||
GoalList,
|
GoalList,
|
||||||
Milestone,
|
Milestone,
|
||||||
Deliverable,
|
Deliverable,
|
||||||
|
LinkedDocument,
|
||||||
MilestoneStatus,
|
MilestoneStatus,
|
||||||
DeliverableStatus,
|
DeliverableStatus,
|
||||||
)
|
)
|
||||||
|
|
@ -211,6 +212,8 @@ class MilestonesParser(BaseParser):
|
||||||
deliverables = []
|
deliverables = []
|
||||||
notes = ""
|
notes = ""
|
||||||
description_lines = []
|
description_lines = []
|
||||||
|
plan_path = None
|
||||||
|
documents = []
|
||||||
|
|
||||||
lines = content.split("\n")
|
lines = content.split("\n")
|
||||||
table_lines = []
|
table_lines = []
|
||||||
|
|
@ -242,6 +245,21 @@ class MilestonesParser(BaseParser):
|
||||||
notes = notes_match.group(1).strip()
|
notes = notes_match.group(1).strip()
|
||||||
continue
|
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
|
# Parse deliverables table
|
||||||
if line_stripped.startswith("|"):
|
if line_stripped.startswith("|"):
|
||||||
in_table = True
|
in_table = True
|
||||||
|
|
@ -270,6 +288,8 @@ class MilestonesParser(BaseParser):
|
||||||
deliverables=deliverables,
|
deliverables=deliverables,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
description=" ".join(description_lines),
|
description=" ".join(description_lines),
|
||||||
|
plan_path=plan_path,
|
||||||
|
documents=documents,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
||||||
|
|
@ -407,6 +427,15 @@ class MilestonesParser(BaseParser):
|
||||||
if milestone.notes:
|
if milestone.notes:
|
||||||
lines.append(f"**Notes**: {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)
|
# Description (after fields, before table)
|
||||||
if milestone.description:
|
if milestone.description:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class TodosParser(BaseParser):
|
||||||
Expected format:
|
Expected format:
|
||||||
## Active Tasks / High Priority / Medium Priority / Low Priority
|
## Active Tasks / High Priority / Medium Priority / Low Priority
|
||||||
- [ ] Task description @project #tag
|
- [ ] Task description @project #tag
|
||||||
|
> Optional notes on indented line
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
- [x] Done task (2026-01-06)
|
- [x] Done task (2026-01-06)
|
||||||
|
|
@ -38,10 +39,25 @@ class TodosParser(BaseParser):
|
||||||
current_priority = "medium"
|
current_priority = "medium"
|
||||||
table_lines = []
|
table_lines = []
|
||||||
in_table = False
|
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()
|
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
|
# Detect section headers
|
||||||
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
|
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
|
||||||
# Save any pending table
|
# Save any pending table
|
||||||
|
|
@ -93,7 +109,12 @@ class TodosParser(BaseParser):
|
||||||
if line_stripped.startswith("- ["):
|
if line_stripped.startswith("- ["):
|
||||||
todo = self._parse_todo_line(line_stripped, current_priority, current_section)
|
todo = self._parse_todo_line(line_stripped, current_priority, current_section)
|
||||||
if todo:
|
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
|
# Handle any remaining table
|
||||||
if in_table and table_lines:
|
if in_table and table_lines:
|
||||||
|
|
@ -105,7 +126,7 @@ class TodosParser(BaseParser):
|
||||||
"""Parse a single todo line.
|
"""Parse a single todo line.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
line: Line like "- [ ] Task @M1 @project #tag"
|
line: Line like "- [ ] [Phase 1] Task @M1 @project #tag"
|
||||||
priority: Current priority level
|
priority: Current priority level
|
||||||
section: Current section name
|
section: Current section name
|
||||||
|
|
||||||
|
|
@ -117,6 +138,9 @@ class TodosParser(BaseParser):
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Extract phase prefix first (e.g., [Phase 1])
|
||||||
|
phase, text = self.extract_phase(text)
|
||||||
|
|
||||||
# Extract metadata (milestone first, then project)
|
# Extract metadata (milestone first, then project)
|
||||||
milestone, text = self.extract_milestone_tag(text)
|
milestone, text = self.extract_milestone_tag(text)
|
||||||
project, text = self.extract_project_tag(text)
|
project, text = self.extract_project_tag(text)
|
||||||
|
|
@ -156,6 +180,7 @@ class TodosParser(BaseParser):
|
||||||
tags=tags,
|
tags=tags,
|
||||||
completed_date=date,
|
completed_date=date,
|
||||||
blocker_reason=blocker_reason if section == "blocked" else None,
|
blocker_reason=blocker_reason if section == "blocked" else None,
|
||||||
|
phase=phase,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle blocked items
|
# 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
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from development_hub.paths import PathResolver
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -35,15 +39,25 @@ class Project:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gitea_url(self) -> str:
|
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:
|
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 f"https://gitea.brrd.tech/{self.owner}/{self.repo}"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def docs_url(self) -> str:
|
def docs_url(self) -> str:
|
||||||
"""URL to public documentation."""
|
"""URL to public documentation."""
|
||||||
|
from development_hub.paths import paths
|
||||||
if self.owner and self.repo:
|
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 f"https://pages.brrd.tech/{self.owner}/{self.repo}/"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
@ -120,10 +134,11 @@ def _load_project_config() -> dict:
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping project keys to their configuration.
|
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 = {}
|
config = {}
|
||||||
|
|
||||||
if not build_script.exists():
|
if not build_script or not build_script.exists():
|
||||||
return config
|
return config
|
||||||
|
|
||||||
pattern = r'PROJECT_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.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog, UpdateDocsThread
|
||||||
|
from development_hub.paths import paths
|
||||||
from development_hub.project_discovery import Project, discover_projects
|
from development_hub.project_discovery import Project, discover_projects
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,29 +164,36 @@ class ProjectListWidget(QWidget):
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
# View on Gitea
|
# Git hosting items - only show if git is configured and project has URL
|
||||||
view_gitea = QAction("View on Gitea", self)
|
if paths.is_git_configured and project.gitea_url:
|
||||||
view_gitea.triggered.connect(lambda: webbrowser.open(project.gitea_url))
|
view_git = QAction("View on Git Host", self)
|
||||||
menu.addAction(view_gitea)
|
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 = QAction("View Documentation", self)
|
||||||
view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url))
|
view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url))
|
||||||
menu.addAction(view_docs)
|
menu.addAction(view_docs)
|
||||||
|
|
||||||
menu.addSeparator()
|
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 = QAction("Update Documentation...", self)
|
||||||
update_docs.triggered.connect(lambda: self._update_docs(project))
|
update_docs.triggered.connect(lambda: self._update_docs(project))
|
||||||
menu.addAction(update_docs)
|
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 = QAction("Deploy Docs", self)
|
||||||
deploy_docs.triggered.connect(lambda: self._deploy_docs(project))
|
deploy_docs.triggered.connect(lambda: self._deploy_docs(project))
|
||||||
menu.addAction(deploy_docs)
|
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 = QAction("Rebuild Main Docs", self)
|
||||||
rebuild_docs.triggered.connect(self._rebuild_main_docs)
|
rebuild_docs.triggered.connect(self._rebuild_main_docs)
|
||||||
menu.addAction(rebuild_docs)
|
menu.addAction(rebuild_docs)
|
||||||
|
|
@ -242,12 +250,13 @@ class ProjectListWidget(QWidget):
|
||||||
|
|
||||||
def _deploy_docs(self, project: Project):
|
def _deploy_docs(self, project: Project):
|
||||||
"""Deploy documentation for project asynchronously."""
|
"""Deploy documentation for project asynchronously."""
|
||||||
build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
|
build_script = paths.build_script
|
||||||
if not build_script.exists():
|
if not build_script or not build_script.exists():
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"Deploy Failed",
|
"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
|
return
|
||||||
|
|
||||||
|
|
@ -472,7 +481,7 @@ class ProjectListWidget(QWidget):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine docs path
|
# 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"]
|
doc_files = ["overview.md", "goals.md", "milestones.md", "todos.md"]
|
||||||
|
|
||||||
# Read existing docs as backup
|
# Read existing docs as backup
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from development_hub.models.health import ProjectHealth, EcosystemHealth, GitInfo
|
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.services.git_service import GitService
|
||||||
from development_hub.parsers.todos_parser import TodosParser
|
from development_hub.parsers.todos_parser import TodosParser
|
||||||
from development_hub.parsers.goals_parser import GoalsParser
|
from development_hub.parsers.goals_parser import GoalsParser
|
||||||
|
|
@ -16,11 +17,11 @@ class HealthChecker:
|
||||||
"""Initialize health checker.
|
"""Initialize health checker.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
projects_root: Root path for projects (default: ~/PycharmProjects)
|
projects_root: Root path for projects (default: from settings)
|
||||||
docs_root: Root path for docs (default: ~/PycharmProjects/project-docs/docs)
|
docs_root: Root path for docs (default: from settings)
|
||||||
"""
|
"""
|
||||||
self.projects_root = projects_root or Path.home() / "PycharmProjects"
|
self.projects_root = projects_root or paths.projects_root
|
||||||
self.docs_root = docs_root or self.projects_root / "project-docs" / "docs"
|
self.docs_root = docs_root or paths.docs_root
|
||||||
|
|
||||||
def check_project(self, project: Project) -> ProjectHealth:
|
def check_project(self, project: Project) -> ProjectHealth:
|
||||||
"""Check health of a single project.
|
"""Check health of a single project.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from development_hub.paths import paths
|
||||||
|
|
||||||
|
|
||||||
class ProgressWriter:
|
class ProgressWriter:
|
||||||
"""Writes daily progress log entries to markdown files."""
|
"""Writes daily progress log entries to markdown files."""
|
||||||
|
|
@ -11,11 +13,9 @@ class ProgressWriter:
|
||||||
"""Initialize progress writer.
|
"""Initialize progress writer.
|
||||||
|
|
||||||
Args:
|
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 (
|
self._progress_dir = progress_dir or paths.progress_dir
|
||||||
Path.home() / "PycharmProjects" / "project-docs" / "docs" / "progress"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_today_path(self) -> Path:
|
def get_today_path(self) -> Path:
|
||||||
"""Get path to today's progress file."""
|
"""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_url": "", # e.g., "https://gitea.example.com" or "https://github.com"
|
||||||
"git_host_owner": "", # username or organization
|
"git_host_owner": "", # username or organization
|
||||||
"git_host_token": "", # API token (stored in settings, not ideal but simple)
|
"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
|
# Available editor choices with display names
|
||||||
|
|
@ -173,6 +181,234 @@ class Settings:
|
||||||
"""Check if git hosting is configured."""
|
"""Check if git hosting is configured."""
|
||||||
return bool(self.git_host_type and self.git_host_url and self.git_host_owner)
|
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):
|
def save_session(self, state: dict):
|
||||||
"""Save session state to file."""
|
"""Save session state to file."""
|
||||||
self._session_file.parent.mkdir(parents=True, exist_ok=True)
|
self._session_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"""Background worker for running goals audit."""
|
"""Background worker for running goals audit."""
|
||||||
|
|
||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
from development_hub.paths import paths
|
||||||
|
|
||||||
|
|
||||||
class AuditWorker(QObject):
|
class AuditWorker(QObject):
|
||||||
"""Background worker for running goals audit."""
|
"""Background worker for running goals audit."""
|
||||||
|
|
@ -12,19 +15,37 @@ class AuditWorker(QObject):
|
||||||
finished = Signal(str, bool) # output, success
|
finished = Signal(str, bool) # output, success
|
||||||
error = Signal(str)
|
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__()
|
super().__init__()
|
||||||
self.project_key = project_key # e.g. "development-hub" or "global"
|
self.project_name = project_name
|
||||||
self.project_path = project_path
|
self.goals_path = goals_path
|
||||||
|
self.milestones_path = milestones_path
|
||||||
|
self.project_dir = project_dir
|
||||||
self._process: subprocess.Popen | None = None
|
self._process: subprocess.Popen | None = None
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Execute the audit command."""
|
"""Execute the audit command."""
|
||||||
cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge"
|
cmdforge_path = paths.cmdforge_executable
|
||||||
if not cmdforge_path.exists():
|
if not cmdforge_path:
|
||||||
cmdforge_path = Path("cmdforge")
|
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:
|
try:
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
[str(cmdforge_path), "run", "audit-goals"],
|
[str(cmdforge_path), "run", "audit-goals"],
|
||||||
|
|
@ -32,11 +53,11 @@ class AuditWorker(QObject):
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
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)
|
# Pass JSON input to tool
|
||||||
stdout, stderr = self._process.communicate(input=self.project_key)
|
stdout, stderr = self._process.communicate(input=json.dumps(input_data))
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
"""Data storage and file management for the dashboard."""
|
"""Data storage and file management for the dashboard."""
|
||||||
|
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal, QFileSystemWatcher, QTimer
|
from PySide6.QtCore import QObject, Signal, QFileSystemWatcher, QTimer
|
||||||
|
|
||||||
|
from development_hub.paths import paths
|
||||||
from development_hub.project_discovery import Project
|
from development_hub.project_discovery import Project
|
||||||
from development_hub.parsers.todos_parser import TodosParser
|
from development_hub.parsers.todos_parser import TodosParser
|
||||||
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
|
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
|
||||||
|
|
@ -57,7 +59,7 @@ class DashboardDataStore(QObject):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._project = project
|
self._project = project
|
||||||
self._is_global = project is None
|
self._is_global = project is None
|
||||||
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
|
self._docs_root = paths.docs_root
|
||||||
|
|
||||||
# File paths
|
# File paths
|
||||||
self._todos_path: Path | None = None
|
self._todos_path: Path | None = None
|
||||||
|
|
@ -77,7 +79,9 @@ class DashboardDataStore(QObject):
|
||||||
self._ideas_list: list[Goal] | None = None
|
self._ideas_list: list[Goal] | None = None
|
||||||
|
|
||||||
# File watching
|
# 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 = QFileSystemWatcher(self)
|
||||||
self._file_watcher.fileChanged.connect(self._on_file_changed)
|
self._file_watcher.fileChanged.connect(self._on_file_changed)
|
||||||
self._file_watcher.directoryChanged.connect(self._on_directory_changed)
|
self._file_watcher.directoryChanged.connect(self._on_directory_changed)
|
||||||
|
|
@ -261,14 +265,14 @@ class DashboardDataStore(QObject):
|
||||||
def save_todos(self) -> None:
|
def save_todos(self) -> None:
|
||||||
"""Save todos with file watcher temporarily disabled."""
|
"""Save todos with file watcher temporarily disabled."""
|
||||||
if self._todos_parser and self._todo_list:
|
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._todos_parser.save(self._todo_list)
|
||||||
self._ensure_file_watched()
|
self._ensure_file_watched()
|
||||||
|
|
||||||
def save_goals(self) -> None:
|
def save_goals(self) -> None:
|
||||||
"""Save goals with file watcher temporarily disabled."""
|
"""Save goals with file watcher temporarily disabled."""
|
||||||
if self._goals_parser and self._goal_list:
|
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 = GoalsSaver(self._goals_path, self._goals_parser.frontmatter)
|
||||||
saver.save(self._goal_list)
|
saver.save(self._goal_list)
|
||||||
self._ensure_file_watched()
|
self._ensure_file_watched()
|
||||||
|
|
@ -276,7 +280,7 @@ class DashboardDataStore(QObject):
|
||||||
def save_milestones(self) -> None:
|
def save_milestones(self) -> None:
|
||||||
"""Save milestones with file watcher temporarily disabled."""
|
"""Save milestones with file watcher temporarily disabled."""
|
||||||
if self._milestones_parser and self._milestones_list:
|
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._milestones_parser.save(self._milestones_list)
|
||||||
self._ensure_file_watched()
|
self._ensure_file_watched()
|
||||||
|
|
||||||
|
|
@ -285,7 +289,7 @@ class DashboardDataStore(QObject):
|
||||||
if self._is_global or not self._ideas_path:
|
if self._is_global or not self._ideas_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._ignoring_file_change = True
|
self._last_save_time = time.time()
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
|
|
@ -313,7 +317,7 @@ class DashboardDataStore(QObject):
|
||||||
|
|
||||||
def toggle_todo(
|
def toggle_todo(
|
||||||
self, todo: Todo, completed: bool
|
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.
|
"""Toggle a todo's completion state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -321,17 +325,19 @@ class DashboardDataStore(QObject):
|
||||||
completed: New completed state
|
completed: New completed state
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Undo data tuple: (text, priority, was_completed, milestone)
|
Undo data tuple: (text, priority, was_completed, milestone, phase, notes)
|
||||||
"""
|
"""
|
||||||
if not self._todo_list:
|
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
|
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
|
# Find and update the todo
|
||||||
for t in self._todo_list.all_todos:
|
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)
|
self._todo_list.remove_todo(t)
|
||||||
if completed:
|
if completed:
|
||||||
t.mark_complete()
|
t.mark_complete()
|
||||||
|
|
@ -349,16 +355,17 @@ class DashboardDataStore(QObject):
|
||||||
|
|
||||||
return undo_data
|
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.
|
"""Delete a todo.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
todo: The todo to delete
|
todo: The todo to delete
|
||||||
|
|
||||||
Returns:
|
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:
|
if self._todo_list:
|
||||||
self._todo_list.remove_todo(todo)
|
self._todo_list.remove_todo(todo)
|
||||||
|
|
@ -369,7 +376,7 @@ class DashboardDataStore(QObject):
|
||||||
|
|
||||||
def edit_todo(
|
def edit_todo(
|
||||||
self, todo: Todo, old_text: str, new_text: str
|
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.
|
"""Edit a todo's text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -378,13 +385,14 @@ class DashboardDataStore(QObject):
|
||||||
new_text: New text
|
new_text: New text
|
||||||
|
|
||||||
Returns:
|
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:
|
if self._todo_list:
|
||||||
for t in self._todo_list.all_todos:
|
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
|
t.text = new_text
|
||||||
break
|
break
|
||||||
todo.text = new_text
|
todo.text = new_text
|
||||||
|
|
@ -393,18 +401,26 @@ class DashboardDataStore(QObject):
|
||||||
|
|
||||||
return undo_data
|
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.
|
"""Add a new todo.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Todo text
|
text: Todo text
|
||||||
priority: Priority level (high, medium, low)
|
priority: Priority level (high, medium, low)
|
||||||
milestone: Optional milestone ID to link to
|
milestone: Optional milestone ID to link to
|
||||||
|
phase: Optional phase label for grouping
|
||||||
|
notes: Optional additional context
|
||||||
"""
|
"""
|
||||||
if not self._todo_list:
|
if not self._todo_list:
|
||||||
return
|
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._todo_list.add_todo(todo)
|
||||||
self.save_todos()
|
self.save_todos()
|
||||||
self.todos_changed.emit()
|
self.todos_changed.emit()
|
||||||
|
|
@ -851,10 +867,13 @@ class DashboardDataStore(QObject):
|
||||||
if file_path.parent.exists() and parent not in watched_dirs:
|
if file_path.parent.exists() and parent not in watched_dirs:
|
||||||
self._file_watcher.addPath(parent)
|
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:
|
def _on_file_changed(self, path: str) -> None:
|
||||||
"""Handle file modification events."""
|
"""Handle file modification events."""
|
||||||
if self._ignoring_file_change:
|
if self._is_within_save_window():
|
||||||
self._ignoring_file_change = False
|
|
||||||
self._ensure_file_watched()
|
self._ensure_file_watched()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -872,8 +891,7 @@ class DashboardDataStore(QObject):
|
||||||
|
|
||||||
def _on_directory_changed(self, path: str) -> None:
|
def _on_directory_changed(self, path: str) -> None:
|
||||||
"""Handle directory changes."""
|
"""Handle directory changes."""
|
||||||
if self._ignoring_file_change:
|
if self._is_within_save_window():
|
||||||
self._ignoring_file_change = False
|
|
||||||
self._ensure_file_watched()
|
self._ensure_file_watched()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Project dashboard view."""
|
"""Project dashboard view."""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal, QTimer, QThread
|
from PySide6.QtCore import Qt, Signal, QTimer, QThread
|
||||||
|
|
@ -20,6 +22,7 @@ from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from development_hub.paths import paths
|
||||||
from development_hub.project_discovery import Project
|
from development_hub.project_discovery import Project
|
||||||
from development_hub.services.health_checker import HealthChecker
|
from development_hub.services.health_checker import HealthChecker
|
||||||
from development_hub.parsers.progress_parser import ProgressLogManager
|
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.services.git_service import GitService
|
||||||
from development_hub.models.health import HealthStatus
|
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.parsers.todos_parser import TodosParser
|
||||||
from development_hub.models.todo import Todo, TodoList
|
from development_hub.models.todo import Todo, TodoList
|
||||||
from development_hub.parsers.goals_parser import MilestonesParser, GoalsParser
|
from development_hub.parsers.goals_parser import MilestonesParser, GoalsParser
|
||||||
|
|
@ -77,7 +81,7 @@ class ProjectDashboard(QWidget):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.project = project
|
self.project = project
|
||||||
self.is_global = project is None
|
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
|
# Data store manages all data loading/saving and file watching
|
||||||
self._data_store = DashboardDataStore(project, self)
|
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
|
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)
|
# Data format depends on action type (from push_action)
|
||||||
# For toggle: (text, priority, was_completed, milestone)
|
# For toggle: (text, priority, was_completed, milestone, phase, notes)
|
||||||
# For delete: (text, priority, was_completed, milestone)
|
# For delete: (text, priority, was_completed, milestone, phase, notes)
|
||||||
# For edit: (old_text, priority, was_completed, new_text)
|
# For edit: (old_text, priority, was_completed, new_text, phase, notes)
|
||||||
|
|
||||||
# Reload fresh data
|
# Reload fresh data
|
||||||
self._data_store.load_todos()
|
self._data_store.load_todos()
|
||||||
|
|
@ -313,22 +317,60 @@ class ProjectDashboard(QWidget):
|
||||||
if not todo_list:
|
if not todo_list:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Determine action type from the undo action that was pushed
|
# Handle both old 4-element tuples (backward compat) and new 6-element tuples
|
||||||
# The data was stored when the action was performed
|
if len(data) >= 4:
|
||||||
if len(data) == 4 and isinstance(data[3], (str, type(None))):
|
# Extract common fields
|
||||||
# Could be toggle (text, priority, was_completed, milestone) or delete
|
text = data[0]
|
||||||
text, priority, was_completed, milestone = data
|
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
|
# Try to find the todo - if found, it's a toggle undo
|
||||||
todo = None
|
todo = None
|
||||||
for t in todo_list.all_todos:
|
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
|
todo = t
|
||||||
break
|
break
|
||||||
|
|
||||||
if todo:
|
if todo:
|
||||||
# Toggle undo - restore previous state
|
# Toggle undo - restore previous state
|
||||||
current_completed = todo.completed
|
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_list.remove_todo(todo)
|
||||||
todo.completed = was_completed
|
todo.completed = was_completed
|
||||||
|
|
@ -350,7 +392,10 @@ class ProjectDashboard(QWidget):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Delete undo - restore the todo
|
# 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:
|
if was_completed:
|
||||||
restored.mark_complete()
|
restored.mark_complete()
|
||||||
todo_list.add_todo(restored)
|
todo_list.add_todo(restored)
|
||||||
|
|
@ -360,28 +405,10 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
return UndoAction(
|
return UndoAction(
|
||||||
action_type="todo_delete",
|
action_type="todo_delete",
|
||||||
data=(text, priority, was_completed, milestone),
|
data=(text, priority, was_completed, milestone, phase, notes),
|
||||||
description=f"Delete: {text[:40]}"
|
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
|
return None
|
||||||
|
|
||||||
def _handle_todo_redo(self, data: tuple) -> UndoAction | None:
|
def _handle_todo_redo(self, data: tuple) -> UndoAction | None:
|
||||||
|
|
@ -399,13 +426,23 @@ class ProjectDashboard(QWidget):
|
||||||
if not todo_list:
|
if not todo_list:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(data) == 4:
|
# Handle both old 4-element tuples (backward compat) and new 6-element tuples
|
||||||
text, priority, was_completed, milestone_or_new_text = data
|
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")):
|
# Determine if this is edit or toggle/delete
|
||||||
# Edit redo: (old_text, priority, was_completed, new_text)
|
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
|
old_text = text
|
||||||
new_text = milestone_or_new_text
|
new_text = field4
|
||||||
|
|
||||||
for todo in todo_list.all_todos:
|
for todo in todo_list.all_todos:
|
||||||
if todo.text == old_text and todo.priority == priority:
|
if todo.text == old_text and todo.priority == priority:
|
||||||
|
|
@ -417,22 +454,22 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
return UndoAction(
|
return UndoAction(
|
||||||
action_type="todo_edit",
|
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]}"
|
description=f"Edit: {old_text[:20]} -> {new_text[:20]}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Toggle or delete redo
|
# Toggle or delete redo
|
||||||
milestone = milestone_or_new_text
|
milestone = field4
|
||||||
todo = None
|
todo = None
|
||||||
for t in todo_list.all_todos:
|
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
|
todo = t
|
||||||
break
|
break
|
||||||
|
|
||||||
if todo:
|
if todo:
|
||||||
# Toggle redo
|
# Toggle redo
|
||||||
current_completed = todo.completed
|
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_list.remove_todo(todo)
|
||||||
todo.completed = was_completed
|
todo.completed = was_completed
|
||||||
|
|
@ -455,7 +492,7 @@ class ProjectDashboard(QWidget):
|
||||||
else:
|
else:
|
||||||
# Delete redo - delete the todo again
|
# Delete redo - delete the todo again
|
||||||
for t in todo_list.all_todos[:]:
|
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)
|
todo_list.remove_todo(t)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -464,7 +501,7 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
return UndoAction(
|
return UndoAction(
|
||||||
action_type="todo_delete",
|
action_type="todo_delete",
|
||||||
data=(text, priority, was_completed, milestone),
|
data=(text, priority, was_completed, milestone, phase, notes),
|
||||||
description=f"Delete: {text[:40]}"
|
description=f"Delete: {text[:40]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -904,6 +941,8 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
goals_header_layout.addStretch()
|
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 = QPushButton("Audit")
|
||||||
audit_goals_btn.setStyleSheet(self._button_style())
|
audit_goals_btn.setStyleSheet(self._button_style())
|
||||||
audit_goals_btn.clicked.connect(self._audit_goals)
|
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_added.connect(self._on_milestone_todo_added)
|
||||||
widget.todo_start_discussion.connect(self._on_todo_start_discussion)
|
widget.todo_start_discussion.connect(self._on_todo_start_discussion)
|
||||||
widget.todo_edited.connect(self._on_todo_edited)
|
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)
|
# Keep legacy signals for deliverables mode (fallback)
|
||||||
widget.deliverable_toggled.connect(self._on_deliverable_toggled)
|
widget.deliverable_toggled.connect(self._on_deliverable_toggled)
|
||||||
widget.deliverable_added.connect(self._on_deliverable_added)
|
widget.deliverable_added.connect(self._on_deliverable_added)
|
||||||
|
|
@ -2039,6 +2082,7 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
# Refresh UI
|
# Refresh UI
|
||||||
self._load_todos()
|
self._load_todos()
|
||||||
|
self._load_milestones()
|
||||||
|
|
||||||
# Show toast
|
# Show toast
|
||||||
self.toast.show_message(
|
self.toast.show_message(
|
||||||
|
|
@ -2065,6 +2109,7 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
# Refresh UI
|
# Refresh UI
|
||||||
self._load_todos()
|
self._load_todos()
|
||||||
|
self._load_milestones()
|
||||||
|
|
||||||
# Show toast
|
# Show toast
|
||||||
self.toast.show_message(
|
self.toast.show_message(
|
||||||
|
|
@ -2129,6 +2174,168 @@ class ProjectDashboard(QWidget):
|
||||||
"pip install -e ~/PycharmProjects/orchestrated-discussions"
|
"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):
|
def _on_filter_changed(self, index):
|
||||||
"""Handle filter dropdown change."""
|
"""Handle filter dropdown change."""
|
||||||
self._current_filter = self.todo_filter.currentData()
|
self._current_filter = self.todo_filter.currentData()
|
||||||
|
|
@ -2470,13 +2677,15 @@ class ProjectDashboard(QWidget):
|
||||||
if confirm != QMessageBox.StandardButton.Ok:
|
if confirm != QMessageBox.StandardButton.Ok:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine project key for the audit tool
|
# Determine paths for the audit tool
|
||||||
if self.is_global:
|
if self.is_global:
|
||||||
project_key = "global"
|
project_name = "Global Goals"
|
||||||
project_root = None
|
milestones_path = self._docs_root / "goals" / "milestones.md"
|
||||||
|
project_dir = None
|
||||||
else:
|
else:
|
||||||
project_key = self.project.key
|
project_name = self.project.key
|
||||||
project_root = Path(self.project.path) if self.project.path else None
|
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
|
# Create progress dialog
|
||||||
self._audit_dialog = QDialog(self)
|
self._audit_dialog = QDialog(self)
|
||||||
|
|
@ -2518,7 +2727,12 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
# Create worker and thread
|
# Create worker and thread
|
||||||
self._audit_thread = QThread()
|
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)
|
self._audit_worker.moveToThread(self._audit_thread)
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,9 @@ class MilestoneWidget(QFrame):
|
||||||
todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos
|
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_start_discussion = Signal(object) # (todo) - for starting discussion from todo
|
||||||
todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -848,6 +851,14 @@ class MilestoneWidget(QFrame):
|
||||||
self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
|
self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
|
||||||
content_layout.addWidget(self.target_label)
|
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
|
# Deliverables list
|
||||||
self.deliverables_container = QWidget()
|
self.deliverables_container = QWidget()
|
||||||
self.deliverables_layout = QVBoxLayout(self.deliverables_container)
|
self.deliverables_layout = QVBoxLayout(self.deliverables_container)
|
||||||
|
|
@ -1005,7 +1016,9 @@ class MilestoneWidget(QFrame):
|
||||||
""")
|
""")
|
||||||
|
|
||||||
def _load_deliverables(self):
|
def _load_deliverables(self):
|
||||||
"""Load deliverable/todo widgets."""
|
"""Load deliverable/todo widgets, grouped by phase if applicable."""
|
||||||
|
import re
|
||||||
|
|
||||||
# Clear existing
|
# Clear existing
|
||||||
while self.deliverables_layout.count():
|
while self.deliverables_layout.count():
|
||||||
item = self.deliverables_layout.takeAt(0)
|
item = self.deliverables_layout.takeAt(0)
|
||||||
|
|
@ -1014,13 +1027,48 @@ class MilestoneWidget(QFrame):
|
||||||
|
|
||||||
# Show todos if available (preferred mode), otherwise show deliverables
|
# Show todos if available (preferred mode), otherwise show deliverables
|
||||||
if self._todos:
|
if self._todos:
|
||||||
|
# Group todos by phase
|
||||||
|
phases = {}
|
||||||
for todo in self._todos:
|
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 = TodoItemWidget(todo, show_priority=True)
|
||||||
widget.toggled.connect(self._on_todo_toggled_internal)
|
widget.toggled.connect(self._on_todo_toggled_internal)
|
||||||
widget.deleted.connect(self._on_todo_deleted_internal)
|
widget.deleted.connect(self._on_todo_deleted_internal)
|
||||||
widget.start_discussion.connect(self.todo_start_discussion.emit)
|
widget.start_discussion.connect(self.todo_start_discussion.emit)
|
||||||
widget.edited.connect(self.todo_edited.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)
|
self.deliverables_layout.addWidget(widget)
|
||||||
else:
|
else:
|
||||||
# Legacy: Add deliverables
|
# Legacy: Add deliverables
|
||||||
|
|
@ -1078,3 +1126,48 @@ class MilestoneWidget(QFrame):
|
||||||
self._update_progress()
|
self._update_progress()
|
||||||
self._update_status_icon()
|
self._update_status_icon()
|
||||||
self._load_deliverables()
|
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
|
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__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
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