From f8e964c47235522cce309810bfa6c4e07c97a8bf Mon Sep 17 00:00:00 2001 From: TimurMalanin Date: Thu, 30 Jul 2026 12:04:12 +0200 Subject: [PATCH] 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../messages/PyBundle.properties | 6 +- .../PyInterpreterHeaderTitleRenderer.kt | 126 +++++++++++++----- .../toolwindow/PyPackagingToolWindowPanel.kt | 9 +- .../PyPackagingToolWindowService.kt | 12 +- .../toolwindow/PyPackagingTreeView.kt | 23 ++++ ...PyTogglePackagingToolWindowAnchorAction.kt | 12 +- .../details/PyPackageDescriptionController.kt | 2 +- .../toolwindow/details/PyPackageInfoPanel.kt | 4 + .../details/PyPackagingJcefHtmlPanel.kt | 18 ++- .../PyDependencyGroupInlayHintsProvider.kt | 12 ++ .../packages/tree/PyPackagesTree.kt | 8 ++ .../toolwindow/ui/PyInstallPackageDialog.kt | 35 +++++ .../python/sdk/uv/UvPackageManager.kt | 53 +++++--- .../python/sdk/uv/impl/UvTreeParsingTest.kt | 98 ++++++++++++++ 14 files changed, 358 insertions(+), 60 deletions(-) diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index fb4bb7b6d209..a6eebca1a7b3 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -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 diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt index 8f357f8c6fa9..7f3bb02380de 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyInterpreterHeaderTitleRenderer.kt @@ -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 } } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt index e526702f27a2..9680a0beb386 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt @@ -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 } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 300cea899a34..5c1173020f64 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -114,12 +114,16 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco private val invalidRepositories: List get() = service().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) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt index 6dcaa75176f4..fe98f358454a 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt @@ -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 diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt b/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt index 436ab79b52e4..9f1f7cb0df26 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/actions/PyTogglePackagingToolWindowAnchorAction.kt @@ -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 diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt index b3eb70e778f9..3dcafadd1a15 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt @@ -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) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageInfoPanel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageInfoPanel.kt index a25405f41830..73a6f1187525 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageInfoPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageInfoPanel.kt @@ -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) } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackagingJcefHtmlPanel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackagingJcefHtmlPanel.kt index e6dd2cea7952..989e3b6ccfbe 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackagingJcefHtmlPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackagingJcefHtmlPanel.kt @@ -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("", "$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 "" + } + + override fun prepareHtml(html: String): String = + html.replaceFirst("", "$cssStyleCodeToInject$bodyColorOverride") override fun setHtml(html: String) { myLastHtml = html 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 b40ad20f0c78..55fd56f90ea9 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt @@ -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().initForSdk(moduleSdk) + } withContext(Dispatchers.EDT) { PyInstallPackageDialog(project).show(preselectModuleName = preselectName, preselectGroupName = groupName) } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt index 3de91dabab19..3663ef55bf46 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTree.kt @@ -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) { 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 ea0648cf13fb..5a6988a31bc9 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,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 = 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() { diff --git a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt index bc6c0710c8e6..964b8ffbe49b 100644 --- a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt @@ -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, - declaredPackageNames: Set, - ): List { - 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, declaredPackageNames: Set, @@ -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, + declaredPackageNames: Set, + undeclaredRoots: List, +): 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, + declaredPackageNames: Set, +): List { + 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)) diff --git a/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt b/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt index 5cd4a2d5eb84..40f32333da5e 100644 --- a/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt +++ b/python/testSrc/com/jetbrains/python/sdk/uv/impl/UvTreeParsingTest.kt @@ -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 { + 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() + } + } }