From 445eb1c46e7d32067d549ac017a5fba71e203810 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 29 Apr 2026 21:51:00 +0200 Subject: [PATCH 1/7] Add support for server-provided document content --- boot.py | 2 ++ plugin/core/collections.py | 11 ++++---- plugin/core/sessions.py | 55 +++++++++++++++++++++++++++++++++++--- plugin/core/views.py | 16 +++++++++++ plugin/panels.py | 10 +------ 5 files changed, 75 insertions(+), 19 deletions(-) diff --git a/boot.py b/boot.py index 4978a0342..156058e60 100644 --- a/boot.py +++ b/boot.py @@ -35,6 +35,7 @@ from .plugin.core.transports import kill_all_subprocesses from .plugin.core.tree_view import LspCollapseTreeItemCommand from .plugin.core.tree_view import LspExpandTreeItemCommand +from .plugin.core.views import LspReplaceReadonlyContentCommand from .plugin.core.views import LspRunTextCommandHelperCommand from .plugin.document_link import LspOpenLinkCommand from .plugin.documents import DocumentSyncListener @@ -137,6 +138,7 @@ "LspPrevDiagnosticCommand", "LspRefactorCommand", "LspRenamePathCommand", + "LspReplaceReadonlyContentCommand", "LspResolveDocsCommand", "LspRestartServerCommand", "LspRunTextCommandHelperCommand", diff --git a/plugin/core/collections.py b/plugin/core/collections.py index a1862d670..cb6ff7400 100644 --- a/plugin/core/collections.py +++ b/plugin/core/collections.py @@ -40,16 +40,15 @@ def from_base_and_override(cls, base: DottedDict, override: dict[str, Any] | Non result.update(deepcopy(override)) return result - def get(self, path: str | None = None) -> Any: + def get(self, path: str | None = None, default: Any = None) -> Any: """ Get a value from the dictionary. :param path: The path, e.g. foo.bar.baz, or None. + :param default: Fallback value if path is not contained in this DottedDict. - :returns: The value stored at the path, or None if it doesn't exist. - Note that this cannot distinguish between None values and - paths that don't exist. If the path is None, returns the - entire dictionary. + :returns: The value stored at the path, or `default` if it doesn't exist. + If the path is None, returns the entire dictionary. """ if path is None: return self._d @@ -59,7 +58,7 @@ def get(self, path: str | None = None) -> Any: if isinstance(current, dict): current = current.get(key) else: - return None + return default return current def walk(self, path: str) -> Generator[Any, None, None]: diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 4f00417a4..b1cddc472 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -57,6 +57,8 @@ from ...protocol import SymbolKind from ...protocol import SymbolTag from ...protocol import TextDocumentClientCapabilities +from ...protocol import TextDocumentContentRefreshParams +from ...protocol import TextDocumentContentResult from ...protocol import TextDocumentSyncKind from ...protocol import TextEdit from ...protocol import TokenFormat @@ -139,8 +141,10 @@ from .url import normalize_uri from .url import parse_uri from .version import __version__ +from .views import entire_content from .views import get_uri_and_range_from_location from .views import kind_contains_other_kind +from .views import MissingUriError from .views import uri_from_view from .workspace import is_subpath_of from .workspace import WorkspaceFolder @@ -161,6 +165,7 @@ from typing import Union from typing_extensions import TypeAlias from typing_extensions import TypeGuard +from urllib.parse import urlparse from weakref import WeakSet import itertools import mdpopups @@ -570,6 +575,9 @@ def get_initialize_params( }, "diagnostics": { "refreshSupport": True + }, + "textDocumentContent": { + "dynamicRegistration": True } } window_capabilities: WindowClientCapabilities = { @@ -1426,7 +1434,8 @@ def try_open_uri_async( flags: sublime.NewFileFlags = sublime.NewFileFlags.NONE, group: int = -1 ) -> Promise[sublime.View | None] | None: - if uri.startswith("file:"): + scheme, _ = parse_uri(uri) + if scheme == 'file': return self._open_file_uri_async(uri, r, flags, group) # Try to find a pre-existing session-buffer if sb := self.get_session_buffer_for_uri_async(uri): @@ -1435,9 +1444,9 @@ def try_open_uri_async( if r: center_selection(view, r) return Promise.resolve(view) - if uri.startswith('res:'): + if scheme == 'res': return self._open_res_uri_async(uri, r, group) - if uri.startswith('untitled:'): # VSCode specific URI scheme for unsaved buffers + if scheme == 'untitled': # VSCode specific URI scheme for unsaved buffers flags &= sublime.NewFileFlags.TRANSIENT | sublime.NewFileFlags.ADD_TO_SELECTION if name := uri[len('untitled:'):]: # Check if there is a pre-existing unsaved buffer with the given name @@ -1452,10 +1461,12 @@ def try_open_uri_async( view = self.window.new_file(flags) view.set_scratch(True) return Promise.resolve(view) + if scheme in self.capabilities.get('workspace.textDocumentContent.schemes', []): + return self.send_request_task(Request('workspace/textDocumentContent', {'uri': uri})) \ + .then(partial(self._on_text_document_content_async, uri, r, flags, group)) # There is no pre-existing session-buffer, so we have to go through the plugin's URI handler. if self._plugin: if isinstance(self._plugin, LspPlugin): - scheme, _ = parse_uri(uri) if handler := self._plugin.get_uri_handler(scheme): return handler(uri, flags).then(lambda sheet: self._on_sheet_opened(sheet, uri, r)) else: @@ -1565,6 +1576,27 @@ def _on_sheet_opened( return Promise.resolve(view) return Promise.resolve(None) + def _on_text_document_content_async( + self, + uri: DocumentUri, + r: Range | None, + flags: sublime.NewFileFlags, + group: int, + response: TextDocumentContentResult | Error + ) -> Promise[sublime.View | None]: + if isinstance(response, Error): + return Promise.resolve(None) + content = response['text'].replace('\r', '') + return self.open_scratch_buffer(urlparse(uri).path.split('/')[-1], content, '', uri, r, flags, group) + + def _on_text_document_content_refreshed_async( + self, view: sublime.View, response: TextDocumentContentResult + ) -> None: + if view.is_valid(): + content = response['text'].replace('\r', '') + if text != entire_content(view): + view.run_command('lsp_replace_readonly_content', {'content': content}) + def open_location_async( self, location: Location | LocationLink, @@ -1929,6 +1961,21 @@ def _refresh_diagnostics(self) -> None: for session_buffer in not_visible_session_buffers: session_buffer.set_pending_refresh(RequestFlags.DIAGNOSTIC) + @request_handler('workspace/textDocumentContent/refresh') + def on_workspace_text_document_content_refresh(self, params: TextDocumentContentRefreshParams) -> Promise[None]: + sublime.set_timeout_async(lambda: self._refresh_text_document_content_async(params['uri'])) + return Promise.resolve(None) + + def _refresh_text_document_content_async(self, uri: DocumentUri) -> None: + for view in self.window.views(): + try: + if uri_from_view(view) == uri: + request = Request('workspace/textDocumentContent', {'uri': uri}) + self.send_request_async(request, partial(self._on_text_document_content_refreshed_async, view)) + break + except MissingUriError: + continue + @notification_handler('textDocument/publishDiagnostics') def on_text_document_publish_diagnostics(self, params: PublishDiagnosticsParams) -> None: self.handle_diagnostics_async(params['uri'], None, None, params['diagnostics']) diff --git a/plugin/core/views.py b/plugin/core/views.py index 0dc0773fd..25ab562c2 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -55,6 +55,7 @@ from .url import encode_code_action_uri from .url import parse_uri from .workspace import is_subpath_of +from contextlib import contextmanager from dataclasses import dataclass from functools import lru_cache from operator import itemgetter @@ -63,6 +64,7 @@ from typing import Any from typing import Callable from typing import cast +from typing import Generator from typing import Iterable from typing import Sequence from typing import TYPE_CHECKING @@ -145,6 +147,13 @@ def __init__(self, uri: str) -> None: super().__init__(f"invalid URI scheme: {uri}") +@contextmanager +def mutable(view: sublime.View) -> Generator: + view.set_read_only(False) + yield + view.set_read_only(True) + + def get_line(window: sublime.Window, file_name: str, row: int, strip: bool = True) -> str: """ Get the line from the buffer if the view is open, else get line from linecache. @@ -680,6 +689,13 @@ def run(self, view_id: int, command: str, args: dict[str, Any] | None = None) -> view.run_command(command, args) +class LspReplaceReadonlyContentCommand(sublime_plugin.TextCommand): + + def run(self, edit: sublime.Edit, content: str) -> None: + with mutable(self.view): + self.view.replace(edit, entire_content_region(self.view), content) + + COLOR_BOX_HTML = """