feat: add psi link doc hover preview (Java support only)

This commit is contained in:
Carl-Robert Linnupuu 2025-10-03 12:53:45 +01:00
parent 58fa8d8819
commit cec9bb6f87
3 changed files with 311 additions and 22 deletions

View file

@ -61,6 +61,7 @@ import ee.carlrobert.codegpt.toolwindow.ui.ResponseBodyProgressPanel;
import ee.carlrobert.codegpt.toolwindow.ui.WebpageList;
import ee.carlrobert.codegpt.ui.ThoughtProcessPanel;
import ee.carlrobert.codegpt.ui.UIUtil;
import ee.carlrobert.codegpt.ui.hover.PsiLinkHoverPreview;
import ee.carlrobert.codegpt.util.EditorUtil;
import ee.carlrobert.codegpt.util.MarkdownUtil;
import java.awt.BorderLayout;
@ -442,6 +443,8 @@ public class ChatMessageResponseBody extends JPanel {
installPopupMenu(textPane);
PsiLinkHoverPreview.install(project, textPane);
return textPane;
}

View file

@ -0,0 +1,269 @@
package ee.carlrobert.codegpt.ui.hover
import com.intellij.codeInsight.documentation.DocumentationHintEditorPane
import com.intellij.codeInsight.documentation.DocumentationManager
import com.intellij.lang.documentation.DocumentationImageResolver
import com.intellij.lang.documentation.psi.createPsiDocumentationTarget
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.platform.backend.documentation.DocumentationTarget
import com.intellij.psi.PsiElement
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.awt.RelativePoint
import com.intellij.ui.popup.PopupFactoryImpl
import com.intellij.util.concurrency.AppExecutorUtil
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import ee.carlrobert.codegpt.util.NavigationResolverFactory
import java.awt.Dimension
import java.awt.Image
import java.awt.MouseInfo
import java.awt.Point
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import javax.swing.JComponent
import javax.swing.SwingUtilities
import javax.swing.JTextPane
import javax.swing.event.HyperlinkEvent
import javax.swing.event.HyperlinkListener
object PsiLinkHoverPreview {
private val logger = thisLogger()
private const val PSI_PREFIX = "psi_element://"
private const val FILE_PREFIX = "file://"
private const val HOVER_DELAY_MS = 250L
private const val EXIT_CLOSE_DELAY_MS = 120L
@JvmStatic
fun install(project: Project, textPane: JTextPane) {
val manager = HoverManager(project, textPane)
textPane.addHyperlinkListener(manager)
textPane.addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
manager.cancelAndHide()
}
})
}
private class HoverManager(
private val project: Project,
private val textPane: JTextPane
) : HyperlinkListener {
private var pending: ScheduledFuture<*>? = null
private var scheduledClose: ScheduledFuture<*>? = null
private var popup: JBPopup? = null
private var popupComponent: JComponent? = null
private var lastDesc: String? = null
private val scheduledExecutor = AppExecutorUtil.getAppScheduledExecutorService()
private val executor = AppExecutorUtil.getAppExecutorService()
override fun hyperlinkUpdate(e: HyperlinkEvent) {
when (e.eventType) {
HyperlinkEvent.EventType.ENTERED -> onEntered(e)
HyperlinkEvent.EventType.EXITED -> onExited()
else -> {}
}
}
private fun onExited() {
scheduledClose?.cancel(true)
scheduledClose = scheduledExecutor.schedule({
ApplicationManager.getApplication().invokeLater({
cancelAndHide()
}, ModalityState.any())
}, EXIT_CLOSE_DELAY_MS, TimeUnit.MILLISECONDS)
}
fun cancelAndHide() {
scheduledClose?.cancel(true)
scheduledClose = null
try {
val comp = popupComponent
if (comp != null && isMouseOverComponent(comp)) return
} catch (_: Throwable) {}
pending?.cancel(true)
pending = null
try {
if (SwingUtilities.isEventDispatchThread()) {
popup?.cancel()
popup = null
popupComponent = null
} else {
ApplicationManager.getApplication().invokeLater({
popup?.cancel()
popup = null
popupComponent = null
}, ModalityState.any())
}
} catch (t: Throwable) {
logger.warn("Failed while cancelling popup", t)
}
}
private fun onEntered(e: HyperlinkEvent) {
val desc = e.description ?: return
lastDesc = desc
scheduledClose?.cancel(true)
scheduledClose = null
pending?.cancel(true)
val point = mouseRelativePointBelow(textPane)
pending = scheduledExecutor.schedule({
resolveAndShow(desc, point)
}, HOVER_DELAY_MS, TimeUnit.MILLISECONDS)
}
private fun resolveAndShow(desc: String, where: RelativePoint) {
try {
when {
desc.startsWith(PSI_PREFIX) || desc.startsWith(FILE_PREFIX) -> {
val prefix = if (desc.startsWith(PSI_PREFIX)) PSI_PREFIX else FILE_PREFIX
val target = decode(desc.removePrefix(prefix))
resolvePsiLikeAndShow(prefix, target, desc, where)
}
else -> {
if (desc != lastDesc) return
val html = "<html><body>${escape(desc)}</body></html>"
showDocHint(where, html)
}
}
} catch (t: Throwable) {
logger.warn("Failed to resolve hover target: $desc", t)
}
}
private fun resolvePsiLikeAndShow(
prefix: String,
target: String,
originalDesc: String,
where: RelativePoint
) {
ReadAction.nonBlocking<String?> {
val resolver = NavigationResolverFactory.create(prefix)
val element = resolver.resolve(target)
buildPsiHtmlOffEdt(element)
}
.expireWith(project)
.finishOnUiThread(ModalityState.any()) { html ->
if (originalDesc != lastDesc || html.isNullOrBlank()) return@finishOnUiThread
showDocHint(where, html)
}
.submit(executor)
}
private fun showDocHint(where: RelativePoint, html: String) {
scheduledClose?.cancel(true)
scheduledClose = null
cancelAndHide()
val imageResolver = object : DocumentationImageResolver {
override fun resolveImage(p0: String): Image? = null
}
val safeHtml = if (html.contains("<body", ignoreCase = true)) {
html.replaceFirst(Regex("<body(\\s|>)", RegexOption.IGNORE_CASE), "<body style=\"margin:0;padding:0\"$1")
} else {
"<html><body style=\"margin:0;padding:0\">$html</body></html>"
}
val editor = DocumentationHintEditorPane(project, emptyMap(), imageResolver).apply {
isEditable = false
try { contentType = "text/html" } catch (_: Throwable) {}
text = safeHtml
caretPosition = 0
try { font = JBFont.label() } catch (_: Throwable) {}
background = UIUtil.getToolTipBackground()
isOpaque = true
try { margin = JBUI.insets(8) } catch (_: Throwable) {}
}
val scroll = ScrollPaneFactory.createScrollPane(editor).apply {
border = JBUI.Borders.empty()
viewportBorder = null
viewport.background = UIUtil.getToolTipBackground()
}
val maxW = JBUI.scale(600)
val maxH = JBUI.scale(400)
editor.size = Dimension(maxW, Int.MAX_VALUE)
val pref = editor.preferredSize
val w = kotlin.math.min(pref.width.coerceAtLeast(200), maxW)
val h = kotlin.math.min(pref.height.coerceAtLeast(50), maxH)
scroll.preferredSize = Dimension(w, h)
val newPopup = PopupFactoryImpl.getInstance()
.createComponentPopupBuilder(scroll, null)
.setRequestFocus(false)
.setResizable(true)
.setMovable(false)
.setShowShadow(true)
.setCancelOnClickOutside(true)
.setCancelKeyEnabled(true)
.createPopup()
try { editor.setHint(newPopup) } catch (_: Throwable) {}
popup = newPopup
popupComponent = scroll
ApplicationManager.getApplication().invokeLater({
try { popup?.show(where) } catch (t: Throwable) { logger.warn("Failed to show popup", t) }
}, ModalityState.any())
}
private fun mouseRelativePointBelow(component: JComponent): RelativePoint {
val mp = component.mousePosition ?: Point(component.width / 2, 0)
val fm = component.getFontMetrics(component.font)
val offset = fm.height + JBUI.scale(4)
val p = Point(mp.x, mp.y + offset)
return RelativePoint(component, p)
}
private fun isMouseOverComponent(component: JComponent): Boolean {
return try {
val pointer = MouseInfo.getPointerInfo()?.location ?: return false
val compPt = Point(pointer.x, pointer.y)
SwingUtilities.convertPointFromScreen(compPt, component)
component.contains(compPt)
} catch (t: Throwable) {
logger.debug("Failed to determine mouse-over state", t)
false
}
}
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8)
} catch (_: Throwable) {
value
}
private fun escape(text: String): String =
text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
private fun buildPsiHtmlOffEdt(element: Any?): String? {
if (element !is PsiElement) return null
val provider = DocumentationManager.getProviderFromElement(element)
return try {
provider.generateDoc(element, element.containingFile)
} catch (t: Throwable) {
logger.warn("Failed to generate doc for ${element.text}", t)
null
}
}
}
}

