diff --git a/.gitignore b/.gitignore index 79a9498..3a47d02 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ __pycache__ htmlcov # --- Odev plugins, loaded as submodules -odev/plugins/* tests/plugins/* diff --git a/README.md b/README.md index 6e33156..a882543 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # ODEV - VSCode Editor -Configure VSCode for a database and open a repository in the editor. +Configure VSCode or any other editor derivated from VSCodium (e.g. VSCodium) for a database and open a repository +in the editor. ## Installation @@ -12,3 +13,12 @@ Enable this plugin by running: ```bash odev plugin --enable odoo-odev/odev-plugin-editor-vscode ``` + +Using other editors with the same base (e.g. VSCodium) should work as well at the cost of setting up a symlink +to the editor executable in your path. + +Antigravity example: + +```bash +ln -s $(which antigravity) /usr/local/bin/code +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..effaf3b --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from .common import editor_vscode # noqa: F401 diff --git a/__manifest__.py b/__manifest__.py index 39d6588..bec5f51 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -22,7 +22,7 @@ # or merged change. # ------------------------------------------------------------------------------ -__version__ = "1.1.0" +__version__ = "2.0.0" # --- Dependencies ------------------------------------------------------------- # List other odev plugins from which this current plugin depends. diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/editor_vscode.py b/common/editor_vscode.py index e1cddf2..7c8bcb3 100644 --- a/common/editor_vscode.py +++ b/common/editor_vscode.py @@ -1,11 +1,14 @@ import json +import os +import re from pathlib import Path -from typing import cast -from odev.common import progress, string -from odev.common.console import console +from jinja2 import Environment, FileSystemLoader + +from odev.common import bash, progress, string from odev.common.databases import LocalDatabase from odev.common.logging import logging +from odev.common.odoobin import OdoobinProcess from odev.common.python import PythonEnv from odev.plugins.odev_plugin_editor_base.common.editor import Editor @@ -20,22 +23,60 @@ class VSCodeEditor(Editor): _name = "code" _display_name = "VSCode" + @property + def display_name(self) -> str: + """Also handle other editors derivated from VSCodium (e.g. Antigravity).""" + result = bash.execute(f"{self._name} --help", raise_on_error=False) + + if result and result.stdout: + first_line = result.stdout.decode().splitlines()[0] + name = re.sub(r"\s+v?[\d.]+.*$", "", first_line).strip() + + if name: + return name + + return self._display_name + + @property + def process(self) -> OdoobinProcess: + """Odoo process backing the configuration. + + Falls back to a version-based process when no local database exists, so that + repositories opened without a database (version-only) can still be configured. + """ + if isinstance(self.database, LocalDatabase) and self.database.process: + return self.database.process + return OdoobinProcess(self.database, version=self.version).with_edition("enterprise") + @property def command(self) -> str: - if isinstance(self.database, LocalDatabase): - return f"{self._name} {self.workspace_path}" - else: - return super().command + return f"{self._name} {self.workspace_path}" + + @property + def templates(self) -> Environment: + return Environment( # noqa: S701 + loader=FileSystemLoader(self.database.odev.plugins_path / "odev_plugin_editor_vscode/templates") + ) + + @property + def odoo_path(self) -> Path: + """The path to the worktree holding the Odoo sources (odoo, enterprise, ...).""" + return self.database.odev.worktrees_path / self.process.worktree @property def workspace_directory(self) -> Path: """The path to the workspace directory.""" - return self.path / ".vscode" + return self.path / ".vscode" if isinstance(self.database, LocalDatabase) else self.path + + @property + def workspace_name(self) -> str: + """The base name used for the workspace file.""" + return self.database.name if isinstance(self.database, LocalDatabase) else str(self.version) @property def workspace_path(self) -> Path: """The path to the workspace file.""" - return self.workspace_directory / f"{self.database.name}.code-workspace" + return self.workspace_directory / f"{self.workspace_name}.code-workspace" @property def launch_path(self) -> Path: @@ -49,106 +90,124 @@ def tasks_path(self) -> Path: def configure(self): """Configure VSCode to work with the database.""" - if not isinstance(self.database, LocalDatabase): + if not isinstance(self.database, LocalDatabase) and not self.version: return logger.warning( - f"No local database associated with repository {self.git.name!r}, skipping VSCode configuration" + f"No local database associated with repository {self.git.name!r}, " + f"skipping {self.display_name} configuration" ) - with progress.spinner(f"Configuring {self._display_name} for project {self.git.name!r}"): - self.workspace_directory.mkdir(parents=True, exist_ok=True) + config_files = [self.workspace_path, self.launch_path, self.tasks_path, self.path / "jsconfig.json"] - missing_files = filter( - lambda path: not path.is_file(), - [self.workspace_path, self.launch_path, self.tasks_path], - ) + if all(path.is_file() for path in config_files): + logger.debug(f"{self.display_name} config files already exist, skipping configuration") + return None - if not list(missing_files): - return logger.debug("VSCode config files already exist") + with progress.spinner(f"Configuring {self.display_name} for project {self.git.name!r}"): + self.workspace_directory.mkdir(parents=True, exist_ok=True) self._create_workspace() self._create_launch() self._create_tasks() + self._create_jsconfig() created_files = string.join_bullet( [ f"Workspace: {self.workspace_path}", - f"Debugging: {self.launch_path}", + f"Launch: {self.launch_path}", f"Tasks: {self.tasks_path}", ], ) - logger.info(f"Created VSCode config for project {self.git.name!r}\n{created_files}") - - def _create_workspace(self): - """Create a workspace file for the project.""" - assert isinstance(self.database, LocalDatabase) + logger.info(f"Created {self.display_name} config for project {self.git.name!r}\n{created_files}") + return None - if self.workspace_path.is_file(): - return logger.debug("Workspace file already exists") - - workspace_config = { - "folders": [{"path": ".."}], - "settings": { - "terminal.integrated.cwd": self.path.as_posix(), - "python.defaultInterpreterPath": self.database.venv.python.as_posix(), - }, - } + def _get_rendered_template(self, template_name, **kwargs): + template = self.templates.get_template(template_name) + return template.render(kwargs) - for worktree in self.database.worktrees: - cast(list, workspace_config["folders"]).append({"path": worktree.path.as_posix()}) + @property + def workspace_folders(self) -> list[dict]: + """The multi-root workspace folders, depending on the configured layout. + + - 'flat' (default): one top-level root per odoo worktree alongside the project. + - 'nested': a single 'odoo' root holding all worktrees as subfolders. + """ + project_folder = {"path": "..", "name": "project"} + layout = self.database.odev.config.vscode.workspace_layout + + if layout == "flat": + return [ + project_folder, + *( + {"path": worktree.path.as_posix(), "name": worktree.path.name} + for worktree in self.process.odoo_worktrees + ), + ] + + return [project_folder, {"path": self.odoo_path.as_posix(), "name": "odoo"}] - console.print(json.dumps(workspace_config, indent=4), file=self.workspace_path) + def _create_workspace(self): + """Create a workspace file for the project.""" + rendered_template = self._get_rendered_template( + "code-workspace.jinja", + DB_NAME=self.workspace_name, + ODOO_PATH=self.odoo_path, + FOLDERS=json.dumps(self.workspace_folders, indent=4), + VENV_PATH=self.process.venv.python.as_posix(), + RUFF_PATH=(self.process.venv.path / "bin" / "ruff").as_posix(), + PYTHON_PATH=PythonEnv().python.as_posix(), + ODEV_EXE_PATH=self.database.odev.executable.with_name("main.py").as_posix(), + ) + with open(self.workspace_path, "w", encoding="utf-8") as f: + f.write(rendered_template) def _create_launch(self): """Create a launch file for the project.""" - if self.launch_path.is_file(): - return logger.debug("Launch file already exists") - - def run_config(shell: bool = False): - title = "Shell" if shell else "Run" - return { - "name": title, - "type": "debugpy", - "request": "launch", - "subProcess": True, - "justMyCode": True, - "console": "integratedTerminal", - "consoleName": f"Odev {title} ({self.database.name})", - "cwd": self.path.as_posix(), - "program": self.database.odev.executable.as_posix(), - "python": PythonEnv().python.as_posix(), - "args": [ - title.lower(), - self.database.name, - "--log-handler=odoo.addons.base.models.ir_attachment:WARNING", - "--limit-time-cpu=0", - "--limit-time-real=0", - ], - } - - launch_config = { - "version": "0.2.0", - "configurations": [ - run_config(), - run_config(True), - { - "name": "Attach Debugger", - "type": "debugpy", - "request": "attach", - "processId": "${command:pickProcess}", - }, - ], - } - - console.print(json.dumps(launch_config, indent=4), file=self.launch_path) + rendered_template = self._get_rendered_template("launch.jinja") + with open(self.launch_path, "w", encoding="utf-8") as f: + f.write(rendered_template) def _create_tasks(self): """Create a tasks file for the project.""" - if self.tasks_path.is_file(): - return logger.debug("Tasks file already exists") - - tasks_config = { - "version": "2.0.0", - "tasks": [], + rendered_template = self._get_rendered_template( + "tasks.jinja", + DB_VERSION=self.version, + ) + with open(self.tasks_path, "w", encoding="utf-8") as f: + f.write(rendered_template) + + def _create_jsconfig(self): + """Create JS config file to provide intellisense JavaScript.""" + root = self.odoo_path.resolve() + + addon_dirs = [ + root / "addons", + root / "odoo" / "addons", + root / "enterprise", + self.path, + ] + + paths_map = { + "@odoo/owl": ["odoo/addons/web/static/src/@types/owl.d.ts"], + "@odoo/hoot": ["odoo/addons/web/static/src/@types/hoot.d.ts"], + "@odoo/hoot-dom": ["odoo/addons/web/static/src/@types/hoot.d.ts"], } - console.print(json.dumps(tasks_config, indent=4), file=self.tasks_path) + for addon_dir in addon_dirs: + if not addon_dir.exists(): + continue + for module in addon_dir.iterdir(): + if module.is_dir(): + static_src_path = module / "static" / "src" + if static_src_path.exists(): + rel_path = os.path.relpath(static_src_path, root) + paths_map[f"@{module.name}/*"] = [f"{rel_path}/*"] + + modules_mapping = dict(sorted(paths_map.items())) + + rendered_template = self._get_rendered_template( + "jsconfig.jinja", + ODOO_PATH=self.odoo_path, + JS_MODULES_PATHS=json.dumps(modules_mapping, indent=4), + ) + with open(self.path / "jsconfig.json", "w", encoding="utf-8") as f: + f.write(rendered_template) diff --git a/config.py b/config.py new file mode 100644 index 0000000..c4968db --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +from odev.common.config import Section + + +class VscodeSection(Section): + _name = "vscode" + + @property + def workspace_layout(self) -> str: + """Layout of the multi-root VSCode workspace, one of: + - 'flat': One top-level root per odoo worktree alongside the project (legacy default). + - 'nested': A single 'odoo' root holding all worktrees as subfolders. + Defaults to 'flat'. + """ + return self.get("workspace_layout", "flat") + + @workspace_layout.setter + def workspace_layout(self, value: str): + if value not in ("flat", "nested"): + raise ValueError(f"'vscode.workspace_layout' must be one of 'flat', 'nested', got {value!r}") + self.set("workspace_layout", value) diff --git a/templates/code-workspace.jinja b/templates/code-workspace.jinja new file mode 100644 index 0000000..f98f5ec --- /dev/null +++ b/templates/code-workspace.jinja @@ -0,0 +1,50 @@ +{ + "folders": {{ FOLDERS }}, + "settings": { + "odoo.db": "{{ DB_NAME }}", + "odoo.modules": "{{ DB_NAME }}_suite", + "odoo.testTags": "*", + "odoo.logLevel": "info", + + "python.languageServer": "None", + "python.analysis.typeCheckingMode": "standard", + "python.analysis.extraPaths": [ + "{{ ODOO_PATH }}/odoo", + "{{ ODOO_PATH }}/enterprise", + ], + "python.analysis.exclude": [ + "{{ ODOO_PATH }}", + ], + "python.analysis.packageIndexDepths": [ + { + "name": "odoo", + "depth": 2 + } + ], + "python.autoComplete.extraPaths": [ + "{{ ODOO_PATH }}/odoo", + "{{ ODOO_PATH }}/enterprise", + ], + "python.defaultInterpreterPath": "{{ VENV_PATH }}", + "python.interpreterPath": "{{ VENV_PATH }}", + + "ruff.path": ["{{ RUFF_PATH }}"], + "ruff.importStrategy": "fromEnvironment", + + "editor.formatOnSave": false, + "editor.rulers": [ + 120, + ], + + "search.exclude": { + "**/*.po*": true, + "**/LICENSE": true, + "**/README.md": true, + }, + + "terminal.integrated.cwd": "${workspaceFolder:project}", + + "pythonPath": "{{ PYTHON_PATH }}", + "odevPath": "{{ ODEV_EXE_PATH }}", + } +} diff --git a/templates/jsconfig.jinja b/templates/jsconfig.jinja new file mode 100644 index 0000000..e0aaab5 --- /dev/null +++ b/templates/jsconfig.jinja @@ -0,0 +1,41 @@ +{ + "include": [ + "**/*.js", + "**/*.d.ts", + "{{ ODOO_PATH }}/**/static/**/*.js", + "{{ ODOO_PATH }}/**/static/**/*.d.ts" + ], + "exclude": [ + "**/node_modules", + "**/setup", + "**/doc", + "**/lib/ace", + "**/lib/bootstrap", + "**/lib/Chart", + "**/lib/chartjs-adapter-luxon", + "**/lib/fullcalendar", + "**/lib/jquery", + "**/lib/luxon", + "**/lib/odoo_ui_icons", + "**/lib/pdfjs", + "**/lib/popper", + "**/lib/qunit", + "**/lib/signature_pad", + "**/lib/stacktracejs", + "**/lib/zxing-library", + "**/l10n*", + "**/o_spreadsheet.js" + ], + "compilerOptions": { + "moduleResolution": "node", + + "baseUrl": "{{ ODOO_PATH }}", + + "target": "ES2022", + "noEmit": true, + "disableSizeLimit": true, + + "paths": + {{ JS_MODULES_PATHS }} + } +} diff --git a/templates/launch.jinja b/templates/launch.jinja new file mode 100644 index 0000000..6e3a218 --- /dev/null +++ b/templates/launch.jinja @@ -0,0 +1,125 @@ +{ + "configurations": [ + { + "name": "Run", + "type": "debugpy", + "request": "launch", + "subProcess": true, + "justMyCode": true, + "program": "${config:odevPath}", + "cwd": "${workspaceFolder:project}", + "python": "${config:pythonPath}", + "args": [ + "run", + "${config:odoo.db}", + "--log-level=${config:odoo.logLevel}", + "--dev=xml", + "--update=${config:odoo.modules}", + "--log-handler=odoo.addons.base.models.ir_attachment:WARNING", + "--limit-time-cpu=0", + "--limit-time-real=0", + "--max-cron-threads=1", + ] + }, + { + "name": "Run without odev", + "type": "debugpy", + "request": "launch", + "program": "./odoo/odoo-bin", + "cwd": "${workspaceFolder:odoo}", + "args": [ + "--dev=xml", + "--database=${config:odoo.db}", + "--update=${config:odoo.modules}", + "--addons-path=odoo/addons,enterprise,design-themes,${workspaceFolder:project}", + "--log-level=${config:odoo.logLevel}", + "--limit-time-cpu=0", + "--limit-time-real=0", + "--max-cron-threads=1", + ] + }, + { + "name": "Test", + "type": "debugpy", + "request": "launch", + "subProcess": true, + "justMyCode": true, + "program": "${config:odevPath}", + "cwd": "${workspaceFolder:project}", + "python": "${config:pythonPath}", + "args": [ + "test", + "-v", + "${config:odoo.logLevel}", + "-t", + "${config:odoo.testTags}", + "-i", + "${config:odoo.modules}", + "-f", + "${config:odoo.db}", + "-p", + "8068", + ] + }, + { + "name": "Test without odev", + "type": "debugpy", + "request": "launch", + "program": "./odoo/odoo-bin", + "cwd": "${workspaceFolder:odoo}", + "preLaunchTask": "update modules (test db)", + "args": [ + "--test-tags=${config:odoo.testTags}", + "--database=${config:odoo.db}_test", + "--addons-path=./odoo/addons,./enterprise,${workspaceFolder:project}", + "--log-level=${config:odoo.logLevel}", + "--limit-time-cpu=0", + "--limit-time-real=0", + "--stop-after-init", + "--max-cron-threads=0", + "-p", + "8067", + ] + }, + { + "name": "Shell", + "type": "debugpy", + "request": "launch", + "subProcess": true, + "justMyCode": true, + "program": "${config:odevPath}", + "cwd": "${workspaceFolder:project}", + "python": "${config:pythonPath}", + "args": [ + "shell", + "${config:odoo.db}", + "--update=${config:odoo.modules}", + "--log-level=${config:odoo.logLevel}", + "--dev=xml", + "--limit-time-cpu=0", + "--limit-time-real=0", + "--max-cron-threads=0", + "--no-http", + ] + }, + { + "name": "Attach Remote", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "remoteRoot": "/home/odoo/src/user", + "localRoot": "${workspaceFolder:project}", + }, + { + "remoteRoot": "/home/odoo/src", + "localRoot": "${workspaceFolder:odoo}", + } + ], + }, + ] +} diff --git a/templates/tasks.jinja b/templates/tasks.jinja new file mode 100644 index 0000000..bd12f06 --- /dev/null +++ b/templates/tasks.jinja @@ -0,0 +1,207 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "~Init Fresh DB", + "dependsOn": ["create fresh db"], + "problemMatcher": [], + "detail": "Creates a new database and initializes modules.", + "presentation": { + "panel": "shared" + } + }, + { + "label": "~Init Fresh Test DB", + "dependsOn": ["create fresh test db"], + "problemMatcher": [], + "detail": "Creates a new test database and initializes modules.", + "presentation": { + "panel": "shared" + } + }, + { + "label": "~Init DB From Template", + "dependsOrder": "sequence", + "dependsOn": [ + "copy db from template", + "init modules (main db)" + ], + "problemMatcher": [], + "detail": "Restores database from template, then initializes modules.", + "presentation": { + "panel": "shared" + } + }, + { + "label": "~Init Test DB From Template", + "dependsOrder": "sequence", + "dependsOn": [ + "copy test db from template", + "init modules (test db)" + ], + "problemMatcher": [], + "detail": "Restores test database from template, then initializes modules.", + "presentation": { + "panel": "shared" + } + }, + { + "label": "~Create Template From DB", + "dependsOn": ["create template from db"], + "problemMatcher": [], + "detail": "Creates a template from the main database.", + "presentation": { + "panel": "shared" + } + }, + + { + "label": "create fresh db", + "command": "odev", + "problemMatcher": [], + "args": [ + "create", + "-f", + "-V", + "{{ DB_VERSION }}", + "${config:odoo.db}", + "--init=${config:odoo.modules}", + "--log-level=${config:odoo.logLevel}", + "--no-http", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "create fresh test db", + "command": "odev", + "problemMatcher": [], + "args": [ + "create", + "-f", + "-V", + "{{ DB_VERSION }}", + "${config:odoo.db}_test", + "--init=${config:odoo.modules}", + "--log-level=${config:odoo.logLevel}", + "--no-http", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "create template from db", + "command": "odev", + "problemMatcher": [], + "args": [ + "create", + "-T", + "-f", + "${config:odoo.db}", + ], + "presentation": { + "panel": "shared", + "reveal": "silent" + }, + }, + { + "label": "copy db from template", + "command": "odev", + "problemMatcher": [], + "args": [ + "create", + "-t", + "${config:odoo.db}:template", + "-f", + "${config:odoo.db}", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "copy test db from template", + "command": "odev", + "problemMatcher": [], + "args": [ + "create", + "-t", + "${config:odoo.db}:template", + "-f", + "${config:odoo.db}_test", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "init modules (main db)", + "command": "odev", + "problemMatcher": [], + "args": [ + "run", + "${config:odoo.db}", + "--init=${config:odoo.modules}", + "--log-level=${config:odoo.logLevel}", + "--no-http", + "--stop-after-init", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "init modules (test db)", + "command": "odev", + "problemMatcher": [], + "args": [ + "run", + "${config:odoo.db}_test", + "--init=${config:odoo.modules}", + "--log-level=${config:odoo.logLevel}", + "--no-http", + "--stop-after-init", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "update modules (test db)", + "command": "odev", + "problemMatcher": [], + "args": [ + "run", + "${config:odoo.db}_test", + "--update=${config:odoo.modules}", + "--log-level=${config:odoo.logLevel}", + "--no-http", + "--stop-after-init", + ], + "presentation": { + "panel": "shared" + }, + }, + { + "label": "~SSH Tunnel for Debugging", + "type": "shell", + "command": "HOST=\"${input:sshTarget}\"; HOST=\"${HOST#ssh }\"; ssh $HOST \"grep -q 'debugpy.listen' /home/odoo/src/odoo/odoo/addons/base/__init__.py || printf '\\ntry:\\n import debugpy\\n debugpy.listen((\\\"127.0.0.1\\\", 5678))\\nexcept:\\n pass\\n' >> /home/odoo/src/odoo/odoo/addons/base/__init__.py; odoosh-restart http\" && ssh -t -L 5678:127.0.0.1:5678 $HOST", + "problemMatcher": [], + "detail": "Open an SSH tunnel for remote debugging.", + "presentation": { + "reveal": "always", + "panel": "new" + } + } + ], + "inputs": [ + { + "id": "sshTarget", + "type": "promptString", + "description": "SSH Command or Host (e.g. ssh user@hostname or just user@hostname)", + "default": "odoo@localhost" + } + ] +}