Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add setting to save modified files after applying a refactoring #2433

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2720f1b
Add argument for rename command to preserve tab states of modified files
jwortmann Mar 16, 2024
5f7bc61
Ensure didChange is never sent after didClose
jwortmann Mar 25, 2024
fdcf57c
Convert to user setting
jwortmann Mar 26, 2024
37d1f66
Missed something
jwortmann Mar 26, 2024
9c9493a
Ensure didChange is never sent after didClose
jwortmann Mar 25, 2024
ba364a7
Missed something
jwortmann Mar 26, 2024
c211f99
Add test
jwortmann Mar 30, 2024
b8b0d9b
Maybe like this?
jwortmann Mar 30, 2024
d430b9f
Try something else
jwortmann Mar 30, 2024
0fc0e1e
Simplify expression to save one unnecessary API call
jwortmann Mar 31, 2024
4dd2e91
Exempt Linux
jwortmann Apr 4, 2024
aca100e
Small tweak to save an API call
jwortmann Apr 7, 2024
740c0cb
Revert "Exempt Linux"
jwortmann Apr 10, 2024
dd66a6a
Fix failing test on Linux
jwortmann Apr 10, 2024
43ede82
actually this test passes locally with this line uncommented
predragnikolic Apr 12, 2024
b5ee72e
Revert, apparently it fails on the CI...
predragnikolic Apr 12, 2024
11c5ecb
try a slightly different approach just to see... test pass locally
predragnikolic Apr 12, 2024
2b5f56f
Revert "try a slightly different approach just to see... test pass lo…
predragnikolic Apr 12, 2024
7b71db2
Merge branch 'fix-notification-order' into workspace-edit-preserve-tabs
jwortmann Apr 14, 2024
572638e
Merge branch 'main' into workspace-edit-preserve-tabs
jwortmann Apr 20, 2024
e1e0d1b
Merge branch 'main' into workspace-edit-preserve-tabs
jwortmann Apr 21, 2024
d6161e3
Add default value into schema
jwortmann Apr 21, 2024
1e3291b
Update to make it work with new rename panel
jwortmann Apr 21, 2024
18cbbf4
Merge branch 'main' into workspace-edit-preserve-tabs
jwortmann Apr 25, 2024
ea58461
Resolve more merge conflicts
jwortmann Apr 25, 2024
2840808
Merge branch 'main' into workspace-edit-preserve-tabs
predragnikolic May 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
predragnikolic marked this conversation as resolved.
Show resolved Hide resolved
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
Loading