Skip to content

Commit 2a47052

Browse files
Add setting to save modified files after applying a refactoring (#2433)
* Add argument for rename command to preserve tab states of modified files * Ensure didChange is never sent after didClose This fixes for example the Pyright warning LSP-pyright: Received change text document command for closed file <URI> when a file is saved and closed immediately after changes were applied. * Convert to user setting * Missed something * Ensure didChange is never sent after didClose This fixes for example the Pyright warning LSP-pyright: Received change text document command for closed file <URI> when a file is saved and closed immediately after changes were applied. * Missed something * Add test * Maybe like this? * Try something else * 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. * Exempt Linux * Small tweak to save an API call * Revert "Exempt Linux" This reverts commit 4dd2e91. * Fix failing test on Linux * actually this test passes locally with this line uncommented * Revert, apparently it fails on the CI... This reverts commit 43ede82. * try a slightly different approach just to see... test pass locally * Revert "try a slightly different approach just to see... test pass locally" the test still fail on the CI This reverts commit 11c5ecb. * Add default value into schema * Update to make it work with new rename panel * Resolve more merge conflicts --------- Co-authored-by: Предраг Николић <[email protected]>
1 parent cdb2430 commit 2a47052

File tree

7 files changed

+99
-14
lines changed

7 files changed

+99
-14
lines changed

LSP.sublime-settings

+8
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@
223223
// "region",
224224
],
225225

226+
// Controls if files that were part of a refactoring (e.g. rename) are saved automatically:
227+
// "always" - save all affected files
228+
// "preserve" - only save files that didn't have unsaved changes beforehand
229+
// "preserve_opened" - only save opened files that didn't have unsaved changes beforehand
230+
// and open other files that were affected by the refactoring
231+
// "never" - never save files automatically
232+
"refactoring_auto_save": "never",
233+
226234
// --- Debugging ----------------------------------------------------------------------
227235

228236
// Show verbose debug messages in the sublime console.

docs/src/keyboard_shortcuts.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin
3333
| Run Code Lens | unbound | `lsp_code_lens`
3434
| Run Refactor Action | unbound | `lsp_code_actions`<br>With args: `{"only_kinds": ["refactor"]}`.
3535
| Run Source Action | unbound | `lsp_code_actions`<br>With args: `{"only_kinds": ["source"]}`.
36-
| Save All | unbound | `lsp_save_all`<br>Supports optional args `{"only_files": true}` - to ignore buffers which have no associated file on disk.
36+
| Save All | unbound | `lsp_save_all`<br>Supports optional args `{"only_files": true | false}` - whether to ignore buffers which have no associated file on disk.
3737
| Show Call Hierarchy | unbound | `lsp_call_hierarchy`
3838
| Show Type Hierarchy | unbound | `lsp_type_hierarchy`
3939
| Signature Help | <kbd>ctrl</kbd> <kbd>alt</kbd> <kbd>space</kbd> | `lsp_signature_help_show`
4040
| Toggle Diagnostics Panel | <kbd>ctrl</kbd> <kbd>alt</kbd> <kbd>m</kbd> | `lsp_show_diagnostics_panel`
41-
| Toggle Inlay Hints | unbound | `lsp_toggle_inlay_hints`<br>Supports optional args: `{"enable": true/false}`.
41+
| Toggle Inlay Hints | unbound | `lsp_toggle_inlay_hints`<br>Supports optional args: `{"enable": true | false}`.
4242
| Toggle Log Panel | unbound | `lsp_toggle_server_panel`

plugin/core/sessions.py

+65-7
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
from .protocol import WorkspaceEdit
8686
from .settings import client_configs
8787
from .settings import globalprefs
88+
from .settings import userprefs
8889
from .transports import Transport
8990
from .transports import TransportCallbacks
9091
from .types import Capabilities
@@ -111,7 +112,7 @@
111112
from abc import ABCMeta
112113
from abc import abstractmethod
113114
from abc import abstractproperty
114-
from enum import IntEnum
115+
from enum import IntEnum, IntFlag
115116
from typing import Any, Callable, Generator, List, Protocol, TypeVar
116117
from typing import cast
117118
from typing_extensions import TypeAlias, TypeGuard
@@ -126,6 +127,11 @@
126127
T = TypeVar('T')
127128

128129

