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:
parent
43f7deb5a6
commit
14885fb567
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
"""Centralized path resolution for Development Hub.
|
||||
|
||||
This module provides a singleton PathResolver that resolves all paths
|
||||
from settings, making it easy to use configurable paths throughout
|
||||
the application.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PathResolver:
|
||||
"""Resolves all application paths from settings.
|
||||
|
||||
This singleton class provides a centralized way to access all
|
||||
configurable paths in the application. It reads from Settings
|
||||
and provides derived paths for documentation, projects, etc.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton pattern."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@property
|
||||
def _settings(self):
|
||||
"""Get settings instance (lazy import to avoid circular imports)."""
|
||||
from development_hub.settings import Settings
|
||||
return Settings()
|
||||
|
||||
@property
|
||||
def projects_root(self) -> Path:
|
||||
"""Get the primary projects root directory."""
|
||||
paths = self._settings.project_search_paths
|
||||
if paths:
|
||||
return Path(paths[0]).expanduser()
|
||||
return Path.home() / "Projects"
|
||||
|
||||
@property
|
||||
def docs_root(self) -> Path:
|
||||
"""Get the documentation root path."""
|
||||
return self._settings.docs_root
|
||||
|
||||
@property
|
||||
def project_docs_dir(self) -> Path | None:
|
||||
"""Get the project-docs directory (Docusaurus root)."""
|
||||
return self._settings.docusaurus_path
|
||||
|
||||
@property
|
||||
def progress_dir(self) -> Path:
|
||||
"""Get the progress log directory."""
|
||||
return self._settings.progress_dir
|
||||
|
||||
@property
|
||||
def build_script(self) -> Path | None:
|
||||
"""Get the build-public-docs.sh script path."""
|
||||
if self.project_docs_dir:
|
||||
script = self.project_docs_dir / "scripts" / "build-public-docs.sh"
|
||||
if script.exists():
|
||||
return script
|
||||
return None
|
||||
|
||||
@property
|
||||
def cmdforge_path(self) -> Path | None:
|
||||
"""Get the CmdForge installation path."""
|
||||
return self._settings.cmdforge_path
|
||||
|
||||
@property
|
||||
def cmdforge_executable(self) -> Path | None:
|
||||
"""Get the CmdForge executable path."""
|
||||
# Check explicit path first
|
||||
if self.cmdforge_path:
|
||||
venv_cmdforge = self.cmdforge_path / ".venv" / "bin" / "cmdforge"
|
||||
if venv_cmdforge.exists():
|
||||
return venv_cmdforge
|
||||
# Check PATH
|
||||
which_result = shutil.which("cmdforge")
|
||||
if which_result:
|
||||
return Path(which_result)
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_docs_enabled(self) -> bool:
|
||||
"""Check if documentation features are enabled."""
|
||||
return self._settings.is_docs_enabled
|
||||
|
||||
@property
|
||||
def is_cmdforge_available(self) -> bool:
|
||||
"""Check if CmdForge is available."""
|
||||
return self._settings.is_cmdforge_available
|
||||
|
||||
@property
|
||||
def is_git_configured(self) -> bool:
|
||||
"""Check if git hosting is configured."""
|
||||
return self._settings.is_git_configured
|
||||
|
||||
@property
|
||||
def effective_docs_mode(self) -> str:
|
||||
"""Get the effective documentation mode."""
|
||||
return self._settings.effective_docs_mode
|
||||
|
||||
def project_docs_path(self, project_key: str) -> Path:
|
||||
"""Get the documentation path for a specific project.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'cmdforge', 'ramble')
|
||||
|
||||
Returns:
|
||||
Path to the project's documentation directory
|
||||
"""
|
||||
return self.docs_root / "projects" / project_key
|
||||
|
||||
def git_url(self, owner: str | None = None, repo: str | None = None) -> str:
|
||||
"""Get the git repository URL for a project.
|
||||
|
||||
Args:
|
||||
owner: Repository owner (default: configured owner)
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
Full git repository URL or empty string if not configured
|
||||
"""
|
||||
settings = self._settings
|
||||
if not settings.git_host_url:
|
||||
return ""
|
||||
owner = owner or settings.git_host_owner
|
||||
if not owner:
|
||||
return ""
|
||||
if repo:
|
||||
return f"{settings.git_host_url}/{owner}/{repo}"
|
||||
return f"{settings.git_host_url}/{owner}"
|
||||
|
||||
def pages_url(self, owner: str | None = None, repo: str | None = None) -> str:
|
||||
"""Get the documentation pages URL for a project.
|
||||
|
||||
Args:
|
||||
owner: Repository owner (default: configured owner)
|
||||
repo: Repository name
|
||||
|
||||
Returns:
|
||||
Full pages URL or empty string if not configured
|
||||
"""
|
||||
settings = self._settings
|
||||
pages_base = settings.pages_url
|
||||
if not pages_base:
|
||||
return ""
|
||||
owner = owner or settings.git_host_owner
|
||||
if not owner:
|
||||
return ""
|
||||
if repo:
|
||||
return f"{pages_base}/{owner}/{repo}/"
|
||||
return f"{pages_base}/{owner}/"
|
||||
|
||||
|
||||
# Singleton instance for easy import
|
||||
paths = PathResolver()
|
||||
|
|
@ -3,6 +3,10 @@
|
|||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from development_hub.paths import PathResolver
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -35,15 +39,25 @@ class Project:
|
|||
|
||||
@property
|
||||
def gitea_url(self) -> str:
|
||||
"""URL to Gitea repository."""
|
||||
"""URL to git repository."""
|
||||
from development_hub.paths import paths
|
||||
if self.owner and self.repo:
|
||||
url = paths.git_url(self.owner, self.repo)
|
||||
if url:
|
||||
return url
|
||||
# Fallback to hardcoded URL for backwards compatibility
|
||||
return f"https://gitea.brrd.tech/{self.owner}/{self.repo}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def docs_url(self) -> str:
|
||||
"""URL to public documentation."""
|
||||
from development_hub.paths import paths
|
||||
if self.owner and self.repo:
|
||||
url = paths.pages_url(self.owner, self.repo)
|
||||
if url:
|
||||
return url
|
||||
# Fallback to hardcoded URL for backwards compatibility
|
||||
return f"https://pages.brrd.tech/{self.owner}/{self.repo}/"
|
||||
return ""
|
||||
|
||||
|
|
@ -120,10 +134,11 @@ def _load_project_config() -> dict:
|
|||
Returns:
|
||||
Dictionary mapping project keys to their configuration.
|
||||
"""
|
||||
build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
|
||||
from development_hub.paths import paths
|
||||
build_script = paths.build_script
|
||||
config = {}
|
||||
|
||||
if not build_script.exists():
|
||||
if not build_script or not build_script.exists():
|
||||
return config
|
||||
|
||||
pattern = r'PROJECT_CONFIG\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
|
|||
)
|
||||
|
||||
from development_hub.dialogs import DeployDocsThread, RebuildMainDocsThread, DocsPreviewDialog, UpdateDocsThread
|
||||
from development_hub.paths import paths
|
||||
from development_hub.project_discovery import Project, discover_projects
|
||||
|
||||
|
||||
|
|
@ -163,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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from pathlib import Path
|
||||
|
||||
from development_hub.models.health import ProjectHealth, EcosystemHealth, GitInfo
|
||||
from development_hub.paths import paths
|
||||
from development_hub.services.git_service import GitService
|
||||
from development_hub.parsers.todos_parser import TodosParser
|
||||
from development_hub.parsers.goals_parser import GoalsParser
|
||||
|
|
@ -16,11 +17,11 @@ class HealthChecker:
|
|||
"""Initialize health checker.
|
||||
|
||||
Args:
|
||||
projects_root: Root path for projects (default: ~/PycharmProjects)
|
||||
docs_root: Root path for docs (default: ~/PycharmProjects/project-docs/docs)
|
||||
projects_root: Root path for projects (default: from settings)
|
||||
docs_root: Root path for docs (default: from settings)
|
||||
"""
|
||||
self.projects_root = projects_root or Path.home() / "PycharmProjects"
|
||||
self.docs_root = docs_root or self.projects_root / "project-docs" / "docs"
|
||||
self.projects_root = projects_root or paths.projects_root
|
||||
self.docs_root = docs_root or paths.docs_root
|
||||
|
||||
def check_project(self, project: Project) -> ProjectHealth:
|
||||
"""Check health of a single project.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from development_hub.paths import paths
|
||||
|
||||
|
||||
class ProgressWriter:
|
||||
"""Writes daily progress log entries to markdown files."""
|
||||
|
|
@ -11,11 +13,9 @@ class ProgressWriter:
|
|||
"""Initialize progress writer.
|
||||
|
||||
Args:
|
||||
progress_dir: Directory for progress files. Defaults to project-docs/docs/progress.
|
||||
progress_dir: Directory for progress files. Defaults to settings value.
|
||||
"""
|
||||
self._progress_dir = progress_dir or (
|
||||
Path.home() / "PycharmProjects" / "project-docs" / "docs" / "progress"
|
||||
)
|
||||
self._progress_dir = progress_dir or paths.progress_dir
|
||||
|
||||
def get_today_path(self) -> Path:
|
||||
"""Get path to today's progress file."""
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ class Settings:
|
|||
"git_host_url": "", # e.g., "https://gitea.example.com" or "https://github.com"
|
||||
"git_host_owner": "", # username or organization
|
||||
"git_host_token": "", # API token (stored in settings, not ideal but simple)
|
||||
# Documentation settings
|
||||
"docs_mode": "auto", # "auto" | "standalone" | "project-docs"
|
||||
"docs_root": "", # Empty = derive from mode
|
||||
"docusaurus_path": "", # Path to project-docs
|
||||
"pages_url": "", # Separate from git_host_url
|
||||
# Integration paths
|
||||
"cmdforge_path": "", # Override cmdforge location
|
||||
"progress_dir": "", # Override progress directory
|
||||
}
|
||||
|
||||
# Available editor choices with display names
|
||||
|
|
@ -173,6 +181,234 @@ class Settings:
|
|||
"""Check if git hosting is configured."""
|
||||
return bool(self.git_host_type and self.git_host_url and self.git_host_owner)
|
||||
|
||||
@property
|
||||
def docs_mode(self) -> str:
|
||||
"""Documentation mode (auto, standalone, project-docs)."""
|
||||
return self.get("docs_mode", "auto")
|
||||
|
||||
@docs_mode.setter
|
||||
def docs_mode(self, value: str):
|
||||
self.set("docs_mode", value)
|
||||
|
||||
@property
|
||||
def effective_docs_mode(self) -> str:
|
||||
"""Get effective docs mode, resolving 'auto' to actual mode."""
|
||||
mode = self.docs_mode
|
||||
if mode == "auto":
|
||||
# Check if project-docs exists in default location
|
||||
project_docs = Path(self.project_search_paths[0]) / "project-docs" if self.project_search_paths else None
|
||||
if project_docs and project_docs.exists():
|
||||
return "project-docs"
|
||||
return "standalone"
|
||||
return mode
|
||||
|
||||
@property
|
||||
def docs_root(self) -> Path:
|
||||
"""Get the documentation root path."""
|
||||
explicit = self.get("docs_root", "")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
# Derive from mode
|
||||
if self.effective_docs_mode == "project-docs":
|
||||
return self.docusaurus_path / "docs" if self.docusaurus_path else Path.home() / ".local" / "share" / "development-hub" / "docs"
|
||||
return Path.home() / ".local" / "share" / "development-hub" / "docs"
|
||||
|
||||
@docs_root.setter
|
||||
def docs_root(self, value: Path | str):
|
||||
self.set("docs_root", str(value) if value else "")
|
||||
|
||||
@property
|
||||
def docusaurus_path(self) -> Path | None:
|
||||
"""Get the path to the docusaurus project."""
|
||||
explicit = self.get("docusaurus_path", "")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
# Default location
|
||||
if self.project_search_paths:
|
||||
default = Path(self.project_search_paths[0]) / "project-docs"
|
||||
if default.exists():
|
||||
return default
|
||||
return None
|
||||
|
||||
@docusaurus_path.setter
|
||||
def docusaurus_path(self, value: Path | str | None):
|
||||
self.set("docusaurus_path", str(value) if value else "")
|
||||
|
||||
@property
|
||||
def pages_url(self) -> str:
|
||||
"""Get the pages URL for documentation hosting."""
|
||||
explicit = self.get("pages_url", "")
|
||||
if explicit:
|
||||
return explicit
|
||||
# Derive from git_host_url for gitea
|
||||
if self.git_host_type == "gitea" and self.git_host_url:
|
||||
# https://gitea.example.com -> https://pages.example.com
|
||||
import re
|
||||
match = re.match(r"https?://gitea\.(.+)", self.git_host_url)
|
||||
if match:
|
||||
return f"https://pages.{match.group(1)}"
|
||||
return ""
|
||||
|
||||
@pages_url.setter
|
||||
def pages_url(self, value: str):
|
||||
self.set("pages_url", value)
|
||||
|
||||
@property
|
||||
def cmdforge_path(self) -> Path | None:
|
||||
"""Get the CmdForge path."""
|
||||
explicit = self.get("cmdforge_path", "")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
# Default locations
|
||||
if self.project_search_paths:
|
||||
default = Path(self.project_search_paths[0]) / "CmdForge"
|
||||
if default.exists():
|
||||
return default
|
||||
return None
|
||||
|
||||
@cmdforge_path.setter
|
||||
def cmdforge_path(self, value: Path | str | None):
|
||||
self.set("cmdforge_path", str(value) if value else "")
|
||||
|
||||
@property
|
||||
def progress_dir(self) -> Path:
|
||||
"""Get the progress log directory."""
|
||||
explicit = self.get("progress_dir", "")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
# Default: under docs_root
|
||||
return self.docs_root / "progress"
|
||||
|
||||
@progress_dir.setter
|
||||
def progress_dir(self, value: Path | str):
|
||||
self.set("progress_dir", str(value) if value else "")
|
||||
|
||||
@property
|
||||
def is_docs_enabled(self) -> bool:
|
||||
"""Check if documentation features are available."""
|
||||
mode = self.effective_docs_mode
|
||||
if mode == "project-docs":
|
||||
return self.docusaurus_path is not None and self.docusaurus_path.exists()
|
||||
return True # standalone mode always works
|
||||
|
||||
@property
|
||||
def is_cmdforge_available(self) -> bool:
|
||||
"""Check if CmdForge is available."""
|
||||
import shutil
|
||||
# Check explicit path
|
||||
if self.cmdforge_path and (self.cmdforge_path / ".venv" / "bin" / "cmdforge").exists():
|
||||
return True
|
||||
# Check PATH
|
||||
return shutil.which("cmdforge") is not None
|
||||
|
||||
def export_workspace(self, path: Path) -> None:
|
||||
"""Export current settings to a workspace file.
|
||||
|
||||
Args:
|
||||
path: Path to write the workspace YAML file
|
||||
"""
|
||||
import yaml
|
||||
|
||||
workspace = {
|
||||
"name": f"{self.git_host_owner}'s Development Environment" if self.git_host_owner else "Development Environment",
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"projects_root": self.project_search_paths[0] if self.project_search_paths else str(Path.home() / "Projects"),
|
||||
},
|
||||
"documentation": {
|
||||
"enabled": self.is_docs_enabled,
|
||||
"mode": self.docs_mode,
|
||||
"auto_start_server": self.auto_start_docs_server,
|
||||
},
|
||||
}
|
||||
|
||||
# Add docs_root if explicit
|
||||
if self.get("docs_root"):
|
||||
workspace["paths"]["docs_root"] = self.get("docs_root")
|
||||
|
||||
# Add docusaurus_path if in project-docs mode
|
||||
if self.effective_docs_mode == "project-docs" and self.docusaurus_path:
|
||||
workspace["documentation"]["docusaurus_path"] = str(self.docusaurus_path)
|
||||
|
||||
# Add git hosting if configured
|
||||
if self.is_git_configured:
|
||||
workspace["git_hosting"] = {
|
||||
"type": self.git_host_type,
|
||||
"url": self.git_host_url,
|
||||
"owner": self.git_host_owner,
|
||||
}
|
||||
if self.pages_url:
|
||||
workspace["git_hosting"]["pages_url"] = self.pages_url
|
||||
|
||||
# Add feature flags
|
||||
workspace["features"] = {
|
||||
"cmdforge_integration": self.is_cmdforge_available,
|
||||
"progress_tracking": True,
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(workspace, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
def import_workspace(self, path: Path) -> dict:
|
||||
"""Import settings from a workspace file.
|
||||
|
||||
Args:
|
||||
path: Path to the workspace YAML file
|
||||
|
||||
Returns:
|
||||
Dict with import results including any warnings
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open(path) as f:
|
||||
workspace = yaml.safe_load(f)
|
||||
|
||||
results = {"imported": [], "warnings": []}
|
||||
|
||||
# Import paths
|
||||
if "paths" in workspace:
|
||||
paths = workspace["paths"]
|
||||
if "projects_root" in paths:
|
||||
projects_root = str(Path(paths["projects_root"]).expanduser())
|
||||
self.project_search_paths = [projects_root]
|
||||
self.default_project_path = Path(projects_root)
|
||||
results["imported"].append("projects_root")
|
||||
if "docs_root" in paths:
|
||||
self.docs_root = paths["docs_root"]
|
||||
results["imported"].append("docs_root")
|
||||
|
||||
# Import documentation settings
|
||||
if "documentation" in workspace:
|
||||
docs = workspace["documentation"]
|
||||
if "mode" in docs:
|
||||
self.docs_mode = docs["mode"]
|
||||
results["imported"].append("docs_mode")
|
||||
if "docusaurus_path" in docs:
|
||||
self.docusaurus_path = docs["docusaurus_path"]
|
||||
results["imported"].append("docusaurus_path")
|
||||
if "auto_start_server" in docs:
|
||||
self.auto_start_docs_server = docs["auto_start_server"]
|
||||
results["imported"].append("auto_start_docs_server")
|
||||
|
||||
# Import git hosting
|
||||
if "git_hosting" in workspace:
|
||||
git = workspace["git_hosting"]
|
||||
if "type" in git:
|
||||
self.git_host_type = git["type"]
|
||||
if "url" in git:
|
||||
self.git_host_url = git["url"]
|
||||
if "owner" in git:
|
||||
self.git_host_owner = git["owner"]
|
||||
if "pages_url" in git:
|
||||
self.pages_url = git["pages_url"]
|
||||
results["imported"].append("git_hosting")
|
||||
|
||||
# Mark setup as completed
|
||||
self.set("setup_completed", True)
|
||||
|
||||
return results
|
||||
|
||||
def save_session(self, state: dict):
|
||||
"""Save session state to file."""
|
||||
self._session_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue