From f5f1f6c304b890dc162e8653005233bce4ea82e4 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 19 Sep 2024 20:56:27 +0200 Subject: [PATCH] feature(vscode): added support for vscode instructions --- bec_widgets/widgets/vscode/vscode.py | 101 ++++++++++++++++++++++++- tests/unit_tests/test_vscode_widget.py | 60 ++++++++------- 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/bec_widgets/widgets/vscode/vscode.py b/bec_widgets/widgets/vscode/vscode.py index 90d84ac1..cfa63600 100644 --- a/bec_widgets/widgets/vscode/vscode.py +++ b/bec_widgets/widgets/vscode/vscode.py @@ -5,10 +5,19 @@ import signal import socket import subprocess import sys +from typing import Literal + +from pydantic import BaseModel +from qtpy.QtCore import Signal, Slot from bec_widgets.widgets.website.website import WebsiteWidget +class VSCodeInstructionMessage(BaseModel): + command: Literal["open", "write", "close", "zenMode", "save", "new", "setCursor"] + content: str = "" + + def get_free_port(): """ Get a free port on the local machine. @@ -28,6 +37,8 @@ class VSCodeEditor(WebsiteWidget): A widget to display the VSCode editor. """ + file_saved = Signal(str) + token = "bec" host = "127.0.0.1" @@ -41,6 +52,7 @@ class VSCodeEditor(WebsiteWidget): self._url = f"http://{self.host}:{self.port}?tkn={self.token}" super().__init__(parent=parent, config=config, client=client, gui_id=gui_id) self.start_server() + self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}") def start_server(self): """ @@ -74,6 +86,92 @@ class VSCodeEditor(WebsiteWidget): self.set_url(self._url) self.wait_until_loaded() + @Slot(str) + def open_file(self, file_path: str): + """ + Open a file in the VSCode editor. + + Args: + file_path: The file path to open + """ + msg = VSCodeInstructionMessage(command="open", content=f"file://{file_path}") + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + + @Slot(dict, dict) + def on_vscode_event(self, content, _metadata): + """ + Handle the VSCode event. VSCode events are received as RawMessages. + + Args: + content: The content of the event + metadata: The metadata of the event + """ + + # the message also contains the content but I think is fine for now to just emit the file path + if not isinstance(content["data"], dict): + return + if "uri" not in content["data"]: + return + if not content["data"]["uri"].startswith("file://"): + return + file_path = content["data"]["uri"].split("file://")[1] + self.file_saved.emit(file_path) + + @Slot() + def save_file(self): + """ + Save the file in the VSCode editor. + """ + msg = VSCodeInstructionMessage(command="save") + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + + @Slot() + def new_file(self): + """ + Create a new file in the VSCode editor. + """ + msg = VSCodeInstructionMessage(command="new") + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + + @Slot() + def close_file(self): + """ + Close the file in the VSCode editor. + """ + msg = VSCodeInstructionMessage(command="close") + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + + @Slot(str) + def write_file(self, content: str): + """ + Write content to the file in the VSCode editor. + + Args: + content: The content to write + """ + msg = VSCodeInstructionMessage(command="write", content=content) + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + + @Slot() + def zen_mode(self): + """ + Toggle the Zen mode in the VSCode editor. + """ + msg = VSCodeInstructionMessage(command="zenMode") + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + + @Slot(int, int) + def set_cursor(self, line: int, column: int): + """ + Set the cursor in the VSCode editor. + + Args: + line: The line number + column: The column number + """ + msg = VSCodeInstructionMessage(command="setCursor", content=f"{line},{column}") + self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) + def cleanup_vscode(self): """ Cleanup the VSCode editor. @@ -87,6 +185,7 @@ class VSCodeEditor(WebsiteWidget): """ Cleanup the widget. This method is called from the dock area when the widget is removed. """ + self.bec_dispatcher.disconnect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}") self.cleanup_vscode() return super().cleanup() @@ -97,7 +196,7 @@ if __name__ == "__main__": # pragma: no cover from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) - widget = VSCodeEditor() + widget = VSCodeEditor(gui_id="unknown") widget.show() app.exec_() widget.bec_dispatcher.disconnect_all() diff --git a/tests/unit_tests/test_vscode_widget.py b/tests/unit_tests/test_vscode_widget.py index 425d1317..af8e8d30 100644 --- a/tests/unit_tests/test_vscode_widget.py +++ b/tests/unit_tests/test_vscode_widget.py @@ -25,42 +25,44 @@ def test_vscode_widget(qtbot, vscode_widget): def test_start_server(qtbot, mocked_client): + with mock.patch("bec_widgets.widgets.vscode.vscode.os.killpg") as mock_killpg: + with mock.patch("bec_widgets.widgets.vscode.vscode.os.getpgid") as mock_getpgid: + with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen: + with mock.patch("bec_widgets.widgets.vscode.vscode.select.select") as mock_select: + with mock.patch( + "bec_widgets.widgets.vscode.vscode.get_free_port" + ) as mock_get_free_port: + mock_get_free_port.return_value = 12345 + mock_process = mock.Mock() + mock_process.stdout.fileno.return_value = 1 + mock_process.poll.return_value = None + mock_process.stdout.read.return_value = f"available at http://{VSCodeEditor.host}:{12345}?tkn={VSCodeEditor.token}" + mock_popen.return_value = mock_process + mock_select.return_value = [[mock_process.stdout], [], []] - with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen: - with mock.patch("bec_widgets.widgets.vscode.vscode.select.select") as mock_select: - with mock.patch( - "bec_widgets.widgets.vscode.vscode.get_free_port" - ) as mock_get_free_port: - mock_get_free_port.return_value = 12345 - mock_process = mock.Mock() - mock_process.stdout.fileno.return_value = 1 - mock_process.poll.return_value = None - mock_process.stdout.read.return_value = ( - f"available at http://{VSCodeEditor.host}:{12345}?tkn={VSCodeEditor.token}" - ) - mock_popen.return_value = mock_process - mock_select.return_value = [[mock_process.stdout], [], []] + widget = VSCodeEditor(client=mocked_client) + widget.close() + widget.deleteLater() - widget = VSCodeEditor(client=mocked_client) - - assert ( - mock.call( - shlex.split( - f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms" - ), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - preexec_fn=os.setsid, - env=mock.ANY, - ) - in mock_popen.mock_calls - ) + assert ( + mock.call( + shlex.split( + f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms" + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + preexec_fn=os.setsid, + env=mock.ANY, + ) + in mock_popen.mock_calls + ) @pytest.fixture def patched_vscode_process(qtbot, vscode_widget): with mock.patch("bec_widgets.widgets.vscode.vscode.os.killpg") as mock_killpg: + mock_killpg.reset_mock() with mock.patch("bec_widgets.widgets.vscode.vscode.os.getpgid") as mock_getpgid: mock_getpgid.return_value = 123 vscode_widget.process = mock.Mock()