Skip to content

Commit 2eebe93

Browse files
Handle Cursor editor on Windows WSL and add tests for this
Signed-off-by: Oliver Holworthy <[email protected]>
1 parent f88c0dc commit 2eebe93

File tree

2 files changed

+257
-23
lines changed

2 files changed

+257
-23
lines changed

nemo_run/devspace/editor.py

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# limitations under the License.
1515

1616
import os
17+
import platform
1718
import shutil
1819
from pathlib import Path
1920

@@ -22,11 +23,92 @@
2223
from nemo_run.core.frontend.console.api import CONSOLE
2324

2425

26+
def find_editor_executable(base_executable_name):
27+
"""Find the proper executable path for an editor, especially in WSL environments.
28+
29+
Args:
30+
base_executable_name (str): The base name of the executable (e.g., 'code', 'cursor')
31+
32+
Returns:
33+
str: The path to the executable
34+
35+
Raises:
36+
ValueError: If the editor is not supported
37+
EnvironmentError: If the editor is not installed or Windows executable not found in WSL
38+
"""
39+
# Define supported editors
40+
SUPPORTED_EDITORS = {
41+
"code": {
42+
"display_name": "VS Code",
43+
"download_url": "https://code.visualstudio.com/",
44+
"exe_name": "Code.exe",
45+
},
46+
"cursor": {
47+
"display_name": "Cursor",
48+
"download_url": "https://www.cursor.com/",
49+
"exe_name": "Cursor.exe",
50+
},
51+
# Add new editors here
52+
}
53+
54+
# Check if the editor is supported
55+
if base_executable_name not in SUPPORTED_EDITORS:
56+
supported_list = ", ".join(SUPPORTED_EDITORS.keys())
57+
raise ValueError(
58+
f"Editor '{base_executable_name}' is not supported. "
59+
f"Supported editors are: {supported_list}"
60+
)
61+
62+
editor_config = SUPPORTED_EDITORS[base_executable_name]
63+
64+
# Check if the editor is installed
65+
executable_path = shutil.which(base_executable_name)
66+
if not executable_path:
67+
raise EnvironmentError(
68+
f"{editor_config['display_name']} is not installed. "
69+
f"Please install it from {editor_config['download_url']}"
70+
)
71+
72+
# Default editor command is the base executable
73+
editor_cmd = base_executable_name
74+
75+
# If we're running in WSL, find the Windows executable
76+
if os.name == "posix" and "WSL" in os.uname().release:
77+
# Start from the executable directory
78+
current_path = Path(executable_path).parent
79+
exe_found = False
80+
81+
# Walk up to 5 levels to find the Windows .exe
82+
for _ in range(5):
83+
potential_exe = current_path / editor_config["exe_name"]
84+
if potential_exe.exists():
85+
editor_cmd = potential_exe.as_posix().replace(" ", "\\ ")
86+
exe_found = True
87+
break
88+
# Move up one directory
89+
parent_path = current_path.parent
90+
if parent_path == current_path: # Reached root
91+
break
92+
current_path = parent_path
93+
94+
# Raise an error if we couldn't find the Windows executable in WSL
95+
if not exe_found:
96+
raise EnvironmentError(
97+
f"Running in WSL but couldn't find {editor_config['exe_name']} in the "
98+
f"directory structure. For proper WSL integration, ensure {editor_config['display_name']} "
99+
f"is installed in Windows and properly configured for WSL. "
100+
f"See the documentation for {editor_config['display_name']} WSL integration."
101+
)
102+
103+
return editor_cmd
104+
105+
25106
def launch_editor(tunnel: str, path: str):
26107
"""Launch a code editor for the specified SSH tunnel.
27108
28109
Args:
29110
tunnel (str): The name of the SSH tunnel.
111+
path (str): The path to open in the editor.
30112
31113
Raises:
32114
EnvironmentError: If the specified editor is not installed.
@@ -42,26 +124,15 @@ def launch_editor(tunnel: str, path: str):
42124

43125
if editor != "none":
44126
CONSOLE.rule(f"[bold green]Launching {editor}", characters="*")
45-
if editor == "code":
46-
if not shutil.which("code"):
47-
raise EnvironmentError(
48-
"VS Code is not installed. Please install it from https://code.visualstudio.com/"
49-
)
50-
51-
code_cli = "code"
52-
53-
# If we're running in WSL. Launch code from the executable directly.
54-
# This avoids the code launch script activating the WSL remote extension
55-
# which enables us to specify the ssh tunnel as the remote
56-
if os.name == "posix" and "WSL" in os.uname().release:
57-
code_cli = (
58-
(Path(shutil.which("code")).parent.parent / "Code.exe")
59-
.as_posix()
60-
.replace(" ", "\\ ")
61-
)
62-
63-
cmd = f"{code_cli} --new-window --remote ssh-remote+tunnel.{tunnel} {path}"
64-
CONSOLE.print(cmd)
65-
local.run(f"NEMO_EDITOR=vscode {cmd}")
66-
elif editor == "cursor":
67-
local.run(f"NEMO_EDITOR=cursor cursor --remote ssh-remote+tunnel.{tunnel} {path}")
127+
128+
# Find the proper executable
129+
editor_cmd = find_editor_executable(editor)
130+
131+
# Execute the editor command
132+
cmd = f"{editor_cmd} --new-window --remote ssh-remote+tunnel.{tunnel} {path}"
133+
CONSOLE.print(cmd)
134+
135+
if platform.system() == "Windows":
136+
local.run(f"set NEMO_EDITOR={editor} && {cmd}")
137+
else:
138+
local.run(f"NEMO_EDITOR={editor} {cmd}")

test/devspace/test_editor.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import os
2+
import platform
3+
import subprocess
4+
import pytest
5+
from pathlib import Path
6+
import shutil
7+
from unittest.mock import patch, MagicMock
8+
9+
from nemo_run.devspace.editor import find_editor_executable
10+
11+
12+
class TestFindEditorExecutable:
13+
def test_unsupported_editor(self):
14+
"""Test that unsupported editors raise ValueError."""
15+
with pytest.raises(ValueError, match="not supported"):
16+
find_editor_executable("unsupported_editor")
17+
18+
def test_editor_not_installed(self, monkeypatch):
19+
"""Test that missing editors raise EnvironmentError."""
20+
# Monkeypatch shutil.which to return None (simulate editor not found)
21+
monkeypatch.setattr(shutil, "which", lambda x: None)
22+
23+
with pytest.raises(EnvironmentError, match="is not installed"):
24+
find_editor_executable("code")
25+
26+
with pytest.raises(EnvironmentError, match="is not installed"):
27+
find_editor_executable("cursor")
28+
29+
def test_non_wsl_environment(self, tmp_path, monkeypatch):
30+
"""Test editor detection in non-WSL environment using real file."""
31+
# Create a fake editor executable in a temp directory
32+
bin_dir = tmp_path / "bin"
33+
bin_dir.mkdir()
34+
35+
code_exec = bin_dir / "code"
36+
code_exec.touch(mode=0o755) # Make it executable
37+
38+
cursor_exec = bin_dir / "cursor"
39+
cursor_exec.touch(mode=0o755)
40+
41+
# Add our temp directory to PATH
42+
old_path = os.environ.get("PATH", "")
43+
os.environ["PATH"] = f"{bin_dir}:{old_path}"
44+
45+
try:
46+
# Monkeypatch os.uname to return a non-WSL environment
47+
if hasattr(os, "uname"): # Skip on Windows
48+
monkeypatch.setattr(os, "uname", lambda: MagicMock(release="Linux 5.10.0"))
49+
50+
# Test with actual executables in path
51+
assert find_editor_executable("code") == "code"
52+
assert find_editor_executable("cursor") == "cursor"
53+
finally:
54+
# Restore PATH
55+
os.environ["PATH"] = old_path
56+
57+
@pytest.mark.skipif(
58+
platform.system() == "Windows", reason="WSL tests only relevant on Unix systems"
59+
)
60+
def test_wsl_environment(self, tmp_path, monkeypatch):
61+
"""Test editor detection in WSL environment."""
62+
# Create directory structure with both Linux and "Windows" executables
63+
bin_dir = tmp_path / "bin"
64+
bin_dir.mkdir()
65+
66+
# Linux executables
67+
code_exec = bin_dir / "code"
68+
code_exec.touch(mode=0o755)
69+
70+
cursor_exec = bin_dir / "cursor"
71+
cursor_exec.touch(mode=0o755)
72+
73+
# Windows .exe files at various levels
74+
exe_dir = tmp_path / "winbin"
75+
exe_dir.mkdir()
76+
code_exe = exe_dir / "Code.exe"
77+
code_exe.touch(mode=0o755)
78+
79+
cursor_exe_dir = tmp_path / "apps" / "cursor"
80+
cursor_exe_dir.mkdir(parents=True)
81+
cursor_exe = cursor_exe_dir / "Cursor.exe"
82+
cursor_exe.touch(mode=0o755)
83+
84+
# Add our temp directory to PATH
85+
old_path = os.environ.get("PATH", "")
86+
os.environ["PATH"] = f"{bin_dir}:{old_path}"
87+
88+
try:
89+
# Mock WSL environment
90+
monkeypatch.setattr(os, "name", "posix")
91+
monkeypatch.setattr(os, "uname", lambda: MagicMock(release="Microsoft-WSL"))
92+
93+
# Test cases with different configurations
94+
95+
# 1. Case where we find the .exe file at a specific location
96+
with monkeypatch.context() as m:
97+
98+
def mock_which(cmd):
99+
if cmd == "code":
100+
return str(code_exec)
101+
elif cmd == "cursor":
102+
return str(cursor_exec)
103+
return None
104+
105+
# Only need to mock exists for the specific paths we want to test
106+
original_exists = Path.exists
107+
108+
def mock_exists(self):
109+
if (
110+
self == code_exe
111+
or self.name == "Code.exe"
112+
and str(self).startswith(str(tmp_path))
113+
):
114+
return True
115+
if (
116+
self == cursor_exe
117+
or self.name == "Cursor.exe"
118+
and str(self).startswith(str(tmp_path))
119+
):
120+
return True
121+
return original_exists(self)
122+
123+
m.setattr(shutil, "which", mock_which)
124+
m.setattr(Path, "exists", mock_exists)
125+
126+
# Test code with .exe available
127+
result = find_editor_executable("code")
128+
assert "Code.exe" in result
129+
130+
# Test cursor with .exe available
131+
result = find_editor_executable("cursor")
132+
assert "Cursor.exe" in result
133+
134+
# 2. Case where we don't find the .exe file (should now raise error)
135+
with monkeypatch.context() as m:
136+
137+
def mock_which(cmd):
138+
if cmd == "code":
139+
return str(code_exec)
140+
elif cmd == "cursor":
141+
return str(cursor_exec)
142+
return None
143+
144+
# Make exists always return False for .exe files
145+
def mock_exists(self):
146+
if ".exe" in str(self).lower():
147+
return False
148+
return original_exists(self)
149+
150+
m.setattr(shutil, "which", mock_which)
151+
m.setattr(Path, "exists", mock_exists)
152+
153+
# Test code with no .exe available (should now raise error)
154+
with pytest.raises(EnvironmentError, match="Running in WSL but couldn't find"):
155+
find_editor_executable("code")
156+
157+
# Test cursor with no .exe available (should now raise error)
158+
with pytest.raises(EnvironmentError, match="Running in WSL but couldn't find"):
159+
find_editor_executable("cursor")
160+
161+
finally:
162+
# Restore PATH
163+
os.environ["PATH"] = old_path

0 commit comments

Comments
 (0)