Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,4 @@ __pycache__
htmlcov

# --- Odev plugins, loaded as submodules
odev/plugins/*
tests/plugins/*
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
```
1 change: 1 addition & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .common import editor_vscode # noqa: F401
2 changes: 1 addition & 1 deletion __manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Empty file added common/__init__.py
Empty file.
229 changes: 144 additions & 85 deletions common/editor_vscode.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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)
20 changes: 20 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -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)
50 changes: 50 additions & 0 deletions templates/code-workspace.jinja
Original file line number Diff line number Diff line change
@@ -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 }}",
}
}
Loading