From 80847857dea18964238eeadb73ec3b8df0a07030 Mon Sep 17 00:00:00 2001 From: Milan Doslic Date: Thu, 13 Mar 2025 03:13:12 +0100 Subject: [PATCH] [markdown] IJPL-177111 add a Registry flag for falling back to the old implementation of link opening does not work in split, will be squashed once it is fixed GitOrigin-RevId: 4fa6bd89e364de0b88383cd6e2b430fc0ea096bc --- .../resources/intellij.markdown.frontend.xml | 3 +- .../accessor/DelegatingMarkdownLinkOpener.kt | 35 +++ .../accessor/impl/MarkdownLinkOpenerImpl.kt | 4 +- .../core/resources/META-INF/plugin.xml | 4 + .../accessor/impl/MarkdownLinkOpenerImpl.kt | 272 ++++++++++++++++++ 5 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/DelegatingMarkdownLinkOpener.kt create mode 100644 plugins/markdown/core/src/org/intellij/plugins/markdown/ui/preview/accessor/impl/MarkdownLinkOpenerImpl.kt diff --git a/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml b/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml index e17a79abc145..aa9284e235de 100644 --- a/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml +++ b/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml @@ -4,6 +4,7 @@ + serviceImplementation="org.intellij.plugins.markdown.frontend.preview.accessor.DelegatingMarkdownLinkOpener"/> + \ No newline at end of file diff --git a/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/DelegatingMarkdownLinkOpener.kt b/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/DelegatingMarkdownLinkOpener.kt new file mode 100644 index 000000000000..cafedaa3fcce --- /dev/null +++ b/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/DelegatingMarkdownLinkOpener.kt @@ -0,0 +1,35 @@ +package org.intellij.plugins.markdown.frontend.preview.accessor + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.vfs.VirtualFile +import org.intellij.plugins.markdown.ui.preview.accessor.MarkdownLinkOpener +import org.intellij.plugins.markdown.ui.preview.accessor.impl.MarkdownLinkOpenerImpl + +class DelegatingMarkdownLinkOpener : MarkdownLinkOpener { + private val oldImplementation: MarkdownLinkOpenerImpl by lazy { + service() + } + private val newImplementation: org.intellij.plugins.markdown.frontend.preview.accessor.impl.MarkdownLinkOpenerImpl by lazy { + service() + } + + private val useFallbackLinkOpener: Boolean = Registry.`is`("markdown.use.fallback.link.opener", false) + + override fun openLink(project: Project?, link: String, virtualFile: VirtualFile?) { + if (useFallbackLinkOpener) { + oldImplementation.openLink(project, link, virtualFile) + } else { + newImplementation.openLink(project, link, virtualFile) + } + } + + override fun isSafeLink(project: Project?, link: String): Boolean { + return if (useFallbackLinkOpener) { + oldImplementation.isSafeLink(project, link) + } else { + newImplementation.isSafeLink(project, link) + } + } +} \ No newline at end of file diff --git a/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/impl/MarkdownLinkOpenerImpl.kt b/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/impl/MarkdownLinkOpenerImpl.kt index 9af7fd715eed..417b6934670b 100644 --- a/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/impl/MarkdownLinkOpenerImpl.kt +++ b/plugins/markdown/core/frontend/src/org/intellij/plugins/markdown/frontend/preview/accessor/impl/MarkdownLinkOpenerImpl.kt @@ -1,6 +1,7 @@ package org.intellij.plugins.markdown.frontend.preview.accessor.impl import com.intellij.ide.BrowserUtil +import com.intellij.ide.vfs.rpcId import com.intellij.openapi.application.EDT import com.intellij.openapi.application.runReadAction import com.intellij.openapi.diagnostic.logger @@ -15,6 +16,7 @@ import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.ui.popup.PopupStep import com.intellij.openapi.ui.popup.util.BaseListPopupStep import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.wm.WindowManager import com.intellij.platform.project.projectId import com.intellij.ui.awt.RelativePoint @@ -38,7 +40,7 @@ internal class MarkdownLinkOpenerImpl private val coroutineScope: CoroutineScope ) : MarkdownLinkOpener { - override fun openLink(project: Project?, link: String) { + override fun openLink(project: Project?, link: String, virtualFile: VirtualFile?) { val uri = createUri(link) ?: return if (tryOpenInEditor(project, uri)) { return diff --git a/plugins/markdown/core/resources/META-INF/plugin.xml b/plugins/markdown/core/resources/META-INF/plugin.xml index 376dcec1855d..0757109f129b 100644 --- a/plugins/markdown/core/resources/META-INF/plugin.xml +++ b/plugins/markdown/core/resources/META-INF/plugin.xml @@ -364,6 +364,10 @@ + + diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/ui/preview/accessor/impl/MarkdownLinkOpenerImpl.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/ui/preview/accessor/impl/MarkdownLinkOpenerImpl.kt new file mode 100644 index 000000000000..ab18a92441a1 --- /dev/null +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/ui/preview/accessor/impl/MarkdownLinkOpenerImpl.kt @@ -0,0 +1,272 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.intellij.plugins.markdown.ui.preview.accessor.impl + +import com.intellij.ide.BrowserUtil +import com.intellij.ide.actions.OpenFileAction +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbModeBlockedFunctionality +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectForFile +import com.intellij.openapi.ui.DoNotAskOption +import com.intellij.openapi.ui.MessageDialogBuilder +import com.intellij.openapi.ui.MessageType +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.WindowManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiManager +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.PsiNavigateUtil +import com.intellij.util.UriUtil +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.io.isLocalHost +import org.intellij.plugins.markdown.MarkdownBundle +import org.intellij.plugins.markdown.lang.index.HeaderAnchorIndex +import org.intellij.plugins.markdown.settings.DocumentLinksSafeState +import org.intellij.plugins.markdown.ui.MarkdownNotifications +import org.intellij.plugins.markdown.ui.preview.MarkdownEditorWithPreview +import org.intellij.plugins.markdown.ui.preview.accessor.MarkdownLinkOpener +import org.intellij.plugins.markdown.util.MarkdownDisposable +import org.jetbrains.annotations.ApiStatus +import java.net.URI +import java.net.URISyntaxException +import java.nio.file.Path + +@Service +@ApiStatus.Internal +class MarkdownLinkOpenerImpl : MarkdownLinkOpener { + override fun openLink(project: Project?, link: String, virtualFile: VirtualFile?) { + val uri = createUri(link) ?: return + if (tryOpenInEditor(project, uri)) { + return + } + invokeLater { + openExternalLink(project, uri) + } + } + + override fun isSafeLink(project: Project?, link: String): Boolean { + val uri = createUri(link)?: return false + return isSafeUri(project, uri) + } + + private fun isSafeUri(project: Project?, uri: URI): Boolean { + val protocol = uri.scheme ?: return false + if (project != null) { + val safeLinksState = DocumentLinksSafeState.getInstance(project) + return safeLinksState.isProtocolAllowed(protocol) + } + return DocumentLinksSafeState.isHttpScheme(protocol) && isLocalHost(uri.host) + } + + @RequiresEdt + private fun openExternalLink(project: Project?, uri: URI) { + if (isSafeUri(project, uri)) { + actuallyBrowseExternalLink(project, uri) + return + } + if (showDialog(project, uri)) { + actuallyBrowseExternalLink(project, uri) + } + } + + @RequiresEdt + private fun showDialog(project: Project?, uri: URI): Boolean { + val dialog = MessageDialogBuilder.yesNo( + title = MarkdownBundle.message("markdown.browse.external.link.open.confirmation.dialog.title"), + message = MarkdownBundle.message("markdown.browse.external.link.open.confirmation.dialog.text", uri) + ).doNotAsk(createDoNotAskOption(project, uri)) + return dialog.ask(project) + } + + @RequiresEdt + private fun actuallyBrowseExternalLink(project: Project?, uri: URI) { + try { + BrowserUtil.browse(uri) + } catch (exception: Throwable) { + logger.warn("Failed to browse external link!", exception) + MarkdownNotifications.showWarning( + project, + id = "markdown.links.external.open.failed", + title = MarkdownBundle.message("markdown.browse.external.link.failed.notification.title"), + message = MarkdownBundle.message("markdown.browse.external.link.failed.notification.content", uri), + ) + } + } + + private fun createDoNotAskOption(project: Project?, uri: URI): DoNotAskOption? { + if (project == null) { + return null + } + val protocol = uri.scheme + if (protocol == null) { + logger.error("Failed to obtain protocol for link: $uri") + return null + } + return object: DoNotAskOption.Adapter() { + override fun rememberChoice(isSelected: Boolean, exitCode: Int) { + if (isSelected) { + DocumentLinksSafeState.getInstance(project).allowProtocol(protocol) + } + } + + override fun getDoNotShowMessage(): String { + return MarkdownBundle.message("markdown.browse.external.link.open.confirmation.dialog.do.not.ask.again.text", protocol) + } + } + } + + companion object { + private val logger = logger() + + fun createUri(link: String): URI? { + return try { + when { + BrowserUtil.isAbsoluteURL(link) -> URI(link) + else -> URI("http://$link") + } + } catch (exception: URISyntaxException) { + logger.warn(exception) + null + } + } + + private fun isLocalHost(hostName: String?): Boolean { + return hostName == null || + hostName.startsWith("127.") || + hostName.endsWith(":1") || + isLocalHost(hostName, false, false) + } + + private fun tryOpenInEditor(project: Project?, uri: URI): Boolean { + if (uri.scheme != "file") { + return false + } + return runReadAction { + actuallyOpenInEditor(project, uri) + } + } + + private fun URI.findVirtualFile(): VirtualFile? { + val actualPath = when { + SystemInfo.isWindows -> UriUtil.trimLeadingSlashes(path) + else -> path + } + val path = Path.of(actualPath) + return VfsUtil.findFile(path, true) + } + + private fun actuallyOpenInEditor(project: Project?, uri: URI): Boolean { + val anchor = uri.fragment + val targetFile = uri.findVirtualFile() ?: return false + @Suppress("NAME_SHADOWING") + val project = project ?: guessProjectForFile(targetFile) ?: return false + if (anchor == null) { + invokeLater { + OpenFileAction.openFile(targetFile, project) + } + return true + } + val point = obtainHeadersPopupPosition(project) + if (point == null) { + logger.warn("Failed to obtain screen point for showing popup") + return false + } + val headers = runReadAction { + if (DumbService.isDumb(project)) { + return@runReadAction null + } + val scope = when (val file = PsiManager.getInstance(project).findFile(targetFile)) { + null -> GlobalSearchScope.EMPTY_SCOPE + else -> GlobalSearchScope.fileScope(file) + } + return@runReadAction HeaderAnchorIndex.collectHeaders(project, scope, anchor) + } + if (headers == null) { + invokeLater { + DumbService.getInstance(project).showDumbModeNotificationForFunctionality( + message = MarkdownBundle.message("markdown.dumb.mode.navigation.is.not.available.notification.text"), + functionality = DumbModeBlockedFunctionality.ActionWithoutId + ) + } + // Return true to prevent external navigation from happening + return true + } + invokeLater { + when { + headers.isEmpty() -> showCannotNavigateNotification(project, anchor, point) + headers.size == 1 -> navigateToHeader(project, targetFile, headers.first()) + else -> showHeadersPopup(project, headers, point) + } + } + return true + } + + private fun obtainHeadersPopupPosition(project: Project?): RelativePoint? { + val frame = WindowManager.getInstance().getFrame(project) + val mousePosition = frame?.mousePosition ?: return null + return RelativePoint(frame, mousePosition) + } + + private fun showCannotNavigateNotification(project: Project, anchor: String, point: RelativePoint) { + val balloonBuilder = JBPopupFactory.getInstance().createHtmlTextBalloonBuilder( + MarkdownBundle.message("markdown.navigate.to.header.no.headers", anchor), + MessageType.WARNING, + null + ) + val balloon = balloonBuilder.createBalloon() + Disposer.register(MarkdownDisposable.getInstance(project), balloon) + balloon.show(point, Balloon.Position.below) + } + + private fun showHeadersPopup(project: Project, headers: Collection, point: RelativePoint) { + JBPopupFactory.getInstance().createListPopup(HeadersPopup(project, headers.toList())).show(point) + } + + private class HeadersPopup( + private val project: Project, + headers: List + ): BaseListPopupStep(MarkdownBundle.message("markdown.navigate.to.header"), headers) { + override fun getTextFor(value: PsiElement): String { + val document = FileDocumentManager.getInstance().getDocument(value.containingFile.virtualFile) + requireNotNull(document) + val name = value.containingFile.virtualFile.name + val line = document.getLineNumber(value.textOffset) + 1 + return "${value.text} ($name:$line)" + } + + override fun onChosen(selectedValue: PsiElement, finalChoice: Boolean): PopupStep<*> { + return doFinalStep { + navigateToHeader(project, selectedValue.containingFile.virtualFile, selectedValue) + } + } + } + + private fun navigateToHeader(project: Project, file: VirtualFile, element: PsiElement) { + val manager = FileEditorManager.getInstance(project) + val openedEditors = manager.getEditorList(file).filterIsInstance() + if (openedEditors.isNotEmpty()) { + for (editor in openedEditors) { + PsiNavigateUtil.navigate(element, true) + } + return + } + val descriptor = OpenFileDescriptor(project, file, element.textOffset) + manager.openEditor(descriptor, true) + } + } +}