diff --git a/dev_env_sync/managers/editor.py b/dev_env_sync/managers/editor.py new file mode 100644 index 0000000..3f3cb3d --- /dev/null +++ b/dev_env_sync/managers/editor.py @@ -0,0 +1,452 @@ +"""Editor configuration synchronization.""" + +import json +import os +import shutil +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Optional +from dataclasses import dataclass + +from dev_env_sync.core.config import EditorExtension, ConfigSchema +from dev_env_sync.utils.file_ops import FileOps, path_expand +from dev_env_sync.utils.logging import get_logger +from dev_env_sync.core.platform import PlatformDetector + + +class EditorError(Exception): + """Raised when an editor operation fails.""" + pass + + +@dataclass +class EditorSyncResult: + """Result of an editor sync operation.""" + success: bool + editor: str + operation: str + details: str + path: Optional[Path] = None + + +class EditorConfig(ABC): + """Abstract base class for editor configuration.""" + + def __init__(self, dry_run: bool = False, logger=None): + self.dry_run = dry_run + self.logger = logger or get_logger(__name__) + self.file_ops = FileOps(dry_run=dry_run, logger=logger) + + @property + @abstractmethod + def name(self) -> str: + """Return the editor name.""" + pass + + @abstractmethod + def get_config_dir(self) -> Path: + """Get the platform-specific config directory.""" + pass + + @abstractmethod + def get_settings_file(self) -> Optional[Path]: + """Get the path to the settings file.""" + pass + + @abstractmethod + def sync_settings(self, settings_source: Path) -> EditorSyncResult: + """Sync editor settings from source file.""" + pass + + @abstractmethod + def sync_extensions(self, extensions: List[EditorExtension]) -> List[EditorSyncResult]: + """Sync editor extensions.""" + pass + + def _ensure_config_dir(self) -> Path: + """Ensure the config directory exists.""" + config_dir = self.get_config_dir() + self.file_ops.create_directory(config_dir, exist_ok=True) + return config_dir + + +class VSCodeConfig(EditorConfig): + """VS Code configuration handler.""" + + @property + def name(self) -> str: + return "vscode" + + def get_config_dir(self) -> Path: + return PlatformDetector.get_editor_config_dir("vscode") + + def get_settings_file(self) -> Optional[Path]: + return self.get_config_dir() / "settings.json" + + def sync_settings(self, settings_source: Path) -> EditorSyncResult: + """Sync VS Code settings from source JSON file.""" + try: + self._ensure_config_dir() + + if not settings_source.exists(): + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Source settings file not found: {settings_source}", + ) + + content = self.file_ops.read_file(settings_source) + if content is None: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Failed to read settings source: {settings_source}", + ) + + valid_json = json.loads(content) + + target = self.get_settings_file() + success = self.file_ops.write_file(target, json.dumps(valid_json, indent=2)) + + if success: + return EditorSyncResult( + success=True, + editor=self.name, + operation="sync_settings", + details=f"Synced settings to {target}", + path=target, + ) + else: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Failed to write settings: {target}", + ) + except json.JSONDecodeError as e: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Invalid JSON in settings source: {e}", + ) + except Exception as e: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Error syncing settings: {e}", + ) + + def sync_extensions(self, extensions: List[EditorExtension]) -> List[EditorSyncResult]: + """Sync VS Code extensions (install command generation).""" + results = [] + + for ext in extensions: + result = self._install_extension(ext.name) + results.append(result) + + return results + + def _install_extension(self, extension_id: str) -> EditorSyncResult: + """Generate extension installation command.""" + install_cmd = f"code --install-extension {extension_id}" + + self.logger.info(f"VS Code extension: {extension_id}") + self.logger.info(f" Install command: {install_cmd}") + + if self.dry_run: + return EditorSyncResult( + success=True, + editor=self.name, + operation="install_extension", + details=f"[DRY-RUN] Would install: {extension_id}", + ) + + return EditorSyncResult( + success=True, + editor=self.name, + operation="install_extension", + details=f"Extension installation queued: {extension_id}", + ) + + def get_extensions_list(self) -> List[str]: + """Get list of currently installed extensions.""" + return [] + + def merge_settings( + self, + source: Path, + target: Path, + strategy: str = "replace", + ) -> EditorSyncResult: + """Merge VS Code settings.""" + try: + source_content = self.file_ops.read_file(source) + target_content = self.file_ops.read_file(target) + + if source_content is None: + return EditorSyncResult( + success=False, + editor=self.name, + operation="merge_settings", + details=f"Could not read source: {source}", + ) + + source_settings = json.loads(source_content) if source_content else {} + + if target_content and strategy == "merge": + target_settings = json.loads(target_content) + merged = {**target_settings, **source_settings} + else: + merged = source_settings + + success = self.file_ops.write_file(target, json.dumps(merged, indent=2)) + + if success: + return EditorSyncResult( + success=True, + editor=self.name, + operation="merge_settings", + details=f"Merged settings to {target}", + path=target, + ) + else: + return EditorSyncResult( + success=False, + editor=self.name, + operation="merge_settings", + details=f"Failed to write merged settings: {target}", + ) + except json.JSONDecodeError as e: + return EditorSyncResult( + success=False, + editor=self.name, + operation="merge_settings", + details=f"Invalid JSON: {e}", + ) + except Exception as e: + return EditorSyncResult( + success=False, + editor=self.name, + operation="merge_settings", + details=f"Error merging settings: {e}", + ) + + +class NeovimConfig(EditorConfig): + """Neovim configuration handler.""" + + @property + def name(self) -> str: + return "neovim" + + def get_config_dir(self) -> Path: + return PlatformDetector.get_editor_config_dir("neovim") + + def get_init_file(self) -> Path: + """Get the path to init.lua or init.vim.""" + init_lua = self.get_config_dir() / "init.lua" + init_vim = self.get_config_dir() / "init.vim" + + if init_lua.exists(): + return init_lua + elif init_vim.exists(): + return init_vim + return init_lua + + def get_settings_file(self) -> Optional[Path]: + return self.get_init_file() + + def sync_settings(self, settings_source: Path) -> EditorSyncResult: + """Sync Neovim config from source file.""" + try: + self._ensure_config_dir() + + if not settings_source.exists(): + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Source config not found: {settings_source}", + ) + + content = self.file_ops.read_file(settings_source) + if content is None: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Failed to read config source: {settings_source}", + ) + + target = self.get_init_file() + success = self.file_ops.write_file(target, content) + + if success: + return EditorSyncResult( + success=True, + editor=self.name, + operation="sync_settings", + details=f"Synced config to {target}", + path=target, + ) + else: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Failed to write config: {target}", + ) + except Exception as e: + return EditorSyncResult( + success=False, + editor=self.name, + operation="sync_settings", + details=f"Error syncing config: {e}", + ) + + def sync_extensions(self, extensions: List[EditorExtension]) -> List[EditorSyncResult]: + """Sync Neovim plugins (vim-plug installation commands).""" + results = [] + + self.logger.info("Neovim plugins detected:") + for ext in extensions: + result = self._install_plugin(ext.name) + results.append(result) + + return results + + def _install_plugin(self, plugin: str) -> EditorSyncResult: + """Generate plugin installation information.""" + install_cmd = f"Plug '{plugin}' # Add to init.lua, then run :PlugInstall" + + self.logger.info(f" Neovim plugin: {plugin}") + self.logger.info(f" Add to init.lua: {install_cmd}") + + return EditorSyncResult( + success=True, + editor=self.name, + operation="install_plugin", + details=f"Plugin configuration: {plugin}", + ) + + def generate_vim_plug_setup(self) -> str: + """Generate vim-plug setup code.""" + return r''' +" Install vim-plug if not installed +if empty(glob('~/.config/nvim/autoload/plug.vim')) + silent !curl -fLo ~/.config/nvim/autoload/plug.vim --create-dirs + \ https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim + autocmd VimEnter * PlugInstall --sync | source $MYVIMRC +endif + +" Add your plugins between plug#begin and plug#end +call plug#begin('~/.config/nvim/plugged') + +" Plugins go here +" Plug 'plugin_author/plugin_name' + +call plug#end() +''' + + def sync_init_lua(self, source: Path) -> EditorSyncResult: + """Sync init.lua configuration.""" + return self.sync_settings(source) + + def sync_lua_config(self, config_dir: Path) -> List[EditorSyncResult]: + """Sync entire lua config directory.""" + results = [] + self._ensure_config_dir() + + lua_target_dir = self.get_config_dir() / "lua" + if config_dir.exists(): + for lua_file in config_dir.rglob("*.lua"): + relative_path = lua_file.relative_to(config_dir) + target = lua_target_dir / relative_path + result = self.sync_settings(lua_file) + results.append(result) + + return results + + +class EditorManager: + """Manager for all editor configurations.""" + + EDITORS = { + "vscode": VSCodeConfig, + "neovim": NeovimConfig, + } + + def __init__(self, config: ConfigSchema, dry_run: bool = False, logger=None): + self.config = config + self.dry_run = dry_run + self.logger = logger or get_logger(__name__) + self._editors: Dict[str, EditorConfig] = {} + + def _get_editor(self, editor_name: str) -> Optional[EditorConfig]: + """Get or create an editor configuration handler.""" + if editor_name not in self.EDITORS: + self.logger.warning(f"Unknown editor: {editor_name}") + return None + + if editor_name not in self._editors: + self._editors[editor_name] = self.EDITORS[editor_name]( + dry_run=self.dry_run, + logger=self.logger, + ) + + return self._editors[editor_name] + + def sync_editor(self, editor_name: str) -> List[EditorSyncResult]: + """Sync a specific editor's configuration.""" + results = [] + + if editor_name not in self.config.editors: + self.logger.warning(f"Editor '{editor_name}' not in configuration") + return results + + editor_config = self._get_editor(editor_name) + if editor_config is None: + return results + + editor_settings = self.config.editors[editor_name] + + if "settings" in editor_settings: + if isinstance(editor_settings["settings"], dict): + settings_file = editor_settings["settings"].get("settings_file") + if settings_file: + result = editor_config.sync_settings(Path(settings_file)) + results.append(result) + elif isinstance(editor_settings["settings"], str): + result = editor_config.sync_settings(Path(editor_settings["settings"])) + results.append(result) + + if "extensions" in editor_settings: + extensions = editor_settings["extensions"] + ext_results = editor_config.sync_extensions(extensions) + results.extend(ext_results) + + return results + + def sync_all(self) -> Dict[str, List[EditorSyncResult]]: + """Sync all editor configurations.""" + results = {} + + for editor_name in self.config.editors: + editor_results = self.sync_editor(editor_name) + results[editor_name] = editor_results + + return results + + def print_summary(self, results: Dict[str, List[EditorSyncResult]]): + """Print a summary of editor sync results.""" + self.logger.info("Editor Configuration Summary:") + + for editor, editor_results in results.items(): + self.logger.info(f" {editor}:") + for result in editor_results: + status = "OK" if result.success else "FAIL" + self.logger.info(f" [{status}] {result.operation}: {result.details}")