diff --git a/platform/platform-impl/BUILD.bazel b/platform/platform-impl/BUILD.bazel index 2acdb1f3c509..b4a0d2090f41 100644 --- a/platform/platform-impl/BUILD.bazel +++ b/platform/platform-impl/BUILD.bazel @@ -641,6 +641,7 @@ jvm_library( "//platform/util/coroutines", "//platform/util/coroutines:coroutines_test_lib", "@lib//:jetbrains-annotations", + "@lib//:kotlinx-coroutines-test", "//libraries/lz4", "//libraries/lz4:lz4_test_lib", "//libraries/kotlinx/collections-immutable:libraries-kotlinx-collections-immutable", diff --git a/platform/platform-impl/api-dump-unreviewed.txt b/platform/platform-impl/api-dump-unreviewed.txt index ba037d8c2e74..3e4ec5b540ad 100644 --- a/platform/platform-impl/api-dump-unreviewed.txt +++ b/platform/platform-impl/api-dump-unreviewed.txt @@ -1642,14 +1642,6 @@ c:com.intellij.ide.plugins.newui.PluginSearchTextField - setSelectedItem(java.lang.String):V - setTextIgnoreEvents(java.lang.String):V - p:showCompletionPopup():V -c:com.intellij.ide.plugins.newui.PluginUpdatesService -- ():V -- calculateUpdates(java.util.function.Consumer):V -- dispose():V -- finishUpdate():V -- s:getUpdates():java.util.Collection -- s:getUpdatesTooltip():java.lang.String -- s:reapplyFilter():V f:com.intellij.ide.plugins.newui.TagComponent - ():V - setText(java.lang.String):V diff --git a/platform/platform-impl/api-dump.txt b/platform/platform-impl/api-dump.txt index 2cbf9d8cb953..1b480040ab18 100644 --- a/platform/platform-impl/api-dump.txt +++ b/platform/platform-impl/api-dump.txt @@ -1357,9 +1357,6 @@ c:com.intellij.ide.plugins.newui.LinkComponent c:com.intellij.ide.plugins.newui.PluginSearchTextField - com.intellij.ui.SearchTextField - ():V -c:com.intellij.ide.plugins.newui.PluginUpdatesService -- s:isNeedUpdate(com.intellij.ide.plugins.IdeaPluginDescriptor):Z -- recalculateUpdates():V f:com.intellij.ide.plugins.newui.TagComponent - com.intellij.ide.plugins.newui.LinkComponent - (java.lang.String):V diff --git a/platform/platform-impl/intellij.platform.ide.impl.tests.iml b/platform/platform-impl/intellij.platform.ide.impl.tests.iml index f9aa92c25579..f416201cb04f 100644 --- a/platform/platform-impl/intellij.platform.ide.impl.tests.iml +++ b/platform/platform-impl/intellij.platform.ide.impl.tests.iml @@ -136,6 +136,7 @@ + diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/InstalledPluginsTab.kt b/platform/platform-impl/src/com/intellij/ide/plugins/InstalledPluginsTab.kt index 93ddde1955cd..d5571ceea7e6 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/InstalledPluginsTab.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/InstalledPluginsTab.kt @@ -12,12 +12,13 @@ import com.intellij.ide.plugins.marketplace.statistics.PluginManagerUsageCollect import com.intellij.ide.plugins.newui.ListPluginComponent import com.intellij.ide.plugins.newui.MultiSelectionEventHandler import com.intellij.ide.plugins.newui.MyPluginModel +import com.intellij.ide.plugins.newui.PluginUpdatesService import com.intellij.ide.plugins.newui.PluginDetailsPageComponent import com.intellij.ide.plugins.newui.PluginInstallationState import com.intellij.ide.plugins.newui.PluginLogo import com.intellij.ide.plugins.newui.PluginModelFacade import com.intellij.ide.plugins.newui.PluginUiModel -import com.intellij.ide.plugins.newui.PluginUpdatesService +import com.intellij.ide.plugins.newui.PluginUpdateSubscription import com.intellij.ide.plugins.newui.PluginsGroup import com.intellij.ide.plugins.newui.PluginsGroupComponent import com.intellij.ide.plugins.newui.PluginsGroupComponentWithProgress @@ -62,7 +63,6 @@ import javax.swing.JLabel @ApiStatus.Internal class InstalledPluginsTab @RequiresEdt constructor( private val pluginModelFacade: PluginModelFacade, - private val pluginUpdatesService: PluginUpdatesService, private val coroutineScope: CoroutineScope, private val searchInMarketplaceTabHandler: Consumer?, searchTextFieldQueryDebouncePeriodMs: Long = 100, @@ -91,6 +91,8 @@ class InstalledPluginsTab @RequiresEdt constructor( private val eventHandler = MultiSelectionEventHandler() private val installedPanel = createInstalledPanel(eventHandler) + private var pluginUpdateSubscription: PluginUpdateSubscription? = null + init { updateAllLink.isVisible = false bundledUpdateAllLink.isVisible = false @@ -290,9 +292,8 @@ class InstalledPluginsTab @RequiresEdt constructor( } } - pluginUpdatesService.calculateUpdates { updates -> - val updateModels = updates?.filter { plugin -> pluginModelFacade.isEnabled(plugin) } - ?: emptyList() + pluginUpdateSubscription = PluginUpdatesService.getInstance().subscribe { updates -> + val updateModels = updates.all.filter{ plugin -> pluginModelFacade.isEnabled(plugin) } setUpdateDescriptors(installedPanel, updateModels) setUpdateDescriptors(searchPanel.panel, updateModels) applyBundledUpdates(updateModels) @@ -305,6 +306,11 @@ class InstalledPluginsTab @RequiresEdt constructor( } } + override fun dispose() { + pluginUpdateSubscription?.cancel() + super.dispose() + } + private fun onUpdateAllClick() { updateAllLink.isEnabled = false bundledUpdateAllLink.isEnabled = false diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/MarketplacePluginsTab.kt b/platform/platform-impl/src/com/intellij/ide/plugins/MarketplacePluginsTab.kt index c1742165b2cc..632f9a418ead 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/MarketplacePluginsTab.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/MarketplacePluginsTab.kt @@ -13,6 +13,7 @@ import com.intellij.ide.plugins.marketplace.statistics.PluginManagerUsageCollect import com.intellij.ide.plugins.newui.ListPluginComponent import com.intellij.ide.plugins.newui.MultiSelectionEventHandler import com.intellij.ide.plugins.newui.MyPluginModel +import com.intellij.ide.plugins.newui.PluginUpdatesService import com.intellij.ide.plugins.newui.NoOpPluginsViewCustomizer import com.intellij.ide.plugins.newui.PluginDetailsPageComponent import com.intellij.ide.plugins.newui.PluginInstallationState @@ -21,7 +22,7 @@ import com.intellij.ide.plugins.newui.PluginManagerCustomizer import com.intellij.ide.plugins.newui.PluginModelFacade import com.intellij.ide.plugins.newui.PluginUiModel import com.intellij.ide.plugins.newui.PluginUiModelAdapter -import com.intellij.ide.plugins.newui.PluginUpdatesService +import com.intellij.ide.plugins.newui.PluginUpdateSubscription import com.intellij.ide.plugins.newui.PluginsGroup import com.intellij.ide.plugins.newui.PluginsGroupComponent import com.intellij.ide.plugins.newui.PluginsGroupComponentWithProgress @@ -77,13 +78,12 @@ internal class MarketplacePluginsTab @RequiresEdt constructor( facade: PluginModelFacade, scope: CoroutineScope, customizer: PluginManagerCustomizer?, - service: PluginUpdatesService, searchTextFieldQueryDebouncePeriodMs: Long = 250, ) : PluginsTab(searchTextFieldQueryDebouncePeriodMs) { private val pluginModelFacade: PluginModelFacade = facade private val coroutineScope: CoroutineScope = scope private val pluginManagerCustomizer: PluginManagerCustomizer? = customizer - private val pluginUpdatesService: PluginUpdatesService = service + private var pluginUpdateSubscription: PluginUpdateSubscription? = null private val marketplaceSortByGroup: DefaultActionGroup = DefaultActionGroup().apply { for (option in MarketplaceTabSearchSortByOptions.entries) { @@ -324,13 +324,8 @@ internal class MarketplacePluginsTab @RequiresEdt constructor( marketplacePanel.doLayout() marketplacePanel.initialSelection() - pluginUpdatesService.calculateUpdates { updates -> - val updateModels: List = if (updates == null) { - emptyList() - } - else { - updates.filter { plugin -> pluginModelFacade.isEnabled(plugin) } - } + pluginUpdateSubscription = PluginUpdatesService.getInstance().subscribe { updates -> + val updateModels: List = updates.all.filter { plugin -> pluginModelFacade.isEnabled(plugin) } setUpdateDescriptors(marketplacePanel, updateModels) setUpdateDescriptors(searchPanel.panel, updateModels) selectionListener.accept(marketplacePanel) @@ -675,6 +670,7 @@ internal class MarketplacePluginsTab @RequiresEdt constructor( override fun dispose() { marketplacePanel.dispose() searchPanel.dispose() + pluginUpdateSubscription?.cancel() super.dispose() } diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurablePanel.kt b/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurablePanel.kt index be97ebc4efd1..ed0f18c3253f 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurablePanel.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurablePanel.kt @@ -10,12 +10,13 @@ import com.intellij.ide.plugins.certificates.PluginCertificateManager import com.intellij.ide.plugins.marketplace.statistics.PluginManagerUsageCollector import com.intellij.ide.plugins.newui.ListPluginComponent import com.intellij.ide.plugins.newui.MyPluginModel +import com.intellij.ide.plugins.newui.PluginUpdatesService import com.intellij.ide.plugins.newui.PluginManagerCustomizer import com.intellij.ide.plugins.newui.PluginModelAsyncOperationsExecutor import com.intellij.ide.plugins.newui.PluginModelFacade import com.intellij.ide.plugins.newui.PluginPriceService import com.intellij.ide.plugins.newui.PluginUiModel -import com.intellij.ide.plugins.newui.PluginUpdatesService +import com.intellij.ide.plugins.newui.PluginUpdateSubscription import com.intellij.ide.plugins.newui.PluginsGroup import com.intellij.ide.plugins.newui.PluginsGroupComponent import com.intellij.ide.plugins.newui.PluginsTab @@ -101,7 +102,7 @@ class PluginManagerConfigurablePanel @RequiresEdt constructor(searchQuery: Strin private val coroutineScope: CoroutineScope private val pluginModelFacade: PluginModelFacade - private val pluginUpdatesService: PluginUpdatesService + private val updateSubscription: PluginUpdateSubscription private val pluginManagerCustomizer: PluginManagerCustomizer? = PluginManagerCustomizer.getInstance() private val tabHeaderComponent: TabbedPaneHeaderComponent @@ -131,11 +132,10 @@ class PluginManagerConfigurablePanel @RequiresEdt constructor(searchQuery: Strin pluginModelFacade.getModel().coroutineScope = childScope coroutineScope = childScope - pluginUpdatesService = - UiPluginManager.getInstance().subscribeToUpdatesCount(pluginModelFacade.getModel().sessionId) { updatesCount -> - coroutineScope.launch(Dispatchers.EDT + any().asContextElement()) { onPluginUpdatesRecalculation(updatesCount) } + updateSubscription = + UiPluginManager.getInstance().subscribeToPluginUpdates(pluginModelFacade.getModel().sessionId) { pluginUpdates -> + coroutineScope.launch(Dispatchers.EDT + any().asContextElement()) { onPluginUpdatesRecalculation(pluginUpdates) } } - pluginModelFacade.getModel().pluginUpdatesService = pluginUpdatesService CustomPluginRepositoryService.getInstance().clearCache() @@ -285,31 +285,38 @@ class PluginManagerConfigurablePanel @RequiresEdt constructor(searchQuery: Strin private fun resetPanels() { CustomPluginRepositoryService.getInstance().clearCache() marketplaceTab.resetCache() - pluginUpdatesService.recalculateUpdates() + PluginUpdatesService.getInstance().recalculateUpdates() marketplaceTab.onPanelReset(tabHeaderComponent.getSelectionTab() == MARKETPLACE_TAB) } - private fun onPluginUpdatesRecalculation(updatesCount: Int?) { - val count = updatesCount ?: 0 - val text = Integer.toString(count) - - val tooltip = PluginUpdatesService.getUpdatesTooltip() + private fun onPluginUpdatesRecalculation(pluginUpdates: List) { + val text = Integer.toString(pluginUpdates.size) + val tooltip = getUpdatesTooltip(pluginUpdates) tabHeaderComponent.setTabTooltip(INSTALLED_TAB, tooltip) - installedTab.onPluginUpdatesRecalculation(updatesCount, tooltip) + installedTab.onPluginUpdatesRecalculation(pluginUpdates.size, tooltip) installedTabHeaderUpdatesCountIcon.setText(text) tabHeaderComponent.update() } + @Nls + fun getUpdatesTooltip(pluginUpdates: List): @Nls String? { + if (pluginUpdates.isEmpty()) { + return null + } + return IdeBundle.message("updates.plugin.ready.tooltip", + StringUtil.join(pluginUpdates.map { it.name }, ", "), + pluginUpdates.size) + } + private fun createMarketplaceTab(): MarketplacePluginsTab { - return MarketplacePluginsTab(pluginModelFacade, coroutineScope, pluginManagerCustomizer, pluginUpdatesService) + return MarketplacePluginsTab(pluginModelFacade, coroutineScope, pluginManagerCustomizer) } private fun createInstalledTab(): InstalledPluginsTab { val installedPluginsTab = InstalledPluginsTab( pluginModelFacade, - pluginUpdatesService, coroutineScope, { _ -> tabHeaderComponent.setSelectionWithEvents(MARKETPLACE_TAB) }, ) @@ -357,7 +364,7 @@ class PluginManagerConfigurablePanel @RequiresEdt constructor(searchQuery: Strin installedTab.getInstalledSearchPanel().dispose() - pluginUpdatesService.dispose() + updateSubscription.cancel() PluginPriceService.cancel() pluginsState.runShutdownCallback() diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurableTreeRenderer.java b/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurableTreeRenderer.java index f8da52dd5c34..99656407dc83 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurableTreeRenderer.java +++ b/platform/platform-impl/src/com/intellij/ide/plugins/PluginManagerConfigurableTreeRenderer.java @@ -2,9 +2,10 @@ package com.intellij.ide.plugins; import com.intellij.ide.plugins.newui.PluginUpdatesService; +import com.intellij.ide.plugins.newui.PluginUpdateSubscription; +import com.intellij.ide.plugins.newui.PluginUpdatesEvent; import com.intellij.openapi.options.ConfigurableTreeRenderer; import com.intellij.openapi.options.UnnamedConfigurable; -import com.intellij.openapi.updateSettings.impl.InternalPluginResults; import com.intellij.openapi.util.NlsSafe; import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.text.StringUtil; @@ -25,10 +26,10 @@ import java.util.function.Consumer; * @author Alexander Lobas */ @ApiStatus.Internal -public final class PluginManagerConfigurableTreeRenderer extends AncestorListenerAdapter implements ConfigurableTreeRenderer, Consumer { +public final class PluginManagerConfigurableTreeRenderer extends AncestorListenerAdapter implements ConfigurableTreeRenderer, Consumer { private final CountComponent myCountLabel = new CountComponent(); - private PluginUpdatesService myService; + private PluginUpdateSubscription myUpdateSubscription; private SimpleTree myTree; private @NlsSafe String myCountValue; @@ -37,7 +38,7 @@ public final class PluginManagerConfigurableTreeRenderer extends AncestorListene @Nullable UnnamedConfigurable configurable, boolean selected) { if (myTree == null) { - myService = PluginUpdatesService.connectWithUpdates(this); + myUpdateSubscription = PluginUpdatesService.getInstance().subscribe(this); tree.addAncestorListener(this); myTree = (SimpleTree)tree; } @@ -68,14 +69,14 @@ public final class PluginManagerConfigurableTreeRenderer extends AncestorListene @Override public void ancestorRemoved(AncestorEvent event) { - myService.dispose(); + myUpdateSubscription.cancel(); } @Override - public void accept(InternalPluginResults results) { + public void accept(PluginUpdatesEvent results) { String oldCountValue = myCountValue; - int countValue = results == null ? 0 : results.getPluginUpdates().getAll().size(); - myCountValue = countValue <= 0 ? null : Integer.toString(countValue); + int countValue = results == null ? 0 : results.getEnabledUpdates().size(); + myCountValue = countValue == 0 ? null : Integer.toString(countValue); if (myTree != null && !StringUtil.equals(oldCountValue, myCountValue)) { myTree.repaint(); } diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultPluginUpdatesProvider.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultPluginUpdatesProvider.kt new file mode 100644 index 000000000000..7fe045fb917d --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultPluginUpdatesProvider.kt @@ -0,0 +1,111 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.plugins.newui + +import com.intellij.ide.plugins.DynamicPluginEnabler +import com.intellij.ide.plugins.IdeaPluginDescriptor +import com.intellij.ide.plugins.PluginEnableStateChangedListener +import com.intellij.ide.plugins.PluginStateListener +import com.intellij.ide.plugins.PluginStateManager +import com.intellij.ide.plugins.api.PluginDto +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.updateSettings.impl.PluginUpdateHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(FlowPreview::class) +@ApiStatus.Internal +class DefaultPluginUpdatesProvider(private val coroutineScope: CoroutineScope) : PluginUpdatesProvider { + private val updateMutex = Mutex() + private val flow = MutableStateFlow(null) + private val updateRequestFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private var lastPluginUpdates: PluginUpdatesEvent? = null + + init { + PluginStateManager.addStateListener(object : PluginStateListener { + override fun install(descriptor: IdeaPluginDescriptor) { + coroutineScope.launch { dropFromLastPluginUpdates(setOf(descriptor.getPluginId())) } + } + + override fun uninstall(descriptor: IdeaPluginDescriptor) { + coroutineScope.launch { dropFromLastPluginUpdates(setOf(descriptor.getPluginId())) } + } + }) + DynamicPluginEnabler.addPluginStateChangedListener( object : PluginEnableStateChangedListener { + override fun stateChanged( + pluginDescriptors: Collection, + enable: Boolean, + ) { + if (enable) { + coroutineScope.launch { update() } + return + } + coroutineScope.launch { dropFromLastPluginUpdates(pluginDescriptors.map { it.getPluginId() }.toSet()) } + } + }) + + collectUpdateRequests() + } + + private fun collectUpdateRequests() = coroutineScope.launch(Dispatchers.IO) { + updateRequestFlow + .debounce(300.milliseconds) + .collectLatest { + updateMutex.withLock { + val model = (PluginUpdateHandler.getInstance().loadAndStorePluginUpdates(null)) + val pluginUpdates = PluginUpdatesEvent(model.pluginUpdates.markLocal(), + model.disabledPluginUpdates.markLocal(), + model.updatesFromCustomRepositories.markLocal()) + lastPluginUpdates = pluginUpdates + emitUpdates(pluginUpdates) + } + } + } + + override suspend fun pluginUpdateEvents(): Flow { + return flow + } + + override suspend fun update() { + updateRequestFlow.emit(Unit) + } + + private suspend fun emitUpdates(updates: PluginUpdatesEvent) { + flow.emit(updates) + } + + private suspend fun dropFromLastPluginUpdates(pluginUpdatesToRemove: Set) { + updateMutex.withLock { + if (lastPluginUpdates == null) return + computeUpdatesWithout(pluginUpdatesToRemove, lastPluginUpdates!!)?.also { + lastPluginUpdates = it + emitUpdates(it) + } + } + } + + private fun computeUpdatesWithout(pluginIds: Set, updates: PluginUpdatesEvent): PluginUpdatesEvent? { + if (updates.all.none { it.pluginId in pluginIds }) { + return null + } + return PluginUpdatesEvent( + updates.enabledUpdates.filter { it.pluginId !in pluginIds }, + updates.disabledUpdates.filter { it.pluginId !in pluginIds }, + updates.pluginNods + ) + } + + private fun List.markLocal(): List = onEach { it.source = PluginSource.LOCAL } +} diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultUiPluginManagerController.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultUiPluginManagerController.kt index 17ff78236df4..e46081be265d 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultUiPluginManagerController.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/DefaultUiPluginManagerController.kt @@ -119,10 +119,6 @@ object DefaultUiPluginManagerController : UiPluginManagerController { return InstalledPluginsState.getInstance().installedPlugins.map { PluginUiModelAdapter(it) }.withSource() } - override suspend fun getUpdates(): List { - return PluginUpdatesService.getUpdates()?.map { PluginUiModelAdapter(it) }?.withSource() ?: emptyList() - } - override suspend fun getPlugin(id: PluginId): PluginUiModel? { return PluginManagerCore.getPlugin(id)?.let { PluginUiModelAdapter(it) }?.withSource() } @@ -135,11 +131,6 @@ object DefaultUiPluginManagerController : UiPluginManagerController { return PluginManagerCore.isPluginInstalled(pluginId) } - override suspend fun isNeedUpdate(pluginId: PluginId): Boolean { - val descriptor = PluginManagerCore.getPlugin(pluginId) ?: return false - return PluginUpdatesService.isNeedUpdate(descriptor) - } - override suspend fun isBundledUpdate(pluginIds: List): Boolean { val pluginIdMap = buildPluginIdMap() return pluginIds.map { pluginIdMap[it] }.all { isBundledUpdate(it) } @@ -534,22 +525,6 @@ object DefaultUiPluginManagerController : UiPluginManagerController { .firstOrNull() } - override fun connectToPluginUpdateService(sessionId: String, callback: (List) -> Unit): PluginUpdatesService { - val session = createSession(sessionId) - if (session.updateService != null) { - val service = session.updateService!! - service.calculateUpdates({ updates -> callback(updates as List) }) - return service - } else { - val service = PluginUpdatesService.connectWithUpdates({ results -> - callback(results.pluginUpdates.all.map { it.uiModel }) - }) - service.setFilter { session.isPluginEnabled(it.pluginId) } - session.updateService = service - return service - } - } - override fun getAllPluginsTags(): Set { return MarketplaceRequests.getInstance().marketplaceTagsSupplier.get() } diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/MyPluginModel.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/MyPluginModel.kt index a841362c4b4d..44a11e4c7766 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/MyPluginModel.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/MyPluginModel.kt @@ -230,11 +230,8 @@ open class MyPluginModel(project: Project?) : InstalledPluginsTableModel(project } } - var pluginUpdatesService: PluginUpdatesService - get() = myPluginUpdatesService!! - set(service) { - myPluginUpdatesService = service - } + val pluginUpdatesService: PluginUpdatesService + get() = PluginUpdatesService.getInstance() val sessionId: String get() = mySessionId.toString() @@ -501,7 +498,7 @@ open class MyPluginModel(project: Project?) : InstalledPluginsTableModel(project } } else { - myPluginUpdatesService!!.finishUpdate() + PluginUpdatesService.getInstance().rerunCallbacks() } info?.indicator?.cancel() @@ -826,7 +823,7 @@ open class MyPluginModel(project: Project?) : InstalledPluginsTableModel(project group.titleWithEnabled(PluginModelFacade(this)) } runInvalidFixCallback() - myPluginUpdatesService?.refreshCallbacks() + PluginUpdatesService.getInstance().rerunCallbacks() } override fun isDisabled(pluginId: PluginId): Boolean { diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginManagerSessionService.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginManagerSessionService.kt index 2ffa153647a9..3957173f51d6 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginManagerSessionService.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginManagerSessionService.kt @@ -69,7 +69,6 @@ class PluginManagerSession(val sessionId: String) { val uninstalledPlugins: MutableSet = ConcurrentCollectionFactory.createConcurrentSet() val pluginStates: MutableMap = mutableMapOf() val statesDiff: MutableMap> = ConcurrentHashMap() - var updateService: PluginUpdatesService? = null var needRestart = false fun isPluginDisabled(pluginId: PluginId): Boolean = !isPluginEnabled(pluginId) diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginModelAsyncOperationsExecutor.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginModelAsyncOperationsExecutor.kt index efdc0d6a3dc9..c562e8444f1b 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginModelAsyncOperationsExecutor.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginModelAsyncOperationsExecutor.kt @@ -11,7 +11,6 @@ import com.intellij.openapi.application.ex.ApplicationInfoEx import com.intellij.openapi.components.service import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.project.Project -import com.intellij.openapi.updateSettings.impl.PluginDownloader import com.intellij.openapi.util.text.HtmlChunk import com.intellij.platform.ide.CoreUiCoroutineScopeHolder import kotlinx.coroutines.CoroutineScope @@ -65,7 +64,7 @@ internal object PluginModelAsyncOperationsExecutor { suspend fun loadUpdates(): List { return withContext(Dispatchers.IO) { - UiPluginManager.getInstance().getUpdateModels() + PluginUpdatesService.getInstance().awaitUpdates().toList() } } @@ -145,10 +144,10 @@ internal object PluginModelAsyncOperationsExecutor { } } - fun findPlugins(downloaders: Collection, callback: Function, Unit>) { + fun findPlugins(pluginIds: Collection, callback: Function, Unit>) { val coroutineScope = service().coroutineScope coroutineScope.launch(Dispatchers.IO) { - val pluginModels = UiPluginManager.getInstance().findInstalledPlugins(downloaders.map(PluginDownloader::id).toSet()) + val pluginModels = UiPluginManager.getInstance().findInstalledPlugins(pluginIds.toSet()) withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) { callback.apply(pluginModels) } diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesProvider.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesProvider.kt new file mode 100644 index 000000000000..e9002f987c05 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesProvider.kt @@ -0,0 +1,39 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.plugins.newui + +import com.intellij.ide.plugins.api.PluginDto +import com.intellij.openapi.extensions.ExtensionPointName +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +/** + * Interface responsible for providing plugin update events and managing the update process. + * + * This interface is intended for internal use and allows clients to subscribe to a flow of plugin update events + * or trigger the update mechanism for plugins. + * + * Currently, there are two implementations of this interface: {@link DefaultPluginUpdatesProvider} in monolith and frontend + * and {@link BackendPluginUpdatesProvider} in frontend-split mode. + */ +@ApiStatus.Internal +interface PluginUpdatesProvider { + suspend fun pluginUpdateEvents(): Flow + suspend fun update() + + companion object { + val EP_NAME: ExtensionPointName = ExtensionPointName.create("com.intellij.pluginUpdatesProvider") + + @JvmStatic + fun getInstances(): Collection = EP_NAME.extensionList + } +} + +/** + * Represents an event triggered when plugin updates occur, is a mirror of {@link InternalPluginResults} + */ +@ApiStatus.Internal +@Serializable +data class PluginUpdatesEvent(val enabledUpdates: List, val disabledUpdates: List, val pluginNods: List) { + val all: Collection by lazy { enabledUpdates + disabledUpdates } +} diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesService.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesService.kt index 73384895d6cb..ea972653e447 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesService.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/PluginUpdatesService.kt @@ -1,310 +1,189 @@ -// 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.plugins.newui; +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.plugins.newui -import com.intellij.ide.IdeBundle; -import com.intellij.ide.plugins.IdeaPluginDescriptor; -import com.intellij.ide.plugins.InstalledPluginsState; -import com.intellij.ide.plugins.PluginManagerCore; -import com.intellij.ide.plugins.PluginStateListener; -import com.intellij.ide.plugins.PluginStateManager; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.application.ModalityState; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.extensions.PluginId; -import com.intellij.openapi.updateSettings.impl.InternalPluginResults; -import com.intellij.openapi.updateSettings.impl.PluginDownloader; -import com.intellij.openapi.updateSettings.impl.PluginUpdates; -import com.intellij.openapi.updateSettings.impl.UpdateCheckerFacade; -import com.intellij.openapi.util.Condition; -import com.intellij.openapi.util.text.StringUtil; -import com.intellij.util.concurrency.NonUrgentExecutor; -import com.intellij.util.containers.ContainerUtil; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nls; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; +import com.intellij.ide.plugins.api.PluginDto +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.UI +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.progress.runBlockingMaybeCancellable +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.VisibleForTesting +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.function.Consumer +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration.Companion.milliseconds -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.function.Consumer; +@ApiStatus.Internal +fun interface PluginUpdateSubscription { + fun cancel() +} -/** - * @author Alexander Lobas - */ -public class PluginUpdatesService { - private static final Logger LOG = Logger.getInstance(PluginUpdatesService.class); - private static final List SERVICES = new ArrayList<>(); - private static final Object ourLock = new Object(); - private static final @NotNull Condition DEFAULT_FILTER = // only enabled plugins by default - descriptor -> !PluginManagerCore.isDisabled(descriptor.getPluginId()); +typealias PluginUpdateCallback = Consumer - // FIXME it is strange that users of this class need to known which updates came from custom repositories (IJPL-6087) - /** clients should receive filtered updates by default */ - private static @NotNull InternalPluginResults ourAllUpdates = InternalPluginResults.empty(); - private static @NotNull Condition ourFilter = DEFAULT_FILTER; - private static boolean ourPrepared; - private static boolean ourPreparing; - private static boolean ourReset; +@ApiStatus.Internal +@Service +@OptIn(FlowPreview::class, ExperimentalAtomicApi::class) +class PluginUpdatesService(val coroutineScope: CoroutineScope) { - private final List> myUpdateCallbacks = new ArrayList<>(); - private boolean mySetFilter; + private val myCallbacks = CopyOnWriteArrayList() + private val pluginUpdateFlow = MutableStateFlow(null) + private val updateIdsFlow = MutableStateFlow?>(null) + private val updateRequestFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val providerSnapshots = ConcurrentHashMap() - static { - PluginStateManager.addStateListener(new PluginStateListener() { - @Override - public void install(@NotNull IdeaPluginDescriptor descriptor) { - finishUpdate(descriptor); - } + companion object { + private val LOG = logger() - @Override - public void uninstall(@NotNull IdeaPluginDescriptor descriptor) { - finishUpdate(descriptor); - } - }); - } + @JvmStatic + fun getInstance(): PluginUpdatesService = service() - @ApiStatus.Internal - public static @NotNull PluginUpdatesService connectWithUpdates(@NotNull Consumer<@NotNull InternalPluginResults> callback) { - PluginUpdatesService service = new PluginUpdatesService(); - service.myUpdateCallbacks.add(callback); - synchronized (ourLock) { - SERVICES.add(service); - if (ourPrepared) { - callback.accept(getFilteredUpdateResult()); - return service; - } - } - calculateUpdates(); - return service; - } - - private static @NotNull InternalPluginResults getFilteredUpdateResult() { - synchronized (ourLock) { - if (ourAllUpdates.isEmpty()) { - return ourAllUpdates; - } - final var filter = ourFilter; - return new InternalPluginResults( - new PluginUpdates( - ContainerUtil.filter(ourAllUpdates.getPluginUpdates().getAllEnabled(), d -> filter.test(d.getDescriptor())), - ContainerUtil.filter(ourAllUpdates.getPluginUpdates().getAllDisabled(), d -> filter.test(d.getDescriptor())), - ourAllUpdates.getPluginUpdates().getIncompatible() - ), - ourAllUpdates.getPluginNods(), - ourAllUpdates.getErrors() - ); + @JvmStatic + fun isNeedUpdate(pluginId: PluginId): Boolean { + return runBlockingMaybeCancellable { getInstance().awaitHasUpdate(pluginId) } } } - public void calculateUpdates(@NotNull Consumer> callback) { - synchronized (ourLock) { - final var adaptedCallback = adaptDescriptorConsumerToUpdateResultConsumer(callback); - myUpdateCallbacks.add(adaptedCallback); - if (ourPrepared) { - adaptedCallback.accept(getFilteredUpdateResult()); - return; - } - } - calculateUpdates(); + init { + startUpdateCollection() + startUpdateTrigger() } - private static void finishUpdate(@NotNull IdeaPluginDescriptor descriptor) { - synchronized (ourLock) { - if (!ourPrepared) { - return; + private suspend fun ensureUpdatesStarted() { + if (pluginUpdateFlow.value == null) { + triggerUpdates() + } + } + + private fun startUpdateCollection() { + for (provider in PluginUpdatesProvider.getInstances()) { + coroutineScope.launch { + provider.pluginUpdateEvents() + .catch { e -> LOG.warn("Plugin update provider failed: ${provider.javaClass.name}", e) } + .collect { event -> + event?.let { + providerSnapshots[provider] = event + onProviderUpdated() + } + } + } + } + } + + private suspend fun onProviderUpdated() { + val merged = mergeUpdates(providerSnapshots.values) + pluginUpdateFlow.value = merged + updateIdsFlow.value = merged.all.mapTo(HashSet()) { plugin -> plugin.pluginId } + withContext(Dispatchers.UI + ModalityState.any().asContextElement()) { + dispatchCallbacks(merged) + } + } + + private fun startUpdateTrigger() = coroutineScope.launch { + updateRequestFlow + .debounce(300.milliseconds) + .collect { + PluginUpdatesProvider.getInstances().forEach { it.update() } } - boolean removed = removeUpdate(descriptor.getPluginId()); - if (removed) { - var results = getFilteredUpdateResult(); - for (PluginUpdatesService service : SERVICES) { - service.runAllCallbacks(results); + } + + private suspend fun triggerUpdates() { + updateRequestFlow.emit(Unit) + } + + /** + * Registers a [callback] to receive plugin update events and returns a [PluginUpdateSubscription] to cancel it. + * + * By default, the [callback] is invoked on [Dispatchers.UI][com.intellij.openapi.application.UI], WIL not allowed. + * + * Note: if an update snapshot is already available, the [callback] is also invoked once synchronously on the + * calling thread of [subscribe] with that snapshot; all later invocations happen on [Dispatchers.UI]. + */ + @RequiresEdt + fun subscribe(@RequiresEdt callback: PluginUpdateCallback): PluginUpdateSubscription { + myCallbacks.add(callback) + + val currentSnapshot = getLastUpdates() + if (currentSnapshot != null) { + callback.accept(currentSnapshot) + } else { + recalculateUpdates() + } + + return PluginUpdateSubscription { + myCallbacks.remove(callback) + } + } + + fun recalculateUpdates(): Job = coroutineScope.launch { triggerUpdates() } + + suspend fun awaitUpdates(): Collection { + ensureUpdatesStarted() + return pluginUpdateFlow.filterNotNull().first().all + } + + @VisibleForTesting + fun flow(): Flow = pluginUpdateFlow + + suspend fun awaitHasUpdate(pluginId: PluginId): Boolean { + ensureUpdatesStarted() + return updateIdsFlow.filterNotNull().first().contains(pluginId) + } + + @RequiresEdt + fun rerunCallbacks() { + val currentUpdates = getLastUpdates() + if (currentUpdates != null) { + dispatchCallbacks(currentUpdates) + } + } + + private fun getLastUpdates(): PluginUpdatesEvent? { + return pluginUpdateFlow.value + } + + private fun dispatchCallbacks(updates: PluginUpdatesEvent) { + myCallbacks.forEach { it.accept(updates) } + } + + @VisibleForTesting + internal fun mergeUpdates(updateEvents: Collection): PluginUpdatesEvent = + PluginUpdatesEvent(mergePlugins(updateEvents) { it.enabledUpdates }, + mergePlugins(updateEvents) { it.disabledUpdates }, + mergePlugins(updateEvents) { it.pluginNods }) + + private fun mergePlugins(updateEvents: Collection, updates: (PluginUpdatesEvent) -> List): List { + val merged = LinkedHashMap() + for (event in updateEvents) { + for (plugin in updates(event)) { + val existing = merged[plugin.pluginId] + if (existing == null) { + merged[plugin.pluginId] = plugin + } + else { + existing.source = existing.source.addSource(plugin.source) } } } - } - - private static boolean removeUpdate(@NotNull PluginId pluginId) { - if (!ContainerUtil.exists(ourAllUpdates.getPluginUpdates().getAll(), d -> Objects.equals(d.getDescriptor().getPluginId(), pluginId))) { - return false; - } - ourAllUpdates = new InternalPluginResults( - new PluginUpdates( - ContainerUtil.filter(ourAllUpdates.getPluginUpdates().getAllEnabled(), d -> !Objects.equals(d.getDescriptor().getPluginId(), pluginId)), - ContainerUtil.filter(ourAllUpdates.getPluginUpdates().getAllDisabled(), d -> !Objects.equals(d.getDescriptor().getPluginId(), pluginId)), - ourAllUpdates.getPluginUpdates().getIncompatible() - ), - ourAllUpdates.getPluginNods(), - ourAllUpdates.getErrors() - ); - return true; - } - - public void finishUpdate() { - synchronized (ourLock) { - if (!ourPrepared) { - return; - } - var results = getFilteredUpdateResult(); - for (PluginUpdatesService service : SERVICES) { - service.runAllCallbacks(results); - } - } - } - - public void recalculateUpdates() { - synchronized (ourLock) { - for (PluginUpdatesService service : SERVICES) { - service.runAllCallbacks(InternalPluginResults.empty()); - } - if (ourPreparing) { - resetUpdates(); - } - else { - calculateUpdates(); - } - } - } - - private static void resetUpdates() { - ourReset = true; - } - - @ApiStatus.Internal - public void setFilter(@NotNull Condition filter) { - synchronized (ourLock) { - if (!mySetFilter && ourFilter != DEFAULT_FILTER) { - LOG.warn("Filter already set to " + ourFilter + ", new filter " + filter + " will be ignored", new Throwable()); - return; - } - mySetFilter = true; - setOurFilter(filter); - } - } - - private static void setOurFilter(@NotNull Condition filter) { - ourFilter = filter; - reapplyFilter(); - } - - public static void reapplyFilter() { - synchronized (ourLock) { - for (PluginUpdatesService service : SERVICES) { - service.runAllCallbacks(InternalPluginResults.empty()); - } - final var filteredUpdates = getFilteredUpdateResult(); - for (PluginUpdatesService service : SERVICES) { - service.runAllCallbacks(filteredUpdates); - } - } - } - - @ApiStatus.Internal - public void refreshCallbacks() { - reapplyFilter(); - } - - public void dispose() { - synchronized (ourLock) { - dispose(this); - myUpdateCallbacks.clear(); - if (mySetFilter) { - setOurFilter(DEFAULT_FILTER); - mySetFilter = false; - } - } - } - - private static void dispose(@NotNull PluginUpdatesService service) { - synchronized (ourLock) { - SERVICES.remove(service); - if (SERVICES.isEmpty()) { - ourAllUpdates = InternalPluginResults.empty(); - ourPrepared = false; - ourPreparing = false; - } - } - } - - public static boolean isNeedUpdate(@NotNull IdeaPluginDescriptor descriptor) { - PluginId pluginId = descriptor.getPluginId(); - synchronized (ourLock) { - if (ourPrepared) { - final var filteredUpdates = getFilteredUpdateResult(); - assert filteredUpdates != null; - return ContainerUtil.exists(filteredUpdates.getPluginUpdates().getAll(), d -> Objects.equals(d.getDescriptor().getPluginId(), pluginId)); - } - } - return InstalledPluginsState.getInstance().hasNewerVersion(pluginId); - } - - - public static @Unmodifiable Collection getUpdates() { - synchronized (ourLock) { - if (!ourPrepared || ourPreparing) { - return List.of(); - } - final var filteredUpdates = getFilteredUpdateResult(); - return ContainerUtil.map(filteredUpdates.getPluginUpdates().getAll(), PluginDownloader::getDescriptor); - } - } - - public static @Nullable @Nls String getUpdatesTooltip() { - Collection updates = getUpdates(); - if (ContainerUtil.isEmpty(updates)) { - return null; - } - return IdeBundle.message("updates.plugin.ready.tooltip", StringUtil.join(updates, plugin -> plugin.getName(), ", "), updates.size()); - } - - private static void calculateUpdates() { - synchronized (ourLock) { - if (ourPreparing) { - return; - } - ourPreparing = true; - ourAllUpdates = InternalPluginResults.empty(); - } - // for example, if executed as part of Traverse UI - don't wait check updates - if (ApplicationManager.getApplication().isHeadlessEnvironment()) { - return; - } - - NonUrgentExecutor.getInstance().execute(() -> { - InternalPluginResults updates = UpdateCheckerFacade.getInstance().checkInstalledPluginUpdates(null, null); - ApplicationManager.getApplication().invokeLater(() -> { - synchronized (ourLock) { - ourPreparing = false; - if (ourReset) { - ourReset = false; - calculateUpdates(); - return; - } - ourPrepared = true; - ourAllUpdates = updates; - final var filteredUpdates = getFilteredUpdateResult(); - for (PluginUpdatesService service : SERVICES) { - service.runAllCallbacks(filteredUpdates); - } - } - }, ModalityState.any()); - }); - } - - private void runAllCallbacks(@NotNull InternalPluginResults filteredUpdates) { - for (var callback : myUpdateCallbacks) { - callback.accept(filteredUpdates); - } - } - - private static @NotNull Consumer adaptDescriptorConsumerToUpdateResultConsumer( - @NotNull Consumer> consumer - ) { - return updateResult -> { - assert updateResult != null; - consumer.accept(ContainerUtil.map(updateResult.getPluginUpdates().getAll(), downloader -> downloader.getUiModel())); - }; + return merged.values.toList() } } diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManager.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManager.kt index 110e1a4f3499..b8f2b0445855 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManager.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManager.kt @@ -63,10 +63,6 @@ class UiPluginManager { return getController().getInstalledPlugins() } - suspend fun getUpdateModels(): List { - return getController().getUpdates() - } - suspend fun loadPluginDetails(model: PluginUiModel): PluginUiModel? { return getController().loadPluginDetails(model) } @@ -236,7 +232,7 @@ class UiPluginManager { @RequiresBackgroundThread(generateAssertion = false) fun isNeedUpdate(pluginId: PluginId): Boolean { - return runBlockingMaybeCancellable { getController().isNeedUpdate(pluginId) } + return runBlockingMaybeCancellable { PluginUpdatesService.getInstance().awaitHasUpdate(pluginId) } } suspend fun getPluginInstallationState(pluginId: PluginId): PluginInstallationState { @@ -250,8 +246,9 @@ class UiPluginManager { return DefaultUiPluginManagerController } - fun subscribeToUpdatesCount(sessionId: String, callback: (Int?) -> Unit): PluginUpdatesService { - return getController().connectToPluginUpdateService(sessionId, { updatedPlugins -> callback(updatedPlugins?.size ?: 0)}) + fun subscribeToPluginUpdates(sessionId: String, callback: (List) -> Unit): PluginUpdateSubscription { + val session = PluginManagerSessionService.getInstance().createSession(sessionId) + return PluginUpdatesService.getInstance().subscribe { updatedPlugins -> callback(updatedPlugins.all.filter { session.isPluginEnabled(it.pluginId) }) } } companion object { diff --git a/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManagerController.kt b/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManagerController.kt index 6a82f7ce5c29..8720f02b73b0 100644 --- a/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManagerController.kt +++ b/platform/platform-impl/src/com/intellij/ide/plugins/newui/UiPluginManagerController.kt @@ -32,7 +32,6 @@ interface UiPluginManagerController { suspend fun getVisiblePlugins(showImplementationDetails: Boolean): List suspend fun initSession(sessionId: String): InitSessionResult suspend fun getInstalledPlugins(): List - suspend fun getUpdates(): List suspend fun executePluginsSearch(query: String, count: Int, includeIncompatible: Boolean): PluginSearchResult suspend fun loadPluginDetails(model: PluginUiModel): PluginUiModel? suspend fun loadPluginReviews(pluginId: PluginId, page: Int): List? @@ -57,7 +56,6 @@ interface UiPluginManagerController { suspend fun getLastCompatiblePluginUpdateModel(pluginId: PluginId, buildNumber: String? = null, indicator: ProgressIndicator? = null): PluginUiModel? suspend fun getLastCompatiblePluginUpdate(allIds: Set, throwExceptions: Boolean, buildNumber: String? = null): List suspend fun updateDescriptorsForInstalledPlugins() - suspend fun isNeedUpdate(pluginId: PluginId): Boolean suspend fun getPluginInstallationState(pluginId: PluginId): PluginInstallationState suspend fun getPluginInstallationStates(): Map suspend fun checkPluginCanBeDownloaded(pluginUiModel: PluginUiModel, progressIndicator: ProgressIndicator?): Boolean @@ -72,7 +70,6 @@ interface UiPluginManagerController { fun filterPluginsRequiringUltimateButItsDisabled(pluginIds: List): List fun getAllPluginsTags(): Set fun getAllVendors(): Set - fun connectToPluginUpdateService(sessionId: String, callback: (List) -> Unit): PluginUpdatesService suspend fun loadErrors(sessionId: String): Map suspend fun loadErrors(sessionId: String, pluginIds: List): Map diff --git a/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/DetectedPluginsPanel.kt b/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/DetectedPluginsPanel.kt index 5d24b07b99ef..f76bb4d3da85 100644 --- a/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/DetectedPluginsPanel.kt +++ b/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/DetectedPluginsPanel.kt @@ -7,7 +7,6 @@ import com.intellij.ide.plugins.newui.MyPluginModel import com.intellij.ide.plugins.newui.PluginDetailsPageComponent import com.intellij.ide.plugins.newui.PluginModelFacade import com.intellij.ide.plugins.newui.PluginUiModelAdapter -import com.intellij.ide.plugins.newui.PluginUpdatesService import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.asContextElement @@ -41,9 +40,6 @@ internal class DetectedPluginsPanel(project: Project?) : OrderPanel }, true) val entryTable = getEntryTable() entryTable.setTableHeader(null) diff --git a/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateDialog.java b/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateDialog.java index e2c79d88417b..41e067890d63 100644 --- a/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateDialog.java +++ b/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateDialog.java @@ -18,7 +18,6 @@ import com.intellij.ide.plugins.newui.PluginDetailsPageComponent; import com.intellij.ide.plugins.newui.PluginModelFacade; import com.intellij.ide.plugins.newui.PluginUiModel; import com.intellij.ide.plugins.newui.PluginUiModelAdapter; -import com.intellij.ide.plugins.newui.PluginUpdatesService; import com.intellij.ide.plugins.newui.PluginsGroup; import com.intellij.ide.plugins.newui.PluginsGroupComponent; import com.intellij.ide.plugins.newui.UiPluginManager; @@ -117,10 +116,6 @@ public class PluginUpdateDialog extends DialogWrapper { }; myPluginModel.setTopController(Configurable.TopComponentController.EMPTY); - myPluginModel.setPluginUpdatesService(new PluginUpdatesService() { - @Override - public void finishUpdate() { } - }); //noinspection unchecked myDetailsPage = new PluginDetailsPageComponent(new PluginModelFacade(myPluginModel), @@ -177,33 +172,22 @@ public class PluginUpdateDialog extends DialogWrapper { setTitle(IdeBundle.message("dialog.title.plugin.updates")); } - public static boolean showDialogAndUpdate(@NotNull Collection downloaders, @NotNull PluginUpdateDialog dialog) { + public static boolean showDialogAndUpdateDownloaders(@NotNull Collection downloaders, @NotNull PluginUpdateDialog dialog) { if (dialog.showAndGet()) { - List selectedPlugins = dialog.getSelectedPluginModels(); - List selectedDownloaders = findDownloadersForPlugins(downloaders, selectedPlugins); + Set selectedPlugins = ContainerUtil.map2Set(dialog.getSelectedPluginModels(), PluginUiModel::getPluginId); + List selectedDownloaders = ContainerUtil.filter(downloaders, downloader -> selectedPlugins.contains(downloader.getId())); runUpdateAll(selectedDownloaders, dialog.getContentPanel(), dialog.myFinishCallback, null); return true; } return false; } - public static List getSelectedDownloaders(@NotNull Collection downloaders, - @NotNull PluginUpdateDialog dialog) { - return findDownloadersForPlugins(downloaders, dialog.getSelectedPluginModels()); - } - - private static @NotNull List findDownloadersForPlugins(@NotNull Collection downloaders, - @NotNull List selectedPlugins) { - List selectedDownloaders = new ArrayList<>(); - Set selectedPluginIds = ContainerUtil.map2Set(selectedPlugins, PluginUiModel::getPluginId); - - for (PluginDownloader downloader : downloaders) { - if (selectedPluginIds.contains(downloader.getDescriptor().getPluginId())) { - selectedDownloaders.add(downloader); - } + public static boolean showDialogAndUpdate(@NotNull PluginUpdateDialog dialog) { + if (dialog.showAndGet()) { + PluginUpdateHandler.installUpdates(dialog.getSelectedPluginModels(), dialog.getContentPanel(), dialog.myFinishCallback, null); + return true; } - - return selectedDownloaders; + return false; } protected void doIgnoreUpdateAction(ActionEvent e) { diff --git a/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateHandler.kt b/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateHandler.kt index 0eb2d048a979..74c3cfd17604 100644 --- a/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateHandler.kt +++ b/platform/platform-impl/src/com/intellij/openapi/updateSettings/impl/PluginUpdateHandler.kt @@ -4,10 +4,11 @@ package com.intellij.openapi.updateSettings.impl import com.intellij.ide.plugins.api.PluginDto import com.intellij.ide.plugins.newui.PluginUiModel import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.runBlockingMaybeCancellable import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jetbrains.annotations.ApiStatus -import java.util.UUID +import java.util.function.Consumer import javax.swing.JComponent // Class used for loading and installing plugin updates on the backend and frontend. @@ -15,20 +16,39 @@ import javax.swing.JComponent // Eventually should be responsible for all plugin updates. @ApiStatus.Internal interface PluginUpdateHandler { - suspend fun loadAndStorePluginUpdates(buildNumber: String?, sessionId: String = UUID.randomUUID().toString(), indicator: ProgressIndicator? = null): PluginUpdatesModel - suspend fun installUpdates(sessionId: String, updates: List, component: JComponent?, finishCallback: Runnable?) + suspend fun loadAndStorePluginUpdates(buildNumber: String?, indicator: ProgressIndicator? = null): PluginUpdatesModel + suspend fun installUpdates(updates: Collection, component: JComponent?, finishCallback: Runnable?, customRestarter: Consumer? = null) - suspend fun ignorePluginUpdates(sessionId: String) + suspend fun ignorePluginUpdates() companion object { + @JvmStatic fun getInstance(): PluginUpdateHandler = PluginUpdateHandlerProvider.getInstance().getPluginUpdateHandler() + + @JvmStatic + fun installUpdates( + updates: Collection, + component: JComponent?, + finishCallback: Runnable?, + customRestarter: Consumer? = null, + ) { + runBlockingMaybeCancellable { + getInstance().installUpdates(updates, component, finishCallback, customRestarter) + } + } + + @JvmStatic + fun loadAndStorePluginUpdates(buildNumber: String?, indicator: ProgressIndicator? = null): PluginUpdatesModel { + return runBlockingMaybeCancellable { + getInstance().loadAndStorePluginUpdates(buildNumber, indicator) + } + } } } @ApiStatus.Internal @Serializable data class PluginUpdatesModel( - val sessionId: String, val pluginUpdates: List, val disabledPluginUpdates: List = emptyList(), val updatesFromCustomRepositories: List, diff --git a/platform/platform-impl/src/com/intellij/openapi/wm/impl/welcomeScreen/PluginsTabFactory.java b/platform/platform-impl/src/com/intellij/openapi/wm/impl/welcomeScreen/PluginsTabFactory.java index 86eb12392f35..3d267e96138e 100644 --- a/platform/platform-impl/src/com/intellij/openapi/wm/impl/welcomeScreen/PluginsTabFactory.java +++ b/platform/platform-impl/src/com/intellij/openapi/wm/impl/welcomeScreen/PluginsTabFactory.java @@ -5,6 +5,7 @@ import com.intellij.ide.IdeBundle; import com.intellij.ide.plugins.CountComponent; import com.intellij.ide.plugins.PluginManagerConfigurable; import com.intellij.ide.plugins.newui.PluginUpdatesService; +import com.intellij.ide.plugins.newui.PluginUpdateSubscription; import com.intellij.ide.plugins.newui.TabbedPaneHeaderComponent; import com.intellij.openapi.Disposable; import com.intellij.openapi.util.Disposer; @@ -31,7 +32,7 @@ public final class PluginsTabFactory implements WelcomeTabFactory { } private static final class MyDefaultWelcomeScreenTab extends TabbedWelcomeScreen.DefaultWelcomeScreenTab { - private final PluginUpdatesService myService; + private final PluginUpdateSubscription myPluginUpdateSubscription; private final CountComponent myCountLabel = new CountComponent(); private JComponent myParent; private final Disposable parentDisposable; @@ -44,9 +45,9 @@ public final class PluginsTabFactory implements WelcomeTabFactory { myKeyComponent.add(myCountLabel, BorderLayout.EAST); myCountLabel.setVisible(false); - myService = PluginUpdatesService.connectWithUpdates(results -> { - int countValue = results.getPluginUpdates().getAll().size(); - @NlsSafe String text = countValue <= 0 ? null : Integer.toString(countValue); + myPluginUpdateSubscription = PluginUpdatesService.getInstance().subscribe(results -> { + int countValue = results.getEnabledUpdates().size(); + @NlsSafe String text = countValue == 0 ? null : Integer.toString(countValue); myCountLabel.setText(text); myCountLabel.setVisible(text != null); if (myParent != null) { @@ -61,8 +62,8 @@ public final class PluginsTabFactory implements WelcomeTabFactory { parent.addAncestorListener(new AncestorListenerAdapter() { @Override public void ancestorRemoved(AncestorEvent event) { - if (myService != null) { - myService.dispose(); + if (myPluginUpdateSubscription != null) { + myPluginUpdateSubscription.cancel(); } } }); diff --git a/platform/platform-impl/testSrc/com/intellij/ide/plugins/newui/PluginUpdatesServiceTest.kt b/platform/platform-impl/testSrc/com/intellij/ide/plugins/newui/PluginUpdatesServiceTest.kt new file mode 100644 index 000000000000..aaf67bd8066b --- /dev/null +++ b/platform/platform-impl/testSrc/com/intellij/ide/plugins/newui/PluginUpdatesServiceTest.kt @@ -0,0 +1,261 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.plugins.newui + +import com.intellij.ide.plugins.api.PluginDto +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.Disposable +import com.intellij.platform.util.coroutines.childScope +import com.intellij.testFramework.ExtensionTestUtil +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.common.waitUntil +import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.junit5.TestDisposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +@TestApplication +internal class PluginUpdatesServiceTest { + @Test + fun `merge updates deduplicates plugin ids independently in each bucket`() { + val mergedUpdates = mergeUpdates( + PluginUpdatesEvent(enabledUpdates = listOf(pluginDto("enabled.local", PluginSource.LOCAL), pluginDto("enabled.both", PluginSource.LOCAL)), + disabledUpdates = listOf(pluginDto("disabled.local", PluginSource.LOCAL), pluginDto("disabled.both", PluginSource.LOCAL)), + pluginNods = listOf(pluginDto("custom.local", PluginSource.LOCAL), pluginDto("custom.both", PluginSource.LOCAL))), + PluginUpdatesEvent(enabledUpdates = listOf(pluginDto("enabled.both", PluginSource.REMOTE), pluginDto("enabled.remote", PluginSource.REMOTE)), + disabledUpdates = listOf(pluginDto("disabled.both", PluginSource.REMOTE), pluginDto("disabled.remote", PluginSource.REMOTE)), + pluginNods = listOf(pluginDto("custom.both", PluginSource.REMOTE), pluginDto("custom.remote", PluginSource.REMOTE))), + ) + + assertMergedPlugins(mergedUpdates.enabledUpdates, "enabled.local", "enabled.both", "enabled.remote") + assertMergedPlugins(mergedUpdates.disabledUpdates, "disabled.local", "disabled.both", "disabled.remote") + assertMergedPlugins(mergedUpdates.pluginNods, "custom.local", "custom.both", "custom.remote") + } + + private fun mergeUpdates(first: PluginUpdatesEvent, second: PluginUpdatesEvent): PluginUpdatesEvent { + return PluginUpdatesService(CoroutineScope(EmptyCoroutineContext)).mergeUpdates(listOf(first, second)) + } + + private fun assertMergedPlugins(plugins: List, vararg pluginIds: String) { + assertThat(plugins.map { it.pluginId.idString }).containsExactly(*pluginIds) + assertThat(plugins.map { it.source }).containsExactly(PluginSource.LOCAL, PluginSource.BOTH, PluginSource.REMOTE) + } + + private fun pluginDto(id: String, source: PluginSource): PluginDto = + PluginDto(name = id, pluginId = PluginId.getId(id)).apply { this.source = source } + + @Test + fun `a failing provider flow must not stop updates from other providers`(@TestDisposable disposable: Disposable): Unit = + runTest { + val healthy = MutableStateFlow(null) + val failing = flow { + throw RuntimeException("provider flow failed") + } + ExtensionTestUtil.maskExtensions( + PluginUpdatesProvider.EP_NAME, listOf(FakeProvider(healthy), FakeProvider(failing)), disposable, + ) + val service = PluginUpdatesService(backgroundScope) + // A healthy provider now reports an update; it must still reach the subscriber. + healthy.value = PluginUpdatesEvent(listOf(pluginDto("plugin.healthy", PluginSource.LOCAL)), emptyList(), emptyList()) + + advanceUntilIdle() + + val received = withTimeout(1.seconds) { service.awaitUpdates() } + assertThat(received).anySatisfy { + assertThat( it.pluginId.idString == "plugin.healthy").isTrue() + } + } + + @Test + fun `a provider that never emits must not block updates from other providers`(@TestDisposable disposable: Disposable): Unit = + runTest { + val healthy = MutableStateFlow(null) + val silent = MutableSharedFlow() // never emits + ExtensionTestUtil.maskExtensions( + PluginUpdatesProvider.EP_NAME, listOf(FakeProvider(healthy), FakeProvider(silent)), disposable, + ) + + val service = PluginUpdatesService(backgroundScope) + healthy.value = PluginUpdatesEvent(listOf(pluginDto("plugin.healthy", PluginSource.LOCAL)), emptyList(), emptyList()) + + advanceUntilIdle() + + val updates = withTimeout(1.seconds) { service.awaitUpdates() } + assertThat(updates).anySatisfy { + assertThat( it.pluginId.idString == "plugin.healthy").isTrue() + } + } + + @Test + fun `subscribe delivers updates and late subscriber also receives updates`(@TestDisposable disposable: Disposable): Unit = + timeoutRunBlocking { + val events = MutableStateFlow(null) + ExtensionTestUtil.maskExtensions(PluginUpdatesProvider.EP_NAME, listOf(FakeProvider(events)), disposable) + + val serviceScope = childScope("PluginUpdatesServiceTest", Dispatchers.Default) + try { + val service = PluginUpdatesService(serviceScope) + val earlyReceiver = CopyOnWriteArrayList() + service.subscribe { earlyReceiver.add(it) } + events.value = PluginUpdatesEvent(listOf(pluginDto("plugin.first", PluginSource.LOCAL)), emptyList(), emptyList()) + + waitUntil("early subscriber receives the plugin.first update", 1.seconds) { + earlyReceiver.hasUpdateFor("plugin.first") + } + assertThat(earlyReceiver) + .describedAs("early subscriber receives exactly one update once it is published") + .hasSize(1) + assertThat(earlyReceiver.single().all.map { it.pluginId.idString }) + .describedAs("the update delivered to the early subscriber is for plugin.first") + .containsExactly("plugin.first") + + val lateReceived = CopyOnWriteArrayList() + service.subscribe { lateReceived.add(it) } + assertThat(lateReceived) + .describedAs("late subscriber immediately receives the current snapshot on subscribe") + .hasSize(1) + assertThat(lateReceived.single().all.map { it.pluginId.idString }) + .describedAs("late subscriber receives the same plugin.first update") + .containsExactly("plugin.first") + } + finally { + serviceScope.cancel() + } + } + + @Test + fun `awaitUpdates and awaitHasUpdate reflect available updates`(@TestDisposable disposable: Disposable): Unit = + runTest { + val events = MutableStateFlow(eventWith("plugin.available")) + ExtensionTestUtil.maskExtensions(PluginUpdatesProvider.EP_NAME, listOf(FakeProvider(events)), disposable) + + val service = PluginUpdatesService(backgroundScope) + + advanceUntilIdle() + + val updates = withTimeout(1.seconds) { service.awaitUpdates() } + assertThat(updates.map { it.pluginId.idString }).contains("plugin.available") + + assertThat(service.awaitHasUpdate(PluginId.getId("plugin.available"))) + .describedAs("awaitHasUpdate is true for an available update").isTrue() + assertThat(service.awaitHasUpdate(PluginId.getId("plugin.absent"))) + .describedAs("awaitHasUpdate is false for an unknown plugin").isFalse() + } + + @Test + fun `rerunCallbacks re-delivers the last snapshot to callbacks`(@TestDisposable disposable: Disposable): Unit = + timeoutRunBlocking { + val events = MutableStateFlow(eventWith("plugin.snapshot")) + ExtensionTestUtil.maskExtensions(PluginUpdatesProvider.EP_NAME, listOf(FakeProvider(events)), disposable) + + val serviceScope = childScope("PluginUpdatesServiceTest", Dispatchers.Default) + try { + val received = CopyOnWriteArrayList() + val service = PluginUpdatesService(serviceScope) + service.subscribe { received.add(it) } + waitUntil("subscriber receives the initial snapshot", 1.seconds) { received.hasUpdateFor("plugin.snapshot") } + + received.clear() + service.rerunCallbacks() + waitUntil("rerunCallbacks re-delivers the last snapshot", 1.seconds) { received.hasUpdateFor("plugin.snapshot") } + } + finally { + serviceScope.cancel() + } + } + + @Test + fun `recalculateUpdates re-runs provider update checks`(@TestDisposable disposable: Disposable): Unit = + runTest { + val nextResult = AtomicReference(emptyEvent()) + val events = MutableStateFlow(null) + val provider = object : PluginUpdatesProvider { + override suspend fun pluginUpdateEvents(): Flow = events + override suspend fun update() { + events.value = nextResult.get() + } + } + ExtensionTestUtil.maskExtensions(PluginUpdatesProvider.EP_NAME, listOf(provider), disposable) + + val service = PluginUpdatesService(backgroundScope) + + // recalculateUpdates() runs the provider's update(), which publishes the staged result. + nextResult.set(eventWith("plugin.first")) + service.recalculateUpdates() + advanceUntilIdle() + withTimeout(1.seconds) { + service.flow().filterNotNull().first { it.hasUpdateFor("plugin.first") } + } + + // A second recalculateUpdates() re-runs the provider check and picks up the new result. + nextResult.set(eventWith("plugin.second")) + service.recalculateUpdates() + advanceUntilIdle() + val second = withTimeout(1.seconds) { + service.flow().filterNotNull().first { it.hasUpdateFor("plugin.second") } + } + assertThat(second.all.map { it.pluginId.idString }).containsExactly("plugin.second") + } + + @Test + fun `incremental updates are delivered as each provider emits`(@TestDisposable disposable: Disposable): Unit = + runTest { + val providerA = MutableSharedFlow(replay = 1) + val providerB = MutableSharedFlow(replay = 1) + ExtensionTestUtil.maskExtensions( + PluginUpdatesProvider.EP_NAME, listOf(FakeProvider(providerA), FakeProvider(providerB)), disposable, + ) + + val service = PluginUpdatesService(backgroundScope) + + // Provider A emits first; its update must reach the merged flow even though provider B has not emitted yet. + providerA.emit(eventWith("plugin.a")) + advanceUntilIdle() + val afterA = withTimeout(1.seconds) { + service.flow().filterNotNull().first { it.hasUpdateFor("plugin.a") } + } + assertThat(afterA.hasUpdateFor("plugin.b")) + .describedAs("provider B's update must not be present before it emits") + .isFalse() + + // Provider B emits; the merged result must now contain both providers' plugins. + providerB.emit(eventWith("plugin.b")) + advanceUntilIdle() + withTimeout(1.seconds) { + service.flow().filterNotNull().first { it.hasUpdateFor("plugin.a") && it.hasUpdateFor("plugin.b") } + } + } + + private class FakeProvider(private val events: Flow) : PluginUpdatesProvider { + override suspend fun pluginUpdateEvents(): Flow = events + override suspend fun update() {} + } + + private fun emptyEvent(): PluginUpdatesEvent = PluginUpdatesEvent(emptyList(), emptyList(), emptyList()) + + private fun eventWith(vararg pluginIds: String): PluginUpdatesEvent = + PluginUpdatesEvent(pluginIds.map { pluginDto(it, PluginSource.LOCAL) }, emptyList(), emptyList()) + + private fun PluginUpdatesEvent.hasUpdateFor(pluginId: String): Boolean = + all.any { it.pluginId.idString == pluginId } + + private fun Collection.hasUpdateFor(pluginId: String): Boolean = + any { it.hasUpdateFor(pluginId) } +} diff --git a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/DefaultPluginUpdateHandler.kt b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/DefaultPluginUpdateHandler.kt index ebd5127ee13a..dff7705a946e 100644 --- a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/DefaultPluginUpdateHandler.kt +++ b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/DefaultPluginUpdateHandler.kt @@ -3,19 +3,20 @@ package com.intellij.openapi.updateSettings.impl import com.intellij.ide.plugins.api.PluginDto import com.intellij.ide.plugins.newui.PluginUiModel +import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.util.BuildNumber import org.jetbrains.annotations.ApiStatus import java.util.concurrent.ConcurrentHashMap +import java.util.function.Consumer import javax.swing.JComponent @ApiStatus.Internal class DefaultPluginUpdateHandler : PluginUpdateHandler { - private val downloaders = ConcurrentHashMap() + private val myDownloaders = ConcurrentHashMap() override suspend fun loadAndStorePluginUpdates( buildNumber: String?, - sessionId: String, indicator: ProgressIndicator?, ): PluginUpdatesModel { val buildNumber = BuildNumber.fromString(buildNumber) @@ -26,48 +27,28 @@ class DefaultPluginUpdateHandler : PluginUpdateHandler { val updateModels = notIgnoredDownloaders.map { it.uiModel } val disabledUpdateModels = pluginUpdates.allDisabled.map { it.uiModel } val incompatiblePluginNames = pluginUpdates.incompatible.map { it.name } - registerDownloaders(sessionId, notIgnoredDownloaders) + storeDownloaders(notIgnoredDownloaders) val errors = internalPluginUpdates.errors.map { it.key to it.value.message.orEmpty() }.toMap() val updateModel = PluginUpdatesModel(pluginUpdates = updateModels.map { PluginDto.fromModel(it) }, disabledPluginUpdates = disabledUpdateModels.map { PluginDto.fromModel(it) }, incompatiblePluginNames = incompatiblePluginNames, updatesFromCustomRepositories = internalPluginUpdates.pluginNods.map { PluginDto.fromModel(it) }, - internalErrors = errors, - sessionId = sessionId) + internalErrors = errors) updateModel.downloaders = notIgnoredDownloaders return updateModel } - override suspend fun installUpdates(sessionId: String, updates: List, component: JComponent?, finishCallback: Runnable?) { - val downloaders = updates.mapNotNull { getDownloader(sessionId, it.pluginId.idString) } - val callbackWrapper = { - finishCallback?.run() - deleteSession(sessionId) - } - PluginUpdateDialog.runUpdateAll(downloaders, component, callbackWrapper, null) + override suspend fun installUpdates(updates: Collection, component: JComponent?, finishCallback: Runnable?, customRestarter: Consumer?) { + val downloaders = updates.mapNotNull { this.myDownloaders[it.pluginId] } + PluginUpdateDialog.runUpdateAll(downloaders, component, finishCallback, customRestarter) } - override suspend fun ignorePluginUpdates(sessionId: String) { - UpdateCheckerFacade.getInstance().ignorePlugins(getDownloaders(sessionId).map { it.descriptor }) + override suspend fun ignorePluginUpdates() { + UpdateCheckerFacade.getInstance().ignorePlugins(myDownloaders.values.map { it.descriptor }) } - private fun registerDownloader(sessionId: String, pluginId: String, downloader: PluginDownloader) { - downloaders.getOrPut(sessionId) { ConcurrentHashMap() }[pluginId] = downloader - } - - private fun registerDownloaders(sessionId: String, downloaders: List) { - downloaders.forEach { registerDownloader(sessionId, it.descriptor.pluginId.idString, it) } - } - - private fun getDownloader(sessionId: String, pluginId: String): PluginDownloader? { - return downloaders[sessionId]?.get(pluginId) - } - - private fun getDownloaders(sessionId: String): List = downloaders[sessionId]?.values?.toList() ?: emptyList() - - private fun deleteSession(sessionId: String) { - downloaders.remove(sessionId) + private fun storeDownloaders(downloaders: List) { + myDownloaders.clear() + downloaders.forEach { myDownloaders[it.descriptor.pluginId] = it } } } - -typealias PluginDownloaders = ConcurrentHashMap diff --git a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PlatformUpdateDialog.java b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PlatformUpdateDialog.java index 1df411671b0c..f56c158ba5e2 100644 --- a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PlatformUpdateDialog.java +++ b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PlatformUpdateDialog.java @@ -267,8 +267,8 @@ public final class PlatformUpdateDialog extends AbstractUpdateDialog { @Override public void actionPerformed(ActionEvent e) { close(OK_EXIT_CODE); - var downloaders = myUpdatesForPlugins != null ? myUpdatesForPlugins : Set.of(); - PluginModelAsyncOperationsExecutor.INSTANCE.findPlugins(downloaders, plugins -> { + var pluginIdsToUpdate = ContainerUtil.map2Set((myUpdatesForPlugins != null ? myUpdatesForPlugins : List.of()), PluginDownloader::getId); + PluginModelAsyncOperationsExecutor.INSTANCE.findPlugins(pluginIdsToUpdate, plugins -> { downloadPatchAndRestart(plugins); return Unit.INSTANCE; }); @@ -305,11 +305,12 @@ public final class PlatformUpdateDialog extends AbstractUpdateDialog { private void downloadPatchAndRestart(Map installedPlugins) { Collection selectedPluginsToUpdate = new ArrayList<>(); if (myUpdatesForPlugins != null && !installedPlugins.isEmpty()) { - var dialog = new PluginUpdateDialog(myProject, ContainerUtil.map(myUpdatesForPlugins, it -> it.getUiModel()), null, installedPlugins); + var dialog = new PluginUpdateDialog(myProject, new ArrayList<>(ContainerUtil.map(myUpdatesForPlugins, PluginDownloader::getUiModel)), null, installedPlugins); if (!dialog.showAndGet()) { return; // update cancelled } - selectedPluginsToUpdate.addAll(PluginUpdateDialog.getSelectedDownloaders(myUpdatesForPlugins, dialog)); + Set selectedPlugins = ContainerUtil.map2Set(dialog.getSelectedPluginModels(), PluginUiModel::getPluginId); + selectedPluginsToUpdate.addAll(ContainerUtil.filter(myUpdatesForPlugins, it -> selectedPlugins.contains(it.getId()))); } //noinspection UsagesOfObsoleteApi diff --git a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PluginUpdateFacade.kt b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PluginUpdateFacade.kt index 64e4b989d8cd..fca61ea37dca 100644 --- a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PluginUpdateFacade.kt +++ b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/PluginUpdateFacade.kt @@ -1,11 +1,18 @@ // 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.openapi.updateSettings.impl +import com.intellij.ide.plugins.newui.PluginUiModel +import com.intellij.openapi.components.service +import com.intellij.platform.ide.CoreUiCoroutineScopeHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.util.function.Consumer import javax.swing.JComponent -fun getPendingUpdates(): Collection? = UpdateSettingsEntryPointActionProvider.getPendingUpdates() +fun getPendingUpdates(): Collection? = UpdateSettingsEntryPointActionProvider.getPendingUpdates() -fun installUpdates(updates: Collection, component: JComponent?, customRestarter: Consumer?) { - PluginUpdateDialog.runUpdateAll(updates, component, null, customRestarter) +fun installUpdates(updates: Collection, component: JComponent?, customRestarter: Consumer?) { + service().coroutineScope.launch(Dispatchers.IO) { + PluginUpdateHandler.getInstance().installUpdates(updates, component, null, customRestarter) + } } \ No newline at end of file diff --git a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateChecker.kt b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateChecker.kt index 29e3ba14e910..e8c63b356437 100644 --- a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateChecker.kt +++ b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateChecker.kt @@ -789,10 +789,10 @@ private suspend fun doUpdateAndShowResult( if (!showResults) { if (platformUpdates is PlatformUpdates.Loaded) { - UpdateSettingsEntryPointActionProvider.newPlatformUpdate(platformUpdates, notIgnoredDownloaders, incompatiblePluginNames) + UpdateSettingsEntryPointActionProvider.newPlatformUpdate(platformUpdates, updatesForPlugins, incompatiblePluginNames, notIgnoredDownloaders) } else { - UpdateSettingsEntryPointActionProvider.newPluginUpdates(notIgnoredDownloaders, customRepoPlugins) + UpdateSettingsEntryPointActionProvider.newPluginUpdates(updatesForPlugins, customRepoPlugins) } callback?.setDone() return null @@ -814,7 +814,6 @@ private suspend fun doUpdateAndShowResult( else { showResults( project = project, - sessionId = updatesModel.sessionId, downloaders = notIgnoredDownloaders, pluginUpdates = updatesForPlugins, customRepoPlugins = customRepoPlugins, @@ -840,7 +839,6 @@ private fun showErrors(project: Project?, @NlsContexts.DialogMessage message: St @RequiresEdt private suspend fun showResults( project: Project?, - sessionId: String, downloaders: List, pluginUpdates: List, customRepoPlugins: Collection, @@ -863,7 +861,7 @@ private suspend fun showResults( if (dialog.showAndGet()) { val selectedPlugins = dialog.getSelectedPluginModels() service().coroutineScope.launch(Dispatchers.IO) { - PluginUpdateHandler.getInstance().installUpdates(sessionId, selectedPlugins, dialog.contentPanel, dialog.finishCallback) + PluginUpdateHandler.getInstance().installUpdates(selectedPlugins, dialog.contentPanel, dialog.finishCallback) } } } @@ -872,11 +870,11 @@ private suspend fun showResults( showUpdateDialog() } else { - UpdateSettingsEntryPointActionProvider.newPluginUpdates(downloaders, customRepoPlugins) + UpdateSettingsEntryPointActionProvider.newPluginUpdates(pluginUpdates, customRepoPlugins) if (userInitiated) { // offer to update only enabled plugins - showUpdatePluginsNotification(sessionId, pluginUpdates, project, showUpdateDialog) + showUpdatePluginsNotification(pluginUpdates, project, showUpdateDialog) } } } @@ -917,7 +915,6 @@ private suspend fun showResults( } private fun showUpdatePluginsNotification( - sessionId: String, updatesForPlugins: List, project: Project?, showUpdateDialog: () -> Unit, @@ -941,13 +938,13 @@ private fun showUpdatePluginsNotification( updatesForPlugins.forEach { PluginUpdateSourceService.getInstance().setPluginUpdateSourceId(it) } - PluginUpdateHandler.getInstance().installUpdates(sessionId, updatesForPlugins, component, null) + PluginUpdateHandler.getInstance().installUpdates(updatesForPlugins, component, null) } }, NotificationAction.createSimpleExpiring(IdeBundle.message("updates.plugins.dialog.action"), showUpdateDialog), NotificationAction.createSimpleExpiring(IdeBundle.message("updates.ignore.updates.link", updatesForPlugins.size)) { coroutineScope.launch { - PluginUpdateHandler.getInstance().ignorePluginUpdates(sessionId) + PluginUpdateHandler.getInstance().ignorePluginUpdates() } }, ), @@ -982,7 +979,7 @@ private fun showResults( showUpdateDialog() } else { - UpdateSettingsEntryPointActionProvider.newPlatformUpdate(platformUpdates, updatesForPlugins, incompatiblePluginNames) + UpdateSettingsEntryPointActionProvider.newPlatformUpdate(platformUpdates, updatesForPlugins.map { it.uiModel }, incompatiblePluginNames, updatesForPlugins) if (showNotification) { IdeUpdateUsageTriggerCollector.NOTIFICATION_SHOWN.log(project) diff --git a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateSettingsEntryPointActionProvider.java b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateSettingsEntryPointActionProvider.java index ff46f04047de..4aae6caec0e4 100644 --- a/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateSettingsEntryPointActionProvider.java +++ b/platform/platform-impl/update-checker/src/com/intellij/openapi/updateSettings/impl/UpdateSettingsEntryPointActionProvider.java @@ -4,18 +4,16 @@ package com.intellij.openapi.updateSettings.impl; import com.intellij.ide.AppLifecycleListener; import com.intellij.ide.IdeBundle; import com.intellij.ide.plugins.IdeaPluginDescriptor; -import com.intellij.ide.plugins.PluginStateListener; -import com.intellij.ide.plugins.PluginStateManager; +import com.intellij.ide.plugins.newui.PluginUpdatesService; import com.intellij.ide.plugins.newui.PluginModelAsyncOperationsExecutor; import com.intellij.ide.plugins.newui.PluginUiModel; -import com.intellij.ide.plugins.newui.PluginUpdatesService; +import com.intellij.ide.plugins.newui.PluginUpdateSubscription; import com.intellij.ide.util.PropertiesComponent; import com.intellij.openapi.actionSystem.ActionUpdateThread; import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.actionSystem.DataContext; import com.intellij.openapi.application.ApplicationInfo; import com.intellij.openapi.application.ApplicationNamesInfo; -import com.intellij.openapi.extensions.PluginDescriptor; import com.intellij.openapi.extensions.PluginId; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.progress.ProgressManager; @@ -24,6 +22,7 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.Messages; import com.intellij.openapi.util.BuildNumber; import com.intellij.openapi.util.Pair; +import com.intellij.platform.ide.productMode.IdeProductMode; import com.intellij.util.containers.ContainerUtil; import kotlin.Unit; import org.jetbrains.annotations.NotNull; @@ -51,11 +50,11 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { private static @Nullable List myIncompatiblePluginNames; private static @Nullable Set myAlreadyShownPluginUpdates; - private static @Nullable Collection myUpdatesForPlugins; + private static @Nullable Collection myUpdatesForPlugins; private static @Nullable Collection myCustomRepositoryPlugins; + private static @Nullable Collection myLocalUpdatesForPlugins; - private static PluginUpdatesService myUpdatesService; - private static PluginStateListener myPluginStateListener; + private static PluginUpdateSubscription myPluginUpdatesSubscription; private static boolean myEnableUpdateAction = true; @@ -108,9 +107,9 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { } private static void initPluginsListeners() { - if (myUpdatesService == null) { - myUpdatesService = PluginUpdatesService.connectWithUpdates(updateResult -> { - if (updateResult.isEmpty()) { + if (myPluginUpdatesSubscription == null) { + myPluginUpdatesSubscription = PluginUpdatesService.getInstance().subscribe(updateResult -> { + if (updateResult.getEnabledUpdates().isEmpty()) { newUpdatesForPlugins(null); myCustomRepositoryPlugins = null; return; @@ -118,34 +117,22 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { if (!UpdateSettings.getInstance().isPluginsCheckNeeded()) { return; } - newUpdatesForPlugins(updateResult.getPluginUpdates().getAll()); - myCustomRepositoryPlugins = updateResult.getPluginNods(); - }); - } - if (myPluginStateListener == null) { - PluginStateManager.addStateListener(myPluginStateListener = new PluginStateListener() { - @Override - public void install(@NotNull IdeaPluginDescriptor descriptor) { - removePluginsUpdate(List.of(descriptor)); - } - - @Override - public void uninstall(@NotNull IdeaPluginDescriptor descriptor) { - install(descriptor); - } + newUpdatesForPlugins(new ArrayList<>(updateResult.getEnabledUpdates())); + myCustomRepositoryPlugins = new ArrayList<>(updateResult.getPluginNods()); }); } } private static void clearUpdatesInfo() { setPlatformUpdateInfo(null); - newPlatformUpdate(null, null, (String)null); + newPlatformUpdate(null, null, (String)null, null); updateState(); } public static void newPlatformUpdate(@NotNull PlatformUpdates.Loaded platformUpdateInfo, - @NotNull List updatesForPlugins, - @NotNull List incompatiblePluginNames) { + @NotNull List updatesForPlugins, + @NotNull List incompatiblePluginNames, + @NotNull List localUpdatesForPlugins) { UpdateSettings settings = UpdateSettings.getInstance(); if (settings.isCheckNeeded()) { setPlatformUpdateInfo(platformUpdateInfo); @@ -154,10 +141,10 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { setPlatformUpdateInfo(null); } if (settings.isPluginsCheckNeeded()) { - newPlatformUpdate(updatesForPlugins, incompatiblePluginNames, null); + newPlatformUpdate(updatesForPlugins, incompatiblePluginNames, null, localUpdatesForPlugins); } else { - newPlatformUpdate(null, null, (String)null); + newPlatformUpdate(null, null, (String)null, null); } updateState(); } @@ -180,15 +167,17 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { } } - private static void newPlatformUpdate(@Nullable List updatesForPlugins, + private static void newPlatformUpdate(@Nullable List updatesForPlugins, @Nullable List incompatiblePluginNames, - @Nullable String nextRunPlatformUpdateVersion) { + @Nullable String nextRunPlatformUpdateVersion, + @Nullable List localUpdatesForPlugins) { myUpdatesForPlugins = updatesForPlugins; myIncompatiblePluginNames = incompatiblePluginNames; myNextRunPlatformUpdateVersion = nextRunPlatformUpdateVersion; + myLocalUpdatesForPlugins = localUpdatesForPlugins; } - public static void newPluginUpdates(@NotNull Collection updatesForPlugins, + public static void newPluginUpdates(@NotNull Collection updatesForPlugins, @NotNull Collection customRepositoryPlugins) { if (UpdateSettings.getInstance().isPluginsCheckNeeded()) { myUpdatesForPlugins = updatesForPlugins; @@ -201,24 +190,24 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { updateState(); } - public static @Nullable Collection getPendingUpdates() { + public static @Nullable Collection getPendingUpdates() { return myUpdatesForPlugins; } - private static void newUpdatesForPlugins(@Nullable Collection updatesForPlugins) { + private static void newUpdatesForPlugins(@Nullable Collection updatesForPlugins) { myUpdatesForPlugins = ContainerUtil.isEmpty(updatesForPlugins) ? null : updatesForPlugins; updateState(); } static void removePluginsUpdate(@NotNull List descriptors) { if (myAlreadyShownPluginUpdates != null) { - myAlreadyShownPluginUpdates.removeIf(name -> descriptors.stream().anyMatch(descriptor -> name.equals(descriptor.getName()))); + myAlreadyShownPluginUpdates.removeIf(name -> ContainerUtil.exists(descriptors, descriptor -> name.equals(descriptor.getName()))); } if (myUpdatesForPlugins != null) { Set pluginIds = ContainerUtil.map2Set(descriptors, IdeaPluginDescriptor::getPluginId); - List updatesForPlugins = ContainerUtil.filter(myUpdatesForPlugins, - downloader -> !pluginIds.contains(downloader.getId())); + List updatesForPlugins = ContainerUtil.filter(myUpdatesForPlugins, + downloader -> !pluginIds.contains(downloader.getPluginId())); if (myUpdatesForPlugins.size() != updatesForPlugins.size()) { newUpdatesForPlugins(updatesForPlugins); } @@ -227,7 +216,7 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { private static boolean isAlreadyShownPluginUpdates() { return myUpdatesForPlugins == null || ContainerUtil.isEmpty(myAlreadyShownPluginUpdates) || - myUpdatesForPlugins.stream().anyMatch(plugin -> !myAlreadyShownPluginUpdates.contains(plugin.getPluginName())); + ContainerUtil.exists(myUpdatesForPlugins, plugin -> !myAlreadyShownPluginUpdates.contains(plugin.getName())); } private static void updateAlreadyShownPluginUpdates() { @@ -235,7 +224,7 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { if (myAlreadyShownPluginUpdates == null) { myAlreadyShownPluginUpdates = new HashSet<>(); } - myUpdatesForPlugins.forEach(plugin -> myAlreadyShownPluginUpdates.add(plugin.getPluginName())); + myUpdatesForPlugins.forEach(plugin -> myAlreadyShownPluginUpdates.add(plugin.getName())); } } @@ -252,35 +241,35 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { @Override public void actionPerformed(@NotNull AnActionEvent e) { Project project = e.getProject(); - Pair result = ProgressManager.getInstance() + Pair result = ProgressManager.getInstance() .run(new Task.WithResult<>(project, IdeBundle.message("find.ide.update.title"), true) { @Override - protected @NotNull Pair<@NotNull PlatformUpdates, @Nullable InternalPluginResults> compute(@NotNull ProgressIndicator indicator) { + protected @NotNull Pair<@NotNull PlatformUpdates, @Nullable PluginUpdatesModel> compute(@NotNull ProgressIndicator indicator) { PlatformUpdates platformUpdates = UpdateChecker.getPlatformUpdates(UpdateSettings.getInstance(), indicator); - InternalPluginResults pluginResults = platformUpdates instanceof PlatformUpdates.Loaded ? + PluginUpdatesModel pluginResults = platformUpdates instanceof PlatformUpdates.Loaded ? getInternalPluginUpdates((PlatformUpdates.Loaded)platformUpdates, indicator) : null; return Pair.create(platformUpdates, pluginResults); } - private static @NotNull InternalPluginResults getInternalPluginUpdates(@NotNull PlatformUpdates.Loaded loadedResult, + private static PluginUpdatesModel getInternalPluginUpdates(@NotNull PlatformUpdates.Loaded loadedResult, @NotNull ProgressIndicator indicator) { - return UpdateCheckerFacade.getInstance().checkInstalledPluginUpdates( - indicator, loadedResult.getNewBuild().getApiVersion() - ); + return PluginUpdateHandler.loadAndStorePluginUpdates(loadedResult.getNewBuild().getApiVersion().asString(), + indicator); } }); PlatformUpdates platformUpdateInfo = result.getFirst(); - InternalPluginResults pluginResults = result.getSecond(); - if (platformUpdateInfo instanceof PlatformUpdates.Loaded && pluginResults != null) { + PluginUpdatesModel pluginUpdatesModel = result.getSecond(); + if (platformUpdateInfo instanceof PlatformUpdates.Loaded && pluginUpdatesModel != null) { setPlatformUpdateInfo((PlatformUpdates.Loaded)platformUpdateInfo); - newPlatformUpdate(pluginResults.getPluginUpdates().getAllEnabled().stream().toList(), - ContainerUtil.map(pluginResults.getPluginUpdates().getIncompatible(), PluginDescriptor::getName), - null); + newPlatformUpdate(new ArrayList<>(pluginUpdatesModel.getPluginUpdates()), + pluginUpdatesModel.getIncompatiblePluginNames(), + null, + pluginUpdatesModel.getDownloaders()); super.actionPerformed(e); } else { @@ -304,12 +293,12 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { actions.add(new IdeUpdateAction(myPlatformUpdateInfo.getNewBuild().getVersion())); } // todo[AL/RS] separate action for plugins compatible with both old and new builds - else if (myUpdatesForPlugins != null && !myUpdatesForPlugins.isEmpty()) { + else if (myUpdatesForPlugins != null && !myUpdatesForPlugins.isEmpty() && !IdeProductMode.isBackend()) { int size = myUpdatesForPlugins.size(); actions.add(new UpdateAction(size == 1 ? IdeBundle.message("settings.entry.point.update.plugin.action", - myUpdatesForPlugins.iterator().next().getPluginName()) + myUpdatesForPlugins.iterator().next().getName()) : IdeBundle.message("settings.entry.point.update.plugins.action", size)) { @Override public boolean isPluginUpdate() { @@ -338,13 +327,11 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { @Override public void actionPerformed(@NotNull AnActionEvent e) { - Collection updatesForPlugins = myUpdatesForPlugins; - PluginModelAsyncOperationsExecutor.INSTANCE.findPlugins(updatesForPlugins, plugins -> { - List<@Nullable PluginUiModel> updateModels = ContainerUtil.map(updatesForPlugins, it -> it.getUiModel()); - var dialog = new PluginUpdateDialog(e.getProject(), updateModels, myCustomRepositoryPlugins, plugins); + PluginModelAsyncOperationsExecutor.INSTANCE.findPlugins(ContainerUtil.map(myUpdatesForPlugins, PluginUiModel::getPluginId), installedPlugins -> { + var dialog = new PluginUpdateDialog(e.getProject(), myUpdatesForPlugins, myCustomRepositoryPlugins, installedPlugins); dialog.setFinishCallback(() -> setEnableUpdateAction(true)); setEnableUpdateAction(false); - if (!PluginUpdateDialog.showDialogAndUpdate(updatesForPlugins, dialog)) { + if (!PluginUpdateDialog.showDialogAndUpdate(dialog)) { setEnableUpdateAction(true); } return Unit.INSTANCE; @@ -380,7 +367,7 @@ final class UpdateSettingsEntryPointActionProvider implements ActionProvider { @Override public void actionPerformed(@NotNull AnActionEvent e) { PlatformUpdateDialog dialog = new PlatformUpdateDialog(e.getProject(), Objects.requireNonNull(myPlatformUpdateInfo), - true, myUpdatesForPlugins, myIncompatiblePluginNames); + true, myLocalUpdatesForPlugins, myIncompatiblePluginNames); if (dialog.showAndGet()) { clearUpdatesInfo(); } diff --git a/platform/platform-resources/src/META-INF/PlatformExtensionPoints.xml b/platform/platform-resources/src/META-INF/PlatformExtensionPoints.xml index f0514290c432..1d5200e98818 100644 --- a/platform/platform-resources/src/META-INF/PlatformExtensionPoints.xml +++ b/platform/platform-resources/src/META-INF/PlatformExtensionPoints.xml @@ -716,6 +716,9 @@ + diff --git a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginManagerApi.kt b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginManagerApi.kt index da21e917e34e..10f26af19ebd 100644 --- a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginManagerApi.kt +++ b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginManagerApi.kt @@ -15,8 +15,6 @@ import com.intellij.ide.plugins.marketplace.PluginSearchResult import com.intellij.ide.plugins.marketplace.SetEnabledStateResult import com.intellij.ide.plugins.newui.DefaultUiPluginManagerController import com.intellij.ide.plugins.newui.PluginInstallationState -import com.intellij.ide.plugins.newui.PluginManagerSessionService -import com.intellij.ide.plugins.newui.PluginUiModel import com.intellij.openapi.application.EDT import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.updateSettings.impl.UpdateSettings @@ -24,9 +22,6 @@ import com.intellij.platform.pluginManager.shared.rpc.PluginManagerApi import com.intellij.platform.project.ProjectId import com.intellij.platform.project.findProjectOrNull import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus @@ -52,10 +47,6 @@ class BackendPluginManagerApi : PluginManagerApi { return DefaultUiPluginManagerController.getInstalledPlugins().map { PluginDto.fromModel(it, true) } } - override suspend fun getUpdates(): List { - return DefaultUiPluginManagerController.getUpdates().map { PluginDto.fromModel(it) } - } - override suspend fun setEnabledState(sessionId: String, pluginIds: List, enable: Boolean) { DefaultUiPluginManagerController.setPluginStatus(sessionId, pluginIds, enable) } @@ -214,32 +205,6 @@ class BackendPluginManagerApi : PluginManagerApi { return DefaultUiPluginManagerController.getLastCompatiblePluginUpdate(allIds, throwExceptions, buildNumber) } - override suspend fun isNeedUpdate(pluginId: PluginId): Boolean { - return DefaultUiPluginManagerController.isNeedUpdate(pluginId) - } - - override suspend fun subscribeToPluginUpdates(sessionId: String): Flow> { - return channelFlow { - DefaultUiPluginManagerController.connectToPluginUpdateService(sessionId) { pluginUiModels: List? -> - trySend(pluginUiModels?.map { PluginDto.fromModel(it) } ?: emptyList()) - } - awaitClose() - } - } - - override suspend fun recalculatePluginUpdates(sessionId: String) { - PluginManagerSessionService.getInstance().getSession(sessionId)?.updateService?.recalculateUpdates() - } - - override suspend fun disposeUpdaterService(sessionId: String) { - PluginManagerSessionService.getInstance().getSession(sessionId)?.updateService?.dispose() - } - - - override suspend fun notifyUpdateFinished(sessionId: String) { - PluginManagerSessionService.getInstance().getSession(sessionId)?.updateService?.finishUpdate() - } - override suspend fun checkPluginCanBeDownloaded(plugin: PluginDto): Boolean { return DefaultUiPluginManagerController.checkPluginCanBeDownloaded(plugin, null) } diff --git a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdaterApi.kt b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdaterApi.kt index fb79145b5249..3f9acef6a1b4 100644 --- a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdaterApi.kt +++ b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdaterApi.kt @@ -16,8 +16,8 @@ import org.jetbrains.annotations.ApiStatus @ApiStatus.Internal class BackendPluginUpdaterApi : PluginUpdaterApi { - override suspend fun loadAndStorePluginUpdates(apiVersion: String?, sessionId: String): PluginUpdatesModel { - val updates = PluginUpdateHandler.getInstance().loadAndStorePluginUpdates(apiVersion, sessionId) + override suspend fun loadAndStorePluginUpdates(apiVersion: String?): PluginUpdatesModel { + val updates = PluginUpdateHandler.getInstance().loadAndStorePluginUpdates(apiVersion) val pluginAutoUpdateService = service() if (pluginAutoUpdateService.isAutoUpdateEnabled()) { pluginAutoUpdateService.onPluginUpdatesChecked(updates.downloaders) @@ -25,10 +25,10 @@ class BackendPluginUpdaterApi : PluginUpdaterApi { return updates } - override suspend fun installUpdates(sessionId: String, updates: List): Deferred { + override suspend fun installUpdates(updates: List): Deferred { return serviceAsync().cs.async { try { - PluginUpdateHandler.getInstance().installUpdates(sessionId, updates, null, null) + PluginUpdateHandler.getInstance().installUpdates(updates, null, null) } catch (_: Exception) { return@async false @@ -37,8 +37,8 @@ class BackendPluginUpdaterApi : PluginUpdaterApi { } } - override suspend fun ignorePluginUpdates(sessionId: String) { - PluginUpdateHandler.getInstance().ignorePluginUpdates(sessionId) + override suspend fun ignorePluginUpdates() { + PluginUpdateHandler.getInstance().ignorePluginUpdates() } } diff --git a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdatesProviderApi.kt b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdatesProviderApi.kt new file mode 100644 index 000000000000..1f3707d8e6c1 --- /dev/null +++ b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/BackendPluginUpdatesProviderApi.kt @@ -0,0 +1,19 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.pluginManager.backend.rpc + +import com.intellij.ide.plugins.newui.PluginUpdatesEvent +import com.intellij.ide.plugins.newui.PluginUpdatesProvider +import com.intellij.platform.pluginManager.shared.rpc.PluginUpdatesProviderApi +import kotlinx.coroutines.flow.Flow + +internal class BackendPluginUpdatesProviderApi() : PluginUpdatesProviderApi { + private val delegate = PluginUpdatesProvider.getInstances().first() + + override suspend fun pluginUpdateEvents(): Flow { + return delegate.pluginUpdateEvents() + } + + override suspend fun update() { + delegate.update() + } +} diff --git a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/PluginManagerApiProvider.kt b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/PluginManagerApiProvider.kt index 8abadbc9715f..89bdcec080c0 100644 --- a/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/PluginManagerApiProvider.kt +++ b/platform/pluginManager/backend/src/com/intellij/platform/pluginManager/backend/rpc/PluginManagerApiProvider.kt @@ -5,11 +5,10 @@ import com.intellij.platform.pluginManager.shared.rpc.PluginErrorReporterApi import com.intellij.platform.pluginManager.shared.rpc.PluginInstallerApi import com.intellij.platform.pluginManager.shared.rpc.PluginManagerApi import com.intellij.platform.pluginManager.shared.rpc.PluginUpdaterApi +import com.intellij.platform.pluginManager.shared.rpc.PluginUpdatesProviderApi import com.intellij.platform.rpc.backend.RemoteApiProvider import fleet.rpc.remoteApiDescriptor -import org.jetbrains.annotations.ApiStatus -@ApiStatus.Internal internal class PluginManagerApiProvider : RemoteApiProvider { override fun RemoteApiProvider.Sink.remoteApis() { remoteApi(remoteApiDescriptor()) { @@ -27,5 +26,9 @@ internal class PluginManagerApiProvider : RemoteApiProvider { remoteApi(remoteApiDescriptor()) { BackendPluginErrorReporterApi() } + + remoteApi(remoteApiDescriptor()) { + BackendPluginUpdatesProviderApi() + } } } \ No newline at end of file diff --git a/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/BackendUiPluginManagerController.kt b/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/BackendUiPluginManagerController.kt index 4c5b9a0770f9..dad59b2ecf50 100644 --- a/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/BackendUiPluginManagerController.kt +++ b/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/BackendUiPluginManagerController.kt @@ -16,7 +16,6 @@ import com.intellij.ide.plugins.marketplace.SetEnabledStateResult import com.intellij.ide.plugins.newui.PluginInstallationState import com.intellij.ide.plugins.newui.PluginSource import com.intellij.ide.plugins.newui.PluginUiModel -import com.intellij.ide.plugins.newui.PluginUpdatesService import com.intellij.ide.plugins.newui.UiPluginManagerController import com.intellij.ide.ui.search.TraverseUIMode import com.intellij.openapi.application.ModalityState @@ -29,13 +28,9 @@ import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.FUSEventSou import com.intellij.platform.pluginManager.shared.rpc.PluginInstallerApi import com.intellij.platform.pluginManager.shared.rpc.PluginManagerApi import com.intellij.platform.project.projectId -import fleet.rpc.client.durable import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.jetbrains.annotations.ApiStatus @@ -65,10 +60,6 @@ class BackendUiPluginManagerController() : UiPluginManagerController { return PluginManagerApi.getInstance().getInstalledPlugins().withSource() } - override suspend fun getUpdates(): List { - return PluginManagerApi.getInstance().getUpdates().withSource() - } - override suspend fun getPlugin(id: PluginId): PluginUiModel? { return PluginManagerApi.getInstance().getPluginById(id)?.withSource() } @@ -176,19 +167,6 @@ class BackendUiPluginManagerController() : UiPluginManagerController { return PluginManagerApi.getInstance().loadErrors(sessionId, pluginIds) } - @OptIn(FlowPreview::class) - override fun connectToPluginUpdateService(sessionId: String, callback: (List) -> Unit): PluginUpdatesService { - val result = RemotePluginUpdatesService(sessionId) - result.coroutineScope.launch { - durable { - PluginManagerApi.getInstance().subscribeToPluginUpdates(sessionId).debounce(100).collectLatest { - callback(it) - } - } - } - return result - } - override fun filterPluginsRequiringUltimateButItsDisabled(pluginIds: List): List { return awaitForResult { PluginManagerApi.getInstance().filterPluginsRequiresUltimateButItsDisabled(pluginIds) } } @@ -271,10 +249,6 @@ class BackendUiPluginManagerController() : UiPluginManagerController { } } - override suspend fun isNeedUpdate(pluginId: PluginId): Boolean { - return PluginManagerApi.getInstance().isNeedUpdate(pluginId) - } - override suspend fun closeSession(sessionId: String) { service().coroutineScope.launch { PluginManagerApi.getInstance().closeSession(sessionId) diff --git a/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/RemotePluginUpdatesService.kt b/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/RemotePluginUpdatesService.kt index 992bf5d04682..e69de29bb2d1 100644 --- a/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/RemotePluginUpdatesService.kt +++ b/platform/pluginManager/frontend/src/com/intellij/platform/pluginManager/frontend/RemotePluginUpdatesService.kt @@ -1,48 +0,0 @@ -// 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.pluginManager.frontend - -import com.intellij.ide.plugins.newui.PluginUiModel -import com.intellij.ide.plugins.newui.PluginUpdatesService -import com.intellij.openapi.components.service -import com.intellij.platform.pluginManager.shared.rpc.PluginManagerApi -import com.intellij.platform.util.coroutines.childScope -import fleet.rpc.client.durable -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import org.jetbrains.annotations.ApiStatus -import java.util.function.Consumer - -@ApiStatus.Internal -open class RemotePluginUpdatesService(private val sessionId: String) : PluginUpdatesService() { - - internal val coroutineScope = service().coroutineScope.childScope("RemotePluginUpdatesServiceScope") - - override fun calculateUpdates(callback: Consumer>) { - coroutineScope.launch { - durable { - PluginManagerApi.getInstance().subscribeToPluginUpdates(sessionId).collect { - callback.accept(it) - } - } - } - } - - override fun recalculateUpdates() { - coroutineScope.launch { - PluginManagerApi.getInstance().recalculatePluginUpdates(sessionId) - } - } - - override fun dispose() { - coroutineScope.launch { - PluginManagerApi.getInstance().disposeUpdaterService(sessionId) - coroutineScope.cancel() - } - } - - override fun finishUpdate() { - coroutineScope.launch { - PluginManagerApi.getInstance().notifyUpdateFinished(sessionId) - } - } -} \ No newline at end of file diff --git a/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginManagerApi.kt b/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginManagerApi.kt index 081b5dca60ad..c47046d31bd3 100644 --- a/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginManagerApi.kt +++ b/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginManagerApi.kt @@ -17,7 +17,6 @@ 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 @Rpc @@ -27,7 +26,6 @@ interface PluginManagerApi : RemoteApi { suspend fun getPluginById(pluginId: PluginId): PluginDto? suspend fun getVisiblePlugins(showImplementationDetails: Boolean): List suspend fun getInstalledPlugins(): List - suspend fun getUpdates(): List suspend fun findPlugin(pluginId: PluginId): PluginDto? suspend fun getLastCompatiblePluginUpdateModel(pluginId: PluginId, buildNumber: String?): PluginDto? suspend fun getLastCompatiblePluginUpdate(allIds: Set, throwExceptions: Boolean, buildNumber: String?): List @@ -54,11 +52,6 @@ interface PluginManagerApi : RemoteApi { suspend fun getPluginsRequiresUltimateMap(pluginIds: List): Map suspend fun filterPluginsRequiresUltimateButItsDisabled(pluginIds: List): List suspend fun findPluginNames(pluginIds: List): List - suspend fun isNeedUpdate(pluginId: PluginId): Boolean - suspend fun subscribeToPluginUpdates(sessionId: String): Flow> - suspend fun recalculatePluginUpdates(sessionId: String) - suspend fun disposeUpdaterService(sessionId: String) - suspend fun notifyUpdateFinished(sessionId: String) suspend fun getPluginInstallationState(pluginId: PluginId): PluginInstallationState suspend fun getPluginInstallationStates(): Map suspend fun checkPluginCanBeDownloaded(plugin: PluginDto): Boolean diff --git a/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdaterApi.kt b/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdaterApi.kt index 206ed653edd6..5743ad7d105c 100644 --- a/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdaterApi.kt +++ b/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdaterApi.kt @@ -13,11 +13,11 @@ import org.jetbrains.annotations.ApiStatus @Rpc @ApiStatus.Internal interface PluginUpdaterApi : RemoteApi { - suspend fun loadAndStorePluginUpdates(apiVersion: String?, sessionId: String): PluginUpdatesModel + suspend fun loadAndStorePluginUpdates(apiVersion: String?): PluginUpdatesModel - suspend fun installUpdates(sessionId: String, updates: List): Deferred + suspend fun installUpdates(updates: List): Deferred - suspend fun ignorePluginUpdates(sessionId: String) + suspend fun ignorePluginUpdates() companion object { suspend fun getInstance(): PluginUpdaterApi { diff --git a/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdatesProviderApi.kt b/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdatesProviderApi.kt new file mode 100644 index 000000000000..dba5bf593a92 --- /dev/null +++ b/platform/pluginManager/shared/src/com/intellij/platform/pluginManager/shared/rpc/PluginUpdatesProviderApi.kt @@ -0,0 +1,24 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.pluginManager.shared.rpc + +import com.intellij.ide.plugins.newui.PluginUpdatesEvent +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 + +@Rpc +@Suppress("NonSerializableTypeInRpcInterface") +@ApiStatus.Internal +interface PluginUpdatesProviderApi : RemoteApi { + suspend fun pluginUpdateEvents(): Flow + suspend fun update() + + companion object { + suspend fun getInstance(): PluginUpdatesProviderApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } +} diff --git a/platform/smart-update/src/com/intellij/smartUpdate/PluginsUpdateStep.kt b/platform/smart-update/src/com/intellij/smartUpdate/PluginsUpdateStep.kt index 5a147409ac1d..0dc6b6184856 100644 --- a/platform/smart-update/src/com/intellij/smartUpdate/PluginsUpdateStep.kt +++ b/platform/smart-update/src/com/intellij/smartUpdate/PluginsUpdateStep.kt @@ -60,7 +60,7 @@ internal class PluginsUpdateStep: SmartUpdateStep { val updates = getPendingUpdates() if (updates.isNullOrEmpty()) return SmartUpdateBundle.message("no.updates.available") return if (updates.size == 1) - SmartUpdateBundle.message("update.plugin", updates.first().pluginName) else + SmartUpdateBundle.message("update.plugin", updates.first().name ?: "") else SmartUpdateBundle.message("update.several.plugins", updates.size) } }