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.
Files changed (50) hide show
  1. vsview_editor/__init__.py +19 -0
  2. vsview_editor/console.py +173 -0
  3. vsview_editor/logging.py +31 -0
  4. vsview_editor/lsp.py +265 -0
  5. vsview_editor/py.typed +0 -0
  6. vsview_editor/settings.py +248 -0
  7. vsview_editor/stubs.py +7 -0
  8. vsview_editor/utils.py +98 -0
  9. vsview_editor/web.py +337 -0
  10. vsview_editor/web_dist/assets/2026-dark-18a9d988.json +1 -0
  11. vsview_editor/web_dist/assets/2026-light-e23b82d6.json +1 -0
  12. vsview_editor/web_dist/assets/MagicPython.tmLanguage-d2adf018.json +1 -0
  13. vsview_editor/web_dist/assets/_commonjsHelpers-DMmVv2OA.js +1 -0
  14. vsview_editor/web_dist/assets/_commonjsHelpers-cc995bf6.js +1 -0
  15. vsview_editor/web_dist/assets/codicon-6bab8fd4.ttf +0 -0
  16. vsview_editor/web_dist/assets/dark-5cb61c67.json +501 -0
  17. vsview_editor/web_dist/assets/dark-colorblind-d5713f76.json +598 -0
  18. vsview_editor/web_dist/assets/dark-default-36ccf315.json +598 -0
  19. vsview_editor/web_dist/assets/dark-dimmed-5cf46ccb.json +598 -0
  20. vsview_editor/web_dist/assets/dark-high-contrast-8f2c613e.json +602 -0
  21. vsview_editor/web_dist/assets/diff_wasm_bg-c6YkaZRL-1ce9891a.wasm +0 -0
  22. vsview_editor/web_dist/assets/diff_wasm_bg-c6YkaZRL-c6YkaZRL.wasm +0 -0
  23. vsview_editor/web_dist/assets/dist-CYrV_pbP.js +8 -0
  24. vsview_editor/web_dist/assets/dist-b4ad7a98.js +8 -0
  25. vsview_editor/web_dist/assets/editor.worker-BHP4V20C.js +41 -0
  26. vsview_editor/web_dist/assets/empty-BvRk9kiK.js +0 -0
  27. vsview_editor/web_dist/assets/empty-a60adbff.js +1 -0
  28. vsview_editor/web_dist/assets/iconv-lite-umd-736e9b08.js +6 -0
  29. vsview_editor/web_dist/assets/index-1e7694cb.css +1 -0
  30. vsview_editor/web_dist/assets/index-e0eb721a.js +1444 -0
  31. vsview_editor/web_dist/assets/jschardet-d8a4cb78.js +11 -0
  32. vsview_editor/web_dist/assets/light-b73a2001.json +497 -0
  33. vsview_editor/web_dist/assets/light-colorblind-16efb166.json +583 -0
  34. vsview_editor/web_dist/assets/light-default-8cff633f.json +583 -0
  35. vsview_editor/web_dist/assets/light-high-contrast-165ac11a.json +586 -0
  36. vsview_editor/web_dist/assets/main-Db95Pfi8.js +4 -0
  37. vsview_editor/web_dist/assets/main-dbf793df.js +4 -0
  38. vsview_editor/web_dist/assets/main2-9655df87.js +1 -0
  39. vsview_editor/web_dist/assets/main2-CWVd-Hyp.js +1 -0
  40. vsview_editor/web_dist/assets/onig-eee6946b.wasm +0 -0
  41. vsview_editor/web_dist/assets/rolldown-runtime-7467d20d.js +1 -0
  42. vsview_editor/web_dist/assets/webWorkerExtensionHostIframe-a59f9e68.html +123 -0
  43. vsview_editor/web_dist/assets/worker-BH1GlPLC.js +17 -0
  44. vsview_editor/web_dist/assets/worker-CQu38ZhE.js +2 -0
  45. vsview_editor/web_dist/index.html +34 -0
  46. vsview_editor/workspace.py +733 -0
  47. vsview_editor-0.1.0.dist-info/METADATA +32 -0
  48. vsview_editor-0.1.0.dist-info/RECORD +50 -0
  49. vsview_editor-0.1.0.dist-info/WHEEL +4 -0
  50. 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
@@ -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
@@ -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