130+
class ViewStateActions(IntFlag):
131+
Close = 2
132+
Save = 1
133+
134+
129135
def is_workspace_full_document_diagnostic_report(
130136
report: WorkspaceDocumentDiagnosticReport
131137
) -> TypeGuard[WorkspaceFullDocumentDiagnosticReport]:
@@ -1773,7 +1779,8 @@ def _apply_code_action_async(
17731779
self.window.status_message(f"Failed to apply code action: {code_action}")
17741780
return Promise.resolve(None)
17751781
edit = code_action.get("edit")
1776-
promise = self.apply_workspace_edit_async(edit) if edit else Promise.resolve(None)
1782+
is_refactoring = code_action.get('kind') == CodeActionKind.Refactor
1783+
promise = self.apply_workspace_edit_async(edit, is_refactoring) if edit else Promise.resolve(None)
17771784
command = code_action.get("command")
17781785
if command is not None:
17791786
execute_command: ExecuteCommandParams = {
@@ -1785,32 +1792,83 @@ def _apply_code_action_async(
17851792
return promise.then(lambda _: self.execute_command(execute_command, progress=False, view=view))
17861793
return promise
17871794

1788-
def apply_workspace_edit_async(self, edit: WorkspaceEdit) -> Promise[None]:
1795+
def apply_workspace_edit_async(self, edit: WorkspaceEdit, is_refactoring: bool = False) -> Promise[None]:
17891796
"""
17901797
Apply workspace edits, and return a promise that resolves on the async thread again after the edits have been
17911798
applied.
17921799
"""
1793-
return self.apply_parsed_workspace_edits(parse_workspace_edit(edit))
1800+
return self.apply_parsed_workspace_edits(parse_workspace_edit(edit), is_refactoring)
17941801

1795-
def apply_parsed_workspace_edits(self, changes: WorkspaceChanges) -> Promise[None]:
1802+
def apply_parsed_workspace_edits(self, changes: WorkspaceChanges, is_refactoring: bool = False) -> Promise[None]:
17961803
active_sheet = self.window.active_sheet()
17971804
selected_sheets = self.window.selected_sheets()
17981805
promises: list[Promise[None]] = []
1806+
auto_save = userprefs().refactoring_auto_save if is_refactoring else 'never'
17991807
for uri, (edits, view_version) in changes.items():
1808+
view_state_actions = self._get_view_state_actions(uri, auto_save)
18001809
promises.append(
18011810
self.open_uri_async(uri).then(functools.partial(self._apply_text_edits, edits, view_version, uri))
1811+
.then(functools.partial(self._set_view_state, view_state_actions))
18021812
)
18031813
return Promise.all(promises) \
18041814
.then(lambda _: self._set_selected_sheets(selected_sheets)) \
18051815
.then(lambda _: self._set_focused_sheet(active_sheet))
18061816

18071817
def _apply_text_edits(
18081818
self, edits: list[TextEdit], view_version: int | None, uri: str, view: sublime.View | None
1809-
) -> None:
1819+
) -> sublime.View | None:
18101820
if view is None or not view.is_valid():
18111821
print(f'LSP: ignoring edits due to no view for uri: {uri}')
1812-
return
1822+
return None
18131823
apply_text_edits(view, edits, required_view_version=view_version)
1824+
return view
1825+
1826+
def _get_view_state_actions(self, uri: DocumentUri, auto_save: str) -> int:
1827+
"""
1828+
Determine the required actions for a view after applying a WorkspaceEdit, depending on the
1829+
"refactoring_auto_save" user setting. Returns a bitwise combination of ViewStateActions.Save and
1830+
ViewStateActions.Close, or 0 if no action is necessary.
1831+
"""
1832+
if auto_save == 'never':
1833+
return 0 # Never save or close automatically
1834+
scheme, filepath = parse_uri(uri)
1835+
if scheme != 'file':
1836+
return 0 # Can't save or close unsafed buffers (and other schemes) without user dialog
1837+
view = self.window.find_open_file(filepath)
1838+
if view:
1839+
is_opened = True
1840+
is_dirty = view.is_dirty()
1841+
else:
1842+
is_opened = False
1843+
is_dirty = False
1844+
actions = 0
1845+
if auto_save == 'always':
1846+
actions |= ViewStateActions.Save # Always save
1847+
if not is_opened:
1848+
actions |= ViewStateActions.Close # Close if file was previously closed
1849+
elif auto_save == 'preserve':
1850+
if not is_dirty:
1851+
actions |= ViewStateActions.Save # Only save if file didn't have unsaved changes
1852+
if not is_opened:
1853+
actions |= ViewStateActions.Close # Close if file was previously closed
1854+
elif auto_save == 'preserve_opened':
1855+
if is_opened and not is_dirty:
1856+
# Only save if file was already open and didn't have unsaved changes, but never close
1857+
actions |= ViewStateActions.Save
1858+
return actions
1859+
1860+
def _set_view_state(self, actions: int, view: sublime.View | None) -> None:
1861+
if not view:
1862+
return
1863+
should_save = bool(actions & ViewStateActions.Save)
1864+
should_close = bool(actions & ViewStateActions.Close)
1865+
if should_save and view.is_dirty():
1866+
# The save operation must be blocking in case the tab should be closed afterwards
1867+
view.run_command('save', {'async': not should_close, 'quiet': True})
1868+
if should_close and not view.is_dirty():
1869+
if view != self.window.active_view():
1870+
self.window.focus_view(view)
1871+
self.window.run_command('close')
18141872

18151873
def _set_selected_sheets(self, sheets: list[sublime.Sheet]) -> None:
18161874
if len(sheets) > 1 and len(self.window.selected_sheets()) != len(sheets):

plugin/core/types.py

+2
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ class Settings:
222222
only_show_lsp_completions = cast(bool, None)
223223
popup_max_characters_height = cast(int, None)
224224
popup_max_characters_width = cast(int, None)
225+
refactoring_auto_save = cast(str, None)
225226
semantic_highlighting = cast(bool, None)
226227
show_code_actions = cast(str, None)
227228
show_code_lens = cast(str, None)
@@ -265,6 +266,7 @@ def r(name: str, default: bool | int | str | list | dict) -> None:
265266
r("completion_insert_mode", 'insert')
266267
r("popup_max_characters_height", 1000)
267268
r("popup_max_characters_width", 120)
269+
r("refactoring_auto_save", "never")
268270
r("semantic_highlighting", False)
269271
r("show_code_actions", "annotation")
270272
r("show_code_lens", "annotation")

plugin/edit.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat
3030

3131
class LspApplyWorkspaceEditCommand(LspWindowCommand):
3232

33-
def run(self, session_name: str, edit: WorkspaceEdit) -> None:
33+
def run(self, session_name: str, edit: WorkspaceEdit, is_refactoring: bool = False) -> None:
3434
session = self.session_by_name(session_name)
3535
if not session:
3636
debug('Could not find session', session_name, 'required to apply WorkspaceEdit')
3737
return
38-
sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit))
38+
sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit, is_refactoring))
3939

