diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml index 202c17b2e9b2..b1c10ed7e045 100644 --- a/python/pluginResources/intellij.python.community.impl.xml +++ b/python/pluginResources/intellij.python.community.impl.xml @@ -164,7 +164,6 @@ - @@ -755,7 +754,7 @@ - diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 7f8787f0e04d..fb4bb7b6d209 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1625,7 +1625,7 @@ debugger.dap.plugin.install.got.it.header=Try the Python DAP Debugger plugin debugger.dap.plugin.install.got.it.text=Install the Python DAP Debugger plugin for faster and more reliable Python debugging with debugpy action.PyInstallPackage.text=Install -action.PyInstallPackageAction.text=Advanced package install +action.PyInstallPackageAction.text=Advanced Package Install\u2026 separator.python.packaging.settings=Settings action.PyInstallFromDiskPackage.text=Install From Disk\u2026 action.InstallFromVcsPackageAction.text=Install From VCS\u2026 @@ -1640,6 +1640,8 @@ package.install.with.options.dialog.message=Options: package.install.with.options.dialog.title=Package Install with Options python.toolwindow.packages.collapse.all.action=Collapse All python.toolwindow.packages.reload.packages.action=Reload Packages +python.toolwindow.packages.move.to.right.action=Move to Right +python.toolwindow.packages.move.to.bottom.action=Move to Bottom python.toolwindow.packages.interpreters.action=Interpreters... python.toolwindow.packages.repositories.action=Repositories... python.toolwindow.packages.settings.group=Settings diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt b/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt index 8147097c22ec..ab38720548a8 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/PyProjectToml.kt @@ -6,7 +6,6 @@ import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.vfs.readText import com.intellij.psi.PsiManager import com.intellij.psi.util.CachedValue import com.intellij.psi.util.CachedValueProvider @@ -131,7 +130,7 @@ data class PyProjectToml( return readAction { val psiFile = PsiManager.getInstance(project).findFile(pyProjectFile) ?: return@readAction null CachedValuesManager.getManager(project).getCachedValue(psiFile, CACHE_KEY, { - CachedValueProvider.Result.create(parse(pyProjectFile.readText()), pyProjectFile) + CachedValueProvider.Result.create(parse(psiFile.text), psiFile) }, false) } } diff --git a/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt b/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt index bf440d37e4ec..d14d903e0220 100644 --- a/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt +++ b/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt @@ -21,8 +21,8 @@ internal class CondaRepositoryManger(override val project: Project, val sdk: Sdk override val builtInRepositories: List get() = listOf(CondaPackageRepository) + pipRepositoryManger.builtInRepositories - override val repositories: List - get() = listOf(CondaPackageRepository) + pipRepositoryManger.repositories + override val allRepositories: List + get() = listOf(CondaPackageRepository) + pipRepositoryManger.allRepositories private val condaPackageCache = service() diff --git a/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt b/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt index 1bd337823eac..47f69546ad86 100644 --- a/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt +++ b/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt @@ -18,7 +18,23 @@ import org.jetbrains.annotations.CheckReturnValue internal interface PythonRepositoryManager { val project: Project - val repositories: List + + /** + * The raw list of repositories owned by this manager, including any the user has disabled in + * Settings. Implementations override this; consumers should normally use [repositories] + * (below) instead, which filters out disabled entries so search / package-existence checks + * automatically respect the user's opt-out. Only touch [allRepositories] directly when the + * UX genuinely needs to expose or manipulate disabled repositories (e.g. the repository + * settings editor). + */ + val allRepositories: List + + /** + * Every enabled repository from [allRepositories]. Filtered centrally so individual query + * paths (search, `hasPackageSnapshot`, …) can't forget to skip disabled entries — an easy + * mistake to make and the reason PY-91041 shipped in the first place. + */ + val repositories: List get() = allRepositories.filter { it.enabled } /** * Built-in repositories that are always shown in the repository settings (e.g. PyPI, Conda). diff --git a/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt b/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt index e6a0356c0875..b78d9fd28305 100644 --- a/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt +++ b/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt @@ -24,7 +24,7 @@ import java.time.Duration @Service(Service.Level.PROJECT) internal class PipRepositoryManager(override val project: Project) : PythonRepositoryManagerBase() { - override val repositories: List + override val allRepositories: List get() = listOf(PyPiPackageRepository) + service().repositories private val packageDetailsCache = Caffeine.newBuilder() diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt new file mode 100644 index 000000000000..8f357f8c6fa9 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt @@ -0,0 +1,82 @@ +// 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.actionSystem.ActionToolbar +import com.intellij.openapi.ui.shortenTextWithEllipsis +import com.intellij.openapi.util.NlsSafe +import com.intellij.openapi.util.text.HtmlChunk +import com.intellij.openapi.wm.ToolWindow +import com.intellij.ui.ColorUtil +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.GraphicsUtil +import com.intellij.util.ui.UIUtil +import org.jetbrains.annotations.Nls +import java.awt.Font + +/** + * Renders the "Python Packages" tool-window title with the active interpreter path appended in + * a lighter (context-help) foreground. The path start-ellipsizes when the header is too narrow + * to fit it in full — the tail (`.venv/bin/python`) is what distinguishes environments, so the + * leading directories are the ones that give way. + * + * The renderer piggybacks on the fact that the platform id-label is a plain `JLabel`; wrapping + * the pieces in HTML with inline styles keeps everything inside the label the platform already + * paints, so we avoid custom components on the header. + * + * The renderer is EDT-affine — call [update] whenever the active SDK changes and [refit] on + * tool-window resize (or let a caller invoke [refit] directly from a component listener). If + * the tool window is not resolvable (test environments, disposed project), calls become no-ops. + */ +internal class PyInterpreterHeaderTitleRenderer( + private val toolWindow: ToolWindow, + @Nls private val plainTitle: String, +) { + private var currentPath: String? = null + + @RequiresEdt + fun update(path: String?) { + currentPath = path?.takeIf { it.isNotEmpty() } + refit() + } + + @RequiresEdt + fun refit() { + val path = currentPath + if (path == null) { + toolWindow.stripeTitle = plainTitle + return + } + toolWindow.stripeTitle = renderHtml(fitPathToHeader(path)) + } + + private fun fitPathToHeader(path: String): @NlsSafe String { + val width = toolWindow.component.width.takeIf { it > 0 } ?: return path + val font = UIUtil.getLabelFont() + val budget = (width + - GraphicsUtil.stringWidth(plainTitle, font.deriveFont(Font.BOLD)) + - GraphicsUtil.stringWidth(" ", font) + - RIGHT_TOOLBAR_RESERVED_PX + ).coerceAtLeast(0) + return shortenTextWithEllipsis( + text = path, + minTextPrefixLength = 0, + minTextSuffixLength = 1, + maxTextPrefixRatio = 0f, + maxTextWidth = budget, + getTextWidth = { GraphicsUtil.stringWidth(it, font) }, + useEllipsisSymbol = true, + ) + } + + private fun renderHtml(fittedPath: @NlsSafe String): @Nls String { + val greyHex = ColorUtil.toHtmlColor(UIUtil.getContextHelpForeground()) + val title = HtmlChunk.text(plainTitle).bold() + val path = HtmlChunk.text(fittedPath).wrapWith(HtmlChunk.span("font-weight:normal;color:$greyHex")) + return HtmlChunk.html().children(title, HtmlChunk.nbsp(2), path).toString() + } + + companion object { + /** Width of the right-side header toolbar (3 action buttons and spaces). */ + private val RIGHT_TOOLBAR_RESERVED_PX: Int get() = 5 * ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE.width + } +} diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowDefaultAnchorMigration.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowDefaultAnchorMigration.kt deleted file mode 100644 index 6bd32e563957..000000000000 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowDefaultAnchorMigration.kt +++ /dev/null @@ -1,43 +0,0 @@ -// 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.ide.util.PropertiesComponent -import com.intellij.openapi.project.Project -import com.intellij.openapi.startup.ProjectActivity -import com.intellij.openapi.wm.ToolWindowAnchor -import com.intellij.toolWindow.ToolWindowDefaultLayoutManager - -private const val MIGRATION_FLAG_KEY = "python.packages.toolwindow.default.anchor.migrated.v2" -private const val TOOL_WINDOW_ID = "Python Packages" - -/** - * The Python Packages tool window now defaults to the **right** stripe (alongside AI Assistant / - * Database), but the app-level [ToolWindowDefaultLayoutManager] caches whatever stripe the user - * was last on across projects. When the branch shipped earlier intermediate registrations on the - * `bottom` and `left` stripes, that cache picked them up — and every freshly-created project then - * inherited the wrong stripe, no matter what the plugin XML or [PyToolWindowLayoutProvider] - * declare. - * - * This activity runs once per IDE install: if the active default layout has the Python Packages - * window pinned to anything other than RIGHT, force it back to RIGHT and re-save. Existing - * per-project [workspace.xml] entries still win (they are read before the default layout), so a - * project where the user has explicitly moved the tool window will keep its custom position; only - * new projects (and projects without saved layout for this id) are affected. - */ -internal class PyPackagesToolWindowDefaultAnchorMigration : ProjectActivity { - override suspend fun execute(project: Project) { - val properties = PropertiesComponent.getInstance() - if (properties.getBoolean(MIGRATION_FLAG_KEY)) return - - val manager = ToolWindowDefaultLayoutManager.getInstance() - val layout = manager.getLayoutCopy() - val info = layout.getInfo(TOOL_WINDOW_ID) - if (info != null && (info.anchor != ToolWindowAnchor.RIGHT || !info.isSplit)) { - info.anchor = ToolWindowAnchor.RIGHT - info.isSplit = true - info.sideWeight = 0.5f - manager.setLayout(layout) - } - properties.setValue(MIGRATION_FLAG_KEY, true) - } -} diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt index 481acc47cc4c..e526702f27a2 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt @@ -4,7 +4,11 @@ package com.jetbrains.python.packaging.toolwindow import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.application.EDT import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.openapi.components.service @@ -18,11 +22,15 @@ import com.intellij.openapi.wm.ToolWindowAnchor import com.intellij.openapi.wm.ToolWindowManager import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.openapi.wm.impl.content.ToolWindowContentUi -import com.intellij.ui.components.fields.ExtendableTextComponent import com.intellij.ui.OnePixelSplitter +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.UnscaledGaps +import com.intellij.ui.dsl.gridLayout.UnscaledGapsY import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.jetbrains.python.PyBundle.message +import com.jetbrains.python.packaging.toolwindow.actions.PyTogglePackagingToolWindowAnchorAction import com.jetbrains.python.packaging.toolwindow.details.PyPackageInfoPanel import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage import com.jetbrains.python.packaging.toolwindow.model.PyPackagesViewData @@ -37,7 +45,10 @@ import org.intellij.lang.annotations.Language import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.Nls import java.awt.BorderLayout +import java.awt.Cursor import java.awt.KeyboardFocusManager +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingUtilities @@ -72,6 +83,7 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple private val listWithSearchPanel by lazy { buildListWithSearch() } private val contentPanel: JPanel + private var headerTitleRenderer: PyInterpreterHeaderTitleRenderer? = null internal var contentVisible: Boolean get() = contentPanel.isVisible @@ -112,13 +124,24 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple fun getSelectedPackage(): DisplayablePackage? = packageListController.getSelectedPackages().firstOrNull() private fun setupToolWindowTitleActions() { - val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Python Packages") ?: return + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(PY_PACKAGES_TOOL_WINDOW_ID) ?: return // Show the default "Python Packages" id-label in the header instead of the SDK dropdown. // SDK can still be picked from elsewhere; the id-label is more discoverable as the title. toolWindow.component.putClientProperty(ToolWindowContentUi.DONT_HIDE_TOOLBAR_IN_HEADER, true) val gearActions = ActionManager.getInstance().getAction(ADDITIONAL_PACKAGE_TOOLBAR_ACTION_ID) as ActionGroup toolWindow.setAdditionalGearActions(gearActions) + toolWindow.setTitleActions(listOf(PyTogglePackagingToolWindowAnchorAction())) + + headerTitleRenderer = PyInterpreterHeaderTitleRenderer( + toolWindow = toolWindow, + plainTitle = message("toolwindow.stripe.Python_Packages_Tool"), + ) + addComponentListener(object : ComponentAdapter() { + override fun componentResized(e: ComponentEvent) { + SwingUtilities.invokeLater { headerTitleRenderer?.refit() } + } + }) } private fun createContentPanel(): JComponent { @@ -159,7 +182,7 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple } private fun isToolWindowHorizontal(): Boolean { - val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Python Packages") + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(PY_PACKAGES_TOOL_WINDOW_ID) ?: return false val anchor = toolWindow.anchor return anchor == ToolWindowAnchor.BOTTOM || anchor == ToolWindowAnchor.TOP @@ -174,21 +197,39 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple } private fun createSearchBar(): JComponent { - packageSearchController.addExtension( - ExtendableTextComponent.Extension.create( - PyPackageIcons.AddPackage, - message("action.PyInstallPackageAction.text"), - Runnable { - PyInstallPackageDialog(project).show(packageSearchController.text.trim().takeIf { it.isNotEmpty() }) - } - ) - ) + val bundledAction = ActionManager.getInstance().getAction(PY_INSTALL_PACKAGE_ACTION_ID) + val installAction = object : DumbAwareAction( + message("action.PyInstallPackageAction.text"), + null, + PyPackageIcons.AddPackage, + ) { + override fun actionPerformed(e: AnActionEvent) { + PyInstallPackageDialog(project).show(packageSearchController.text.trim().takeIf { it.isNotEmpty() }) + } + }.apply { + bundledAction?.shortcutSet?.let { shortcutSet = it } + } + val toolbar = ActionManager.getInstance().createActionToolbar( + ActionPlaces.TOOLWINDOW_CONTENT, + DefaultActionGroup(installAction), + true, + ).apply { + setReservePlaceAutoPopupIcon(false) + component.border = JBUI.Borders.empty() + component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } val hPad = UIUtil.getListCellHPadding() val vPad = UIUtil.getListCellVPadding() - return JPanel(BorderLayout()).apply { - border = JBUI.Borders.empty(vPad, hPad) - add(packageSearchController, BorderLayout.CENTER) + val searchBarPanel = panel { + row { + cell(packageSearchController).align(AlignX.FILL).resizableColumn().customize(UnscaledGaps.EMPTY) + cell(toolbar.component).customize(UnscaledGaps(left = hPad)) + }.customize(UnscaledGapsY.EMPTY) + }.apply { + border = JBUI.Borders.empty(vPad, hPad, vPad, hPad) } + toolbar.targetComponent = searchBarPanel + return searchBarPanel } private fun trackModules() { @@ -232,6 +273,11 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple packageListController.startSdkInit() } + @RequiresEdt + fun setInterpreterPath(path: String?) { + headerTitleRenderer?.update(path) + } + internal fun setRefreshIndicatorVisible(visible: Boolean) { packageListController.setLoadingState(visible) } @@ -272,8 +318,12 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple override fun dispose() {} companion object { + internal const val PY_PACKAGES_TOOL_WINDOW_ID: String = "Python Packages" @Language("devkit-action-id") private const val ADDITIONAL_PACKAGE_TOOLBAR_ACTION_ID = "PyPackageToolbarAdditional" + + @Language("devkit-action-id") + private const val PY_INSTALL_PACKAGE_ACTION_ID = "PyInstallPackageAction" } } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index fbeb08736cbd..300cea899a34 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -38,6 +38,7 @@ import com.jetbrains.python.packaging.common.PythonOutdatedPackage import com.jetbrains.python.packaging.common.PythonPackage import com.jetbrains.python.packaging.common.PythonPackageDetails import com.jetbrains.python.packaging.common.PythonPackageManagementListener +import com.jetbrains.python.sdk.pyInterpreterPresentation import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification import com.jetbrains.python.packaging.conda.CondaPackage import com.jetbrains.python.packaging.conda.CondaPackageRepository @@ -376,6 +377,7 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco toolWindowPanel?.let { it.packageListController.setLoadingState(false) it.contentVisible = false + it.setInterpreterPath(null) } } showNoInterpreterMessage() @@ -385,6 +387,7 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco withContext(Dispatchers.EDT) { toolWindowPanel?.let { it.startLoadingSdk(sdk.name) + it.setInterpreterPath(sdk.pyInterpreterPresentation().fullName) it.syncSdkControllerSelection(sdk) } } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt index 47c13240fa35..6dcaa75176f4 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt @@ -260,11 +260,10 @@ internal class PyPackagingTreeView( private fun createNewRepository(repository: PyPackageRepository, items: List, moreItems: Int, sortedAll: List) { val newTree = PyPackagesTree(project, controller) - newTree.items = items - if (sortedAll.size > items.size) newTree.primeSortedMatches(sortedAll) - newTree.pendingMore = moreItems val newTreeGroup = PyPackagingTreeGroup(repository, newTree, container, showHeader = true, useTreeNodeHeader = true) newTreeGroup.items = items + if (sortedAll.size > items.size) newTree.primeSortedMatches(sortedAll) + newTree.pendingMore = moreItems repositories.add(newTreeGroup) newTreeGroup.addTo(uninstalledContainerPanel) newTree.addTreeSelectionListener { diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt b/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt new file mode 100644 index 000000000000..436ab79b52e4 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt @@ -0,0 +1,45 @@ +// 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.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowAnchor +import com.intellij.openapi.wm.ToolWindowManager +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowPanel + +internal class PyTogglePackagingToolWindowAnchorAction : DumbAwareAction() { + + override fun update(e: AnActionEvent) { + val toolWindow = findToolWindow(e) + if (toolWindow == null) { + e.presentation.isEnabledAndVisible = false + return + } + e.presentation.isEnabledAndVisible = true + val (icon, text) = if (toolWindow.anchor == ToolWindowAnchor.RIGHT) { + AllIcons.Actions.MoveToBottomRight to PyBundle.message("python.toolwindow.packages.move.to.bottom.action") + } + else { + AllIcons.Actions.MoveToRightBottom to PyBundle.message("python.toolwindow.packages.move.to.right.action") + } + e.presentation.icon = icon + e.presentation.text = text + } + + override fun actionPerformed(e: AnActionEvent) { + val toolWindow = findToolWindow(e) ?: return + val target = if (toolWindow.anchor == ToolWindowAnchor.RIGHT) ToolWindowAnchor.BOTTOM else ToolWindowAnchor.RIGHT + toolWindow.setAnchor(target, null) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + private fun findToolWindow(e: AnActionEvent): ToolWindow? { + val project = e.project ?: return null + return ToolWindowManager.getInstance(project).getToolWindow(PyPackagingToolWindowPanel.PY_PACKAGES_TOOL_WINDOW_ID) + } +} diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt b/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt index 0734dcf92203..b40ad20f0c78 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt @@ -25,7 +25,9 @@ import com.jetbrains.python.packaging.utils.PyPackageCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import com.intellij.psi.PsiElement +import com.intellij.psi.PsiErrorElement import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil import com.intellij.ui.JBColor import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI @@ -68,9 +70,24 @@ internal class PyDependencyGroupInlayHintsProvider : InlayHintsProvider= 0 } ?: return null val rowBounds = getRowBounds(row) ?: return null if (event.y < rowBounds.y || event.y >= rowBounds.y + rowBounds.height) return null - val pkg = packageAtRow(row).asInstalledPackageOrNull() ?: return null + val pkg = packageAtRow(row) ?: return null val node = getPathForRow(row).lastPathComponent as DefaultMutableTreeNode val renderer = cellRenderer.getTreeCellRendererComponent( this, node, isPathSelected(getPathForRow(row)), isExpanded(row), model.isLeaf(node), row, hasFocus() @@ -195,25 +196,25 @@ internal class PyPackagesTree( renderer.setSize(rowBounds.width, rowBounds.height) val relativeX = event.x - rowBounds.x - val changeIconX = renderer.inlineChangeVersionIconX - val changeIcon = renderer.inlineChangeVersionIcon - if (changeIconX > 0 && changeIcon != null && relativeX in changeIconX..(changeIconX + changeIcon.iconWidth)) { - val next = pkg.nextVersion?.presentableText - return if (next != null && pkg.canBeUpdated) { - PyBundle.message("python.toolwindow.packages.tooltip.update.to", next) - } - else { - PyBundle.message("python.toolwindow.packages.tooltip.change.version") + val installedPkg = pkg.asInstalledPackageOrNull() + if (installedPkg != null) { + val changeIconX = renderer.inlineChangeVersionIconX + val changeIcon = renderer.inlineChangeVersionIcon + if (changeIconX > 0 && changeIcon != null && relativeX in changeIconX..(changeIconX + changeIcon.iconWidth)) { + val next = installedPkg.nextVersion?.presentableText + return if (next != null && installedPkg.canBeUpdated) { + PyBundle.message("python.toolwindow.packages.tooltip.update.to", next) + } + else { + PyBundle.message("python.toolwindow.packages.tooltip.change.version") + } } } val trailingIconX = renderer.trailingIconX val trailingIcon = renderer.trailingIcon - if (trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth)) { - return PyBundle.message("python.toolwindow.packages.tooltip.uninstall") - } - - return null + val overTrailingIcon = trailingIconX > 0 && trailingIcon != null && relativeX in trailingIconX..(trailingIconX + trailingIcon.iconWidth) + return if (overTrailingIcon) pkg.trailingIconTooltip() else null } private val hoverHandler = PyPackagesTreeHoverHandler(this) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreePresenter.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreePresenter.kt index 1cf65a144819..36ae10696a4a 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreePresenter.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTreePresenter.kt @@ -1,6 +1,7 @@ // 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.packages.tree.renderers +import com.jetbrains.python.PyBundle import com.jetbrains.python.packaging.toolwindow.model.DependencyGroupNode import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage @@ -62,3 +63,15 @@ internal fun DisplayablePackage?.asInstalledPackageOrNull(): InstalledPackage? = null, -> null } + +/** Tooltip for the trailing action icon in a tree row, or `null` if the row type has no action. */ +internal fun DisplayablePackage.trailingIconTooltip(): String? = when (this) { + is InstallablePackage -> PyBundle.message("action.PyInstallPackageAction.text") + is InstalledPackage -> PyBundle.message("python.toolwindow.packages.tooltip.uninstall") + is RequirementPackage, + is WorkspaceMember, + is LoadingNode, + is DependencyGroupNode, + is UndeclaredPackagesGroup, + -> null +} 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 1ff40dc009cf..bcf837fcb19a 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallDialogVersionPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallDialogVersionPanel.kt @@ -164,8 +164,14 @@ internal class PyInstallDialogVersionPanel( selectedPackageName = name packageInfoLabel.text = "$name ($repoName)" packageInfoLabel.icon = PyPackageIcons.PackageGray - resetVersionState(repoName) + // Order matters: ask the presenter for the new view state (which snapshots documentation + // + version list for the *newly* selected package) BEFORE clearing local state. The old + // sequence — `resetVersionState` first, then `onPackageSelected` — meant the presenter + // was called after we had wiped `selectedPackageName` / `availableVersions`, so on a + // rapid re-select (typing filters the list, selection follows) the documentation pane + // briefly rendered the previous package while the version state was mid-reset. val state = presenter.onPackageSelected(descriptionToggle.isSelected) + resetVersionState(repoName) applyViewState(state) } 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 7ffa657bb02f..ea0648cf13fb 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyInstallPackageDialog.kt @@ -121,6 +121,8 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI private var balloonFullSize: Dimension? = null private var collapsedSize: Dimension? = null + private var isOpeningFileBrowser = false + override fun createList(): JBList = resultsList.list override fun createCellRenderer(): ListCellRenderer = resultsList.renderer @@ -206,7 +208,8 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI .createComponentPopupBuilder(this, mySearchField) .setProject(project) .setModalContext(false) - .setCancelOnWindowDeactivation(false) + .setCancelOnWindowDeactivation(true) + .setCancelCallback { !isOpeningFileBrowser } .setCancelOnClickOutside(true) .setRequestFocus(true) .setResizable(true) @@ -421,8 +424,14 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI } private fun openFileBrowser() { - val file = FileChooser.chooseFile(packageFileDescriptor, project, null) - if (file != null) mySearchField.text = file.path + isOpeningFileBrowser = true + try { + val file = FileChooser.chooseFile(packageFileDescriptor, project, null) + if (file != null) mySearchField.text = file.path + } + finally { + isOpeningFileBrowser = false + } } private fun setupSearchListener() { diff --git a/python/src/com/jetbrains/python/requirements/HtmlBuffer.kt b/python/src/com/jetbrains/python/requirements/HtmlBuffer.kt new file mode 100644 index 000000000000..de2d9d70dc1a --- /dev/null +++ b/python/src/com/jetbrains/python/requirements/HtmlBuffer.kt @@ -0,0 +1,30 @@ +// 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.requirements + +/** + * HTML string builder with a compile-visible escape boundary — [text] escapes XML entities, + * [raw] appends verbatim. Splitting the two APIs stops the double-escape / missed-escape + * mistake Ilya's PY-89838 review flagged: at every call site the caller has to pick which + * method to invoke, so the escaping story is explicit at every point instead of relying on + * convention. + * + * `toString` is the read side used by the documentation provider — it must return the + * accumulated HTML, not the default `Object` identity string. That single method is what + * PY-91067 exercised: before the override existed, the Quick Doc tooltip printed + * `HtmlBuffer@` verbatim. + */ +internal class HtmlBuffer { + private val buffer = StringBuilder() + + fun text(value: String): HtmlBuffer { + buffer.append(value.replace("&", "&").replace("<", "<").replace(">", ">")) + return this + } + + fun raw(value: String): HtmlBuffer { + buffer.append(value) + return this + } + + override fun toString(): String = buffer.toString() +} diff --git a/python/src/com/jetbrains/python/requirements/RequirementsDocumentationProvider.kt b/python/src/com/jetbrains/python/requirements/RequirementsDocumentationProvider.kt index 28ff054f3edf..6933a1e96171 100644 --- a/python/src/com/jetbrains/python/requirements/RequirementsDocumentationProvider.kt +++ b/python/src/com/jetbrains/python/requirements/RequirementsDocumentationProvider.kt @@ -284,25 +284,6 @@ internal class RequirementDocumentationTarget( replace("&", "&").replace("<", "<").replace(">", ">") } -/** - * HTML string builder with a compile-visible escape boundary — [text] escapes XML entities, - * [raw] appends verbatim. Splitting the two APIs stops the double-escape / missed-escape mistake - * Ilya's review flagged: at every call site you have to pick which one to invoke, so the - * escaping story is explicit at every point instead of relying on convention. - */ -private class HtmlBuffer { - private val buffer = StringBuilder() - - fun text(value: String): HtmlBuffer { - buffer.append(value.replace("&", "&").replace("<", "<").replace(">", ">")) - return this - } - - fun raw(value: String): HtmlBuffer { - buffer.append(value) - return this - } -} /** * Routes our two link schemes: diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt index 8965ed4619d9..1e4f9a3ed731 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryPackageManager.kt @@ -30,6 +30,7 @@ import com.jetbrains.python.packaging.packageRequirements.TreeParser import com.jetbrains.python.packaging.packageRequirements.collectAllNames import com.jetbrains.python.packaging.pip.PipRepositoryManager import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.pyproject.PY_PROJECT_TOML_DEPENDENCY_GROUPS import com.intellij.python.pyproject.PyProjectToml import com.jetbrains.python.poetry.POETRY_LOCK import com.jetbrains.python.sdk.pySdkAdditionalData @@ -276,15 +277,26 @@ internal class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageM } /** - * Returns Poetry dependency group names from `[tool.poetry.group..dependencies]` sections. - * Always includes "main" as the first entry (representing `[tool.poetry.dependencies]`). + * Returns Poetry dependency group names. Merges every group source Poetry itself recognises + * (Poetry 2.x accepts PEP 621 / PEP 735 alongside the legacy Poetry layout): + * - "main" — implicit `[tool.poetry.dependencies]` / `[project].dependencies` group; + * - "dev" — legacy `[tool.poetry.dev-dependencies]` shortcut (Poetry <2.0), still installable via + * `poetry install --with dev`; + * - `[tool.poetry.group..dependencies]` — Poetry-native group layout; + * - `[dependency-groups]` keys — PEP 735 layout; + * - `[project.optional-dependencies]` keys — PEP 621 extras, installable with `poetry install --extras`. + * + * Order: "main" first, then legacy dev, Poetry-native groups, PEP 735 groups, PEP 621 extras. + * Duplicates de-duplicated so a group declared in multiple spots surfaces once. */ @ApiStatus.Internal private fun PyProjectToml.getPoetryGroupNames(): List { - val toolTable = toml.getTable("tool") ?: return PyProjectToml.DEFAULT_GROUP_NAMES - val poetryTable = toolTable.getTable("poetry") ?: return PyProjectToml.DEFAULT_GROUP_NAMES - val groupTable = poetryTable.getTable("group") ?: return PyProjectToml.DEFAULT_GROUP_NAMES - return PyProjectToml.DEFAULT_GROUP_NAMES + groupTable.keySet().toList() + val poetryTable = toml.getTable("tool")?.getTable("poetry") + val legacyDev = listOfNotNull("dev".takeIf { poetryTable?.getTable("dev-dependencies") != null }) + val poetryGroups = poetryTable?.getTable("group")?.keySet().orEmpty() + val pep735Groups = toml.getTable(PY_PROJECT_TOML_DEPENDENCY_GROUPS)?.keySet().orEmpty() + val pep621Extras = project.dependencies.optional.keys + return (PyProjectToml.DEFAULT_GROUP_NAMES + legacyDev + poetryGroups + pep735Groups + pep621Extras).distinct() } private class PoetryWorkspaceSupport(private val project: Project, private val sdk: Sdk) : PythonWorkspaceSupport { diff --git a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt index 4aafebd93c67..64070362abb7 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -163,7 +163,7 @@ private class UvLowLevelImpl

( } override suspend fun listProjectStructureTree(): PyResult { - val out = uvCli.runUv(cwd, venvPath, false, "tree", "--frozen", "--no-dedupe") + val out = uvCli.runUv(cwd, venvPath, false, "tree", "--frozen", "--no-dedupe", "--all-groups") .getOr { return it } return PyExecResult.success(out) diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/HtmlBufferTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/HtmlBufferTest.kt new file mode 100644 index 000000000000..e9796f9f8dfe --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/HtmlBufferTest.kt @@ -0,0 +1,84 @@ +// 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 + +import com.jetbrains.python.requirements.HtmlBuffer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * PY-91067: the Quick Doc tooltip for `requirements.txt` used to render the accumulator's + * `Object.toString()` identity (`HtmlBuffer@57f54152`) because `HtmlBuffer` never overrode + * `toString()`. These tests pin the accumulator's behavior: `toString` must return the + * accumulated HTML, `text` must escape user-supplied strings, `raw` must emit verbatim, and + * the two paths must compose in call order. + */ +internal class HtmlBufferTest { + + @Test + fun `toString returns accumulated content, not the Object identity`() { + val buffer = HtmlBuffer().text("hello").raw("
").text("world") + + val html = buffer.toString() + + assertEquals("hello
world", html) + // Guard against the regression itself — the identity string looks like `HtmlBuffer@57f54152`. + assertFalse( + html.startsWith("com.jetbrains.python.requirements.HtmlBuffer@") + || html.startsWith("HtmlBuffer@"), + "toString must not return the default Object identity string; got `$html`", + ) + } + + @Test + fun `toString on an empty buffer returns an empty string`() { + assertEquals("", HtmlBuffer().toString()) + } + + @Test + fun `text escapes XML special characters`() { + val html = HtmlBuffer() + .text("") + .toString() + + assertEquals("<script>alert('x')</script>", html) + } + + @Test + fun `text escapes an ampersand exactly once`() { + val html = HtmlBuffer().text("Rock & Roll").toString() + + assertEquals("Rock & Roll", html) + assertFalse(html.contains("&amp;"), "double-escape regression check") + } + + @Test + fun `raw preserves markup verbatim`() { + val html = HtmlBuffer().raw("bold").toString() + + assertEquals("bold", html) + } + + @Test + fun `text and raw compose in call order`() { + val name = "" + val html = HtmlBuffer() + .raw("") + .text(name) + .raw("") + .toString() + + assertEquals("<foo & bar>", html) + assertTrue(html.startsWith(""), "raw prefix survives verbatim") + assertTrue(html.endsWith(""), "raw suffix survives verbatim") + } + + @Test + fun `builder methods return the same instance for chaining`() { + val buffer = HtmlBuffer() + + assertTrue(buffer.text("a") === buffer, "text must return the receiver for fluent chaining") + assertTrue(buffer.raw("b") === buffer, "raw must return the receiver for fluent chaining") + } +} diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyDependencyGroupInlayParseErrorGateTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyDependencyGroupInlayParseErrorGateTest.kt new file mode 100644 index 000000000000..93f75ec1c3e6 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyDependencyGroupInlayParseErrorGateTest.kt @@ -0,0 +1,90 @@ +// 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 + +import com.intellij.openapi.application.readAction +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.junit5.fixture.projectFixture +import com.jetbrains.python.packaging.toolwindow.marker.PyDependencyGroupInlayHintsProvider +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.toml.lang.psi.TomlFileType + +/** + * PY-91037: the "+ Add package" inlay must not appear while `pyproject.toml` is syntactically + * broken. `PyDependencyGroupInlayHintsProvider.getCollectorFor` gates the entire file on + * [PyDependencyGroupInlayHintsProvider.hasParseErrors]; these tests pin the gate. + * + * The regression was: TOML parser recovery preserves a valid-looking `TomlKeySegment` for a + * bare `test` line under `[dependency-groups]`. The segment-level resolver happily classifies + * it as a group, so the inlay was drawn — but clicking it eventually shelled out to + * `uv add` / `poetry add`, both of which refused the malformed file with a raw stderr trace. + * Better no affordance than one that corrupts the file. + */ +@TestApplication +internal class PyDependencyGroupInlayParseErrorGateTest { + private val projectFixture = projectFixture() + private val project get() = projectFixture.get() + + @Test + fun `well-formed pyproject with dependency groups has no parse errors`() = timeoutRunBlocking { + val file = readAction { tomlFile( + """ + [project] + name = "pkg" + + [dependency-groups] + test = ["pytest"] + lint = ["ruff"] + """.trimIndent() + ) } + + assertFalse(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) }, + "complete PEP 735 groups must not gate the inlay") + } + + @Test + fun `incomplete PEP 735 dependency group entry is treated as parse error`() = timeoutRunBlocking { + val file = readAction { tomlFile( + """ + [dependency-groups] + test + """.trimIndent() + ) } + + assertTrue(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) }, + "bare `test` line without `=` must trip the inlay gate — this is the PY-91037 regression") + } + + @Test + fun `unclosed inline array under dependency-groups is a parse error`() = timeoutRunBlocking { + val file = readAction { tomlFile( + """ + [dependency-groups] + test = ["pytest", + """.trimIndent() + ) } + + assertTrue(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) }, + "half-written array literal must trip the inlay gate") + } + + @Test + fun `unclosed table header trips the inlay gate`() = timeoutRunBlocking { + val file = readAction { tomlFile( + """ + [dependency-groups + test = ["pytest"] + """.trimIndent() + ) } + + assertTrue(readAction { PyDependencyGroupInlayHintsProvider.hasParseErrors(file) }, + "missing `]` on the header must trip the inlay gate") + } + + private fun tomlFile(text: String): PsiFile = + PsiFileFactory.getInstance(project).createFileFromText("pyproject.toml", TomlFileType, text) +} diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PyInstallDialogSearchAggregatorTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PyInstallDialogSearchAggregatorTest.kt index 899dc36486e0..4ba44109520f 100644 --- a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PyInstallDialogSearchAggregatorTest.kt +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PyInstallDialogSearchAggregatorTest.kt @@ -46,7 +46,7 @@ private class FakeRepository( } private class FakeRepositoryManager( - override val repositories: List, + override val allRepositories: List, var awaitReadyCalled: Boolean = false, ) : PythonRepositoryManager { override val project: Project get() = error("Project unused in this test") @@ -79,7 +79,7 @@ internal class PyInstallDialogSearchAggregatorTest { @Test fun `awaitReady is called before searching`() = runTest { - val manager = FakeRepositoryManager(repositories = emptyList()) + val manager = FakeRepositoryManager(allRepositories =emptyList()) aggregateInstallDialogSearch(manager, extraRepositories = emptyList(), query = "anything") @@ -89,7 +89,7 @@ internal class PyInstallDialogSearchAggregatorTest { @Test fun `built-in repositories are searched through the manager`() = runTest { val pypi = FakeRepository("PyPI", "https://pypi.org/simple", packages = listOf("requests", "requests-mock", "flask")) - val manager = FakeRepositoryManager(repositories = listOf(pypi)) + val manager = FakeRepositoryManager(allRepositories =listOf(pypi)) val result = aggregateInstallDialogSearch(manager, extraRepositories = emptyList(), query = "request") @@ -101,7 +101,7 @@ internal class PyInstallDialogSearchAggregatorTest { fun `enabled extra repository not already covered is searched directly`() = runTest { val pypi = FakeRepository("PyPI", "https://pypi.org/simple", packages = listOf("flask")) val internalRepo = FakeRepository("Internal", "https://repo.internal/simple", packages = listOf("internal-tool", "unrelated")) - val manager = FakeRepositoryManager(repositories = listOf(pypi)) + val manager = FakeRepositoryManager(allRepositories =listOf(pypi)) val result = aggregateInstallDialogSearch(manager, extraRepositories = listOf(internalRepo), query = "internal") @@ -113,7 +113,7 @@ internal class PyInstallDialogSearchAggregatorTest { fun `extra repository with same url as a built-in is not searched twice`() = runTest { val pypi = FakeRepository("PyPI", "https://pypi.org/simple", packages = listOf("requests")) val duplicate = FakeRepository("PyPI mirror", "https://pypi.org/simple", packages = listOf("requests")) - val manager = FakeRepositoryManager(repositories = listOf(pypi)) + val manager = FakeRepositoryManager(allRepositories =listOf(pypi)) val result = aggregateInstallDialogSearch(manager, extraRepositories = listOf(duplicate), query = "requests") @@ -124,7 +124,7 @@ internal class PyInstallDialogSearchAggregatorTest { @Test fun `disabled extra repository is skipped`() = runTest { val internalRepo = FakeRepository("Internal", "https://repo.internal/simple", enabled = false, packages = listOf("internal-tool")) - val manager = FakeRepositoryManager(repositories = emptyList()) + val manager = FakeRepositoryManager(allRepositories =emptyList()) val result = aggregateInstallDialogSearch(manager, extraRepositories = listOf(internalRepo), query = "internal") diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PythonRepositoryManagerFilteringTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PythonRepositoryManagerFilteringTest.kt new file mode 100644 index 000000000000..4d85c5cffdd8 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PythonRepositoryManagerFilteringTest.kt @@ -0,0 +1,148 @@ +// 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.repository + +import com.intellij.openapi.project.Project +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.packaging.PyPackageName +import com.jetbrains.python.packaging.PyPackageVersion +import com.jetbrains.python.packaging.PyRequirement +import com.jetbrains.python.packaging.cache.PythonPackageSearchPage +import com.jetbrains.python.packaging.cache.PythonPackageSearchResult +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonRepositoryPackageSpecification +import com.jetbrains.python.packaging.management.PythonRepositoryManager +import com.jetbrains.python.packaging.repository.PyPackageRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * PY-91041: disabled repositories used to keep contributing packages to the PPTW list and the + * install-dialog search because `searchPackages` / `hasPackageSnapshot` iterated the raw + * repository list without checking [PyPackageRepository.enabled]. The fix promoted the filter + * into the interface — the `repositories` property is a default getter over + * `allRepositories.filter { it.enabled }` — so every query path picks up the exclusion + * automatically. + * + * These tests pin the resulting contract: + * - the default `repositories` getter hides disabled entries; + * - `searchPackages(needle)` returns only enabled repositories; + * - `hasPackageSnapshot(name)` short-circuits over disabled repositories; + * - toggling `enabled` at runtime is picked up without rebuilding the manager. + */ +internal class PythonRepositoryManagerFilteringTest { + + @Test + fun `default repositories getter filters out disabled entries`() { + val enabled = fakeRepo("PyPI", packages = listOf("requests"), enabled = true) + val disabled = fakeRepo("Internal", packages = listOf("internal-tool"), enabled = false) + val manager = FilteringFakeRepositoryManager(listOf(enabled, disabled)) + + assertEquals(listOf(enabled, disabled), manager.allRepositories, "raw list is the source of truth") + assertEquals(listOf(enabled), manager.repositories, "filtered view hides disabled entries") + } + + @Test + fun `searchPackages skips disabled repositories`() { + val enabled = fakeRepo("PyPI", packages = listOf("requests", "requests-mock"), enabled = true) + val disabled = fakeRepo("Internal", packages = listOf("requests-internal"), enabled = false) + val manager = FilteringFakeRepositoryManager(listOf(enabled, disabled)) + + val hits = manager.searchPackages(needle = "requests") + + assertEquals(setOf(enabled), hits.keys, "disabled repo must not appear in search results") + assertFalse(disabled in hits.keys, "regression guard for PY-91041") + assertEquals(listOf("requests", "requests-mock"), hits.getValue(enabled).firstPage()) + } + + @Test + fun `hasPackageSnapshot short-circuits over disabled repositories`() { + val enabled = fakeRepo("PyPI", packages = listOf("flask"), enabled = true) + val disabled = fakeRepo("Internal", packages = listOf("internal-only"), enabled = false) + val manager = FilteringFakeRepositoryManager(listOf(enabled, disabled)) + + assertTrue(manager.hasPackageSnapshot("flask"), "enabled repo still contributes existence checks") + assertFalse(manager.hasPackageSnapshot("internal-only"), "disabled repo must not answer existence checks") + } + + @Test + fun `re-enabling a repository restores its results without rebuilding the manager`() { + val toggle = fakeRepo("Toggle", packages = listOf("secret"), enabled = false) + val manager = FilteringFakeRepositoryManager(listOf(toggle)) + + assertTrue(manager.repositories.isEmpty(), "disabled at start — nothing to search") + assertFalse(manager.hasPackageSnapshot("secret")) + + toggle.enabled = true + + assertEquals(listOf(toggle), manager.repositories, "flip flows through the default filter") + assertTrue(manager.hasPackageSnapshot("secret"), "flip is picked up on the next query") + } + + private fun fakeRepo(name: String, packages: List, enabled: Boolean): ToggleableFakeRepository = + ToggleableFakeRepository(name, url = "https://$name.example.test/simple", enabled = enabled, packages = packages) + + private fun PythonPackageSearchResult.firstPage(): List { + val page = pages.firstOrNull() ?: return emptyList() + val result = page.contents() + return (result as? Result.Success>)?.result ?: emptyList() + } +} + +/** + * Test-only [PythonRepositoryManager] that returns its constructor argument verbatim as + * [allRepositories]. Everything else is left to the interface defaults — that is the whole + * point of the test: the default `repositories` / `searchPackages` / `hasPackageSnapshot` + * paths must apply the `enabled` filter without any help from the implementation. + */ +private class FilteringFakeRepositoryManager( + override val allRepositories: List, +) : PythonRepositoryManager { + override val project: Project get() = error("Project unused in this test") + + override suspend fun getPackageDetails(packageName: String, repository: PyPackageRepository?): PyResult = + error("not used in this test") + override suspend fun getLatestVersion(packageName: String, repository: PyPackageRepository?): PyPackageVersion? = + error("not used in this test") + override suspend fun getVersions(packageName: String, repository: PyPackageRepository?): List? = + error("not used in this test") + override suspend fun refreshCaches() = error("not used in this test") + override suspend fun initCaches() = error("not used in this test") + override suspend fun findPackageSpecification(requirement: PyRequirement, repository: PyPackageRepository?): PythonRepositoryPackageSpecification? = + error("not used in this test") +} + +/** + * Minimal test repository — an in-memory package set searched by prefix normalisation, plus a + * flip-able `enabled` flag to exercise runtime toggles without going through the persistent + * `PyPackageRepositories` service. Distinct name from the aggregator test's `FakeRepository` + * to avoid a same-package top-level clash. + */ +private class ToggleableFakeRepository( + name: String, + url: String, + enabled: Boolean, + private val packages: List, +) : PyPackageRepository(name, url, null) { + + init { + this.enabled = enabled + } + + override fun search(needle: String, pageSize: Int): PythonPackageSearchResult { + val normalized = PyPackageName.normalizePackageName(needle) + val matches = packages.filter { it.contains(normalized, ignoreCase = true) } + return PythonPackageSearchResult( + total = matches.size, + pages = listOf(object : PythonPackageSearchPage { + override fun contents(): Result, PythonPackageSearchPage.DataInvalidatedError> = + Result.Success(matches) + }), + maxPageSize = pageSize, + ) + } + + override fun hasPackage(name: String): Boolean = packages.contains(name) +} diff --git a/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt b/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt index dece09cc2119..eb002acda8cf 100644 --- a/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt +++ b/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt @@ -45,7 +45,7 @@ internal class TestPythonRepositoryManager( return this } - override val repositories: List + override val allRepositories: List get() = listOf(TestPackageRepository(packageNames)) override suspend fun getPackageDetails(packageName: String, repository: PyPackageRepository?): PyResult {