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

Experimental Workspace Symbols overhaul #2333

Merged
merged 33 commits into from
Jan 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1d6964c
Experimental Workspace Symbols overhaul
jwortmann Oct 10, 2023
7f72d5e
Select topmost item in the list
jwortmann Oct 11, 2023
aa23d73
Move abstract input handlers to separate file
jwortmann Oct 11, 2023
6410630
Merge functions and add support for workspaceSymbol/resolve
jwortmann Oct 11, 2023
6569c62
Send requests to all sessions
jwortmann Oct 13, 2023
cfde4aa
Add @final decorator
jwortmann Oct 13, 2023
d24b8ae
Delete test file with tests for now removed function
jwortmann Oct 14, 2023
bb02736
Simplifications
jwortmann Oct 15, 2023
970e5c9
Merge branch 'main' into workspace-symbols
jwortmann Oct 15, 2023
7bfe49a
Display message item when there are no search results
jwortmann Oct 18, 2023
e7f7ad0
Avoid empty item if possible
jwortmann Oct 19, 2023
e3ab446
Keep original command arguments
jwortmann Oct 21, 2023
c4105a6
Make initial list items optional
jwortmann Oct 21, 2023
ad4613c
Update docstrings
jwortmann Oct 21, 2023
927a2df
Merge branch 'main' into workspace-symbols
jwortmann Oct 21, 2023
024e4ba
Remove initial selection workaround
jwortmann Oct 25, 2023
74d4c09
Improve type annotations for decorator
jwortmann Oct 26, 2023
8093ced
Add compatibility with all ST versions
jwortmann Oct 26, 2023
c0c4773
Lint
jwortmann Oct 26, 2023
f985901
Merge branch 'main' into workspace-symbols
jwortmann Oct 28, 2023
be6a7ee
Merge branch 'main' into workspace-symbols
jwortmann Oct 28, 2023
d3ae8c2
Improve type annotations
jwortmann Oct 28, 2023
0543c4f
Merge branch 'main' into workspace-symbols
jwortmann Oct 30, 2023
4f0e2f9
Update condition for initial_selection workaround
jwortmann Nov 8, 2023
5d8c837
Merge branch 'main' into workspace-symbols
jwortmann Nov 10, 2023
1a4b8a5
Move ST_VERSION to constants
jwortmann Nov 10, 2023
4e61a81
Merge branch 'main' into workspace-symbols
jwortmann Dec 5, 2023
408de85
Fix missed conflict
jwortmann Dec 5, 2023
a5fe8ad
Merge branch 'main' into workspace-symbols
jwortmann Dec 13, 2023
94933b9
Cleanup
jwortmann Dec 13, 2023
7b65fb2
Single line
jwortmann Dec 20, 2023
dff0050
Ensure debounced function runs on UI thread
jwortmann Dec 20, 2023
2b36bf3
Use ST API instead of custom thread for debounced decorator
jwortmann Dec 22, 2023
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
2 changes: 2 additions & 0 deletions plugin/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
SublimeKind = Tuple[int, str, str]


ST_VERSION = int(sublime.version())

# Keys for View.add_regions
HOVER_HIGHLIGHT_KEY = 'lsp_hover_highlight'

Expand Down
209 changes: 209 additions & 0 deletions plugin/core/input_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
from .constants import ST_VERSION
from .typing import Any, Callable, Dict, List, Optional, ParamSpec, Tuple, Union
from .typing import final
from abc import ABCMeta
from abc import abstractmethod
import functools
import sublime
import sublime_plugin
import time
import weakref


ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int],
List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]]

P = ParamSpec('P')


def debounced(user_function: Callable[P, Any]) -> Callable[P, None]:
""" A decorator which debounces the calls to a function.

Note that the return value of the function will be discarded, so it only makes sense to use this decorator for
functions that return None. The function will run on Sublime's main thread.
"""
DEBOUNCE_TIME = 0.5 # seconds

@functools.wraps(user_function)
def wrapped_function(*args: P.args, **kwargs: P.kwargs) -> None:
def check_call_function() -> None:
target_time = getattr(wrapped_function, '_target_time', None)
if isinstance(target_time, float):
additional_delay = target_time - time.monotonic()
if additional_delay > 0:
setattr(wrapped_function, '_target_time', None)
sublime.set_timeout(check_call_function, int(additional_delay * 1000))
return
delattr(wrapped_function, '_target_time')
user_function(*args, **kwargs)
if hasattr(wrapped_function, '_target_time'):
setattr(wrapped_function, '_target_time', time.monotonic() + DEBOUNCE_TIME)
return
setattr(wrapped_function, '_target_time', None)
sublime.set_timeout(check_call_function, int(DEBOUNCE_TIME * 1000))
return wrapped_function


class PreselectedListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta):
""" A ListInputHandler which can preselect a value.

Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but instead `get_list_items`,
i.e. just prepend `get_` to the regular `list_items` method.

To create an instance of PreselectedListInputHandler pass the window to the constructor, and optionally a second
argument `initial_value` to preselect a value. Usually you then want to use the `next_input` method to push another
InputHandler onto the input stack.

Inspired by https://github.com/sublimehq/sublime_text/issues/5507.
"""

def __init__(
self, window: sublime.Window, initial_value: Optional[Union[str, sublime.ListInputItem]] = None
) -> None:
super().__init__()
self._window = window
self._initial_value = initial_value

@final
def list_items(self) -> ListItemsReturn:
if self._initial_value is not None:
sublime.set_timeout(self._select_and_reset)
return [self._initial_value], 0 # pyright: ignore[reportGeneralTypeIssues]
else:
return self.get_list_items()

def _select_and_reset(self) -> None:
self._initial_value = None
if self._window.is_valid():
self._window.run_command('select')

@abstractmethod
def get_list_items(self) -> ListItemsReturn:
raise NotImplementedError()


class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta):
""" A ListInputHandler which can update its items while typing in the input field.

Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but can override
`get_list_items` for the initial list items. The `on_modified` method will be called after a small delay (debounced)
whenever changes were made to the input text. You can use this to call the `update` method with a list of
`ListInputItem`s to update the list items.

To create an instance of the derived class pass the command instance and the command arguments to the constructor,
like this:

def input(self, args):
return MyDynamicListInputHandler(self, args)

For now, the type of the command must be a WindowCommand, but maybe it can be generalized later if needed.
This class will set and modify `_items` and '_text' attributes of the command, so make sure that those attribute
names are not used in another way in the command's class.
"""

def __init__(self, command: sublime_plugin.WindowCommand, args: Dict[str, Any]) -> None:
super().__init__()
self.command = command
self.args = args
self.text = getattr(command, '_text', '')
self.listener = None # type: Optional[sublime_plugin.TextChangeListener]
self.input_view = None # type: Optional[sublime.View]

def attach_listener(self) -> None:
for buffer in sublime._buffers(): # type: ignore
view = buffer.primary_view()
# This condition to find the input field view might not be sufficient if there is another command palette
# open in another group in the same window
if view.element() == 'command_palette:input' and view.window() == self.command.window:
self.input_view = view
break
else:
raise RuntimeError('Could not find the Command Palette input field view')
self.listener = InputListener(self)
self.listener.attach(buffer)
if ST_VERSION < 4161:
predragnikolic marked this conversation as resolved.
Show resolved Hide resolved
# Workaround for initial_selection not working; see https://github.com/sublimehq/sublime_text/issues/6175
selection = self.input_view.sel()
selection.clear()
selection.add(len(self.text))

@final
def list_items(self) -> List[sublime.ListInputItem]:
if not self.text: # Show initial items when the command was just invoked
return self.get_list_items() or [sublime.ListInputItem("No Results", "")]
else: # Items were updated after typing
items = getattr(self.command, '_items', None)
if items:
if ST_VERSION >= 4157:
return items
else:
# Trick to select the topmost item; see https://github.com/sublimehq/sublime_text/issues/6162
sublime.set_timeout(self._select_first_row)
return [sublime.ListInputItem("", "")] + items
return [sublime.ListInputItem('No Symbol found: "{}"'.format(self.text), "")]

def _select_first_row(self) -> None:
self.command.window.run_command('move', {'by': 'lines', 'forward': True})

def initial_text(self) -> str:
setattr(self.command, '_text', '')
sublime.set_timeout(self.attach_listener)
return self.text

def initial_selection(self) -> List[Tuple[int, int]]:
pt = len(self.text)
return [(pt, pt)]

def validate(self, text: str) -> bool:
return bool(text)

def cancel(self) -> None:
if self.listener and self.listener.is_attached():
self.listener.detach()

def confirm(self, text: str) -> None:
if self.listener and self.listener.is_attached():
self.listener.detach()

def on_modified(self, text: str) -> None:
""" Called after changes have been made to the input, with the text of the input field passed as argument. """
pass

def get_list_items(self) -> List[sublime.ListInputItem]:
""" The list items which are initially shown. """
return []

def update(self, items: List[sublime.ListInputItem]) -> None:
""" Call this method to update the list items. """
if not self.input_view:
return
setattr(self.command, '_items', items)
text = self.input_view.substr(sublime.Region(0, self.input_view.size()))
setattr(self.command, '_text', text)
self.command.window.run_command('chain', {
'commands': [
# Note that the command palette changes its width after the update, due to the hide_overlay command
['hide_overlay', {}],
[self.command.name(), self.args]
]
})


class InputListener(sublime_plugin.TextChangeListener):

def __init__(self, handler: DynamicListInputHandler) -> None:
super().__init__()
self.weakhandler = weakref.ref(handler)

