diff --git a/platform/find/backend/src/FindRemoteApiImpl.kt b/platform/find/backend/src/FindRemoteApiImpl.kt index 3fcfc492a4f2..40bfb7d84dd5 100644 --- a/platform/find/backend/src/FindRemoteApiImpl.kt +++ b/platform/find/backend/src/FindRemoteApiImpl.kt @@ -12,6 +12,7 @@ import com.intellij.find.replaceInProject.ReplaceInProjectManager import com.intellij.ide.ui.colors.rpcId import com.intellij.ide.ui.icons.rpcId import com.intellij.ide.ui.toSerializableTextChunk +import com.intellij.ide.util.scopeChooser.ScopesStateService import com.intellij.ide.vfs.VirtualFileId import com.intellij.ide.vfs.rpcId import com.intellij.ide.vfs.virtualFile @@ -56,6 +57,12 @@ internal class FindRemoteApiImpl : FindRemoteApi { return@coroutineScope } val filesToScanInitially = filesToScanInitially.mapNotNull { it.virtualFile() }.toSet() + // SearchScope is not serializable, so we will get it by id from the client + findModel.customScopeId?.let { scopeId -> + ScopesStateService.getInstance(project).getScopeById(scopeId)?.let { + findModel.customScope = it + } + } //read action is necessary in case of the loading from a directory val scope = readAction { FindInProjectUtil.getGlobalSearchScope(project, findModel) } FindInProjectUtil.findUsages(findModel, project, progressIndicator, presentation, filesToScanInitially) { usageInfo -> diff --git a/platform/indexing-api/api-dump.txt b/platform/indexing-api/api-dump.txt index 0d23448c80f6..11b3b54548b6 100644 --- a/platform/indexing-api/api-dump.txt +++ b/platform/indexing-api/api-dump.txt @@ -3,7 +3,7 @@ c:com.intellij.find.FindModel - java.lang.Cloneable - sf:Companion:com.intellij.find.FindModel$Companion - ():V -- b:(I,java.lang.String,java.lang.String,Z,Z,Z,com.intellij.find.FindModel$SearchContext,Z,Z,Z,Z,I,Z,Z,Z,Z,Z,Z,Z,java.lang.String,java.lang.String,Z,java.lang.String,java.lang.String,Z,Z,Z,Z,kotlinx.serialization.internal.SerializationConstructorMarker):V +- b:(I,java.lang.String,java.lang.String,Z,Z,Z,com.intellij.find.FindModel$SearchContext,Z,Z,Z,Z,I,Z,Z,Z,Z,Z,Z,Z,java.lang.String,java.lang.String,Z,java.lang.String,java.lang.String,java.lang.String,Z,Z,Z,Z,kotlinx.serialization.internal.SerializationConstructorMarker):V - f:addObserver(com.intellij.find.FindModel$FindModelObserver):V - clone():com.intellij.find.FindModel - f:compileRegExp():java.util.regex.Pattern diff --git a/platform/indexing-api/src/com/intellij/find/FindModel.kt b/platform/indexing-api/src/com/intellij/find/FindModel.kt index 3b5d3ff58e20..7ba2d040d44c 100644 --- a/platform/indexing-api/src/com/intellij/find/FindModel.kt +++ b/platform/indexing-api/src/com/intellij/find/FindModel.kt @@ -15,6 +15,7 @@ import com.intellij.util.containers.ContainerUtil import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.intellij.lang.annotations.MagicConstant +import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.Nls import org.jetbrains.annotations.UnknownNullability import java.util.regex.Pattern @@ -371,6 +372,15 @@ open class FindModel : UserDataHolder, Cloneable { } } + @ApiStatus.Internal + var customScopeId: String? = null + set(value) { + if (value != field) { + field = value + notifyObservers() + } + } + @get:JvmName("isCustomScope") @set:JvmName("setCustomScope") var isCustomScope: Boolean = false @@ -581,6 +591,7 @@ open class FindModel : UserDataHolder, Cloneable { moduleName = model.moduleName customScopeName = model.customScopeName customScope = model.customScope + customScopeId = model.customScopeId isCustomScope = model.isCustomScope isFindAll = model.isFindAll searchContext = model.searchContext @@ -622,6 +633,7 @@ open class FindModel : UserDataHolder, Cloneable { if (isWholeWordsOnly != findModel.isWholeWordsOnly) return false if (isWithSubdirectories != findModel.isWithSubdirectories) return false if (if (customScope != null) (customScope != findModel.customScope) else findModel.customScope != null) return false + if (if (customScopeId != null) (customScopeId != findModel.customScopeId) else findModel.customScopeId != null) return false if (if (customScopeName != null) (customScopeName != findModel.customScopeName) else findModel.customScopeName != null) return false if (if (directoryName != null) (directoryName != findModel.directoryName) else findModel.directoryName != null) return false if (if (fileFilter != null) (fileFilter != findModel.fileFilter) else findModel.fileFilter != null) return false @@ -663,6 +675,7 @@ open class FindModel : UserDataHolder, Cloneable { result = 31 * result + (fileFilter?.hashCode() ?: 0) result = 31 * result + (customScopeName?.hashCode() ?: 0) result = 31 * result + (customScope?.hashCode() ?: 0) + result = 31 * result + (customScopeId?.hashCode() ?: 0) result = 31 * result + (if (isCustomScope) 1 else 0) result = 31 * result + (if (isMultiline) 1 else 0) result = 31 * result + (if (isPreserveCase) 1 else 0) diff --git a/platform/lang-impl/src/com/intellij/find/impl/FindPopupPanel.java b/platform/lang-impl/src/com/intellij/find/impl/FindPopupPanel.java index 6ad80132ad63..2309e24d5d67 100644 --- a/platform/lang-impl/src/com/intellij/find/impl/FindPopupPanel.java +++ b/platform/lang-impl/src/com/intellij/find/impl/FindPopupPanel.java @@ -1628,6 +1628,7 @@ public final class FindPopupPanel extends JBPanel implements Fin model.setModuleName(null); model.setCustomScopeName(null); model.setCustomScope(null); + model.setCustomScopeId(null); model.setCustomScope(false); myScopeUI.applyTo(model, mySelectedScope); diff --git a/platform/lang-impl/src/com/intellij/find/impl/FindPopupScopeUIImpl.java b/platform/lang-impl/src/com/intellij/find/impl/FindPopupScopeUIImpl.java index 17d7536ae17d..622a814228f7 100644 --- a/platform/lang-impl/src/com/intellij/find/impl/FindPopupScopeUIImpl.java +++ b/platform/lang-impl/src/com/intellij/find/impl/FindPopupScopeUIImpl.java @@ -1,9 +1,10 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.find.impl; import com.intellij.find.FindBundle; import com.intellij.find.FindModel; import com.intellij.find.FindSettings; +import com.intellij.ide.util.scopeChooser.FrontendScopeChooserCombo; import com.intellij.ide.util.scopeChooser.ScopeChooserCombo; import com.intellij.ide.util.scopeChooser.ScopeDescriptor; import com.intellij.openapi.module.Module; @@ -48,6 +49,7 @@ final class FindPopupScopeUIImpl implements FindPopupScopeUI { private ComboBox myModuleComboBox; private FindPopupDirectoryChooser myDirectoryChooser; private ScopeChooserCombo myScopeCombo; + private FrontendScopeChooserCombo newScopeCombo; FindPopupScopeUIImpl(@NotNull FindPopupPanel panel) { myHelper = panel.getHelper(); @@ -61,8 +63,8 @@ final class FindPopupScopeUIImpl implements FindPopupScopeUI { ? ContainerUtil.ar(new Pair<>(PROJECT, new JLabel()), new Pair<>(MODULE, shrink(myModuleComboBox)), new Pair<>(DIRECTORY, myDirectoryChooser), - new Pair<>(SCOPE, shrink(myScopeCombo))) - : ContainerUtil.ar(new Pair<>(SCOPE, shrink(myScopeCombo)), + new Pair<>(SCOPE, shrink(getScopeChooser()))) + : ContainerUtil.ar(new Pair<>(SCOPE, shrink(getScopeChooser())), new Pair<>(DIRECTORY, myDirectoryChooser)); } @@ -80,47 +82,85 @@ final class FindPopupScopeUIImpl implements FindPopupScopeUI { myDirectoryChooser = new FindPopupDirectoryChooser(myFindPopupPanel); - myScopeCombo = new ScopeChooserCombo(); - Object selection = ObjectUtils.coalesce(myHelper.getModel().getCustomScopeName(), FindSettings.getInstance().getDefaultScopeName()); - myScopeCombo.init(myProject, true, true, selection, new Condition<>() { - //final String projectFilesScopeName = PsiBundle.message("psi.search.scope.project"); - final String moduleFilesScopeName; + initScopeCombo(restartSearchListener); + } - { - String moduleScopeName = IndexingBundle.message("search.scope.module", ""); - final int ind = moduleScopeName.indexOf(' '); - moduleFilesScopeName = moduleScopeName.substring(0, ind + 1); - } + private JComponent getScopeChooser() { + return FindKey.isEnabled() ? newScopeCombo : myScopeCombo; + } - @Override - public boolean value(ScopeDescriptor descriptor) { - final String display = descriptor.getDisplayName(); - return /*!projectFilesScopeName.equals(display) &&*/ !display.startsWith(moduleFilesScopeName); - } - }); - myScopeCombo.setBrowseListener(new ScopeChooserCombo.BrowseListener() { + private ComboBox getScopeCombo() { + return FindKey.isEnabled() ? newScopeCombo: myScopeCombo.getComboBox(); + } - private FindModel myModelSnapshot; + private void initScopeCombo(ActionListener restartSearchListener) { + if (FindKey.isEnabled()) { + newScopeCombo = new FrontendScopeChooserCombo(myProject); + Disposer.register(myFindPopupPanel.getDisposable(), newScopeCombo); + } + else { + myScopeCombo = new ScopeChooserCombo(); + Object selection = ObjectUtils.coalesce(myHelper.getModel().getCustomScopeName(), FindSettings.getInstance().getDefaultScopeName()); + myScopeCombo.init(myProject, true, true, selection, new Condition<>() { + //final String projectFilesScopeName = PsiBundle.message("psi.search.scope.project"); + final String moduleFilesScopeName; - @Override - public void onBeforeBrowseStarted() { - myModelSnapshot = myHelper.getModel(); - myFindPopupPanel.getCanClose().set(false); - } - - @Override - public void onAfterBrowseFinished() { - if (myModelSnapshot != null) { - SearchScope scope = myScopeCombo.getSelectedScope(); - if (scope != null) { - myModelSnapshot.setCustomScope(scope); - } - myFindPopupPanel.getCanClose().set(true); + { + String moduleScopeName = IndexingBundle.message("search.scope.module", ""); + final int ind = moduleScopeName.indexOf(' '); + moduleFilesScopeName = moduleScopeName.substring(0, ind + 1); } - } - }); - myScopeCombo.getComboBox().addActionListener(restartSearchListener); - Disposer.register(myFindPopupPanel.getDisposable(), myScopeCombo); + + @Override + public boolean value(ScopeDescriptor descriptor) { + final String display = descriptor.getDisplayName(); + return /*!projectFilesScopeName.equals(display) &&*/ !display.startsWith(moduleFilesScopeName); + } + }); + myScopeCombo.setBrowseListener(new ScopeChooserCombo.BrowseListener() { + + private FindModel myModelSnapshot; + + @Override + public void onBeforeBrowseStarted() { + myModelSnapshot = myHelper.getModel(); + myFindPopupPanel.getCanClose().set(false); + } + + @Override + public void onAfterBrowseFinished() { + if (myModelSnapshot != null) { + SearchScope scope = myScopeCombo.getSelectedScope(); + if (scope != null) { + myModelSnapshot.setCustomScope(scope); + } + myFindPopupPanel.getCanClose().set(true); + } + } + }); + Disposer.register(myFindPopupPanel.getDisposable(), myScopeCombo); + } + getScopeCombo().addActionListener(restartSearchListener); + } + + private String getSelectedScopeName() { + if (FindKey.isEnabled()) { + return newScopeCombo.getSelectedScopeName(); + } + return myScopeCombo.getSelectedScopeName(); + } + + private void applyScopeTo(FindModel findModel) { + if (FindKey.isEnabled()) { + findModel.setCustomScopeId(newScopeCombo.getSelectedScopeId()); + findModel.setCustomScopeName(newScopeCombo.getSelectedScopeName()); + } + else { + SearchScope selectedCustomScope = myScopeCombo.getSelectedScope(); + String customScopeName = selectedCustomScope == null ? null : selectedCustomScope.getDisplayName(); + findModel.setCustomScopeName(customScopeName); + findModel.setCustomScope(selectedCustomScope); + } } @Override @@ -130,7 +170,7 @@ final class FindPopupScopeUIImpl implements FindPopupScopeUI { @Override public void applyTo(@NotNull FindSettings findSettings, @NotNull FindPopupScopeUI.ScopeType selectedScope) { - findSettings.setDefaultScopeName(myScopeCombo.getSelectedScopeName()); + findSettings.setDefaultScopeName(getSelectedScopeName()); } @Override @@ -146,10 +186,7 @@ final class FindPopupScopeUIImpl implements FindPopupScopeUI { findModel.setModuleName((String)myModuleComboBox.getSelectedItem()); } else if (selectedScope == SCOPE) { - SearchScope selectedCustomScope = myScopeCombo.getSelectedScope(); - String customScopeName = selectedCustomScope == null ? null : selectedCustomScope.getDisplayName(); - findModel.setCustomScopeName(customScopeName); - findModel.setCustomScope(selectedCustomScope); + applyScopeTo(findModel); findModel.setCustomScope(true); } } @@ -164,7 +201,7 @@ final class FindPopupScopeUIImpl implements FindPopupScopeUI { @Override public boolean hideAllPopups() { - final JComboBox[] candidates = { myModuleComboBox, myScopeCombo.getComboBox(), myDirectoryChooser.getComboBox() }; + final JComboBox[] candidates = { myModuleComboBox, getScopeCombo(), myDirectoryChooser.getComboBox() }; for (JComboBox candidate : candidates) { if (candidate.isPopupVisible()) { candidate.hidePopup(); diff --git a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/FrontendScopeChooserCombo.kt b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/FrontendScopeChooserCombo.kt new file mode 100644 index 000000000000..21df9c86d587 --- /dev/null +++ b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/FrontendScopeChooserCombo.kt @@ -0,0 +1,132 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.util.scopeChooser + +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.EDT +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.popup.ListSeparator +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.fields.ExtendableTextComponent +import com.intellij.ui.components.fields.ExtendableTextField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Nls +import java.awt.Dimension +import java.util.* +import javax.swing.Icon +import javax.swing.JTextField +import javax.swing.plaf.basic.BasicComboBoxEditor +import kotlin.math.min + +/** + * Instances of `ScopeChooserCombo` **must be disposed** when the corresponding dialog or settings page is closed. Otherwise, + * listeners registered in `init()` cause memory leak.



