diff --git a/platform/searchEverywhere/backend/src/impl/SeBackendService.kt b/platform/searchEverywhere/backend/src/impl/SeBackendService.kt index 3bb85d9fc639..3e9a92158878 100644 --- a/platform/searchEverywhere/backend/src/impl/SeBackendService.kt +++ b/platform/searchEverywhere/backend/src/impl/SeBackendService.kt @@ -271,6 +271,18 @@ class SeBackendService(val project: Project, private val coroutineScope: Corouti } } + suspend fun isCommandsSupported( + session: SeSession, + dataContextId: DataContextId, + providerIds: List, + isAllTab: Boolean, + ): Boolean { + return providerIds.any { providerId -> + val provider = getProvidersHolder(session, dataContextId)?.get(providerId, isAllTab) + provider?.isCommandsSupported() ?: false + } + } + companion object { @JvmStatic fun getInstance(project: Project): SeBackendService = project.service() diff --git a/platform/searchEverywhere/backend/src/impl/SeRemoteApiImpl.kt b/platform/searchEverywhere/backend/src/impl/SeRemoteApiImpl.kt index 50b0b8a9f694..eb4665ed2937 100644 --- a/platform/searchEverywhere/backend/src/impl/SeRemoteApiImpl.kt +++ b/platform/searchEverywhere/backend/src/impl/SeRemoteApiImpl.kt @@ -154,4 +154,9 @@ class SeRemoteApiImpl : SeRemoteApi { val project = projectId.findProjectOrNull() ?: return false return SeBackendService.getInstance(project).isExtendedInfoEnabled(session, dataContextId, providerIds, isAllTab) } + + override suspend fun isCommandsSupported(projectId: ProjectId, session: SeSession, dataContextId: DataContextId, providerIds: List, isAllTab: Boolean): Boolean { + val project = projectId.findProjectOrNull() ?: return false + return SeBackendService.getInstance(project).isCommandsSupported(session, dataContextId, providerIds, isAllTab) + } } diff --git a/platform/searchEverywhere/backend/src/providers/runConfigurations/SeRunConfigurationsProvider.kt b/platform/searchEverywhere/backend/src/providers/runConfigurations/SeRunConfigurationsProvider.kt index e358c1592820..3861f7a99900 100644 --- a/platform/searchEverywhere/backend/src/providers/runConfigurations/SeRunConfigurationsProvider.kt +++ b/platform/searchEverywhere/backend/src/providers/runConfigurations/SeRunConfigurationsProvider.kt @@ -33,7 +33,7 @@ class SeRunConfigurationsItem( } @ApiStatus.Internal -class SeRunConfigurationsProvider(private val contributorWrapper: SeAsyncContributorWrapper) : SeWrappedLegacyContributorItemsProvider() { +class SeRunConfigurationsProvider(private val contributorWrapper: SeAsyncContributorWrapper) : SeWrappedLegacyContributorItemsProvider(), SeCommandsProviderInterface { override val contributor: SearchEverywhereContributor get() = contributorWrapper.contributor override val id: String get() = SeProviderIdUtils.RUN_CONFIGURATIONS_ID override val displayName: @Nls String get() = contributor.fullGroupName @@ -61,6 +61,10 @@ class SeRunConfigurationsProvider(private val contributorWrapper: SeAsyncContrib return contributor.showInFindResults() } + override fun getSupportedCommands(): List { + return contributor.supportedCommands.map { commandInfo -> SeCommandInfo(commandInfo, id) } + } + override fun dispose() { Disposer.dispose(contributorWrapper) } diff --git a/platform/searchEverywhere/frontend/src/SeFrontendItemDataProvidersFacade.kt b/platform/searchEverywhere/frontend/src/SeFrontendItemDataProvidersFacade.kt index 361b2c5413a8..141160606400 100644 --- a/platform/searchEverywhere/frontend/src/SeFrontendItemDataProvidersFacade.kt +++ b/platform/searchEverywhere/frontend/src/SeFrontendItemDataProvidersFacade.kt @@ -103,6 +103,12 @@ class SeFrontendItemDataProvidersFacade(private val projectId: ProjectId, ) } + suspend fun isCommandsSupported(): Boolean { + return SeRemoteApi.getInstance().isCommandsSupported( + projectId, providerIds = providerIds, session = session, dataContextId = dataContextId, isAllTab = isAllTab + ) + } + companion object { private const val DEFAULT_CHUNK_SIZE: Int = 50 } diff --git a/platform/searchEverywhere/frontend/src/SeSelectionResult.kt b/platform/searchEverywhere/frontend/src/SeSelectionResult.kt new file mode 100644 index 000000000000..4a1c9ad147b9 --- /dev/null +++ b/platform/searchEverywhere/frontend/src/SeSelectionResult.kt @@ -0,0 +1,14 @@ +// 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.searchEverywhere.frontend + +import org.jetbrains.annotations.ApiStatus.Internal + +@Internal +sealed interface SeSelectionResult + +@Internal +class SeSelectionResultClose : SeSelectionResult +@Internal +class SeSelectionResultKeep : SeSelectionResult +@Internal +class SeSelectionResultText(val searchText: String) : SeSelectionResult \ No newline at end of file diff --git a/platform/searchEverywhere/frontend/src/SeTab.kt b/platform/searchEverywhere/frontend/src/SeTab.kt index 9edfc603c5ba..c2692d63a14d 100644 --- a/platform/searchEverywhere/frontend/src/SeTab.kt +++ b/platform/searchEverywhere/frontend/src/SeTab.kt @@ -49,4 +49,6 @@ interface SeTab : Disposable { suspend fun getPreviewInfo(itemData: SeItemData): SePreviewInfo? suspend fun isExtendedInfoEnabled(): Boolean + + suspend fun isCommandsSupported(): Boolean } \ No newline at end of file diff --git a/platform/searchEverywhere/frontend/src/resultsProcessing/SeTabDelegate.kt b/platform/searchEverywhere/frontend/src/resultsProcessing/SeTabDelegate.kt index 3f3199b0c158..3089c67d0c62 100644 --- a/platform/searchEverywhere/frontend/src/resultsProcessing/SeTabDelegate.kt +++ b/platform/searchEverywhere/frontend/src/resultsProcessing/SeTabDelegate.kt @@ -26,6 +26,9 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* import org.jetbrains.annotations.ApiStatus.Internal import org.jetbrains.annotations.Nls +import kotlin.collections.flatMap +import kotlin.collections.plus +import kotlin.collections.toSet @OptIn(ExperimentalCoroutinesApi::class) @Internal @@ -167,6 +170,10 @@ class SeTabDelegate( return providers.getValue().isExtendedInfoEnabled() } + suspend fun isCommandsSupported(): Boolean { + return providers.getValue().isCommandsSupported() + } + override fun dispose() {} private class Providers( @@ -274,6 +281,10 @@ class SeTabDelegate( suspend fun isExtendedInfoEnabled(): Boolean { return localProviders.values.any { it.isExtendedInfoEnabled() } || frontendProvidersFacade?.isExtendedInfoEnabled() == true } + + suspend fun isCommandsSupported(): Boolean { + return localProviders.values.any { it.isCommandsSupported() } || frontendProvidersFacade?.isCommandsSupported() == true + } } // Workaround for: IJPL-188383 Search Everywhere, All tab: 'Top Hit' filter is duplicated diff --git a/platform/searchEverywhere/frontend/src/tabs/actions/SeActionsTab.kt b/platform/searchEverywhere/frontend/src/tabs/actions/SeActionsTab.kt index e34fba2fecf3..3dadf4d7cf00 100644 --- a/platform/searchEverywhere/frontend/src/tabs/actions/SeActionsTab.kt +++ b/platform/searchEverywhere/frontend/src/tabs/actions/SeActionsTab.kt @@ -68,6 +68,10 @@ class SeActionsTab(private val delegate: SeTabDelegate) : SeTab { return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) } diff --git a/platform/searchEverywhere/frontend/src/tabs/all/SeAllTab.kt b/platform/searchEverywhere/frontend/src/tabs/all/SeAllTab.kt index 77f8109bd85a..4377584a8b07 100644 --- a/platform/searchEverywhere/frontend/src/tabs/all/SeAllTab.kt +++ b/platform/searchEverywhere/frontend/src/tabs/all/SeAllTab.kt @@ -82,6 +82,10 @@ class SeAllTab(private val delegate: SeTabDelegate) : SeTab { return delegate.getPreviewInfo(itemData, true) } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) } diff --git a/platform/searchEverywhere/frontend/src/tabs/classes/SeClassesTab.kt b/platform/searchEverywhere/frontend/src/tabs/classes/SeClassesTab.kt index 3da3498c5f37..c412171e349c 100644 --- a/platform/searchEverywhere/frontend/src/tabs/classes/SeClassesTab.kt +++ b/platform/searchEverywhere/frontend/src/tabs/classes/SeClassesTab.kt @@ -69,6 +69,10 @@ class SeClassesTab(private val delegate: SeTabDelegate) : SeTab { return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) } diff --git a/platform/searchEverywhere/frontend/src/tabs/files/SeFilesTab.kt b/platform/searchEverywhere/frontend/src/tabs/files/SeFilesTab.kt index 7da8de198c1e..8c5fe0d81d38 100644 --- a/platform/searchEverywhere/frontend/src/tabs/files/SeFilesTab.kt +++ b/platform/searchEverywhere/frontend/src/tabs/files/SeFilesTab.kt @@ -70,6 +70,10 @@ class SeFilesTab(private val delegate: SeTabDelegate) : SeTab { return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) } diff --git a/platform/searchEverywhere/frontend/src/tabs/mocks/SeTabMock.kt b/platform/searchEverywhere/frontend/src/tabs/mocks/SeTabMock.kt index a1dc05d48861..d86f28e39747 100644 --- a/platform/searchEverywhere/frontend/src/tabs/mocks/SeTabMock.kt +++ b/platform/searchEverywhere/frontend/src/tabs/mocks/SeTabMock.kt @@ -49,6 +49,10 @@ class SeTabMock( return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) } diff --git a/platform/searchEverywhere/frontend/src/tabs/symbols/SeSymbolsTab.kt b/platform/searchEverywhere/frontend/src/tabs/symbols/SeSymbolsTab.kt index 885a80d481b6..f723fe19a673 100644 --- a/platform/searchEverywhere/frontend/src/tabs/symbols/SeSymbolsTab.kt +++ b/platform/searchEverywhere/frontend/src/tabs/symbols/SeSymbolsTab.kt @@ -69,6 +69,10 @@ open class SeSymbolsTab(private val delegate: SeTabDelegate) : SeTab { return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) } diff --git a/platform/searchEverywhere/frontend/src/tabs/text/SeTextTab.kt b/platform/searchEverywhere/frontend/src/tabs/text/SeTextTab.kt index fd9ada36a200..e0838d45f232 100644 --- a/platform/searchEverywhere/frontend/src/tabs/text/SeTextTab.kt +++ b/platform/searchEverywhere/frontend/src/tabs/text/SeTextTab.kt @@ -72,6 +72,10 @@ class SeTextTab(private val delegate: SeTabDelegate, registerShortcut: (AnAction return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(filterEditorDisposable) Disposer.dispose(delegate) diff --git a/platform/searchEverywhere/frontend/src/ui/SePopupContentPane.kt b/platform/searchEverywhere/frontend/src/ui/SePopupContentPane.kt index 85e5e67be583..3f0c83deb1ec 100644 --- a/platform/searchEverywhere/frontend/src/ui/SePopupContentPane.kt +++ b/platform/searchEverywhere/frontend/src/ui/SePopupContentPane.kt @@ -30,6 +30,8 @@ import com.intellij.platform.searchEverywhere.frontend.AutoToggleAction import com.intellij.platform.searchEverywhere.frontend.SeSearchStatePublisher import com.intellij.platform.searchEverywhere.frontend.SeSelectionListener import com.intellij.platform.searchEverywhere.frontend.SeSelectionState +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResultClose +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResultText import com.intellij.platform.searchEverywhere.frontend.tabs.actions.SeActionItemPresentationRenderer import com.intellij.platform.searchEverywhere.frontend.tabs.all.SeAllTab import com.intellij.platform.searchEverywhere.frontend.tabs.files.SeTargetItemPresentationRenderer @@ -271,7 +273,7 @@ class SePopupContentPane( hintHelper.setSearchInProgress(false) val wasFrozen = resultListModel.freezer.isEnabled - resultListModel.addFromThrottledEvent(searchId, event) + resultListModel.addFromThrottledEvent(searchContext, event) // Freeze back if it was frozen before if (wasFrozen) resultListModel.freezer.enable() @@ -343,11 +345,16 @@ class SePopupContentPane( } launch { - vm.searchFieldWarning.collect { warning -> + vm.searchFieldHint.collect { hint -> withContext(Dispatchers.EDT) { - warning?.let { (text, tooltip) -> - hintHelper.setLoadingText(text, tooltip) - } ?: hintHelper.setHint(null) + hint.let { (text, tooltip, isWarning) -> + if (isWarning) { + hintHelper.setLoadingText(text, tooltip) + } + else { + hintHelper.setHint(text) + } + } } } } @@ -488,10 +495,13 @@ class SePopupContentPane( } } - if (vmState.value?.itemsSelected(itemDataList, nonItemDataCount == 0, modifiers) == true) { + val selectedItems = vmState.value?.itemsSelected(itemDataList, nonItemDataCount == 0, modifiers) + if (selectedItems?.any { it is SeSelectionResultClose } == true) { closePopup() } else { + (selectedItems?.filterIsInstance()?.firstOrNull())?.let { textField.text = it.searchText } + resultList.repaint() refreshPresentations() } @@ -502,7 +512,7 @@ class SePopupContentPane( val listSize = resultListModel.size val firstIndex = resultList.firstVisibleIndex.takeIf { it in 0..= firstIndex} ?: return + val lastIndex = resultList.lastVisibleIndex.takeIf { it in 0..= firstIndex } ?: return val visibleRows = (firstIndex..lastIndex).mapNotNull { resultListModel.get(it) as? SeResultListItemRow diff --git a/platform/searchEverywhere/frontend/src/ui/SeResultListModel.kt b/platform/searchEverywhere/frontend/src/ui/SeResultListModel.kt index 996ee51bab40..1a9fca6c7adb 100644 --- a/platform/searchEverywhere/frontend/src/ui/SeResultListModel.kt +++ b/platform/searchEverywhere/frontend/src/ui/SeResultListModel.kt @@ -6,6 +6,7 @@ import com.intellij.ide.rpc.ThrottledItems import com.intellij.ide.rpc.ThrottledOneItem import com.intellij.platform.searchEverywhere.SeResultEvent import com.intellij.platform.searchEverywhere.frontend.SeSearchStatePublisher +import com.intellij.platform.searchEverywhere.frontend.vm.SeSearchContext import com.intellij.platform.searchEverywhere.providers.SeLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -43,7 +44,7 @@ class SeResultListModel(private val searchStatePublisher: SeSearchStatePublisher } } - fun addFromThrottledEvent(searchId: String, throttledEvent: ThrottledItems) { + fun addFromThrottledEvent(searchContext: SeSearchContext, throttledEvent: ThrottledItems) { if (!isValid) reset() val resultListAdapter = SeResultListModelAdapter(this, selectionModelProvider()) @@ -51,7 +52,7 @@ class SeResultListModel(private val searchStatePublisher: SeSearchStatePublisher is ThrottledAccumulatedItems -> { val accumulatedList = SeResultListCollection(pendingReplacementElementUuids) throttledEvent.items.forEach { - accumulatedList.handleEvent(it) + accumulatedList.handleEvent(searchContext, it) } // Remove SeResultListMoreRow from the accumulatedList if we already have one in the real listModel @@ -71,14 +72,14 @@ class SeResultListModel(private val searchStatePublisher: SeSearchStatePublisher accumulatedList.list.filterIsInstance().takeIf { it.isNotEmpty() }?.map { it.item }?.let { items -> - searchStatePublisher.elementsAdded(searchId, items.associateBy { it.uuid }) + searchStatePublisher.elementsAdded(searchContext.searchId, items.associateBy { it.uuid }) } } is ThrottledOneItem -> { - resultListAdapter.handleEvent(throttledEvent.item, onAdd = { - searchStatePublisher.elementsAdded(searchId, mapOf(it.uuid to it)) + resultListAdapter.handleEvent(searchContext, throttledEvent.item, onAdd = { + searchStatePublisher.elementsAdded(searchContext.searchId, mapOf(it.uuid to it)) }, onRemove = { - searchStatePublisher.elementsRemoved(searchId, 1) + searchStatePublisher.elementsRemoved(searchContext.searchId, 1) }) } } diff --git a/platform/searchEverywhere/frontend/src/ui/resultListEventHandlingUtils.kt b/platform/searchEverywhere/frontend/src/ui/resultListEventHandlingUtils.kt index a304af53d614..84593007f10a 100644 --- a/platform/searchEverywhere/frontend/src/ui/resultListEventHandlingUtils.kt +++ b/platform/searchEverywhere/frontend/src/ui/resultListEventHandlingUtils.kt @@ -4,6 +4,7 @@ package com.intellij.platform.searchEverywhere.frontend.ui import com.intellij.ide.actions.searcheverywhere.RecentFilesSEContributor import com.intellij.openapi.options.advanced.AdvancedSettings import com.intellij.platform.searchEverywhere.* +import com.intellij.platform.searchEverywhere.frontend.vm.SeSearchContext import com.intellij.platform.searchEverywhere.providers.SeLog import com.intellij.platform.searchEverywhere.providers.topHit.SeTopHitItemsProvider import org.jetbrains.annotations.ApiStatus @@ -37,14 +38,14 @@ interface SeResultList { } @ApiStatus.Internal -fun SeResultList.handleEvent(event: SeResultEvent, onAdd: ((SeItemData) -> Unit)? = null, onRemove: (() -> Unit)? = null) { +fun SeResultList.handleEvent(searchContext: SeSearchContext, event: SeResultEvent, onAdd: ((SeItemData) -> Unit)? = null, onRemove: (() -> Unit)? = null) { when (event) { is SeResultAddedEvent -> { if (pendingReplacementElementUuids.remove(event.itemData.uuid)) { SeLog.log(SeLog.DEFAULT) { "SeResultAddedEvent: uuid ${event.itemData.uuid} was skipped because it was supposed to be replaced by an element which came earlier" } } else { - val index = indexToAdd(event.itemData) + val index = indexToAdd(event.itemData, searchContext.searchPattern) addRow(index, SeResultListItemRow(event.itemData)) onAdd?.invoke(event.itemData) @@ -69,7 +70,7 @@ fun SeResultList.handleEvent(event: SeResultEvent, onAdd: ((SeItemData) -> Unit) }.sortedDescending() if (indexes.isEmpty()) { - val index = indexToAdd(event.newItemData) + val index = indexToAdd(event.newItemData, searchContext.searchPattern) addRow(index, SeResultListItemRow(event.newItemData)) onAdd?.invoke(event.newItemData) } @@ -85,12 +86,34 @@ fun SeResultList.handleEvent(event: SeResultEvent, onAdd: ((SeItemData) -> Unit) } } } - is SeResultEndEvent -> {}// Do nothing + is SeResultEndEvent -> {} // Do nothing } } -private fun SeResultList.indexToAdd(newItem: SeItemData): Int { +private fun SeResultList.indexToAdd(newItem: SeItemData, searchPattern: String): Int { + if (newItem.isCommand) { + val firstNotCommandIndex = firstIndexOrNull(true, true) { item -> !item.isCommand } ?: size + + val comparator = compareBy( + { !it.presentation.text.lowercase().startsWith(searchPattern) }, + { it.presentation.text.lowercase() } + ) + for (i in 0.. + if (item.isCommand) return@firstIndexOrNull false + val newItemProviderPriority = SeResultList.prioritizedProvidersPriorities[newItem.providerId] ?: 0 val itemProviderPriority = SeResultList.prioritizedProvidersPriorities[item.providerId] ?: 0 @@ -103,7 +126,7 @@ private fun SeResultList.indexToAdd(newItem: SeItemData): Int { } ?: lastIndexToInsertItem } -private fun SeResultList.firstIndexOrNull(fullSearch: Boolean, predicate: (SeItemData) -> Boolean): Int? { +private fun SeResultList.firstIndexOrNull(fullSearch: Boolean, acceptMoreRow: Boolean = false, predicate: (SeItemData) -> Boolean): Int? { val startIndex = if (fullSearch) 0 else frozenCount return (startIndex until size).firstOrNull { index -> @@ -111,7 +134,7 @@ private fun SeResultList.firstIndexOrNull(fullSearch: Boolean, predicate: (SeIte is SeResultListItemRow -> { predicate(row.item) } - SeResultListMoreRow -> false + SeResultListMoreRow -> acceptMoreRow } } } diff --git a/platform/searchEverywhere/frontend/src/vm/SePopupVm.kt b/platform/searchEverywhere/frontend/src/vm/SePopupVm.kt index faf3c0781e54..7a4424d25af2 100644 --- a/platform/searchEverywhere/frontend/src/vm/SePopupVm.kt +++ b/platform/searchEverywhere/frontend/src/vm/SePopupVm.kt @@ -2,6 +2,7 @@ package com.intellij.platform.searchEverywhere.frontend.vm import com.intellij.ide.IdeBundle +import com.intellij.ide.SearchTopHitProvider.Companion.getTopHitAccelerator import com.intellij.ide.actions.searcheverywhere.* import com.intellij.ide.actions.searcheverywhere.SEHeaderActionListener.Companion.SE_HEADER_ACTION_TOPIC import com.intellij.ide.actions.searcheverywhere.SearchEverywhereUI.PREVIEW_EVENTS @@ -21,6 +22,7 @@ import com.intellij.openapi.wm.ToolWindowManager.Companion.getInstance import com.intellij.platform.searchEverywhere.SeItemData import com.intellij.platform.searchEverywhere.SeProviderId import com.intellij.platform.searchEverywhere.SeSession +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResult import com.intellij.platform.searchEverywhere.frontend.SeTab import com.intellij.platform.searchEverywhere.frontend.tabs.actions.SeActionsTab import com.intellij.platform.searchEverywhere.utils.SuspendLazyProperty @@ -62,8 +64,10 @@ class SePopupVm( private val canBeShownInFindResultsFlow = MutableStateFlow(false) val canBeShownInFindResults: Boolean get() = canBeShownInFindResultsFlow.value - private val _searchFieldWarning = MutableStateFlow?>(null) - val searchFieldWarning: StateFlow?> = _searchFieldWarning + data class SearchFieldHint(val text: String?, val tooltip: String?, val isWarning: Boolean) + + private val _searchFieldHint = MutableStateFlow(SearchFieldHint("", null, false)) + val searchFieldHint: StateFlow = _searchFieldHint private var historyIterator: HistoryIterator = historyList.getIterator(currentTab.tabId) get() { @@ -135,21 +139,26 @@ class SePopupVm( }.map { (currentTab, isDumb, isIncomplete) -> // IJPL-193615: In RemDev, IncompleteDependenciesService state is not synchronized between frontend and backend, // so isIncomplete always remains false on frontend, making dependency loading messages unavailable in RemDev. - if (!currentTab.isIndexingDependent || (!isDumb && !isIncomplete)) return@map null - - if (isDumb) { + if (currentTab.isIndexingDependent && isDumb) { if (currentTab.tabId == SeActionsTab.ID) { - Pair(IdeBundle.message("dumb.mode.analyzing.project"), IdeBundle.message("dumb.mode.some.actions.might.be.unavailable.during.project.analysis")) + SearchFieldHint(IdeBundle.message("dumb.mode.analyzing.project"), IdeBundle.message("dumb.mode.some.actions.might.be.unavailable.during.project.analysis"), true) } else { - Pair(IdeBundle.message("dumb.mode.analyzing.project"), IdeBundle.message("dumb.mode.results.might.be.incomplete.during.project.analysis")) + SearchFieldHint(IdeBundle.message("dumb.mode.analyzing.project"), IdeBundle.message("dumb.mode.results.might.be.incomplete.during.project.analysis"), true) } } + else if (currentTab.isIndexingDependent && isIncomplete) { + SearchFieldHint(IdeBundle.message("incomplete.mode.results.might.be.incomplete"), null, true) + } else { - Pair(IdeBundle.message("incomplete.mode.results.might.be.incomplete"), null) + val text = if (currentTab.isCommandsSupported()) { + IdeBundle.message("searcheverywhere.textfield.hint", getTopHitAccelerator()) + } + else null + SearchFieldHint(text, null, false) } }.distinctUntilChanged().collect { - _searchFieldWarning.value = it + _searchFieldHint.value = it } } @@ -182,7 +191,7 @@ class SePopupVm( } } - suspend fun itemsSelected(indexedItems: List>, areIndexesOriginal: Boolean, modifiers: Int): Boolean { + suspend fun itemsSelected(indexedItems: List>, areIndexesOriginal: Boolean, modifiers: Int): List { val currentTab = currentTab return coroutineScope { @@ -190,7 +199,7 @@ class SePopupVm( async { currentTab.itemSelected(item, areIndexesOriginal, modifiers, searchPattern.value) } - }.awaitAll().any { it } + }.awaitAll() } } diff --git a/platform/searchEverywhere/frontend/src/vm/SeTabVm.kt b/platform/searchEverywhere/frontend/src/vm/SeTabVm.kt index 3919387c472b..92621e5b7467 100644 --- a/platform/searchEverywhere/frontend/src/vm/SeTabVm.kt +++ b/platform/searchEverywhere/frontend/src/vm/SeTabVm.kt @@ -21,6 +21,10 @@ import com.intellij.platform.searchEverywhere.* import com.intellij.platform.searchEverywhere.frontend.AutoToggleAction import com.intellij.platform.searchEverywhere.frontend.SeEmptyResultInfo import com.intellij.platform.searchEverywhere.frontend.SeFilterEditor +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResult +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResultClose +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResultKeep +import com.intellij.platform.searchEverywhere.frontend.SeSelectionResultText import com.intellij.platform.searchEverywhere.frontend.SeTab import com.intellij.platform.searchEverywhere.providers.SeAdaptedItem import com.intellij.platform.searchEverywhere.providers.SeLog @@ -32,6 +36,7 @@ import org.jetbrains.annotations.ApiStatus import java.util.* import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.text.toBoolean @OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class) @ApiStatus.Internal @@ -152,9 +157,17 @@ class SeTabVm( isActiveFlow.value = isActive } - suspend fun itemSelected(itemWithIndex: Pair, isIndexOriginal: Boolean, modifiers: Int, searchText: String): Boolean { + suspend fun itemSelected(itemWithIndex: Pair, isIndexOriginal: Boolean, modifiers: Int, searchText: String): SeSelectionResult { logItemSelectedEvent(itemWithIndex, isIndexOriginal) - return tab.itemSelected(itemWithIndex.second, modifiers, searchText) + + if (itemWithIndex.second.isCommand) { + return SeSelectionResultText(itemWithIndex.second.presentation.text) + } + + return if (tab.itemSelected(itemWithIndex.second, modifiers, searchText)) + SeSelectionResultClose() + else + SeSelectionResultKeep() } private fun logItemSelectedEvent(itemWithIndex: Pair, isIndexOriginal: Boolean) { @@ -242,6 +255,10 @@ class SeTabVm( suspend fun isExtendedInfoEnabled() : Boolean { return tab.isExtendedInfoEnabled() } + + suspend fun isCommandsSupported(): Boolean { + return tab.isCommandsSupported() + } } private const val ESSENTIALS_THROTTLE_DELAY: Long = 100 @@ -258,7 +275,7 @@ private fun Flow.throttleUntilEssentialsArrive(essentialProviderI resultThrottlingMs = essentialWaitingTimeout, shouldPassItem = { it !is SeResultEndEvent }, fastPassThrottlingMs = FAST_PASS_THROTTLE, - shouldFastPassItem = { it.providerId().shouldIgnoreThrottling() } + shouldFastPassItem = { it.itemDataOrNull()?.shouldIgnoreThrottling() == true } ) { event: SeResultEvent, _: Int -> val providerId = event.providerId() @@ -295,8 +312,12 @@ private fun SeResultEvent.itemDataOrNull(): SeItemData? = when (this) { is SeResultEndEvent -> null } -private fun SeProviderId.shouldIgnoreThrottling(): Boolean = - AdvancedSettings.getBoolean("search.everywhere.recent.at.top") && this.value == SeProviderIdUtils.RECENT_FILES_ID +private fun SeItemData.shouldIgnoreThrottling(): Boolean = + AdvancedSettings.getBoolean("search.everywhere.recent.at.top") && + this.providerId.value == SeProviderIdUtils.RECENT_FILES_ID || + this.isCommand @ApiStatus.Internal -class SeSearchContext(val searchId: String, val tabId: String, val searchPattern: String, val resultsFlow: Flow>) +class SeSearchContext(val searchId: String, val tabId: String, + val searchPattern: String, + val resultsFlow: Flow>) diff --git a/platform/searchEverywhere/shared/src/SeCommandInfo.kt b/platform/searchEverywhere/shared/src/SeCommandInfo.kt new file mode 100644 index 000000000000..867ca4f436b8 --- /dev/null +++ b/platform/searchEverywhere/shared/src/SeCommandInfo.kt @@ -0,0 +1,21 @@ +// 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.searchEverywhere + +import com.intellij.ide.actions.searcheverywhere.SearchEverywhereCommandInfo +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +@ApiStatus.Internal +@Serializable +class SeCommandInfo ( + val command: String, + val definition: String, + val providerId: String, +) { + constructor(searchEverywhereCommandInfo: SearchEverywhereCommandInfo, providerId: String) : this( + command = searchEverywhereCommandInfo.command, + definition = searchEverywhereCommandInfo.definition, + providerId = providerId + ) +} \ No newline at end of file diff --git a/platform/searchEverywhere/shared/src/SeCommandsProviderInterface.kt b/platform/searchEverywhere/shared/src/SeCommandsProviderInterface.kt new file mode 100644 index 000000000000..930048f4ac70 --- /dev/null +++ b/platform/searchEverywhere/shared/src/SeCommandsProviderInterface.kt @@ -0,0 +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 com.intellij.platform.searchEverywhere + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +interface SeCommandsProviderInterface { + fun getSupportedCommands(): List +} \ No newline at end of file diff --git a/platform/searchEverywhere/shared/src/SeItemData.kt b/platform/searchEverywhere/shared/src/SeItemData.kt index 1b7b2c3fec9a..42edefaa1664 100644 --- a/platform/searchEverywhere/shared/src/SeItemData.kt +++ b/platform/searchEverywhere/shared/src/SeItemData.kt @@ -22,6 +22,8 @@ class SeItemData private constructor( val additionalInfo: Map, private val itemRef: DurableRef, ) { + val isCommand: Boolean get() = additionalInfo[SeItemDataKeys.IS_COMMAND]?.toBoolean() == true + fun fetchItemIfExists(): SeItem? { return itemRef.derefOrNull()?.findItemOrNull() } diff --git a/platform/searchEverywhere/shared/src/SeItemDataKeys.kt b/platform/searchEverywhere/shared/src/SeItemDataKeys.kt index 50d5fb22fa0a..4834e31067f9 100644 --- a/platform/searchEverywhere/shared/src/SeItemDataKeys.kt +++ b/platform/searchEverywhere/shared/src/SeItemDataKeys.kt @@ -8,4 +8,5 @@ object SeItemDataKeys { const val IS_SEMANTIC: String = "SeItemDataKeys.IsSemantic" const val PSI_LANGUAGE_ID: String = "SeItemDataKeys.PsiLanguage" const val REPORTABLE_PROVIDER_ID: String = "SeItemDataKeys.ReportableProviderId" + const val IS_COMMAND: String = "SeItemDataKeys.IsCommand" } \ No newline at end of file diff --git a/platform/searchEverywhere/shared/src/impl/SeRemoteApi.kt b/platform/searchEverywhere/shared/src/impl/SeRemoteApi.kt index fa9772414739..c29c5ba8dc0c 100644 --- a/platform/searchEverywhere/shared/src/impl/SeRemoteApi.kt +++ b/platform/searchEverywhere/shared/src/impl/SeRemoteApi.kt @@ -131,11 +131,21 @@ interface SeRemoteApi : RemoteApi { isAllTab: Boolean, ): Boolean - suspend fun isExtendedInfoEnabled(projectId: ProjectId, - session: SeSession, - dataContextId: DataContextId, - providerIds: List, - isAllTab: Boolean): Boolean + suspend fun isExtendedInfoEnabled( + projectId: ProjectId, + session: SeSession, + dataContextId: DataContextId, + providerIds: List, + isAllTab: Boolean, + ): Boolean + + suspend fun isCommandsSupported( + projectId: ProjectId, + session: SeSession, + dataContextId: DataContextId, + providerIds: List, + isAllTab: Boolean, + ): Boolean companion object { @JvmStatic diff --git a/platform/searchEverywhere/shared/src/providers/SeAdaptedItemsProvider.kt b/platform/searchEverywhere/shared/src/providers/SeAdaptedItemsProvider.kt index c0ea49dc329a..616cf4746879 100644 --- a/platform/searchEverywhere/shared/src/providers/SeAdaptedItemsProvider.kt +++ b/platform/searchEverywhere/shared/src/providers/SeAdaptedItemsProvider.kt @@ -60,6 +60,14 @@ class SeAdaptedItemsProvider(contributor: SearchEverywhereContributor) : Se return contributorWrapper.contributor is SearchEverywhereExtendedInfoProvider } + fun isCommandsSupported(): Boolean { + return contributorWrapper.contributor.supportedCommands.isNotEmpty() + } + + fun getSupportedCommands(): List { + return contributorWrapper.contributor.supportedCommands.map { commandInfo -> SeCommandInfo(commandInfo, id) } + } + override fun dispose() { Disposer.dispose(contributorWrapper) } diff --git a/platform/searchEverywhere/shared/src/providers/SeLocalItemDataProvider.kt b/platform/searchEverywhere/shared/src/providers/SeLocalItemDataProvider.kt index b5a579dda554..5101b7497c66 100644 --- a/platform/searchEverywhere/shared/src/providers/SeLocalItemDataProvider.kt +++ b/platform/searchEverywhere/shared/src/providers/SeLocalItemDataProvider.kt @@ -1,6 +1,7 @@ // 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.searchEverywhere.providers +import com.intellij.ide.SearchTopHitProvider import com.intellij.ide.actions.searcheverywhere.statistics.SearchEverywhereUsageTriggerCollector import com.intellij.openapi.Disposable import com.intellij.openapi.application.EDT @@ -8,8 +9,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.platform.scopes.SearchScopesInfo import com.intellij.platform.searchEverywhere.* +import com.intellij.platform.searchEverywhere.providers.commands.SeCommandItem import com.intellij.platform.searchEverywhere.providers.target.SeTypeVisibilityStatePresentation -import com.intellij.platform.searchEverywhere.providers.topHit.SeTopHitItemsProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -19,9 +20,12 @@ import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.Nls import java.util.* +import kotlin.collections.filter +import kotlin.collections.map import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch +import kotlin.to @ApiStatus.Internal class SeLocalItemDataProvider( @@ -44,19 +48,72 @@ class SeLocalItemDataProvider( @OptIn(ExperimentalAtomicApi::class) fun getItems(params: SeParams): Flow { val counter = AtomicInt(0) - return getRawItems(params).mapNotNull { item -> - val itemData = SeItemData.createItemData(session, UUID.randomUUID().toString(), item, id, item.weight(), item.presentation(), infoWithReportableId, emptyList()) - itemData?.also { - SeLog.log(SeLog.ITEM_EMIT) { - val count = counter.incrementAndFetch() - "$logLabel provider for ${id.value} receives (total=$count, priority=${itemData.weight}): ${itemData.uuid} - ${itemData.presentation.text.split("\n").firstOrNull()}" - } - } - }.buffer(0, onBufferOverflow = BufferOverflow.SUSPEND) + + val commandItemsFlow = createCommandItemsFlow(params, counter) + val rawItemsFlow = createRawItemsFlow(params, counter) + + return merge(commandItemsFlow, rawItemsFlow) + .buffer(0, onBufferOverflow = BufferOverflow.SUSPEND) } + @OptIn(ExperimentalAtomicApi::class) + private fun createCommandItemsFlow( + params: SeParams, + counter: AtomicInt, + ): Flow = channelFlow { + val supportedCommands = getSupportedCommands() + val commandItems = getCommandItems(params, supportedCommands) + + for (item in commandItems) { + SeItemData.createItemData(session, UUID.randomUUID().toString(), item, id, item.weight(), + item.presentation(), mapOf(SeItemDataKeys.IS_COMMAND to "true"), emptyList() + )?.also { itemData -> + val count = counter.incrementAndFetch() + SeLog.log(SeLog.ITEM_EMIT) { + "$logLabel provider for ${id.value} receives command (total=$count): " + + "${itemData.presentation.text.split("\n").firstOrNull()}" + } + send(itemData) + } + } + }.buffer(0, onBufferOverflow = BufferOverflow.SUSPEND) + + private fun getCommandItems(params: SeParams, supportedCommands: List): List { + val inputQuery = params.inputQuery + if (!inputQuery.isCommandQuery) return emptyList() + + val commandPrefix = SearchTopHitProvider.getTopHitAccelerator() + val typedCommand = inputQuery.removePrefix(commandPrefix) + val matchingCommands = supportedCommands + .filter { it.command.contains(typedCommand) } + + SeLog.log(SeLog.ITEM_EMIT) { + "Command item list completed - $logLabel - ${matchingCommands.size}" + } + + return matchingCommands.map { SeCommandItem(it) } + } + + @OptIn(ExperimentalAtomicApi::class) + fun createRawItemsFlow( + params: SeParams, + counter: AtomicInt, + ): Flow = getRawItems(params).mapNotNull { item -> + val itemData = SeItemData.createItemData(session, UUID.randomUUID().toString(), item, id, item.weight(), item.presentation(), infoWithReportableId, emptyList()) + itemData?.also { + SeLog.log(SeLog.ITEM_EMIT) { + val count = counter.incrementAndFetch() + "$logLabel provider for ${id.value} receives (total=$count, priority=${itemData.weight}): ${itemData.uuid} - ${itemData.presentation.text.split("\n").firstOrNull()}" + } + } + }.buffer(0, onBufferOverflow = BufferOverflow.SUSPEND) + @OptIn(ExperimentalAtomicApi::class) fun getRawItems(params: SeParams): Flow { + if (params.inputQuery.isCommandWithArgs && !isCommandsSupported()) { + return emptyFlow() + } + return channelFlow { try { provider.collectItems(params) { item -> @@ -110,26 +167,38 @@ class SeLocalItemDataProvider( } fun isPreviewEnabled(): Boolean { - if (provider is SeTopHitItemsProvider) { - return provider.isPreviewProvider() - } else if (provider is SeAdaptedItemsProvider) { + if (provider is SeAdaptedItemsProvider) { return provider.isPreviewProvider() } return provider is SeItemsPreviewProvider } fun isExtendedInfoEnabled(): Boolean { - if (provider is SeTopHitItemsProvider) { - return provider.isExtendedInfoProvider() - } - else if (provider is SeAdaptedItemsProvider) { + if (provider is SeAdaptedItemsProvider) { return provider.isExtendedInfoProvider() } return provider is SeExtendedInfoProvider } + fun isCommandsSupported(): Boolean { + if (provider is SeAdaptedItemsProvider) { + return provider.isCommandsSupported() + } + return provider is SeCommandsProviderInterface + } + + fun getSupportedCommands(): List { + if (provider is SeAdaptedItemsProvider) { + return provider.getSupportedCommands() + } + return (provider as? SeCommandsProviderInterface)?.getSupportedCommands() ?: emptyList() + } + override fun dispose() { SeLog.log(SeLog.LIFE_CYCLE, "$logLabel provider ${id.value} disposed") Disposer.dispose(provider) } -} \ No newline at end of file +} + +private val String.isCommandQuery get() = startsWith(SearchTopHitProvider.getTopHitAccelerator()) && !contains(" ") +private val String.isCommandWithArgs get() = startsWith(SearchTopHitProvider.getTopHitAccelerator()) && contains(" ") \ No newline at end of file diff --git a/platform/searchEverywhere/shared/src/providers/commands/SeCommandItem.kt b/platform/searchEverywhere/shared/src/providers/commands/SeCommandItem.kt new file mode 100644 index 000000000000..4becb5a6e6f4 --- /dev/null +++ b/platform/searchEverywhere/shared/src/providers/commands/SeCommandItem.kt @@ -0,0 +1,28 @@ +// 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.searchEverywhere.providers.commands + +import com.intellij.ide.SearchTopHitProvider +import com.intellij.ide.ui.icons.rpcId +import com.intellij.platform.searchEverywhere.SeCommandInfo +import com.intellij.platform.searchEverywhere.SeItem +import com.intellij.platform.searchEverywhere.SeItemPresentation +import com.intellij.platform.searchEverywhere.SeTargetItemPresentation +import com.intellij.util.ui.EmptyIcon +import org.jetbrains.annotations.ApiStatus.Internal + +@Internal +class SeCommandItem( + val commandInfo: SeCommandInfo, +) : SeItem { + override fun weight(): Int = 10 // same as in SearchEverywhereUI.myStubCommandContributor + override suspend fun presentation(): SeItemPresentation { + return SeTargetItemPresentation( + iconId = EmptyIcon.ICON_16.rpcId(), + presentableText = SearchTopHitProvider.getTopHitAccelerator() + commandInfo.command, + containerText = commandInfo.definition, + extendedInfo = null, + isMultiSelectionSupported = false) + } + + override val rawObject: Any get() = commandInfo +} diff --git a/platform/searchEverywhere/shared/src/providers/topHit/SeTopHitItemsProvider.kt b/platform/searchEverywhere/shared/src/providers/topHit/SeTopHitItemsProvider.kt index c7f9f0ac0924..ecc003edb24b 100644 --- a/platform/searchEverywhere/shared/src/providers/topHit/SeTopHitItemsProvider.kt +++ b/platform/searchEverywhere/shared/src/providers/topHit/SeTopHitItemsProvider.kt @@ -2,8 +2,6 @@ package com.intellij.platform.searchEverywhere.providers.topHit import com.intellij.ide.actions.searcheverywhere.SearchEverywhereContributor -import com.intellij.ide.actions.searcheverywhere.SearchEverywherePreviewProvider -import com.intellij.ide.actions.searcheverywhere.SearchEverywhereExtendedInfoProvider import com.intellij.openapi.application.EDT import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer @@ -35,7 +33,7 @@ open class SeTopHitItemsProvider( private val project: Project, private val contributorWrapper: SeAsyncContributorWrapper, override val displayName: @Nls String, -) : SeWrappedLegacyContributorItemsProvider() { +) : SeWrappedLegacyContributorItemsProvider(), SeCommandsProviderInterface { override val contributor: SearchEverywhereContributor = contributorWrapper.contributor override val id: String get() = id(isHost) @@ -61,12 +59,8 @@ open class SeTopHitItemsProvider( return contributor.showInFindResults() } - fun isPreviewProvider(): Boolean { - return contributorWrapper.contributor is SearchEverywherePreviewProvider - } - - fun isExtendedInfoProvider(): Boolean { - return contributor is SearchEverywhereExtendedInfoProvider + override fun getSupportedCommands(): List { + return contributor.supportedCommands.map { commandInfo -> SeCommandInfo(commandInfo, id) } } override fun dispose() { diff --git a/platform/searchEverywhere/shared/src/utils/SeResultsCountBalancer.kt b/platform/searchEverywhere/shared/src/utils/SeResultsCountBalancer.kt index 90498c3856b0..ddf9b0955096 100644 --- a/platform/searchEverywhere/shared/src/utils/SeResultsCountBalancer.kt +++ b/platform/searchEverywhere/shared/src/utils/SeResultsCountBalancer.kt @@ -60,6 +60,13 @@ class SeResultsCountBalancer(private val logLabel: String, } suspend fun add(newItem: SeItemData): SeItemData { + if (newItem.isCommand) { + SeLog.logSuspendable(SeLog.BALANCING) { + "($logLabel) Command item ${newItem.presentation.text} with provider ${newItem.providerId} is returned without balancing" + } + return newItem + } + allProvidersCounts[newItem.providerId]?.fetchAndIncrement() highPriorityPermits[newItem.providerId]?.acquire() diff --git a/plugins/git4idea/frontend/src/com/intellij/vcs/git/frontend/searchEverywhere/SeGitTab.kt b/plugins/git4idea/frontend/src/com/intellij/vcs/git/frontend/searchEverywhere/SeGitTab.kt index b0039463f827..ffa006afc815 100644 --- a/plugins/git4idea/frontend/src/com/intellij/vcs/git/frontend/searchEverywhere/SeGitTab.kt +++ b/plugins/git4idea/frontend/src/com/intellij/vcs/git/frontend/searchEverywhere/SeGitTab.kt @@ -62,6 +62,10 @@ class SeGitTab(private val delegate: SeTabDelegate) : SeTab { return delegate.isExtendedInfoEnabled() } + override suspend fun isCommandsSupported(): Boolean { + return delegate.isCommandsSupported() + } + override fun dispose() { Disposer.dispose(delegate) }