diff --git a/pyproject.toml b/pyproject.toml
index 54473db..f2f422b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/src/development_hub/dialogs.py b/src/development_hub/dialogs.py
index d2a7c74..0ecbc9c 100644
--- a/src/development_hub/dialogs.py
+++ b/src/development_hub/dialogs.py
@@ -7,6 +7,7 @@ from pathlib import Path
from PySide6.QtCore import Qt, QThread, Signal, QProcess, QTimer
from PySide6.QtWidgets import (
+ QApplication,
QCheckBox,
QComboBox,
QDialog,
@@ -30,6 +31,7 @@ from PySide6.QtWidgets import (
QSplitter,
)
+from development_hub.paths import paths
from development_hub.settings import Settings
@@ -174,10 +176,10 @@ class DeployDocsThread(QThread):
def run(self):
"""Run the deploy docs script."""
- script = Path.home() / "PycharmProjects" / "project-docs" / "scripts" / "build-public-docs.sh"
+ script = paths.build_script
- if not script.exists():
- self.finished.emit(False, f"Build script not found: {script}")
+ if not script or not script.exists():
+ self.finished.emit(False, "Build script not found. Documentation may not be configured.")
return
cmd = [str(script), self.project_key, "--deploy"]
@@ -256,10 +258,10 @@ class RebuildMainDocsThread(QThread):
def run(self):
"""Clear cache and restart dev server."""
- project_docs = Path.home() / "PycharmProjects" / "project-docs"
+ project_docs = paths.project_docs_dir
- if not project_docs.exists():
- self.finished.emit(False, f"Project docs not found: {project_docs}")
+ if not project_docs or not project_docs.exists():
+ self.finished.emit(False, "Project docs not found. Documentation may not be configured.")
return
try:
@@ -564,18 +566,54 @@ class SettingsDialog(QDialog):
# Documentation settings
docs_group = QGroupBox("Documentation")
- docs_layout = QVBoxLayout(docs_group)
+ docs_layout = QFormLayout(docs_group)
+ # Mode selection
+ self.docs_mode_combo = QComboBox()
+ self.docs_mode_combo.addItem("Auto-detect", "auto")
+ self.docs_mode_combo.addItem("Standalone (local storage)", "standalone")
+ self.docs_mode_combo.addItem("Project-Docs (Docusaurus)", "project-docs")
+ current_mode = self.settings.docs_mode
+ for i in range(self.docs_mode_combo.count()):
+ if self.docs_mode_combo.itemData(i) == current_mode:
+ self.docs_mode_combo.setCurrentIndex(i)
+ break
+ self.docs_mode_combo.currentIndexChanged.connect(self._on_docs_mode_changed)
+ docs_layout.addRow("Mode:", self.docs_mode_combo)
+
+ # Docusaurus path
+ docusaurus_layout = QHBoxLayout()
+ self.docusaurus_path_edit = QLineEdit()
+ docusaurus_path = self.settings.docusaurus_path
+ self.docusaurus_path_edit.setText(str(docusaurus_path) if docusaurus_path else "")
+ self.docusaurus_path_edit.setPlaceholderText("Path to project-docs directory")
+ docusaurus_layout.addWidget(self.docusaurus_path_edit)
+
+ browse_docusaurus_btn = QPushButton("Browse...")
+ browse_docusaurus_btn.clicked.connect(self._browse_docusaurus_path)
+ docusaurus_layout.addWidget(browse_docusaurus_btn)
+ docs_layout.addRow("Docusaurus Path:", docusaurus_layout)
+
+ # Pages URL
+ self.pages_url_edit = QLineEdit()
+ self.pages_url_edit.setText(self.settings.pages_url)
+ self.pages_url_edit.setPlaceholderText("https://pages.example.com (optional)")
+ docs_layout.addRow("Pages URL:", self.pages_url_edit)
+
+ # Auto-start checkbox
self.auto_docs_server_checkbox = QCheckBox("Auto-start docs server on application startup")
self.auto_docs_server_checkbox.setChecked(self.settings.auto_start_docs_server)
self.auto_docs_server_checkbox.setToolTip(
"Automatically start the Docusaurus dev server (localhost:3000) when Development Hub opens,\n"
"and stop it when the application closes."
)
- docs_layout.addWidget(self.auto_docs_server_checkbox)
+ docs_layout.addRow("", self.auto_docs_server_checkbox)
layout.addWidget(docs_group)
+ # Update visibility based on current mode
+ self._on_docs_mode_changed()
+
# Git hosting settings
git_group = QGroupBox("Git Hosting")
git_layout = QFormLayout(git_group)
@@ -653,6 +691,23 @@ class SettingsDialog(QDialog):
row = self.paths_list.row(current)
self.paths_list.takeItem(row)
+ def _on_docs_mode_changed(self, index: int = None):
+ """Update visibility of docs-related fields based on mode."""
+ mode = self.docs_mode_combo.currentData()
+ is_project_docs = mode == "project-docs" or (mode == "auto" and self.settings.effective_docs_mode == "project-docs")
+
+ # Show/hide docusaurus path based on mode
+ self.docusaurus_path_edit.setEnabled(is_project_docs)
+ self.pages_url_edit.setEnabled(is_project_docs)
+ self.auto_docs_server_checkbox.setEnabled(is_project_docs)
+
+ def _browse_docusaurus_path(self):
+ """Browse for docusaurus project directory."""
+ current = self.docusaurus_path_edit.text() or str(Path.home())
+ path = QFileDialog.getExistingDirectory(self, "Select Docusaurus Project", current)
+ if path:
+ self.docusaurus_path_edit.setText(path)
+
def _on_git_type_changed(self, index: int):
"""Update URL placeholder based on git type."""
git_type = self.git_type_combo.currentData()
@@ -689,14 +744,23 @@ class SettingsDialog(QDialog):
def _save(self):
"""Save settings and close."""
# Save search paths
- paths = []
+ paths_list = []
for i in range(self.paths_list.count()):
- paths.append(self.paths_list.item(i).text())
- self.settings.project_search_paths = paths
+ paths_list.append(self.paths_list.item(i).text())
+ self.settings.project_search_paths = paths_list
# Save other settings
self.settings.deploy_docs_after_creation = self.deploy_checkbox.isChecked()
self.settings.preferred_editor = self.editor_combo.currentData()
+
+ # Save documentation settings
+ self.settings.docs_mode = self.docs_mode_combo.currentData()
+ docusaurus_path = self.docusaurus_path_edit.text().strip()
+ if docusaurus_path:
+ self.settings.docusaurus_path = Path(docusaurus_path)
+ pages_url = self.pages_url_edit.text().strip()
+ if pages_url:
+ self.settings.pages_url = pages_url
self.settings.auto_start_docs_server = self.auto_docs_server_checkbox.isChecked()
# Save git hosting settings
@@ -709,142 +773,563 @@ class SettingsDialog(QDialog):
class SetupWizardDialog(QDialog):
- """First-run setup wizard for configuring Development Hub."""
+ """Enhanced first-run setup wizard with multi-page flow.
+
+ Pages:
+ 0. Welcome - Mode selection (Simple, Documentation, Import)
+ 1. Simple Mode - Basic projects directory setup
+ 2. Documentation Mode - Full Docusaurus integration
+ 3. Import Workspace - Load settings from file
+ """
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Welcome to Development Hub")
- self.setMinimumWidth(500)
+ self.setMinimumWidth(600)
+ self.setMinimumHeight(500)
self.settings = Settings()
+ self._selected_mode = None
self._setup_ui()
def _setup_ui(self):
- """Set up the wizard UI."""
- layout = QVBoxLayout(self)
+ """Set up the wizard UI with stacked pages."""
+ from PySide6.QtWidgets import QStackedWidget, QFrame
- # Welcome message
- welcome = QLabel(
- "
Welcome to Development Hub!
"
- "Let's set up a few things to get you started.
"
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Stacked widget for pages
+ self.stack = QStackedWidget()
+ layout.addWidget(self.stack)
+
+ # Create pages
+ self._create_welcome_page()
+ self._create_simple_mode_page()
+ self._create_docs_mode_page()
+ self._create_import_page()
+
+ # Start on welcome page
+ self.stack.setCurrentIndex(0)
+
+ def _create_welcome_page(self):
+ """Create the welcome/mode selection page."""
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(40, 40, 40, 40)
+
+ # Welcome header
+ header = QLabel("Welcome to Development Hub!
")
+ header.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(header)
+
+ subtitle = QLabel(
+ "Choose how you'd like to get started:
"
)
- welcome.setWordWrap(True)
- layout.addWidget(welcome)
+ subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(subtitle)
+
+ layout.addSpacing(30)
+
+ # Mode buttons
+ modes_layout = QHBoxLayout()
+ modes_layout.addStretch()
+
+ # Simple Mode
+ simple_btn = self._create_mode_button(
+ "Simple Mode",
+ "Quick setup for basic use.\nProjects directory + local storage.\nNo documentation integration.",
+ self._select_simple_mode
+ )
+ modes_layout.addWidget(simple_btn)
+
+ modes_layout.addSpacing(20)
+
+ # Documentation Mode
+ docs_btn = self._create_mode_button(
+ "Documentation Mode",
+ "Full Docusaurus integration.\nCentralized docs, git hosting,\nproject creation automation.",
+ self._select_docs_mode
+ )
+ modes_layout.addWidget(docs_btn)
+
+ modes_layout.addStretch()
+ layout.addLayout(modes_layout)
+
+ layout.addSpacing(20)
+
+ # Import button (smaller, below)
+ import_layout = QHBoxLayout()
+ import_layout.addStretch()
+
+ import_btn = QPushButton("Import Workspace File...")
+ import_btn.setMinimumWidth(200)
+ import_btn.clicked.connect(self._select_import)
+ import_layout.addWidget(import_btn)
+
+ import_layout.addStretch()
+ layout.addLayout(import_layout)
+
+ layout.addStretch()
+
+ self.stack.addWidget(page)
+
+ def _create_mode_button(self, title: str, description: str, callback) -> QPushButton:
+ """Create a styled mode selection button."""
+ btn = QPushButton()
+ btn.setMinimumSize(200, 150)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+
+ btn_layout = QVBoxLayout(btn)
+ btn_layout.setContentsMargins(15, 15, 15, 15)
+
+ title_label = QLabel(f"{title}")
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ btn_layout.addWidget(title_label)
+
+ desc_label = QLabel(description)
+ desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ desc_label.setWordWrap(True)
+ desc_label.setStyleSheet("color: #888; font-size: 11px;")
+ btn_layout.addWidget(desc_label)
+
+ btn.clicked.connect(callback)
+ return btn
+
+ def _create_simple_mode_page(self):
+ """Create the Simple Mode setup page."""
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(40, 30, 40, 30)
+
+ # Header
+ header = QLabel("Simple Mode Setup
")
+ layout.addWidget(header)
+
+ desc = QLabel(
+ "Development Hub will store data locally and discover projects from your chosen directory."
+ )
+ desc.setWordWrap(True)
+ desc.setStyleSheet("color: #888;")
+ layout.addWidget(desc)
+
+ layout.addSpacing(20)
# Projects directory
projects_group = QGroupBox("Projects Directory")
projects_layout = QHBoxLayout(projects_group)
- self.projects_dir_edit = QLineEdit()
- self.projects_dir_edit.setText(str(self.settings.default_project_path))
- projects_layout.addWidget(self.projects_dir_edit)
+ self.simple_projects_edit = QLineEdit()
+ self.simple_projects_edit.setText(str(Path.home() / "Projects"))
+ self.simple_projects_edit.setPlaceholderText("~/Projects")
+ projects_layout.addWidget(self.simple_projects_edit)
browse_btn = QPushButton("Browse...")
- browse_btn.clicked.connect(self._browse_projects_dir)
+ browse_btn.clicked.connect(lambda: self._browse_directory(self.simple_projects_edit))
projects_layout.addWidget(browse_btn)
layout.addWidget(projects_group)
- # Git hosting settings
- git_group = QGroupBox("Git Hosting (for creating new projects)")
+ # Optional git hosting (collapsed by default)
+ git_group = QGroupBox("Git Hosting (Optional)")
+ git_group.setCheckable(True)
+ git_group.setChecked(False)
git_layout = QFormLayout(git_group)
- self.git_type_combo = QComboBox()
- for value, label in Settings.GIT_HOST_CHOICES:
- self.git_type_combo.addItem(label, value)
- self.git_type_combo.currentIndexChanged.connect(self._on_git_type_changed)
- git_layout.addRow("Provider:", self.git_type_combo)
+ self.simple_git_type = QComboBox()
+ self.simple_git_type.addItem("GitHub", "github")
+ self.simple_git_type.addItem("GitLab", "gitlab")
+ self.simple_git_type.addItem("Gitea", "gitea")
+ self.simple_git_type.currentIndexChanged.connect(self._on_simple_git_type_changed)
+ git_layout.addRow("Provider:", self.simple_git_type)
- self.git_url_edit = QLineEdit()
- self.git_url_edit.setPlaceholderText("https://github.com")
- git_layout.addRow("URL:", self.git_url_edit)
+ self.simple_git_url = QLineEdit()
+ self.simple_git_url.setText("https://github.com")
+ git_layout.addRow("URL:", self.simple_git_url)
- self.git_owner_edit = QLineEdit()
- self.git_owner_edit.setPlaceholderText("your username or organization")
- git_layout.addRow("Owner:", self.git_owner_edit)
-
- self.git_token_edit = QLineEdit()
- self.git_token_edit.setEchoMode(QLineEdit.EchoMode.Password)
- self.git_token_edit.setPlaceholderText("API token (optional - needed for creating repos)")
- git_layout.addRow("Token:", self.git_token_edit)
-
- skip_note = QLabel(
- "You can skip git setup and configure it later in Settings."
- )
- skip_note.setStyleSheet("color: #888;")
- git_layout.addRow("", skip_note)
+ self.simple_git_owner = QLineEdit()
+ self.simple_git_owner.setPlaceholderText("username or organization")
+ git_layout.addRow("Owner:", self.simple_git_owner)
layout.addWidget(git_group)
+ self.simple_git_group = git_group
layout.addStretch()
# Buttons
- button_layout = QHBoxLayout()
+ btn_layout = QHBoxLayout()
+ back_btn = QPushButton("Back")
+ back_btn.clicked.connect(lambda: self.stack.setCurrentIndex(0))
+ btn_layout.addWidget(back_btn)
- skip_btn = QPushButton("Skip Setup")
- skip_btn.clicked.connect(self._skip)
- button_layout.addWidget(skip_btn)
-
- button_layout.addStretch()
+ btn_layout.addStretch()
finish_btn = QPushButton("Finish Setup")
finish_btn.setDefault(True)
- finish_btn.clicked.connect(self._finish)
- button_layout.addWidget(finish_btn)
+ finish_btn.clicked.connect(self._finish_simple_mode)
+ btn_layout.addWidget(finish_btn)
- layout.addLayout(button_layout)
+ layout.addLayout(btn_layout)
- # Set initial URL based on default selection
- self._on_git_type_changed(0)
+ self.stack.addWidget(page)
- def _browse_projects_dir(self):
- """Browse for projects directory."""
- path = QFileDialog.getExistingDirectory(
+ def _create_docs_mode_page(self):
+ """Create the Documentation Mode setup page."""
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(40, 30, 40, 30)
+
+ # Header
+ header = QLabel("Documentation Mode Setup
")
+ layout.addWidget(header)
+
+ desc = QLabel(
+ "Configure Docusaurus integration for centralized documentation and automated publishing."
+ )
+ desc.setWordWrap(True)
+ desc.setStyleSheet("color: #888;")
+ layout.addWidget(desc)
+
+ layout.addSpacing(15)
+
+ # Projects directory
+ projects_group = QGroupBox("Projects Directory")
+ projects_layout = QHBoxLayout(projects_group)
+
+ self.docs_projects_edit = QLineEdit()
+ self.docs_projects_edit.setText(str(Path.home() / "PycharmProjects"))
+ projects_layout.addWidget(self.docs_projects_edit)
+
+ browse_btn = QPushButton("Browse...")
+ browse_btn.clicked.connect(lambda: self._browse_directory(self.docs_projects_edit))
+ projects_layout.addWidget(browse_btn)
+
+ layout.addWidget(projects_group)
+
+ # Docusaurus path
+ docusaurus_group = QGroupBox("Docusaurus Project")
+ docusaurus_layout = QHBoxLayout(docusaurus_group)
+
+ self.docs_docusaurus_edit = QLineEdit()
+ # Default to project-docs inside projects dir
+ self.docs_docusaurus_edit.setPlaceholderText("{projects}/project-docs")
+ docusaurus_layout.addWidget(self.docs_docusaurus_edit)
+
+ browse_btn2 = QPushButton("Browse...")
+ browse_btn2.clicked.connect(lambda: self._browse_directory(self.docs_docusaurus_edit))
+ docusaurus_layout.addWidget(browse_btn2)
+
+ layout.addWidget(docusaurus_group)
+
+ # Git hosting
+ git_group = QGroupBox("Git Hosting")
+ git_layout = QFormLayout(git_group)
+
+ self.docs_git_type = QComboBox()
+ self.docs_git_type.addItem("Gitea", "gitea")
+ self.docs_git_type.addItem("GitHub", "github")
+ self.docs_git_type.addItem("GitLab", "gitlab")
+ self.docs_git_type.currentIndexChanged.connect(self._on_docs_git_type_changed)
+ git_layout.addRow("Provider:", self.docs_git_type)
+
+ self.docs_git_url = QLineEdit()
+ self.docs_git_url.setPlaceholderText("https://gitea.example.com")
+ git_layout.addRow("URL:", self.docs_git_url)
+
+ self.docs_git_owner = QLineEdit()
+ self.docs_git_owner.setPlaceholderText("username or organization")
+ git_layout.addRow("Owner:", self.docs_git_owner)
+
+ self.docs_git_token = QLineEdit()
+ self.docs_git_token.setEchoMode(QLineEdit.EchoMode.Password)
+ self.docs_git_token.setPlaceholderText("API token for repo creation")
+ git_layout.addRow("Token:", self.docs_git_token)
+
+ self.docs_pages_url = QLineEdit()
+ self.docs_pages_url.setPlaceholderText("https://pages.example.com (optional)")
+ git_layout.addRow("Pages URL:", self.docs_pages_url)
+
+ layout.addWidget(git_group)
+
+ # Options
+ self.docs_auto_start = QCheckBox("Auto-start docs server on launch")
+ self.docs_auto_start.setChecked(True)
+ layout.addWidget(self.docs_auto_start)
+
+ layout.addStretch()
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ back_btn = QPushButton("Back")
+ back_btn.clicked.connect(lambda: self.stack.setCurrentIndex(0))
+ btn_layout.addWidget(back_btn)
+
+ btn_layout.addStretch()
+
+ finish_btn = QPushButton("Finish Setup")
+ finish_btn.setDefault(True)
+ finish_btn.clicked.connect(self._finish_docs_mode)
+ btn_layout.addWidget(finish_btn)
+
+ layout.addLayout(btn_layout)
+
+ self.stack.addWidget(page)
+
+ def _create_import_page(self):
+ """Create the Import Workspace page."""
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(40, 30, 40, 30)
+
+ # Header
+ header = QLabel("Import Workspace
")
+ layout.addWidget(header)
+
+ desc = QLabel(
+ "Load settings from a workspace file (.devhub-workspace.yaml)"
+ )
+ desc.setWordWrap(True)
+ desc.setStyleSheet("color: #888;")
+ layout.addWidget(desc)
+
+ layout.addSpacing(20)
+
+ # File picker
+ file_group = QGroupBox("Workspace File")
+ file_layout = QHBoxLayout(file_group)
+
+ self.import_file_edit = QLineEdit()
+ self.import_file_edit.setPlaceholderText("Select a workspace file...")
+ self.import_file_edit.textChanged.connect(self._preview_workspace)
+ file_layout.addWidget(self.import_file_edit)
+
+ browse_btn = QPushButton("Browse...")
+ browse_btn.clicked.connect(self._browse_workspace_file)
+ file_layout.addWidget(browse_btn)
+
+ layout.addWidget(file_group)
+
+ # Preview area
+ preview_group = QGroupBox("Preview")
+ preview_layout = QVBoxLayout(preview_group)
+
+ self.import_preview = QTextEdit()
+ self.import_preview.setReadOnly(True)
+ self.import_preview.setMaximumHeight(200)
+ self.import_preview.setPlaceholderText("Select a file to preview settings...")
+ preview_layout.addWidget(self.import_preview)
+
+ layout.addWidget(preview_group)
+
+ layout.addStretch()
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ back_btn = QPushButton("Back")
+ back_btn.clicked.connect(lambda: self.stack.setCurrentIndex(0))
+ btn_layout.addWidget(back_btn)
+
+ btn_layout.addStretch()
+
+ self.import_finish_btn = QPushButton("Import & Finish")
+ self.import_finish_btn.setDefault(True)
+ self.import_finish_btn.setEnabled(False)
+ self.import_finish_btn.clicked.connect(self._finish_import)
+ btn_layout.addWidget(self.import_finish_btn)
+
+ layout.addLayout(btn_layout)
+
+ self.stack.addWidget(page)
+
+ def _browse_directory(self, line_edit: QLineEdit):
+ """Browse for a directory and update the line edit."""
+ current = line_edit.text() or str(Path.home())
+ path = QFileDialog.getExistingDirectory(self, "Select Directory", current)
+ if path:
+ line_edit.setText(path)
+
+ def _browse_workspace_file(self):
+ """Browse for a workspace file."""
+ path, _ = QFileDialog.getOpenFileName(
self,
- "Select Projects Directory",
- self.projects_dir_edit.text() or str(Path.home()),
+ "Select Workspace File",
+ str(Path.home()),
+ "Workspace Files (*.yaml *.yml);;All Files (*)"
)
if path:
- self.projects_dir_edit.setText(path)
+ self.import_file_edit.setText(path)
- def _on_git_type_changed(self, index: int):
- """Update URL based on git type."""
- git_type = self.git_type_combo.currentData()
+ def _preview_workspace(self, path: str):
+ """Preview the contents of a workspace file."""
+ if not path or not Path(path).exists():
+ self.import_preview.clear()
+ self.import_finish_btn.setEnabled(False)
+ return
+
+ try:
+ import yaml
+ with open(path) as f:
+ workspace = yaml.safe_load(f)
+
+ # Format preview
+ lines = []
+ if "name" in workspace:
+ lines.append(f"Name: {workspace['name']}")
+
+ if "paths" in workspace:
+ paths_info = workspace["paths"]
+ if "projects_root" in paths_info:
+ lines.append(f"Projects: {paths_info['projects_root']}")
+
+ if "documentation" in workspace:
+ docs = workspace["documentation"]
+ mode = docs.get("mode", "auto")
+ lines.append(f"Docs Mode: {mode}")
+ if "docusaurus_path" in docs:
+ lines.append(f"Docusaurus: {docs['docusaurus_path']}")
+
+ if "git_hosting" in workspace:
+ git = workspace["git_hosting"]
+ git_type = git.get("type", "")
+ git_url = git.get("url", "")
+ lines.append(f"Git: {git_type} @ {git_url}")
+
+ self.import_preview.setPlainText("\n".join(lines))
+ self.import_finish_btn.setEnabled(True)
+
+ except Exception as e:
+ self.import_preview.setPlainText(f"Error reading file:\n{e}")
+ self.import_finish_btn.setEnabled(False)
+
+ def _select_simple_mode(self):
+ """Switch to Simple Mode page."""
+ self._selected_mode = "simple"
+ self.stack.setCurrentIndex(1)
+
+ def _select_docs_mode(self):
+ """Switch to Documentation Mode page."""
+ self._selected_mode = "docs"
+ self.stack.setCurrentIndex(2)
+
+ def _select_import(self):
+ """Switch to Import page."""
+ self._selected_mode = "import"
+ self.stack.setCurrentIndex(3)
+
+ def _on_simple_git_type_changed(self, index: int):
+ """Update URL placeholder for simple mode git type."""
+ git_type = self.simple_git_type.currentData()
if git_type == "github":
- self.git_url_edit.setText("https://github.com")
+ self.simple_git_url.setText("https://github.com")
elif git_type == "gitlab":
- self.git_url_edit.setText("https://gitlab.com")
+ self.simple_git_url.setText("https://gitlab.com")
elif git_type == "gitea":
- self.git_url_edit.setText("")
- self.git_url_edit.setPlaceholderText("https://gitea.example.com")
+ self.simple_git_url.setText("")
+ self.simple_git_url.setPlaceholderText("https://gitea.example.com")
+
+ def _on_docs_git_type_changed(self, index: int):
+ """Update URL placeholder for docs mode git type."""
+ git_type = self.docs_git_type.currentData()
+ if git_type == "github":
+ self.docs_git_url.setText("https://github.com")
+ elif git_type == "gitlab":
+ self.docs_git_url.setText("https://gitlab.com")
+ elif git_type == "gitea":
+ self.docs_git_url.setText("")
+ self.docs_git_url.setPlaceholderText("https://gitea.example.com")
+
+ def _finish_simple_mode(self):
+ """Save Simple Mode settings and close."""
+ projects_dir = self.simple_projects_edit.text().strip()
+ if not projects_dir:
+ QMessageBox.warning(self, "Error", "Please specify a projects directory.")
+ return
+
+ # Create directory if it doesn't exist
+ projects_path = Path(projects_dir).expanduser()
+ projects_path.mkdir(parents=True, exist_ok=True)
+
+ # Save settings
+ self.settings.default_project_path = projects_path
+ self.settings.project_search_paths = [str(projects_path)]
+ self.settings.docs_mode = "standalone"
+
+ # Save git settings if enabled
+ if self.simple_git_group.isChecked():
+ git_type = self.simple_git_type.currentData()
+ git_url = self.simple_git_url.text().strip()
+ git_owner = self.simple_git_owner.text().strip()
+ if git_type and git_url and git_owner:
+ self.settings.git_host_type = git_type
+ self.settings.git_host_url = git_url
+ self.settings.git_host_owner = git_owner
- def _skip(self):
- """Skip setup and just set first_run flag."""
self.settings.set("setup_completed", True)
- self.reject()
+ self.accept()
- def _finish(self):
- """Save settings and finish setup."""
- # Save projects directory
- projects_dir = self.projects_dir_edit.text()
- if projects_dir:
- self.settings.default_project_path = Path(projects_dir)
- self.settings.project_search_paths = [projects_dir]
+ def _finish_docs_mode(self):
+ """Save Documentation Mode settings and close."""
+ projects_dir = self.docs_projects_edit.text().strip()
+ if not projects_dir:
+ QMessageBox.warning(self, "Error", "Please specify a projects directory.")
+ return
- # Save git settings if provided
- git_type = self.git_type_combo.currentData()
- git_url = self.git_url_edit.text().rstrip("/")
- git_owner = self.git_owner_edit.text()
- git_token = self.git_token_edit.text()
+ projects_path = Path(projects_dir).expanduser()
+
+ # Docusaurus path
+ docusaurus_dir = self.docs_docusaurus_edit.text().strip()
+ if docusaurus_dir:
+ docusaurus_path = Path(docusaurus_dir).expanduser()
+ else:
+ docusaurus_path = projects_path / "project-docs"
+
+ # Git settings
+ git_type = self.docs_git_type.currentData()
+ git_url = self.docs_git_url.text().strip()
+ git_owner = self.docs_git_owner.text().strip()
+ git_token = self.docs_git_token.text().strip()
+ pages_url = self.docs_pages_url.text().strip()
+
+ # Save settings
+ self.settings.default_project_path = projects_path
+ self.settings.project_search_paths = [str(projects_path)]
+ self.settings.docs_mode = "project-docs"
+ self.settings.docusaurus_path = docusaurus_path
+ self.settings.auto_start_docs_server = self.docs_auto_start.isChecked()
if git_type and git_url and git_owner:
self.settings.git_host_type = git_type
self.settings.git_host_url = git_url
self.settings.git_host_owner = git_owner
self.settings.git_host_token = git_token
+ if pages_url:
+ self.settings.pages_url = pages_url
self.settings.set("setup_completed", True)
self.accept()
+ def _finish_import(self):
+ """Import workspace file and close."""
+ file_path = self.import_file_edit.text().strip()
+ if not file_path or not Path(file_path).exists():
+ QMessageBox.warning(self, "Error", "Please select a valid workspace file.")
+ return
+
+ try:
+ results = self.settings.import_workspace(Path(file_path))
+ imported = results.get("imported", [])
+
+ if imported:
+ QMessageBox.information(
+ self,
+ "Import Complete",
+ f"Successfully imported: {', '.join(imported)}"
+ )
+
+ self.accept()
+
+ except Exception as e:
+ QMessageBox.critical(self, "Import Error", f"Failed to import workspace:\n{e}")
+
class StandupDialog(QDialog):
"""Dialog for capturing daily standup progress."""
@@ -1425,12 +1910,10 @@ class RealignGoalsThread(QThread):
def run(self):
"""Run realign-goals tool."""
- cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge"
- if not cmdforge_path.exists():
- cmdforge_path = shutil.which("cmdforge")
- if not cmdforge_path:
- self.error.emit("CmdForge not found")
- return
+ cmdforge_path = paths.cmdforge_executable
+ if not cmdforge_path:
+ self.error.emit("CmdForge not found")
+ return
# Format answers as input
input_text = json.dumps(self.answers, indent=2)
@@ -1694,7 +2177,7 @@ class WeeklyReportDialog(QDialog):
from development_hub.parsers.progress_parser import ProgressLogManager
days = self._get_days()
- progress_dir = Path.home() / "PycharmProjects" / "project-docs" / "docs" / "progress"
+ progress_dir = paths.progress_dir
if not progress_dir.exists():
self.report_text.setPlainText(
@@ -1947,3 +2430,489 @@ class AutoAcceptDialog(QDialog):
def get_duration(self) -> int:
"""Get the selected duration in seconds."""
return self._duration_seconds
+
+
+class ImportPlanDialog(QDialog):
+ """Dialog for importing an implementation plan into a milestone.
+
+ Allows pasting plan text, extracting tasks via AI, previewing,
+ and importing as todos linked to the milestone.
+ """
+
+ def __init__(self, milestone, project_path: str | None = None, parent=None):
+ """Initialize the import plan dialog.
+
+ Args:
+ milestone: The milestone to import tasks into
+ project_path: Path to the project for saving plan document
+ parent: Parent widget
+ """
+ super().__init__(parent)
+ self.milestone = milestone
+ self.project_path = project_path
+ self._extracted_tasks = [] # List of (text, priority, phase, notes, selected) tuples
+ self._task_widgets = [] # List of (checkbox, priority_combo, text_label) tuples
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Set up the dialog UI."""
+ self.setWindowTitle(f"Import Plan to {self.milestone.id}: {self.milestone.name}")
+ self.resize(800, 700)
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(12)
+
+ # Header with instructions
+ header = QLabel(
+ "Paste your implementation plan below and click 'Extract Tasks' to "
+ "identify actionable items. You can then review and import them as todos."
+ )
+ header.setWordWrap(True)
+ header.setStyleSheet("color: #b0b0b0; font-size: 13px; padding: 8px;")
+ layout.addWidget(header)
+
+ # Plan text input
+ input_label = QLabel("Implementation Plan:")
+ input_label.setStyleSheet("font-weight: bold; color: #e0e0e0;")
+ layout.addWidget(input_label)
+
+ self.plan_input = QTextEdit()
+ self.plan_input.setPlaceholderText(
+ "Paste your implementation plan here...\n\n"
+ "The AI will extract tasks from numbered lists, bullet points, "
+ "phase headers, and action items."
+ )
+ self.plan_input.setStyleSheet("""
+ QTextEdit {
+ background-color: #2d2d2d;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 8px;
+ color: #e0e0e0;
+ font-family: monospace;
+ font-size: 12px;
+ }
+ QTextEdit:focus {
+ border-color: #4a9eff;
+ }
+ """)
+ self.plan_input.setMinimumHeight(200)
+ layout.addWidget(self.plan_input)
+
+ # Extract button
+ extract_layout = QHBoxLayout()
+ extract_layout.addStretch()
+
+ self.extract_btn = QPushButton("Extract Tasks")
+ self.extract_btn.clicked.connect(self._extract_tasks)
+ self.extract_btn.setStyleSheet("""
+ QPushButton {
+ padding: 8px 24px;
+ background-color: #4a9eff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-weight: bold;
+ }
+ QPushButton:hover {
+ background-color: #5aafff;
+ }
+ QPushButton:disabled {
+ background-color: #555555;
+ color: #888888;
+ }
+ """)
+ extract_layout.addWidget(self.extract_btn)
+ layout.addLayout(extract_layout)
+
+ # Preview section (initially hidden)
+ self.preview_section = QWidget()
+ preview_layout = QVBoxLayout(self.preview_section)
+ preview_layout.setContentsMargins(0, 0, 0, 0)
+
+ preview_header = QHBoxLayout()
+ preview_label = QLabel("Extracted Tasks:")
+ preview_label.setStyleSheet("font-weight: bold; color: #e0e0e0;")
+ preview_header.addWidget(preview_label)
+
+ self.task_count_label = QLabel("0 tasks")
+ self.task_count_label.setStyleSheet("color: #888888;")
+ preview_header.addWidget(self.task_count_label)
+
+ preview_header.addStretch()
+
+ # Select all / none buttons
+ select_all_btn = QPushButton("Select All")
+ select_all_btn.clicked.connect(self._select_all)
+ select_all_btn.setStyleSheet("padding: 4px 12px;")
+ preview_header.addWidget(select_all_btn)
+
+ select_none_btn = QPushButton("Select None")
+ select_none_btn.clicked.connect(self._select_none)
+ select_none_btn.setStyleSheet("padding: 4px 12px;")
+ preview_header.addWidget(select_none_btn)
+
+ preview_layout.addLayout(preview_header)
+
+ # Scrollable task list
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setStyleSheet("""
+ QScrollArea {
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ background-color: #252526;
+ }
+ """)
+
+ self.task_container = QWidget()
+ self.task_layout = QVBoxLayout(self.task_container)
+ self.task_layout.setContentsMargins(8, 8, 8, 8)
+ self.task_layout.setSpacing(4)
+ self.task_layout.addStretch()
+
+ scroll.setWidget(self.task_container)
+ preview_layout.addWidget(scroll)
+
+ # Options
+ options_layout = QHBoxLayout()
+
+ self.save_plan_checkbox = QCheckBox("Save plan to docs/plans/")
+ self.save_plan_checkbox.setChecked(True)
+ self.save_plan_checkbox.setStyleSheet("color: #b0b0b0;")
+ options_layout.addWidget(self.save_plan_checkbox)
+
+ self.update_desc_checkbox = QCheckBox("Update milestone description")
+ self.update_desc_checkbox.setChecked(True)
+ self.update_desc_checkbox.setStyleSheet("color: #b0b0b0;")
+ options_layout.addWidget(self.update_desc_checkbox)
+
+ options_layout.addStretch()
+ preview_layout.addLayout(options_layout)
+
+ self.preview_section.setVisible(False)
+ layout.addWidget(self.preview_section)
+
+ # Button row
+ button_layout = QHBoxLayout()
+ button_layout.addStretch()
+
+ cancel_btn = QPushButton("Cancel")
+ cancel_btn.clicked.connect(self.reject)
+ cancel_btn.setStyleSheet("padding: 8px 24px;")
+ button_layout.addWidget(cancel_btn)
+
+ self.import_btn = QPushButton("Import Selected Tasks")
+ self.import_btn.clicked.connect(self.accept)
+ self.import_btn.setEnabled(False)
+ self.import_btn.setStyleSheet("""
+ QPushButton {
+ padding: 8px 24px;
+ background-color: #4caf50;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-weight: bold;
+ }
+ QPushButton:hover {
+ background-color: #5cbf60;
+ }
+ QPushButton:disabled {
+ background-color: #555555;
+ color: #888888;
+ }
+ """)
+ button_layout.addWidget(self.import_btn)
+
+ layout.addLayout(button_layout)
+
+ def _extract_tasks(self):
+ """Extract tasks from the plan text using AI."""
+ plan_text = self.plan_input.toPlainText().strip()
+ if not plan_text:
+ return
+
+ self.extract_btn.setEnabled(False)
+ self.extract_btn.setText("Extracting with AI...")
+ QApplication.processEvents() # Update UI immediately
+
+ extraction_method = "regex" # Track which method was used
+
+ # Try to use cmdforge tool for extraction
+ try:
+ import subprocess
+ import json
+
+ # Call cmdforge tool if available
+ result = subprocess.run(
+ ["extract-plan"],
+ input=plan_text,
+ capture_output=True,
+ text=True,
+ timeout=120, # Increased timeout for AI
+ )
+
+ if result.returncode == 0:
+ try:
+ data = json.loads(result.stdout)
+ # Check if it's an error response from the tool
+ if "error" in data and not data.get("phases") and not data.get("tasks"):
+ print(f"[ImportPlan] AI extraction returned error: {data.get('error')}")
+ print(f"[ImportPlan] Raw response: {data.get('raw', '')[:500]}")
+ self._simple_extract(plan_text)
+ else:
+ extraction_method = "AI (extract-plan)"
+ self._process_extracted_data(data)
+ except json.JSONDecodeError as e:
+ print(f"[ImportPlan] JSON decode error: {e}")
+ print(f"[ImportPlan] stdout: {result.stdout[:500]}")
+ self._simple_extract(plan_text)
+ else:
+ print(f"[ImportPlan] Tool returned non-zero: {result.returncode}")
+ print(f"[ImportPlan] stderr: {result.stderr}")
+ self._simple_extract(plan_text)
+
+ except FileNotFoundError:
+ print("[ImportPlan] extract-plan tool not found, using regex fallback")
+ self._simple_extract(plan_text)
+ except subprocess.TimeoutExpired:
+ print("[ImportPlan] AI extraction timed out after 120s, using regex fallback")
+ self._simple_extract(plan_text)
+ except Exception as e:
+ print(f"[ImportPlan] Unexpected error: {type(e).__name__}: {e}")
+ self._simple_extract(plan_text)
+
+ self.extract_btn.setEnabled(True)
+ self.extract_btn.setText(f"Re-extract ({extraction_method})")
+
+ def _simple_extract(self, text: str):
+ """Simple regex-based task extraction fallback."""
+ import re
+
+ tasks = []
+ current_phase = None
+ lines = text.split('\n')
+
+ for line in lines:
+ line = line.strip()
+ if not line:
+ continue
+
+ # Detect phase headers
+ phase_match = re.match(r'^(?:Phase\s*(\d+)|#{1,5}\s*Phase\s*(\d+))[:.\s]*(.+)?', line, re.IGNORECASE)
+ if phase_match:
+ num = phase_match.group(1) or phase_match.group(2)
+ name = phase_match.group(3) or ""
+ current_phase = f"Phase {num}" + (f": {name.strip()}" if name.strip() else "")
+ continue
+
+ # Detect numbered list items
+ numbered_match = re.match(r'^(\d+)[.\)]\s*(.+)$', line)
+ if numbered_match:
+ task_text = numbered_match.group(2).strip()
+ if task_text and len(task_text) > 3:
+ tasks.append({
+ "text": task_text,
+ "priority": "medium",
+ "phase": current_phase,
+ "notes": None,
+ })
+ continue
+
+ # Detect bullet list items
+ bullet_match = re.match(r'^[-*•]\s*(.+)$', line)
+ if bullet_match:
+ task_text = bullet_match.group(1).strip()
+ # Skip if it looks like a sub-description or too short
+ if task_text and len(task_text) > 5 and not task_text.startswith(('>', 'Note:', 'Example:')):
+ tasks.append({
+ "text": task_text,
+ "priority": "medium",
+ "phase": current_phase,
+ "notes": None,
+ })
+ continue
+
+ # Detect checkbox items
+ checkbox_match = re.match(r'^-?\s*\[[ xX]\]\s*(.+)$', line)
+ if checkbox_match:
+ task_text = checkbox_match.group(1).strip()
+ if task_text and len(task_text) > 3:
+ tasks.append({
+ "text": task_text,
+ "priority": "medium",
+ "phase": current_phase,
+ "notes": None,
+ })
+
+ self._process_extracted_data({"tasks": tasks})
+
+ def _process_extracted_data(self, data: dict):
+ """Process extracted data and populate the preview."""
+ self._extracted_tasks = []
+
+ # Handle flat tasks list or phased structure
+ if "phases" in data:
+ for phase in data["phases"]:
+ phase_name = phase.get("name", "")
+ for task in phase.get("tasks", []):
+ self._extracted_tasks.append({
+ "text": task.get("text", ""),
+ "priority": task.get("priority", "medium"),
+ "phase": phase_name,
+ "notes": task.get("notes") or task.get("file"),
+ "selected": True,
+ })
+ elif "tasks" in data:
+ for task in data["tasks"]:
+ self._extracted_tasks.append({
+ "text": task.get("text", ""),
+ "priority": task.get("priority", "medium"),
+ "phase": task.get("phase"),
+ "notes": task.get("notes"),
+ "selected": True,
+ })
+
+ # Store overview for milestone description update
+ self._overview = data.get("overview", "")
+
+ self._populate_task_list()
+ self.preview_section.setVisible(True)
+ self._update_import_button()
+
+ def _populate_task_list(self):
+ """Populate the task list with extracted tasks."""
+ # Clear existing
+ self._task_widgets = []
+ while self.task_layout.count() > 1: # Keep the stretch
+ item = self.task_layout.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+
+ # Collect unique phases for dropdown
+ phases = set()
+ for task in self._extracted_tasks:
+ if task["phase"]:
+ phases.add(task["phase"])
+ self._available_phases = sorted(phases, key=lambda p: (
+ int(p.split()[1].rstrip(':')) if p.startswith('Phase ') and p.split()[1].rstrip(':').isdigit() else 999,
+ p
+ ))
+
+ # Add task rows
+ for i, task in enumerate(self._extracted_tasks):
+ row = QWidget()
+ row_layout = QHBoxLayout(row)
+ row_layout.setContentsMargins(4, 2, 4, 2)
+ row_layout.setSpacing(8)
+
+ # Checkbox
+ checkbox = QCheckBox()
+ checkbox.setChecked(task["selected"])
+ checkbox.stateChanged.connect(lambda state, idx=i: self._on_task_toggled(idx, state))
+ row_layout.addWidget(checkbox)
+
+ # Priority dropdown
+ priority_combo = QComboBox()
+ priority_combo.addItem("H", "high")
+ priority_combo.addItem("M", "medium")
+ priority_combo.addItem("L", "low")
+ priority_map = {"high": 0, "medium": 1, "low": 2}
+ priority_combo.setCurrentIndex(priority_map.get(task["priority"], 1))
+ priority_combo.setFixedWidth(45)
+ priority_combo.currentIndexChanged.connect(
+ lambda idx, task_idx=i, combo=priority_combo: self._on_priority_changed(task_idx, combo)
+ )
+ row_layout.addWidget(priority_combo)
+
+ # Phase dropdown (editable)
+ phase_combo = QComboBox()
+ phase_combo.setEditable(True)
+ phase_combo.addItem("(none)", None)
+ for phase in self._available_phases:
+ phase_combo.addItem(phase, phase)
+ # Set current phase
+ if task["phase"]:
+ idx = phase_combo.findData(task["phase"])
+ if idx >= 0:
+ phase_combo.setCurrentIndex(idx)
+ else:
+ # Phase not in list, add it
+ phase_combo.addItem(task["phase"], task["phase"])
+ phase_combo.setCurrentIndex(phase_combo.count() - 1)
+ else:
+ phase_combo.setCurrentIndex(0) # (none)
+ phase_combo.setMinimumWidth(220)
+ phase_combo.currentTextChanged.connect(
+ lambda text, task_idx=i: self._on_phase_changed(task_idx, text)
+ )
+ row_layout.addWidget(phase_combo)
+
+ # Task text (without phase prefix since it's in dropdown now)
+ text_label = QLabel(task["text"])
+ text_label.setWordWrap(True)
+ text_label.setStyleSheet("color: #e0e0e0;")
+ if task["notes"]:
+ text_label.setToolTip(task["notes"])
+ row_layout.addWidget(text_label, 1)
+
+ self._task_widgets.append((checkbox, priority_combo, phase_combo, text_label))
+ self.task_layout.insertWidget(self.task_layout.count() - 1, row)
+
+ self.task_count_label.setText(f"{len(self._extracted_tasks)} tasks")
+
+ def _on_task_toggled(self, index: int, state: int):
+ """Handle task checkbox toggle."""
+ self._extracted_tasks[index]["selected"] = state == Qt.CheckState.Checked.value
+ self._update_import_button()
+
+ def _on_priority_changed(self, index: int, combo: QComboBox):
+ """Handle priority change."""
+ self._extracted_tasks[index]["priority"] = combo.currentData()
+
+ def _on_phase_changed(self, index: int, text: str):
+ """Handle phase change."""
+ if text == "(none)" or not text.strip():
+ self._extracted_tasks[index]["phase"] = None
+ else:
+ self._extracted_tasks[index]["phase"] = text.strip()
+
+ def _select_all(self):
+ """Select all tasks."""
+ for i, (checkbox, *_) in enumerate(self._task_widgets):
+ checkbox.setChecked(True)
+ self._extracted_tasks[i]["selected"] = True
+ self._update_import_button()
+
+ def _select_none(self):
+ """Deselect all tasks."""
+ for i, (checkbox, *_) in enumerate(self._task_widgets):
+ checkbox.setChecked(False)
+ self._extracted_tasks[i]["selected"] = False
+ self._update_import_button()
+
+ def _update_import_button(self):
+ """Update import button state."""
+ selected_count = sum(1 for t in self._extracted_tasks if t["selected"])
+ self.import_btn.setEnabled(selected_count > 0)
+ self.import_btn.setText(f"Import {selected_count} Task{'s' if selected_count != 1 else ''}")
+
+ def get_selected_tasks(self) -> list[dict]:
+ """Get the list of selected tasks with their settings."""
+ return [t for t in self._extracted_tasks if t["selected"]]
+
+ def get_plan_text(self) -> str:
+ """Get the original plan text."""
+ return self.plan_input.toPlainText()
+
+ def should_save_plan(self) -> bool:
+ """Check if plan should be saved to file."""
+ return self.save_plan_checkbox.isChecked()
+
+ def should_update_description(self) -> bool:
+ """Check if milestone description should be updated."""
+ return self.update_desc_checkbox.isChecked()
+
+ def get_overview(self) -> str:
+ """Get the extracted overview text."""
+ return getattr(self, '_overview', '')
diff --git a/src/development_hub/main_window.py b/src/development_hub/main_window.py
index 8d6cb74..ec58356 100644
--- a/src/development_hub/main_window.py
+++ b/src/development_hub/main_window.py
@@ -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):
""
"- Ctrl+Z - Undo (dashboard)
"
"- Ctrl+Shift+Z - Redo (dashboard)
"
+ "- Ctrl+G - Global Dashboard
"
"- Ctrl+Shift+T - New terminal tab
"
"- Ctrl+Shift+W - Close current tab
"
"- Ctrl+Shift+D - Split pane horizontal
"
@@ -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
diff --git a/src/development_hub/paths.py b/src/development_hub/paths.py
new file mode 100644
index 0000000..a005d11
--- /dev/null
+++ b/src/development_hub/paths.py
@@ -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()
diff --git a/src/development_hub/project_discovery.py b/src/development_hub/project_discovery.py
index 8d356fc..62c420b 100644
--- a/src/development_hub/project_discovery.py
+++ b/src/development_hub/project_discovery.py
@@ -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\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'
diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py
index 17fe97c..888a68b 100644
--- a/src/development_hub/project_list.py
+++ b/src/development_hub/project_list.py
@@ -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
diff --git a/src/development_hub/services/health_checker.py b/src/development_hub/services/health_checker.py
index 3724799..199fe1a 100644
--- a/src/development_hub/services/health_checker.py
+++ b/src/development_hub/services/health_checker.py
@@ -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.
diff --git a/src/development_hub/services/progress_writer.py b/src/development_hub/services/progress_writer.py
index 022dba3..c4ce39d 100644
--- a/src/development_hub/services/progress_writer.py
+++ b/src/development_hub/services/progress_writer.py
@@ -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."""
diff --git a/src/development_hub/settings.py b/src/development_hub/settings.py
index ffb5efa..53b4a1e 100644
--- a/src/development_hub/settings.py
+++ b/src/development_hub/settings.py
@@ -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)
diff --git a/src/development_hub/views/dashboard/audit_worker.py b/src/development_hub/views/dashboard/audit_worker.py
index 900dfbd..d55db76 100644
--- a/src/development_hub/views/dashboard/audit_worker.py
+++ b/src/development_hub/views/dashboard/audit_worker.py
@@ -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:
diff --git a/src/development_hub/views/dashboard/data_store.py b/src/development_hub/views/dashboard/data_store.py
index 9a2fd26..756fc4b 100644
--- a/src/development_hub/views/dashboard/data_store.py
+++ b/src/development_hub/views/dashboard/data_store.py
@@ -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()
diff --git a/src/development_hub/views/dashboard/project_dashboard.py b/src/development_hub/views/dashboard/project_dashboard.py
index 66a52da..918b0b4 100644
--- a/src/development_hub/views/dashboard/project_dashboard.py
+++ b/src/development_hub/views/dashboard/project_dashboard.py
@@ -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()