Skip to content

Commit

Permalink
Enhancements for the rename panel (#2428)
Browse files Browse the repository at this point in the history
  • Loading branch information
jwortmann authored Mar 12, 2024
1 parent ca03baf commit 983df8b
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 23 deletions.
2 changes: 2 additions & 0 deletions boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from .plugin.documents import DocumentSyncListener
from .plugin.documents import TextChangeListener
from .plugin.edit import LspApplyDocumentEditCommand
from .plugin.edit import LspApplyWorkspaceEditCommand
from .plugin.execute_command import LspExecuteCommand
from .plugin.folding_range import LspFoldAllCommand
from .plugin.folding_range import LspFoldCommand
Expand All @@ -68,6 +69,7 @@
from .plugin.panels import LspUpdateLogPanelCommand
from .plugin.panels import LspUpdatePanelCommand
from .plugin.references import LspSymbolReferencesCommand
from .plugin.rename import LspHideRenameButtonsCommand
from .plugin.rename import LspSymbolRenameCommand
from .plugin.save_command import LspSaveAllCommand
from .plugin.save_command import LspSaveCommand
Expand Down
10 changes: 9 additions & 1 deletion plugin/core/panels.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .types import PANEL_FILE_REGEX
from .types import PANEL_LINE_REGEX
from .typing import Optional
from .typing import Iterable, Optional
import sublime


Expand Down Expand Up @@ -38,6 +38,7 @@ class PanelName:
class PanelManager:
def __init__(self, window: sublime.Window) -> None:
self._window = window
self._rename_panel_buttons = None # type: Optional[sublime.PhantomSet]

def destroy_output_panels(self) -> None:
for field in filter(lambda a: not a.startswith('__'), PanelName.__dict__.keys()):
Expand All @@ -46,6 +47,7 @@ def destroy_output_panels(self) -> None:
if panel and panel.is_valid():
panel.settings().set("syntax", "Packages/Text/Plain text.tmLanguage")
self._window.destroy_output_panel(panel_name)
self._rename_panel_buttons = None

def toggle_output_panel(self, panel_type: str) -> None:
panel_name = "output.{}".format(panel_type)
Expand Down Expand Up @@ -91,6 +93,8 @@ def _create_panel(self, name: str, result_file_regex: str, result_line_regex: st
panel = self.create_output_panel(name)
if not panel:
return None
if name == PanelName.Rename:
self._rename_panel_buttons = sublime.PhantomSet(panel, "lsp_rename_buttons")
settings = panel.settings()
if result_file_regex:
settings.set("result_file_regex", result_file_regex)
Expand Down Expand Up @@ -121,3 +125,7 @@ def show_diagnostics_panel_async(self) -> None:
def hide_diagnostics_panel_async(self) -> None:
if self.is_panel_open(PanelName.Diagnostics):
self.toggle_output_panel(PanelName.Diagnostics)

def update_rename_panel_buttons(self, phantoms: Iterable[sublime.Phantom]) -> None:
if self._rename_panel_buttons:
self._rename_panel_buttons.update(phantoms)
7 changes: 4 additions & 3 deletions plugin/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def __str__(self) -> str:
return "invalid URI scheme: {}".format(self.uri)


def get_line(window: sublime.Window, file_name: str, row: int) -> str:
def get_line(window: sublime.Window, file_name: str, row: int, strip: bool = True) -> str:
'''
Get the line from the buffer if the view is open, else get line from linecache.
row - is 0 based. If you want to get the first line, you should pass 0.
Expand All @@ -95,11 +95,12 @@ def get_line(window: sublime.Window, file_name: str, row: int) -> str:
if view:
# get from buffer
point = view.text_point(row, 0)
return view.substr(view.line(point)).strip()
line = view.substr(view.line(point))
else:
# get from linecache
# linecache row is not 0 based, so we increment it by 1 to get the correct line.
return linecache.getline(file_name, row + 1).strip()
line = linecache.getline(file_name, row + 1)
return line.strip() if strip else line


def get_storage_path() -> str:
Expand Down
13 changes: 13 additions & 0 deletions plugin/edit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from .core.edit import parse_range
from .core.logging import debug
from .core.protocol import TextEdit
from .core.protocol import WorkspaceEdit
from .core.registry import LspWindowCommand
from .core.typing import List, Optional, Any, Generator, Iterable, Tuple
from contextlib import contextmanager
import operator
Expand All @@ -24,6 +27,16 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat
settings.set(key, prev_val)


class LspApplyWorkspaceEditCommand(LspWindowCommand):

def run(self, session_name: str, edit: WorkspaceEdit) -> 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))


class LspApplyDocumentEditCommand(sublime_plugin.TextCommand):
re_placeholder = re.compile(r'\$(0|\{0:([^}]*)\})')

Expand Down
156 changes: 137 additions & 19 deletions plugin/rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,66 @@
import sublime_plugin


BUTTONS_TEMPLATE = """
<style>
html {{
background-color: transparent;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}}
a {{
line-height: 1.6rem;
padding-left: 0.6rem;
padding-right: 0.6rem;
border-width: 1px;
border-style: solid;
border-color: #fff4;
border-radius: 4px;
color: #cccccc;
background-color: #3f3f3f;
text-decoration: none;
}}
html.light a {{
border-color: #000a;
color: white;
background-color: #636363;
}}
a.primary, html.light a.primary {{
background-color: color(var(--accent) min-contrast(white 6.0));
}}
</style>
<body id='lsp-buttons'>
<a href='{apply}' class='primary'>Apply</a>&nbsp;
<a href='{discard}'>Discard</a>
</body>"""

DISCARD_COMMAND_URL = sublime.command_url('chain', {
'commands': [
['hide_panel', {}],
['lsp_hide_rename_buttons', {}]
]
})


def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]:
return 'start' in result


def utf16_to_code_points(s: str, col: int) -> int:
"""Convert a position from UTF-16 code units to Unicode code points, usable for string slicing."""
utf16_len = 0
idx = 0
for idx, c in enumerate(s):
if utf16_len >= col:
if utf16_len > col: # If col is in the middle of a character (emoji), don't advance to the next code point
idx -= 1
break
utf16_len += 1 if ord(c) < 65536 else 2
else:
idx += 1 # get_line function trims the trailing '\n'
return idx


# The flow of this command is fairly complicated so it deserves some documentation.
#
# When "LSP: Rename" is triggered from the Command Palette, the flow can go one of two ways:
Expand Down Expand Up @@ -134,17 +190,17 @@ def _on_rename_result_async(self, session: Session, response: Optional[Workspace
if not response:
return session.window.status_message('Nothing to rename')
changes = parse_workspace_edit(response)
count = len(changes.keys())
if count == 1:
file_count = len(changes.keys())
if file_count == 1:
session.apply_parsed_workspace_edits(changes)
return
total_changes = sum(map(len, changes.values()))
message = "Replace {} occurrences across {} files?".format(total_changes, count)
choice = sublime.yes_no_cancel_dialog(message, "Replace", "Dry Run")
message = "Replace {} occurrences across {} files?".format(total_changes, file_count)
choice = sublime.yes_no_cancel_dialog(message, "Replace", "Preview", title="Rename")
if choice == sublime.DIALOG_YES:
session.apply_parsed_workspace_edits(changes)
elif choice == sublime.DIALOG_NO:
self._render_rename_panel(changes, total_changes, count)
self._render_rename_panel(response, changes, total_changes, file_count, session.config.name)

def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None:
if response is None:
Expand Down Expand Up @@ -172,39 +228,91 @@ def _get_relative_path(self, file_path: str) -> str:
base_dir = wm.get_project_path(file_path)
return os.path.relpath(file_path, base_dir) if base_dir else file_path

def _render_rename_panel(self, changes_per_uri: WorkspaceChanges, total_changes: int, file_count: int) -> None:
def _render_rename_panel(
self,
workspace_edit: WorkspaceEdit,
changes_per_uri: WorkspaceChanges,
total_changes: int,
file_count: int,
session_name: str
) -> None:
wm = windows.lookup(self.view.window())
if not wm:
return
panel = wm.panel_manager and wm.panel_manager.ensure_rename_panel()
pm = wm.panel_manager
if not pm:
return
panel = pm.ensure_rename_panel()
if not panel:
return
to_render = [] # type: List[str]
reference_document = [] # type: List[str]
header_lines = "{} changes across {} files.\n".format(total_changes, file_count)
to_render.append(header_lines)
reference_document.append(header_lines)
ROWCOL_PREFIX = " {:>4}:{:<4} {}"
for uri, (changes, _) in changes_per_uri.items():
scheme, file = parse_uri(uri)
if scheme == "file":
to_render.append('{}:'.format(self._get_relative_path(file)))
else:
to_render.append('{}:'.format(uri))
filename_line = '{}:'.format(self._get_relative_path(file) if scheme == 'file' else uri)
to_render.append(filename_line)
reference_document.append(filename_line)
for edit in changes:
start = parse_range(edit['range']['start'])
if scheme == "file":
line_content = get_line(wm.window, file, start[0])
start_row, start_col_utf16 = parse_range(edit['range']['start'])
line_content = get_line(wm.window, file, start_row, strip=False) if scheme == 'file' else \
'<no preview available>'
start_col = utf16_to_code_points(line_content, start_col_utf16)
original_line = ROWCOL_PREFIX.format(start_row + 1, start_col + 1, line_content.strip() + "\n")
reference_document.append(original_line)
if scheme == "file" and line_content:
end_row, end_col_utf16 = parse_range(edit['range']['end'])
new_text_rows = edit['newText'].split('\n')
new_line_content = line_content[:start_col] + new_text_rows[0]
if start_row == end_row and len(new_text_rows) == 1:
end_col = start_col if end_col_utf16 <= start_col_utf16 else \
utf16_to_code_points(line_content, end_col_utf16)
if end_col < len(line_content):
new_line_content += line_content[end_col:]
to_render.append(
ROWCOL_PREFIX.format(start_row + 1, start_col + 1, new_line_content.strip() + "\n"))
else:
line_content = '<no preview available>'
to_render.append(" {:>4}:{:<4} {}".format(start[0] + 1, start[1] + 1, line_content))
to_render.append("") # this adds a spacing between filenames
to_render.append(original_line)
characters = "\n".join(to_render)
base_dir = wm.get_project_path(self.view.file_name() or "")
panel.settings().set("result_base_dir", base_dir)
panel.run_command("lsp_clear_panel")
wm.window.run_command("show_panel", {"panel": "output.rename"})
fmt = "{} changes across {} files.\n\n{}"
panel.run_command('append', {
'characters': fmt.format(total_changes, file_count, characters),
'characters': characters,
'force': True,
'scroll_to_end': False
})
panel.set_reference_document("\n".join(reference_document))
selection = panel.sel()
selection.add(sublime.Region(0, panel.size()))
panel.run_command('toggle_inline_diff')
selection.clear()
BUTTONS_HTML = BUTTONS_TEMPLATE.format(
apply=sublime.command_url('chain', {
'commands': [
[
'lsp_apply_workspace_edit',
{'session_name': session_name, 'edit': workspace_edit}
],
[
'hide_panel',
{}
],
[
'lsp_hide_rename_buttons',
{}
]
]
}),
discard=DISCARD_COMMAND_URL
)
pm.update_rename_panel_buttons([
sublime.Phantom(sublime.Region(len(to_render[0]) - 1), BUTTONS_HTML, sublime.LAYOUT_BLOCK)
])


class RenameSymbolInputHandler(sublime_plugin.TextInputHandler):
Expand All @@ -226,3 +334,13 @@ def initial_text(self) -> str:

def validate(self, name: str) -> bool:
return len(name) > 0


class LspHideRenameButtonsCommand(sublime_plugin.WindowCommand):

def run(self) -> None:
wm = windows.lookup(self.window)
if not wm:
return
if wm.panel_manager:
wm.panel_manager.update_rename_panel_buttons([])
50 changes: 50 additions & 0 deletions tests/test_rename_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from LSP.plugin.rename import utf16_to_code_points
import unittest


class LspRenamePanelTests(unittest.TestCase):

def test_utf16_ascii(self):
s = 'abc'
self.assertEqual(utf16_to_code_points(s, 0), 0)
self.assertEqual(utf16_to_code_points(s, 1), 1)
self.assertEqual(utf16_to_code_points(s, 2), 2)
self.assertEqual(utf16_to_code_points(s, 3), 3) # EOL after last character should count as its own code point
self.assertEqual(utf16_to_code_points(s, 1337), 3) # clamp to EOL

def test_utf16_deseret_letter(self):
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocuments
s = 'a𐐀b'
self.assertEqual(len(s), 3)
self.assertEqual(utf16_to_code_points(s, 0), 0)
self.assertEqual(utf16_to_code_points(s, 1), 1)
self.assertEqual(utf16_to_code_points(s, 2), 1) # 𐐀 needs 2 UTF-16 code units, so this is still at code point 1
self.assertEqual(utf16_to_code_points(s, 3), 2)
self.assertEqual(utf16_to_code_points(s, 4), 3)
self.assertEqual(utf16_to_code_points(s, 1337), 3)

def test_utf16_emoji(self):
s = 'a😀x'
self.assertEqual(len(s), 3)
self.assertEqual(utf16_to_code_points(s, 0), 0)
self.assertEqual(utf16_to_code_points(s, 1), 1)
self.assertEqual(utf16_to_code_points(s, 2), 1)
self.assertEqual(utf16_to_code_points(s, 3), 2)
self.assertEqual(utf16_to_code_points(s, 4), 3)
self.assertEqual(utf16_to_code_points(s, 1337), 3)

def test_utf16_emoji_zwj_sequence(self):
# https://unicode.org/emoji/charts/emoji-zwj-sequences.html
s = 'a😵‍💫x'
self.assertEqual(len(s), 5)
self.assertEqual(s, 'a\U0001f635\u200d\U0001f4abx')
# 😵‍💫 consists of 5 UTF-16 code units and Python treats it as 3 characters
self.assertEqual(utf16_to_code_points(s, 0), 0) # a
self.assertEqual(utf16_to_code_points(s, 1), 1) # \U0001f635
self.assertEqual(utf16_to_code_points(s, 2), 1) # \U0001f635
self.assertEqual(utf16_to_code_points(s, 3), 2) # \u200d (zero width joiner)
self.assertEqual(utf16_to_code_points(s, 4), 3) # \U0001f4ab
self.assertEqual(utf16_to_code_points(s, 5), 3) # \U0001f4ab
self.assertEqual(utf16_to_code_points(s, 6), 4) # x
self.assertEqual(utf16_to_code_points(s, 7), 5) # after x
self.assertEqual(utf16_to_code_points(s, 1337), 5)

0 comments on commit 983df8b

Please sign in to comment.