Repository: sublimelsp/LSP

Issue:
Suggestions for `Source Action...` menu handling
From https://github.com/sublimelsp/LSP/pull/2064#issuecomment-1256289124:
>  -  remove the "Source Action..." entry from the context menu (because source actions are not bound to a particular point or selection, but apply to the entire file)
> -   Add "LSP: Source Actions..." to the "Edit" main menu, and either add another argument full_range: bool = False (or so) for the code actions command, which would be activated for the source actions menu entry, or alternatively derive this from whether only_kinds includes "source". So that the request for source actions would use the range of the entire file and also include all of the diagnostics in the file (this could be useful for the source.fixAll kind)



Discussion:
@jwortmann 

Your two points make sense on the surface but what makes me a bit unsure is seeing how VSCode doesn't follow those.
It has `Source actions...` in the context menu and sends current selection range for the request. So I'm not sure we should be removing it from the context menu. If we did, then should we also remove "Format file" since it's a file-level action? And also, if we look outside of LSP, there are other actions in the view context menu that are file level rather than selection-level.
> VSCode [...] sends current selection range for the request.

Maybe the `range` is ignored anyways by the servers when using `"only": ["source"]` in the request params. But from my point of view it would make more sense to use the whole content range and also include all of the diagnostics in the file. If in doubt, maybe we should ask what's expected for this request in https://github.com/microsoft/language-server-protocol/issues?

> If we did, then should we also remove "Format file" since it's a file-level action? And also, if we look outside of LSP, there are other actions in the view context menu that are file level rather than selection-level.

Personally I would be okay with removing "Format file" from the context menu. But probably it's not only about the file-level action, but should also depend on the expected usage frequence whether an entry should be included in the context menu. I guess most users run "Source Action..." only very rarely, while "Format file" is maybe used more often (and probably more often than "Format Selection", because it's simpler and not all servers are also documentRangeFormattingProvider).

---

I would probably also remove "Expand Selection" from the context menu, add a
```python
def is_visible(self, event: Optional[dict] = None, point: Optional[int] = None) -> bool:
        return self.is_enabled(event, point)
```
method to all of the commands in order to hide them when not activated, and then remove the "LSP" submenu.
> but from my point of view it would make more sense to use the **whole** content range and also include all of the diagnostics in the file

This comment is related, it contains examples why sending the current selection makes sense https://github.com/sublimelsp/LSP/pull/779#issuecomment-557704533
I've asked some related questions in https://github.com/microsoft/language-server-protocol/issues/1554

