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 e277c542f..d5ee364ae 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -33,10 +33,10 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | 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 bff4245d2..7879ddacf 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -85,6 +85,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 @@ -111,7 +112,7 @@ from abc import ABCMeta from abc import abstractmethod from abc import abstractproperty -from enum import IntEnum +from enum import IntEnum, IntFlag from typing import Any, Callable, Generator, List, Protocol, TypeVar from typing import cast from typing_extensions import TypeAlias, TypeGuard @@ -126,6 +127,11 @@ T = TypeVar('T') +class ViewStateActions(IntFlag): + Close = 2 + Save = 1 + + def is_workspace_full_document_diagnostic_report( report: WorkspaceDocumentDiagnosticReport ) -> TypeGuard[WorkspaceFullDocumentDiagnosticReport]: @@ -1773,7 +1779,8 @@ def _apply_code_action_async( self.window.status_message(f"Failed to apply code action: {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: ExecuteCommandParams = { @@ -1785,20 +1792,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) -> 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)) + return self.apply_parsed_workspace_edits(parse_workspace_edit(edit), is_refactoring) - def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> 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: list[Promise[None]] = [] + auto_save = userprefs().refactoring_auto_save if is_refactoring else 'never' for uri, (edits, view_version) in changes.items(): + 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_view_state, view_state_actions)) ) return Promise.all(promises) \ .then(lambda _: self._set_selected_sheets(selected_sheets)) \ @@ -1806,11 +1816,59 @@ def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> Promise[Non def _apply_text_edits( self, edits: list[TextEdit], view_version: int | None, uri: str, view: sublime.View | None - ) -> None: + ) -> sublime.View | None: if view is None or not view.is_valid(): print(f'LSP: ignoring edits due to no view for uri: {uri}') - return + return None apply_text_edits(view, edits, required_view_version=view_version) + return view + + 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': + 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: + 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: sublime.View | None) -> None: + if not view: + return + 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 50793d625..901d3c565 100644 --- a/plugin/core/types.py +++ b/plugin/core/types.py @@ -222,6 +222,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) @@ -265,6 +266,7 @@ def r(name: str, default: 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/edit.py b/plugin/edit.py index 4f711b5dc..b5862221c 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -30,12 +30,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, 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)) + 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 73a8d08d9..309d0e5d4 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -194,13 +194,13 @@ def _on_rename_result_async(self, session: Session, response: WorkspaceEdit | No 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, True) return total_changes = sum(map(len, changes.values())) message = f"Replace {total_changes} occurrences across {file_count} files?" 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, True) elif choice == sublime.DIALOG_NO: self._render_rename_panel(response, changes, total_changes, file_count, session.config.name) @@ -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', diff --git a/sublime-package.json b/sublime-package.json index 40b959870..81c687770 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -757,6 +757,23 @@ }, "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" + ], + "default": "never", + "markdownDescription": "Controls if files that were part of a refactoring (e.g. rename) are saved automatically." } }, "additionalProperties": false