From 28a366fcef5da6a9bcbfba22224506071d8fc203 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 2 Mar 2024 21:11:27 +0100 Subject: [PATCH 01/16] Add buttons in rename panel --- boot.py | 1 + plugin/core/panels.py | 3 ++ plugin/edit.py | 13 +++++++ plugin/rename.py | 88 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/boot.py b/boot.py index dc4b8632e..edfa61936 100644 --- a/boot.py +++ b/boot.py @@ -42,6 +42,7 @@ from .plugin.documents import DocumentSyncListener from .plugin.documents import TextChangeListener from .plugin.edit import LspApplyDocumentEditCommand +from .plugin.edit import LspApplyWorkspaceChangesCommand from .plugin.execute_command import LspExecuteCommand from .plugin.folding_range import LspFoldAllCommand from .plugin.folding_range import LspFoldCommand diff --git a/plugin/core/panels.py b/plugin/core/panels.py index eb8c15ac7..51473c2a7 100644 --- a/plugin/core/panels.py +++ b/plugin/core/panels.py @@ -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()): @@ -91,6 +92,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) diff --git a/plugin/edit.py b/plugin/edit.py index f0f5dd15a..a6e052828 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,5 +1,8 @@ from .core.edit import parse_range +from .core.edit import WorkspaceChanges +from .core.logging import debug from .core.protocol import TextEdit +from .core.registry import LspWindowCommand from .core.typing import List, Optional, Any, Generator, Iterable, Tuple from contextlib import contextmanager import operator @@ -24,6 +27,16 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat settings.set(key, prev_val) +class LspApplyWorkspaceChangesCommand(LspWindowCommand): + + def run(self, session_name: str, workspace_changes: WorkspaceChanges) -> None: + session = self.session_by_name(session_name) + if not session: + debug('Could not find session', session_name) + return + session.apply_parsed_workspace_edits(workspace_changes) + + class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): re_placeholder = re.compile(r'\$(0|\{0:([^}]*)\})') diff --git a/plugin/rename.py b/plugin/rename.py index 07f89d7ba..82faaf682 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -24,6 +24,43 @@ import sublime_plugin +BUTTON_HTML = """ + + + {label} +""" + +DISCARD_BUTTON_HTML = BUTTON_HTML.format( + label="Discard", + command=sublime.command_url('hide_panel'), + color="#3f3f3f", + color_light="#636363" +) + + def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]: return 'start' in result @@ -134,17 +171,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(changes, total_changes, file_count, session.config.name) def _on_prepare_result(self, pos: int, response: Optional[PrepareRenameResult]) -> None: if response is None: @@ -172,14 +209,24 @@ 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, + 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] + to_render.append("{} changes across {} files.\n\n \n".format(total_changes, file_count)) for uri, (changes, _) in changes_per_uri.items(): scheme, file = parse_uri(uri) if scheme == "file": @@ -199,12 +246,35 @@ def _render_rename_panel(self, changes_per_uri: WorkspaceChanges, total_changes: 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 }) + buttons = pm.rename_panel_buttons + if buttons: + buttons_position = len(to_render[0]) - 2 + APPLY_BUTTON_HTML = BUTTON_HTML.format( + label="Apply", + command=sublime.command_url('chain', { + 'commands': [ + [ + 'lsp_apply_workspace_changes', + {'session_name': session_name, 'workspace_changes': changes_per_uri} + ], + [ + 'hide_panel', + {} + ] + ] + }), + color="var(--accent)", + color_light="var(--accent)" + ) + buttons.update([ + sublime.Phantom(sublime.Region(buttons_position), APPLY_BUTTON_HTML, sublime.LAYOUT_INLINE), + sublime.Phantom(sublime.Region(buttons_position + 1), DISCARD_BUTTON_HTML, sublime.LAYOUT_INLINE) + ]) class RenameSymbolInputHandler(sublime_plugin.TextInputHandler): From 0f9f72f2716656d8565a72d83c6542a932ece9a2 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 3 Mar 2024 11:58:20 +0100 Subject: [PATCH 02/16] Add inline diff and hide buttons after click --- boot.py | 1 + plugin/rename.py | 107 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/boot.py b/boot.py index edfa61936..4af9edd5c 100644 --- a/boot.py +++ b/boot.py @@ -69,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 diff --git a/plugin/rename.py b/plugin/rename.py index 82faaf682..2285ea66a 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -55,7 +55,12 @@ DISCARD_BUTTON_HTML = BUTTON_HTML.format( label="Discard", - command=sublime.command_url('hide_panel'), + command=sublime.command_url('chain', { + 'commands': [ + ['hide_panel', {}], + ['lsp_hide_rename_buttons', {}] + ] + }), color="#3f3f3f", color_light="#636363" ) @@ -226,21 +231,31 @@ def _render_rename_panel( if not panel: return to_render = [] # type: List[str] - to_render.append("{} changes across {} files.\n\n \n".format(total_changes, file_count)) + reference_document = [] # type: List[str] + header_lines = "{} changes across {} files.\n\n \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) if scheme == 'file' else '' + original_line = ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, line_content + "\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') + # TODO: string slicing is not compatible with UTF-16 column numbers + new_line_content = line_content[:start_col_utf16] + new_text_rows[0] + if start_row == end_row and len(new_text_rows) == 1 and end_col_utf16 < len(line_content): + new_line_content += line_content[end_col_utf16:] + to_render.append(ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, new_line_content + "\n")) else: - line_content = '' - 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) @@ -251,30 +266,40 @@ def _render_rename_panel( '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 = pm.rename_panel_buttons - if buttons: - buttons_position = len(to_render[0]) - 2 - APPLY_BUTTON_HTML = BUTTON_HTML.format( - label="Apply", - command=sublime.command_url('chain', { - 'commands': [ - [ - 'lsp_apply_workspace_changes', - {'session_name': session_name, 'workspace_changes': changes_per_uri} - ], - [ - 'hide_panel', - {} - ] + if not buttons: + return + buttons_position = len(to_render[0]) - 2 + APPLY_BUTTON_HTML = BUTTON_HTML.format( + label="Apply", + command=sublime.command_url('chain', { + 'commands': [ + [ + 'lsp_apply_workspace_changes', + {'session_name': session_name, 'workspace_changes': changes_per_uri} + ], + [ + 'hide_panel', + {} + ], + [ + 'lsp_hide_rename_buttons', + {} ] - }), - color="var(--accent)", - color_light="var(--accent)" - ) - buttons.update([ - sublime.Phantom(sublime.Region(buttons_position), APPLY_BUTTON_HTML, sublime.LAYOUT_INLINE), - sublime.Phantom(sublime.Region(buttons_position + 1), DISCARD_BUTTON_HTML, sublime.LAYOUT_INLINE) - ]) + ] + }), + color="var(--accent)", + color_light="var(--accent)" + ) + buttons.update([ + sublime.Phantom(sublime.Region(buttons_position), APPLY_BUTTON_HTML, sublime.LAYOUT_INLINE), + sublime.Phantom(sublime.Region(buttons_position + 1), DISCARD_BUTTON_HTML, sublime.LAYOUT_INLINE) + ]) class RenameSymbolInputHandler(sublime_plugin.TextInputHandler): @@ -296,3 +321,17 @@ 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 + pm = wm.panel_manager + if not pm: + return + buttons = pm.rename_panel_buttons + if buttons: + buttons.update([]) From c667f3bac1850ce2aa0caeaba1574a6b21504542 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 3 Mar 2024 12:49:47 +0100 Subject: [PATCH 03/16] Fix wrong diffs due to strip whitespace --- plugin/core/views.py | 12 ++++++++++++ plugin/rename.py | 9 +++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/plugin/core/views.py b/plugin/core/views.py index 060de4264..41a7b5a31 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -102,6 +102,18 @@ def get_line(window: sublime.Window, file_name: str, row: int) -> str: return linecache.getline(file_name, row + 1).strip() +def get_line2(window: sublime.Window, file_name: str, row: int) -> str: + ''' + Same as get_line, but don't strip away leading and trailing whitespace. + ''' + view = window.find_open_file(file_name) + if view: + point = view.text_point(row, 0) + return view.substr(view.line(point)) + else: + return linecache.getline(file_name, row + 1) + + def get_storage_path() -> str: """ The "Package Storage" is a way to store server data without influencing the behavior of Sublime Text's "catalog". diff --git a/plugin/rename.py b/plugin/rename.py index 2285ea66a..78867d208 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -15,7 +15,7 @@ from .core.typing import cast from .core.url import parse_uri from .core.views import first_selection_region -from .core.views import get_line +from .core.views import get_line2 from .core.views import range_to_region from .core.views import text_document_position_params from functools import partial @@ -243,8 +243,8 @@ def _render_rename_panel( reference_document.append(filename_line) for edit in changes: start_row, start_col_utf16 = parse_range(edit['range']['start']) - line_content = get_line(wm.window, file, start_row) if scheme == 'file' else '' - original_line = ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, line_content + "\n") + line_content = get_line2(wm.window, file, start_row) if scheme == 'file' else '' + original_line = ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 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']) @@ -253,7 +253,8 @@ def _render_rename_panel( new_line_content = line_content[:start_col_utf16] + new_text_rows[0] if start_row == end_row and len(new_text_rows) == 1 and end_col_utf16 < len(line_content): new_line_content += line_content[end_col_utf16:] - to_render.append(ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, new_line_content + "\n")) + to_render.append( + ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, new_line_content.strip() + "\n")) else: to_render.append(original_line) characters = "\n".join(to_render) From 0d93fc9c4ae34fd2b72f96afa9960cfd43b6d746 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 3 Mar 2024 13:11:46 +0100 Subject: [PATCH 04/16] Simplify --- plugin/core/views.py | 19 ++++--------------- plugin/rename.py | 5 +++-- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/plugin/core/views.py b/plugin/core/views.py index 41a7b5a31..327e1bb26 100644 --- a/plugin/core/views.py +++ b/plugin/core/views.py @@ -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. @@ -95,23 +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() - - -def get_line2(window: sublime.Window, file_name: str, row: int) -> str: - ''' - Same as get_line, but don't strip away leading and trailing whitespace. - ''' - view = window.find_open_file(file_name) - if view: - point = view.text_point(row, 0) - return view.substr(view.line(point)) - else: - return linecache.getline(file_name, row + 1) + line = linecache.getline(file_name, row + 1) + return line.strip() if strip else line def get_storage_path() -> str: diff --git a/plugin/rename.py b/plugin/rename.py index 78867d208..d50f9d262 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -15,7 +15,7 @@ from .core.typing import cast from .core.url import parse_uri from .core.views import first_selection_region -from .core.views import get_line2 +from .core.views import get_line from .core.views import range_to_region from .core.views import text_document_position_params from functools import partial @@ -243,7 +243,8 @@ def _render_rename_panel( reference_document.append(filename_line) for edit in changes: start_row, start_col_utf16 = parse_range(edit['range']['start']) - line_content = get_line2(wm.window, file, start_row) if scheme == 'file' else '' + line_content = get_line(wm.window, file, start_row, strip=False) if scheme == 'file' else \ + '' original_line = ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, line_content.strip() + "\n") reference_document.append(original_line) if scheme == "file" and line_content: From cb9fd8f325669ffd8e74bdecb502cfe596613114 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 6 Mar 2024 21:18:12 +0100 Subject: [PATCH 05/16] Ensure that preview is always accurate --- plugin/rename.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/plugin/rename.py b/plugin/rename.py index d50f9d262..856d33718 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -70,6 +70,21 @@ def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]: return 'start' in result +def utf16_to_code_points(s: str, col: int) -> int: + """ Converts 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: @@ -245,15 +260,17 @@ def _render_rename_panel( 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 \ '' - original_line = ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, line_content.strip() + "\n") + 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') - # TODO: string slicing is not compatible with UTF-16 column numbers - new_line_content = line_content[:start_col_utf16] + new_text_rows[0] - if start_row == end_row and len(new_text_rows) == 1 and end_col_utf16 < len(line_content): - new_line_content += line_content[end_col_utf16:] + end_col = start_col if end_col_utf16 <= start_col_utf16 else \ + utf16_to_code_points(line_content, end_col_utf16) + new_line_content = line_content[:start_col] + new_text_rows[0] + if start_row == end_row and len(new_text_rows) == 1 and end_col < len(line_content): + new_line_content += line_content[end_col:] to_render.append( ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, new_line_content.strip() + "\n")) else: From 7ded35c3fb102b4a93b635d33964cd3375a4c598 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 6 Mar 2024 21:29:28 +0100 Subject: [PATCH 06/16] Missed a variable update --- plugin/rename.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/rename.py b/plugin/rename.py index 856d33718..be63e6072 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -272,7 +272,7 @@ def _render_rename_panel( if start_row == end_row and len(new_text_rows) == 1 and end_col < len(line_content): new_line_content += line_content[end_col:] to_render.append( - ROWCOL_PREFIX.format(start_row + 1, start_col_utf16 + 1, new_line_content.strip() + "\n")) + ROWCOL_PREFIX.format(start_row + 1, start_col + 1, new_line_content.strip() + "\n")) else: to_render.append(original_line) characters = "\n".join(to_render) From b817449c6528e40f42e2bc06c7ff7dd6cd34557e Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 6 Mar 2024 22:03:17 +0100 Subject: [PATCH 07/16] Reorder code a bit to avoid unnecessary function calls --- plugin/rename.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugin/rename.py b/plugin/rename.py index be63e6072..e697c267b 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -266,11 +266,12 @@ def _render_rename_panel( if scheme == "file" and line_content: end_row, end_col_utf16 = parse_range(edit['range']['end']) new_text_rows = edit['newText'].split('\n') - end_col = start_col if end_col_utf16 <= start_col_utf16 else \ - utf16_to_code_points(line_content, end_col_utf16) new_line_content = line_content[:start_col] + new_text_rows[0] - if start_row == end_row and len(new_text_rows) == 1 and end_col < len(line_content): - new_line_content += line_content[end_col:] + 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: From 897297c4ec3df60b669c1a72da4895dea5bf1ee4 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 7 Mar 2024 01:54:53 +0100 Subject: [PATCH 08/16] Add UTF-16 tests --- tests/test_rename_panel.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_rename_panel.py diff --git a/tests/test_rename_panel.py b/tests/test_rename_panel.py new file mode 100644 index 000000000..a9fd78a7a --- /dev/null +++ b/tests/test_rename_panel.py @@ -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) From 3d0ee6a4f777197c008641fb780319a70e048735 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 7 Mar 2024 15:24:37 +0100 Subject: [PATCH 09/16] Use single phantom to avoid trailing space --- plugin/rename.py | 54 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/plugin/rename.py b/plugin/rename.py index e697c267b..ca19fdd73 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -24,15 +24,16 @@ import sublime_plugin -BUTTON_HTML = """ +BUTTONS_TEMPLATE = """ - - {label} + + Apply + Discard """ -DISCARD_BUTTON_HTML = BUTTON_HTML.format( - label="Discard", - command=sublime.command_url('chain', { - 'commands': [ - ['hide_panel', {}], - ['lsp_hide_rename_buttons', {}] - ] - }), - color="#3f3f3f", - color_light="#636363" -) +DISCARD_COMMAND = sublime.command_url('chain', { + 'commands': [ + ['hide_panel', {}], + ['lsp_hide_rename_buttons', {}] + ] +}) def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]: @@ -247,7 +250,7 @@ def _render_rename_panel( return to_render = [] # type: List[str] reference_document = [] # type: List[str] - header_lines = "{} changes across {} files.\n\n \n".format(total_changes, file_count) + header_lines = "{} changes across {} files.\n\n".format(total_changes, file_count) to_render.append(header_lines) reference_document.append(header_lines) ROWCOL_PREFIX = " {:>4}:{:<4} {}" @@ -294,10 +297,9 @@ def _render_rename_panel( buttons = pm.rename_panel_buttons if not buttons: return - buttons_position = len(to_render[0]) - 2 - APPLY_BUTTON_HTML = BUTTON_HTML.format( - label="Apply", - command=sublime.command_url('chain', { + buttons_position = sublime.Region(len(to_render[0]) - 1) + BUTTONS_HTML = BUTTONS_TEMPLATE.format( + apply=sublime.command_url('chain', { 'commands': [ [ 'lsp_apply_workspace_changes', @@ -313,12 +315,10 @@ def _render_rename_panel( ] ] }), - color="var(--accent)", - color_light="var(--accent)" + discard=DISCARD_COMMAND ) buttons.update([ - sublime.Phantom(sublime.Region(buttons_position), APPLY_BUTTON_HTML, sublime.LAYOUT_INLINE), - sublime.Phantom(sublime.Region(buttons_position + 1), DISCARD_BUTTON_HTML, sublime.LAYOUT_INLINE) + sublime.Phantom(buttons_position, BUTTONS_HTML, sublime.LAYOUT_BLOCK) ]) From 7a66743c542c968dcf86ac70a3c10a0955308040 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 7 Mar 2024 15:28:13 +0100 Subject: [PATCH 10/16] Refactor command to take WorkspaceEdit --- boot.py | 2 +- plugin/edit.py | 11 ++++++----- plugin/rename.py | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/boot.py b/boot.py index 4af9edd5c..e86454de7 100644 --- a/boot.py +++ b/boot.py @@ -42,7 +42,7 @@ from .plugin.documents import DocumentSyncListener from .plugin.documents import TextChangeListener from .plugin.edit import LspApplyDocumentEditCommand -from .plugin.edit import LspApplyWorkspaceChangesCommand +from .plugin.edit import LspApplyWorkspaceEditCommand from .plugin.execute_command import LspExecuteCommand from .plugin.folding_range import LspFoldAllCommand from .plugin.folding_range import LspFoldCommand diff --git a/plugin/edit.py b/plugin/edit.py index a6e052828..be22f3964 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,7 +1,8 @@ from .core.edit import parse_range -from .core.edit import WorkspaceChanges +from .core.edit import parse_workspace_edit 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 @@ -27,14 +28,14 @@ def temporary_setting(settings: sublime.Settings, key: str, val: Any) -> Generat settings.set(key, prev_val) -class LspApplyWorkspaceChangesCommand(LspWindowCommand): +class LspApplyWorkspaceEditCommand(LspWindowCommand): - def run(self, session_name: str, workspace_changes: WorkspaceChanges) -> None: + 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) + debug('Could not find session', session_name, 'required to apply WorkspaceEdit') return - session.apply_parsed_workspace_edits(workspace_changes) + session.apply_parsed_workspace_edits(parse_workspace_edit(edit)) class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): diff --git a/plugin/rename.py b/plugin/rename.py index ca19fdd73..cf11b4f37 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -204,7 +204,7 @@ def _on_rename_result_async(self, session: Session, response: Optional[Workspace if choice == sublime.DIALOG_YES: session.apply_parsed_workspace_edits(changes) elif choice == sublime.DIALOG_NO: - self._render_rename_panel(changes, total_changes, file_count, session.config.name) + 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: @@ -234,6 +234,7 @@ def _get_relative_path(self, file_path: str) -> str: def _render_rename_panel( self, + workspace_edit: WorkspaceEdit, changes_per_uri: WorkspaceChanges, total_changes: int, file_count: int, @@ -302,8 +303,8 @@ def _render_rename_panel( apply=sublime.command_url('chain', { 'commands': [ [ - 'lsp_apply_workspace_changes', - {'session_name': session_name, 'workspace_changes': changes_per_uri} + 'lsp_apply_workspace_edit', + {'session_name': session_name, 'edit': workspace_edit} ], [ 'hide_panel', From 2225e1c005ec5bd89c6135188983220934c69b5e Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 8 Mar 2024 16:32:24 +0100 Subject: [PATCH 11/16] Reword docstring --- plugin/rename.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/rename.py b/plugin/rename.py index cf11b4f37..f7584f5c1 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -74,7 +74,7 @@ def is_range_response(result: PrepareRenameResult) -> TypeGuard[Range]: def utf16_to_code_points(s: str, col: int) -> int: - """ Converts from UTF-16 code units to Unicode code points, usable for string slicing. """ + """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): From 670ff23df12d23c71d1747b83173c6c6931edf6d Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 8 Mar 2024 16:33:09 +0100 Subject: [PATCH 12/16] Rename constant --- plugin/rename.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/rename.py b/plugin/rename.py index f7584f5c1..46e550968 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -61,7 +61,7 @@ Discard """ -DISCARD_COMMAND = sublime.command_url('chain', { +DISCARD_COMMAND_URL = sublime.command_url('chain', { 'commands': [ ['hide_panel', {}], ['lsp_hide_rename_buttons', {}] @@ -316,7 +316,7 @@ def _render_rename_panel( ] ] }), - discard=DISCARD_COMMAND + discard=DISCARD_COMMAND_URL ) buttons.update([ sublime.Phantom(buttons_position, BUTTONS_HTML, sublime.LAYOUT_BLOCK) From 239d4afabe43d5a45f8d7134aa66aabc0c72b596 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 9 Mar 2024 16:18:54 +0100 Subject: [PATCH 13/16] Simplify --- plugin/edit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugin/edit.py b/plugin/edit.py index be22f3964..b22dcdac1 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,5 +1,4 @@ from .core.edit import parse_range -from .core.edit import parse_workspace_edit from .core.logging import debug from .core.protocol import TextEdit from .core.protocol import WorkspaceEdit @@ -35,7 +34,7 @@ def run(self, session_name: str, edit: WorkspaceEdit) -> None: if not session: debug('Could not find session', session_name, 'required to apply WorkspaceEdit') return - session.apply_parsed_workspace_edits(parse_workspace_edit(edit)) + sublime.set_timeout_async(lambda: session.apply_workspace_edit_async(edit)) class LspApplyDocumentEditCommand(sublime_plugin.TextCommand): From c24f4e2ea18e7048f0025263924a1069ab5b9f89 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 10 Mar 2024 22:57:54 +0100 Subject: [PATCH 14/16] CSS tweaks --- plugin/rename.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugin/rename.py b/plugin/rename.py index 46e550968..e37f6e399 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -28,11 +28,10 @@ - Apply + Apply  Discard """ From 36218f97a2d10b6b7fed65160058da7497f5d39d Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 10 Mar 2024 23:10:48 +0100 Subject: [PATCH 15/16] Add method to access PanelManager buttons --- plugin/core/panels.py | 11 ++++++++--- plugin/rename.py | 15 ++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/plugin/core/panels.py b/plugin/core/panels.py index 51473c2a7..58a5190a1 100644 --- a/plugin/core/panels.py +++ b/plugin/core/panels.py @@ -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 @@ -38,7 +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] + 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()): @@ -47,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) @@ -93,7 +94,7 @@ def _create_panel(self, name: str, result_file_regex: str, result_line_regex: st if not panel: return None if name == PanelName.Rename: - self.rename_panel_buttons = sublime.PhantomSet(panel, "lsp_rename_buttons") + 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) @@ -124,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) diff --git a/plugin/rename.py b/plugin/rename.py index e37f6e399..9ebfaa589 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -294,10 +294,6 @@ def _render_rename_panel( selection.add(sublime.Region(0, panel.size())) panel.run_command('toggle_inline_diff') selection.clear() - buttons = pm.rename_panel_buttons - if not buttons: - return - buttons_position = sublime.Region(len(to_render[0]) - 1) BUTTONS_HTML = BUTTONS_TEMPLATE.format( apply=sublime.command_url('chain', { 'commands': [ @@ -317,8 +313,8 @@ def _render_rename_panel( }), discard=DISCARD_COMMAND_URL ) - buttons.update([ - sublime.Phantom(buttons_position, BUTTONS_HTML, sublime.LAYOUT_BLOCK) + pm.update_rename_panel_buttons([ + sublime.Phantom(sublime.Region(len(to_render[0]) - 1), BUTTONS_HTML, sublime.LAYOUT_BLOCK) ]) @@ -350,8 +346,5 @@ def run(self) -> None: if not wm: return pm = wm.panel_manager - if not pm: - return - buttons = pm.rename_panel_buttons - if buttons: - buttons.update([]) + if pm: + pm.update_rename_panel_buttons([]) From 76db9d856e138e922095f479f5dfa63516078c74 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Mon, 11 Mar 2024 15:31:19 +0100 Subject: [PATCH 16/16] More CSS magic --- plugin/rename.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/plugin/rename.py b/plugin/rename.py index 9ebfaa589..3acdbd626 100644 --- a/plugin/rename.py +++ b/plugin/rename.py @@ -28,7 +28,7 @@ @@ -250,7 +247,7 @@ def _render_rename_panel( return to_render = [] # type: List[str] reference_document = [] # type: List[str] - header_lines = "{} changes across {} files.\n\n".format(total_changes, file_count) + 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} {}" @@ -345,6 +342,5 @@ def run(self) -> None: wm = windows.lookup(self.window) if not wm: return - pm = wm.panel_manager - if pm: - pm.update_rename_panel_buttons([]) + if wm.panel_manager: + wm.panel_manager.update_rename_panel_buttons([])