View file

@ -1,7 +1,6 @@
package ee.carlrobert.codegpt.util
import com.intellij.ide.util.gotoByName.GotoClassModel2
import com.intellij.ide.util.gotoByName.GotoFileModel
import com.intellij.ide.util.gotoByName.GotoSymbolModel2
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.ReadAction
@ -13,7 +12,6 @@ import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.concurrency.AppExecutorUtil
import org.jetbrains.kotlin.psi.KtClass
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
@ -76,9 +74,8 @@ object PsiLinkNavigator {
private fun navigate(target: Navigatable) {
if (target.canNavigate()) {
target.navigate(true)
logger.info("Successfully navigated to: $target")
} else {
logger.info("Target cannot navigate: $target")
logger.warn("Cannot navigate to target: $target")
}
}
}
@ -93,7 +90,6 @@ abstract class NavigationResolver {
class PsiElementResolver : NavigationResolver() {
override fun resolve(target: String): Navigatable? {
val project = getProject() ?: return null
logger.info("Resolving PSI element: $target")
findByJavaFQN(project, target)?.let { return it }
@ -157,7 +153,7 @@ class PsiElementResolver : NavigationResolver() {
findMemberInClass(ownerElement, member)?.let { return it }
}
if (ownerElement is PsiElement) {
findNavigatableElement(ownerElement, member)?.let { return it }
ownerElement.findNavigatableChildElement(member)?.let { return it }
}
}
return ownerElement
@ -171,12 +167,7 @@ class PsiElementResolver : NavigationResolver() {
psiClass.findFieldByName(memberName, false)?.let { return it }
psiClass.findInnerClassByName(memberName, false)?.let { return it }
return findNavigatableElement(psiClass, memberName)
}
private fun findNavigatableElement(psiElement: PsiElement, memberName: String): Navigatable? {
return PsiTreeUtil.findChildrenOfType(psiElement, PsiNamedElement::class.java)
.firstOrNull { it.name == memberName } as? Navigatable
return psiClass.findNavigatableChildElement(memberName)
}
private fun searchInModels(project: Project, searchTerm: String): Navigatable? {
@ -203,7 +194,6 @@ class FileResolver : NavigationResolver() {
override fun resolve(target: String): Navigatable? {
val project = getProject() ?: return null
logger.info("Resolving file: $target")
val memberSeparatorIndex = target.indexOf('#')
val filePath = if (memberSeparatorIndex > 0) {
@ -224,26 +214,48 @@ class FileResolver : NavigationResolver() {
if (psiFile != null) {
if (memberSeparatorIndex > 0) {
val memberName = target.substring(memberSeparatorIndex + 1)
val memberElement =
PsiTreeUtil.findChildrenOfType(psiFile, PsiNamedElement::class.java)
.firstOrNull { it.name == memberName }
return (memberElement as? Navigatable) ?: psiFile
val memberElement = psiFile.findNavigatableChildElement(memberName)
return memberElement ?: psiFile
}
return psiFile
}
}
return try {
val fileModel = GotoFileModel(project)
fileModel.getElementsByName(fileName, true, fileName)
.filterIsInstance<Navigatable>()
.firstOrNull { it.canNavigate() }
val fileNameWithoutExtension = fileName.takeWhile { it == '.' }
searchInModels(project, fileNameWithoutExtension)?.let { ownerElement ->
val member = target.substring(memberSeparatorIndex + 1)
if (memberSeparatorIndex > 0) {
if (ownerElement is PsiElement) {
ownerElement.findNavigatableChildElement(member)?.let { return it }
}
}
return ownerElement
}
} catch (t: Throwable) {
logger.warn("File search failed for: $target", t)
null
}
}
private fun searchInModels(project: Project, searchTerm: String): Navigatable? {
try {
val classModel = GotoClassModel2(project)
classModel.getElementsByName(searchTerm, true, searchTerm)
.filterIsInstance<Navigatable>()
.firstOrNull { it.canNavigate() }
?.let { return it }
val symbolModel = GotoSymbolModel2(project, project)
symbolModel.getElementsByName(searchTerm, true, searchTerm)
.filterIsInstance<Navigatable>()
.firstOrNull { it.canNavigate() }
?.let { return it }
} catch (e: Exception) {
logger.warn("Search failed for term: $searchTerm", e)
}
return null
}
}
object NavigationResolverFactory {
@ -254,4 +266,9 @@ object NavigationResolverFactory {
else -> throw IllegalArgumentException("Unsupported protocol: $protocol")
}
}
}
private fun PsiElement.findNavigatableChildElement(memberName: String): Navigatable? {
return PsiTreeUtil.findChildrenOfType(this, PsiNamedElement::class.java)
.firstOrNull { it.name == memberName } as? Navigatable
}