diff --git a/plugins/markdown/core/backend/src/org/intellij/plugins/markdown/backend/services/MarkdownLinkOpenerRemoteApiImpl.kt b/plugins/markdown/core/backend/src/org/intellij/plugins/markdown/backend/services/MarkdownLinkOpenerRemoteApiImpl.kt index 6726b795ec8c..f2cf1136e25e 100644 --- a/plugins/markdown/core/backend/src/org/intellij/plugins/markdown/backend/services/MarkdownLinkOpenerRemoteApiImpl.kt +++ b/plugins/markdown/core/backend/src/org/intellij/plugins/markdown/backend/services/MarkdownLinkOpenerRemoteApiImpl.kt @@ -2,23 +2,25 @@ // Use of this source code is governed by the Apache 2.0 license. package org.intellij.plugins.markdown.backend.services +import com.intellij.ide.BrowserUtil import com.intellij.ide.vfs.VirtualFileId +import com.intellij.ide.vfs.rpcId import com.intellij.ide.vfs.virtualFile import com.intellij.openapi.application.runReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectForFile import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.findFile -import com.intellij.platform.project.ProjectId -import com.intellij.platform.project.findProject import com.intellij.platform.project.projectId import com.intellij.psi.PsiManager import com.intellij.psi.search.GlobalSearchScope import com.intellij.util.UriUtil import org.intellij.plugins.markdown.dto.MarkdownHeaderInfo +import org.intellij.plugins.markdown.dto.MarkdownLinkNavigationData import org.intellij.plugins.markdown.lang.index.HeaderAnchorIndex import org.intellij.plugins.markdown.mapper.MarkdownHeaderMapper import org.intellij.plugins.markdown.service.MarkdownLinkOpenerRemoteApi @@ -29,43 +31,67 @@ import java.nio.file.Path internal class MarkdownLinkOpenerRemoteApiImpl : MarkdownLinkOpenerRemoteApi { companion object { private val logger: Logger = Logger.getInstance(MarkdownLinkOpenerRemoteApiImpl::class.java) - } - /** - * Tries to resolve the link as a path to file. Path to file can be: - * - absolute path - * - relative path to the file from which the link is resolved - * @param link the link to resolve - * @param virtualFileId the id of the file from which the link is resolved - * @return the path to the file as schema if the link is resolved successfully, null otherwise - */ - override suspend fun resolveLinkAsFilePath(link: String, virtualFileId: VirtualFileId?): String?{ - val containingFile = virtualFileId?.virtualFile()?.parent ?: return null - val targetFile = containingFile.findFile(link.trimAnchor()) ?: return null - val anchor = if ('#' in link) link.substring(link.lastIndexOf('#')) else "" - return targetFile.url + anchor - } + private fun extractAnchor(link: String): String { + val lastHashIndex = link.lastIndexOf('#') + if (lastHashIndex == -1) { + return "" + } + val potentialAnchor = link.substring(lastHashIndex + 1) + if (potentialAnchor.contains("/") || potentialAnchor.contains("\\")) { + return "" + } + return potentialAnchor + } - private fun String.trimAnchor(): String { - val anchorIndex = lastIndexOf('#') - return if (anchorIndex == -1) this else substring(0, anchorIndex) - } + private fun String.trimAnchor(): String { + val anchorIndex = lastIndexOf('#') + return if (anchorIndex == -1) this else substring(0, anchorIndex) + } - private fun parseUri(uri: String): URI? { - return try { - URI(uri) - } catch (e: URISyntaxException) { - logger.warn(e) - return null + 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 createUri(link: String): URI? { + return try { + URI(link) + } catch (exception: URISyntaxException) { + logger.warn(exception) + null + } } } - override suspend fun collectHeaders(projectId: ProjectId?, uri: String): List? { - val project = projectId?.findProject() ?: return emptyList() - val parsedUri = parseUri(uri)?: return emptyList() - val targetFile = parsedUri.findVirtualFile() ?: return emptyList() - val anchor = parsedUri.fragment + override suspend fun fetchLinkNavigationData(link: String, virtualFileId: VirtualFileId?): MarkdownLinkNavigationData { + val file = resolveLinkAsFile(link, virtualFileId)?: return MarkdownLinkNavigationData(link, null, null, null) + var path = file.url + val anchor = extractAnchor(link) + if (!anchor.isEmpty()) path += "#$anchor" + val project = guessProjectForFile(file)?: return MarkdownLinkNavigationData(path, file.rpcId(), null, null) + if (anchor.isEmpty()) return MarkdownLinkNavigationData(path, file.rpcId(), project.projectId(), null) + val headers = collectHeaders(anchor, file, project) + return MarkdownLinkNavigationData(path, file.rpcId(), project.projectId(), headers) + } + private fun resolveLinkAsFile(link: String, virtualFileId: VirtualFileId?): VirtualFile?{ + if (BrowserUtil.isAbsoluteURL(link)){ + val uri = createUri(link) + if (uri != null && uri.scheme == "file") { + return uri.findVirtualFile() + } + } + val containingFile = virtualFileId?.virtualFile()?.parent ?: return null + val targetFile = containingFile.findFile(link.trimAnchor()) ?: return null + return targetFile + } + + private fun collectHeaders(anchor: String, targetFile: VirtualFile, project: Project): List? { return runReadAction { if (DumbService.isDumb(project)) { return@runReadAction emptyList() @@ -77,20 +103,4 @@ internal class MarkdownLinkOpenerRemoteApiImpl : MarkdownLinkOpenerRemoteApi { return@runReadAction HeaderAnchorIndex.collectHeaders(project, scope, anchor).map(MarkdownHeaderMapper::map) } } - - override suspend fun guessProjectForUri(uri: String): ProjectId? { - val parsedUri = parseUri(uri) ?: return null - val file = parsedUri.findVirtualFile() ?: return null - val project = guessProjectForFile(file) ?: return null - return project.projectId() - } - - 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) - } } \ No newline at end of file diff --git a/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml b/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml index 74aac0a1d33a..df5cb56dcde6 100644 --- a/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml +++ b/plugins/markdown/core/frontend/resources/intellij.markdown.frontend.xml @@ -6,7 +6,7 @@ \ 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 08345e3fc4cf..79eda043b326 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 @@ -3,10 +3,10 @@ package org.intellij.plugins.markdown.frontend.preview.accessor.impl import com.intellij.ide.BrowserUtil import com.intellij.ide.actions.OpenFileAction import com.intellij.ide.vfs.rpcId +import com.intellij.ide.vfs.virtualFile import com.intellij.openapi.application.EDT import com.intellij.openapi.application.runReadAction import com.intellij.openapi.diagnostic.logger -import com.intellij.openapi.progress.runBlockingCancellable import com.intellij.openapi.project.DumbModeBlockedFunctionality import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project @@ -23,7 +23,6 @@ import com.intellij.openapi.wm.WindowManager import com.intellij.platform.project.findProject import com.intellij.platform.project.projectId import com.intellij.ui.awt.RelativePoint -import com.intellij.util.PlatformUtils import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.io.isLocalHost import kotlinx.coroutines.CoroutineScope @@ -53,12 +52,53 @@ internal class MarkdownLinkOpenerImpl(val coroutineScope: CoroutineScope) : Mark } override fun openLink(project: Project?, link: String, containingFile: VirtualFile?) { - val uri = createUri(link, containingFile) ?: return - if (tryOpenInEditor(project, uri)) { + coroutineScope.launch { + val data = MarkdownLinkOpenerRemoteApi.getInstance().fetchLinkNavigationData(link, containingFile?.rpcId()) + val uri = createUri(data.uri) ?: return@launch + if (uri.scheme != "file") { + openExternalLink(project, uri) + return@launch + } + @Suppress("NAME_SHADOWING") + val project = project ?: data.projectId?.findProject() ?: return@launch + val fileToOpen = data.virtualFileId?.virtualFile() ?: return@launch + val anchor = uri.fragment + if (anchor == null) { + withContext(Dispatchers.EDT) { + runReadAction { + OpenFileAction.openFile(fileToOpen, project) + } + } + return@launch + } + processHeaders(fileToOpen, anchor, project, data.headers) + } + } + + private suspend fun processHeaders(file: VirtualFile, anchor: String, project: Project, headers: List?){ + if (headers == null) { + DumbService.getInstance(project).showDumbModeNotificationForFunctionality( + message = MarkdownBundle.message("markdown.dumb.mode.navigation.is.not.available.notification.text"), + functionality = DumbModeBlockedFunctionality.ActionWithoutId + ) return } - coroutineScope.launch { - openExternalLink(project, uri) + if (headers.size == 1) { + withContext(Dispatchers.EDT) { + runReadAction { + MarkdownFrontendService.getInstance().navigateToHeader(project.projectId(), headers.first()) + } + } + return + } + val point = obtainHeadersPopupPosition(project) + if (point == null) { + logger.warn("Failed to obtain screen point for showing popup") + return + } + when { + headers.isEmpty() -> showCannotNavigateNotification(project, anchor, point) + headers.size > 1 -> showHeadersPopup(project, headers, point) } } @@ -133,15 +173,6 @@ internal class MarkdownLinkOpenerImpl(val coroutineScope: CoroutineScope) : Mark } } - private fun tryOpenInEditor(project: Project?, uri: URI): Boolean { - if (uri.scheme != "file") { - return false - } - return runReadAction { - actuallyOpenInEditor(project, uri) - } - } - private fun tryOpenInEditorDeprecated(project: Project?, uri: URI): Boolean { if (uri.scheme != "file") { return false @@ -193,80 +224,6 @@ internal class MarkdownLinkOpenerImpl(val coroutineScope: CoroutineScope) : Mark return true } - private fun actuallyOpenInEditor(project: Project?, uri: URI): Boolean { - @Suppress("NAME_SHADOWING") - val project = project ?: - runBlockingCancellable { - withContext(Dispatchers.IO) { - MarkdownLinkOpenerRemoteApi.getInstance().guessProjectForUri(uri.toString())?.findProject() - } - } - if (project == null) return false - val anchor = uri.fragment - if (anchor == null){ - coroutineScope.launch(Dispatchers.EDT) { - OpenFileAction.openFile(uri.path, project) - } - return true - } - var headers = runBlockingCancellable { - withContext(Dispatchers.IO) { - MarkdownLinkOpenerRemoteApi.getInstance().collectHeaders(project.projectId(), uri.toString()) - } - } - if (headers == null) { - coroutineScope.launch { - 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 - } - if (headers.size == 1) { - coroutineScope.launch(Dispatchers.EDT) { - MarkdownFrontendService.getInstance().navigateToHeader(project.projectId(), headers.first()) - } - return true - } - val point = obtainHeadersPopupPosition(project) - if (point == null) { - logger.warn("Failed to obtain screen point for showing popup") - return false - } - coroutineScope.launch { - when { - headers.isEmpty() -> showCannotNavigateNotification(project, anchor, point) - headers.size > 1 -> showHeadersPopup(project, headers, point) - } - } - return true - } - - private fun createUri(link: String, containingFile: VirtualFile?): URI? { - return try { - if (BrowserUtil.isAbsoluteURL(link)) return URI(link) - else { - if (PlatformUtils.isJetBrainsClient()){ - val scheme = runBlockingCancellable { - withContext(Dispatchers.IO) { - MarkdownLinkOpenerRemoteApi.getInstance().resolveLinkAsFilePath(link, containingFile?.rpcId()) - } - } - if (scheme != null && scheme.startsWith("file://")) { - return URI(scheme) - } - } - - return URI("http://$link") - } - } catch (exception: URISyntaxException) { - logger.warn(exception) - null - } - } - companion object { private val logger = logger() diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/dto/MarkdownDtos.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/dto/MarkdownDtos.kt index 42dcb6e93f49..13db8f75a632 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/dto/MarkdownDtos.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/dto/MarkdownDtos.kt @@ -1,7 +1,9 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.intellij.plugins.markdown.dto +import com.intellij.ide.vfs.VirtualFileId import com.intellij.openapi.util.NlsContexts +import com.intellij.platform.project.ProjectId import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName @@ -16,5 +18,19 @@ data class MarkdownHeaderInfo( @SerialName("line_number") val lineNumber: Int, @SerialName("text_offset") - val textOffset: Int + val textOffset: Int, + @SerialName("file_id") + val virtualFileId: VirtualFileId ) + +@Serializable +data class MarkdownLinkNavigationData( + @SerialName("uri") + val uri: String, + @SerialName("file") + val virtualFileId: VirtualFileId?, + @SerialName("project") + val projectId: ProjectId?, + @SerialName("headers") + val headers: List? +) \ No newline at end of file diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/mapper/MarkdownHeaderMapper.java b/plugins/markdown/core/src/org/intellij/plugins/markdown/mapper/MarkdownHeaderMapper.java index 2424725f6acd..6cfca2890866 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/mapper/MarkdownHeaderMapper.java +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/mapper/MarkdownHeaderMapper.java @@ -1,11 +1,13 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.intellij.plugins.markdown.mapper; +import com.intellij.ide.vfs.VirtualFileId; import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; import org.intellij.plugins.markdown.dto.MarkdownHeaderInfo; import org.intellij.plugins.markdown.lang.psi.impl.MarkdownHeader; +import com.intellij.ide.vfs.VirtualFileIdKt; /** * Mapper class for converting a MarkdownHeader into a MarkdownHeaderInfo. @@ -36,7 +38,9 @@ public final class MarkdownHeaderMapper { lineNumber = document.getLineNumber(textOffset) + 1; } - return new MarkdownHeaderInfo(headerText, filePath, fileName, lineNumber, textOffset); + VirtualFileId virtualFileId = VirtualFileIdKt.rpcId(header.getContainingFile().getVirtualFile()); + + return new MarkdownHeaderInfo(headerText, filePath, fileName, lineNumber, textOffset, virtualFileId); } } diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownFrontendServiceImpl.java b/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownFrontendServiceImpl.java index f18049b15425..9a4e1882973c 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownFrontendServiceImpl.java +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownFrontendServiceImpl.java @@ -2,6 +2,7 @@ package org.intellij.plugins.markdown.service; import com.intellij.ide.actions.OpenFileAction; +import com.intellij.ide.vfs.VirtualFileIdKt; import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.fileEditor.FileEditorManager; @@ -70,14 +71,15 @@ public final class MarkdownFrontendServiceImpl implements MarkdownFrontendServic public void navigateToHeader(ProjectId projectId, MarkdownHeaderInfo headerInfo) { URI uri = createFileUri(headerInfo.getFilePath()); if (uri == null) return; - VirtualFile file = findVirtualFile(uri); + VirtualFile file = VirtualFileIdKt.virtualFile(headerInfo.getVirtualFileId()); + if (file == null) return; Project project = ProjectIdKt.findProject(projectId); FileEditorManager manager = FileEditorManager.getInstance(project); List openedEditors = manager.getEditorList(file).stream() .filter(editor -> editor instanceof MarkdownEditorWithPreview) .map(editor -> (MarkdownEditorWithPreview)editor) .toList(); - PsiElement element = PsiUtilCore.getPsiFile(project,file).findElementAt(headerInfo.getTextOffset()); + PsiElement element = PsiUtilCore.getPsiFile(project, file).findElementAt(headerInfo.getTextOffset()); if (element == null) return; if (!openedEditors.isEmpty()) { for (MarkdownEditorWithPreview editor : openedEditors) { diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownLinkOpenerRemoteApi.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownLinkOpenerRemoteApi.kt index fb7d40f58450..b4825a88b0af 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownLinkOpenerRemoteApi.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/service/MarkdownLinkOpenerRemoteApi.kt @@ -8,14 +8,13 @@ import fleet.rpc.RemoteApi import fleet.rpc.Rpc import fleet.rpc.remoteApiDescriptor import org.intellij.plugins.markdown.dto.MarkdownHeaderInfo +import org.intellij.plugins.markdown.dto.MarkdownLinkNavigationData import org.jetbrains.annotations.ApiStatus @ApiStatus.Internal @Rpc interface MarkdownLinkOpenerRemoteApi: RemoteApi { - suspend fun collectHeaders(projectId: ProjectId?, uri: String): List? - suspend fun guessProjectForUri(uri: String): ProjectId? - suspend fun resolveLinkAsFilePath(link: String, virtualFileId: VirtualFileId?): String? + suspend fun fetchLinkNavigationData(link: String, virtualFileId: VirtualFileId?): MarkdownLinkNavigationData companion object { @JvmStatic