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