diff --git a/platform/core-api/src/com/intellij/platform/backend/navigation/NavigationRequest.kt b/platform/core-api/src/com/intellij/platform/backend/navigation/NavigationRequest.kt index c670879c383d..eadcc37b11f8 100644 --- a/platform/core-api/src/com/intellij/platform/backend/navigation/NavigationRequest.kt +++ b/platform/core-api/src/com/intellij/platform/backend/navigation/NavigationRequest.kt @@ -34,6 +34,8 @@ interface NavigationRequest { } /** + * @param elementRange is used to determine whether + * to preserve the caret if [preserveCaret][com.intellij.platform.ide.navigation.NavigationOptions.preserveCaret] is set * @return a request for the navigation to the [start offset][TextRange.getStartOffset] of [elementRange], * or `null` if the navigation is not possible for any reason */ diff --git a/platform/lang-impl/src/com/intellij/codeInsight/navigation/NavigationUtil.java b/platform/lang-impl/src/com/intellij/codeInsight/navigation/NavigationUtil.java index fd1b2f1dc27a..486432b60055 100644 --- a/platform/lang-impl/src/com/intellij/codeInsight/navigation/NavigationUtil.java +++ b/platform/lang-impl/src/com/intellij/codeInsight/navigation/NavigationUtil.java @@ -48,6 +48,7 @@ import com.intellij.ui.popup.list.ListPopupImpl; import com.intellij.ui.popup.list.PopupListElementRenderer; import com.intellij.util.Processor; import com.intellij.util.TextWithIcon; +import com.intellij.util.ui.EDT; import com.intellij.util.ui.JBUI; import org.jetbrains.annotations.ApiStatus.Internal; import org.jetbrains.annotations.NotNull; @@ -222,13 +223,15 @@ public final class NavigationUtil { return activateFileIfOpen(project, vFile, element.getTextRange(), searchForOpen, requestFocus); } - private static boolean activateFileIfOpen( + @Internal + public static boolean activateFileIfOpen( @NotNull Project project, @NotNull VirtualFile vFile, @Nullable TextRange range, boolean searchForOpen, boolean requestFocus ) { + EDT.assertIsEdt(); if (!EditorHistoryManager.getInstance(project).hasBeenOpen(vFile)) { return false; } diff --git a/platform/lang-impl/src/com/intellij/ide/projectView/impl/nodes/AbstractPsiBasedNode.java b/platform/lang-impl/src/com/intellij/ide/projectView/impl/nodes/AbstractPsiBasedNode.java index 2d18d96265cc..5a35dc4bc7e2 100644 --- a/platform/lang-impl/src/com/intellij/ide/projectView/impl/nodes/AbstractPsiBasedNode.java +++ b/platform/lang-impl/src/com/intellij/ide/projectView/impl/nodes/AbstractPsiBasedNode.java @@ -1,4 +1,4 @@ -// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.ide.projectView.impl.nodes; @@ -27,6 +27,7 @@ import com.intellij.openapi.vcs.FileStatus; import com.intellij.openapi.vcs.FileStatusManager; import com.intellij.openapi.vfs.VFileProperty; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.platform.backend.navigation.NavigationRequest; import com.intellij.pom.StatePreservingNavigatable; import com.intellij.psi.PsiDirectory; import com.intellij.psi.PsiElement; @@ -37,6 +38,8 @@ import com.intellij.ui.LayeredIcon; import com.intellij.util.AstLoadingFilter; import com.intellij.util.IconUtil; import com.intellij.util.PlatformIcons; +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread; +import com.intellij.util.concurrency.annotations.RequiresReadLock; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -288,6 +291,17 @@ public abstract class AbstractPsiBasedNode extends ProjectViewNode return psiElement instanceof NavigationItem ? (NavigationItem) psiElement : null; } + @RequiresReadLock + @RequiresBackgroundThread + @Override + public @Nullable NavigationRequest navigationRequest() { + PsiElement element = extractPsiFromValue(); + if (element == null) { + return null; + } + return ((NavigationItem)element).navigationRequest(); + } + @Override public void navigate(boolean requestFocus, boolean preserveState) { if (canNavigate()) { diff --git a/platform/lang-impl/src/com/intellij/platform/backend/navigation/impl/requests.kt b/platform/lang-impl/src/com/intellij/platform/backend/navigation/impl/requests.kt index c35e511fe728..10c0dfb90cf2 100644 --- a/platform/lang-impl/src/com/intellij/platform/backend/navigation/impl/requests.kt +++ b/platform/lang-impl/src/com/intellij/platform/backend/navigation/impl/requests.kt @@ -8,6 +8,12 @@ import com.intellij.pom.Navigatable import com.intellij.psi.PsiDirectory import org.jetbrains.annotations.ApiStatus.Internal +/** + * @param offsetMarker desired caret position, or `null` to keep the position unchanged + * @param elementRangeMarker marker of a range, where the existing caret should remain unchanged + * if [com.intellij.platform.ide.navigation.NavigationOptions.preserveCaret] is set, + * or `null` to change the caret position according to [offsetMarker] + */ @Internal class SourceNavigationRequest internal constructor( val file: VirtualFile, diff --git a/platform/lang-impl/src/com/intellij/platform/ide/navigation/impl/IdeNavigationService.kt b/platform/lang-impl/src/com/intellij/platform/ide/navigation/impl/IdeNavigationService.kt new file mode 100644 index 000000000000..e41428078ae5 --- /dev/null +++ b/platform/lang-impl/src/com/intellij/platform/ide/navigation/impl/IdeNavigationService.kt @@ -0,0 +1,210 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.ide.navigation.impl + +import com.intellij.codeInsight.navigation.NavigationUtil.activateFileIfOpen +import com.intellij.codeInsight.navigation.NavigationUtil.shouldOpenAsNative +import com.intellij.ide.ui.UISettings +import com.intellij.ide.util.PsiNavigationSupport +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.impl.Utils.isAsyncDataContext +import com.intellij.openapi.actionSystem.impl.Utils.wrapToAsyncDataContext +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.progress.blockingContext +import com.intellij.openapi.progress.mapWithProgress +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.intellij.platform.backend.navigation.NavigationRequest +import com.intellij.platform.backend.navigation.impl.DirectoryNavigationRequest +import com.intellij.platform.backend.navigation.impl.RawNavigationRequest +import com.intellij.platform.backend.navigation.impl.SourceNavigationRequest +import com.intellij.platform.ide.navigation.NavigationOptions +import com.intellij.platform.ide.navigation.NavigationService +import com.intellij.pom.Navigatable +import com.intellij.util.OverflowSemaphore +import com.intellij.util.ui.EDT +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus.Internal + +@Internal +internal class IdeNavigationService(private val project: Project) : NavigationService { + + /** + * - `permits = 1` means at any given time only 1 request is being handled. + * - [BufferOverflow.DROP_OLDEST] makes each new navigation request cancel the previous one. + */ + private val semaphore: OverflowSemaphore = OverflowSemaphore(permits = 1, overflow = BufferOverflow.DROP_OLDEST) + + override suspend fun navigate(ctx: DataContext, options: NavigationOptions) { + if (!isAsyncDataContext(ctx)) { + LOG.error("Expected async context, got: $ctx") + val asyncContext = withContext(Dispatchers.EDT) { + // hope that context component is still available + wrapToAsyncDataContext(ctx) + } + navigate(asyncContext, options) + } + return semaphore.withPermit { + val navigatables = readAction { + ctx.getData(CommonDataKeys.NAVIGATABLE_ARRAY) + } + if (!navigatables.isNullOrEmpty()) { + doNavigate(navigatables.toList(), options) + } + } + } + + override suspend fun navigate(navigatables: List, options: NavigationOptions): Boolean { + return semaphore.withPermit { + doNavigate(navigatables, options) + } + } + + private suspend fun doNavigate(navigatables: List, options: NavigationOptions): Boolean { + val requests = navigatables.mapWithProgress(concurrent = true) { + readAction { + it.navigationRequest() + } + }.filterNotNull() + return withContext(Dispatchers.EDT) { + blockingContext { + navigate(project, requests, options) + } + } + } + + override suspend fun navigate(navigatable: Navigatable, options: NavigationOptions): Boolean { + return semaphore.withPermit { + val request = readAction { + navigatable.navigationRequest() + } + if (request == null) { + false + } + else { + withContext(Dispatchers.EDT) { + blockingContext { + navigateToSource(project, request, options as NavigationOptions.Impl) + } + } + } + } + } +} + +internal val LOG = Logger.getInstance("#com.intellij.platform.ide.navigation.impl") + +/** + * Navigates to all sources from [requests], or navigates to first non-source request. + */ +private fun navigate(project: Project, requests: List, options: NavigationOptions): Boolean { + EDT.assertIsEdt() + + val maxSourceRequests = Registry.intValue("ide.source.file.navigation.limit", 100) + var nonSourceRequest: NavigationRequest? = null + + options as NavigationOptions.Impl + var navigatedSourcesCounter = 0 + for (requestFromNavigatable in requests) { + if (maxSourceRequests in 1..navigatedSourcesCounter) { + break + } + if (navigateToSource(project, requestFromNavigatable, options)) { + navigatedSourcesCounter++ + } + else if (nonSourceRequest == null) { + nonSourceRequest = requestFromNavigatable + } + } + if (navigatedSourcesCounter > 0) { + return true + } + if (nonSourceRequest == null) { + return false + } + navigateNonSource(nonSourceRequest, options) + return true +} + +private fun navigateToSource(project: Project, request: NavigationRequest, options: NavigationOptions.Impl): Boolean { + EDT.assertIsEdt() + + when (request) { + is SourceNavigationRequest -> { + navigateToSource(project, request, options) + return true + } + is DirectoryNavigationRequest -> { + return false + } + is RawNavigationRequest -> { + if (request.canNavigateToSource) { + request.navigatable.navigate(options.requestFocus) + return true + } + else { + return false + } + } + else -> { + error("Unsupported request: $request") + } + } +} + +private fun navigateToSource(project: Project, request: SourceNavigationRequest, options: NavigationOptions.Impl) { + EDT.assertIsEdt() + + if (tryActivateOpenFile(project, request, options)) { + return + } + // TODO support pure source request without OpenFileDescriptor + val offset = request.elementRangeMarker?.takeIf { it.isValid }?.startOffset ?: -1 + val openFileDescriptor = OpenFileDescriptor(project, request.file, offset) + openFileDescriptor.isUseCurrentWindow = true + if (UISettings.getInstance().openInPreviewTabIfPossible && Registry.`is`("editor.preview.tab.navigation")) { + openFileDescriptor.isUsePreviewTab = true + } + openFileDescriptor.navigate(options.requestFocus) +} + +private fun tryActivateOpenFile( + project: Project, + request: SourceNavigationRequest, + options: NavigationOptions.Impl, +): Boolean { + if (!options.preserveCaret && !options.requestFocus) { + return false + } + if (shouldOpenAsNative(request.file)) { + return false + } + val elementRangeMarker = request.elementRangeMarker + if (elementRangeMarker == null || !elementRangeMarker.isValid) { + return false + } + val elementRange = elementRangeMarker.textRange + return activateFileIfOpen(project, request.file, elementRange, options.requestFocus, options.requestFocus) +} + +private fun navigateNonSource(request: NavigationRequest, options: NavigationOptions.Impl) { + EDT.assertIsEdt() + + return when (request) { + is DirectoryNavigationRequest -> { + PsiNavigationSupport.getInstance().navigateToDirectory(request.directory, options.requestFocus) + } + is RawNavigationRequest -> { + check(!request.canNavigateToSource) + request.navigatable.navigate(options.requestFocus) + } + else -> { + error("Non-source request expected here, got: $request") + } + } +} diff --git a/platform/platform-api/src/com/intellij/platform/ide/navigation/NavigationOptions.kt b/platform/platform-api/src/com/intellij/platform/ide/navigation/NavigationOptions.kt new file mode 100644 index 000000000000..719dc43f3340 --- /dev/null +++ b/platform/platform-api/src/com/intellij/platform/ide/navigation/NavigationOptions.kt @@ -0,0 +1,62 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.ide.navigation + +import org.jetbrains.annotations.ApiStatus.Experimental +import org.jetbrains.annotations.ApiStatus.Internal + +@Experimental +interface NavigationOptions { + + /** + * Sets whether to request the focus. + * + * Default: `true`. + */ + fun requestFocus(value: Boolean): NavigationOptions + + /** + * If the navigation leads to a file, which is already open in some editor, + * the editor will be focused, but the caret position will remain unchanged, + * if the caret position is within text range of requested PsiElement. + * + * For example, when requesting navigation to PsiElement, which corresponds to class `C`: + * ``` + * package com.foo.bar; + * class C { } + * ``` + * the caret will be placed here: + * ``` + * package com.foo.bar; + * class C { } + * ``` + * But if the caret was already inside the [element range][com.intellij.platform.backend.navigation.impl.SourceNavigationRequest.elementRangeMarker], + * it will remain unchanged: + * ``` + * package com.foo.bar; + * class C { } + * ``` + * + * Default: `false`. + */ + fun preserveCaret(value: Boolean): NavigationOptions + + companion object { + + @JvmStatic + fun defaultOptions(): NavigationOptions = defaultOptions + + private val defaultOptions = Impl( + requestFocus = true, + preserveCaret = false, + ) + } + + @Internal + data class Impl internal constructor( + val requestFocus: Boolean, + val preserveCaret: Boolean, + ) : NavigationOptions { + override fun requestFocus(value: Boolean): NavigationOptions = copy(requestFocus = value) + override fun preserveCaret(value: Boolean): NavigationOptions = copy(preserveCaret = value) + } +} diff --git a/platform/platform-api/src/com/intellij/platform/ide/navigation/NavigationService.kt b/platform/platform-api/src/com/intellij/platform/ide/navigation/NavigationService.kt new file mode 100644 index 000000000000..b79b8bca634e --- /dev/null +++ b/platform/platform-api/src/com/intellij/platform/ide/navigation/NavigationService.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.ide.navigation + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.pom.Navigatable +import org.jetbrains.annotations.ApiStatus.Experimental +import org.jetbrains.annotations.ApiStatus.Internal + +@Experimental +interface NavigationService { + + companion object { + + @JvmStatic + fun getInstance(project: Project): NavigationService { + return project.service() + } + } + + suspend fun navigate(ctx: DataContext, options: NavigationOptions) + + @Internal // compatibility function + suspend fun navigate(navigatables: List, options: NavigationOptions): Boolean + + @Internal // compatibility function + suspend fun navigate(navigatable: Navigatable, options: NavigationOptions): Boolean +} diff --git a/platform/platform-api/src/com/intellij/util/EditSourceOnDoubleClickHandler.kt b/platform/platform-api/src/com/intellij/util/EditSourceOnDoubleClickHandler.kt index e0bac40f2af6..7634dc872c86 100644 --- a/platform/platform-api/src/com/intellij/util/EditSourceOnDoubleClickHandler.kt +++ b/platform/platform-api/src/com/intellij/util/EditSourceOnDoubleClickHandler.kt @@ -1,12 +1,14 @@ -// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.util import com.intellij.ide.DataManager import com.intellij.ide.util.treeView.NodeDescriptor import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.AccessToken import com.intellij.openapi.application.ModalityState import com.intellij.openapi.util.Key +import com.intellij.openapi.util.registry.Registry import com.intellij.ui.ClientProperty import com.intellij.ui.DoubleClickListener import com.intellij.ui.treeStructure.treetable.TreeTable @@ -176,7 +178,13 @@ object EditSourceOnDoubleClickHandler { } protected open fun processDoubleClick(e: MouseEvent, dataContext: DataContext, treePath: TreePath) { - SlowOperations.knownIssue("IDEA-304701, EA-659716").use { + val token = if (Registry.`is`("ide.navigation.requests")) { + AccessToken.EMPTY_ACCESS_TOKEN + } + else { + SlowOperations.knownIssue("IDEA-304701, EA-659716") + } + token.use { OpenSourceUtil.openSourcesFrom(dataContext, true) } whenPerformed?.run() diff --git a/platform/platform-api/src/com/intellij/util/OpenSourceUtil.java b/platform/platform-api/src/com/intellij/util/OpenSourceUtil.java index c64c64a37bd8..046fecd2a595 100644 --- a/platform/platform-api/src/com/intellij/util/OpenSourceUtil.java +++ b/platform/platform-api/src/com/intellij/util/OpenSourceUtil.java @@ -4,6 +4,7 @@ package com.intellij.util; import com.intellij.openapi.actionSystem.CommonDataKeys; import com.intellij.openapi.actionSystem.DataContext; import com.intellij.openapi.actionSystem.DataProvider; +import com.intellij.openapi.project.Project; import com.intellij.openapi.util.registry.Registry; import com.intellij.pom.Navigatable; import com.intellij.pom.StatePreservingNavigatable; @@ -17,6 +18,10 @@ public final class OpenSourceUtil { } public static void openSourcesFrom(@NotNull DataContext context, boolean requestFocus) { + if (Registry.is("ide.navigation.requests")) { + OpenSourceUtilKt.openSourcesFrom(context, requestFocus); + return; + } navigate(requestFocus, false, CommonDataKeys.NAVIGATABLE_ARRAY.getData(context)); } @@ -81,6 +86,12 @@ public final class OpenSourceUtil { if (navigatables == null) { return false; } + if (Registry.is("ide.navigation.requests")) { + Project project = OpenSourceUtilKt.findProject(navigatables); + if (project != null) { + return OpenSourceUtilKt.navigate(project, requestFocus, tryNotToScroll, navigatables); + } + } Navigatable nonSourceNavigatable = null; @@ -139,7 +150,16 @@ public final class OpenSourceUtil { * @return {@code true} if navigation is done, {@code false} otherwise */ public static boolean navigateToSource(boolean requestFocus, boolean tryNotToScroll, @Nullable Navigatable navigatable) { - if (navigatable == null || !navigatable.canNavigateToSource()) { + if (navigatable == null) { + return false; + } + if (Registry.is("ide.navigation.requests")) { + Project project = OpenSourceUtilKt.findProject(navigatable); + if (project != null) { + return OpenSourceUtilKt.navigateToSource(project, requestFocus, tryNotToScroll, navigatable); + } + } + if (!navigatable.canNavigateToSource()) { return false; } if (tryNotToScroll && navigatable instanceof StatePreservingNavigatable) { diff --git a/platform/platform-api/src/com/intellij/util/OpenSourceUtil.kt b/platform/platform-api/src/com/intellij/util/OpenSourceUtil.kt new file mode 100644 index 000000000000..f3aeed77ea83 --- /dev/null +++ b/platform/platform-api/src/com/intellij/util/OpenSourceUtil.kt @@ -0,0 +1,67 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@file:Internal + +package com.intellij.util + +import com.intellij.ide.IdeBundle +import com.intellij.ide.ui.IdeUiService +import com.intellij.ide.util.treeView.NodeDescriptor +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.progress.runBlockingModal +import com.intellij.openapi.project.Project +import com.intellij.platform.ide.navigation.NavigationOptions +import com.intellij.platform.ide.navigation.NavigationService +import com.intellij.pom.Navigatable +import com.intellij.psi.PsiElement +import org.jetbrains.annotations.ApiStatus.Internal + +internal fun openSourcesFrom(context: DataContext, requestFocus: Boolean) { + val project = context.getData(CommonDataKeys.PROJECT) ?: return + val asyncContext = IdeUiService.getInstance().createAsyncDataContext(context) + val options = NavigationOptions.defaultOptions().requestFocus(requestFocus) + runBlockingModal(project, IdeBundle.message("progress.title.preparing.navigation")) { + NavigationService.getInstance(project).navigate(asyncContext, options) + } +} + +internal fun navigate(project: Project, requestFocus: Boolean, tryNotToScroll: Boolean, navigatables: Iterable?): Boolean { + if (navigatables == null) { + return false + } + val filteredNavigatables = navigatables.filterNotNull() + val options = NavigationOptions.defaultOptions().requestFocus(requestFocus).preserveCaret(tryNotToScroll) + return runBlockingModal(project, IdeBundle.message("progress.title.preparing.navigation")) { + NavigationService.getInstance(project).navigate(filteredNavigatables, options) + } +} + +/** + * Navigates to source of the specified navigatable. + * + * @param requestFocus specifies whether a focus should be requested or not + * @param tryNotToScroll specifies whether a corresponding editor should preserve its state if it is possible + * @return `true` if navigation is done, `false` otherwise + */ +internal fun navigateToSource(project: Project, requestFocus: Boolean, tryNotToScroll: Boolean, navigatable: Navigatable): Boolean { + val options = NavigationOptions.defaultOptions().requestFocus(requestFocus).preserveCaret(tryNotToScroll) + return runBlockingModal(project, IdeBundle.message("progress.title.preparing.navigation")) { + NavigationService.getInstance(project).navigate(navigatable, options) + } +} + +internal fun findProject(navigatables: Iterable): Project? { + return navigatables.firstNotNullOfOrNull { + findProject(it) + } +} + +internal fun findProject(navigatable: Navigatable): Project? { + return when (navigatable) { + is PsiElement -> navigatable.project + is OpenFileDescriptor -> navigatable.project + is NodeDescriptor<*> -> navigatable.project + else -> null + } +} diff --git a/platform/platform-resources/src/META-INF/LangExtensions.xml b/platform/platform-resources/src/META-INF/LangExtensions.xml index a451b22db72e..514042111d8e 100644 --- a/platform/platform-resources/src/META-INF/LangExtensions.xml +++ b/platform/platform-resources/src/META-INF/LangExtensions.xml @@ -246,6 +246,8 @@ + diff --git a/platform/util/resources/misc/registry.properties b/platform/util/resources/misc/registry.properties index 06c02ff824d7..ba5db3551c09 100644 --- a/platform/util/resources/misc/registry.properties +++ b/platform/util/resources/misc/registry.properties @@ -2339,5 +2339,7 @@ ide.log.coroutine.pce.description=Log PCEs occurring in coroutines. \ ide.colorful.toolbar.force=false ide.colorful.toolbar.force.description=Show 'Colorful toolbar' even if there is one project open (works only if the feature is enabled). \ Reopen project or restart IDE to apply the setting +ide.navigation.requests=true +ide.navigation.requests.description=When navigation works by computing a navigation request on a BG thread. # please leave this note as last line # TODO please use EP com.intellij.registryKey for plugin/product specific keys