From 14885fb56725d44a5be777850e9d90f6ca30e0d5 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 25 Jan 2026 05:40:02 -0400 Subject: [PATCH] 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 --- pyproject.toml | 1 + src/development_hub/dialogs.py | 1159 +++++++++++++++-- src/development_hub/main_window.py | 128 +- src/development_hub/paths.py | 159 +++ src/development_hub/project_discovery.py | 21 +- src/development_hub/project_list.py | 59 +- .../services/health_checker.py | 9 +- .../services/progress_writer.py | 8 +- src/development_hub/settings.py | 236 ++++ .../views/dashboard/audit_worker.py | 6 +- .../views/dashboard/data_store.py | 43 +- .../views/dashboard/project_dashboard.py | 354 +++-- 12 files changed, 1946 insertions(+), 237 deletions(-) create mode 100644 src/development_hub/paths.py 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): "