vsview-editor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vsview_editor/__init__.py +19 -0
- vsview_editor/console.py +173 -0
- vsview_editor/logging.py +31 -0
- vsview_editor/lsp.py +265 -0
- vsview_editor/py.typed +0 -0
- vsview_editor/settings.py +248 -0
- vsview_editor/stubs.py +7 -0
- vsview_editor/utils.py +98 -0
- vsview_editor/web.py +337 -0
- vsview_editor/web_dist/assets/2026-dark-18a9d988.json +1 -0
- vsview_editor/web_dist/assets/2026-light-e23b82d6.json +1 -0
- vsview_editor/web_dist/assets/MagicPython.tmLanguage-d2adf018.json +1 -0
- vsview_editor/web_dist/assets/_commonjsHelpers-DMmVv2OA.js +1 -0
- vsview_editor/web_dist/assets/_commonjsHelpers-cc995bf6.js +1 -0
- vsview_editor/web_dist/assets/codicon-6bab8fd4.ttf +0 -0
- vsview_editor/web_dist/assets/dark-5cb61c67.json +501 -0
- vsview_editor/web_dist/assets/dark-colorblind-d5713f76.json +598 -0
- vsview_editor/web_dist/assets/dark-default-36ccf315.json +598 -0
- vsview_editor/web_dist/assets/dark-dimmed-5cf46ccb.json +598 -0
- vsview_editor/web_dist/assets/dark-high-contrast-8f2c613e.json +602 -0
- vsview_editor/web_dist/assets/diff_wasm_bg-c6YkaZRL-1ce9891a.wasm +0 -0
- vsview_editor/web_dist/assets/diff_wasm_bg-c6YkaZRL-c6YkaZRL.wasm +0 -0
- vsview_editor/web_dist/assets/dist-CYrV_pbP.js +8 -0
- vsview_editor/web_dist/assets/dist-b4ad7a98.js +8 -0
- vsview_editor/web_dist/assets/editor.worker-BHP4V20C.js +41 -0
- vsview_editor/web_dist/assets/empty-BvRk9kiK.js +0 -0
- vsview_editor/web_dist/assets/empty-a60adbff.js +1 -0
- vsview_editor/web_dist/assets/iconv-lite-umd-736e9b08.js +6 -0
- vsview_editor/web_dist/assets/index-1e7694cb.css +1 -0
- vsview_editor/web_dist/assets/index-e0eb721a.js +1444 -0
- vsview_editor/web_dist/assets/jschardet-d8a4cb78.js +11 -0
- vsview_editor/web_dist/assets/light-b73a2001.json +497 -0
- vsview_editor/web_dist/assets/light-colorblind-16efb166.json +583 -0
- vsview_editor/web_dist/assets/light-default-8cff633f.json +583 -0
- vsview_editor/web_dist/assets/light-high-contrast-165ac11a.json +586 -0
- vsview_editor/web_dist/assets/main-Db95Pfi8.js +4 -0
- vsview_editor/web_dist/assets/main-dbf793df.js +4 -0
- vsview_editor/web_dist/assets/main2-9655df87.js +1 -0
- vsview_editor/web_dist/assets/main2-CWVd-Hyp.js +1 -0
- vsview_editor/web_dist/assets/onig-eee6946b.wasm +0 -0
- vsview_editor/web_dist/assets/rolldown-runtime-7467d20d.js +1 -0
- vsview_editor/web_dist/assets/webWorkerExtensionHostIframe-a59f9e68.html +123 -0
- vsview_editor/web_dist/assets/worker-BH1GlPLC.js +17 -0
- vsview_editor/web_dist/assets/worker-CQu38ZhE.js +2 -0
- vsview_editor/web_dist/index.html +34 -0
- vsview_editor/workspace.py +733 -0
- vsview_editor-0.1.0.dist-info/METADATA +32 -0
- vsview_editor-0.1.0.dist-info/RECORD +50 -0
- vsview_editor-0.1.0.dist-info/WHEEL +4 -0
- vsview_editor-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from vsview.api import get_console_level, hookimpl
|
|
6
|
+
from vsview.api.workspace import PluginBaseWorkspace
|
|
7
|
+
|
|
8
|
+
# Suppress internal Chromium C++ stderr output (LOG_FATAL = 3)
|
|
9
|
+
if get_console_level() > logging.DEBUG:
|
|
10
|
+
name = "QTWEBENGINE_CHROMIUM_FLAGS"
|
|
11
|
+
os.environ[name] = " ".join([os.environ.setdefault(name, ""), "--log-level=3"]).strip()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
from .workspace import EditorWorkspace
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@hookimpl
|
|
18
|
+
def vsview_register_workspace() -> type[PluginBaseWorkspace[Any, Any]]:
|
|
19
|
+
return EditorWorkspace
|
vsview_editor/console.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable, Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from io import StringIO
|
|
9
|
+
from logging import Handler, Logger, LogRecord
|
|
10
|
+
from typing import Literal, TextIO, override
|
|
11
|
+
|
|
12
|
+
from jetpytools import CustomRuntimeError, Singleton, inject_self
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.logging import RichHandler
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from vsview.api import QObjectSet, get_console_level
|
|
18
|
+
|
|
19
|
+
from .web import MonacoBridge
|
|
20
|
+
|
|
21
|
+
IN_LOGGING = threading.local()
|
|
22
|
+
EXECUTION_BRIDGE = ContextVar[MonacoBridge | None]("EXECUTION_BRIDGE", default=None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EditorConsoleHandler(RichHandler):
|
|
26
|
+
def __init__(self, dispatch: Callable[[Literal["stderr", "stdout"], str], None]) -> None:
|
|
27
|
+
self.dispatch = dispatch
|
|
28
|
+
super().__init__(
|
|
29
|
+
console=Console(file=StringIO(), record=True),
|
|
30
|
+
rich_tracebacks=True,
|
|
31
|
+
log_time_format=lambda dt: Text("[{}.{:03d}]".format(dt.strftime("%H:%M:%S"), dt.microsecond // 1000)),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def emit(self, record: LogRecord) -> None:
|
|
36
|
+
IN_LOGGING.depth = getattr(IN_LOGGING, "depth", 0) + 1
|
|
37
|
+
try:
|
|
38
|
+
self.console.width = GlobalConsoleHub.target_width
|
|
39
|
+
super().emit(record)
|
|
40
|
+
self.dispatch("stderr", self.console.export_text(clear=True, styles=True))
|
|
41
|
+
finally:
|
|
42
|
+
IN_LOGGING.depth -= 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConsoleStreamRedirector:
|
|
46
|
+
def __init__(self, name: Literal["stdout", "stderr"]) -> None:
|
|
47
|
+
self.name = name
|
|
48
|
+
self._original_stream: TextIO = getattr(sys, self.name)
|
|
49
|
+
self._installed = False
|
|
50
|
+
|
|
51
|
+
def __getattr__(self, name: str) -> object:
|
|
52
|
+
return getattr(self._original_stream, name)
|
|
53
|
+
|
|
54
|
+
def install(self) -> None:
|
|
55
|
+
if self._installed:
|
|
56
|
+
raise CustomRuntimeError("This ConsoleStreamRedirector is already installed", self.install)
|
|
57
|
+
|
|
58
|
+
setattr(sys, self.name, self)
|
|
59
|
+
self._installed = True
|
|
60
|
+
|
|
61
|
+
def uninstall(self) -> None:
|
|
62
|
+
if not self._installed:
|
|
63
|
+
raise CustomRuntimeError("This ConsoleStreamRedirector is already uninstalled", self.uninstall)
|
|
64
|
+
|
|
65
|
+
setattr(sys, self.name, self._original_stream)
|
|
66
|
+
self._installed = False
|
|
67
|
+
|
|
68
|
+
def write(self, text: str) -> int:
|
|
69
|
+
res = self._original_stream.write(text)
|
|
70
|
+
# Suppress dispatching when text comes from an active logging emit() call
|
|
71
|
+
if text and getattr(IN_LOGGING, "depth", 0) == 0:
|
|
72
|
+
GlobalConsoleHub.dispatch({"stream": self.name, "text": text})
|
|
73
|
+
return res
|
|
74
|
+
|
|
75
|
+
def isatty(self) -> bool:
|
|
76
|
+
return getattr(self._original_stream, "isatty", lambda: False)()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GlobalConsoleHub(Singleton):
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self._bridges = QObjectSet[MonacoBridge]()
|
|
82
|
+
self._bridge_widths = dict[MonacoBridge, int]()
|
|
83
|
+
self._stdout_redirector = ConsoleStreamRedirector("stdout")
|
|
84
|
+
self._stderr_redirector = ConsoleStreamRedirector("stderr")
|
|
85
|
+
self._log_handler: EditorConsoleHandler | None = None
|
|
86
|
+
self._orig_handler_handle: Callable[[Handler, LogRecord], int | None] | None = None
|
|
87
|
+
self._installed = False
|
|
88
|
+
|
|
89
|
+
@inject_self.property
|
|
90
|
+
def target_width(self) -> int:
|
|
91
|
+
if (target_bridge := EXECUTION_BRIDGE.get()) in self._bridge_widths:
|
|
92
|
+
return self._bridge_widths[target_bridge]
|
|
93
|
+
|
|
94
|
+
for bridge in self._bridges:
|
|
95
|
+
if bridge in self._bridge_widths:
|
|
96
|
+
return self._bridge_widths[bridge]
|
|
97
|
+
|
|
98
|
+
if self._log_handler:
|
|
99
|
+
return self._log_handler.console.width
|
|
100
|
+
|
|
101
|
+
return 80
|
|
102
|
+
|
|
103
|
+
@inject_self
|
|
104
|
+
def register(self, bridge: MonacoBridge) -> None:
|
|
105
|
+
self._bridges.add(bridge)
|
|
106
|
+
if not self._installed:
|
|
107
|
+
self._install_global_hooks()
|
|
108
|
+
|
|
109
|
+
@inject_self
|
|
110
|
+
def unregister(self, bridge: MonacoBridge) -> None:
|
|
111
|
+
self._bridges.discard(bridge)
|
|
112
|
+
self._bridge_widths.pop(bridge, None)
|
|
113
|
+
if not self._bridges and self._installed:
|
|
114
|
+
self._uninstall_global_hooks()
|
|
115
|
+
|
|
116
|
+
@inject_self
|
|
117
|
+
def set_width(self, bridge: MonacoBridge, width: int) -> None:
|
|
118
|
+
self._bridge_widths[bridge] = width
|
|
119
|
+
target_bridge = EXECUTION_BRIDGE.get()
|
|
120
|
+
if self._log_handler and (target_bridge == bridge or (target_bridge is None and bridge in self._bridges)):
|
|
121
|
+
self._log_handler.console.width = width
|
|
122
|
+
|
|
123
|
+
@inject_self
|
|
124
|
+
@contextmanager
|
|
125
|
+
def bind_execution(self, bridge: MonacoBridge) -> Generator[None]:
|
|
126
|
+
token = EXECUTION_BRIDGE.set(bridge)
|
|
127
|
+
try:
|
|
128
|
+
yield
|
|
129
|
+
finally:
|
|
130
|
+
EXECUTION_BRIDGE.reset(token)
|
|
131
|
+
|
|
132
|
+
@inject_self
|
|
133
|
+
def dispatch(self, payload: dict[str, str], target_bridge: MonacoBridge | None = None) -> None:
|
|
134
|
+
dest_bridge = target_bridge or EXECUTION_BRIDGE.get()
|
|
135
|
+
if dest_bridge is not None:
|
|
136
|
+
dest_bridge.dispatch("console.append", payload)
|
|
137
|
+
else:
|
|
138
|
+
for bridge in self._bridges:
|
|
139
|
+
bridge.dispatch("console.append", payload)
|
|
140
|
+
|
|
141
|
+
def _install_global_hooks(self) -> None:
|
|
142
|
+
self._stdout_redirector.install()
|
|
143
|
+
self._stderr_redirector.install()
|
|
144
|
+
|
|
145
|
+
self._orig_handler_handle = _orig_handler_handle = Handler.handle
|
|
146
|
+
|
|
147
|
+
def _protected_handle(handler_self: Handler, record: LogRecord) -> bool:
|
|
148
|
+
nonlocal _orig_handler_handle
|
|
149
|
+
IN_LOGGING.depth = getattr(IN_LOGGING, "depth", 0) + 1
|
|
150
|
+
try:
|
|
151
|
+
return _orig_handler_handle(handler_self, record)
|
|
152
|
+
finally:
|
|
153
|
+
IN_LOGGING.depth -= 1
|
|
154
|
+
|
|
155
|
+
setattr(Handler, "handle", _protected_handle)
|
|
156
|
+
|
|
157
|
+
self._log_handler = EditorConsoleHandler(dispatch=lambda s, t: self.dispatch({"stream": s, "text": t}))
|
|
158
|
+
self._log_handler.setLevel(get_console_level())
|
|
159
|
+
Logger.root.addHandler(self._log_handler)
|
|
160
|
+
self._installed = True
|
|
161
|
+
|
|
162
|
+
def _uninstall_global_hooks(self) -> None:
|
|
163
|
+
self._stdout_redirector.uninstall()
|
|
164
|
+
self._stderr_redirector.uninstall()
|
|
165
|
+
|
|
166
|
+
if self._orig_handler_handle is not None:
|
|
167
|
+
setattr(Handler, "handle", self._orig_handler_handle)
|
|
168
|
+
self._orig_handler_handle = None
|
|
169
|
+
|
|
170
|
+
if self._log_handler is not None:
|
|
171
|
+
Logger.root.removeHandler(self._log_handler)
|
|
172
|
+
self._log_handler = None
|
|
173
|
+
self._installed = False
|
vsview_editor/logging.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from logging import Logger, LogRecord, getLogger
|
|
2
|
+
|
|
3
|
+
from jetpytools import SPath
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def context_filter(record: LogRecord) -> bool:
|
|
7
|
+
source: str | None = getattr(record, "js_source", None)
|
|
8
|
+
line: int | None = getattr(record, "js_lineno", None)
|
|
9
|
+
|
|
10
|
+
if source and line:
|
|
11
|
+
record.pathname = (SPath(__file__).parent / "web_dist" / "assets" / source).to_str()
|
|
12
|
+
record.filename = source
|
|
13
|
+
record.module = source
|
|
14
|
+
record.lineno = line
|
|
15
|
+
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup_js_logger() -> Logger:
|
|
20
|
+
logger = getLogger("vsview_editor_js")
|
|
21
|
+
|
|
22
|
+
for h in logger.handlers[:]:
|
|
23
|
+
logger.removeHandler(h)
|
|
24
|
+
for f in logger.filters[:]:
|
|
25
|
+
logger.removeFilter(f)
|
|
26
|
+
|
|
27
|
+
logger.addFilter(context_filter)
|
|
28
|
+
return logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
js_logger = setup_js_logger()
|
vsview_editor/lsp.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import weakref
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from jetpytools import CustomValueError
|
|
13
|
+
from PySide6.QtCore import QObject, QProcess, Slot
|
|
14
|
+
from PySide6.QtNetwork import QHostAddress
|
|
15
|
+
from PySide6.QtWebSockets import QWebSocket, QWebSocketServer
|
|
16
|
+
|
|
17
|
+
from vsview.api import QObjectSet
|
|
18
|
+
|
|
19
|
+
logger = getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class LSPConfig:
|
|
24
|
+
id: str
|
|
25
|
+
name: str
|
|
26
|
+
command: Sequence[str]
|
|
27
|
+
language: str
|
|
28
|
+
file_events_pattern: str | None
|
|
29
|
+
configuration_section: str | Sequence[str] | None = None
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not self.command:
|
|
33
|
+
raise CustomValueError("LSP config %r has an empty command list.", self.id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
LSP_BASEDPYRIGHT_CONFIG = LSPConfig(
|
|
37
|
+
id="basedpyright",
|
|
38
|
+
name="Basedpyright Language Server",
|
|
39
|
+
command=["basedpyright-langserver", "--stdio"],
|
|
40
|
+
language="python",
|
|
41
|
+
file_events_pattern="**/*.py",
|
|
42
|
+
configuration_section=["basedpyright", "python"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LSPProcessServer(QObject):
|
|
47
|
+
"""Manages a single LSP subprocess and its associated WebSocket bridge server."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: LSPConfig, parent: QObject | None = None) -> None:
|
|
50
|
+
super().__init__(parent)
|
|
51
|
+
self.config = config
|
|
52
|
+
self.server: QWebSocketServer | None = None
|
|
53
|
+
self.clients = QObjectSet[QWebSocket]()
|
|
54
|
+
self.process: QProcess | None = None
|
|
55
|
+
self._stdout_buffer = bytearray()
|
|
56
|
+
self.real_uri: str | None = None
|
|
57
|
+
self.uri_variations = list[str]()
|
|
58
|
+
|
|
59
|
+
def start(self, port: int = 0, workspace_dir: Path | None = None) -> int:
|
|
60
|
+
if not workspace_dir:
|
|
61
|
+
workspace_dir = Path.cwd()
|
|
62
|
+
|
|
63
|
+
self.real_uri = workspace_dir.as_uri()
|
|
64
|
+
|
|
65
|
+
# Build URI variations to replace in JSON-RPC messages from server -> client
|
|
66
|
+
self.uri_variations = [
|
|
67
|
+
self.real_uri,
|
|
68
|
+
self.real_uri.replace("file:///", "file://"),
|
|
69
|
+
self.real_uri.replace("file:///", "file:/"),
|
|
70
|
+
urllib.parse.quote(self.real_uri, safe=":/"),
|
|
71
|
+
urllib.parse.quote(self.real_uri.replace("file:///", "file://"), safe=":/"),
|
|
72
|
+
urllib.parse.quote(self.real_uri.replace("file:///", "file:/"), safe=":/"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
executable_name = Path(self.config.command[0])
|
|
76
|
+
if executable_name.is_file():
|
|
77
|
+
binary = executable_name.resolve()
|
|
78
|
+
elif not (binary := shutil.which(executable_name)):
|
|
79
|
+
logger.error("LSP executable %r for %r not found in PATH.", executable_name, self.config.id)
|
|
80
|
+
return -1
|
|
81
|
+
|
|
82
|
+
self.server = QWebSocketServer(f"LSP Bridge ({self.config.name})", QWebSocketServer.SslMode.NonSecureMode, self)
|
|
83
|
+
if not self.server.listen(QHostAddress.SpecialAddress.LocalHost, port):
|
|
84
|
+
logger.error("Failed to start LSP WebSocket server for %r: %s", self.config.id, self.server.errorString())
|
|
85
|
+
return -1
|
|
86
|
+
|
|
87
|
+
actual_port = self.server.serverPort()
|
|
88
|
+
logger.debug("LSP WebSocket server for %r listening on 127.0.0.1:%d", self.config.id, actual_port)
|
|
89
|
+
|
|
90
|
+
self.server.newConnection.connect(self._on_new_connection)
|
|
91
|
+
|
|
92
|
+
self.process = QProcess(self)
|
|
93
|
+
self.process.readyReadStandardOutput.connect(self._on_stdout_ready)
|
|
94
|
+
self.process.readyReadStandardError.connect(self._on_stderr_ready)
|
|
95
|
+
self.process.errorOccurred.connect(self._on_process_error)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
self.process.start(str(binary), self.config.command[1:])
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.exception("Failed to launch LSP subprocess for %r:", self.config.id)
|
|
101
|
+
|
|
102
|
+
return actual_port
|
|
103
|
+
|
|
104
|
+
def stop(self) -> None:
|
|
105
|
+
if self.clients:
|
|
106
|
+
for client in self.clients:
|
|
107
|
+
if client.isValid():
|
|
108
|
+
client.close()
|
|
109
|
+
client.deleteLater()
|
|
110
|
+
self.clients.clear()
|
|
111
|
+
|
|
112
|
+
if self.server is not None:
|
|
113
|
+
server = self.server
|
|
114
|
+
self.server = None
|
|
115
|
+
server.close()
|
|
116
|
+
server.deleteLater()
|
|
117
|
+
|
|
118
|
+
if self.process is not None:
|
|
119
|
+
proc = self.process
|
|
120
|
+
self.process = None
|
|
121
|
+
logger.debug("Stopping LSP QProcess for %r (current state: %s)", self.config.id, proc.state())
|
|
122
|
+
proc.readyReadStandardOutput.disconnect(self._on_stdout_ready)
|
|
123
|
+
proc.readyReadStandardError.disconnect(self._on_stderr_ready)
|
|
124
|
+
proc.errorOccurred.disconnect(self._on_process_error)
|
|
125
|
+
|
|
126
|
+
if proc.state() != QProcess.ProcessState.NotRunning:
|
|
127
|
+
if sys.platform == "win32":
|
|
128
|
+
proc.kill()
|
|
129
|
+
if not proc.waitForFinished(3000):
|
|
130
|
+
logger.warning(
|
|
131
|
+
"LSP QProcess kill timed out for %r, state is still %s",
|
|
132
|
+
self.config.id,
|
|
133
|
+
proc.state(),
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
proc.terminate()
|
|
137
|
+
if not proc.waitForFinished(1000):
|
|
138
|
+
proc.kill()
|
|
139
|
+
proc.waitForFinished(1000)
|
|
140
|
+
|
|
141
|
+
proc.deleteLater()
|
|
142
|
+
|
|
143
|
+
@Slot()
|
|
144
|
+
def _on_new_connection(self) -> None:
|
|
145
|
+
if not self.server:
|
|
146
|
+
return
|
|
147
|
+
client = self.server.nextPendingConnection()
|
|
148
|
+
self.clients.add(client)
|
|
149
|
+
logger.debug("New LSP WebSocket connection established for %r", self.config.id)
|
|
150
|
+
|
|
151
|
+
client.textMessageReceived.connect(self._on_ws_message)
|
|
152
|
+
|
|
153
|
+
client_weak = weakref.ref(client)
|
|
154
|
+
|
|
155
|
+
@Slot()
|
|
156
|
+
def _on_client_disconnected() -> None:
|
|
157
|
+
if c := client_weak():
|
|
158
|
+
self.clients.discard(c)
|
|
159
|
+
logger.debug("LSP WebSocket connection closed for %r (Python)", self.config.id)
|
|
160
|
+
|
|
161
|
+
client.disconnected.connect(_on_client_disconnected)
|
|
162
|
+
|
|
163
|
+
@Slot(str)
|
|
164
|
+
def _on_ws_message(self, message: str) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Receive JSON-RPC from frontend WebSocket, translate virtual URI to real URI and forward to LSP stdin.
|
|
167
|
+
"""
|
|
168
|
+
if not self.process or self.process.state() != QProcess.ProcessState.Running:
|
|
169
|
+
logger.warning("Cannot write to LSP stdin for %r: process is not running.", self.config.id)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if self.real_uri and "file:///workspace" in message:
|
|
173
|
+
message = message.replace("file:///workspace", self.real_uri)
|
|
174
|
+
|
|
175
|
+
body = message.encode("utf-8")
|
|
176
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode()
|
|
177
|
+
self.process.write(header + body)
|
|
178
|
+
|
|
179
|
+
@Slot()
|
|
180
|
+
def _on_stdout_ready(self) -> None:
|
|
181
|
+
if not self.process:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
self._stdout_buffer.extend(self.process.readAllStandardOutput().data())
|
|
185
|
+
|
|
186
|
+
while True:
|
|
187
|
+
header_end = self._stdout_buffer.find(b"\r\n\r\n")
|
|
188
|
+
if header_end == -1:
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
header_bytes = self._stdout_buffer[:header_end]
|
|
192
|
+
content_length = None
|
|
193
|
+
for line in header_bytes.split(b"\r\n"):
|
|
194
|
+
if line.startswith(b"Content-Length:"):
|
|
195
|
+
content_length = int(line.split(b":")[1].strip())
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
if content_length is None:
|
|
199
|
+
del self._stdout_buffer[: header_end + 4]
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
total_needed = header_end + 4 + content_length
|
|
203
|
+
if len(self._stdout_buffer) < total_needed:
|
|
204
|
+
break # Incomplete message body; await next read event
|
|
205
|
+
|
|
206
|
+
body_bytes = self._stdout_buffer[header_end + 4 : total_needed]
|
|
207
|
+
del self._stdout_buffer[:total_needed]
|
|
208
|
+
|
|
209
|
+
msg_str = body_bytes.decode("utf-8", errors="replace")
|
|
210
|
+
for var in self.uri_variations:
|
|
211
|
+
if var in msg_str:
|
|
212
|
+
msg_str = msg_str.replace(var, "file:///workspace")
|
|
213
|
+
|
|
214
|
+
for client in self.clients:
|
|
215
|
+
if client.isValid():
|
|
216
|
+
client.sendTextMessage(msg_str)
|
|
217
|
+
|
|
218
|
+
@Slot()
|
|
219
|
+
def _on_stderr_ready(self) -> None:
|
|
220
|
+
if not self.process:
|
|
221
|
+
return
|
|
222
|
+
output = bytes(self.process.readAllStandardError().data()).decode("utf-8", errors="replace").strip()
|
|
223
|
+
if output:
|
|
224
|
+
for line in output.splitlines():
|
|
225
|
+
logger.debug("[LSP %s stderr] %s", self.config.id, line)
|
|
226
|
+
|
|
227
|
+
@Slot(QProcess.ProcessError)
|
|
228
|
+
def _on_process_error(self, error: QProcess.ProcessError) -> None:
|
|
229
|
+
if self.process:
|
|
230
|
+
logger.error("LSP QProcess error for %r (%s): %s", self.config.id, error, self.process.errorString())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class LSPProcessManager(QObject):
|
|
234
|
+
def __init__(self, parent: QObject) -> None:
|
|
235
|
+
super().__init__(parent)
|
|
236
|
+
self.servers = dict[str, LSPProcessServer]()
|
|
237
|
+
|
|
238
|
+
def start_server(
|
|
239
|
+
self,
|
|
240
|
+
config: LSPConfig,
|
|
241
|
+
port: int = 0,
|
|
242
|
+
workspace_dir: Path | None = None,
|
|
243
|
+
) -> int:
|
|
244
|
+
if config.id in self.servers:
|
|
245
|
+
logger.debug("LSP server %r is already running. Stopping existing server first.", config.id)
|
|
246
|
+
self.stop_server(config.id)
|
|
247
|
+
|
|
248
|
+
server = LSPProcessServer(config, self)
|
|
249
|
+
actual_port = server.start(port=port, workspace_dir=workspace_dir)
|
|
250
|
+
if actual_port > 0:
|
|
251
|
+
self.servers[config.id] = server
|
|
252
|
+
return actual_port
|
|
253
|
+
return -1
|
|
254
|
+
|
|
255
|
+
def stop_server(self, server_id: str) -> None:
|
|
256
|
+
if server := self.servers.pop(server_id, None):
|
|
257
|
+
server.stop()
|
|
258
|
+
|
|
259
|
+
def stop(self, server_id: str | None = None) -> None:
|
|
260
|
+
if server_id is not None:
|
|
261
|
+
self.stop_server(server_id)
|
|
262
|
+
else:
|
|
263
|
+
for server in list(self.servers.values()):
|
|
264
|
+
server.stop()
|
|
265
|
+
self.servers.clear()
|
vsview_editor/py.typed
ADDED
|
File without changes
|