PY-91324 [PPTW] scope PySdkListener updates to the owning project

PySdkListener is published on the application message bus, so every
open project's PyPackagingToolWindowService received module SDK updates
from unrelated projects and re-pointed itself at the foreign SDK. When
the user created a venv in project B, project A's PPTW switched to that
venv. Gate the callback on module.project == project so only the
project that owns the module reacts.

PY-91321 [PPTW] show full interpreter path in a tooltip on the header
The header ellipsizes the interpreter path when the tool window is
narrow (and hides it entirely below a threshold), which is useful for
the layout but hides the venv name the user relies on to identify the
active environment. Expose the untruncated path via toolTipText on
both the path label and its wrapper — the wrapper carries the tooltip
when the label collapses to invisible so the hover target survives even
in the tightest header.

PY-91319 [PPTW] set 30/70 default split between package list and doc preview
Horizontal (bottom/top) anchor rendered a ~50/50 split, cramping the doc
pane. Default the OnePixelSplitter proportion to 0.3 so the list takes
the narrow left column and the doc preview keeps the remaining space,
per the redesign spec. Bump the splitter key to .v2 so users with the
old 0.55 stored proportion also pick up the new default.
[PPTW] misc UI polish
Move-anchor action: use ToolWindowMoveAction.Anchor.BottomLeft /
RightBottom so the switch is atomic (single fireStateChanged, no
duplicate stripe buttons and no wrong-side flash), pick the matching
icon (MoveToBottomLeft / MoveToRightBottom), and reword the tooltips
to name the exact destination corner.
PPTW header: keep stripeTitle pinned to the plain "Python Packages"
so the stripe-button tooltip stays clean, and surface the active
interpreter path via a CustomComponentAction registered in
setTitleActions. The path lives in a JBLabel with context-help
foreground, ellipsizes from the tail, and disappears entirely when the
header cannot fit a legible slice; budgets are derived from
ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE so they track LaF scaling.
Doc pane: switch the header separator from JBColor.GRAY to
JBColor.border() so it stays visible on Light / Darcula themes, and
paint the placeholder / loading / JCEF backgrounds with
JBUI.CurrentTheme.ToolWindow.background() to eliminate the seam
against the surrounding tool window.

PY-90501 [PPTW] paginate search results on keyboard navigation
PPTW renders one JTree per repository inside a single scroll pane. When
the DOWN key reached the last row of a tree the tree absorbed the key
without moving the outer scroll bar, so the scroll adjustment listener
never fired and pagination was keyboard-inaccessible (trackpad/wheel
already worked because they scroll the viewport directly).
Trigger loadMore from the tree selection listener whenever selection
lands on the last row and pendingMore > 0 — source-agnostic. loadMore
preserves the previous selection and advances by one so the caret keeps
moving into the newly revealed rows instead of resetting to the top.

PY-91300 [PPTW] refresh state on venv changes without requiring PPTW to be opened first
Two related issues in the "user creates a venv, then clicks + Add package
before opening PPTW" flow:
PyPackagingToolWindowService subscribed to PySdkListener /
ModuleRootListener / FileEditorManagerListener only from initialize,
which runs on PPTW first-open. Venv-creation events before that were
dropped and PPTW stayed on stale state until the user also switched
between subproject editors. Move subscribeToChanges into init so the
listeners attach as soon as the project service is created.
The + Add package inlay handler opened PyInstallPackageDialog before
the packaging service had a currentSdk, so the install click read null
and silently no-oped. Resolve the SDK from the clicked module and
await initForSdk before opening the dialog.

PY-91263 [PPTW] grow install popup to screen bottom when doc pane opens
The doc pane shared the tiny default popup height with the results list
and was nearly unreadable. Snapshot the pre-toggle size, extend the popup
downward to ScreenUtil.getScreenRectangle().maxY on open, restore on
close, and keep balloonFullSize in sync so a later applyBalloonSize
does not collapse back to the old height.

PY-90113 [PPTW][Uv] hide transitives of declared packages from "Not added to pyproject.toml"
Non-workspace branch of UvPackageManager.getPackageTree only filtered by
declared root names; transitives of a declared package that surfaced as
roots in uv pip tree (typical after a fresh uv add jupyterlab) were
labeled undeclared. Mirror the workspace path: collect every name shown
inside declared subtrees and also drop the project root names, so only
truly standalone packages remain in the group.
Adds an @ApiStatus.Internal buildNonWorkspacePackageStructure helper
and unit tests exercising the jupyter scenario against pure fixtures.