> This comment is related, it contains examples why sending the current selection makes sense [#779 (comment)](https://github.com/sublimelsp/LSP/pull/779#issuecomment-557704533)

It makes sense for normal code actions (especially the quickfix ones) as those depend on specific diagnostics and location. And we are sending selection range and all intersecting diagnostics in that case already.

`source` ones are typically not specific to location. With that said, I agree that it makes more sense to send whole range and all diagnostics in that case but in practice servers might be fine either way.

Resolving patch:
diff --git a/boot.py b/boot.py
--- a/boot.py
+++ b/boot.py
@@ -5,6 +5,7 @@
 # Please keep this list sorted (Edit -> Sort Lines)
 from .plugin.code_actions import LspCodeActionsCommand
 from .plugin.code_actions import LspRefactorCommand
+from .plugin.code_actions import LspSourceActionCommand
 from .plugin.code_lens import LspCodeLensCommand
 from .plugin.color import LspColorPresentationCommand
 from .plugin.completion import LspCommitCompletionWithOppositeInsertMode
diff --git a/plugin/code_actions.py b/plugin/code_actions.py
--- a/plugin/code_actions.py
+++ b/plugin/code_actions.py
@@ -17,12 +17,15 @@
 from .core.views import format_code_actions_for_quick_panel
 from .core.views import text_document_code_action_params
 from .save_command import LspSaveCommand, SaveTask
+from abc import ABCMeta
+from abc import abstractmethod
 from functools import partial
 import sublime
 
 ConfigName = str
 CodeActionOrCommand = Union[CodeAction, Command]
 CodeActionsByConfigName = Tuple[ConfigName, List[CodeActionOrCommand]]
+MENU_ACTIONS_KINDS = [CodeActionKind.Refactor, CodeActionKind.Source]
 
 
 def is_command(action: CodeActionOrCommand) -> TypeGuard[Command]:
@@ -34,8 +37,9 @@ class CodeActionsManager:
 
     def __init__(self) -> None:
         self._response_cache = None  # type: Optional[Tuple[str, Promise[List[CodeActionsByConfigName]]]]
-        self.refactor_actions_key = None  # type: Optional[str]
+        self.menu_actions_cache_key = None  # type: Optional[str]
         self.refactor_actions_cache = []  # type: List[Tuple[str, CodeAction]]
+        self.source_actions_cache = []  # type: List[Tuple[str, CodeAction]]
 
     def request_for_region_async(
         self,
@@ -51,7 +55,7 @@ def request_for_region_async(
         """
         listener = windows.listener_for_view(view)
         if not listener:
-            self.refactor_actions_key = None
+            self.menu_actions_cache_key = None
             return Promise.resolve([])
         location_cache_key = None
         use_cache = not manual
@@ -63,9 +67,10 @@ def request_for_region_async(
                     return task
                 else:
                     self._response_cache = None
-        elif only_kinds == [CodeActionKind.Refactor]:
-            self.refactor_actions_key = "{}#{}:{}".format(view.buffer_id(), view.change_count(), region)
+        elif only_kinds == MENU_ACTIONS_KINDS:
+            self.menu_actions_cache_key = "{}#{}:{}".format(view.buffer_id(), view.change_count(), region)
             self.refactor_actions_cache.clear()
+            self.source_actions_cache.clear()
 
         def request_factory(session: Session) -> Optional[Request]:
             diagnostics = []  # type: List[Diagnostic]
@@ -79,16 +84,15 @@ def request_factory(session: Session) -> Optional[Request]:
         def response_filter(session: Session, actions: List[CodeActionOrCommand]) -> List[CodeActionOrCommand]:
             # Filter out non "quickfix" code actions unless "only_kinds" is provided.
             if only_kinds:
-                if manual and only_kinds == [CodeActionKind.Refactor]:
-                    self.refactor_actions_cache.extend([
-                        (session.config.name, cast(CodeAction, a)) for a in actions
-                        if not is_command(a) and kinds_include_kind([CodeActionKind.Refactor], a.get('kind')) and
-                        not a.get('disabled')
-                    ])
-                return [
-                    a for a in actions
-                    if not is_command(a) and kinds_include_kind(only_kinds, a.get('kind')) and not a.get('disabled')
-                ]
+                code_actions = [cast(CodeAction, a) for a in actions if not is_command(a) and not a.get('disabled')]
+                if manual and only_kinds == MENU_ACTIONS_KINDS:
+                    for action in code_actions:
+                        kind = action.get('kind')
+                        if kinds_include_kind([CodeActionKind.Refactor], kind):
+                            self.refactor_actions_cache.append((session.config.name, action))
+                        elif kinds_include_kind([CodeActionKind.Source], kind):
+                            self.source_actions_cache.append((session.config.name, action))
+                return [action for action in code_actions if kinds_include_kind(only_kinds, action.get('kind'))]
             if manual:
                 return [a for a in actions if not a.get('disabled')]
             # On implicit (selection change) request, only return commands and quick fix kinds.
@@ -348,32 +352,38 @@ def _handle_response_async(self, session_name: str, response: Any) -> None:
             sublime.error_message("{}: {}".format(session_name, str(response)))
 
 
-class LspRefactorCommand(LspTextCommand):
+class LspMenuActionCommand(LspTextCommand, metaclass=ABCMeta):
+    """Handles a particular kind of code actions with the purpose to list them as items in a submenu."""
 
     capability = 'codeActionProvider'
 
+    @property
+    @abstractmethod
+    def actions_cache(self) -> List[Tuple[str, CodeAction]]:
+        ...
+
     def is_enabled(self, id: int, event: Optional[dict] = None, point: Optional[int] = None) -> bool:
         if not super().is_enabled(event, point):
             return False
-        return -1 < id < len(actions_manager.refactor_actions_cache)
+        return -1 < id < len(self.actions_cache)
 
     def is_visible(self, id: int, event: Optional[dict] = None, point: Optional[int] = None) -> bool:
         if id == -1:
             if super().is_enabled(event, point):
-                sublime.set_timeout_async(self._request_refactor_actions_async)
+                sublime.set_timeout_async(partial(self._request_menu_actions_async, event))
             return False
-        return id < len(actions_manager.refactor_actions_cache) and self._is_cache_valid()
+        return id < len(self.actions_cache) and self._is_cache_valid(event)
 
     def description(self, id: int, event: Optional[dict] = None, point: Optional[int] = None) -> Optional[str]:
-        if -1 < id < len(actions_manager.refactor_actions_cache):
-            return actions_manager.refactor_actions_cache[id][1]['title']
+        if -1 < id < len(self.actions_cache):
+            return self.actions_cache[id][1]['title']
 
     def run(self, edit: sublime.Edit, id: int, event: Optional[dict] = None, point: Optional[int] = None) -> None:
-        sublime.set_timeout_async(partial(self.run_async, id))
+        sublime.set_timeout_async(partial(self.run_async, id, event))
 
-    def run_async(self, id: int) -> None:
-        if self._is_cache_valid():
-            config_name, action = actions_manager.refactor_actions_cache[id]
+    def run_async(self, id: int, event: Optional[dict]) -> None:
+        if self._is_cache_valid(event):
+            config_name, action = self.actions_cache[id]
             session = self.session_by_name(config_name)
             if session:
                 session.run_code_action_async(action, progress=True) \
@@ -383,15 +393,33 @@ def _handle_response_async(self, session_name: str, response: Any) -> None:
         if isinstance(response, Error):
             sublime.error_message("{}: {}".format(session_name, str(response)))
 
-    def _is_cache_valid(self) -> bool:
-        v = self.view
-        region = first_selection_region(v)
+    def _is_cache_valid(self, event: Optional[dict]) -> bool:
+        region = self._get_region(event)
         if region is None:
             return False
-        return actions_manager.refactor_actions_key == "{}#{}:{}".format(v.buffer_id(), v.change_count(), region)
+        v = self.view
+        return actions_manager.menu_actions_cache_key == "{}#{}:{}".format(v.buffer_id(), v.change_count(), region)
 
-    def _request_refactor_actions_async(self) -> None:
-        region = first_selection_region(self.view)
-        if region is None:
-            return
-        actions_manager.request_for_region_async(self.view, region, [], [CodeActionKind.Refactor], True)
+    def _get_region(self, event: Optional[dict]) -> Optional[sublime.Region]:
+        if event is not None and self.applies_to_context_menu(event):
+            return sublime.Region(self.view.window_to_text((event['x'], event['y'])))
+        return first_selection_region(self.view)
+
+    def _request_menu_actions_async(self, event: Optional[dict]) -> None:
+        region = self._get_region(event)
+        if region is not None:
+            actions_manager.request_for_region_async(self.view, region, [], MENU_ACTIONS_KINDS, True)
+
+
+class LspRefactorCommand(LspMenuActionCommand):
+
+    @property
+    def actions_cache(self) -> List[Tuple[str, CodeAction]]:
+        return actions_manager.refactor_actions_cache
+
+
+class LspSourceActionCommand(LspMenuActionCommand):
+
+    @property
+    def actions_cache(self) -> List[Tuple[str, CodeAction]]:
+        return actions_manager.source_actions_cache


Test patch: