Skip to content

Commit

Permalink
Add setting to save modified files after applying a refactoring (#2433)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
jwortmann and predragnikolic authored May 3, 2024
1 parent cdb2430 commit 2a47052
Show file tree
Hide file tree
Showing 7 changed files with 99 additions and 14 deletions.
8 changes: 8 additions & 0 deletions LSP.sublime-settings
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/keyboard_shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>With args: `{"only_kinds": ["refactor"]}`.
| Run Source Action | unbound | `lsp_code_actions`<br>With args: `{"only_kinds": ["source"]}`.
| Save All | unbound | `lsp_save_all`<br>Supports optional args `{"only_files": true}` - to ignore buffers which have no associated file on disk.
| 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.
| Show Call Hierarchy | unbound | `lsp_call_hierarchy`
| Show Type Hierarchy | unbound | `lsp_type_hierarchy`
| Signature Help | <kbd>ctrl</kbd> <kbd>alt</kbd> <kbd>space</kbd> | `lsp_signature_help_show`
| Toggle Diagnostics Panel | <kbd>ctrl</kbd> <kbd>alt</kbd> <kbd>m</kbd> | `lsp_show_diagnostics_panel`
| Toggle Inlay Hints | unbound | `lsp_toggle_inlay_hints`<br>Supports optional args: `{"enable": true/false}`.
| Toggle Inlay Hints | unbound | `lsp_toggle_inlay_hints`<br>Supports optional args: `{"enable": true | false}`.
| Toggle Log Panel | unbound | `lsp_toggle_server_panel`
72 changes: 65 additions & 7 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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 = {
Expand All @@ -1785,32 +1792,83 @@ 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)) \
.then(lambda _: self._set_focused_sheet(active_sheet))

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):
Expand Down
2 changes: 2 additions & 0 deletions plugin/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions plugin/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 3 additions & 3 deletions plugin/rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions sublime-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 2a47052

Please sign in to comment.