PY-91438 Show install progress and installed state in package install dialog

GitOrigin-RevId: d32875ce05d2e68be29b49367514433a38740ff7
This commit is contained in:
Vitaly Legchilkin
2026-08-05 13:51:04 +00:00
committed by intellij-monorepo-bot
parent 6d14fcd5d8
commit ad7ef1a738
3 changed files with 216 additions and 14 deletions
@@ -1669,6 +1669,9 @@ python.packaging.install.dialog.select.dependency.group=Select Dependency Group
python.packaging.install.dialog.editable=Editable (-e)
python.packaging.install.dialog.show.description=Show package description
python.packaging.install.dialog.install=Install
python.packaging.install.dialog.installed=Installed
python.packaging.install.dialog.installed.version=Installed {0}
python.packaging.install.dialog.installation.failed=Installation failed
python.packaging.install.dialog.version=Version: {0}
python.packaging.install.dialog.select.version=Select Version
python.packaging.install.dialog.install.failed=Package installation failed: {0}
@@ -12,10 +12,13 @@ import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.NlsContexts
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.text.StringUtil
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.JBValue
import com.intellij.util.ui.NamedColorUtil
import com.intellij.util.ui.UIUtil
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.Result
@@ -87,6 +90,24 @@ internal class PyInstallDialogVersionPanel(
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addActionListener { onInstall() }
}
/**
* Replaces [installButton] once an install of the current target finishes (green "Installed" /
* red "Installation failed"). Mutually exclusive with the button in the controls row; hidden by
* default. Driven by [applyInstallControlState].
*
* Floors its height to the button's so swapping button↔label keeps the controls row the same
* height — otherwise the shorter label shrinks the FlowLayout row and top-aligns it, shifting the
* whole row (including the version chooser) upward.
*/
private val installStatusLabel: JBLabel = object : JBLabel() {
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
size.height = maxOf(size.height, installButton.preferredSize.height)
return size
}
}.apply { isVisible = false }
val descriptionToggle: JToggleButton = object : JToggleButton(AllIcons.Actions.PreviewDetailsVertically) {
init {
toolTipText = message("python.packaging.install.dialog.show.description")
@@ -134,6 +155,7 @@ internal class PyInstallDialogVersionPanel(
val controlsPanel = JPanel(FlowLayout(FlowLayout.RIGHT, UIUtil.getRegularPanelInsets().left, 0)).apply { isOpaque = false }
controlsPanel.add(versionButton)
controlsPanel.add(editableCheckbox)
controlsPanel.add(installStatusLabel)
controlsPanel.add(installButton)
mainPanel.add(controlsPanel, BorderLayout.EAST)
@@ -205,6 +227,72 @@ internal class PyInstallDialogVersionPanel(
applyViewState(presenter.onSelectionCleared())
}
/**
* Drives the install-control slot from a per-target state the host computes.
* [InstallControlState.INSTALLABLE] leaves the ordinary button and an interactive version
* selector; [InstallControlState.INSTALLING] disables the button (spinner) and locks the version
* selector; [InstallControlState.INSTALLED] / [InstallControlState.FAILED] hide the button and
* the selector and show a status label in their place — the installed version, when known, is
* folded into the "Installed" text. [hideInstallStatus] is used when no target is active.
*/
fun applyInstallControlState(state: InstallControlState, installedVersion: String?) {
when (state) {
InstallControlState.INSTALLABLE -> {
setVersionSelectionEnabled(true)
installButton.icon = null
installButton.disabledIcon = null
installButton.isVisible = true
installStatusLabel.isVisible = false
}
InstallControlState.INSTALLING -> {
setVersionSelectionEnabled(false)
// Set disabledIcon too: a disabled JButton paints its disabledIcon, so pointing that at the
// AnimatedIcon is what keeps the spinner animating while the button is greyed out.
installButton.icon = AnimatedIcon.Default.INSTANCE
installButton.disabledIcon = AnimatedIcon.Default.INSTANCE
installButton.isEnabled = false
installButton.isVisible = true
installStatusLabel.isVisible = false
}
InstallControlState.INSTALLED -> {
versionButton.isVisible = false
val text = if (installedVersion != null) message("python.packaging.install.dialog.installed.version", installedVersion)
else message("python.packaging.install.dialog.installed")
showStatus(AllIcons.General.InspectionsOK, text, SUCCESS_FOREGROUND)
}
InstallControlState.FAILED -> {
versionButton.isVisible = false
showStatus(AllIcons.General.Error, message("python.packaging.install.dialog.installation.failed"), NamedColorUtil.getErrorForeground())
}
}
}
/** Hides the status label without forcing the button visible — used when no install target is active. */
fun hideInstallStatus() {
setVersionSelectionEnabled(true)
installButton.icon = null
installButton.disabledIcon = null
installStatusLabel.isVisible = false
}
private var versionSelectionEnabled = true
/** Locks/unlocks the version selector (used while an install is running): greys the text and ignores clicks. */
private fun setVersionSelectionEnabled(enabled: Boolean) {
versionSelectionEnabled = enabled
versionButton.cursor = Cursor.getPredefinedCursor(if (enabled) Cursor.HAND_CURSOR else Cursor.DEFAULT_CURSOR)
val fg = if (enabled) UIUtil.getLabelForeground() else UIUtil.getInactiveTextColor()
for (child in versionButton.components) (child as? JBLabel)?.foreground = fg
}
private fun showStatus(icon: javax.swing.Icon, @Nls text: String, fg: java.awt.Color) {
installButton.isVisible = false
installStatusLabel.icon = icon
installStatusLabel.text = text
installStatusLabel.foreground = fg
installStatusLabel.isVisible = true
}
suspend fun notifyInstallError(msg: @NlsContexts.NotificationContent String) {
withContext(Dispatchers.EDT) {
NotificationGroupManager.getInstance()
@@ -290,6 +378,7 @@ internal class PyInstallDialogVersionPanel(
}
private fun showVersionPopup(component: JComponent) {
if (!versionSelectionEnabled) return
if (selectedPackageName == null) return
loadVersionsJob?.cancel()
loadVersionsJob = packagingService.serviceScope.launch {
@@ -342,8 +431,16 @@ internal class PyInstallDialogVersionPanel(
if (!summary.isNullOrEmpty()) return "<html><body><p>${StringUtil.escapeXmlEntities(summary)}</p></body></html>"
return "<html><body><p>${message("python.toolwindow.packages.no.description.placeholder")}</p></body></html>"
}
private companion object {
// Green "Installed" foreground; falls back to a green pair if the L&F doesn't define the key.
private val SUCCESS_FOREGROUND: JBColor = JBColor.namedColor("Label.successForeground", JBColor(0x368746, 0x5FAD65))
}
}
/** State of the install-control slot for the currently active target. @see PyInstallDialogVersionPanel.applyInstallControlState */
internal enum class InstallControlState { INSTALLABLE, INSTALLING, INSTALLED, FAILED }
/**
* How the install dialog should turn a package's `description` field into HTML for the JCEF panel.
*
@@ -3,49 +3,50 @@ package com.jetbrains.python.packaging.toolwindow.ui
import com.intellij.icons.AllIcons
import com.intellij.ide.actions.BigPopupUI
import com.intellij.python.pytools.PyTool
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.readAction
import com.intellij.openapi.components.service
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptor
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.ui.components.panels.Wrapper
import com.intellij.ui.components.SearchFieldWithExtension
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.readAction
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.WindowStateService
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.wm.WindowManager
import com.intellij.python.processOutput.common.ProcessOutputQuery
import com.intellij.python.processOutput.common.sendProcessOutputQuery
import com.intellij.python.pytools.PyTool
import com.jetbrains.python.packaging.PyPackageVersionNormalizer
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.ScreenUtil
import com.intellij.ui.awt.RelativePoint
import com.intellij.ui.components.JBList
import com.intellij.ui.components.SearchFieldWithExtension
import com.intellij.ui.components.fields.ExtendableTextComponent
import com.intellij.ui.components.panels.Wrapper
import com.intellij.ui.scale.JBUIScale
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.statistics.PythonPackagesToolwindowStatisticsCollector
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.findFirstPythonSdk
import com.jetbrains.python.sdk.findModuleForSdk
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.packaging.PyPackageVersionNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.io.IOException
import org.jetbrains.annotations.Nls
import java.awt.BorderLayout
@@ -83,12 +84,28 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
)
private val resultsList = PyInstallDialogResultsList(
project, packagingService,
onPackageSelected = { name, repo -> versionPanel.selectPackage(name, repo) },
onPackageSelected = { name, repo -> versionPanel.selectPackage(name, repo); refreshInstallControl() },
onCommandSelected = { cmd -> mySearchField.text = "$cmd "; mySearchField.requestFocusInWindow() },
onSelectionCleared = { versionPanel.clearSelection() },
onResultsUpdated = { _ -> },
)
/** Identity of an install target: the mode plus its payload (package name, URL/path, or command). */
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).
*/
private val installingTargets = HashSet<InstallTarget>()
/**
* Location (URL/path) targets that finished successfully. Unlike packages, these have no name to
* look up in the manager, so their completion is tracked here to show "Installed".
*/
private val finishedLocationInstalls = HashSet<InstallTarget>()
/**
* File-chooser descriptor for the "Browse for a local distribution" action in the search field.
*
@@ -152,7 +169,9 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
listScrollPane = scroll
resultsList.attachToScroll(scroll)
val refocus = object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) { mySearchField.requestFocusInWindow() }
override fun mousePressed(e: MouseEvent) {
mySearchField.requestFocusInWindow()
}
}
resultsList.list.addMouseListener(refocus)
scroll.viewport.addMouseListener(refocus)
@@ -274,6 +293,9 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
ViewType.FULL -> Unit
}
showPopup()
// Render installed state for already-installed packages up front (no-op until the SDK is ready —
// ensureSdkInitialized refreshes once it loads).
refreshInstalledState()
SwingUtilities.invokeLater {
SwingUtilities.getRootPane(versionPanel.installButton)?.defaultButton = versionPanel.installButton
}
@@ -412,6 +434,7 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
val sdk = readAction { project.findFirstPythonSdk() }
?: return@launch
packagingService.initForSdk(sdk)
refreshInstalledState()
withContext(Dispatchers.EDT) {
updatePlaceholder()
val q = mySearchField.text.trim()
@@ -548,6 +571,9 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
versionPanel.installButton.isEnabled = view.installButtonEnabled
versionPanel.installButton.isVisible = view.installButtonVisible
bottomContainer.isVisible = view.bottomContainerVisible
// Overlay the tracked per-target state (spinner / installed) on top of the base button the view
// just set. Runs before refreshPopupSize so the swapped-in control is measured.
refreshInstallControl()
if (strategy.collapseToShort(context)) updateViewType(ViewType.SHORT)
refreshPopupSize()
@@ -565,6 +591,59 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
}
}
/**
* Identity of the install target the bottom bar currently represents, or `null` if none (search
* mode with no selected package). Packages carry their normalized name; URL/path and command
* installs carry their raw text — the [InstallTarget.mode] keeps the three kinds distinct.
*/
private fun currentTarget(): InstallTarget? = when (currentMode) {
DialogMode.SEARCH -> versionPanel.selectedPackageName?.let { InstallTarget(DialogMode.SEARCH, PyPackageName.from(it).name) }
DialogMode.DIRECT_INSTALL -> InstallTarget(DialogMode.DIRECT_INSTALL, directInstallText)
DialogMode.COMMAND -> InstallTarget(DialogMode.COMMAND, mySearchField.text.trim())
}
/**
* Installed version of the currently selected package, read live from the package manager's
* snapshot (updated by `installPackage`, so it reflects fresh installs and transitive deps), or
* `null` when the package isn't installed / not applicable.
*/
private fun installedVersionOfSelected(): String? {
val name = versionPanel.selectedPackageName?.let { PyPackageName.from(it).name } ?: return null
val sdk = packagingService.currentSdk ?: return null
return PythonPackageManager.forSdk(project, sdk).listInstalledPackagesSnapshot()
.firstOrNull { it.name == name }?.version?.takeIf { it.isNotEmpty() }
}
/** Renders the bottom-bar control for the current target: installing (ours) → installed (from the manager) → installable. */
private fun refreshInstallControl() {
val target = currentTarget()
if (target == null) {
versionPanel.hideInstallStatus()
return
}
if (target in installingTargets) {
versionPanel.applyInstallControlState(InstallControlState.INSTALLING, null)
return
}
// Packages: installed state + version read live from the manager. Location installs: no name to
// look up, so fall back to the local "finished" set (version unknown → plain "Installed").
val installedVersion = if (currentMode == DialogMode.SEARCH) installedVersionOfSelected() else null
val installed = installedVersion != null || target in finishedLocationInstalls
val state = if (installed) InstallControlState.INSTALLED else InstallControlState.INSTALLABLE
versionPanel.applyInstallControlState(state, installedVersion)
}
/** Warms up the manager's installed-packages snapshot, then re-renders (state/version are read live). */
private fun refreshInstalledState() {
packagingService.serviceScope.launch {
val sdk = packagingService.currentSdk ?: return@launch
PythonPackageManager.forSdk(project, sdk).listInstalledPackages() // waits for init so the snapshot is populated
withContext(Dispatchers.EDT) {
if (::popup.isInitialized && !popup.isDisposed) refreshInstallControl()
}
}
}
/**
* Runs an install/command [block] in the service scope under a fresh trace.
*
@@ -574,15 +653,38 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
* pull focus away from the dialog (PY-89838 follow-up).
*/
private fun runWithTrace(@Nls title: String, block: suspend () -> Boolean) {
// No install target means nothing to run (search mode with no selected package); bail out.
// Captured now (press time) from the current mode/selection, since the selection may change
// before the async install finishes.
val target = currentTarget() ?: return
val trace = com.jetbrains.python.TraceContext(title, null)
installingTargets.add(target)
refreshInstallControl()
packagingService.serviceScope.launch {
val success = withContext(trace) { block() }
if (!success) {
if (success) {
// Location installs can't be found in the manager by name, so remember completion locally
// (the completion handler below re-renders and picks it up as "Installed").
if (target.mode == DialogMode.DIRECT_INSTALL) {
withContext(Dispatchers.EDT) { finishedLocationInstalls.add(target) }
}
}
else {
// Failure keeps today's behavior: close the dialog and surface the logs.
withContext(Dispatchers.EDT) { popup.cancel() }
sendProcessOutputQuery(
ProcessOutputQuery.OpenToolWindowByTraceUuid(trace.uuid.toString())
)
}
}.invokeOnCompletion {
ApplicationManager.getApplication().invokeLater(
{
installingTargets.remove(target)
if (::popup.isInitialized && !popup.isDisposed) refreshInstallControl()
},
ModalityState.any(),
)
}
}