+ * Example: if `ScopeChooserCombo` is used in a + * `DialogWrapper` subclass, call `Disposer.register(getDisposable(), myScopeChooserCombo)`, where + * `getDisposable()` is `DialogWrapper`'s method. + */ +@ApiStatus.Internal +class FrontendScopeChooserCombo(project: Project) : ComboBox(), Disposable { + private val scopeService = ScopeModelService.getInstance(project) + private val modelId = UUID.randomUUID().toString() + private var loadingTextComponent: ExtendableTextField? = null + private var scopesMap: Map = emptyMap() + private val scopeToSeparator: MutableMap = mutableMapOf() + + private val browseExtension: ExtendableTextComponent.Extension = ExtendableTextComponent.Extension.create(AllIcons.General.ArrowDown, "", //TODO() + { TODO() }) + private val loadingExtension: ExtendableTextComponent.Extension = ExtendableTextComponent.Extension.create(AnimatedIcon.Default(), "", { TODO() }) + + + init { + loadItemsAsync() + setEditor(object : BasicComboBoxEditor() { + override fun createEditorComponent(): JTextField { + val ecbEditor = ExtendableTextField() + ecbEditor.addExtension(browseExtension) + ecbEditor.setBorder(null) + loadingTextComponent = ecbEditor + return ecbEditor + } + }.apply { + renderer = createScopeDescriptorRenderer { descriptor -> scopeToSeparator[descriptor] } + }) + } + + private fun setLoading(loading: Boolean) { + isEnabled = !loading + + loadingTextComponent?.let { editor -> + + editor.removeExtension(loadingExtension) + editor.removeExtension(browseExtension) + if (loading) { // Add loading indicator extension + editor.addExtension(loadingExtension) + } + editor.addExtension(browseExtension) + editor.repaint() + } + } + + private fun loadItemsAsync() { + setLoading(true) + + scopeService.loadItemsAsync(modelId, onFinished = { scopeIdToScopeDescriptor -> + scopesMap = scopeIdToScopeDescriptor ?: emptyMap() + val items = scopesMap.values + withContext(Dispatchers.EDT) { + removeAllItems() + items.filterOutSeparators().forEach { addItem(it) } + setLoading(false) + } + }) + } + + private fun Collection.filterOutSeparators(): List { + var lastItem: ScopeDescriptor? = null + + return this.filter { item -> + if (item is ScopeSeparator) { + if (lastItem != null) { + scopeToSeparator[lastItem] = ListSeparator(item.text) + } + } + lastItem = item + item !is ScopeSeparator + } + } + + override fun getPreferredSize(): Dimension? { + if (isPreferredSizeSet) { + return super.getPreferredSize() + } + val preferredSize = super.getPreferredSize() + return Dimension(min(400, preferredSize.width), preferredSize.height) + } + + fun getSelectedScopeId(): String? { + val scopeDescriptor = selectedItem as? ScopeDescriptor + return scopesMap.entries.firstOrNull { it.value == scopeDescriptor }?.key + } + + @Nls + fun getSelectedScopeName(): String? { + return (selectedItem as? ScopeDescriptor)?.displayName + } + + + override fun dispose() { + scopeService.disposeModel(modelId) // ActionListener[] listeners = myBrowseButton.getActionListeners(); + // for (ActionListener listener : listeners) { + // myBrowseButton.removeActionListener(listener); + // } + } + +} + + +@ApiStatus.Internal +data class SearchScopeUiInfo(val id: String, val name: String, val icon: Icon?, val isSeparator: Boolean) \ No newline at end of file diff --git a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeChooserCombo.java b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeChooserCombo.java index 7403958fed09..d5b343099e47 100644 --- a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeChooserCombo.java +++ b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeChooserCombo.java @@ -1,4 +1,4 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.ide.util.scopeChooser; import com.intellij.openapi.Disposable; @@ -107,7 +107,8 @@ public class ScopeChooserCombo extends ComboboxWithBrowseButton implements Dispo ComboBox combo = getComboBox(); combo.setMinimumAndPreferredWidth(JBUIScale.scale(300)); - combo.setRenderer(ScopeSeparatorKt.createScopeDescriptorRenderer(() -> scopes)); + combo.setRenderer( + ScopeSeparatorKt.createScopeDescriptorRenderer(scopes == null ? null : (descriptor) -> scopes.getSeparatorFor(descriptor))); combo.setSwingPopup(false); if (selection != null) { diff --git a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeModelService.kt b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeModelService.kt new file mode 100644 index 000000000000..d8802b314e22 --- /dev/null +++ b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeModelService.kt @@ -0,0 +1,24 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.util.scopeChooser + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import org.jetbrains.annotations.ApiStatus + + +@ApiStatus.Internal +interface ScopeModelService { + + fun loadItemsAsync(modelId: String, onFinished: suspend (Map?) -> Unit) + + fun disposeModel(modelId: String) + + fun getScopeById(scopeId: String): ScopeDescriptor? + + companion object { + @JvmStatic + fun getInstance(project: Project): ScopeModelService { + return project.service() + } + } +} \ No newline at end of file diff --git a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeSeparator.kt b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeSeparator.kt index bfa7fc1aa816..ae0d0deb8ae2 100644 --- a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeSeparator.kt +++ b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopeSeparator.kt @@ -1,10 +1,10 @@ -// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.ide.util.scopeChooser +import com.intellij.openapi.ui.popup.ListSeparator import com.intellij.ui.dsl.listCellRenderer.listCellRenderer import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.Nls -import java.util.function.Supplier import javax.swing.ListCellRenderer @ApiStatus.Internal @@ -15,15 +15,14 @@ class ScopeSeparator @ApiStatus.Internal constructor(@Nls val text: String) : Sc } } -internal fun createScopeDescriptorRenderer(scopesSupplier: Supplier): ListCellRenderer { +internal fun createScopeDescriptorRenderer(separatorProvider: ((ScopeDescriptor) -> ListSeparator?)?): ListCellRenderer { return listCellRenderer("") { value.icon?.let { icon(it) } text(value.displayName ?: "") - val scopes = scopesSupplier.get() - scopes?.getSeparatorFor(value)?.let { + separatorProvider?.invoke(value)?.let { separator { text = it.text } diff --git a/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopesStateService.kt b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopesStateService.kt new file mode 100644 index 000000000000..b61bd6642f94 --- /dev/null +++ b/platform/lang-impl/src/com/intellij/ide/util/scopeChooser/ScopesStateService.kt @@ -0,0 +1,41 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.util.scopeChooser + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.psi.search.SearchScope +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +@Service(Service.Level.PROJECT) +class ScopesStateService(val project: Project) { + private var scopesState: ScopesState? = null + + fun getScopeById(scopeId: String): SearchScope? { + return scopesState?.scopeIdToDescriptor[scopeId]?.let { return it.scope } + } + + fun getOrCreateScopesState(): ScopesState { + if (scopesState != null) return scopesState!! + val state = ScopesState(project) + scopesState = state + return state + } + + companion object { + @JvmStatic + fun getInstance(project: Project): ScopesStateService { + return project.service() + } + } +} + +@ApiStatus.Internal +class ScopesState internal constructor(val project: Project) { + var scopeIdToDescriptor: Map = mapOf() + + fun updateScopes(scopesStateMap: Map) { + scopeIdToDescriptor = scopesStateMap + } +} \ No newline at end of file diff --git a/platform/scopes/BUILD.bazel b/platform/scopes/BUILD.bazel index b8e3b6fed5d3..66aa846655ef 100644 --- a/platform/scopes/BUILD.bazel +++ b/platform/scopes/BUILD.bazel @@ -22,6 +22,10 @@ jvm_library( "@lib//:kotlinx-serialization-core", "@lib//:kotlinx-serialization-json", "//platform/lang-impl", + "//platform/util:util-ui", + "//platform/project/shared:project", + "//platform/kernel/shared:kernel", + "//platform/util/coroutines", ], runtime_deps = [":scopes_resources"] ) diff --git a/platform/scopes/api-dump.txt b/platform/scopes/api-dump.txt index 5a12305a473f..e69de29bb2d1 100644 --- a/platform/scopes/api-dump.txt +++ b/platform/scopes/api-dump.txt @@ -1,8 +0,0 @@ -c:com.intellij.ide.util.scopeChooser.ScopeDescriptor -- com.intellij.openapi.util.ColoredItem -- (com.intellij.psi.search.SearchScope):V -- getColor():java.awt.Color -- getDisplayName():java.lang.String -- getIcon():javax.swing.Icon -- getScope():com.intellij.psi.search.SearchScope -- scopeEquals(com.intellij.psi.search.SearchScope):Z diff --git a/platform/scopes/backend/BUILD.bazel b/platform/scopes/backend/BUILD.bazel index 861ceff1efb1..dd2b4aef8fe1 100644 --- a/platform/scopes/backend/BUILD.bazel +++ b/platform/scopes/backend/BUILD.bazel @@ -12,7 +12,17 @@ jvm_library( module_name = "intellij.platform.scopes.backend", visibility = ["//visibility:public"], srcs = glob(["src/**/*.kt", "src/**/*.java"], allow_empty = True), - deps = ["@lib//:kotlin-stdlib"], + deps = [ + "@lib//:kotlin-stdlib", + "//platform/scopes", + "//platform/project/shared:project", + "//platform/kernel/backend", + "//platform/core-api:core", + "//platform/util", + "//platform/lang-impl", + "//platform/core-ui", + "//platform/util/coroutines", + ], runtime_deps = [ ":backend_resources", "//platform/backend", diff --git a/platform/scopes/backend/intellij.platform.scopes.backend.iml b/platform/scopes/backend/intellij.platform.scopes.backend.iml index 745264fcc004..595fa6a6bb67 100644 --- a/platform/scopes/backend/intellij.platform.scopes.backend.iml +++ b/platform/scopes/backend/intellij.platform.scopes.backend.iml @@ -14,7 +14,11 @@ - $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + + $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + $MAVEN_REPOSITORY$/jetbrains/fleet/rhizomedb-compiler-plugin/2.2.0-RC2-0.2/rhizomedb-compiler-plugin-2.2.0-RC2-0.2.jar + $MAVEN_REPOSITORY$/com/jetbrains/fleet/rpc-compiler-plugin/2.2.0-RC2-0.1/rpc-compiler-plugin-2.2.0-RC2-0.1.jar + @@ -32,5 +36,13 @@ + + + + + + + + \ No newline at end of file diff --git a/platform/scopes/backend/resources/intellij.platform.scopes.backend.xml b/platform/scopes/backend/resources/intellij.platform.scopes.backend.xml index c74b673c5b4a..517ed55e555c 100644 --- a/platform/scopes/backend/resources/intellij.platform.scopes.backend.xml +++ b/platform/scopes/backend/resources/intellij.platform.scopes.backend.xml @@ -2,5 +2,9 @@ + + + /> + diff --git a/platform/scopes/backend/src/ScopeModelApiImpl.kt b/platform/scopes/backend/src/ScopeModelApiImpl.kt new file mode 100644 index 000000000000..e68ec321dbd1 --- /dev/null +++ b/platform/scopes/backend/src/ScopeModelApiImpl.kt @@ -0,0 +1,95 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.scopes.backend + +import com.intellij.ide.util.scopeChooser.* +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.findProjectOrNull +import com.intellij.platform.rpc.backend.RemoteApiProvider +import com.intellij.platform.scopes.ScopeModelApi +import com.intellij.platform.scopes.SearchScopeData +import com.intellij.platform.scopes.SearchScopesInfo +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import java.util.* + +private val LOG = logger() + +internal class ScopesModelApiImpl : ScopeModelApi { + private val modelIdToModel = mutableMapOf() + private val modelIdToScopes = mutableMapOf() + + override suspend fun createModelAndSubscribe(projectId: ProjectId, modelId: String): Flow? { + val project = projectId.findProjectOrNull() + if (project == null) { + LOG.warn("Project not found for projectId: $projectId") + return null + } + val model = project.getService(ScopeService::class.java) + .createModel(EnumSet.of( + ScopeOption.FROM_SELECTION, + ScopeOption.USAGE_VIEW, + ScopeOption.LIBRARIES, + ScopeOption.SEARCH_RESULTS + )) + modelIdToModel[modelId] = model + val flow = subscribeToModelUpdates(model, modelId, project) + model.refreshScopes(null) + return flow + } + + + private fun subscribeToModelUpdates(model: AbstractScopeModel, modelId: String, project: Project): Flow { + val flow = channelFlow { + model.addScopeModelListener(object : ScopeModelListener { + override fun scopesUpdated(scopes: ScopesSnapshot) { + val scopesStateMap = mutableMapOf() + val scopesData = scopes.scopeDescriptors.mapNotNull { descriptor -> + val scopeId = UUID.randomUUID().toString() + val scopeData = SearchScopeData.from(descriptor, scopeId) ?: return@mapNotNull null + scopesStateMap[scopeData.scopeId] = descriptor + scopeData + } + var scopeState = modelIdToScopes[modelId] + if (scopeState == null) { + scopeState = ScopesStateService.getInstance(project).getOrCreateScopesState(project) + modelIdToScopes[modelId] = scopeState + } + scopeState.updateScopes(scopesStateMap) + + val searchScopesInfo = SearchScopesInfo(scopesData, null, null, null) + launch { + send(searchScopesInfo) + } + } + }) + + awaitClose {} + } + return flow + } + + override suspend fun updateModel(modelId: String, scopesInfo: SearchScopesInfo): Flow { + TODO("Not yet implemented") + } + + override suspend fun dispose(modelId: String) { + modelIdToScopes.remove(modelId) + val model = modelIdToModel[modelId] ?: return + Disposer.dispose(model) + modelIdToModel.remove(modelId) + } +} + +private class ScopesStateApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + ScopesModelApiImpl() + } + } +} \ No newline at end of file diff --git a/platform/scopes/intellij.platform.scopes.iml b/platform/scopes/intellij.platform.scopes.iml index 5573c969f401..1fafece94e71 100644 --- a/platform/scopes/intellij.platform.scopes.iml +++ b/platform/scopes/intellij.platform.scopes.iml @@ -14,7 +14,11 @@ - $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + + $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + $MAVEN_REPOSITORY$/jetbrains/fleet/rhizomedb-compiler-plugin/2.2.0-RC2-0.2/rhizomedb-compiler-plugin-2.2.0-RC2-0.2.jar + $MAVEN_REPOSITORY$/com/jetbrains/fleet/rpc-compiler-plugin/2.2.0-RC2-0.1/rpc-compiler-plugin-2.2.0-RC2-0.1.jar + @@ -39,5 +43,9 @@ + + + + \ No newline at end of file diff --git a/platform/scopes/resources/intellij.platform.scopes.xml b/platform/scopes/resources/intellij.platform.scopes.xml index 164f46cfcfb2..9eb7f192a4e9 100644 --- a/platform/scopes/resources/intellij.platform.scopes.xml +++ b/platform/scopes/resources/intellij.platform.scopes.xml @@ -1,2 +1,8 @@ + + + + + diff --git a/platform/scopes/src/com/intellij/platform/scopes/ScopeModelApi.kt b/platform/scopes/src/com/intellij/platform/scopes/ScopeModelApi.kt new file mode 100644 index 000000000000..39a04a237f63 --- /dev/null +++ b/platform/scopes/src/com/intellij/platform/scopes/ScopeModelApi.kt @@ -0,0 +1,26 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.scopes + +import com.intellij.platform.project.ProjectId +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +@Rpc +interface ScopeModelApi : RemoteApi { + suspend fun createModelAndSubscribe(projectId: ProjectId, modelId: String): Flow? + + suspend fun updateModel(modelId: String, scopesInfo: SearchScopesInfo): Flow + suspend fun dispose(modelId: String) + + companion object { + @JvmStatic + suspend fun getInstance(): ScopeModelApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } +} \ No newline at end of file diff --git a/platform/scopes/src/com/intellij/platform/scopes/SearchScopeData.kt b/platform/scopes/src/com/intellij/platform/scopes/SearchScopeData.kt index 4aad1ba080ce..813e553897dc 100644 --- a/platform/scopes/src/com/intellij/platform/scopes/SearchScopeData.kt +++ b/platform/scopes/src/com/intellij/platform/scopes/SearchScopeData.kt @@ -2,14 +2,22 @@ package com.intellij.platform.scopes import com.intellij.ide.ui.colors.ColorId +import com.intellij.ide.ui.colors.color import com.intellij.ide.ui.colors.rpcId import com.intellij.ide.ui.icons.IconId +import com.intellij.ide.ui.icons.icon import com.intellij.ide.ui.icons.rpcId import com.intellij.ide.util.scopeChooser.ScopeDescriptor import com.intellij.ide.util.scopeChooser.ScopeSeparator +import com.intellij.openapi.module.Module +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.search.GlobalSearchScope import kotlinx.serialization.Serializable import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.Nls +import java.awt.Color +import java.util.* +import javax.swing.Icon @ApiStatus.Internal @Serializable @@ -31,4 +39,22 @@ class SearchScopeData(val scopeId: String, val name: @Nls String, val iconId: Ic class SearchScopesInfo(val scopes: List, val selectedScopeId: String?, val projectScopeId: String?, - val everywhereScopeId: String?) + val everywhereScopeId: String?) { + fun getScopeDescriptors(): Map { + return scopes.associate { + val descriptor = + if (it.isSeparator) ScopeSeparator(it.name) + else object : ScopeDescriptor(object : GlobalSearchScope() { + override fun contains(file: VirtualFile): Boolean = throw IllegalStateException("Should not be called") + override fun isSearchInModuleContent(aModule: Module): Boolean = throw IllegalStateException("Should not be called") + override fun isSearchInLibraries(): Boolean = throw IllegalStateException("Should not be called") + }) { + override fun getColor(): Color? = it.colorId?.color() + override fun getDisplayName(): @Nls(capitalization = Nls.Capitalization.Sentence) String = it.name + override fun getIcon(): Icon? = it.iconId?.icon() + } + + it.scopeId to descriptor + } + } +} diff --git a/platform/scopes/src/com/intellij/platform/scopes/service/ScopeModelServiceImpl.kt b/platform/scopes/src/com/intellij/platform/scopes/service/ScopeModelServiceImpl.kt new file mode 100644 index 000000000000..81afe94ac06a --- /dev/null +++ b/platform/scopes/src/com/intellij/platform/scopes/service/ScopeModelServiceImpl.kt @@ -0,0 +1,60 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.scopes.service + +import com.intellij.ide.util.scopeChooser.ScopeDescriptor +import com.intellij.ide.util.scopeChooser.ScopeModelService +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.platform.project.projectId +import com.intellij.platform.scopes.ScopeModelApi +import com.intellij.platform.util.coroutines.childScope +import fleet.rpc.client.RpcTimeoutException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.annotations.ApiStatus + + +private val LOG = logger() + +@ApiStatus.Internal +private class ScopeModelServiceImpl(private val project: Project, private val coroutineScope: CoroutineScope) : ScopeModelService { + private var scopeIdToDescriptor = mapOf() + + override fun loadItemsAsync(modelId: String, onFinished: suspend (Map?) -> Unit) { + coroutineScope.childScope("ScopesStateService.subscribeToScopeStates").launch { + try { + val scopesFlow = ScopeModelApi.getInstance().createModelAndSubscribe(project.projectId(), modelId) + if (scopesFlow == null) { + LOG.warn("Failed to subscribe to model updates for modelId: $modelId") + onFinished(null) + return@launch + } + scopesFlow.collect { scopesInfo -> + val fetchedScopes = scopesInfo.getScopeDescriptors() + onFinished(fetchedScopes) + scopeIdToDescriptor = fetchedScopes + } + } + catch (e: RpcTimeoutException) { + LOG.warn("Failed to subscribe to model updates for modelId: $modelId", e) + onFinished(null) + } + } + } + + override fun disposeModel(modelId: String) { + coroutineScope.launch { + try { + ScopeModelApi.getInstance().dispose(modelId) + } + catch (e: RpcTimeoutException) { + LOG.warn("Failed to dispose model for modelId: $modelId", e) + } + } + } + + override fun getScopeById(scopeId: String): ScopeDescriptor? { + scopeIdToDescriptor.get(scopeId)?.let { return it } + return null + } +} \ No newline at end of file diff --git a/platform/searchEverywhere/frontend/src/tabs/target/SeScopeChooserActionProvider.kt b/platform/searchEverywhere/frontend/src/tabs/target/SeScopeChooserActionProvider.kt index e4af3ed22127..f0f678272363 100644 --- a/platform/searchEverywhere/frontend/src/tabs/target/SeScopeChooserActionProvider.kt +++ b/platform/searchEverywhere/frontend/src/tabs/target/SeScopeChooserActionProvider.kt @@ -2,39 +2,17 @@ package com.intellij.platform.searchEverywhere.frontend.tabs.target import com.intellij.ide.actions.searcheverywhere.ScopeChooserAction -import com.intellij.ide.ui.colors.color -import com.intellij.ide.ui.icons.icon import com.intellij.ide.util.scopeChooser.ScopeDescriptor -import com.intellij.ide.util.scopeChooser.ScopeSeparator import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.module.Module -import com.intellij.openapi.vfs.VirtualFile import com.intellij.platform.scopes.SearchScopesInfo import com.intellij.platform.searchEverywhere.frontend.AutoToggleAction import com.intellij.psi.search.GlobalSearchScope import com.intellij.util.Processor import org.jetbrains.annotations.ApiStatus -import org.jetbrains.annotations.Nls -import java.awt.Color -import javax.swing.Icon @ApiStatus.Internal class SeScopeChooserActionProvider(val scopesInfo: SearchScopesInfo, private val onSelectedScopeChanged: (String?) -> Unit) { - private val descriptors: Map = scopesInfo.scopes.associate { - val descriptor = - if (it.isSeparator) ScopeSeparator(it.name) - else object : ScopeDescriptor(object : GlobalSearchScope() { - override fun contains(file: VirtualFile): Boolean = throw IllegalStateException("Should not be called") - override fun isSearchInModuleContent(aModule: Module): Boolean = throw IllegalStateException("Should not be called") - override fun isSearchInLibraries(): Boolean = throw IllegalStateException("Should not be called") - }) { - override fun getColor(): Color? = it.colorId?.color() - override fun getDisplayName(): @Nls(capitalization = Nls.Capitalization.Sentence) String = it.name - override fun getIcon(): Icon? = it.iconId?.icon() - } - - it.scopeId to descriptor - } + private val descriptors: Map = scopesInfo.getScopeDescriptors() var selectedScopeId: String? = scopesInfo.selectedScopeId private set(value) { diff --git a/platform/searchEverywhere/shared/resources/intellij.platform.searchEverywhere.xml b/platform/searchEverywhere/shared/resources/intellij.platform.searchEverywhere.xml index 3fc798b4e66c..4ff4060a99e2 100644 --- a/platform/searchEverywhere/shared/resources/intellij.platform.searchEverywhere.xml +++ b/platform/searchEverywhere/shared/resources/intellij.platform.searchEverywhere.xml @@ -1,4 +1,7 @@ + + +