From ec17d60871bea982b00595860aa293ddeb963afc Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Mon, 10 Aug 2026 18:08:37 +0200 Subject: [PATCH] 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 --- .../packaging/toolwindow/PyActiveInstalls.kt | 53 +++++++++++++++ .../PyPackagingToolWindowService.kt | 49 ++++++++++++++ .../details/PyPackageDescriptionController.kt | 50 ++++++++++++-- .../packages/tree/PyPackagesTree.kt | 36 +++++++++- .../renderers/PyPackageTreeCellRenderer.kt | 13 +++- .../toolwindow/ui/PyInstallPackageDialog.kt | 29 +++++++-- .../toolwindow/PyActiveInstallsTest.kt | 65 +++++++++++++++++++ 7 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt create mode 100644 python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt new file mode 100644 index 000000000000..e9f802af03af --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt @@ -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 = 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::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 + } +} diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 44e99a89d851..4b3306eef5ea 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -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() + + /** `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() + /** + * 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. diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt index 3dcafadd1a15..3f91426d5a25 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt @@ -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) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt index 31665849f80c..26bd0951e74e 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt @@ -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) + } } } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt index 2c42ca8e4786..d33c904d0208 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreeCellRenderer.kt @@ -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 diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt index d4b0daa2902f..2a242705c0fc 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt @@ -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() + 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(), diff --git a/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt b/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt new file mode 100644 index 000000000000..513e68656d17 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt @@ -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")) + } +}