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