From fe1905f1e26116c6e0ae678f0a577dd897d254d2 Mon Sep 17 00:00:00 2001 From: TimurMalanin Date: Wed, 29 Jul 2026 15:56:12 +0000 Subject: [PATCH] tmalanin/PTW-fixes-262 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PY-90072 PPTW: promote anchor-toggle action to the tool-window header Add PyTogglePackagingToolWindowAnchorAction so users can switch between bottom and right docking directly from the PPTW header without opening the gear menu. Also center the "Advanced Package Install" icon between the search field and the right border. PPTW: show active interpreter path in the tool-window title Render the SDK interpreter path next to "Python Packages" in the tool-window stripe title using PyInterpreterHeaderTitleRenderer. The path start-ellipsizes on resize so the tail (.venv/bin/python) stays visible when the header is narrow. Clears automatically when switching to a file with no Python SDK.ё Original MR: IJ-MR-215692 PY-89838 PY-90501 PY-91037 PY-91040 PY-91041 PY-91067 PY-91077: Python Packages tool window redesign follow-ups and bug fixes Original MR: IJ-MR-214946 GitOrigin-RevId: 552de7eb4445ee1f185de0b03ebf9db71c75faa4 --- .../intellij.python.community.impl.xml | 3 +- .../messages/PyBundle.properties | 4 +- .../python/pyproject/PyProjectToml.kt | 3 +- .../packaging/conda/CondaRepositoryManger.kt | 4 +- .../management/PythonRepositoryManager.kt | 18 ++- .../packaging/pip/PipRepositoryManager.kt | 2 +- .../PyInterpreterHeaderTitleRenderer.kt | 82 ++++++++++ ...ackagesToolWindowDefaultAnchorMigration.kt | 43 ----- .../toolwindow/PyPackagingToolWindowPanel.kt | 80 ++++++++-- .../PyPackagingToolWindowService.kt | 3 + .../toolwindow/PyPackagingTreeView.kt | 5 +- ...PyTogglePackagingToolWindowAnchorAction.kt | 45 ++++++ .../PyDependencyGroupInlayHintsProvider.kt | 17 ++ .../packages/PyPackageSearchTextField.kt | 2 + .../packages/tree/PyPackagesTree.kt | 31 ++-- .../tree/renderers/PyPackageTreePresenter.kt | 13 ++ .../ui/PyInstallDialogVersionPanel.kt | 8 +- .../toolwindow/ui/PyInstallPackageDialog.kt | 15 +- .../python/requirements/HtmlBuffer.kt | 30 ++++ .../RequirementsDocumentationProvider.kt | 19 --- .../python/sdk/poetry/PoetryPackageManager.kt | 24 ++- .../python/sdk/uv/impl/UvLowLevel.kt | 2 +- .../unit/packaging/HtmlBufferTest.kt | 84 ++++++++++ ...yDependencyGroupInlayParseErrorGateTest.kt | 90 +++++++++++ .../PyInstallDialogSearchAggregatorTest.kt | 12 +- .../PythonRepositoryManagerFilteringTest.kt | 148 ++++++++++++++++++ .../management/TestPythonRepositoryManager.kt | 2 +- 27 files changed, 667 insertions(+), 122 deletions(-) create mode 100644 python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt delete mode 100644 python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowDefaultAnchorMigration.kt create mode 100644 python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt create mode 100644 python/src/com/jetbrains/python/requirements/HtmlBuffer.kt create mode 100644 python/testSrc/com/intellij/python/junit5Tests/unit/packaging/HtmlBufferTest.kt create mode 100644 python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyDependencyGroupInlayParseErrorGateTest.kt create mode 100644 python/testSrc/com/intellij/python/junit5Tests/unit/packaging/repository/PythonRepositoryManagerFilteringTest.kt 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 {