Add workspace files and enhanced first-run wizard

- Add PathResolver module for centralized path resolution from settings
- Add workspace export/import (YAML) for shareable configuration
- Replace SetupWizardDialog with multi-page wizard (Simple/Docs/Import modes)
- Add documentation mode settings (auto/standalone/project-docs)
- Implement graceful degradation (hide features when not configured)
- Add Export/Import Workspace menu items
- Update Settings dialog with documentation mode section
- Replace hardcoded paths with paths resolver throughout codebase
- Add pyyaml dependency for workspace file parsing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-25 05:40:02 -04:00
parent 43f7deb5a6
commit 14885fb567
12 changed files with 1946 additions and 237 deletions

View File

@ -12,6 +12,7 @@ requires-python = ">=3.10"
dependencies = [
"PySide6>=6.4.0",
"pyte>=0.8.0",
"pyyaml>=6.0",
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
"cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git",

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ from pathlib import Path
from PySide6.QtCore import QObject, Signal
from development_hub.paths import paths
class AuditWorker(QObject):
"""Background worker for running goals audit."""
@ -21,8 +23,8 @@ class AuditWorker(QObject):
def run(self):
"""Execute the audit command."""
cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge"
if not cmdforge_path.exists():
cmdforge_path = paths.cmdforge_executable
if not cmdforge_path:
cmdforge_path = Path("cmdforge")
try:

View File

@ -6,6 +6,7 @@ from typing import Any
from PySide6.QtCore import QObject, Signal, QFileSystemWatcher, QTimer
from development_hub.paths import paths
from development_hub.project_discovery import Project
from development_hub.parsers.todos_parser import TodosParser
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
@ -57,7 +58,7 @@ class DashboardDataStore(QObject):
super().__init__(parent)
self._project = project
self._is_global = project is None
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
self._docs_root = paths.docs_root
# File paths
self._todos_path: Path | None = None
@ -313,7 +314,7 @@ class DashboardDataStore(QObject):
def toggle_todo(
self, todo: Todo, completed: bool
) -> tuple[str, str, str, bool, str | None]:
) -> tuple[str, str, bool, str | None, str | None, str | None]:
"""Toggle a todo's completion state.
Args:
@ -321,17 +322,19 @@ class DashboardDataStore(QObject):
completed: New completed state
Returns:
Undo data tuple: (text, priority, was_completed, milestone)
Undo data tuple: (text, priority, was_completed, milestone, phase, notes)
"""
if not self._todo_list:
return (todo.text, todo.priority, not completed, todo.milestone)
return (todo.text, todo.priority, not completed, todo.milestone,
todo.phase, todo.notes)
was_completed = not completed
undo_data = (todo.text, todo.priority, was_completed, todo.milestone)
undo_data = (todo.text, todo.priority, was_completed, todo.milestone,
todo.phase, todo.notes)
# Find and update the todo
for t in self._todo_list.all_todos:
if t.text == todo.text and t.priority == todo.priority:
if t.text == todo.text and t.priority == todo.priority and t.phase == todo.phase:
self._todo_list.remove_todo(t)
if completed:
t.mark_complete()
@ -349,16 +352,17 @@ class DashboardDataStore(QObject):
return undo_data
def delete_todo(self, todo: Todo) -> tuple[str, str, bool, str | None]:
def delete_todo(self, todo: Todo) -> tuple[str, str, bool, str | None, str | None, str | None]:
"""Delete a todo.
Args:
todo: The todo to delete
Returns:
Undo data tuple: (text, priority, was_completed, milestone)
Undo data tuple: (text, priority, was_completed, milestone, phase, notes)
"""
undo_data = (todo.text, todo.priority, todo.completed, todo.milestone)
undo_data = (todo.text, todo.priority, todo.completed, todo.milestone,
todo.phase, todo.notes)
if self._todo_list:
self._todo_list.remove_todo(todo)
@ -369,7 +373,7 @@ class DashboardDataStore(QObject):
def edit_todo(
self, todo: Todo, old_text: str, new_text: str
) -> tuple[str, str, bool, str]:
) -> tuple[str, str, bool, str, str | None, str | None]:
"""Edit a todo's text.
Args:
@ -378,13 +382,14 @@ class DashboardDataStore(QObject):
new_text: New text
Returns:
Undo data tuple: (old_text, priority, was_completed, new_text)
Undo data tuple: (old_text, priority, was_completed, new_text, phase, notes)
"""
undo_data = (old_text, todo.priority, todo.completed, new_text)
undo_data = (old_text, todo.priority, todo.completed, new_text,
todo.phase, todo.notes)
if self._todo_list:
for t in self._todo_list.all_todos:
if t.text == old_text and t.priority == todo.priority:
if t.text == old_text and t.priority == todo.priority and t.phase == todo.phase:
t.text = new_text
break
todo.text = new_text
@ -393,18 +398,26 @@ class DashboardDataStore(QObject):
return undo_data
def add_todo(self, text: str, priority: str, milestone: str | None = None) -> None:
def add_todo(
self, text: str, priority: str, milestone: str | None = None,
phase: str | None = None, notes: str | None = None
) -> None:
"""Add a new todo.
Args:
text: Todo text
priority: Priority level (high, medium, low)
milestone: Optional milestone ID to link to
phase: Optional phase label for grouping
notes: Optional additional context
"""
if not self._todo_list:
return
todo = Todo(text=text, completed=False, priority=priority, milestone=milestone)
todo = Todo(
text=text, completed=False, priority=priority,
milestone=milestone, phase=phase, notes=notes
)
self._todo_list.add_todo(todo)
self.save_todos()
self.todos_changed.emit()

View File

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