From ad7ef1a738d6c5de9ed0402828dc4ecfb5698762 Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Tue, 4 Aug 2026 19:34:40 +0200 Subject: [PATCH] PY-91438 Show install progress and installed state in package install dialog GitOrigin-RevId: d32875ce05d2e68be29b49367514433a38740ff7 --- .../messages/PyBundle.properties | 3 + .../ui/PyInstallDialogVersionPanel.kt | 97 +++++++++++++ .../toolwindow/ui/PyInstallPackageDialog.kt | 130 ++++++++++++++++-- 3 files changed, 216 insertions(+), 14 deletions(-) diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 090ae277f17b..e7531cc0a163 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -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} diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallDialogVersionPanel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallDialogVersionPanel.kt index 44fdd6e155ed..64e8cad31c6f 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallDialogVersionPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallDialogVersionPanel.kt @@ -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 "

${StringUtil.escapeXmlEntities(summary)}

" return "

${message("python.toolwindow.packages.no.description.placeholder")}

" } + + 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. * 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 6a7afcd7f4bc..d4b0daa2902f 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt @@ -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() + + /** + * 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() + /** * 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(), + ) } }