[markdown] IJPL-177111 combine multiple RPCs needed for link opening into one

Refactored Markdown link navigation by introducing MarkdownLinkNavigationData. Legacy URI-based handling has been replaced to streamline file and header navigation while eliminating redundant methods. Instead of performing slow findVirtualFile calls on EDT, the implementation now passes a VirtualFile directly to the openFile and navigateToHeader actions. Related services, DTOs, and mappings were updated accordingly to support the new, more efficient approach.

GitOrigin-RevId: 4545b23b17c55fe191e1b3c1f77617b6c409818d
This commit is contained in:
Milan Doslic
2025-04-15 07:21:53 +00:00
committed by intellij-monorepo-bot
parent 4ba841da7b
commit 8cd04eb35c
7 changed files with 133 additions and 145 deletions
@@ -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<MarkdownHeaderInfo>? {
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<MarkdownHeaderInfo>? {
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)
}
}
@@ -6,7 +6,7 @@
<applicationService serviceInterface="org.intellij.plugins.markdown.ui.preview.accessor.MarkdownLinkOpener"
serviceImplementation="org.intellij.plugins.markdown.frontend.preview.accessor.impl.MarkdownLinkOpenerImpl"/>
<registryKey key="markdown.open.link.fallback"
defaultValue="true"
defaultValue="false"
description="Should fallback to Markdown link opening implementation before the split."/>
</extensions>
</idea-plugin>
@@ -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<MarkdownHeaderInfo>?){
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<MarkdownLinkOpenerImpl>()
@@ -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<MarkdownHeaderInfo>?
)
@@ -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);
}
}
@@ -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<MarkdownEditorWithPreview> 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) {
@@ -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<Unit> {
suspend fun collectHeaders(projectId: ProjectId?, uri: String): List<MarkdownHeaderInfo>?
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