From 2720f1b400c274df32c24da4a823f408c6ae8be4 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 16 Mar 2024 15:31:47 +0100 Subject: [PATCH 01/21] Add argument for rename command to preserve tab states of modified files --- docs/src/keyboard_shortcuts.md | 6 +++--- plugin/core/sessions.py | 38 +++++++++++++++++++++++++++++----- plugin/core/views.py | 8 ++++++- plugin/edit.py | 4 ++-- plugin/rename.py | 27 +++++++++++++----------- 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/docs/src/keyboard_shortcuts.md b/docs/src/keyboard_shortcuts.md index e277c542f..33409e555 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -27,16 +27,16 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Insert/Replace Completions | alt enter | `lsp_commit_completion_with_opposite_insert_mode` | Next Diagnostic | unbound | `lsp_next_diagnostic` | Previous Diagnostic | unbound | `lsp_prev_diagnostic` -| Rename | unbound | `lsp_symbol_rename` +| Rename | unbound | `lsp_symbol_rename`
Supports optional args `{"preserve_tabs": true | false}` - if set to `true`, the current states of the tabs in the window are preserved when applying the rename. This means that affected files are automatically saved unless they had unsaved changes beforehand, and other files in the workspace with rename modifications but not currently open in the window aren't kept open as new tabs. | Restart Server | unbound | `lsp_restart_server` | Run Code Action | unbound | `lsp_code_actions` | Run Code Lens | unbound | `lsp_code_lens` | Run Refactor Action | unbound | `lsp_code_actions`
With args: `{"only_kinds": ["refactor"]}`. | Run Source Action | unbound | `lsp_code_actions`
With args: `{"only_kinds": ["source"]}`. -| Save All | unbound | `lsp_save_all`
Supports optional args `{"only_files": true}` - to ignore buffers which have no associated file on disk. +| Save All | unbound | `lsp_save_all`
Supports optional args `{"only_files": true | false}` - whether to ignore buffers which have no associated file on disk. | Show Call Hierarchy | unbound | `lsp_call_hierarchy` | Show Type Hierarchy | unbound | `lsp_type_hierarchy` | Signature Help | ctrl alt space | `lsp_signature_help_show` | Toggle Diagnostics Panel | ctrl alt m | `lsp_show_diagnostics_panel` -| Toggle Inlay Hints | unbound | `lsp_toggle_inlay_hints`
Supports optional args: `{"enable": true/false}`. +| Toggle Inlay Hints | unbound | `lsp_toggle_inlay_hints`
Supports optional args: `{"enable": true | false}`. | Toggle Log Panel | unbound | `lsp_toggle_server_panel` diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 827ba788c..cddbdf36d 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -105,6 +105,7 @@ from .views import get_storage_path from .views import get_uri_and_range_from_location from .views import MarkdownLangMap +from .views import UriTabState from .workspace import is_subpath_of from .workspace import WorkspaceFolder from abc import ABCMeta @@ -1774,20 +1775,22 @@ def _apply_code_action_async( return promise.then(lambda _: self.execute_command(execute_command, progress=False, view=view)) return promise - def apply_workspace_edit_async(self, edit: WorkspaceEdit) -> Promise[None]: + def apply_workspace_edit_async(self, edit: WorkspaceEdit, preserve_tabs: bool = False) -> Promise[None]: """ Apply workspace edits, and return a promise that resolves on the async thread again after the edits have been applied. """ - return self.apply_parsed_workspace_edits(parse_workspace_edit(edit)) + return self.apply_parsed_workspace_edits(parse_workspace_edit(edit), preserve_tabs) - def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> Promise[None]: + def apply_parsed_workspace_edits(self, changes: WorkspaceChanges, preserve_tabs: bool) -> Promise[None]: active_sheet = self.window.active_sheet() selected_sheets = self.window.selected_sheets() promises = [] # type: List[Promise[None]] for uri, (edits, view_version) in changes.items(): + tab_state = self._get_tab_state(uri) if preserve_tabs else UriTabState.DIRTY promises.append( self.open_uri_async(uri).then(functools.partial(self._apply_text_edits, edits, view_version, uri)) + .then(functools.partial(self._set_tab_state, tab_state)) ) return Promise.all(promises) \ .then(lambda _: self._set_selected_sheets(selected_sheets)) \ @@ -1795,11 +1798,36 @@ def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> Promise[Non def _apply_text_edits( self, edits: List[TextEdit], view_version: Optional[int], uri: str, view: Optional[sublime.View] - ) -> None: + ) -> Optional[sublime.View]: if view is None or not view.is_valid(): print('LSP: ignoring edits due to no view for uri: {}'.format(uri)) - return + return None apply_text_edits(view, edits, required_view_version=view_version) + return view + + def _get_tab_state(self, uri: DocumentUri) -> UriTabState: + scheme, filepath = parse_uri(uri) + if scheme == 'file': + view = self.window.find_open_file(filepath) + if view: + return UriTabState.DIRTY if view.is_dirty() else UriTabState.SAVED + else: + # Only file URIs can be saved (or closed) without a save dialog; "DIRTY" means that nothing needs to be done + return UriTabState.DIRTY + return UriTabState.UNOPENED + + def _set_tab_state(self, tab_state: UriTabState, view: Optional[sublime.View]) -> None: + if not view: + return + if tab_state is UriTabState.SAVED: + view.run_command('save', {'async': True, 'quiet': True}) + elif tab_state is UriTabState.UNOPENED: + # The save operation should be blocking, because we want to close the tab afterwards + view.run_command('save', {'async': False, 'quiet': True}) + if not view.is_dirty(): + if not view == self.window.active_view(): + self.window.focus_view(view) + self.window.run_command('close') def _set_selected_sheets(self, sheets: List[sublime.Sheet]) -> None: if len(sheets) > 1 and len(self.window.selected_sheets()) != len(sheets): diff --git a/plugin/core/views.py b/plugin/core/views.py index 327e1bb26..035c8e746 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -38,7 +38,7 @@ from .protocol import WillSaveTextDocumentParams from .settings import userprefs from .types import ClientConfig -from .typing import Callable, Optional, Dict, Any, Iterable, List, Union, Tuple, cast +from .typing import Callable, Optional, Dict, Any, Enum, Iterable, List, Union, Tuple, cast from .url import parse_uri from .workspace import is_subpath_of import html @@ -78,6 +78,12 @@ def __init__(self, severity: int) -> None: self.icon = "" if severity == DiagnosticSeverity.Hint else userprefs().diagnostics_gutter_marker +class UriTabState(Enum): + UNOPENED = 1 + SAVED = 2 + DIRTY = 3 + + class InvalidUriSchemeException(Exception): def __init__(self, uri: str) -> None: self.uri = uri diff --git a/plugin/edit.py b/plugin/edit.py index b22dcdac1..c589ff5ac 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -29,12 +29,12 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat class LspApplyWorkspaceEditCommand(LspWindowCommand): - def run(self, session_name: str, edit: WorkspaceEdit) -> None: + def run(self, session_name: str, edit: WorkspaceEdit, preserve_tabs: bool = False) -> None: session = self.session_by_name(session_name) if not session: debug('Could not find session', session_name, 'required to apply WorkspaceEdit') return - sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit)) + sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit, preserve_tabs)) class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): diff --git a/plugin/rename.py b/plugin/rename.py index 3acdbd626..ac13dd646 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -120,6 +120,7 @@ def is_visible( self, new_name: str = "", placeholder: str = "", + preserve_tabs: bool = False, event: Optional[dict] = None, point: Optional[int] = None ) -> bool: @@ -152,6 +153,7 @@ def run( edit: sublime.Edit, new_name: str = "", placeholder: str = "", + preserve_tabs: bool = False, event: Optional[dict] = None, point: Optional[int] = None ) -> None: @@ -162,7 +164,7 @@ def run( prepare_provider_session = self.best_session("renameProvider.prepareProvider") if new_name or placeholder or not prepare_provider_session: if location is not None and new_name: - self._do_rename(location, new_name) + self._do_rename(location, new_name, preserve_tabs) return # Trigger InputHandler manually. raise TypeError("required positional argument") @@ -171,9 +173,9 @@ def run( params = cast(PrepareRenameParams, text_document_position_params(self.view, location)) request = Request.prepareRename(params, self.view, progress=True) prepare_provider_session.send_request( - request, partial(self._on_prepare_result, location), self._on_prepare_error) + request, partial(self._on_prepare_result, location, preserve_tabs), self._on_prepare_error) - def _do_rename(self, position: int, new_name: str) -> None: + def _do_rename(self, position: int, new_name: str, preserve_tabs: bool) -> None: session = self.best_session(self.capability) if not session: return @@ -184,25 +186,25 @@ def _do_rename(self, position: int, new_name: str) -> None: "newName": new_name, } # type: RenameParams request = Request.rename(params, self.view, progress=True) - session.send_request(request, partial(self._on_rename_result_async, session)) + session.send_request(request, partial(self._on_rename_result_async, session, preserve_tabs)) - def _on_rename_result_async(self, session: Session, response: Optional[WorkspaceEdit]) -> None: + def _on_rename_result_async(self, session: Session, preserve_tabs: bool, response: Optional[WorkspaceEdit]) -> None: if not response: return session.window.status_message('Nothing to rename') changes = parse_workspace_edit(response) file_count = len(changes.keys()) if file_count == 1: - session.apply_parsed_workspace_edits(changes) + session.apply_parsed_workspace_edits(changes, preserve_tabs) return total_changes = sum(map(len, changes.values())) message = "Replace {} occurrences across {} files?".format(total_changes, file_count) choice = sublime.yes_no_cancel_dialog(message, "Replace", "Preview", title="Rename") if choice == sublime.DIALOG_YES: - session.apply_parsed_workspace_edits(changes) + session.apply_parsed_workspace_edits(changes, preserve_tabs) elif choice == sublime.DIALOG_NO: - self._render_rename_panel(response, changes, total_changes, file_count, session.config.name) + self._render_rename_panel(response, changes, total_changes, file_count, session.config.name, preserve_tabs) - def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None: + def _on_prepare_result(self, pos: int, preserve_tabs: bool, response: Optional[PrepareRenameResult]) -> None: if response is None: sublime.error_message("The current selection cannot be renamed") return @@ -215,7 +217,7 @@ def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) pos = range_to_region(response["range"], self.view).a # type: ignore else: placeholder = self.view.substr(self.view.word(pos)) - args = {"placeholder": placeholder, "point": pos} + args = {"placeholder": placeholder, "point": pos, "preserve_tabs": preserve_tabs} self.view.run_command("lsp_symbol_rename", args) def _on_prepare_error(self, error: Any) -> None: @@ -234,7 +236,8 @@ def _render_rename_panel( changes_per_uri: WorkspaceChanges, total_changes: int, file_count: int, - session_name: str + session_name: str, + preserve_tabs: bool ) -> None: wm = windows.lookup(self.view.window()) if not wm: @@ -296,7 +299,7 @@ def _render_rename_panel( 'commands': [ [ 'lsp_apply_workspace_edit', - {'session_name': session_name, 'edit': workspace_edit} + {'session_name': session_name, 'edit': workspace_edit, 'preserve_tabs': preserve_tabs} ], [ 'hide_panel', From 5f7bc61fd85bef48cfcc65d638c7a4aa126efeab Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 25 Mar 2024 22:29:28 +0100 Subject: [PATCH 02/21] Ensure didChange is never sent after didClose This fixes for example the Pyright warning LSP-pyright: Received change text document command for closed file when a file is saved and closed immediately after changes were applied. --- plugin/session_buffer.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 71febb56b..56bc403a9 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -166,8 +166,9 @@ def _check_did_open(self, view: sublime.View) -> None: self._do_document_link_async(view, version) self.session.notify_plugin_on_session_buffer_change(self) - def _check_did_close(self) -> None: + def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): + self.purge_changes_async(view) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False @@ -202,9 +203,9 @@ def remove_session_view(self, sv: SessionViewProtocol) -> None: self._clear_semantic_token_regions(sv.view) self.session_views.remove(sv) if len(self.session_views) == 0: - self._on_before_destroy() + self._on_before_destroy(sv.view) - def _on_before_destroy(self) -> None: + def _on_before_destroy(self, view: sublime.View) -> None: self.remove_all_inlay_hints() if self.has_capability("diagnosticProvider") and self.session.config.diagnostics_mode == "open_files": self.session.m_textDocument_publishDiagnostics({'uri': self._last_known_uri, 'diagnostics': []}) @@ -216,7 +217,7 @@ def _on_before_destroy(self) -> None: # in unregistering ourselves from the session. if not self.session.exiting: # Only send textDocument/didClose when we are the only view left (i.e. there are no other clones). - self._check_did_close() + self._check_did_close(view) self.session.unregister_session_buffer_async(self) def register_capability_async( @@ -308,7 +309,7 @@ def on_revert_async(self, view: sublime.View) -> None: on_reload_async = on_revert_async - def purge_changes_async(self, view: sublime.View) -> None: + def purge_changes_async(self, view: sublime.View, suppress_requests: bool = False) -> None: if self._pending_changes is None: return sync_kind = self.text_sync_kind() @@ -316,7 +317,7 @@ def purge_changes_async(self, view: sublime.View) -> None: return if sync_kind == TextDocumentSyncKind.Full: changes = None - version = view.change_count() + version = view.change_count() if view.is_valid() else self._pending_changes.version else: changes = self._pending_changes.changes version = self._pending_changes.version @@ -329,12 +330,14 @@ def purge_changes_async(self, view: sublime.View) -> None: finally: self._pending_changes = None self.session.notify_plugin_on_session_buffer_change(self) - sublime.set_timeout_async(lambda: self._on_after_change_async(view, version)) + sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) - def _on_after_change_async(self, view: sublime.View, version: int) -> None: + def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: if self._is_saving: self._has_changed_during_save = True return + if suppress_requests or not view.is_valid(): + return self._do_color_boxes_async(view, version) self.do_document_diagnostic_async(view, version) if self.session.config.diagnostics_mode == "workspace" and \ @@ -357,7 +360,7 @@ def on_pre_save_async(self, view: sublime.View) -> None: def on_post_save_async(self, view: sublime.View, new_uri: DocumentUri) -> None: self._is_saving = False if new_uri != self._last_known_uri: - self._check_did_close() + self._check_did_close(view) self._last_known_uri = new_uri self._check_did_open(view) else: From fdcf57c69b8fff5f3643904394e1343917d5f3fe Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 26 Mar 2024 16:52:12 +0100 Subject: [PATCH 03/21] Convert to user setting --- LSP.sublime-settings | 8 ++++ docs/src/keyboard_shortcuts.md | 2 +- plugin/core/sessions.py | 84 +++++++++++++++++++++++----------- plugin/core/types.py | 2 + plugin/core/views.py | 8 +--- plugin/edit.py | 4 +- plugin/rename.py | 27 +++++------ sublime-package.json | 16 +++++++ 8 files changed, 99 insertions(+), 52 deletions(-) diff --git a/LSP.sublime-settings b/LSP.sublime-settings index b938ccdd7..1489931cf 100644 --- a/LSP.sublime-settings +++ b/LSP.sublime-settings @@ -223,6 +223,14 @@ // "region", ], + // Controls if files that were part of a refactoring (e.g. rename) are saved automatically: + // "always" - save all affected files + // "preserve" - only save files that didn't have unsaved changes beforehand + // "preserve_opened" - only save opened files that didn't have unsaved changes beforehand + // and open other files that were affected by the refactoring + // "never" - never save files automatically + "refactoring_auto_save": "never", + // --- Debugging ---------------------------------------------------------------------- // Show verbose debug messages in the sublime console. diff --git a/docs/src/keyboard_shortcuts.md b/docs/src/keyboard_shortcuts.md index 33409e555..d5ee364ae 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -27,7 +27,7 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Insert/Replace Completions | alt enter | `lsp_commit_completion_with_opposite_insert_mode` | Next Diagnostic | unbound | `lsp_next_diagnostic` | Previous Diagnostic | unbound | `lsp_prev_diagnostic` -| Rename | unbound | `lsp_symbol_rename`
Supports optional args `{"preserve_tabs": true | false}` - if set to `true`, the current states of the tabs in the window are preserved when applying the rename. This means that affected files are automatically saved unless they had unsaved changes beforehand, and other files in the workspace with rename modifications but not currently open in the window aren't kept open as new tabs. +| Rename | unbound | `lsp_symbol_rename` | Restart Server | unbound | `lsp_restart_server` | Run Code Action | unbound | `lsp_code_actions` | Run Code Lens | unbound | `lsp_code_lens` diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index cddbdf36d..d7df2cfb5 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -83,6 +83,7 @@ from .protocol import WorkspaceEdit from .settings import client_configs from .settings import globalprefs +from .settings import userprefs from .transports import Transport from .transports import TransportCallbacks from .types import Capabilities @@ -95,7 +96,7 @@ from .types import SettingsRegistration from .types import sublime_pattern_to_glob from .types import WORKSPACE_DIAGNOSTICS_TIMEOUT -from .typing import Callable, cast, Dict, Any, Optional, List, Tuple, Generator, Type, TypeGuard, Protocol, Set, TypeVar, Union # noqa: E501 +from .typing import Callable, cast, Dict, Any, Optional, List, Tuple, Generator, Type, TypeGuard, Protocol, Set, TypeVar, Union, IntFlag # noqa: E501 from .url import filename_to_uri from .url import parse_uri from .url import unparse_uri @@ -105,7 +106,6 @@ from .views import get_storage_path from .views import get_uri_and_range_from_location from .views import MarkdownLangMap -from .views import UriTabState from .workspace import is_subpath_of from .workspace import WorkspaceFolder from abc import ABCMeta @@ -122,6 +122,11 @@ T = TypeVar('T') +class ViewStateActions(IntFlag): + Close = 2 + Save = 1 + + def is_workspace_full_document_diagnostic_report( report: WorkspaceDocumentDiagnosticReport ) -> TypeGuard[WorkspaceFullDocumentDiagnosticReport]: @@ -1763,7 +1768,8 @@ def _apply_code_action_async( self.window.status_message("Failed to apply code action: {}".format(code_action)) return Promise.resolve(None) edit = code_action.get("edit") - promise = self.apply_workspace_edit_async(edit) if edit else Promise.resolve(None) + is_refactoring = code_action.get('kind') == CodeActionKind.Refactor + promise = self.apply_workspace_edit_async(edit, is_refactoring) if edit else Promise.resolve(None) command = code_action.get("command") if command is not None: execute_command = { @@ -1775,22 +1781,23 @@ def _apply_code_action_async( return promise.then(lambda _: self.execute_command(execute_command, progress=False, view=view)) return promise - def apply_workspace_edit_async(self, edit: WorkspaceEdit, preserve_tabs: bool = False) -> Promise[None]: + def apply_workspace_edit_async(self, edit: WorkspaceEdit, is_refactoring: bool = False) -> Promise[None]: """ Apply workspace edits, and return a promise that resolves on the async thread again after the edits have been applied. """ - return self.apply_parsed_workspace_edits(parse_workspace_edit(edit), preserve_tabs) + return self.apply_parsed_workspace_edits(parse_workspace_edit(edit), is_refactoring) - def apply_parsed_workspace_edits(self, changes: WorkspaceChanges, preserve_tabs: bool) -> Promise[None]: + def apply_parsed_workspace_edits(self, changes: WorkspaceChanges, is_refactoring: bool = False) -> Promise[None]: active_sheet = self.window.active_sheet() selected_sheets = self.window.selected_sheets() promises = [] # type: List[Promise[None]] + auto_save = userprefs().refactoring_auto_save if is_refactoring else 'never' for uri, (edits, view_version) in changes.items(): - tab_state = self._get_tab_state(uri) if preserve_tabs else UriTabState.DIRTY + view_state_actions = self._get_view_state_actions(uri, auto_save) promises.append( self.open_uri_async(uri).then(functools.partial(self._apply_text_edits, edits, view_version, uri)) - .then(functools.partial(self._set_tab_state, tab_state)) + .then(functools.partial(self._set_view_state, view_state_actions)) ) return Promise.all(promises) \ .then(lambda _: self._set_selected_sheets(selected_sheets)) \ @@ -1805,29 +1812,52 @@ def _apply_text_edits( apply_text_edits(view, edits, required_view_version=view_version) return view - def _get_tab_state(self, uri: DocumentUri) -> UriTabState: + def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> int: + """ + Determine the required actions for a view after applying a WorkspaceEdit, depending on the + "refactoring_auto_save" user setting. Returns a bitwise combination of ViewStateActions.Save and + ViewStateActions.Close, or 0 if no action is necessary. + """ + if auto_save == 'never': + return 0 # Never save or close automatically scheme, filepath = parse_uri(uri) - if scheme == 'file': - view = self.window.find_open_file(filepath) - if view: - return UriTabState.DIRTY if view.is_dirty() else UriTabState.SAVED + if scheme != 'file': + return 0 # Can't save or close unsafed buffers (and other schemes) without user dialog + view = self.window.find_open_file(filepath) + if view: + is_opened = True + is_dirty = view.is_dirty() else: - # Only file URIs can be saved (or closed) without a save dialog; "DIRTY" means that nothing needs to be done - return UriTabState.DIRTY - return UriTabState.UNOPENED - - def _set_tab_state(self, tab_state: UriTabState, view: Optional[sublime.View]) -> None: + is_opened = False + is_dirty = False + actions = 0 + if auto_save == 'always': + actions |= ViewStateActions.Save # Always save + if not is_opened: + actions |= ViewStateActions.Close # Close if file was previously closed + elif auto_save == 'preserve': + if not is_dirty: + actions |= ViewStateActions.Save # Only save if file didn't have unsaved changes + if not is_opened: + actions |= ViewStateActions.Close # Close if file was previously closed + elif auto_save == 'preserve_opened': + if is_opened and not is_dirty: + # Only save if file was already open and didn't have unsaved changes, but never close + actions |= ViewStateActions.Save + return actions + + def _set_view_state(self, actions: int, view: Optional[sublime.View]) -> None: if not view: return - if tab_state is UriTabState.SAVED: - view.run_command('save', {'async': True, 'quiet': True}) - elif tab_state is UriTabState.UNOPENED: - # The save operation should be blocking, because we want to close the tab afterwards - view.run_command('save', {'async': False, 'quiet': True}) - if not view.is_dirty(): - if not view == self.window.active_view(): - self.window.focus_view(view) - self.window.run_command('close') + should_save = bool(actions & ViewStateActions.Save) + should_close = bool(actions & ViewStateActions.Close) + if should_save and view.is_dirty(): + # The save operation must be blocking in case the tab should be closed afterwards + view.run_command('save', {'async': not should_close, 'quiet': True}) + if should_close and not view.is_dirty(): + if view != self.window.active_view(): + self.window.focus_view(view) + self.window.run_command('close') def _set_selected_sheets(self, sheets: List[sublime.Sheet]) -> None: if len(sheets) > 1 and len(self.window.selected_sheets()) != len(sheets): diff --git a/plugin/core/types.py b/plugin/core/types.py index 06710a094..47640525e 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -221,6 +221,7 @@ class Settings: only_show_lsp_completions = cast(bool, None) popup_max_characters_height = cast(int, None) popup_max_characters_width = cast(int, None) + refactoring_auto_save = cast(str, None) semantic_highlighting = cast(bool, None) show_code_actions = cast(str, None) show_code_lens = cast(str, None) @@ -264,6 +265,7 @@ def r(name: str, default: Union[bool, int, str, list, dict]) -> None: r("completion_insert_mode", 'insert') r("popup_max_characters_height", 1000) r("popup_max_characters_width", 120) + r("refactoring_auto_save", "never") r("semantic_highlighting", False) r("show_code_actions", "annotation") r("show_code_lens", "annotation") diff --git a/plugin/core/views.py b/plugin/core/views.py index 035c8e746..327e1bb26 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -38,7 +38,7 @@ from .protocol import WillSaveTextDocumentParams from .settings import userprefs from .types import ClientConfig -from .typing import Callable, Optional, Dict, Any, Enum, Iterable, List, Union, Tuple, cast +from .typing import Callable, Optional, Dict, Any, Iterable, List, Union, Tuple, cast from .url import parse_uri from .workspace import is_subpath_of import html @@ -78,12 +78,6 @@ def __init__(self, severity: int) -> None: self.icon = "" if severity == DiagnosticSeverity.Hint else userprefs().diagnostics_gutter_marker -class UriTabState(Enum): - UNOPENED = 1 - SAVED = 2 - DIRTY = 3 - - class InvalidUriSchemeException(Exception): def __init__(self, uri: str) -> None: self.uri = uri diff --git a/plugin/edit.py b/plugin/edit.py index c589ff5ac..eed5aed9d 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -29,12 +29,12 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat class LspApplyWorkspaceEditCommand(LspWindowCommand): - def run(self, session_name: str, edit: WorkspaceEdit, preserve_tabs: bool = False) -> None: + def run(self, session_name: str, edit: WorkspaceEdit, is_refactoring: bool = False) -> None: session = self.session_by_name(session_name) if not session: debug('Could not find session', session_name, 'required to apply WorkspaceEdit') return - sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit, preserve_tabs)) + sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit, is_refactoring)) class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): diff --git a/plugin/rename.py b/plugin/rename.py index ac13dd646..957944780 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -120,7 +120,6 @@ def is_visible( self, new_name: str = "", placeholder: str = "", - preserve_tabs: bool = False, event: Optional[dict] = None, point: Optional[int] = None ) -> bool: @@ -153,7 +152,6 @@ def run( edit: sublime.Edit, new_name: str = "", placeholder: str = "", - preserve_tabs: bool = False, event: Optional[dict] = None, point: Optional[int] = None ) -> None: @@ -164,7 +162,7 @@ def run( prepare_provider_session = self.best_session("renameProvider.prepareProvider") if new_name or placeholder or not prepare_provider_session: if location is not None and new_name: - self._do_rename(location, new_name, preserve_tabs) + self._do_rename(location, new_name) return # Trigger InputHandler manually. raise TypeError("required positional argument") @@ -173,9 +171,9 @@ def run( params = cast(PrepareRenameParams, text_document_position_params(self.view, location)) request = Request.prepareRename(params, self.view, progress=True) prepare_provider_session.send_request( - request, partial(self._on_prepare_result, location, preserve_tabs), self._on_prepare_error) + request, partial(self._on_prepare_result, location), self._on_prepare_error) - def _do_rename(self, position: int, new_name: str, preserve_tabs: bool) -> None: + def _do_rename(self, position: int, new_name: str) -> None: session = self.best_session(self.capability) if not session: return @@ -186,25 +184,25 @@ def _do_rename(self, position: int, new_name: str, preserve_tabs: bool) -> None: "newName": new_name, } # type: RenameParams request = Request.rename(params, self.view, progress=True) - session.send_request(request, partial(self._on_rename_result_async, session, preserve_tabs)) + session.send_request(request, partial(self._on_rename_result_async, session)) - def _on_rename_result_async(self, session: Session, preserve_tabs: bool, response: Optional[WorkspaceEdit]) -> None: + def _on_rename_result_async(self, session: Session, response: Optional[WorkspaceEdit]) -> None: if not response: return session.window.status_message('Nothing to rename') changes = parse_workspace_edit(response) file_count = len(changes.keys()) if file_count == 1: - session.apply_parsed_workspace_edits(changes, preserve_tabs) + session.apply_parsed_workspace_edits(changes, True) return total_changes = sum(map(len, changes.values())) message = "Replace {} occurrences across {} files?".format(total_changes, file_count) choice = sublime.yes_no_cancel_dialog(message, "Replace", "Preview", title="Rename") if choice == sublime.DIALOG_YES: - session.apply_parsed_workspace_edits(changes, preserve_tabs) + session.apply_parsed_workspace_edits(changes, True) elif choice == sublime.DIALOG_NO: - self._render_rename_panel(response, changes, total_changes, file_count, session.config.name, preserve_tabs) + self._render_rename_panel(response, changes, total_changes, file_count, session.config.name) - def _on_prepare_result(self, pos: int, preserve_tabs: bool, response: Optional[PrepareRenameResult]) -> None: + def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None: if response is None: sublime.error_message("The current selection cannot be renamed") return @@ -217,7 +215,7 @@ def _on_prepare_result(self, pos: int, preserve_tabs: bool, response: Optional[P pos = range_to_region(response["range"], self.view).a # type: ignore else: placeholder = self.view.substr(self.view.word(pos)) - args = {"placeholder": placeholder, "point": pos, "preserve_tabs": preserve_tabs} + args = {"placeholder": placeholder, "point": pos} self.view.run_command("lsp_symbol_rename", args) def _on_prepare_error(self, error: Any) -> None: @@ -236,8 +234,7 @@ def _render_rename_panel( changes_per_uri: WorkspaceChanges, total_changes: int, file_count: int, - session_name: str, - preserve_tabs: bool + session_name: str ) -> None: wm = windows.lookup(self.view.window()) if not wm: @@ -299,7 +296,7 @@ def _render_rename_panel( 'commands': [ [ 'lsp_apply_workspace_edit', - {'session_name': session_name, 'edit': workspace_edit, 'preserve_tabs': preserve_tabs} + {'session_name': session_name, 'edit': workspace_edit} ], [ 'hide_panel', diff --git a/sublime-package.json b/sublime-package.json index 40b959870..356bd5c9b 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -757,6 +757,22 @@ }, "uniqueItems": true, "markdownDescription": "Determines ranges which initially should be folded when a document is opened, provided that the language server has support for this." + }, + "refactoring_auto_save": { + "type": "string", + "enum": [ + "always", + "preserve", + "preserve_opened", + "never" + ], + "markdownEnumDescriptions": [ + "Save all affected files", + "Only save files that didn't have unsaved changes beforehand", + "Only save opened files that didn't have unsaved changes beforehand and open other files that were affected by the refactoring", + "Never save files automatically" + ], + "markdownDescription": "Controls if files that were part of a refactoring (e.g. rename) are saved automatically." } }, "additionalProperties": false From 37d1f66ffa8f454fe1300f8ca7cf871a973a2ec7 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 26 Mar 2024 17:19:26 +0100 Subject: [PATCH 04/21] Missed something --- plugin/session_buffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 56bc403a9..deb5f85c5 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -168,7 +168,7 @@ def _check_did_open(self, view: sublime.View) -> None: def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): - self.purge_changes_async(view) + self.purge_changes_async(view, suppress_requests=True) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False From 9c9493a7acc2428614f51c5c34a7168f8781902f Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 25 Mar 2024 22:29:28 +0100 Subject: [PATCH 05/21] Ensure didChange is never sent after didClose This fixes for example the Pyright warning LSP-pyright: Received change text document command for closed file when a file is saved and closed immediately after changes were applied. --- plugin/session_buffer.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 71febb56b..56bc403a9 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -166,8 +166,9 @@ def _check_did_open(self, view: sublime.View) -> None: self._do_document_link_async(view, version) self.session.notify_plugin_on_session_buffer_change(self) - def _check_did_close(self) -> None: + def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): + self.purge_changes_async(view) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False @@ -202,9 +203,9 @@ def remove_session_view(self, sv: SessionViewProtocol) -> None: self._clear_semantic_token_regions(sv.view) self.session_views.remove(sv) if len(self.session_views) == 0: - self._on_before_destroy() + self._on_before_destroy(sv.view) - def _on_before_destroy(self) -> None: + def _on_before_destroy(self, view: sublime.View) -> None: self.remove_all_inlay_hints() if self.has_capability("diagnosticProvider") and self.session.config.diagnostics_mode == "open_files": self.session.m_textDocument_publishDiagnostics({'uri': self._last_known_uri, 'diagnostics': []}) @@ -216,7 +217,7 @@ def _on_before_destroy(self) -> None: # in unregistering ourselves from the session. if not self.session.exiting: # Only send textDocument/didClose when we are the only view left (i.e. there are no other clones). - self._check_did_close() + self._check_did_close(view) self.session.unregister_session_buffer_async(self) def register_capability_async( @@ -308,7 +309,7 @@ def on_revert_async(self, view: sublime.View) -> None: on_reload_async = on_revert_async - def purge_changes_async(self, view: sublime.View) -> None: + def purge_changes_async(self, view: sublime.View, suppress_requests: bool = False) -> None: if self._pending_changes is None: return sync_kind = self.text_sync_kind() @@ -316,7 +317,7 @@ def purge_changes_async(self, view: sublime.View) -> None: return if sync_kind == TextDocumentSyncKind.Full: changes = None - version = view.change_count() + version = view.change_count() if view.is_valid() else self._pending_changes.version else: changes = self._pending_changes.changes version = self._pending_changes.version @@ -329,12 +330,14 @@ def purge_changes_async(self, view: sublime.View) -> None: finally: self._pending_changes = None self.session.notify_plugin_on_session_buffer_change(self) - sublime.set_timeout_async(lambda: self._on_after_change_async(view, version)) + sublime.set_timeout_async(lambda: self._on_after_change_async(view, version, suppress_requests)) - def _on_after_change_async(self, view: sublime.View, version: int) -> None: + def _on_after_change_async(self, view: sublime.View, version: int, suppress_requests: bool = False) -> None: if self._is_saving: self._has_changed_during_save = True return + if suppress_requests or not view.is_valid(): + return self._do_color_boxes_async(view, version) self.do_document_diagnostic_async(view, version) if self.session.config.diagnostics_mode == "workspace" and \ @@ -357,7 +360,7 @@ def on_pre_save_async(self, view: sublime.View) -> None: def on_post_save_async(self, view: sublime.View, new_uri: DocumentUri) -> None: self._is_saving = False if new_uri != self._last_known_uri: - self._check_did_close() + self._check_did_close(view) self._last_known_uri = new_uri self._check_did_open(view) else: From ba364a78d6ef2f086d1d4c7caea77889550307ab Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 26 Mar 2024 17:19:26 +0100 Subject: [PATCH 06/21] Missed something --- plugin/session_buffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 56bc403a9..deb5f85c5 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -168,7 +168,7 @@ def _check_did_open(self, view: sublime.View) -> None: def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): - self.purge_changes_async(view) + self.purge_changes_async(view, suppress_requests=True) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False From c211f9962ff432e45caab4846488d42fb5a288f1 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 30 Mar 2024 22:50:11 +0100 Subject: [PATCH 07/21] Add test --- tests/test_single_document.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 4d1bb10d3..d48083157 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -109,6 +109,19 @@ def test_did_change(self) -> 'Generator': } }) + def test_did_change_before_did_close(self) -> 'Generator': + assert self.view + self.view.window().run_command("chain", { + "commands": [ + ["insert", {"characters": "TEST"}], + ["save", {"async": False}], + ["close", {}] + ] + }) + yield from self.await_message('textDocument/didChange') + # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? + yield from self.await_message('textDocument/didClose') + def test_sends_save_with_purge(self) -> 'Generator': assert self.view self.view.settings().set("lsp_format_on_save", False) From b8b0d9b1c47c50ab9fe66eb39f0d3a36fb9b761c Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 30 Mar 2024 22:57:58 +0100 Subject: [PATCH 08/21] Maybe like this? --- tests/test_single_document.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index d48083157..6f80dd644 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -109,19 +109,6 @@ def test_did_change(self) -> 'Generator': } }) - def test_did_change_before_did_close(self) -> 'Generator': - assert self.view - self.view.window().run_command("chain", { - "commands": [ - ["insert", {"characters": "TEST"}], - ["save", {"async": False}], - ["close", {}] - ] - }) - yield from self.await_message('textDocument/didChange') - # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? - yield from self.await_message('textDocument/didClose') - def test_sends_save_with_purge(self) -> 'Generator': assert self.view self.view.settings().set("lsp_format_on_save", False) @@ -411,3 +398,19 @@ def test_will_save_wait_until(self) -> 'Generator': text = self.view.substr(sublime.Region(0, self.view.size())) self.assertEquals("BBB", text) yield from self.await_clear_view_and_save() + + +class AnotherDocumentTestCase(TextDocumentTestCase): + + def test_did_change_before_did_close(self) -> 'Generator': + assert self.view + self.view.window().run_command("chain", { + "commands": [ + ["insert", {"characters": "TEST"}], + ["save", {"async": False}], + ["close", {}] + ] + }) + yield from self.await_message('textDocument/didChange') + # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? + yield from self.await_message('textDocument/didClose') From d430b9f943885a7e19c2b30a90c6f458cba6fa13 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 30 Mar 2024 23:09:23 +0100 Subject: [PATCH 09/21] Try something else --- tests/test_single_document.py | 4 ++++ tests/testfile2.txt | 0 2 files changed, 4 insertions(+) create mode 100644 tests/testfile2.txt diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 6f80dd644..4ac3b4255 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -402,6 +402,10 @@ def test_will_save_wait_until(self) -> 'Generator': class AnotherDocumentTestCase(TextDocumentTestCase): + @classmethod + def get_test_name(cls) -> str: + return "testfile2" + def test_did_change_before_did_close(self) -> 'Generator': assert self.view self.view.window().run_command("chain", { diff --git a/tests/testfile2.txt b/tests/testfile2.txt new file mode 100644 index 000000000..e69de29bb From 0fc0e1e67817ea366acaac89942ccdc39c2a0a7e Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 31 Mar 2024 20:40:07 +0200 Subject: [PATCH 10/21] Simplify expression to save one unnecessary API call view.change_count() returns 0 if the view isn't valid anymore (closed), so we can simply use short-circuit evaluation for this and don't need the is_valid() API call. --- plugin/session_buffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index deb5f85c5..68f1828e8 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -317,7 +317,7 @@ def purge_changes_async(self, view: sublime.View, suppress_requests: bool = Fals return if sync_kind == TextDocumentSyncKind.Full: changes = None - version = view.change_count() if view.is_valid() else self._pending_changes.version + version = view.change_count() or self._pending_changes.version else: changes = self._pending_changes.changes version = self._pending_changes.version From 4dd2e91ee9f9bc6bf8b93082b5be4df7243b0c75 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 4 Apr 2024 13:22:47 +0200 Subject: [PATCH 11/21] Exempt Linux --- plugin/core/constants.py | 1 + plugin/session_buffer.py | 4 +++- tests/test_single_document.py | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugin/core/constants.py b/plugin/core/constants.py index e8a84f34e..5fc4fbfa5 100644 --- a/plugin/core/constants.py +++ b/plugin/core/constants.py @@ -11,6 +11,7 @@ ST_VERSION = int(sublime.version()) +ST_PLATFORM = sublime.platform() # Keys for View.add_regions HOVER_HIGHLIGHT_KEY = 'lsp_hover_highlight' diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 68f1828e8..97a0d4a15 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -1,5 +1,6 @@ from .core.constants import DOCUMENT_LINK_FLAGS from .core.constants import SEMANTIC_TOKEN_FLAGS +from .core.constants import ST_PLATFORM from .core.protocol import ColorInformation from .core.protocol import Diagnostic from .core.protocol import DocumentDiagnosticParams @@ -168,7 +169,8 @@ def _check_did_open(self, view: sublime.View) -> None: def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): - self.purge_changes_async(view, suppress_requests=True) + if ST_PLATFORM != 'linux': # https://github.com/sublimelsp/LSP/pull/2438 + self.purge_changes_async(view, suppress_requests=True) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 4ac3b4255..8a655a97c 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -9,6 +9,8 @@ from setup import YieldPromise import os import sublime +import sys +import unittest try: @@ -406,6 +408,9 @@ class AnotherDocumentTestCase(TextDocumentTestCase): def get_test_name(cls) -> str: return "testfile2" + # The fix from https://github.com/sublimelsp/LSP/pull/2438 oddly causes an unrelated test to fail on Linux, so it's + # only applied on Windows and macOS for now. + @unittest.skipIf(sys.platform.startswith("linux"), "not working as expected on Linux") def test_did_change_before_did_close(self) -> 'Generator': assert self.view self.view.window().run_command("chain", { From aca100ea67129a73b77e406e03ef839e1f7650bf Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 7 Apr 2024 08:06:14 +0200 Subject: [PATCH 12/21] Small tweak to save an API call --- plugin/session_buffer.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 97a0d4a15..1fbbc1925 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -338,19 +338,22 @@ def _on_after_change_async(self, view: sublime.View, version: int, suppress_requ if self._is_saving: self._has_changed_during_save = True return - if suppress_requests or not view.is_valid(): + if suppress_requests: return - self._do_color_boxes_async(view, version) - self.do_document_diagnostic_async(view, version) - if self.session.config.diagnostics_mode == "workspace" and \ - not self.session.workspace_diagnostics_pending_response and \ - self.session.has_capability('diagnosticProvider.workspaceDiagnostics'): - self._workspace_diagnostics_debouncer_async.debounce( - self.session.do_workspace_diagnostics_async, timeout_ms=WORKSPACE_DIAGNOSTICS_TIMEOUT) - self.do_semantic_tokens_async(view) - if userprefs().link_highlight_style in ("underline", "none"): - self._do_document_link_async(view, version) - self.do_inlay_hints_async(view) + try: + self._do_color_boxes_async(view, version) + self.do_document_diagnostic_async(view, version) + if self.session.config.diagnostics_mode == "workspace" and \ + not self.session.workspace_diagnostics_pending_response and \ + self.session.has_capability('diagnosticProvider.workspaceDiagnostics'): + self._workspace_diagnostics_debouncer_async.debounce( + self.session.do_workspace_diagnostics_async, timeout_ms=WORKSPACE_DIAGNOSTICS_TIMEOUT) + self.do_semantic_tokens_async(view) + if userprefs().link_highlight_style in ("underline", "none"): + self._do_document_link_async(view, version) + self.do_inlay_hints_async(view) + except MissingUriError: + pass def on_pre_save_async(self, view: sublime.View) -> None: self._is_saving = True From 740c0cbf0cbd506259de7c93704a91318bb52942 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 10 Apr 2024 18:41:10 +0200 Subject: [PATCH 13/21] Revert "Exempt Linux" This reverts commit 4dd2e91ee9f9bc6bf8b93082b5be4df7243b0c75. --- plugin/core/constants.py | 1 - plugin/session_buffer.py | 4 +--- tests/test_single_document.py | 5 ----- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/plugin/core/constants.py b/plugin/core/constants.py index 5fc4fbfa5..e8a84f34e 100644 --- a/plugin/core/constants.py +++ b/plugin/core/constants.py @@ -11,7 +11,6 @@ ST_VERSION = int(sublime.version()) -ST_PLATFORM = sublime.platform() # Keys for View.add_regions HOVER_HIGHLIGHT_KEY = 'lsp_hover_highlight' diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py index 1fbbc1925..e2c6907de 100644 --- a/plugin/session_buffer.py +++ b/plugin/session_buffer.py @@ -1,6 +1,5 @@ from .core.constants import DOCUMENT_LINK_FLAGS from .core.constants import SEMANTIC_TOKEN_FLAGS -from .core.constants import ST_PLATFORM from .core.protocol import ColorInformation from .core.protocol import Diagnostic from .core.protocol import DocumentDiagnosticParams @@ -169,8 +168,7 @@ def _check_did_open(self, view: sublime.View) -> None: def _check_did_close(self, view: sublime.View) -> None: if self.opened and self.should_notify_did_close(): - if ST_PLATFORM != 'linux': # https://github.com/sublimelsp/LSP/pull/2438 - self.purge_changes_async(view, suppress_requests=True) + self.purge_changes_async(view, suppress_requests=True) self.session.send_notification(did_close(uri=self._last_known_uri)) self.opened = False diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 8a655a97c..4ac3b4255 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -9,8 +9,6 @@ from setup import YieldPromise import os import sublime -import sys -import unittest try: @@ -408,9 +406,6 @@ class AnotherDocumentTestCase(TextDocumentTestCase): def get_test_name(cls) -> str: return "testfile2" - # The fix from https://github.com/sublimelsp/LSP/pull/2438 oddly causes an unrelated test to fail on Linux, so it's - # only applied on Windows and macOS for now. - @unittest.skipIf(sys.platform.startswith("linux"), "not working as expected on Linux") def test_did_change_before_did_close(self) -> 'Generator': assert self.view self.view.window().run_command("chain", { From dd66a6acc304d50e69dcd444e0acb8db2166a96c Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 10 Apr 2024 18:47:39 +0200 Subject: [PATCH 14/21] Fix failing test on Linux --- tests/test_single_document.py | 93 ++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 4ac3b4255..673f1842f 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -84,31 +84,6 @@ def test_did_close(self) -> 'Generator': self.view.close() yield from self.await_message("textDocument/didClose") - def test_did_change(self) -> 'Generator': - assert self.view - self.maxDiff = None - self.insert_characters("A") - yield from self.await_message("textDocument/didChange") - # multiple changes are batched into one didChange notification - self.insert_characters("B\n") - self.insert_characters("🙂\n") - self.insert_characters("D") - promise = YieldPromise() - yield from self.await_message("textDocument/didChange", promise) - self.assertEqual(promise.result(), { - 'contentChanges': [ - {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 1}, 'end': {'line': 0, 'character': 1}}, 'text': 'B'}, # noqa - {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 2}}, 'text': '\n'}, # noqa - {'rangeLength': 0, 'range': {'start': {'line': 1, 'character': 0}, 'end': {'line': 1, 'character': 0}}, 'text': '🙂'}, # noqa - # Note that this is character offset (2) is correct (UTF-16). - {'rangeLength': 0, 'range': {'start': {'line': 1, 'character': 2}, 'end': {'line': 1, 'character': 2}}, 'text': '\n'}, # noqa - {'rangeLength': 0, 'range': {'start': {'line': 2, 'character': 0}, 'end': {'line': 2, 'character': 0}}, 'text': 'D'}], # noqa - 'textDocument': { - 'version': self.view.change_count(), - 'uri': filename_to_uri(TEST_FILE_PATH) - } - }) - def test_sends_save_with_purge(self) -> 'Generator': assert self.view self.view.settings().set("lsp_format_on_save", False) @@ -371,6 +346,54 @@ def test_progress(self) -> 'Generator': self.assertEqual(result, {"general": "kenobi"}) +class SingleDocumentTestCase2(TextDocumentTestCase): + + def test_did_change(self) -> 'Generator': + assert self.view + self.maxDiff = None + self.insert_characters("A") + yield from self.await_message("textDocument/didChange") + # multiple changes are batched into one didChange notification + self.insert_characters("B\n") + self.insert_characters("🙂\n") + self.insert_characters("D") + promise = YieldPromise() + yield from self.await_message("textDocument/didChange", promise) + self.assertEqual(promise.result(), { + 'contentChanges': [ + {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 1}, 'end': {'line': 0, 'character': 1}}, 'text': 'B'}, # noqa + {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 2}}, 'text': '\n'}, # noqa + {'rangeLength': 0, 'range': {'start': {'line': 1, 'character': 0}, 'end': {'line': 1, 'character': 0}}, 'text': '🙂'}, # noqa + # Note that this is character offset (2) is correct (UTF-16). + {'rangeLength': 0, 'range': {'start': {'line': 1, 'character': 2}, 'end': {'line': 1, 'character': 2}}, 'text': '\n'}, # noqa + {'rangeLength': 0, 'range': {'start': {'line': 2, 'character': 0}, 'end': {'line': 2, 'character': 0}}, 'text': 'D'}], # noqa + 'textDocument': { + 'version': self.view.change_count(), + 'uri': filename_to_uri(TEST_FILE_PATH) + } + }) + + +class SingleDocumentTestCase3(TextDocumentTestCase): + + @classmethod + def get_test_name(cls) -> str: + return "testfile2" + + def test_did_change_before_did_close(self) -> 'Generator': + assert self.view + self.view.window().run_command("chain", { + "commands": [ + ["insert", {"characters": "TEST"}], + ["save", {"async": False}], + ["close", {}] + ] + }) + yield from self.await_message('textDocument/didChange') + # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? + yield from self.await_message('textDocument/didClose') + + class WillSaveWaitUntilTestCase(TextDocumentTestCase): @classmethod @@ -398,23 +421,3 @@ def test_will_save_wait_until(self) -> 'Generator': text = self.view.substr(sublime.Region(0, self.view.size())) self.assertEquals("BBB", text) yield from self.await_clear_view_and_save() - - -class AnotherDocumentTestCase(TextDocumentTestCase): - - @classmethod - def get_test_name(cls) -> str: - return "testfile2" - - def test_did_change_before_did_close(self) -> 'Generator': - assert self.view - self.view.window().run_command("chain", { - "commands": [ - ["insert", {"characters": "TEST"}], - ["save", {"async": False}], - ["close", {}] - ] - }) - yield from self.await_message('textDocument/didChange') - # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? - yield from self.await_message('textDocument/didClose') From 43ede82dba41c96e26c035bd4809ef6714782304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=80=D0=B0=D0=B3=20=D0=9D=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=9B?= Date: Fri, 12 Apr 2024 11:59:48 +0200 Subject: [PATCH 15/21] actually this test passes locally with this line uncommented --- tests/test_single_document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 673f1842f..64d76e85e 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -390,7 +390,7 @@ def test_did_change_before_did_close(self) -> 'Generator': ] }) yield from self.await_message('textDocument/didChange') - # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? + yield from self.await_message('textDocument/didSave') yield from self.await_message('textDocument/didClose') From b5ee72eae57baefe4f9d4876611fce7591824060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=80=D0=B0=D0=B3=20=D0=9D=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=9B?= Date: Fri, 12 Apr 2024 12:03:49 +0200 Subject: [PATCH 16/21] Revert, apparently it fails on the CI... This reverts commit 43ede82dba41c96e26c035bd4809ef6714782304. --- tests/test_single_document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 64d76e85e..673f1842f 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -390,7 +390,7 @@ def test_did_change_before_did_close(self) -> 'Generator': ] }) yield from self.await_message('textDocument/didChange') - yield from self.await_message('textDocument/didSave') + # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? yield from self.await_message('textDocument/didClose') From 11c5ecbc82423bbcd8f08dc7243b3c20141a5fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=80=D0=B0=D0=B3=20=D0=9D=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=9B?= Date: Fri, 12 Apr 2024 12:18:30 +0200 Subject: [PATCH 17/21] try a slightly different approach just to see... test pass locally --- tests/test_single_document.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index 673f1842f..b2988fb34 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -382,15 +382,11 @@ def get_test_name(cls) -> str: def test_did_change_before_did_close(self) -> 'Generator': assert self.view - self.view.window().run_command("chain", { - "commands": [ - ["insert", {"characters": "TEST"}], - ["save", {"async": False}], - ["close", {}] - ] - }) + self.insert_characters("TEST") + self.view.window().run_command("save", {'async': False}) + self.view.window().run_command("close", {}) yield from self.await_message('textDocument/didChange') - # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? + yield from self.await_message('textDocument/didSave') yield from self.await_message('textDocument/didClose') From 2b5f56f2e25496a17d377b01f613aae63622bad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=80=D0=B0=D0=B3=20=D0=9D=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=9B?= Date: Fri, 12 Apr 2024 12:21:17 +0200 Subject: [PATCH 18/21] Revert "try a slightly different approach just to see... test pass locally" the test still fail on the CI This reverts commit 11c5ecbc82423bbcd8f08dc7243b3c20141a5fc7. --- tests/test_single_document.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_single_document.py b/tests/test_single_document.py index b2988fb34..673f1842f 100644 --- a/tests/test_single_document.py +++ b/tests/test_single_document.py @@ -382,11 +382,15 @@ def get_test_name(cls) -> str: def test_did_change_before_did_close(self) -> 'Generator': assert self.view - self.insert_characters("TEST") - self.view.window().run_command("save", {'async': False}) - self.view.window().run_command("close", {}) + self.view.window().run_command("chain", { + "commands": [ + ["insert", {"characters": "TEST"}], + ["save", {"async": False}], + ["close", {}] + ] + }) yield from self.await_message('textDocument/didChange') - yield from self.await_message('textDocument/didSave') + # yield from self.await_message('textDocument/didSave') # TODO why is this not sent? yield from self.await_message('textDocument/didClose') From d6161e36eef603b203e7c5d784f3d0535dd7df50 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 21 Apr 2024 19:35:21 +0200 Subject: [PATCH 19/21] Add default value into schema --- sublime-package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/sublime-package.json b/sublime-package.json index 356bd5c9b..81c687770 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -772,6 +772,7 @@ "Only save opened files that didn't have unsaved changes beforehand and open other files that were affected by the refactoring", "Never save files automatically" ], + "default": "never", "markdownDescription": "Controls if files that were part of a refactoring (e.g. rename) are saved automatically." } }, From 1e3291b314de7f35747bcccaeb25b3f1e86bec41 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 21 Apr 2024 19:37:39 +0200 Subject: [PATCH 20/21] Update to make it work with new rename panel --- plugin/rename.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/rename.py b/plugin/rename.py index 1dfbdf491..258c176c8 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -298,7 +298,7 @@ def _render_rename_panel( 'commands': [ [ 'lsp_apply_workspace_edit', - {'session_name': session_name, 'edit': workspace_edit} + {'session_name': session_name, 'edit': workspace_edit, 'is_refactoring': True} ], [ 'hide_panel', From ea58461c60b831e17a48e191838539704a566749 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 25 Apr 2024 11:15:39 +0200 Subject: [PATCH 21/21] Resolve more merge conflicts --- plugin/core/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index 06b88648c..7879ddacf 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -1857,7 +1857,7 @@ def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> int: actions |= ViewStateActions.Save return actions - def _set_view_state(self, actions: int, view: Optional[sublime.View]) -> None: + def _set_view_state(self, actions: int, view: sublime.View | None) -> None: if not view: return should_save = bool(actions & ViewStateActions.Save)