PY-91302 [PPTW] rename "Uninstalled" section caption to "Not installed"
Section groups packages available from repositories that aren't installed
yet; "Not installed" reads as the current state rather than a past action.

GitOrigin-RevId: 34138df28efbab8e87bedab47a61ad56237d0cad
This commit is contained in:
TimurMalanin
2026-07-30 14:42:38 +00:00
committed by intellij-monorepo-bot
parent e2d398ff1b
commit f8e964c472
14 changed files with 358 additions and 60 deletions
@@ -1398,7 +1398,7 @@ python.packaging.repository.form.tab.packages.search=Search for Packages
python.packaging.repository.form.enabled=Enable repository
python.packaging.repository.form.url.root.path.warning=URL has no path. Specify the package index endpoint (e.g. https://your-server/simple/)
python.packaging.repository.duplicate.name.error=A repository with this name already exists
python.toolwindow.packages.uninstalled.label=Uninstalled
python.toolwindow.packages.uninstalled.label=Not installed
python.packaging.loading.packages.progress.text=Loading packages\u2026
notification.group.packaging=Python packaging
notification.group.packaging.toolwindow=Python Packages
@@ -1640,8 +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.move.to.right.action=Move tool window to right bottom corner
python.toolwindow.packages.move.to.bottom.action=Move tool window to bottom left corner
python.toolwindow.packages.interpreters.action=Interpreters...
python.toolwindow.packages.repositories.action=Repositories...
python.toolwindow.packages.settings.group=Settings
@@ -2,40 +2,92 @@
package com.jetbrains.python.packaging.toolwindow
import com.intellij.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.ex.CustomComponentAction
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.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.GraphicsUtil
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import org.jetbrains.annotations.Nls
import java.awt.Font
import java.awt.FlowLayout
import javax.swing.JComponent
import javax.swing.JPanel
/**
* 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.
* Exposes "Python Packages" (bold) + interpreter path (light "context-help" foreground) as a
* title action, so the platform renders it inline in the tool window header alongside the other
* header buttons — no tab-chip background, no stripe-tooltip pollution.
*
* 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.
* `stripeTitle` is unfit to carry the path: it doubles as the stripe-button tooltip source and
* `HelpTooltip.setPlainTextTitle` XML-escapes it, so HTML markup and the full path would leak
* into the hover tooltip. Using `setTitle` (content tab title) forces the platform to paint a
* tab-chip background around the text. A [CustomComponentAction] sidesteps both: the component
* lives in the header actions row without any tab decoration, and `stripeTitle` stays pinned to
* the plain "Python Packages" for a clean tooltip.
*
* 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.
* The path start-ellipsizes to the tail (`.venv/bin/python`) — leading directories yield first
* because environment identity lives in the trailing directory.
*/
internal class PyInterpreterHeaderTitleRenderer(
private val toolWindow: ToolWindow,
@Nls private val plainTitle: String,
) {
init {
if (toolWindow.stripeTitle != plainTitle) {
toolWindow.stripeTitle = plainTitle
}
}
private val pathLabel: JBLabel = JBLabel("").apply {
foreground = UIUtil.getContextHelpForeground()
isVisible = false
}
// Bold "Python Packages" is already drawn by the platform id-label to the left; the header
// action only contributes the light-gray interpreter path so the two together read as
// "Python Packages …/path" without repeating the title. The trailing empty inset matches the
// platform's inter-action horizontal gap so the path does not butt up against the anchor
// toggle button that follows it in the actions row.
private val component: JComponent = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
border = JBUI.Borders.emptyRight(JBUI.CurrentTheme.ActionsList.elementIconGap())
add(pathLabel)
}
/**
* The `CustomComponentAction` that hosts the header component. Register via
* `toolWindow.setTitleActions(listOf(headerAction, …))`; the platform creates the component
* once through [CustomComponentAction.createCustomComponent] and reuses the same instance for
* the lifetime of the toolbar.
*/
val headerAction: AnAction = object : AnAction(), CustomComponentAction {
override fun createCustomComponent(presentation: Presentation, place: String): JComponent = component
override fun actionPerformed(e: AnActionEvent) {}
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = true
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
private var currentPath: String? = null
private var lastRendered: String? = null
@RequiresEdt
fun update(path: String?) {
currentPath = path?.takeIf { it.isNotEmpty() }
// Always expose the full path via tooltip so the user can inspect the venv name / directory
// even when the header ellipsizes it or hides it entirely on a narrow tool window (PY-91321).
// Sat on both the label and its wrapper — an invisible JLabel does not fire tooltip events,
// so the wrapper carries the tooltip while the header collapses the visible slice.
pathLabel.toolTipText = currentPath
(component as JPanel).toolTipText = currentPath
refit()
}
@@ -43,40 +95,54 @@ internal class PyInterpreterHeaderTitleRenderer(
fun refit() {
val path = currentPath
if (path == null) {
toolWindow.stripeTitle = plainTitle
if (pathLabel.isVisible) pathLabel.isVisible = false
pathLabel.toolTipText = null
(component as JPanel).toolTipText = null
lastRendered = null
return
}
toolWindow.stripeTitle = renderHtml(fitPathToHeader(path))
val fitted = fitPathToBar(path)
val text = if (fitted.isEmpty() || fitted.trim().length <= 2) " " else fitted
if (text == lastRendered && pathLabel.isVisible) return
lastRendered = text
pathLabel.text = text
pathLabel.isVisible = true
component.revalidate()
component.repaint()
}
private fun fitPathToHeader(path: String): @NlsSafe String {
private fun fitPathToBar(path: String): @NlsSafe String {
val width = toolWindow.component.width.takeIf { it > 0 } ?: return path
val font = UIUtil.getLabelFont()
val pathFont = pathLabel.font
val titleFont = pathFont.deriveFont(java.awt.Font.BOLD)
val budget = (width
- GraphicsUtil.stringWidth(plainTitle, font.deriveFont(Font.BOLD))
- GraphicsUtil.stringWidth(" ", font)
- RIGHT_TOOLBAR_RESERVED_PX
- GraphicsUtil.stringWidth(plainTitle, titleFont)
- HEADER_RIGHT_RESERVED_PX
).coerceAtLeast(0)
if (budget < MIN_READABLE_WIDTH_PX) return ""
return shortenTextWithEllipsis(
text = path,
minTextPrefixLength = 0,
minTextSuffixLength = 1,
maxTextPrefixRatio = 0f,
maxTextWidth = budget,
getTextWidth = { GraphicsUtil.stringWidth(it, font) },
getTextWidth = { GraphicsUtil.stringWidth(it, pathFont) },
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
/**
* Width the platform reserves on the right of the header for the anchor toggle,
* the gear popup, and the hide button.
*/
private val HEADER_RIGHT_RESERVED_PX = 5 * ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE.width +
2 * JBUI.CurrentTheme.ActionsList.elementIconGap()
/**
* Below this budget every rendered slice degenerates to just the ellipsis symbol, so the
* label is hidden entirely instead.
*/
private val MIN_READABLE_WIDTH_PX: Int = 2 * ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE.width
}
}
@@ -97,6 +97,7 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
contentPanel = PyPackagesUiComponents.borderPanel {
add(createContentPanel(), BorderLayout.CENTER)
}
contentPanel.background = JBUI.CurrentTheme.ToolWindow.background()
setContent(contentPanel)
setupToolWindowTitleActions()
rebuildCenterLayout()
@@ -131,12 +132,14 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
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"),
)
toolWindow.setTitleActions(listOfNotNull(
headerTitleRenderer?.headerAction,
PyTogglePackagingToolWindowAnchorAction(),
))
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { headerTitleRenderer?.refit() }
@@ -163,7 +166,7 @@ internal class PyPackagingToolWindowPanel(private val project: Project) : Simple
lastIsHorizontal = horizontal
centerSlot.removeAll()
if (horizontal) {
val splitter = OnePixelSplitter(false, "py.packages.tool.window.splitter", 0.55f).apply {
val splitter = OnePixelSplitter(false, "py.packages.tool.window.splitter.v2", 0.3f).apply {
firstComponent = listWithSearchPanel
secondComponent = infoPanel.component
}
@@ -114,12 +114,16 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
private val invalidRepositories: List<PyInvalidRepositoryViewData>
get() = service<PyPackageRepositories>().invalidRepositories.filter { it.enabled }.map(::PyInvalidRepositoryViewData)
init {
subscribeToChanges()
}
fun initialize(toolWindowPanel: PyPackagingToolWindowPanel) {
this.toolWindowPanel = toolWindowPanel
serviceScope.launch(Dispatchers.IO) {
initForSdk(readAction { project.modules.firstNotNullOfOrNull { it.pythonSdk } })
val sdk = currentSdk ?: readAction { project.modules.firstNotNullOfOrNull { it.pythonSdk } }
initForSdk(sdk)
}
subscribeToChanges()
}
suspend fun detailsForPackage(selectedPackage: DisplayablePackage): PythonPackageDetails? {
@@ -428,6 +432,10 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco
ApplicationManager.getApplication().messageBus.connect(serviceScope)
.subscribe(PySdkListener.TOPIC, object : PySdkListener {
override fun moduleSdkUpdated(module: Module, prevSdk: Sdk?, newSdk: Sdk?) {
// `PySdkListener` fires on the application bus, so every open project's service is
// notified. Ignore modules that don't belong to *this* project — otherwise creating a
// venv in project B repoints project A's PPTW to that venv (PY-91324).
if (module.project != project) return
if (newSdk != null && newSdk == currentSdk) return
serviceScope.launch(Dispatchers.IO) {
initForSdk(newSdk)
@@ -268,10 +268,33 @@ internal class PyPackagingTreeView(
newTreeGroup.addTo(uninstalledContainerPanel)
newTree.addTreeSelectionListener {
syncTreeSelection(newTree)
maybePrefetchMore(newTree)
}
synchronizeScrollPaneSize()
}
/**
* Prefetches the next page as soon as selection lands on (or near) the last row and more
* results are available. Works regardless of input source — keyboard, trackpad, or the outer
* scroll bar — so `DOWN`, `PAGE_DOWN`, `END`, and mouse-wheel-driven wrap-around all trigger
* pagination through the same path (PY-90501). No key-code branching: the tree does not
* need to know which action moved the selection.
*/
private fun maybePrefetchMore(tree: PyPackagesTree) {
if (isLoadingMore) return
if (tree.pendingMore <= 0) return
val row = tree.selectionRows?.firstOrNull() ?: return
if (row < tree.rowCount - 1) return
isLoadingMore = true
try {
tree.loadMore()
synchronizeScrollPaneSize()
}
finally {
isLoadingMore = false
}
}
private var scrollLoaderInstalled: Boolean = false
private var isLoadingMore: Boolean = false
@@ -2,6 +2,7 @@
package com.jetbrains.python.packaging.toolwindow.actions
import com.intellij.icons.AllIcons
import com.intellij.ide.actions.ToolWindowMoveAction
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAwareAction
@@ -21,7 +22,7 @@ internal class PyTogglePackagingToolWindowAnchorAction : DumbAwareAction() {
}
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")
AllIcons.Actions.MoveToBottomLeft to PyBundle.message("python.toolwindow.packages.move.to.bottom.action")
}
else {
AllIcons.Actions.MoveToRightBottom to PyBundle.message("python.toolwindow.packages.move.to.right.action")
@@ -32,8 +33,13 @@ internal class PyTogglePackagingToolWindowAnchorAction : DumbAwareAction() {
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)
val target = if (toolWindow.anchor == ToolWindowAnchor.RIGHT) {
ToolWindowMoveAction.Anchor.BottomLeft
}
else {
ToolWindowMoveAction.Anchor.RightBottom
}
target.applyTo(toolWindow)
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
@@ -235,7 +235,7 @@ internal class PyPackageDescriptionController(
private val component = PyPackagesUiComponents.borderPanel {
add(PyPackagesUiComponents.borderPanel {
border = SideBorder(JBColor.GRAY, SideBorder.BOTTOM)
border = SideBorder(JBColor.border(), SideBorder.BOTTOM)
leftPanel.border = BorderFactory.createEmptyBorder(0, 10, 0, 0)
rightPanel.border = BorderFactory.createEmptyBorder(0, 0, 0, 10)
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBPanelWithEmptyText
import com.intellij.util.ui.JBUI
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.packaging.toolwindow.model.DependencyGroupNode
@@ -69,15 +70,18 @@ internal class PyPackageInfoPanel(
private val noPackagePanel = JBPanelWithEmptyText().apply {
emptyText.text = message("python.toolwindow.packages.description.panel.placeholder")
emptyText.setShowAboveCenter(false)
background = JBUI.CurrentTheme.ToolWindow.background()
}
private val loadingPanel = JBPanelWithEmptyText().apply {
emptyText.appendLine(AnimatedIcon.Default.INSTANCE, message("python.toolwindow.packages.description.panel.loading"), SimpleTextAttributes.SIMPLE_CELL_ATTRIBUTES, null)
emptyText.setShowAboveCenter(false)
background = JBUI.CurrentTheme.ToolWindow.background()
}
private var updateJob: Job? = null
val component: JPanel = JPanel(BorderLayout()).apply {
background = JBUI.CurrentTheme.ToolWindow.background()
add(noPackagePanel, BorderLayout.CENTER)
}
@@ -7,7 +7,10 @@ import com.intellij.ide.ui.LafManagerListener
import com.intellij.ide.ui.laf.UIThemeLookAndFeelInfo
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.ui.ColorUtil
import com.intellij.ui.jcef.JCEFHtmlPanel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicInteger
@@ -46,7 +49,20 @@ internal class PyPackagingJcefHtmlPanel(project: Project) : JCEFHtmlPanel(unique
setOpenLinksInExternalBrowser(true)
}
override fun prepareHtml(html: String): String = html.replaceFirst("<head>", "<head>$cssStyleCodeToInject")
/**
* Overrides body background/foreground so the JCEF pane matches the surrounding tool-window
* background instead of a hardcoded `#f7f8fa` / `#2b2d30` from the theme CSS file. Computed at
* inject time (not cached) so LaF changes are reflected on the next [setHtml] call.
*/
private val bodyColorOverride: String
get() {
val bg = ColorUtil.toHtmlColor(JBUI.CurrentTheme.ToolWindow.background())
val fg = ColorUtil.toHtmlColor(UIUtil.getLabelForeground())
return "<style>body { background-color: $bg; color: $fg; }</style>"
}
override fun prepareHtml(html: String): String =
html.replaceFirst("<head>", "<head>$cssStyleCodeToInject$bodyColorOverride")
override fun setHtml(html: String) {
myLastHtml = html
@@ -21,6 +21,9 @@ import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.pyproject.dependencies.spi.resolveDependencyGroupName
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.readAction
import com.intellij.openapi.components.service
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -107,6 +110,15 @@ internal class PyDependencyGroupInlayHintsProvider : InlayHintsProvider<NoSettin
?.let { PyProjectToml.parseCached(project, it) }
?.project?.name
?: module.name
// Bind the packaging service to the *clicked* module's SDK before the dialog opens.
// Without this, the dialog falls back to `findFirstPythonSdk()`, which in a multi-project
// workspace (e.g. poetry subprojects) may pick the wrong SDK — or the service may still
// be uninitialized, in which case the install click silently no-ops because
// `packagingService.currentSdk` is null (PY-91300).
val moduleSdk = readAction { PythonSdkUtil.findPythonSdk(module) }
if (moduleSdk != null) {
project.service<PyPackagingToolWindowService>().initForSdk(moduleSdk)
}
withContext(Dispatchers.EDT) {
PyInstallPackageDialog(project).show(preselectModuleName = preselectName, preselectGroupName = groupName)
}
@@ -437,8 +437,16 @@ internal class PyPackagesTree(
val from = items.size
val to = minOf(from + LOAD_MORE_PAGE, sorted.size)
if (from >= to) return
val previousSelection = selectionRows?.firstOrNull()
setItemsKeepingCache(sorted.subList(0, to))
pendingMore = (sorted.size - to).coerceAtLeast(0)
if (previousSelection != null) {
val next = (previousSelection + 1).coerceAtMost(rowCount - 1)
if (next >= 0) {
setSelectionRow(next)
scrollRowToVisible(next)
}
}
}
private fun setItemsKeepingCache(value: List<DisplayablePackage>) {
@@ -121,6 +121,12 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
private var balloonFullSize: Dimension? = null
private var collapsedSize: Dimension? = null
/**
* Popup size captured immediately before the doc pane is opened. On close, the popup is
* restored to this size so the user's original layout is not lost (PY-91263).
*/
private var sizeBeforeDescription: Dimension? = null
private var isOpeningFileBrowser = false
override fun createList(): JBList<Any> = resultsList.list
@@ -368,6 +374,35 @@ internal class PyInstallPackageDialog(private val project: Project) : BigPopupUI
listOrDescContainer.add(center, BorderLayout.CENTER)
listOrDescContainer.revalidate()
listOrDescContainer.repaint()
if (::popup.isInitialized && !popup.isDisposed) {
applyDescriptionModeSize(showDescription)
}
}
private fun applyDescriptionModeSize(showDescription: Boolean) {
if (showDescription) {
val current = popup.size
if (sizeBeforeDescription == null) sizeBeforeDescription = Dimension(current)
val topLeft = popup.locationOnScreen
val screen = ScreenUtil.getScreenRectangle(topLeft)
val insets = popup.content.insets
val availableHeight = (screen.maxY.toInt() - topLeft.y).coerceAtLeast(current.height)
if (availableHeight > current.height) {
popup.size = Dimension(current.width, availableHeight)
}
val innerHeight = availableHeight - insets.top - insets.bottom
if (innerHeight > (balloonFullSize?.height ?: 0)) {
balloonFullSize = Dimension(current.width - insets.left - insets.right, innerHeight)
}
}
else {
sizeBeforeDescription?.let {
popup.size = it
val insets = popup.content.insets
balloonFullSize = Dimension(it.width - insets.left - insets.right, it.height - insets.top - insets.bottom)
}
sizeBeforeDescription = null
}
}
private fun ensureSdkInitialized() {
@@ -154,24 +154,10 @@ internal class UvPackageManager internal constructor(
val workspaceTree = buildWorkspaceStructure(allTrees, declaredPackageNames)
if (workspaceTree != null) return workspaceTree
val declaredPackages = extractDeclaredPackagesFromParsedTrees(allTrees, declaredPackageNames)
val undeclaredPackages = extractUndeclaredPackages(declaredPackageNames)
return PackageCollectionPackageStructureNode(declaredPackages, undeclaredPackages)
val undeclaredRoots = extractUndeclaredPackages(declaredPackageNames)
return buildNonWorkspacePackageStructure(allTrees, declaredPackageNames, undeclaredRoots)
}
private fun extractDeclaredPackagesFromParsedTrees(
allTrees: List<PackageTreeNode>,
declaredPackageNames: Set<String>,
): List<PackageTreeNode> {
val projectRoot = allTrees.firstOrNull()
?: return declaredPackageNames.map { createLeafNode(it) }
val childrenByName = projectRoot.children.associateBy { it.name.name }
return declaredPackageNames.map { name -> childrenByName[name] ?: createLeafNode(name) }
}
private fun createLeafNode(packageName: String): PackageTreeNode =
PackageTreeNode(PyPackageName.from(packageName))
private suspend fun buildWorkspaceStructure(
allTrees: List<PackageTreeNode>,
declaredPackageNames: Set<String>,
@@ -189,7 +175,7 @@ internal class UvPackageManager internal constructor(
val shownPackageNames = collectAllPackageNames(rootTree, subMembers)
val undeclared = extractUndeclaredPackages(declaredPackageNames)
.filter { it.name.name !in shownPackageNames }
.filter { it.name.name !in shownPackageNames && it.name.name !in allMemberNames }
return WorkspaceMemberPackageStructureNode(rootName, subMembers, rootTree, undeclared)
}
@@ -395,3 +381,36 @@ internal class UvPackageManagerProvider : PythonPackageManagerProvider {
return UvPackageManager(project, sdk, uvExecutionContext)
}
}
/**
* Builds the non-workspace package structure: keeps declared depth-1 dependencies with their
* transitive subtrees, and filters out any `uv pip tree` root that either matches a project root
* name or already appears inside a declared subtree (transitive of a declared package).
*/
@ApiStatus.Internal
fun buildNonWorkspacePackageStructure(
allTrees: List<PackageTreeNode>,
declaredPackageNames: Set<String>,
undeclaredRoots: List<PackageTreeNode>,
): PackageCollectionPackageStructureNode {
val rootProjectNames = allTrees.mapTo(mutableSetOf()) { it.name.name }
val declaredPackages = extractDeclaredPackagesFromParsedTrees(allTrees, declaredPackageNames)
val shownPackageNames = declaredPackages.flatMapTo(mutableSetOf()) { it.collectAllNames() }
val filtered = undeclaredRoots.filter {
it.name.name !in shownPackageNames && it.name.name !in rootProjectNames
}
return PackageCollectionPackageStructureNode(declaredPackages, filtered)
}
private fun extractDeclaredPackagesFromParsedTrees(
allTrees: List<PackageTreeNode>,
declaredPackageNames: Set<String>,
): List<PackageTreeNode> {
val projectRoot = allTrees.firstOrNull()
?: return declaredPackageNames.map { createLeafNode(it) }
val childrenByName = projectRoot.children.associateBy { it.name.name }
return declaredPackageNames.map { name -> childrenByName[name] ?: createLeafNode(name) }
}
private fun createLeafNode(packageName: String): PackageTreeNode =
PackageTreeNode(PyPackageName.from(packageName))
@@ -12,6 +12,7 @@ import com.jetbrains.python.packaging.packageRequirements.TreeParser
import com.jetbrains.python.packaging.packageRequirements.TreeParser.parseTrees
import com.jetbrains.python.packaging.packageRequirements.collectAllNames
import com.jetbrains.python.packaging.packageRequirements.extractDeclaredDependencies
import com.jetbrains.python.sdk.uv.buildNonWorkspacePackageStructure
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@@ -744,4 +745,101 @@ class UvTreeParsingTest {
assertThat(TreeParser.isRootLine("")).isFalse()
}
}
@Nested
inner class NonWorkspaceUndeclaredFilter {
/**
* Reproduces PTW jupyter case: `jupyterlab` is the only declared dependency; all other packages
* are transitive deps of `jupyterlab`. Exercises the pure non-workspace branch of
* `UvPackageManager.getPackageTree` via [buildNonWorkspacePackageStructure].
*/
private fun undeclaredNames(projectTreeOutput: String, pipTreeOutput: String): List<String> {
val allTrees = parseTrees(projectTreeOutput.lines())
val declaredPackageNames = extractDeclaredDependencies(allTrees).mapTo(mutableSetOf()) { it.name }
val undeclaredRoots = parseTrees(pipTreeOutput.lines())
val structure = buildNonWorkspacePackageStructure(allTrees, declaredPackageNames, undeclaredRoots)
return structure.undeclaredPackages.map { it.name.name }
}
@Test
fun `jupyter transitives of declared package are not marked undeclared`() {
val projectTree = """
jupyterproject v0.1.0
└── jupyterlab v4.6.2
├── async-lru v2.3.0
├── httpx v0.28.1
│ ├── anyio v4.14.2
│ │ └── idna v3.18
│ ├── certifi v2026.7.22
│ ├── httpcore v1.0.9
│ │ ├── certifi v2026.7.22
│ │ └── h11 v0.16.0
│ └── idna v3.18
├── ipykernel v7.3.0
│ └── traitlets v5.15.1
├── jinja2 v3.1.6
│ └── markupsafe v3.0.3
├── packaging v26.2
├── tornado v6.5.7
└── traitlets v5.15.1
""".trimIndent()
// `uv pip tree` roots include the project itself + a few transitives that no other installed
// package depends on (typical for jupyter installs — build backends, prompt helpers, etc.).
val pipTree = """
jupyterproject v0.1.0
└── jupyterlab v4.6.2
packaging v26.2
idna v3.18
markupsafe v3.0.3
setuptools v75.0.0
""".trimIndent()
val undeclared = undeclaredNames(projectTree, pipTree)
// jupyterproject (root) and jupyterlab (declared) are filtered.
// packaging, idna, markupsafe are transitives of jupyterlab -> filtered too.
// setuptools is truly undeclared (not in project tree) -> remains.
assertThat(undeclared).containsExactly("setuptools")
}
@Test
fun `simple project without transitives keeps unrelated pip tree roots`() {
val projectTree = """
myapp v1.0.0
└── requests v2.31.0
└── urllib3 v2.1.0
""".trimIndent()
val pipTree = """
myapp v1.0.0
└── requests v2.31.0
urllib3 v2.1.0
pip v24.0
""".trimIndent()
val undeclared = undeclaredNames(projectTree, pipTree)
// urllib3 is transitive of declared requests -> filtered; myapp is project root -> filtered.
assertThat(undeclared).containsExactly("pip")
}
@Test
fun `project root name itself is filtered from undeclared`() {
val projectTree = """
jupyterproject v0.1.0
└── jupyterlab v4.6.2
""".trimIndent()
val pipTree = """
jupyterproject v0.1.0
└── jupyterlab v4.6.2
""".trimIndent()
val undeclared = undeclaredNames(projectTree, pipTree)
assertThat(undeclared).isEmpty()
}
}
}