@classmethod
def is_applicable(cls, buffer: sublime.Buffer) -> bool:
return False

@debounced
def on_text_changed(self, changes: List[sublime.TextChange]) -> None:
handler = self.weakhandler()
if not handler:
return
view = self.buffer.primary_view()
if view and view.id():
handler.on_modified(view.substr(sublime.Region(0, view.size())))
4 changes: 4 additions & 0 deletions plugin/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -6145,6 +6145,10 @@ def foldingRange(cls, params: FoldingRangeParams, view: sublime.View) -> 'Reques
def workspaceSymbol(cls, params: WorkspaceSymbolParams) -> 'Request':
return Request("workspace/symbol", params, None, progress=True)

@classmethod
def resolveWorkspaceSymbol(cls, params: WorkspaceSymbol) -> 'Request':
return Request('workspaceSymbol/resolve', params)

@classmethod
def documentDiagnostic(cls, params: DocumentDiagnosticParams, view: sublime.View) -> 'Request':
return Request('textDocument/diagnostic', params, view)
Expand Down
3 changes: 3 additions & 0 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,9 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor
"workspaceFolders": True,
"symbol": {
"dynamicRegistration": True, # exceptional
"resolveSupport": {
"properties": ["location.range"]
},
"symbolKind": {
"valueSet": symbol_kinds
},
Expand Down
12 changes: 12 additions & 0 deletions plugin/core/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import cast
from typing import Deque
from typing import Dict
from typing import final
from typing import Generator
from typing import Generic
from typing import IO
Expand All @@ -18,6 +19,7 @@
from typing import Mapping
from typing import NotRequired
from typing import Optional
from typing import ParamSpec
from typing import Protocol
from typing import Required
from typing import Sequence
Expand All @@ -37,6 +39,9 @@
def cast(typ, val): # type: ignore
return val

def final(func): # type: ignore
return func

def _make_type(name: str) -> '_TypeMeta':
return _TypeMeta(name, (Type,), {}) # type: ignore

Expand Down Expand Up @@ -138,3 +143,10 @@ class NotRequired(Type): # type: ignore

def TypeVar(*args, **kwargs) -> Any: # type: ignore
return object

class ParamSpec(Type): # type: ignore
args = ...
kwargs = ...

def __init__(*args, **kwargs) -> None: # type: ignore
pass
45 changes: 2 additions & 43 deletions plugin/goto_diagnostic.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .core.constants import DIAGNOSTIC_KINDS
from .core.diagnostics_storage import is_severity_included
from .core.diagnostics_storage import ParsedUri
from .core.input_handlers import PreselectedListInputHandler
from .core.paths import project_base_dir
from .core.paths import project_path
from .core.paths import simple_project_path
Expand All @@ -12,7 +13,7 @@
from .core.sessions import Session
from .core.settings import userprefs
from .core.types import ClientConfig
from .core.typing import Any, Dict, Iterator, List, Optional, Tuple, Union
from .core.typing import Dict, Iterator, List, Optional, Tuple, Union
from .core.typing import cast
from .core.url import parse_uri, unparse_uri
from .core.views import diagnostic_severity
Expand All @@ -23,8 +24,6 @@
from .core.views import MissingUriError
from .core.views import to_encoded_filename
from .core.views import uri_from_view
from abc import ABCMeta
from abc import abstractmethod
from collections import Counter, OrderedDict
from pathlib import Path
import functools
Expand Down Expand Up @@ -94,46 +93,6 @@ def input_description(self) -> str:
return "Goto Diagnostic"


ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int],
List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]]


class PreselectedListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta):
"""
Similar to ListInputHandler, but allows to preselect a value like some of the input overlays in Sublime Merge.
Inspired by https://github.com/sublimehq/sublime_text/issues/5507.

Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but instead `get_list_items`,
i.e. just prepend `get_` to the regular `list_items` method.

When an instance of PreselectedListInputHandler is created, it must be given the window as an argument.
An optional second argument `initial_value` can be provided to preselect a value.
"""

def __init__(
self, window: sublime.Window, initial_value: Optional[Union[str, sublime.ListInputItem]] = None
) -> None:
super().__init__()
self._window = window
self._initial_value = initial_value

def list_items(self) -> ListItemsReturn:
if self._initial_value is not None:
sublime.set_timeout(self._select_and_reset)
return [self._initial_value], 0 # pyright: ignore[reportGeneralTypeIssues]
else:
return self.get_list_items()

def _select_and_reset(self) -> None:
self._initial_value = None
if self._window.is_valid():
self._window.run_command('select')

@abstractmethod
def get_list_items(self) -> ListItemsReturn:
raise NotImplementedError()


class DiagnosticUriInputHandler(PreselectedListInputHandler):
_preview = None # type: Optional[sublime.View]
uri = None # Optional[DocumentUri]
Expand Down
Loading