PY-91529 disable PPTW install link while a package install is in progress

Track active installs in a shared per-SDK map (PyActiveInstalls, stored in
SDK user data) used by the tool-window tree, info pane and install dialog, so
the inline Install link greys with a spinner and repeated clicks can't start
duplicate installs.

(cherry picked from commit 5f60367a8a4d1be7fa1ea61cc573d46ce2607272)

GitOrigin-RevId: 2902674fb1a4feda31966b9ad3973be44eead400
This commit is contained in:
Vitaly Legchilkin
2026-08-10 19:27:27 +00:00
committed by intellij-monorepo-bot
parent a59f351ad8
commit ec17d60871
7 changed files with 279 additions and 16 deletions
@@ -0,0 +1,53 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.toolwindow
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.getOrCreateUserDataUnsafe
import com.jetbrains.python.packaging.PyPackageName
import java.util.concurrent.ConcurrentHashMap
/**
* Per-SDK set of "installs currently running", keyed by opaque strings — the normalized package
* name for package installs (see [packageKey]) or the install dialog's namespaced `"location:"` /
* `"command:"` keys. It is the single source of truth behind the tool-window tree link, the info
* pane and the install dialog, so a package cannot be installed twice at once into the same
* interpreter and every surface shows the same in-progress state (PY-91529).
*
* Scoped to the [Sdk] (stored in its user data, mirroring [com.jetbrains.python.packaging.management.CancellableJobSerialRunner]'s
* per-SDK mutex) rather than to the project: installs happen per interpreter, so "is X installing"
* only makes sense for a given SDK. Change notification is *not* kept here — it is UI-scoped and
* owned by [PyPackagingToolWindowService], which fires its listeners on the EDT.
*
* Thread-safe.
*/
internal class PyActiveInstalls {
private val keys: MutableSet<String> = ConcurrentHashMap.newKeySet()
/** `true` while an install keyed by exactly [key] is running. */
fun isInstalling(key: String): Boolean = keys.contains(key)
/** `true` while a package named [packageName] (any version) is being installed. */
fun isPackageInstalling(packageName: String): Boolean = isInstalling(packageKey(packageName))
/** Records [key]. Returns `false` if it was already recorded (so a repeated trigger is rejected). */
fun mark(key: String): Boolean = keys.add(key)
/** Clears [key]. Returns `false` if it was not recorded. */
fun unmark(key: String): Boolean = keys.remove(key)
companion object {
private val KEY = Key.create<PyActiveInstalls>(PyActiveInstalls::class.java.name)
/** The [Sdk]'s active-installations set, created on first use and living in its user data. */
fun forSdk(sdk: Sdk): PyActiveInstalls = of(sdk)
/** Storage accessor by raw [UserDataHolder] — the SDK in production, any holder in tests. */
internal fun of(holder: UserDataHolder): PyActiveInstalls =
synchronized(holder) { holder.getOrCreateUserDataUnsafe(KEY) { PyActiveInstalls() } }
/** Normalized key for a package install: `Django`, `django` and `DJANGO` collapse to one entry. */
fun packageKey(packageName: String): String = PyPackageName.from(packageName).name
}
}
@@ -21,6 +21,7 @@ import com.intellij.openapi.project.modules
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ModuleRootEvent
import com.intellij.openapi.roots.ModuleRootListener
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.util.messages.MessageBusConnection
@@ -101,6 +102,48 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
private var currentQuery: String = ""
internal val activeSearchQuery: String get() = currentQuery
// --- Shared "active installations" state (PY-91529) --------------------------------------------
// The state itself is per-SDK (installs happen per interpreter — see [PyActiveInstalls]); this
// service only exposes it to, and notifies, the UI. Shared by the packages tree, the info pane and
// the install dialog (all hold this project-level service). Marked at the UI action sites (mirrors
// the dialog's former local `installingTargets`), so the visual state flips synchronously at click
// time. Listeners are project/UI-scoped and fire on the EDT.
private val installStateListeners = java.util.concurrent.CopyOnWriteArrayList<Runnable>()
/** `true` while an install keyed by exactly [key] is running on [sdk] (verbatim key — see [packageKey]). */
fun isInstalling(sdk: Sdk, key: String): Boolean = PyActiveInstalls.forSdk(sdk).isInstalling(key)
/** `true` while a package named [packageName] (any version) is being installed on [sdk]. */
fun isPackageInstalling(sdk: Sdk, packageName: String): Boolean = PyActiveInstalls.forSdk(sdk).isPackageInstalling(packageName)
/** Records [key] as an active install on [sdk]. Returns `false` if already recorded (rejects a rapid re-trigger). */
fun markInstalling(sdk: Sdk, key: String): Boolean =
PyActiveInstalls.forSdk(sdk).mark(key).also { if (it) fireInstallStateChanged() }
/** Clears [key] on [sdk]; must run in a `finally` / completion handler so a cancelled install can't leak it. */
fun unmarkInstalling(sdk: Sdk, key: String) {
if (PyActiveInstalls.forSdk(sdk).unmark(key)) fireInstallStateChanged()
}
/**
* Subscribes [listener] to any change of the active-installations state; it is invoked on the EDT
* and unregistered when [parent] is disposed. The signal is not SDK-filtered (a listener repaints
* and re-queries for its own SDK), and the service stays Swing-free — it only *invokes* the UI
* callback, it never touches Swing itself.
*/
fun addInstallStateListener(parent: Disposable, listener: Runnable) {
installStateListeners.add(listener)
Disposer.register(parent) { installStateListeners.remove(listener) }
}
private fun fireInstallStateChanged() {
if (installStateListeners.isEmpty()) return
serviceScope.launch(Dispatchers.EDT) {
installStateListeners.forEach { it.run() }
}
}
// -----------------------------------------------------------------------------------------------
private data class SdkContext(
val sdk: Sdk,
val managerUI: PythonPackageManagerUI
@@ -863,6 +906,12 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
fun getInstance(project: Project): PyPackagingToolWindowService = project.service<PyPackagingToolWindowService>()
/**
* Normalized active-installations key for a package install (PY-91529); distinct from the
* dialog's `"location:"` / `"command:"` keys because a package name can never contain a colon.
*/
fun packageKey(packageName: String): String = PyActiveInstalls.packageKey(packageName)
/**
* Query-aware comparator over package names: prefix matches first (shortest wins), then plain
* lexicographic name fallback so the sort is stable when two items tie on the primary key.
@@ -103,7 +103,11 @@ internal class PyPackageDescriptionController(
private val installActionButton = JBOptionButton(null, emptyArray())
private val installAction = wrapAction(message("action.PyInstallPackage.text"), message("progress.text.installing")) {
private val installAction = wrapAction(
message("action.PyInstallPackage.text"),
message("progress.text.installing"),
installKey = { selectedPackage.get()?.name?.let { PyPackagingToolWindowService.packageKey(it) } },
) {
val details = selectedPackageDetails.get() ?: return@wrapAction
val version = versionSelector.text.takeIf { it != latestText }
val specification = details.toPackageSpecification(version) ?: return@wrapAction
@@ -120,6 +124,14 @@ internal class PyPackageDescriptionController(
private val progressEnabledProperty = AtomicBooleanProperty(false)
/**
* Mirrors the shared active-installations set ([PyPackagingToolWindowService]) for the currently
* selected package, so an install started elsewhere (tree link, install dialog) also disables the
* install button and version selector here (PY-91529). Recomputed from an install-state listener
* and whenever the selection changes.
*/
private val sharedInstallingProperty = AtomicBooleanProperty(false)
private val progressIndicatorComponent = JPanel()
private val htmlPanel: JCEFHtmlPanel = PyPackagingJcefHtmlPanel(project).also { panel ->
@@ -199,7 +211,7 @@ internal class PyPackageDescriptionController(
versionSelector.text = it
}
val comboBox = cell(versionSelector)
comboBox.enabledIf(isManagement.and(progressEnabledProperty.not())).gap(RightGap.SMALL)
comboBox.enabledIf(isManagement.and(progressEnabledProperty.not()).and(sharedInstallingProperty.not())).gap(RightGap.SMALL)
// Editable installs (``pip install -e .``) are managed from outside the package list — the
// user edits the source directory directly, and there is no upstream version to switch to.
// Surfacing the dropdown for them would imply a fake "latest" version is selectable.
@@ -221,6 +233,7 @@ internal class PyPackageDescriptionController(
installActionButton.action = installAction
installActionButton.options = emptyArray()
cell(installActionButton).visibleIf(selectedPackage.transform { it is InstallablePackage }.and(isManagement).and(progressEnabledProperty.not()))
.enabledIf(sharedInstallingProperty.not())
.gap(RightGap.SMALL)
button(message("action.PyDeletePackage.text")) {
@@ -311,21 +324,32 @@ internal class PyPackageDescriptionController(
if (selectedPackage.get() !is InstalledPackage)
return
wrapInvokeOp(message("progress.text.installing")) {
wrapInvokeOp(
message("progress.text.installing"),
installKey = selectedPackage.get()?.name?.let { PyPackagingToolWindowService.packageKey(it) },
) {
updatePackageVersion(selectedValue)
}
}
private fun calculateVersionText() = (selectedPackage.get() as? InstalledPackage)?.currentVersion?.presentableText ?: latestText
private fun wrapAction(@Nls text: String, @Nls progressText: String, actionPerformed: suspend () -> Unit): Action = object : AbstractAction(text) {
private fun wrapAction(@Nls text: String, @Nls progressText: String, installKey: (() -> String?)? = null, actionPerformed: suspend () -> Unit): Action = object : AbstractAction(text) {
override fun actionPerformed(e: ActionEvent) {
wrapInvokeOp(progressText, actionPerformed)
wrapInvokeOp(progressText, installKey?.invoke(), actionPerformed)
}
}
private fun wrapInvokeOp(@Nls progressText: String, actionPerformed: suspend () -> Unit) {
/**
* [installKey] — when non-null, records the operation in the shared active-installations map for
* its whole duration (PY-91529) so the tree link and the install dialog also reflect it. Left
* null for non-install ops (e.g. uninstall).
*/
private fun wrapInvokeOp(@Nls progressText: String, installKey: String? = null, actionPerformed: suspend () -> Unit) {
progressEnabledProperty.set(true)
// Capture the SDK now so mark/unmark target the same interpreter even if the selection changes.
val installSdk = service.currentSdk
if (installKey != null && installSdk != null) service.markInstalling(installSdk, installKey)
val progressIndicator = OneLineProgressIndicator(true, true)
progressIndicator.text = progressText
progressIndicatorComponent.removeAll()
@@ -339,6 +363,7 @@ internal class PyPackageDescriptionController(
finally {
withContext(Dispatchers.EDT) {
progressEnabledProperty.set(false)
if (installKey != null && installSdk != null) service.unmarkInstalling(installSdk, installKey)
selectedPackage.set(null)
onActionCompleted?.invoke()
}
@@ -351,6 +376,19 @@ internal class PyPackageDescriptionController(
}
}
init {
// Keep [sharedInstallingProperty] in sync with the shared active-installations map: an install
// started from the tree or dialog, or a selection change, must re-evaluate the button state.
service.addInstallStateListener(this) { recomputeSharedInstalling() }
selectedPackage.afterChange { recomputeSharedInstalling() }
}
private fun recomputeSharedInstalling() {
val name = selectedPackage.get()?.name
val sdk = service.currentSdk
sharedInstallingProperty.set(name != null && sdk != null && service.isPackageInstalling(sdk, name))
}
override fun dispose() {
versionSelectorMouseListener?.let {
versionSelector.removeMouseListener(it)
@@ -11,6 +11,7 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.ClientProperty
import com.intellij.ui.hover.TreeHoverListener
import com.intellij.ui.render.RenderingHelper
@@ -117,6 +118,12 @@ internal class PyPackagesTree(
internal val isReadOnly
get() = packagingService.currentSdk?.isReadOnly != false
/** Whether an install for [packageName] is currently running on the current SDK (shared across all surfaces). */
internal fun isInstalling(packageName: String): Boolean {
val sdk = packagingService.currentSdk ?: return false
return packagingService.isPackageInstalling(sdk, packageName)
}
/**
* `true` once [PyPackageTreeCellRenderer] has been installed during construction. Used to
* pin our renderer for the rest of the tree's lifetime — see [setCellRenderer] / [updateUI].
@@ -165,6 +172,9 @@ internal class PyPackagesTree(
init {
putClientProperty(TREE_KEY, this)
ClientProperty.put(this, RenderingHelper.SHRINK_LONG_RENDERER, false)
// Allow AnimatedIcon (the install spinner in PyPackageTreeCellRenderer) to animate inside the
// cell renderer; without this the platform paints only a single static frame (PY-91529).
ClientProperty.put(this, AnimatedIcon.ANIMATION_IN_RENDERER_ALLOWED, true)
model = treeModel
alignmentX = LEFT_ALIGNMENT
alignmentY = TOP_ALIGNMENT
@@ -184,6 +194,10 @@ internal class PyPackagesTree(
}
})
initializeUI()
// Repaint when the shared active-installations set changes (an install started/finished from
// any surface — this tree, the info pane, or the install dialog) so installing rows show the
// spinner and a greyed, non-clickable link (PY-91529).
packagingService.addInstallStateListener(controller) { repaint() }
}
override fun getToolTipText(event: MouseEvent): String? {
@@ -388,16 +402,34 @@ internal class PyPackagesTree(
val requirement = pyRequirement(pkg.name, pyRequirementVersionSpec(versionString))
val spec = pkg.repository?.findPackageSpecification(requirement) ?: return
val installRequest = PythonPackageInstallRequest.ByRepositoryPythonPackageSpecifications(listOf(spec))
val sdk = packagingService.currentSdk ?: return
val key = PyPackagingToolWindowService.packageKey(pkg.name)
if (!packagingService.markInstalling(sdk, key)) return
PyPackageCoroutine.launch(project, Dispatchers.IO) {
packagingService.installPackage(installRequest, workspaceMember = pkg.workspaceMember, dependencyGroup = pkg.dependencyGroup)
try {
packagingService.installPackage(installRequest, workspaceMember = pkg.workspaceMember, dependencyGroup = pkg.dependencyGroup)
}
finally {
packagingService.unmarkInstalling(sdk, key)
}
}
}
private fun installPackage(pkg: InstallablePackage) {
val spec = pkg.repository.findPackageSpecification(pyRequirement(pkg.name, null)) ?: return
val installRequest = PythonPackageInstallRequest.ByRepositoryPythonPackageSpecifications(listOf(spec))
val sdk = packagingService.currentSdk ?: return
// Reject a repeated click while this package is already installing on this SDK — otherwise every
// click fires another heavy install coroutine (PY-91529). The renderer greys the link in parallel.
val key = PyPackagingToolWindowService.packageKey(pkg.name)
if (!packagingService.markInstalling(sdk, key)) return
PyPackageCoroutine.launch(project, Dispatchers.IO) {
packagingService.installPackage(installRequest)
try {
packagingService.installPackage(installRequest)
}
finally {
packagingService.unmarkInstalling(sdk, key)
}
}
}
@@ -271,7 +271,18 @@ internal class PyPackageTreeCellRenderer(
appendTextPadding(padTo)
val leftOff = textLeftOffset()
if (showActions) {
if (packagesTree.isInstalling(pkg.name)) {
// Install in progress (started here, from the info pane, or from the dialog): show a greyed,
// non-clickable "Install" label with a spinner in place of the + icon, regardless of hover.
// linkStartX/linkEndX stay -1 so PyPackagesTree.handleLinkClick rejects the click (PY-91529).
val disabledAttributes = SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, UIUtil.getInactiveTextColor())
append(linkText, disabledAttributes)
appendTextPadding(padTo + textWidth + gap)
trailingIconX = leftOff + padTo + textWidth + gap
trailingIcon = AnimatedIcon.Default.INSTANCE
appendTextPadding(padTo + blockWidth)
}
else if (showActions) {
val linkStyle = if (packagesTree.linkHoveredRow == currentRow) SimpleTextAttributes.STYLE_UNDERLINE else SimpleTextAttributes.STYLE_PLAIN
val linkAttributes = SimpleTextAttributes(linkStyle, LINK_COLOR)
linkStartX = padTo
@@ -94,11 +94,18 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
private data class InstallTarget(val mode: DialogMode, val id: String)
/**
* Targets whose install we started and is still running. Package "installed" state is not stored —
* it is read live from the package manager (so transitive dependencies and real versions are
* always current).
* Shared active-installations key for [target]. Package (SEARCH) installs use the normalized
* package name so the key matches the tool-window tree and info pane; URL/path and command
* installs use namespaced keys the tool window never queries. The "is installing" state itself
* lives in the shared [PyPackagingToolWindowService] map (PY-91529) — not a dialog-local set — so
* an install started from the tree or the info pane also disables this dialog's control, and
* vice-versa. Package "installed" state is still read live from the package manager.
*/
private val installingTargets = HashSet<InstallTarget>()
private fun installKeyOf(target: InstallTarget): String = when (target.mode) {
DialogMode.SEARCH -> PyPackagingToolWindowService.packageKey(target.id)
DialogMode.DIRECT_INSTALL -> "location:${target.id}"
DialogMode.COMMAND -> "command:${target.id}"
}
/**
* Location (URL/path) targets that finished successfully. Unlike packages, these have no name to
@@ -252,6 +259,11 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
val balloon = popup
Disposer.register(project, balloon)
// Re-render the install control when the shared active-installations set changes, so an install
// started from the tool-window tree or info pane also shows INSTALLING here (PY-91529).
packagingService.addInstallStateListener(balloon) {
if (::popup.isInitialized && !popup.isDisposed) refreshInstallControl()
}
val initialMin = minimumSize
JBInsets.addTo(initialMin, balloon.content.insets)
balloon.setMinimumSize(initialMin)
@@ -621,7 +633,8 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
versionPanel.hideInstallStatus()
return
}
if (target in installingTargets) {
val sdk = packagingService.currentSdk
if (sdk != null && packagingService.isInstalling(sdk, installKeyOf(target))) {
versionPanel.applyInstallControlState(InstallControlState.INSTALLING, null)
return
}
@@ -657,8 +670,10 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
// Captured now (press time) from the current mode/selection, since the selection may change
// before the async install finishes.
val target = currentTarget() ?: return
val sdk = packagingService.currentSdk ?: return
val trace = com.jetbrains.python.TraceContext(title, null)
installingTargets.add(target)
val installKey = installKeyOf(target)
packagingService.markInstalling(sdk, installKey)
refreshInstallControl()
packagingService.serviceScope.launch {
@@ -680,7 +695,7 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
}.invokeOnCompletion {
ApplicationManager.getApplication().invokeLater(
{
installingTargets.remove(target)
packagingService.unmarkInstalling(sdk, installKey)
if (::popup.isInitialized && !popup.isDisposed) refreshInstallControl()
},
ModalityState.any(),
@@ -0,0 +1,65 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.toolwindow
import com.intellij.openapi.util.UserDataHolderBase
import com.jetbrains.python.allure.Layers
import com.jetbrains.python.allure.Subsystems
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@Subsystems.PackagingRequirements
@Layers.Functional
internal class PyActiveInstallsTest {
@Test
fun `package key is case-insensitive so the same package collapses to one entry`() {
assertEquals(PyActiveInstalls.packageKey("numpy"), PyActiveInstalls.packageKey("NumPy"))
}
@Test
fun `mark then unmark toggles installing state, normalized by package name`() {
val installs = PyActiveInstalls()
assertFalse(installs.isPackageInstalling("numpy"))
assertTrue(installs.mark(PyActiveInstalls.packageKey("numpy")))
// A differently-cased spelling of the same package is recognized as installing.
assertTrue(installs.isPackageInstalling("NumPy"))
assertTrue(installs.unmark(PyActiveInstalls.packageKey("numpy")))
assertFalse(installs.isPackageInstalling("numpy"))
}
@Test
fun `mark rejects a duplicate so a second click cannot start a parallel install`() {
val installs = PyActiveInstalls()
val key = PyActiveInstalls.packageKey("numpy")
assertTrue(installs.mark(key))
assertFalse(installs.mark(key))
}
@Test
fun `namespaced dialog keys are independent of package keys`() {
val installs = PyActiveInstalls()
installs.mark("location:https://example.com/pkg.whl")
assertTrue(installs.isInstalling("location:https://example.com/pkg.whl"))
assertFalse(installs.isPackageInstalling("pkg"))
}
@Test
fun `state is per-SDK - the same holder shares one instance, different holders are isolated`() {
val sdkA = UserDataHolderBase()
val sdkB = UserDataHolderBase()
// Same holder always resolves to the same instance (stored in its user data).
assertSame(PyActiveInstalls.of(sdkA), PyActiveInstalls.of(sdkA))
PyActiveInstalls.of(sdkA).mark(PyActiveInstalls.packageKey("numpy"))
// Installing numpy into sdkA must not read as installing into sdkB.
assertTrue(PyActiveInstalls.of(sdkA).isPackageInstalling("numpy"))
assertFalse(PyActiveInstalls.of(sdkB).isPackageInstalling("numpy"))
}
}