From 77bbf5696df0b86777d92ff6c65c1e7ed8459e13 Mon Sep 17 00:00:00 2001 From: Ivan Kulikov Date: Tue, 11 Aug 2026 14:40:33 +0300 Subject: [PATCH] PY-91529 clicking the PPTW install spinner opens Python Process Output The spinner that replaces the "+" icon while a package installs inherited that icon's hit-box, so clicking it still popped up the install dialog: the trailing icon hit-test in PyPackagesTree.handleLinkClick runs before the linkStartX guard the original fix relied on, and treated every trailing icon as an action. The renderer now publishes what its trailing icon means (TrailingIconKind ACTION or PROGRESS), so click handling and tooltips have to spell out both cases in an exhaustive when instead of assuming an action. A click on PROGRESS opens Python Process Output with the running install command preselected, and the tooltip offers that rather than "Install package". To make the command findable, the tree owns the TraceContext its install runs in (PyPackagingToolWindowService.installPackage accepts it instead of opening a nested one, so the uuid the tree holds is the one the spawned pip / uv process reports) and stores that uuid next to the install key in PyActiveInstalls. PyInstallPackageDialog stores the uuid of the trace it already owned, so the tree's spinner also works for an install started from the dialog. Out of scope: * an install started from the package details pane (PyPackageDescriptionController.wrapInvokeOp) carries no trace, because the trace is born inside the service below its suspend () -> Unit callback. Its spinner keeps a plain "Installing package X..." tooltip and a no-op click; threading a trace through that callback is a separate refactoring. * updatePackageToLatest gets no trace: renderInstalledPackage never consults isInstalling, so an installed row shows the uninstall icon or nothing, never a spinner. (cherry picked from commit 7c18eca608a99067fecfd48d0a41359f408e0097) IJ-MR-217932 GitOrigin-RevId: 9755d3bc2c6c19465ac4d9b08ee22205127cfe13 --- .../messages/PyBundle.properties | 1 + .../packaging/toolwindow/PyActiveInstalls.kt | 30 ++++-- .../PyPackagingToolWindowService.kt | 24 ++++- .../packages/tree/PyPackagesTree.kt | 69 +++++++++++--- .../renderers/PyPackageTreeCellRenderer.kt | 93 +++++++++++++------ .../toolwindow/ui/PyInstallPackageDialog.kt | 4 +- ...PackageTreeCellRendererTrailingIconTest.kt | 37 ++++++++ .../toolwindow/PyActiveInstallsTest.kt | 57 ++++++++++++ 8 files changed, 263 insertions(+), 52 deletions(-) create mode 100644 python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index e7531cc0a163..4ab2db6d8e2e 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1695,6 +1695,7 @@ python.toolwindow.packages.installed.from=Installed from: {0} python.toolwindow.packages.tooltip.change.version=Change version python.toolwindow.packages.tooltip.update.to=Update to {0} python.toolwindow.packages.tooltip.uninstall=Uninstall package +python.toolwindow.packages.tooltip.show.install.output=Show installation output INLAY.py.packaging.group.name=Add package to dependency group INLAY.py.packaging.group.description=Adds a clickable "+ Add package" inlay hint after dependency-group headers in pyproject.toml INLAY.py.packaging.group.add=+ Add package diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt index e9f802af03af..268f8c3d6ed3 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyActiveInstalls.kt @@ -9,7 +9,7 @@ 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 + * Per-SDK map 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 @@ -23,24 +23,38 @@ import java.util.concurrent.ConcurrentHashMap * Thread-safe. */ internal class PyActiveInstalls { - private val keys: MutableSet = ConcurrentHashMap.newKeySet() + private val installs: ConcurrentHashMap = ConcurrentHashMap() + + /** State of one running install. A value type because [ConcurrentHashMap] forbids null values. */ + private class ActiveInstall(val traceUuid: String?) /** `true` while an install keyed by exactly [key] is running. */ - fun isInstalling(key: String): Boolean = keys.contains(key) + fun isInstalling(key: String): Boolean = installs.containsKey(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) + /** + * Records [key], optionally tagging it with the uuid of the [com.jetbrains.python.TraceContext] the + * install runs in so its command can be located later (see [traceUuid]). Returns `false` if [key] + * was already recorded (so a repeated trigger is rejected); the uuid of the install that is + * actually running is kept in that case, never overwritten by the rejected caller. + */ + fun mark(key: String, traceUuid: String? = null): Boolean = installs.putIfAbsent(key, ActiveInstall(traceUuid)) == null - /** Clears [key]. Returns `false` if it was not recorded. */ - fun unmark(key: String): Boolean = keys.remove(key) + /** Clears [key] along with its trace uuid. Returns `false` if it was not recorded. */ + fun unmark(key: String): Boolean = installs.remove(key) != null + + /** + * Uuid of the trace the install under [key] runs in, or `null` when nothing is running under [key] + * or the install was started without a trace — not every surface owns one. + */ + fun traceUuid(key: String): String? = installs[key]?.traceUuid 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. */ + /** The [Sdk]'s active-installations map, 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. */ diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 4b3306eef5ea..4e7de0cdddcf 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -116,15 +116,24 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco /** `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() } + /** + * Records [key] as an active install on [sdk]. Returns `false` if already recorded (rejects a rapid re-trigger). + * + * [traceUuid] — uuid of the trace the install runs in, for callers that own one (see [installPackage]'s + * `trace` parameter). Stored so a surface showing the install as in progress can point the user at the + * running command's output; cleared again by [unmarkInstalling]. + */ + fun markInstalling(sdk: Sdk, key: String, traceUuid: String? = null): Boolean = + PyActiveInstalls.forSdk(sdk).mark(key, traceUuid).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() } + /** Uuid of the trace of the install running under [key] on [sdk], or `null` if unknown or nothing is running. */ + fun installTraceUuid(sdk: Sdk, key: String): String? = PyActiveInstalls.forSdk(sdk).traceUuid(key) + /** * 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 @@ -354,17 +363,24 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco } } + /** + * [trace] — when non-null the install runs directly in this trace instead of a fresh nested one, so + * the caller's uuid is the one the spawned pip / uv process reports. Callers that show their own + * in-progress UI need that: nothing between here and the process launcher adds another trace, so + * whichever trace wraps this call is the one the process is tagged with. + */ suspend fun installPackage( installRequest: PythonPackageInstallRequest, options: List = emptyList(), workspaceMember: PyWorkspaceMember? = null, dependencyGroup: PyDependencyGroup? = null, + trace: TraceContext? = null, ) { val context = sdkContext ?: return val managerUI = context.managerUI val module = workspaceMember?.let { context.manager.workspaceSupport?.resolveModule(it) } - withContext(TraceContext(message("trace.context.packaging.tool.window.install"))) { + withContext(trace ?: TraceContext(message("trace.context.packaging.tool.window.install"))) { PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project) managerUI.installPackagesRequestBackground(installRequest, options, module, dependencyGroup)?.let { handleActionCompleted( 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 26bd0951e74e..851ef366e94a 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 @@ -36,6 +36,7 @@ import com.jetbrains.python.packaging.toolwindow.model.WorkspaceMember import com.jetbrains.python.packaging.toolwindow.model.DependencyGroupNode import com.jetbrains.python.packaging.toolwindow.model.UndeclaredPackagesGroup import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.PyPackageTreeCellRenderer +import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.TrailingIconKind import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.asInstalledPackageOrNull import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.trailingIconTooltip import com.jetbrains.python.packaging.statistics.PyInstallDialogSource @@ -229,8 +230,14 @@ internal class PyPackagesTree( val trailingIconX = renderer.trailingIconX val trailingIcon = renderer.trailingIcon + val trailingIconKind = renderer.trailingIconKind val overTrailingIcon = trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth) - return if (overTrailingIcon) pkg.trailingIconTooltip() else null + if (!overTrailingIcon) return null + return when (trailingIconKind) { + TrailingIconKind.PROGRESS -> installSpinnerTooltip(pkg) + TrailingIconKind.ACTION -> pkg.trailingIconTooltip() + null -> null + } } private val hoverHandler = PyPackagesTreeHoverHandler(this) @@ -360,15 +367,22 @@ internal class PyPackagesTree( val trailingIconX = renderer.trailingIconX val trailingIcon = renderer.trailingIcon + val trailingIconKind = renderer.trailingIconKind if (trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth)) { - val handled = when (pkg) { - is InstalledPackage -> { setSelectionRow(row); handlePackageSelection(pkg); deletePackageInline(pkg); true } - is InstallablePackage -> { setSelectionRow(row); handlePackageSelection(pkg); showInstallDialog(pkg); true } - is RequirementPackage, - is UndeclaredPackagesGroup, - is DependencyGroupNode, - is WorkspaceMember, - is LoadingNode -> false + val handled = when (trailingIconKind) { + // A spinner is a progress indicator, not the action button whose hit-box it inherited: show the + // output of the install it stands for instead of re-opening the install dialog (PY-91529). + TrailingIconKind.PROGRESS -> { setSelectionRow(row); handlePackageSelection(pkg); showInstallOutput(pkg); true } + TrailingIconKind.ACTION -> when (pkg) { + is InstalledPackage -> { setSelectionRow(row); handlePackageSelection(pkg); deletePackageInline(pkg); true } + is InstallablePackage -> { setSelectionRow(row); handlePackageSelection(pkg); showInstallDialog(pkg); true } + is RequirementPackage, + is UndeclaredPackagesGroup, + is DependencyGroupNode, + is WorkspaceMember, + is LoadingNode -> false + } + null -> false } if (handled) return true } @@ -397,6 +411,36 @@ internal class PyPackagesTree( PyInstallPackageDialog(project).show(initialSearchText = pkg.name) } + /** + * Uuid of the trace the install running for [pkg] on the current SDK was started in, or `null` when + * nothing is installing or the install carries no trace (see [installSpinnerTooltip]). + */ + private fun installTraceUuid(pkg: DisplayablePackage): String? { + val sdk = packagingService.currentSdk ?: return null + return packagingService.installTraceUuid(sdk, PyPackagingToolWindowService.packageKey(pkg.name)) + } + + /** + * Opens Python Process Output with the command installing [pkg] preselected. Fire-and-forget: the + * query waits for the process to show up — clicking the spinner right after the link can beat the + * actual pip / uv launch — and gives up on its own timeout, so there is nothing to await here. + */ + private fun showInstallOutput(pkg: DisplayablePackage) { + val traceUuid = installTraceUuid(pkg) ?: return + PyPackageCoroutine.launch(project, Dispatchers.Default) { + sendProcessOutputQuery(ProcessOutputQuery.OpenToolWindowByTraceUuid(traceUuid)) + } + } + + /** + * Tooltip of the running-install spinner: offers the output when the install can be traced back to a + * command, otherwise just states what is going on — an install started from the package details pane + * has no trace to point at. + */ + private fun installSpinnerTooltip(pkg: DisplayablePackage): String = + if (installTraceUuid(pkg) != null) PyBundle.message("python.toolwindow.packages.tooltip.show.install.output") + else PyBundle.message("python.packaging.installing.package", pkg.name) + private fun updatePackageToLatest(pkg: InstalledPackage) { val versionString = pkg.nextVersion?.presentableText ?: return val requirement = pyRequirement(pkg.name, pyRequirementVersionSpec(versionString)) @@ -422,10 +466,13 @@ internal class PyPackagesTree( // 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 + // Own the trace rather than letting the service open a nested one: the uuid stored next to the key + // has to be the one the spawned pip / uv process reports, or clicking the spinner finds nothing. + val trace = TraceContext(PyBundle.message("python.packaging.installing.package", pkg.name), null) + if (!packagingService.markInstalling(sdk, key, trace.uuid.toString())) return PyPackageCoroutine.launch(project, Dispatchers.IO) { try { - packagingService.installPackage(installRequest) + packagingService.installPackage(installRequest, trace = trace) } 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 d33c904d0208..203819867479 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 @@ -42,12 +42,16 @@ internal class PyPackageTreeCellRenderer( private set private var currentRow: Int = -1 - /** Trailing action icon (delete or install) painted inline after the row content. */ + /** Trailing icon painted inline after the row content: a row action, or a running-install spinner. */ var trailingIcon: Icon? = null private set var trailingIconX: Int = -1 private set + /** What [trailingIcon] means for click handling and tooltips; `null` whenever [trailingIcon] is `null`. */ + var trailingIconKind: TrailingIconKind? = null + private set + /** Inline change-version icon painted right after the version text. */ var inlineChangeVersionIcon: Icon? = null private set @@ -74,6 +78,7 @@ internal class PyPackageTreeCellRenderer( currentRow = row trailingIcon = null trailingIconX = -1 + trailingIconKind = null inlineChangeVersionIcon = null inlineChangeVersionIconX = -1 toolTipText = null @@ -129,6 +134,16 @@ internal class PyPackageTreeCellRenderer( } } + /** + * Fills the trailing icon slot, keeping the icon and its [kind] in sync by construction — a `null` + * [icon] reserves the geometry without painting anything and leaves [trailingIconKind] `null`. + */ + private fun setTrailingIcon(x: Int, icon: Icon?, kind: TrailingIconKind) { + trailingIconX = x + trailingIcon = icon + trailingIconKind = icon?.let { kind } + } + private fun isAncestorOnlyMatch(pkg: DisplayablePackage): Boolean { val query = packagesTree.project.let { com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService.getInstance(it).activeSearchQuery @@ -225,8 +240,7 @@ internal class PyPackageTreeCellRenderer( rowIndent - cellChromeWidth() - DELETE_ICON.iconWidth - JBUI.scale(DefaultTreeUI.HORIZONTAL_SELECTION_OFFSET) val padTo = maxOf(viewportPadTo, minPadTo) appendTextPadding(padTo) - trailingIconX = textLeftOffset() + padTo - trailingIcon = if (showActions) DELETE_ICON else null + setTrailingIcon(textLeftOffset() + padTo, if (showActions) DELETE_ICON else null, TrailingIconKind.ACTION) appendTextPadding(padTo + DELETE_ICON.iconWidth) } @@ -271,32 +285,30 @@ internal class PyPackageTreeCellRenderer( appendTextPadding(padTo) val leftOff = textLeftOffset() - 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 - append(linkText, linkAttributes) - linkEndX = linkStartX + textWidth - appendTextPadding(padTo + textWidth + gap) - trailingIconX = leftOff + padTo + textWidth + gap - trailingIcon = INSTALL_ICON - appendTextPadding(padTo + blockWidth) - } - else { - trailingIconX = leftOff + padTo + textWidth + gap - appendTextPadding(padTo + blockWidth) + val iconX = leftOff + padTo + textWidth + gap + when (chooseInstallableTrailingIconKind(isInstalling = packagesTree.isInstalling(pkg.name), showActions = showActions)) { + TrailingIconKind.PROGRESS -> { + // 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 the text half is inert; the spinner itself opens the + // installation output instead of the install dialog (PY-91529). + val disabledAttributes = SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, UIUtil.getInactiveTextColor()) + append(linkText, disabledAttributes) + appendTextPadding(padTo + textWidth + gap) + setTrailingIcon(iconX, AnimatedIcon.Default.INSTANCE, TrailingIconKind.PROGRESS) + } + TrailingIconKind.ACTION -> { + val linkStyle = if (packagesTree.linkHoveredRow == currentRow) SimpleTextAttributes.STYLE_UNDERLINE else SimpleTextAttributes.STYLE_PLAIN + val linkAttributes = SimpleTextAttributes(linkStyle, LINK_COLOR) + linkStartX = padTo + append(linkText, linkAttributes) + linkEndX = linkStartX + textWidth + appendTextPadding(padTo + textWidth + gap) + setTrailingIcon(iconX, INSTALL_ICON, TrailingIconKind.ACTION) + } + null -> setTrailingIcon(iconX, null, TrailingIconKind.ACTION) } + appendTextPadding(padTo + blockWidth) } private fun rightAlignPadding(depth: Int, blockWidth: Int, iconWidth: Int): Int { @@ -369,3 +381,28 @@ internal fun chooseInlineChangeVersionIcon( else -> null } +/** + * What the trailing icon of a row means, so that everything hit-testing it (click handling, + * tooltips) has to spell out both cases instead of assuming every trailing icon is actionable. + */ +internal enum class TrailingIconKind { + /** A row action the user invokes: install, uninstall. */ + ACTION, + + /** A running-install spinner; clicking it shows that install's output rather than acting on the row. */ + PROGRESS, +} + +/** + * Chooses what the trailing icon of an installable row stands for, `null` for "no icon". + * + * Extracted from the renderer so the precedence can be unit-tested without a Swing tree: a running + * install outranks the hover state, so the row shows a spinner instead of an install icon even while + * hovered (PY-91529). + */ +internal fun chooseInstallableTrailingIconKind(isInstalling: Boolean, showActions: Boolean): TrailingIconKind? = when { + isInstalling -> TrailingIconKind.PROGRESS + showActions -> TrailingIconKind.ACTION + else -> null +} + 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 2a242705c0fc..d57cb8446637 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt @@ -673,7 +673,9 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI val sdk = packagingService.currentSdk ?: return val trace = com.jetbrains.python.TraceContext(title, null) val installKey = installKeyOf(target) - packagingService.markInstalling(sdk, installKey) + // [trace] is the one the spawned process reports (this path calls the manager UI directly), so the + // spinner the packages tree shows for this install can open its output on click (PY-91529). + packagingService.markInstalling(sdk, installKey, trace.uuid.toString()) refreshInstallControl() packagingService.serviceScope.launch { diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt new file mode 100644 index 000000000000..c796c790f0e0 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/renderers/PyPackageTreeCellRendererTrailingIconTest.kt @@ -0,0 +1,37 @@ +// 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.python.junit5Tests.unit.packaging.renderers + +import com.intellij.idea.TestFor +import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.TrailingIconKind +import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.chooseInstallableTrailingIconKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +@TestFor(issues = ["PY-91529"]) +internal class PyPackageTreeCellRendererTrailingIconTest { + + @Test + fun `a running install outranks the hover state so the row shows a spinner, not an install icon`() { + val kind = chooseInstallableTrailingIconKind(isInstalling = true, showActions = true) + assertEquals(TrailingIconKind.PROGRESS, kind, "hovering a row whose install is running must not turn the spinner back into an action") + } + + @Test + fun `the spinner is shown even when the row is not hovered`() { + val kind = chooseInstallableTrailingIconKind(isInstalling = true, showActions = false) + assertEquals(TrailingIconKind.PROGRESS, kind, "progress must stay visible regardless of hover state") + } + + @Test + fun `hovering an idle row offers the install action`() { + val kind = chooseInstallableTrailingIconKind(isInstalling = false, showActions = true) + assertEquals(TrailingIconKind.ACTION, kind) + } + + @Test + fun `no icon when nothing is running and the row is not hovered`() { + val kind = chooseInstallableTrailingIconKind(isInstalling = false, showActions = false) + assertNull(kind) + } +} diff --git a/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt b/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt index 513e68656d17..6ad53f6ff28b 100644 --- a/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt +++ b/python/testSrc/com/jetbrains/python/packaging/toolwindow/PyActiveInstallsTest.kt @@ -1,11 +1,13 @@ // 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.idea.TestFor 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.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -62,4 +64,59 @@ internal class PyActiveInstallsTest { assertTrue(PyActiveInstalls.of(sdkA).isPackageInstalling("numpy")) assertFalse(PyActiveInstalls.of(sdkB).isPackageInstalling("numpy")) } + + @Test + @TestFor(issues = ["PY-91529"]) + fun `mark keeps the trace uuid so the running command can be located`() { + val installs = PyActiveInstalls() + val key = PyActiveInstalls.packageKey("numpy") + + assertTrue(installs.mark(key, "trace-1")) + assertEquals("trace-1", installs.traceUuid(key)) + } + + @Test + @TestFor(issues = ["PY-91529"]) + fun `an install started without a trace has no uuid but still counts as installing`() { + val installs = PyActiveInstalls() + val key = PyActiveInstalls.packageKey("numpy") + + assertTrue(installs.mark(key)) + assertTrue(installs.isInstalling(key)) + assertNull(installs.traceUuid(key)) + } + + @Test + @TestFor(issues = ["PY-91529"]) + fun `unmark clears the uuid so it cannot leak into the next install of the same package`() { + val installs = PyActiveInstalls() + val key = PyActiveInstalls.packageKey("numpy") + + installs.mark(key, "trace-1") + assertTrue(installs.unmark(key)) + assertNull(installs.traceUuid(key)) + + // Re-installing the same package without a trace must not resurrect the previous run's uuid. + installs.mark(key) + assertNull(installs.traceUuid(key)) + } + + @Test + @TestFor(issues = ["PY-91529"]) + fun `a rejected duplicate keeps the uuid of the install that is actually running`() { + val installs = PyActiveInstalls() + val key = PyActiveInstalls.packageKey("numpy") + + assertTrue(installs.mark(key, "trace-1")) + assertFalse(installs.mark(key, "trace-2")) + assertEquals("trace-1", installs.traceUuid(key)) + } + + @Test + @TestFor(issues = ["PY-91529"]) + fun `traceUuid of a key that is not installing is null`() { + val installs = PyActiveInstalls() + + assertNull(installs.traceUuid(PyActiveInstalls.packageKey("numpy"))) + } }