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