diff --git a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java
index 235c5196..9bfce520 100644
--- a/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java
+++ b/src/main/java/ee/carlrobert/codegpt/toolwindow/chat/ui/ChatMessageResponseBody.java
@@ -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;
}
diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt
new file mode 100644
index 00000000..513c1fd1
--- /dev/null
+++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt
@@ -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 = "
${escape(desc)}"
+ 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 {
+ 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(")", RegexOption.IGNORE_CASE), "$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
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt
index f1c75891..888915bd 100644
--- a/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt
+++ b/src/main/kotlin/ee/carlrobert/codegpt/util/PsiLinkNavigator.kt
@@ -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()
- .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()
+ .firstOrNull { it.canNavigate() }
+ ?.let { return it }
+
+ val symbolModel = GotoSymbolModel2(project, project)
+ symbolModel.getElementsByName(searchTerm, true, searchTerm)
+ .filterIsInstance()
+ .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
}
\ No newline at end of file