Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
94d7b66
Copy hover response content and diagnostics text from the hover popup
predragnikolic Aug 16, 2025
17f0c5e
fix display of errors diagnostics
predragnikolic Aug 16, 2025
a57a692
prepend the source of the diagnostics when coping diagnostics
predragnikolic Aug 16, 2025
6e524fb
allow copying text from the popup that appears with completions COOPE…
predragnikolic Aug 16, 2025
7d4ade4
cover signature help
predragnikolic Aug 16, 2025
c0ba0a1
rename copy_text_html_element to copy_text_html
predragnikolic Aug 16, 2025
75b3549
remove unused imports
predragnikolic Sep 2, 2025
1d8a53a
set status message as the original copy command
predragnikolic Sep 2, 2025
03c6702
fix flake8
predragnikolic Sep 2, 2025
9765365
make copy_text not nullable
predragnikolic Sep 2, 2025
5a77bf4
inherit the color instead of overriding it
predragnikolic Sep 2, 2025
51417da
fix flake8
predragnikolic Sep 2, 2025
0a25dab
don't expose _markup_to_string and only expose copy_text_html
predragnikolic Sep 2, 2025
bc2a060
split import
predragnikolic Sep 2, 2025
ce73520
sublime.command_url already does html.unescape
predragnikolic Sep 3, 2025
ab65e90
this is the reason why copying content from the signature help opened…
predragnikolic Sep 3, 2025
95d0512
wrap both the diagnostic message and source in a block element
predragnikolic Sep 5, 2025
18b26db
don't handle signature help for now
predragnikolic Sep 10, 2025
8c5a999
Revert "don't handle signature help for now"
predragnikolic Sep 10, 2025
a6655ae
Signature help was actually working, but the tests needed to be updated
predragnikolic Sep 12, 2025
8198c66
Update plugin/core/views.py
predragnikolic Sep 13, 2025
6f52d0d
sort lines
predragnikolic Sep 13, 2025
0e4ce02
if instead of elif
predragnikolic Sep 13, 2025
baed32c
remove .replace(' ', ' ') as I cannot seem to find the example hover …
predragnikolic Sep 13, 2025
1eea3a8
move _markup_to_string after copy_text_html
predragnikolic Sep 13, 2025
1ae508b
rename copy_text_html to wrap_in_copy_link and call markup_to_string …
predragnikolic Sep 13, 2025
08ae8cb
Merge branch 'main' into feature/copy_hover_content
predragnikolic Oct 15, 2025
7b327aa
add debug logs when on_navigate logic is not handled
predragnikolic Oct 15, 2025
aa4be0f
Add copy button for each diagnostic, but remove ability to copy text …
predragnikolic Oct 15, 2025
f05e74a
Revert test changes
predragnikolic Oct 15, 2025
d128f6d
remove wrap_in_copy_link
predragnikolic Oct 15, 2025
5b6088e
Change the look of the copy button
predragnikolic Oct 15, 2025
3b3839c
revert as it was
predragnikolic Oct 15, 2025
e02f3db
Reduce the padding
predragnikolic Oct 15, 2025
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 boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
from .plugin.hierarchy import LspCallHierarchyCommand
from .plugin.hierarchy import LspHierarchyToggleCommand
from .plugin.hierarchy import LspTypeHierarchyCommand
from .plugin.hover import LspCopyTextCommand
from .plugin.hover import LspHoverCommand
from .plugin.hover import LspToggleHoverPopupsCommand
from .plugin.inlay_hint import LspInlayHintClickCommand
Expand Down Expand Up @@ -100,6 +101,7 @@
"LspCollapseTreeItemCommand",
"LspColorPresentationCommand",
"LspCommitCompletionWithOppositeInsertMode",
"LspCopyTextCommand",
"LspCopyToClipboardFromBase64Command",
"LspDisableLanguageServerGloballyCommand",
"LspDisableLanguageServerInProjectCommand",
Expand Down
11 changes: 7 additions & 4 deletions plugin/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
from .core.edit import apply_text_edits
from .core.logging import debug
from .core.promise import Promise
from .core.protocol import EditRangeWithInsertReplace
from .core.protocol import CompletionItem
from .core.protocol import CompletionItemDefaults
from .core.protocol import CompletionItemKind
from .core.protocol import CompletionItemTag
from .core.protocol import CompletionList
from .core.protocol import CompletionParams
from .core.protocol import EditRangeWithInsertReplace
from .core.protocol import Error
from .core.protocol import InsertReplaceEdit
from .core.protocol import InsertTextFormat
Expand All @@ -20,6 +20,7 @@
from .core.registry import LspTextCommand
from .core.sessions import Session
from .core.settings import userprefs
from .core.views import copy_text_html
from .core.views import FORMAT_STRING, FORMAT_MARKUP_CONTENT
from .core.views import MarkdownLangMap
from .core.views import minihtml
Expand Down Expand Up @@ -303,9 +304,10 @@ def _handle_resolve_response_async(self, language_map: MarkdownLangMap | None, i
documentation = self._format_documentation(markdown, None)
minihtml_content = ""
if detail:
minihtml_content += f"<div class='highlight'>{detail}</div>"
minihtml_content += copy_text_html(f"<div class='highlight'>{detail}</div>",
copy_text=item.get('detail') or "")
if documentation:
minihtml_content += documentation
minihtml_content += copy_text_html(documentation, copy_text=item.get("documentation") or "")

def run_main() -> None:
if not self.view.is_valid():
Expand All @@ -330,7 +332,8 @@ def _format_documentation(
return minihtml(self.view, content, FORMAT_STRING | FORMAT_MARKUP_CONTENT, language_map)

def _on_navigate(self, url: str) -> None:
webbrowser.open(url)
if url.startswith("http"):
webbrowser.open(url)


class LspCommitCompletionWithOppositeInsertMode(LspTextCommand):
Expand Down
13 changes: 8 additions & 5 deletions plugin/core/signature_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .protocol import SignatureHelp
from .protocol import SignatureInformation
from .registry import LspTextCommand
from .views import copy_text_html
from .views import FORMAT_MARKUP_CONTENT
from .views import FORMAT_STRING
from .views import MarkdownLangMap
Expand Down Expand Up @@ -83,7 +84,7 @@ def render(self, view: sublime.View) -> str:
else:
self._active_parameter_bold = active_parameter_style.get('bold', False)
self._active_parameter_underline = active_parameter_style.get('underline', False)
formatted.extend(self._render_label(signature))
formatted.append(self._render_label(signature))
formatted.extend(self._render_docs(view, signature))
return "".join(formatted)

Expand Down Expand Up @@ -111,7 +112,7 @@ def _render_intro(self) -> str:
len(self._signatures),
)

def _render_label(self, signature: SignatureInformation) -> list[str]:
def _render_label(self, signature: SignatureInformation) -> str:
formatted: list[str] = []
# Note that this <div> class and the extra <pre> are copied from mdpopups' HTML output. When mdpopups changes
# its output style, we must update this literal string accordingly.
Expand Down Expand Up @@ -147,7 +148,7 @@ def _render_label(self, signature: SignatureInformation) -> list[str]:
else:
formatted.append(self._function(label))
formatted.append("</pre></div>")
return formatted
return copy_text_html("".join(formatted), label)

def _render_docs(self, view: sublime.View, signature: SignatureInformation) -> list[str]:
formatted: list[str] = []
Expand Down Expand Up @@ -175,14 +176,16 @@ def _parameter_documentation(self, view: sublime.View, signature: SignatureInfor
documentation = parameter.get("documentation")
if documentation:
allowed_formats = FORMAT_STRING | FORMAT_MARKUP_CONTENT
return minihtml(view, documentation, allowed_formats, self._language_map)
return copy_text_html(minihtml(view, documentation, allowed_formats, self._language_map),
copy_text=documentation)
return None

def _signature_documentation(self, view: sublime.View, signature: SignatureInformation) -> str | None:
documentation = signature.get("documentation")
if documentation:
allowed_formats = FORMAT_STRING | FORMAT_MARKUP_CONTENT
return minihtml(view, documentation, allowed_formats, self._language_map)
return copy_text_html(minihtml(view, documentation, allowed_formats, self._language_map),
copy_text=documentation)
return None

def _function(self, content: str) -> str:
Expand Down
30 changes: 27 additions & 3 deletions plugin/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,9 +820,9 @@ def _html_element(name: str, text: str, class_name: str | None = None, escape: b


def format_diagnostic_for_html(config: ClientConfig, diagnostic: Diagnostic, base_dir: str | None = None) -> str:
html = _html_element('span', diagnostic["message"])
code = diagnostic.get("code")
source = diagnostic.get("source")
source = diagnostic.get("source") or ""
html = _html_element('span', diagnostic["message"])
if source or code is not None:
meta_info = ""
if source:
Expand All @@ -832,14 +832,38 @@ def format_diagnostic_for_html(config: ClientConfig, diagnostic: Diagnostic, bas
meta_info += "({})".format(
make_link(code_description["href"], str(code)) if code_description else text2html(str(code)))
html += " " + _html_element("span", meta_info, class_name="color-muted", escape=False)
html = copy_text_html(html, copy_text=f"{source} {diagnostic['message']}")
related_infos = diagnostic.get("relatedInformation")
if related_infos:
info = "<br>".join(_format_diagnostic_related_info(config, info, base_dir) for info in related_infos)
info = "<br>".join(copy_text_html(
_format_diagnostic_related_info(config, info, base_dir),
copy_text=info['message']
) for info in related_infos)
html += '<br>' + _html_element("pre", info, class_name="related_info", escape=False)
severity_class = DIAGNOSTIC_SEVERITY[diagnostic_severity(diagnostic) - 1][1]
return _html_element("pre", html, class_name=severity_class, escape=False)


def copy_text_html(html_content: str, copy_text: str | MarkupContent | MarkedString | list[MarkedString]) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This naming makes it hard to understand the purpose IMO.

Perhaps wrap_in_copy_to_clipboard_link with arguments link_text and text_to_copy?

And maybe the _markup_to_string should also be done separately so that this function is more specialized and less do-it-all.

copy_text = _markup_to_string(copy_text)
if not len(copy_text):
return html_content
return f"""<a title="Click to Copy"
style='text-decoration: none; display: block; color: inherit'
href='{sublime.command_url('lsp_copy_text', {
'text': copy_text
})}'>{html_content}</a>"""
Copy link
Member

@rchl rchl Sep 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to create an extra padding in the popups (the bottom one should be the same as all other):

Image

I have not looked into it too much. Perhaps there is an empty a element there?
Or perhaps it has some margin collapsing bug which ST is known for.

Copy link
Member Author

@predragnikolic predragnikolic Sep 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be a

When I hover:

const foo = "bar"

the html content with this branch is:

mdpopups: =====HTML OUTPUT=====
mdpopups: 
<body><a title="Click to Copy"
       style='text-decoration: none; display: block; color: inherit'
       href='subl:lsp_copy_text {&quot;text&quot;:&quot;\n```typescript\nconst foo: \&quot;bar\&quot;\n```\n&quot;}'><div class="highlight"><pre><span style="color: #fcb6b6;">const</span><span style="color: #ffffff;"> </span><span style="color: #ffffff;">foo</span><span style="color: #ffffff;">:</span><span style="color: #ffffff;"> </span><span style="color: #fcd49e;">"</span><span style="color: #fcd49e;">bar</span><span style="color: #fcd49e;">"</span><br></pre></div></a><div class="lsp_popup--spacer"></div></body>

while the html with the main branch still contains a br

mdpopups: =====HTML OUTPUT=====
mdpopups: 
<body><div class="highlight"><pre><span style="color: #fcb6b6;">const</span><span style="color: #ffffff;"> </span><span style="color: #ffffff;">foo</span><span style="color: #ffffff;">:</span><span style="color: #ffffff;"> </span><span style="color: #fcd49e;">"</span><span style="color: #fcd49e;">bar</span><span style="color: #fcd49e;">"</span><br></pre></div><div class="lsp_popup--spacer"></div></body>

but there is margin bottom on main.

I will see what is going on.



def _markup_to_string(content: MarkupContent | MarkedString | list[MarkedString]) -> str:
if isinstance(content, str):
return content
if isinstance(content, dict):
return content.get('value', '')
if isinstance(content, list):
return " ".join([_markup_to_string(text) for text in content])


def format_code_actions_for_quick_panel(
session_actions: Iterable[tuple[str, CodeAction | Command]]
) -> tuple[list[sublime.QuickPanelItem], int]:
Expand Down
3 changes: 2 additions & 1 deletion plugin/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,8 @@ def _on_sighelp_hide(self) -> None:
self._sighelp = None

def _on_sighelp_navigate(self, href: str) -> None:
webbrowser.open_new_tab(href)
if href.startswith("http"):
webbrowser.open_new_tab(href)

# --- textDocument/codeAction --------------------------------------------------------------------------------------

Expand Down
15 changes: 12 additions & 3 deletions plugin/hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .core.sessions import SessionBufferProtocol
from .core.settings import userprefs
from .core.url import parse_uri
from .core.views import copy_text_html
from .core.views import diagnostic_severity
from .core.views import format_code_actions_for_quick_panel
from .core.views import format_diagnostic_for_html
Expand All @@ -46,7 +47,6 @@
import sublime
import sublime_plugin


SessionName = str
ResolvedHover = Union[Hover, Error]

Expand Down Expand Up @@ -259,11 +259,13 @@ def diagnostics_content(self) -> str:
return "".join(formatted)

def hover_content(self) -> str:
contents = []
contents: list[str] = []
for hover, language_map in self._hover_responses:
content = (hover.get('contents') or '') if isinstance(hover, dict) else ''
allowed_formats = FORMAT_MARKED_STRING | FORMAT_MARKUP_CONTENT
contents.append(minihtml(self.view, content, allowed_formats, language_map))
html_content = copy_text_html(minihtml(self.view, content, allowed_formats, language_map),
copy_text=content)
contents.append(html_content)
return '<hr>'.join(contents)

def hover_range(self) -> sublime.Region | None:
Expand Down Expand Up @@ -420,3 +422,10 @@ def _update_views_async(self, enable: bool) -> None:
session_view.view.settings().set(SHOW_DEFINITIONS_KEY, False)
else:
session_view.reset_show_definitions()


class LspCopyTextCommand(sublime_plugin.TextCommand):
def run(self, edit, text: str) -> None:
sublime.set_clipboard(text)
text_length = len(text)
sublime.status_message(f"Copied {text_length} character{'s' if text_length > 1 else ''}")
33 changes: 30 additions & 3 deletions tests/test_signature_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,22 @@ def test_signature(self) -> None:
"activeParameter": 0
},
r'''
<a[^>]+>
<div class="highlight"><pre>
<span style="color: #\w{6}">f\(</span>
<span style="color: #\w{6}; font-weight: bold; text-decoration: underline">x</span>
<span style="color: #\w{6}">\)</span>
</pre></div>
</a>
<a[^>]+>
<p>must be in the frobnicate range</p>
</a>
<hr/>
<div style="font-size: 0\.9rem"><p>f does interesting things</p></div>
<div style="font-size: 0\.9rem">
<a[^>]+>
<p>f does interesting things</p>
</a>
</div>
'''
)

Expand Down Expand Up @@ -84,14 +92,22 @@ def test_markdown(self) -> None:
"activeParameter": 0
},
r'''
<a[^>]+>
<div class="highlight"><pre>
<span style="color: #\w{6}">f\(</span>
<span style="color: #\w{6}; font-weight: bold; text-decoration: underline">x</span>
<span style="color: #\w{6}">\)</span>
</pre></div>
</a>
<a[^>]+>
<p>must be in the <strong>frobnicate</strong> range</p>
</a>
<hr/>
<div style="font-size: 0\.9rem"><p>f does <em>interesting</em> things</p></div>
<div style="font-size: 0\.9rem">
<a[^>]+>
<p>f does <em>interesting</em> things</p>
</a>
</div>
'''
)

Expand All @@ -118,14 +134,18 @@ def test_second_parameter(self) -> None:
"activeParameter": 1
},
r'''
<a[^>]+>
<div class="highlight"><pre>
<span style="color: #\w{6}">f\(</span>
<span style="color: #\w{6}">x</span>
<span style="color: #\w{6}">, </span>
<span style="color: #\w{6}; font-weight: bold; text-decoration: underline">y</span>
<span style="color: #\w{6}">\)</span>
</pre></div>
</a>
<a[^>]+>
<p>hello there</p>
</a>
'''
)

Expand All @@ -152,14 +172,18 @@ def test_parameter_ranges(self) -> None:
"activeParameter": 1
},
r'''
<a[^>]+>
<div class="highlight"><pre>
<span style="color: #\w{6}">f\(</span>
<span style="color: #\w{6}">x</span>
<span style="color: #\w{6}">, </span>
<span style="color: #\w{6}; font-weight: bold; text-decoration: underline">y</span>
<span style="color: #\w{6}">\)</span>
</pre></div>
</a>
<a[^>]+>
<p>hello there</p>
</a>
'''
)

Expand Down Expand Up @@ -206,6 +230,7 @@ def test_overloads(self) -> None:
<b>2</b> of <b>2</b> overloads \(use <kbd>↑</kbd> <kbd>↓</kbd> to navigate, press <kbd>Esc</kbd> to hide\):
</div>
</p>
<a[^>]+>
<div class="highlight"><pre><span style="color: #\w{6}">f\(</span>
<span style="color: #\w{6}; font-weight: bold; text-decoration: underline">x</span>
<span style="color: #\w{6}">, </span>
Expand All @@ -214,6 +239,7 @@ def test_overloads(self) -> None:
<span style="color: #\w{6}">b</span>
<span style="color: #\w{6}">\)</span>
</pre></div>
</a>
'''
)

Expand All @@ -238,7 +264,7 @@ def test_dockerfile_signature(self) -> None:
"activeParameter": 2
},
r'''
<div class="highlight"><pre>
<a[^>]+><div class="highlight"><pre>
<span style="color: #\w{6}">RUN </span>
<span style="color: #\w{6}">\[</span>
<span style="color: #\w{6}"> </span>
Expand All @@ -250,5 +276,6 @@ def test_dockerfile_signature(self) -> None:
<span style="color: #\w{6}"> </span>
<span style="color: #\w{6}">\]</span>
</pre></div>
</a>
'''
)
Loading