mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 07:54:46 +00:00
feat: add psi link doc hover preview (Java support only)
This commit is contained in:
parent
58fa8d8819
commit
cec9bb6f87
3 changed files with 311 additions and 22 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue