"""Settings management for Development Hub.""" import json from pathlib import Path class Settings: """Application settings with file persistence.""" _instance = None _settings_file = Path.home() / ".config" / "development-hub" / "settings.json" _session_file = Path.home() / ".config" / "development-hub" / "session.json" # Default settings DEFAULTS = { "deploy_docs_after_creation": True, "default_project_path": str(Path.home() / "PycharmProjects"), "preferred_editor": "auto", # "auto", "pycharm", "xed", "code", "gedit" "project_search_paths": [str(Path.home() / "PycharmProjects")], "project_ignore_folders": ["trash", "project-docs", ".cache", "__pycache__", "node_modules"], "auto_start_docs_server": True, # Git hosting settings "git_host_type": "", # "gitea", "github", "gitlab", "" "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 EDITOR_CHOICES = [ ("auto", "Auto-detect"), ("pycharm", "PyCharm (IDE with markdown preview)"), ("code", "VS Code"), ("xed", "Xed (quick text editor)"), ("gedit", "Gedit"), ("subl", "Sublime Text"), ] # Git hosting provider choices GIT_HOST_CHOICES = [ ("gitea", "Gitea"), ("github", "GitHub"), ("gitlab", "GitLab"), ] def __new__(cls): """Singleton pattern.""" if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._settings = {} cls._instance._load() return cls._instance def _load(self): """Load settings from file.""" self._settings = self.DEFAULTS.copy() if self._settings_file.exists(): try: with open(self._settings_file) as f: saved = json.load(f) self._settings.update(saved) except (json.JSONDecodeError, OSError): pass def _save(self): """Save settings to file.""" self._settings_file.parent.mkdir(parents=True, exist_ok=True) with open(self._settings_file, "w") as f: json.dump(self._settings, f, indent=2) def get(self, key: str, default=None): """Get a setting value.""" return self._settings.get(key, default) def set(self, key: str, value): """Set a setting value and save.""" self._settings[key] = value self._save() @property def deploy_docs_after_creation(self) -> bool: """Whether to deploy docs after creating a new project.""" return self.get("deploy_docs_after_creation", True) @deploy_docs_after_creation.setter def deploy_docs_after_creation(self, value: bool): self.set("deploy_docs_after_creation", value) @property def default_project_path(self) -> Path: """Default path for new projects.""" return Path(self.get("default_project_path", str(Path.home() / "PycharmProjects"))) @default_project_path.setter def default_project_path(self, value: Path): self.set("default_project_path", str(value)) @property def preferred_editor(self) -> str: """Preferred editor for opening files.""" return self.get("preferred_editor", "auto") @preferred_editor.setter def preferred_editor(self, value: str): self.set("preferred_editor", value) @property def project_search_paths(self) -> list[str]: """List of directories to search for projects.""" return self.get("project_search_paths", [str(Path.home() / "PycharmProjects")]) @project_search_paths.setter def project_search_paths(self, value: list[str]): self.set("project_search_paths", value) @property def project_ignore_folders(self) -> list[str]: """Folder names to ignore during project discovery.""" return self.get("project_ignore_folders", ["trash", ".cache", "__pycache__", "node_modules"]) @project_ignore_folders.setter def project_ignore_folders(self, value: list[str]): self.set("project_ignore_folders", value) @property def auto_start_docs_server(self) -> bool: """Whether to auto-start the docs server on application startup.""" return self.get("auto_start_docs_server", True) @auto_start_docs_server.setter def auto_start_docs_server(self, value: bool): self.set("auto_start_docs_server", value) @property def git_host_type(self) -> str: """Git hosting type (gitea, github, gitlab).""" return self.get("git_host_type", "") @git_host_type.setter def git_host_type(self, value: str): self.set("git_host_type", value) @property def git_host_url(self) -> str: """Git host URL.""" return self.get("git_host_url", "") @git_host_url.setter def git_host_url(self, value: str): self.set("git_host_url", value) @property def git_host_owner(self) -> str: """Git host username or organization.""" return self.get("git_host_owner", "") @git_host_owner.setter def git_host_owner(self, value: str): self.set("git_host_owner", value) @property def git_host_token(self) -> str: """Git host API token.""" return self.get("git_host_token", "") @git_host_token.setter def git_host_token(self, value: str): self.set("git_host_token", value) @property def is_git_configured(self) -> bool: """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) try: with open(self._session_file, "w") as f: json.dump(state, f, indent=2) except OSError: pass def load_session(self) -> dict: """Load session state from file.""" if self._session_file.exists(): try: with open(self._session_file) as f: return json.load(f) except (json.JSONDecodeError, OSError): pass return {}