mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
a59f351ad8
commit
ec17d60871
@@ -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.
|
||||
|
||||
+44
-6
@@ -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)
|
||||
|
||||
+34
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user