4040

4141
class LspApplyDocumentEditCommand(sublime_plugin.TextCommand):

plugin/rename.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,13 @@ def _on_rename_result_async(self, session: Session, response: WorkspaceEdit | No
194194
changes = parse_workspace_edit(response)
195195
file_count = len(changes.keys())
196196
if file_count == 1:
197-
session.apply_parsed_workspace_edits(changes)
197+
session.apply_parsed_workspace_edits(changes, True)
198198
return
199199
total_changes = sum(map(len, changes.values()))
200200
message = f"Replace {total_changes} occurrences across {file_count} files?"
201201
choice = sublime.yes_no_cancel_dialog(message, "Replace", "Preview", title="Rename")
202202
if choice == sublime.DIALOG_YES:
203-
session.apply_parsed_workspace_edits(changes)
203+
session.apply_parsed_workspace_edits(changes, True)
204204
elif choice == sublime.DIALOG_NO:
205205
self._render_rename_panel(response, changes, total_changes, file_count, session.config.name)
206206

@@ -298,7 +298,7 @@ def _render_rename_panel(
298298
'commands': [
299299
[
300300
'lsp_apply_workspace_edit',
301-
{'session_name': session_name, 'edit': workspace_edit}
301+
{'session_name': session_name, 'edit': workspace_edit, 'is_refactoring': True}
302302
],
303303
[
304304
'hide_panel',

sublime-package.json

+17
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,23 @@
757757
},
758758
"uniqueItems": true,
759759
"markdownDescription": "Determines ranges which initially should be folded when a document is opened, provided that the language server has support for this."
760+
},
761+
"refactoring_auto_save": {
762+
"type": "string",
763+
"enum": [
764+
"always",
765+
"preserve",
766+
"preserve_opened",
767+
"never"
768+
],
769+
"markdownEnumDescriptions": [
770+
"Save all affected files",
771+
"Only save files that didn't have unsaved changes beforehand",
772+
"Only save opened files that didn't have unsaved changes beforehand and open other files that were affected by the refactoring",
773+
"Never save files automatically"
774+
],
775+
"default": "never",
776+
"markdownDescription": "Controls if files that were part of a refactoring (e.g. rename) are saved automatically."
760777
}
761778
},
762779
"additionalProperties": false

0 commit comments

Comments
 (0)