diff --git a/platform/platform-impl/src/com/intellij/ui/components/JBTreeTable.java b/platform/platform-impl/src/com/intellij/ui/components/JBTreeTable.java
index 394aab21167e..3f2604b59e1a 100644
--- a/platform/platform-impl/src/com/intellij/ui/components/JBTreeTable.java
+++ b/platform/platform-impl/src/com/intellij/ui/components/JBTreeTable.java
@@ -171,6 +171,11 @@ public class JBTreeTable extends JComponent implements TreePathBackgroundSupplie
return myTable;
}
+ @ApiStatus.Internal
+ public final @NotNull OnePixelSplitter getSplitter() {
+ return split;
+ }
+
public void setDefaultRenderer(@NotNull Class> columnClass, @NotNull TableCellRenderer renderer) {
myTable.setDefaultRenderer(columnClass,renderer);
}
diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml
index 72f899887fed..74f54260f241 100644
--- a/python/pluginResources/intellij.python.community.impl.xml
+++ b/python/pluginResources/intellij.python.community.impl.xml
@@ -778,7 +778,11 @@
dynamic="true"/>
+
+
@@ -886,6 +890,11 @@
+
+
+
+
+
diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties
index f3c011682f1a..0d9af23c5843 100644
--- a/python/pluginResources/messages/PyBundle.properties
+++ b/python/pluginResources/messages/PyBundle.properties
@@ -1421,6 +1421,7 @@ python.toolwindow.packages.add.package.dialog.title=Install Package
python.toolwindow.packages.load.more=\u2026 {0} more items. Double click to show more.
python.toolwindow.packages.converting.description.progress=Reading package description
python.toolwindow.packages.documentation.local.interpreter=PyCharm need at least one local interpreter to display package documentation.
+python.toolwindow.packages.sdk.label.html={0} ({1})
python.packages.dictionary.name=Python Packages Dictionary
vcs.activate.virtualenv.checkbox.text=Activate virtualenv for hooks
diff --git a/python/src/com/jetbrains/python/packaging/common/packages.kt b/python/src/com/jetbrains/python/packaging/common/packages.kt
index cdb383b2c35d..7f02367e1ace 100644
--- a/python/src/com/jetbrains/python/packaging/common/packages.kt
+++ b/python/src/com/jetbrains/python/packaging/common/packages.kt
@@ -7,8 +7,10 @@ import com.jetbrains.python.packaging.pyRequirementVersionSpec
import com.jetbrains.python.packaging.repository.PyPackageRepository
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
import com.jetbrains.python.packaging.requirement.PyRequirementVersionSpec
+import com.jetbrains.python.psi.icons.PythonPsiApiIcons
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
+import javax.swing.Icon
@ApiStatus.Internal
@JvmInline
@@ -26,6 +28,8 @@ open class PythonPackage(name: String, val version: String, val isEditableMode:
val name: String = NormalizedPythonPackageName.from(name).name
val presentableName: String = name
+ @ApiStatus.Internal
+ open val sourceRepoIcon: Icon = PythonPsiApiIcons.Python
override fun toString(): String {
return "PythonPackage(name='$name', version='$version')"
diff --git a/python/src/com/jetbrains/python/packaging/conda/common.kt b/python/src/com/jetbrains/python/packaging/conda/common.kt
index 61d6f020cb8a..c04215df22e7 100644
--- a/python/src/com/jetbrains/python/packaging/conda/common.kt
+++ b/python/src/com/jetbrains/python/packaging/conda/common.kt
@@ -2,15 +2,24 @@
package com.jetbrains.python.packaging.conda
import com.jetbrains.python.PyBundle
+import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.common.PythonPackageDetails
import com.jetbrains.python.packaging.repository.PyPackageRepository
+import com.jetbrains.python.psi.icons.PythonPsiApiIcons
+import javax.swing.Icon
class CondaPackage(
name: String, version: String,
editableMode: Boolean,
val installedWithPip: Boolean = false,
) : PythonPackage(name, version, editableMode) {
+ override val sourceRepoIcon: Icon = if (installedWithPip) {
+ PythonPsiApiIcons.Python
+ } else {
+ PythonIcons.Python.Anaconda
+ }
+
override fun toString(): String {
return "CondaPackage(name='$name', version='$version', installedWithPip=$installedWithPip)"
}
diff --git a/python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.kt b/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementExtractor.kt
similarity index 76%
rename from python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.kt
rename to python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementExtractor.kt
index 2fe94348c225..d02e42ca91d5 100644
--- a/python/src/com/jetbrains/python/packaging/packageRequires/PythonPackageRequiresExtractor.kt
+++ b/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementExtractor.kt
@@ -1,5 +1,5 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
-package com.jetbrains.python.packaging.packageRequires
+package com.jetbrains.python.packaging.packageRequirements
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.module.Module
@@ -9,20 +9,20 @@ import com.jetbrains.python.packaging.common.PythonPackage
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
-interface PythonPackageRequiresExtractor {
+interface PythonPackageRequirementExtractor {
suspend fun extract(pkg: PythonPackage, module: Module): List
companion object {
- fun forSdk(sdk: Sdk): PythonPackageRequiresExtractor? =
- PythonPackageRequiresExtractorProvider.EP_NAME.extensionList.firstNotNullOf { it.createExtractor(sdk) }
+ fun forSdk(sdk: Sdk): PythonPackageRequirementExtractor? =
+ PythonPackageRequiresExtractorProvider.EP_NAME.extensionList.firstNotNullOfOrNull { it.createExtractor(sdk) }
}
}
@ApiStatus.Internal
interface PythonPackageRequiresExtractorProvider {
- fun createExtractor(sdk: Sdk): PythonPackageRequiresExtractor?
+ fun createExtractor(sdk: Sdk): PythonPackageRequirementExtractor?
companion object {
val EP_NAME: ExtensionPointName = ExtensionPointName.create("Pythonid.PythonPackageRequiresExtractorProvider")
diff --git a/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt b/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt
new file mode 100644
index 000000000000..7938698b49fb
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/packageRequirements/PythonPackageRequirementsTreeExtractor.kt
@@ -0,0 +1,84 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.packaging.packageRequirements
+
+import com.intellij.openapi.extensions.ExtensionPointName
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.projectRoots.Sdk
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+import com.jetbrains.python.packaging.common.PythonPackage
+import org.jetbrains.annotations.ApiStatus
+
+@ApiStatus.Internal
+interface PythonPackageRequirementsTreeExtractor {
+ suspend fun extract(pkg: PythonPackage, module: Module): PackageNode
+
+ companion object {
+ private val treeParser = TreeParser()
+
+ fun forSdk(sdk: Sdk): PythonPackageRequirementsTreeExtractor? =
+ PythonPackageRequirementsTreeExtractorProvider.EP_NAME.extensionList
+ .firstNotNullOfOrNull { it.createExtractor(sdk) }
+
+ fun parseTree(lines: List): PackageNode = treeParser.parseTree(lines)
+ }
+}
+
+@ApiStatus.Internal
+interface PythonPackageRequirementsTreeExtractorProvider {
+ fun createExtractor(sdk: Sdk): PythonPackageRequirementsTreeExtractor?
+
+ companion object {
+ private const val EP_NAME_VALUE = "Pythonid.PythonPackageRequirementsTreeExtractorProvider"
+ val EP_NAME: ExtensionPointName =
+ ExtensionPointName.create(EP_NAME_VALUE)
+ }
+}
+
+@ApiStatus.Internal
+data class PackageNode(
+ val name: NormalizedPythonPackageName,
+ val children: MutableList = mutableListOf(),
+)
+
+@ApiStatus.Internal
+class TreeParser {
+ private data class ParseResult(
+ val node: PackageNode,
+ val nextIndex: Int,
+ )
+
+ fun parseTree(lines: List): PackageNode {
+ val (node, _) = parseLevel(lines, calculateIndentLevel(lines.first()), 0)
+ return node
+ }
+
+ private fun parseLevel(lines: List, startIndent: Int, index: Int): ParseResult {
+ val line = lines[index]
+ val name = extractPackageName(line)
+ val node = PackageNode(NormalizedPythonPackageName.from(name))
+ var currentIndex = index + 1
+ while (currentIndex < lines.size && calculateIndentLevel(lines[currentIndex]) > startIndent) {
+ val result = parseLevel(lines, calculateIndentLevel(lines[currentIndex]), currentIndex)
+ node.children.add(result.node)
+ currentIndex = result.nextIndex
+ }
+ return ParseResult(node, currentIndex)
+ }
+
+ private fun calculateIndentLevel(line: String): Int {
+ val indentMatch = TREE_LINE_REGEX.find(line)?.value ?: ""
+ return indentMatch.chunked(4).count { it.isNotBlank() }
+ }
+
+ private fun extractPackageName(line: String): String {
+ val clean = line.replaceFirst(TREE_LINE_REGEX, "").trimStart()
+ return clean.split(SPACE_DELIMITER, limit = 2)[0]
+ .substringBefore(VERSION_DELIMITER)
+ }
+
+ companion object Constants {
+ private val TREE_LINE_REGEX = Regex("""^[\sā|`]*[āā`|][-ā]+ """)
+ private const val SPACE_DELIMITER = ' '
+ private const val VERSION_DELIMITER = '['
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt
deleted file mode 100644
index 3758fb229adc..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
-package com.jetbrains.python.packaging.toolwindow
-
-import com.intellij.icons.AllIcons
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.projectRoots.Sdk
-import com.intellij.ui.JBColor
-import com.jetbrains.python.PyBundle.message
-import com.jetbrains.python.packaging.repository.InstalledPyPackagedRepository
-import com.jetbrains.python.packaging.repository.PyPackageRepository
-import com.jetbrains.python.packaging.toolwindow.model.*
-import com.jetbrains.python.packaging.toolwindow.packages.PyPackagingTableGroup
-import com.jetbrains.python.packaging.toolwindow.packages.table.PyPackagesTable
-import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
-import java.awt.Rectangle
-import javax.swing.JLabel
-import javax.swing.JPanel
-import javax.swing.JTable
-
-class PyPackagingTablesView(
- private val project: Project,
- private val container: JPanel,
- private val controller: PyPackagingToolWindowPanel,
-) {
- private val repositories: MutableList = mutableListOf()
- private val installedPackages = PyPackagingTableGroup(
- InstalledPyPackagedRepository(),
- PyPackagesTable(project, this, controller))
- private val invalidRepositories: MutableMap = mutableMapOf()
-
- init {
- installedPackages.addTo(container)
- installedPackages.expand()
- }
-
- fun showSearchResult(installed: List, repoData: List) {
- updatePackages(installed, repoData)
-
- installedPackages.expand()
- installedPackages.updateHeaderText(installed.size)
-
- val tableToData = repositories.map { repo -> repo to repoData.find { it.repository.name == repo.name }!! }
- tableToData.forEach { (table, data) ->
- table.updateHeaderText(data.packages.size + data.moreItems)
- table.expand()
- }
-
- // todo[akniazev]: selecting a package in 'installed' list might make more sense
- tableToData
- .firstOrNull { (_, data) -> data.exactMatch != -1 }
- ?.let { selectPackage(it.second) }
- }
-
- fun resetSearch(installed: List, repoData: List, currentSdk: Sdk?) {
- updatePackages(installed, repoData)
-
- installedPackages.expand()
- installedPackages.setSdkToHeader(currentSdk?.name)
-
- repositories.forEach {
- it.collapse()
- it.updateHeaderText(null)
- }
-
- container.scrollRectToVisible(Rectangle(0, 0))
- }
-
- private fun updatePackages(installed: List, repoData: List) {
- installedPackages.table.items = installed
-
- val (validRepoData, invalid) = repoData.partition { it !is PyInvalidRepositoryViewData }
-
- for (data in validRepoData) {
- val existingRepo = findTableForRepo(data.repository)
- val withExpander = if (data.moreItems > 0) data.packages + listOf(ExpandResultNode(data.moreItems, data.repository)) else data.packages
-
- if (existingRepo != null) {
- // recreate order of the repositories -- it might have changed in the package manager (e.g. Sdk switch)
- existingRepo.removeFrom(container)
- val selectedItem = existingRepo.table.selectedItem()
- existingRepo.items = withExpander
- existingRepo.addTo(container)
- selectedItem?.let { existingRepo.table.selectPackage(it) }
- }
- else {
- val newTable = PyPackagesTable(project, this, controller)
- newTable.items = withExpander
-
- val newTableGroup = PyPackagingTableGroup(data.repository, newTable)
-
- repositories.add(newTableGroup)
- newTableGroup.addTo(container)
- }
- }
- val existingRepositories = validRepoData.map { it.repository.name }
-
- val removedRepositories = repositories.filter { it.name !in existingRepositories }
- removedRepositories.forEach { it.removeFrom(container) }
- repositories.removeAll(removedRepositories)
-
- @Suppress("UNCHECKED_CAST")
- refreshInvalidRepositories(invalid as List)
- }
-
- private fun refreshInvalidRepositories(invalid: List) {
- val invalidRepoNames = invalid.map { it.repository.name }
-
- invalidRepositories.forEach { container.remove(it.value) }
- invalidRepositories.keys.removeIf { it !in invalidRepoNames }
-
- invalid.asSequence()
- .map { it.repository.name!! }
- .filterNot { it in invalidRepositories }
- .map {
- val label = JLabel(message("python.toolwindow.packages.custom.repo.invalid", it)).apply {
- foreground = JBColor.RED
- icon = AllIcons.General.Error
- }
- it to PyPackagesUiComponents.headerPanel(label, null)
- }
- .forEach {
- invalidRepositories[it.first] = it.second
- }
- invalidRepositories.forEach { container.add(it.value) }
- }
-
- fun selectPackage(packageName: String) {
- val repos = getRepos()
- for (repo in repos) {
- val pyPackage = repo.items.firstOrNull { it.name == packageName } ?: continue
- repo.table.selectPackage(pyPackage)
- }
- }
-
- private fun selectPackage(matchData: PyPackagesViewData) {
- val tableWithMatch = findTableForRepo(matchData.repository)!!.table
- val exactMatch = matchData.exactMatch
-
- tableWithMatch.setRowSelectionInterval(exactMatch, exactMatch)
- }
-
- fun removeSelectionNotFormTable(table: JTable) {
- if (table != installedPackages.table) installedPackages.table.clearSelection()
- repositories.asSequence()
- .filter { it.table != table }
- .forEach { it.table.clearSelection() }
- }
-
- private fun findTableForRepo(repository: PyPackageRepository) = repositories.find { it.name == repository.name }
-
- fun selectNextFrom(currentTable: JTable) {
- val targetGroup = when (currentTable) {
- installedPackages.table -> repositories.firstOrNull()
- else -> {
- val currentIndex = repositories.indexOfFirst { it.table == currentTable }
- if (currentIndex + 1 != repositories.size) repositories[currentIndex + 1]
- else return
- }
- }
-
-
- targetGroup?.apply {
- if (items.isNotEmpty()) {
- expand()
- table.setRowSelectionInterval(0, 0)
- table.requestFocus()
- }
- }
- }
-
- fun selectPreviousOf(currentTable: JTable) {
- val targetGroup = when (currentTable) {
- installedPackages.table -> return
- repositories.firstOrNull()?.table -> installedPackages
- else -> {
- val currentIndex = repositories.indexOfFirst { it.table == currentTable }
- repositories[currentIndex - 1]
- }
- }
-
-
- targetGroup.apply {
- if (items.isNotEmpty()) {
- expand()
- val newIndex = items.size - 1
- table.setRowSelectionInterval(newIndex, newIndex)
- table.requestFocus()
- }
- }
- }
-
- fun getSelectedPackages(): List {
- val repos = getRepos()
- return repos.flatMap { it.table.selectedItems() }
- }
-
- fun collapseAll() {
- getRepos().forEach { it.collapse() }
- }
-
- private fun getRepos() = listOf(installedPackages) + repositories
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt
index cdb8e1aceb2e..35830023f563 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt
@@ -10,9 +10,11 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManagerEvent
import com.intellij.openapi.fileEditor.FileEditorManagerListener
+import com.intellij.openapi.module.Module
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.options.ex.SingleConfigurableEditor
import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.modules
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ModuleRootEvent
import com.intellij.openapi.roots.ModuleRootListener
@@ -21,45 +23,41 @@ import com.intellij.openapi.vfs.VirtualFileManager
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.getOrThrow
import com.jetbrains.python.packaging.*
+ import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+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.packaging.conda.CondaPackage
import com.jetbrains.python.packaging.management.*
import com.jetbrains.python.packaging.management.ui.PythonPackageManagerUI
import com.jetbrains.python.packaging.management.ui.updatePackagesByNamesBackground
-import com.jetbrains.python.packaging.repository.PyPackageRepositories
-import com.jetbrains.python.packaging.repository.PyPackageRepository
-import com.jetbrains.python.packaging.repository.PyRepositoriesList
-import com.jetbrains.python.packaging.repository.checkValid
+import com.jetbrains.python.packaging.packageRequirements.PackageNode
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor
+import com.jetbrains.python.packaging.repository.*
import com.jetbrains.python.packaging.statistics.PythonPackagesToolwindowStatisticsCollector
import com.jetbrains.python.packaging.toolwindow.model.*
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.pythonSdk
-import com.jetbrains.python.statistics.modules
import kotlinx.coroutines.*
import org.jetbrains.annotations.Nls
@Service(Service.Level.PROJECT)
class PyPackagingToolWindowService(val project: Project, val serviceScope: CoroutineScope) : Disposable {
private var toolWindowPanel: PyPackagingToolWindowPanel? = null
-
private var installedPackages: Map = emptyMap()
-
private var searchJob: Job? = null
private var currentQuery: String = ""
-
internal var currentSdk: Sdk? = null
- private var managerUI: PythonPackageManagerUI? = null
+ private lateinit var managerUI: PythonPackageManagerUI
private val manager: PythonPackageManager?
- get() = managerUI?.manager
+ get() = managerUI.manager
private val invalidRepositories: List
get() = service().invalidRepositories.map(::PyInvalidRepositoryViewData)
-
fun initialize(toolWindowPanel: PyPackagingToolWindowPanel) {
this.toolWindowPanel = toolWindowPanel
serviceScope.launch(Dispatchers.IO) {
@@ -73,22 +71,22 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
val packageManager = manager ?: return null
return withContext(Dispatchers.IO) {
PythonPackagesToolwindowStatisticsCollector.requestDetailsEvent.log(project)
+ val pkgName = NormalizedPythonPackageName.from(selectedPackage.name).name
val spec = when (selectedPackage) {
- is InstalledPackage -> packageManager.findPackageSpecification(selectedPackage.name)
- is InstallablePackage -> selectedPackage.repository.findPackageSpecification(selectedPackage.name)
- is ExpandResultNode -> selectedPackage.repository.findPackageSpecification(selectedPackage.name)
- else -> error("Invalidate package spec ${selectedPackage::class.java.name}")
+ is InstalledPackage -> packageManager.findPackageSpecification(pkgName)
+ is InstallablePackage -> selectedPackage.repository.findPackageSpecification(pkgName)
+ is ExpandResultNode -> selectedPackage.repository.findPackageSpecification(pkgName)
+ is RequirementPackage -> selectedPackage.repository.findPackageSpecification(pkgName)
}
if (spec == null) {
return@withContext null
}
- spec.let { packageManager.repositoryManager.getPackageDetails(it).getOrThrow() } ?: error("Invalid package specification")
+ spec.let { packageManager.repositoryManager.getPackageDetails(it).getOrThrow() }
}
}
-
fun handleSearch(query: String) {
val manager = manager ?: return
val prevSelected = toolWindowPanel?.getSelectedPackage()
@@ -130,20 +128,27 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
suspend fun installPackage(installRequest: PythonPackageInstallRequest, options: List = emptyList()) {
PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
- managerUI?.installPackagesBackground(installRequest, options)?.let {
+ managerUI.installPackagesBackground(installRequest, options)?.let {
+ handleActionCompleted(message("python.packaging.notification.installed", installRequest.title))
+ }
+ }
+
+ suspend fun installPackage(pkg: PythonPackage, options: List = emptyList()) {
+ val installRequest = manager?.findPackageSpecification(pkg.name, pkg.version)?.toInstallRequest() ?: return
+ PythonPackagesToolwindowStatisticsCollector.installPackageEvent.log(project)
+ managerUI.installPackagesBackground(installRequest, options)?.let {
handleActionCompleted(message("python.packaging.notification.installed", installRequest.title))
}
}
suspend fun deletePackage(vararg selectedPackages: InstalledPackage) {
PythonPackagesToolwindowStatisticsCollector.uninstallPackageEvent.log(project)
- managerUI?.uninstallPackagesBackground(selectedPackages.map { it.instance.name }) ?: return
+ managerUI.uninstallPackagesBackground(selectedPackages.map { it.instance.name }) ?: return
handleActionCompleted(message("python.packaging.notification.deleted", selectedPackages.joinToString(", ") { it.name }))
-
}
suspend fun updatePackages(vararg packages: String) {
- managerUI?.updatePackagesByNamesBackground(packages.toList()) ?: return
+ managerUI.updatePackagesByNamesBackground(packages.toList()) ?: return
val singlePackage = packages.singleOrNull()
@@ -154,6 +159,8 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
else {
handleActionCompleted(message("python.packaging.notification.all.updated"))
}
+
+ refreshInstalledPackages()
}
@@ -172,10 +179,10 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
val previousSdk = currentSdk
currentSdk = sdk
- managerUI = sdk?.let { PythonPackageManagerUI.forSdk(project, it) }
if (sdk == null) {
return
}
+ managerUI = PythonPackageManagerUI.forSdk(project, sdk)
withContext(Dispatchers.EDT) {
@@ -226,22 +233,127 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
})
}
- suspend fun refreshInstalledPackages() {
- val packages = manager?.listInstalledPackages()?.map {
- val spec = manager?.findPackageSpecification(it.name, it.version)
- val repository = spec?.repository
- val nextVersionRaw = manager?.listOutdatedPackagesSnapshot()[it.name]?.latestVersion
- val nextVersion = nextVersionRaw?.let { PyPackageVersionNormalizer.normalize(it) }
- InstalledPackage(it, repository, nextVersion)
- } ?: emptyList()
+ /**
+ * Information about a Python package and its state in the package management system.
+ *
+ * @property packageData Information about the currently installed package
+ * @property repository The package repository where this package is hosted
+ * @property nextVersion The next available version of the package in its repository.
+ * Null if the package is already at its latest version
+ * @property dependencies List of packages that this package depends on
+ */
+ data class PackageInfo(
+ val packageData: InstalledPackage,
+ val repository: PyPackageRepository,
+ val nextVersion: String?,
+ val dependencies: List
+ )
- installedPackages = packages.associateBy { it.name }
+ suspend fun refreshInstalledPackages() {
+ val sdk = currentSdk ?: return
+ val targetModule = findTargetModule(sdk) ?: return
+ val manager = manager ?: return
+ withContext(Dispatchers.Default) {
+ val declaredPackages = manager.reloadDependencies()
+ val installedDeclaredPackages = findInstalledDeclaredPackages(declaredPackages)
+ val treeExtractor = PythonPackageRequirementsTreeExtractor.forSdk(sdk)
+
+ val packagesWithDependencies = if (treeExtractor != null) {
+ processPackagesWithRequirementsTree(
+ installedDeclaredPackages,
+ treeExtractor,
+ targetModule
+ )
+ } else {
+ emptyList()
+ }
+
+ val standalonePackages = findStandalonePackages(packagesWithDependencies)
+
+ installedPackages = (packagesWithDependencies + standalonePackages)
+ .associateBy { it.name }
+ }
withContext(Dispatchers.Main) {
handleSearch(query = currentQuery)
}
}
+ private fun findTargetModule(sdk: Sdk): Module? =
+ project.modules.find { it.pythonSdk == sdk }
+
+ private suspend fun findInstalledDeclaredPackages(declaredPackages: List): List =
+ manager?.listInstalledPackages()?.filter {
+ it.name in declaredPackages.map { pkg -> pkg.name }
+ } ?: emptyList()
+
+ private suspend fun processPackagesWithRequirementsTree(
+ packages: List,
+ treeExtractor: PythonPackageRequirementsTreeExtractor,
+ targetModule: Module,
+ ): List {
+ return packages.mapNotNull { pkg ->
+ val tree = treeExtractor.extract(pkg, targetModule)
+ createInstalledPackageFromTree(pkg, tree)
+ }
+ }
+
+ private suspend fun createInstalledPackageFromTree(
+ pkg: PythonPackage,
+ tree: PackageNode,
+ ): InstalledPackage? {
+ val manager = manager ?: return null
+ val spec = manager.findPackageSpecification(pkg.name, pkg.version)
+ val repository = spec?.repository
+ val nextVersionRaw = manager.listOutdatedPackagesSnapshot()[pkg.name]?.latestVersion
+ val nextVersion = nextVersionRaw?.let { PyPackageVersionNormalizer.normalize(it) }
+ val requirements = createRequirementsFromTree(tree.children, repository ?: PyPIPackageRepository)
+
+ return InstalledPackage(pkg, repository, nextVersion, requirements)
+ }
+
+ private suspend fun createRequirementsFromTree(
+ nodes: List,
+ repository: PyPackageRepository,
+ ): List {
+ val manager = manager ?: return emptyList()
+
+ return nodes.mapNotNull { node ->
+ val packageName = node.name.name
+ val dependencyPkg = manager.listInstalledPackages().find { it.name == packageName }
+ dependencyPkg?.let { depPkg ->
+ val childRequirements = createRequirementsFromTree(node.children, repository)
+ RequirementPackage(depPkg, repository, childRequirements)
+ }
+ }
+ }
+
+ private fun collectPackageNamesRecursively(
+ pkg: DisplayablePackage,
+ ): List {
+ return listOf(pkg.name) + pkg.getRequirements().flatMap { requirement ->
+ collectPackageNamesRecursively(requirement)
+ }
+ }
+
+ private suspend fun findStandalonePackages(
+ processedPackages: List,
+ ): List {
+ val manager = manager ?: return emptyList()
+ val processedPackageNames = processedPackages.flatMap { pkg ->
+ collectPackageNamesRecursively(pkg)
+ }.toSet()
+
+ return manager.listInstalledPackages()
+ .filter { it.name !in processedPackageNames }
+ .map { pkg ->
+ val repository = installedPackages.values.find { it.name == pkg.name }?.repository ?: PyPIPackageRepository
+ val nextVersionRaw = manager.listOutdatedPackagesSnapshot()[pkg.name]?.latestVersion
+ val nextVersion = nextVersionRaw?.let { PyPackageVersionNormalizer.normalize(it) }
+ InstalledPackage(pkg, repository, nextVersion, emptyList())
+ }
+ }
+
private suspend fun handleActionCompleted(text: @Nls String) {
VirtualFileManager.getInstance().asyncRefresh()
showPackagingNotification(text)
@@ -274,7 +386,6 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
return PyPackagesViewData(repository, shownPackages, exactMatch, moreItems)
}
-
override fun dispose() {
searchJob?.cancel()
serviceScope.cancel()
@@ -282,7 +393,7 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
fun reloadPackages() {
serviceScope.launch(Dispatchers.Default) {
- managerUI?.reloadPackagesBackground()
+ managerUI.reloadPackagesBackground()
refreshInstalledPackages()
}
}
@@ -367,4 +478,4 @@ class PyPackagingToolWindowService(val project: Project, val serviceScope: Corou
return nameComparator
}
}
-}
\ No newline at end of file
+}
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt
new file mode 100644
index 000000000000..e25d25c079bb
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTreeView.kt
@@ -0,0 +1,228 @@
+// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
+package com.jetbrains.python.packaging.toolwindow
+
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.ui.JBColor
+import com.jetbrains.python.PyBundle.message
+import com.jetbrains.python.packaging.repository.InstalledPyPackagedRepository
+import com.jetbrains.python.packaging.repository.PyPackageRepository
+import com.jetbrains.python.packaging.toolwindow.model.*
+import com.jetbrains.python.packaging.toolwindow.packages.PyPackagingTreeGroup
+import com.jetbrains.python.packaging.toolwindow.packages.tree.PyPackagesTreeTable
+import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
+import java.awt.Rectangle
+import java.awt.event.ComponentAdapter
+import java.awt.event.ComponentEvent
+import javax.swing.JLabel
+import javax.swing.JPanel
+
+internal class PyPackagingTreeView(
+ private val project: Project,
+ private val container: JPanel,
+ private val controller: PyPackagingToolWindowPanel,
+) {
+ private val repositories: MutableList = mutableListOf()
+ private val installedPackages =
+ PyPackagingTreeGroup(
+ InstalledPyPackagedRepository(),
+ PyPackagesTreeTable(project, controller),
+ container
+ )
+
+ private val invalidRepositories: MutableMap = mutableMapOf()
+
+ private var isSyncingTreeSelection = false
+
+ init {
+ installedPackages.addTo(container)
+ installedPackages.expand()
+
+ container.addComponentListener(object : ComponentAdapter() {
+ override fun componentResized(e: ComponentEvent) {
+ synchronizeScrollPaneSize()
+ }
+ })
+
+ getRepos().forEach { tree ->
+ tree.tree.tree.addTreeSelectionListener {
+ syncTreeSelection(tree.tree)
+ }
+ }
+ }
+
+ fun showSearchResult(installed: List, repoData: List) {
+ updatePackages(installed, repoData)
+
+ installedPackages.expand()
+ installedPackages.updatePreferredSize()
+ installedPackages.updateHeaderText(installed.size)
+
+ val tableToData = repositories.map { repo -> repo to repoData.find { it.repository.name == repo.repositoryName }!! }
+ tableToData.forEach { (table, data) ->
+ table.updateHeaderText(data.packages.size + data.moreItems)
+ table.expand()
+ }
+
+ synchronizeScrollPaneSize()
+
+ val exactMatchPackageName = tableToData
+ .firstOrNull { (_, data) -> data.exactMatch != -1 }
+ ?.second?.let { data -> data.packages.getOrNull(data.exactMatch)?.name }
+
+ exactMatchPackageName?.let { packageName ->
+ val installedPackageIndex = installedPackages.items.indexOfFirst { it.name == packageName }
+ if (installedPackageIndex != -1) {
+ installedPackages.tree.tree.setSelectionInterval(installedPackageIndex, installedPackageIndex)
+ installedPackages.tree.table.setRowSelectionInterval(installedPackageIndex, installedPackageIndex)
+ }
+ }
+ }
+
+ fun resetSearch(installed: List, repoData: List, currentSdk: Sdk?) {
+ updatePackages(installed, repoData)
+
+ installedPackages.expand()
+ installedPackages.setSdkToHeader(currentSdk?.name)
+
+ repositories.forEach {
+ it.collapseAll()
+ it.updateHeaderText(null)
+ }
+
+ container.scrollRectToVisible(Rectangle(0, 0))
+ }
+
+ private fun updatePackages(installed: List, repoData: List) {
+ val sortedInstalled = installed.sortedBy { it.name }
+ installedPackages.tree.items = sortedInstalled
+ updateExistingRepository(installedPackages, sortedInstalled)
+ val (validRepoData, invalidData) = repoData.partition { it !is PyInvalidRepositoryViewData }
+
+ updateValidRepositories(validRepoData)
+
+ cleanupRemovedRepositories(validRepoData.map { it.repository.name })
+
+ val invalidRepoData = invalidData.filterIsInstance()
+ refreshInvalidRepositories(invalidRepoData)
+
+ }
+
+ private fun synchronizeScrollPaneSize() {
+ getRepos().forEach { repo ->
+ repo.updatePreferredSize()
+ }
+
+ container.revalidate()
+ container.repaint()
+ }
+
+ private fun updateValidRepositories(validRepoData: List) {
+ for (data in validRepoData) {
+ val withExpander = if (data.moreItems > 0) {
+ data.packages + listOf(ExpandResultNode(data.moreItems, data.repository))
+ }
+ else {
+ data.packages
+ }
+
+ val existingRepo = findTableForRepo(data.repository)
+
+ if (existingRepo != null) {
+ updateExistingRepository(existingRepo, withExpander)
+ }
+ else {
+ createNewRepository(data.repository, withExpander)
+ }
+ }
+ }
+
+ private fun updateExistingRepository(repo: PyPackagingTreeGroup, items: List) {
+ repo.removeFrom(container)
+ val selectedPackage = repo.items.firstOrNull { it in repo.tree.items }
+ repo.items = items
+ repo.addTo(container)
+ if (selectedPackage != null) {
+ repo.tree.items = items
+ }
+
+ synchronizeScrollPaneSize()
+ }
+
+ private fun createNewRepository(repository: PyPackageRepository, items: List) {
+ val newTable = PyPackagesTreeTable(project, controller)
+ newTable.items = items
+ val newTableGroup = PyPackagingTreeGroup(repository, newTable, container)
+ newTableGroup.items = items
+ repositories.add(newTableGroup)
+ newTableGroup.addTo(container)
+ newTable.tree.addTreeSelectionListener {
+ syncTreeSelection(newTable)
+ }
+ synchronizeScrollPaneSize()
+ }
+
+ private fun cleanupRemovedRepositories(existingRepositoryNames: List) {
+ val removedRepositories = repositories.filter { it.repositoryName !in existingRepositoryNames }
+ removedRepositories.forEach { it.removeFrom(container) }
+ repositories.removeAll(removedRepositories)
+ }
+
+ private fun refreshInvalidRepositories(invalid: List) {
+ val invalidRepoNames = invalid.map { it.repository.name }
+
+ invalidRepositories.forEach { container.remove(it.value) }
+ invalidRepositories.keys.removeIf { it !in invalidRepoNames }
+
+ invalid.asSequence()
+ .map { it.repository.name }
+ .filterNot { it in invalidRepositories }
+ .map {
+ val label = JLabel(message("python.toolwindow.packages.custom.repo.invalid", it)).apply {
+ foreground = JBColor.RED
+ icon = AllIcons.General.Error
+ }
+ it to PyPackagesUiComponents.headerPanel(label, null)
+ }
+ .forEach {
+ invalidRepositories[it.first] = it.second
+ }
+ invalidRepositories.forEach { container.add(it.value) }
+ }
+
+ fun selectPackage(packageName: String) {
+ val repos = getRepos()
+ for (repo in repos) {
+ val pkg = repo.items.firstOrNull { it.name == packageName } ?: continue
+ repo.tree.selectPackage(pkg)
+ }
+ }
+
+ private fun findTableForRepo(repository: PyPackageRepository) = repositories.find { it.repositoryName == repository.name }
+
+ fun getSelectedPackages(): List {
+ val repos = getRepos()
+ return repos.flatMap { it.tree.selectedItems() }
+ }
+
+ fun collapseAll() {
+ getRepos().forEach { it.collapseAll() }
+ }
+
+ private fun getRepos() = listOf(installedPackages) + repositories
+
+ internal fun syncTreeSelection(selectedTree: PyPackagesTreeTable) {
+ if (isSyncingTreeSelection) return
+
+ try {
+ isSyncingTreeSelection = true
+ getRepos().map { it.tree }.filter { it != selectedTree }.forEach {
+ it.tree.clearSelection()
+ it.table.clearSelection()
+ }
+ } finally {
+ isSyncingTreeSelection = false
+ }
+ }
+}
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/actions/UpdatePackageToLatestAction.kt b/python/src/com/jetbrains/python/packaging/toolwindow/actions/UpdatePackageToLatestAction.kt
index ca27127ec4ad..25126c604ee3 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/actions/UpdatePackageToLatestAction.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/actions/UpdatePackageToLatestAction.kt
@@ -3,8 +3,10 @@ package com.jetbrains.python.packaging.toolwindow.actions
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAwareAction
import com.jetbrains.python.PyBundle
+import com.jetbrains.python.packaging.management.PythonPackageInstallRequest
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents.selectedPackages
@@ -20,9 +22,12 @@ internal class UpdatePackageToLatestAction : DumbAwareAction() {
return
}
- val service = PyPackagingToolWindowService.getInstance(project)
PyPackageCoroutine.getIoScope(project).launch {
- service.updatePackages(*packages.map { it.name }.toTypedArray())
+ val pyPackages = packages.mapNotNull { pkg ->
+ pkg.repository?.findPackageSpecification(pkg.name, pkg.nextVersion?.presentableText)
+ }
+ val installRequest = PythonPackageInstallRequest.ByRepositoryPythonPackageSpecifications(pyPackages)
+ project.service().installPackage(installRequest)
}
}
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 c59927dd88a6..d594dc1d66ba 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/details/PyPackageDescriptionController.kt
@@ -33,9 +33,7 @@ import com.jetbrains.python.packaging.common.PythonPackageDetails
import com.jetbrains.python.packaging.management.toInstallRequest
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.packaging.toolwindow.actions.InstallWithOptionsPackageAction
-import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
-import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage
-import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
+import com.jetbrains.python.packaging.toolwindow.model.*
import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import kotlinx.coroutines.Dispatchers
@@ -142,7 +140,14 @@ class PyPackageDescriptionController(val project: Project) : Disposable {
}
val comboBox = cell(versionSelector)
comboBox.enabledIf(isManagement.and(progressEnabledProperty.not())).gap(RightGap.SMALL)
- comboBox.visibleIf(progressEnabledProperty.not())
+ comboBox.visibleIf(progressEnabledProperty.not().and(
+ selectedPackage.transform { pkg ->
+ when (pkg) {
+ is InstallablePackage, is InstalledPackage -> true
+ is RequirementPackage, is ExpandResultNode, is DisplayablePackage, null -> false
+ }
+ }
+ ))
installActionButton.action = installAction
installActionButton.options = arrayOf(installWithOptionAction)
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/model/modelComponents.kt b/python/src/com/jetbrains/python/packaging/toolwindow/model/modelComponents.kt
index 387f0a3d47c0..44dc863739db 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/model/modelComponents.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/model/modelComponents.kt
@@ -2,40 +2,37 @@
package com.jetbrains.python.packaging.toolwindow.model
import com.intellij.openapi.util.NlsSafe
-import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.packaging.PyPackageVersion
import com.jetbrains.python.packaging.PyPackageVersionComparator
import com.jetbrains.python.packaging.PyPackageVersionNormalizer
import com.jetbrains.python.packaging.common.PythonPackage
-import com.jetbrains.python.packaging.conda.CondaPackage
import com.jetbrains.python.packaging.repository.PyPackageRepository
-import com.jetbrains.python.psi.icons.PythonPsiApiIcons
import javax.swing.Icon
-sealed class DisplayablePackage(val name: @NlsSafe String, open val repository: PyPackageRepository?)
+sealed class DisplayablePackage(val name: @NlsSafe String, open val repository: PyPackageRepository?) {
+ open fun getRequirements(): List = emptyList()
+}
-class InstalledPackage(val instance: PythonPackage, repository: PyPackageRepository?, val nextVersion: PyPackageVersion? = null) : DisplayablePackage(instance.presentableName, repository) {
+class InstalledPackage(val instance: PythonPackage, repository: PyPackageRepository?, val nextVersion: PyPackageVersion? = null, private val requirements: List) : DisplayablePackage(instance.name, repository) {
val currentVersion: PyPackageVersion? = PyPackageVersionNormalizer.normalize(instance.version)
val isEditMode: Boolean = instance.isEditableMode
- val sourceRepoIcon: Icon?
- get() {
- val condaPackage = instance as? CondaPackage ?: return null
- return if (condaPackage.installedWithPip) {
- PythonPsiApiIcons.Python
- }
- else {
- PythonIcons.Python.Anaconda
- }
- }
+ val sourceRepoIcon: Icon = instance.sourceRepoIcon
val canBeUpdated: Boolean
get() {
currentVersion ?: return false
return nextVersion != null && PyPackageVersionComparator.compare(nextVersion, currentVersion) > 0
}
+
+ override fun getRequirements(): List = requirements
}
+class RequirementPackage(val instance: PythonPackage, override val repository: PyPackageRepository, private val requirements: List = emptyList()) : DisplayablePackage(instance.name, repository) {
+ val sourceRepoIcon: Icon = instance.sourceRepoIcon
+
+ override fun getRequirements(): List = requirements
+}
class InstallablePackage(name: String, override val repository: PyPackageRepository) : DisplayablePackage(name, repository)
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagesListController.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagesListController.kt
index 6b4e54f31e4c..4299e715b99a 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagesListController.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagesListController.kt
@@ -11,25 +11,29 @@ import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBPanelWithEmptyText
import com.intellij.util.ui.UIUtil
import com.jetbrains.python.PyBundle.message
-import com.jetbrains.python.packaging.toolwindow.PyPackagingTablesView
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowPanel
+import com.jetbrains.python.packaging.toolwindow.PyPackagingTreeView
import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
import com.jetbrains.python.packaging.toolwindow.model.PyPackagesViewData
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
+import javax.swing.JScrollPane
+import javax.swing.ScrollPaneConstants
-class PyPackagesListController(val project: Project, val controller: PyPackagingToolWindowPanel) : Disposable {
+internal class PyPackagesListController(val project: Project, val controller: PyPackagingToolWindowPanel) : Disposable {
private val packageListPanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
alignmentX = LEFT_ALIGNMENT
background = UIUtil.getListBackground()
}
- private val tablesView = PyPackagingTablesView(project, packageListPanel, controller)
+ private val tablesView = PyPackagingTreeView(project, packageListPanel, controller)
- private val scrollingPackageListComponent = ScrollPaneFactory.createScrollPane(packageListPanel, true)
+ private val scrollingPackageListComponent: JScrollPane = ScrollPaneFactory.createScrollPane(packageListPanel, true).apply {
+ horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
+ }
private val loadingPanel = JBPanelWithEmptyText().apply {
emptyText.appendLine(AnimatedIcon.Default.INSTANCE, message("python.toolwindow.packages.description.panel.loading"), SimpleTextAttributes.SIMPLE_CELL_ATTRIBUTES, null)
@@ -72,7 +76,7 @@ class PyPackagesListController(val project: Project, val controller: PyPackaging
tablesView.collapseAll()
}
- internal fun setLoadingState(isLoading: Boolean) {
+ internal fun setLoadingState(isLoading: Boolean) {
val newPanel = if (isLoading) loadingPanel else scrollingPackageListComponent
val currentComponent = component.components.firstOrNull()
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagingTableGroup.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagingTableGroup.kt
deleted file mode 100644
index 424dff017b62..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagingTableGroup.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright 2000-2024 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
-
-import com.intellij.icons.AllIcons
-import com.intellij.openapi.util.NlsSafe
-import com.intellij.ui.components.JBLabel
-import com.jetbrains.python.PyBundle
-import com.jetbrains.python.packaging.repository.PyPackageRepository
-import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
-import com.jetbrains.python.packaging.toolwindow.packages.table.PyPackagesTable
-import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
-import org.jetbrains.annotations.Nls
-import javax.swing.JPanel
-
-internal class PyPackagingTableGroup(val repository: PyPackageRepository, val table: PyPackagesTable) {
- @NlsSafe
- val name: String = repository.name!!
-
- private var expanded = false
- private val label = JBLabel(name).apply { icon = AllIcons.General.ArrowDown }
- private val header: JPanel = PyPackagesUiComponents.headerPanel(label, table)
- private var itemsCount: Int? = null
-
-
- internal var items: List
- get() = table.items
- set(value) {
- table.items = value
- }
-
- fun collapse() {
- expanded = false
- table.isVisible = false
- label.icon = if (table.isVisible) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
- }
-
- fun expand() {
- expanded = true
- table.isVisible = true
- label.icon = if (table.isVisible) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
- }
-
- fun updateHeaderText(newItemCount: Int?) {
- itemsCount = newItemCount
- label.text = if (itemsCount == null) name else PyBundle.message("python.toolwindow.packages.custom.repo.searched", name, itemsCount)
- }
-
- fun setSdkToHeader(@Nls sdkName: String?) {
- itemsCount = null
- @Suppress("HardCodedStringLiteral")
- label.text = "$name (${sdkName})"
- }
-
-
- fun addTo(panel: JPanel) {
- panel.add(header)
- panel.add(table)
- }
-
- fun removeFrom(panel: JPanel) {
- panel.remove(header)
- panel.remove(table)
- }
-
- fun repaint() {
- table.invalidate()
- table.repaint()
- }
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagingTreeGroup.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagingTreeGroup.kt
new file mode 100644
index 000000000000..0a7afaef5381
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPackagingTreeGroup.kt
@@ -0,0 +1,146 @@
+// Copyright 2000-2024 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
+
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.util.NlsSafe
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.util.ui.JBUI
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.packaging.repository.PyPackageRepository
+import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
+import com.jetbrains.python.packaging.toolwindow.packages.tree.PyPackagesTreeListener
+import com.jetbrains.python.packaging.toolwindow.packages.tree.PyPackagesTreeTable
+import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
+import org.jetbrains.annotations.Nls
+import java.awt.Component
+import java.awt.Dimension
+import javax.swing.JPanel
+import javax.swing.ScrollPaneConstants
+import javax.swing.SwingConstants
+import javax.swing.SwingUtilities
+
+internal class PyPackagingTreeGroup(
+ repository: PyPackageRepository,
+ val tree: PyPackagesTreeTable,
+ private val container: JPanel,
+) {
+
+ private data class HeaderProperties(
+ val label: JBLabel,
+ val panel: JPanel,
+ )
+
+ @NlsSafe
+ val repositoryName: String = repository.name
+
+ private var itemsCount: Int? = null
+
+ val scrollPane = JBScrollPane(tree).apply {
+ border = JBUI.Borders.empty()
+ verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
+ horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
+ }
+
+ private val headerProperties: HeaderProperties = createHeaderProperties()
+
+ internal var items: List
+ get() = tree.items
+ set(value) {
+ tree.items = value
+ }
+
+ init {
+ setupTreeListener()
+ }
+
+ private fun createHeaderProperties(): HeaderProperties {
+ val label = JBLabel(repositoryName).apply {
+ icon = AllIcons.General.ArrowDown
+ horizontalAlignment = SwingConstants.LEFT
+ verticalAlignment = SwingConstants.CENTER
+ }
+ val panel = PyPackagesUiComponents.headerPanel(label, scrollPane)
+ return HeaderProperties(label, panel)
+ }
+
+ private fun setupTreeListener() {
+ tree.setTreeListener(object : PyPackagesTreeListener {
+ override fun onTreeStructureChanged() {
+ SwingUtilities.invokeLater {
+ updatePreferredSize()
+ repaint()
+ }
+ }
+ })
+ }
+
+ fun setItems(value: List) {
+ tree.items = value
+ }
+
+ fun updatePreferredSize() {
+ val totalHeight = tree.tree.rowCount * tree.tree.rowHeight
+ val width = container.width
+
+ scrollPane.preferredSize = Dimension(width, totalHeight)
+ scrollPane.minimumSize = Dimension(width, totalHeight)
+ scrollPane.maximumSize = Dimension(width, totalHeight)
+ }
+
+ fun collapse() {
+ scrollPane.isVisible = false
+ updateExpandStateIcon()
+ repaint()
+ }
+
+ fun collapseAll() {
+ for (i in 0 until tree.tree.rowCount) {
+ tree.tree.collapseRow(i)
+ }
+ collapse()
+ }
+
+
+ fun expand() {
+ scrollPane.isVisible = true
+ updateExpandStateIcon()
+ repaint()
+ }
+
+ private fun updateExpandStateIcon() {
+ headerProperties.label.icon = if (scrollPane.isVisible)
+ AllIcons.General.ArrowDown
+ else
+ AllIcons.General.ArrowRight
+ }
+
+ fun updateHeaderText(newItemCount: Int?) {
+ itemsCount = newItemCount
+ headerProperties.label.text = if (itemsCount == null)
+ repositoryName
+ else
+ PyBundle.message("python.toolwindow.packages.custom.repo.searched", repositoryName, itemsCount)
+ }
+
+ fun setSdkToHeader(@Nls sdkName: String?) {
+ itemsCount = null
+ headerProperties.label.text = PyBundle.message("python.toolwindow.packages.sdk.label.html", repositoryName, sdkName)
+ }
+
+ fun addTo(panel: JPanel) {
+ scrollPane.alignmentX = Component.LEFT_ALIGNMENT
+ panel.add(headerProperties.panel)
+ panel.add(scrollPane)
+ }
+
+ fun removeFrom(panel: JPanel) {
+ panel.remove(headerProperties.panel)
+ panel.remove(scrollPane)
+ }
+
+ fun repaint() {
+ container.revalidate()
+ container.repaint()
+ }
+}
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPaginationAwareRenderer.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPaginationAwareRenderer.kt
deleted file mode 100644
index a481d324c00b..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/PyPaginationAwareRenderer.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright 2000-2024 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
-
-import com.intellij.icons.AllIcons
-import com.intellij.openapi.util.NlsSafe
-import com.intellij.ui.components.JBLabel
-import com.intellij.ui.hover.TableHoverListener
-import com.intellij.util.ui.JBUI
-import com.intellij.util.ui.UIUtil
-import com.jetbrains.python.PyBundle
-import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
-import com.jetbrains.python.packaging.toolwindow.model.ExpandResultNode
-import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage
-import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
-import com.jetbrains.python.packaging.toolwindow.packages.table.PyPackagesTable
-import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
-import java.awt.Component
-import java.awt.font.TextAttribute
-import javax.swing.BoxLayout
-import javax.swing.JLabel
-import javax.swing.JPanel
-import javax.swing.JTable
-import javax.swing.table.DefaultTableCellRenderer
-
-internal class PyPaginationAwareRenderer : DefaultTableCellRenderer() {
- private val nameLabel = JBLabel().apply { border = JBUI.Borders.empty(0, 12) }
-
- private val versionLabel = JLabel().apply { border = JBUI.Borders.emptyRight(12) }
-
- private val linkLabel = JLabel(PyBundle.message("action.python.packages.install.text")).apply {
- border = JBUI.Borders.emptyRight(12)
- foreground = JBUI.CurrentTheme.Link.Foreground.ENABLED
- }
-
- private val namePanel = JPanel().apply {
- layout = BoxLayout(this, BoxLayout.X_AXIS)
- border = JBUI.Borders.empty()
- add(nameLabel)
- }
-
- private val versionPanel = PyPackagesUiComponents.boxPanel {
- border = JBUI.Borders.emptyRight(12)
- add(versionLabel)
- }
-
- override fun getTableCellRendererComponent(
- table: JTable,
- value: Any?,
- isSelected: Boolean,
- hasFocus: Boolean,
- row: Int,
- column: Int,
- ): Component {
- val rowSelected = row in table.selectedRows
- val tableFocused = table.hasFocus()
-
- if (value is ExpandResultNode) {
- if (column == 1) {
- versionPanel.removeAll()
- return versionPanel
- }
- else {
- nameLabel.text = PyBundle.message("python.toolwindow.packages.load.more", value.more)
- nameLabel.foreground = UIUtil.getContextHelpForeground()
- return namePanel
- }
- }
-
- // version column
- if (column == 1) {
- versionPanel.background = JBUI.CurrentTheme.Table.background(rowSelected, tableFocused)
- versionPanel.foreground = JBUI.CurrentTheme.Table.foreground(rowSelected, tableFocused)
- versionPanel.removeAll()
-
- if (value is InstallablePackage) {
- linkLabel.text = PyBundle.message("action.python.packages.install.text")
- linkLabel.updateUnderline(table, row)
- if (rowSelected || TableHoverListener.getHoveredRow(table) == row) {
- versionPanel.add(linkLabel)
- }
- }
- else if (value is InstalledPackage && value.nextVersion != null && value.canBeUpdated) {
- @NlsSafe val updateLink = value.instance.version + " -> " + value.nextVersion.presentableText
- linkLabel.text = updateLink
- linkLabel.updateUnderline(table, row)
- versionPanel.add(linkLabel)
- }
- else {
- @NlsSafe val version = (value as InstalledPackage).instance.version
- versionLabel.text = version
- versionPanel.add(versionLabel)
- }
- versionLabel.icon = (value as? InstalledPackage)?.sourceRepoIcon
- return versionPanel
- }
-
- // package name column
- val currentPackage = value as DisplayablePackage
-
- namePanel.background = JBUI.CurrentTheme.Table.background(rowSelected, tableFocused)
- namePanel.foreground = JBUI.CurrentTheme.Table.foreground(rowSelected, tableFocused)
-
- nameLabel.text = currentPackage.name
- val isEditMode = (currentPackage as? InstalledPackage)?.isEditMode == true
- nameLabel.icon = if (isEditMode) AllIcons.Actions.Edit else null
- nameLabel.foreground = JBUI.CurrentTheme.Label.foreground()
-
- return namePanel
- }
-
- @Suppress("UNCHECKED_CAST")
- private fun JLabel.updateUnderline(table: JTable, currentRow: Int) {
- val hoveredRow = TableHoverListener.getHoveredRow(table)
- val hoveredColumn = (table as PyPackagesTable).hoveredColumn
- val underline = if (hoveredRow == currentRow && hoveredColumn == 1) TextAttribute.UNDERLINE_ON else -1
-
- val attributes = font.attributes as MutableMap
- attributes[TextAttribute.UNDERLINE] = underline
- attributes[TextAttribute.LIGATURES] = TextAttribute.LIGATURES_ON
- font = font.deriveFont(attributes)
- }
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackageTableMouseAdapter.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackageTableMouseAdapter.kt
deleted file mode 100644
index 698a487fce97..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackageTableMouseAdapter.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright 2000-2024 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.table
-
-import com.intellij.codeInsight.hints.presentation.MouseButton
-import com.intellij.codeInsight.hints.presentation.mouseButton
-import com.intellij.openapi.application.EDT
-import com.intellij.openapi.components.service
-import com.intellij.openapi.project.Project
-import com.intellij.ui.awt.RelativePoint
-import com.intellij.ui.hover.TableHoverListener
-import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
-import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage
-import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
-import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
-import com.jetbrains.python.packaging.utils.PyPackageCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.awt.event.MouseAdapter
-import java.awt.event.MouseEvent
-
-internal class PyPackageTableMouseAdapter(private val table: PyPackagesTable) : MouseAdapter() {
- val project: Project = table.project
-
- val service
- get() = project.service()
-
- override fun mouseClicked(e: MouseEvent) {
- // double click or click on package name column, nothing to be done
- if (e.mouseButton != MouseButton.Left ||
- e.clickCount != 1 ||
- table.columnAtPoint(e.point) != 1) {
- return
- }
-
- val hoveredRow = TableHoverListener.getHoveredRow(table)
- val selectedPackage = table.items.getOrNull(hoveredRow) ?: table.selectedItem() ?: return
-
- if (selectedPackage is InstallablePackage) {
- PyPackageCoroutine.launch(project) {
- val details = service.detailsForPackage(selectedPackage) ?: return@launch
- withContext(Dispatchers.EDT) {
- PyPackagesUiComponents.createAvailableVersionsPopup(selectedPackage, details, project).show(RelativePoint(e))
- }
- }
- return
- }
-
- if (selectedPackage is InstalledPackage &&
- selectedPackage.canBeUpdated &&
- selectedPackage.repository != null &&
- selectedPackage.nextVersion != null) {
- PyPackageCoroutine.launch(project) {
- project.service().updatePackages(selectedPackage.name)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackagesHoverListener.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackagesHoverListener.kt
deleted file mode 100644
index f747466e91e0..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackagesHoverListener.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright 2000-2024 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.table
-
-import com.intellij.ui.hover.TableHoverListener
-import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage
-import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
-import java.awt.Cursor
-import javax.swing.JTable
-
-internal class PyPackagesHoverListener(private val pyPackageTable: PyPackagesTable) : TableHoverListener() {
-
- override fun onHover(table: JTable, row: Int, column: Int) {
- pyPackageTable.hoveredColumn = column
- if (column == 1) {
- table.repaint(table.getCellRect(row, column, true))
- val currentPackage = pyPackageTable.items[row]
- if (currentPackage is InstallablePackage
- || (currentPackage is InstalledPackage && currentPackage.canBeUpdated)) {
- table.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
- return
- }
- }
- table.cursor = Cursor.getDefaultCursor()
- }
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackagesTable.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackagesTable.kt
deleted file mode 100644
index 86cc73caead8..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/packages/table/PyPackagesTable.kt
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright 2000-2024 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.table
-
-import com.intellij.openapi.actionSystem.ActionGroup
-import com.intellij.openapi.actionSystem.ActionManager
-import com.intellij.openapi.components.service
-import com.intellij.openapi.project.Project
-import com.intellij.ui.DoubleClickListener
-import com.intellij.ui.PopupHandler
-import com.intellij.ui.SideBorder
-import com.intellij.ui.table.JBTable
-import com.intellij.util.ui.NamedColorUtil
-import com.jetbrains.python.packaging.toolwindow.PyPackagingTablesView
-import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowPanel
-import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
-import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
-import com.jetbrains.python.packaging.toolwindow.model.ExpandResultNode
-import com.jetbrains.python.packaging.toolwindow.packages.PyPaginationAwareRenderer
-import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesTableModel
-import java.awt.event.ActionEvent
-import java.awt.event.KeyEvent
-import java.awt.event.MouseEvent
-import javax.swing.AbstractAction
-import javax.swing.KeyStroke
-import javax.swing.ListSelectionModel
-
-@Suppress("UsePropertyAccessSyntax")
-internal class PyPackagesTable(
- val project: Project,
- tablesView: PyPackagingTablesView,
- val controller: PyPackagingToolWindowPanel,
-) : JBTable(PyPackagesTableModel()) {
- internal var hoveredColumn = -1
-
- @Suppress("UNCHECKED_CAST")
- val model: PyPackagesTableModel = getModel() as PyPackagesTableModel
-
- var items: List
- get() = model.items
- set(value) {
- model.items = value.toMutableList()
- }
-
- init {
- val service = project.service()
- setShowGrid(false)
- setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION)
-
- val column = columnModel.getColumn(1)
- column.minWidth = 130
- column.maxWidth = 130
- column.resizable = false
- border = SideBorder(NamedColorUtil.getBoundsColor(), SideBorder.BOTTOM)
- rowHeight = 20
-
- initCrossNavigation(service, tablesView)
-
- val hoverListener = PyPackagesHoverListener(this)
- hoverListener.addTo(this)
-
- addMouseListener(PyPackageTableMouseAdapter(this))
-
- selectionModel.addListSelectionListener {
- val pkg = selectedItem()
- if (pkg != null && pkg !is ExpandResultNode) {
- tablesView.removeSelectionNotFormTable(this)
- controller.packageSelected(pkg)
- }
- else {
- controller.setEmpty()
- }
- }
-
- object : DoubleClickListener() {
- override fun onDoubleClick(event: MouseEvent): Boolean {
- val pkg = selectedItem() ?: return true
- if (pkg is ExpandResultNode)
- loadMoreItems(service, pkg)
- return true
- }
- }.installOn(this)
-
- val packageActionGroup = ActionManager.getInstance().getAction("PyPackageToolwindowContext") as ActionGroup
- PopupHandler.installPopupMenu(this, packageActionGroup, "PackagePopup")
- }
-
- override fun getCellRenderer(row: Int, column: Int) = PyPaginationAwareRenderer()
-
- fun selectedItem(): DisplayablePackage? = items.getOrNull(selectedRow)
-
- fun selectedItems(): Sequence {
- return selectedRows.asSequence().mapNotNull { items.getOrNull(it) }
- }
-
- fun selectPackage(pkg: DisplayablePackage) {
- val index = items.indexOf(pkg)
- if (index != -1) {
- setRowSelectionInterval(index, index)
- }
- }
-
- private fun initCrossNavigation(service: PyPackagingToolWindowService, tablesView: PyPackagingTablesView) {
- getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ENTER_ACTION)
- actionMap.put(ENTER_ACTION, object : AbstractAction() {
- override fun actionPerformed(e: ActionEvent?) {
- if (selectedRow == -1) return
- val index = selectedRow
- val selectedItem = selectedItem() ?: return
-
- if (selectedItem is ExpandResultNode) {
- loadMoreItems(service, selectedItem)
- }
- setRowSelectionInterval(index, index)
- }
- })
-
- val nextRowAction = actionMap[NEXT_ROW_ACTION]
- actionMap.put(NEXT_ROW_ACTION, object : AbstractAction() {
- override fun actionPerformed(e: ActionEvent?) {
- if (selectedRow == -1) return
-
- if (selectedRow + 1 == items.size) {
- tablesView.selectNextFrom(this@PyPackagesTable)
- }
- else {
- nextRowAction.actionPerformed(e)
- }
- }
- })
-
- val prevRowAction = actionMap[PREVIOUS_ROW_ACTION]
- actionMap.put(PREVIOUS_ROW_ACTION, object : AbstractAction() {
- override fun actionPerformed(e: ActionEvent?) {
- if (selectedRow == -1) return
-
- if (selectedRow == 0) {
- tablesView.selectPreviousOf(this@PyPackagesTable)
- }
- else {
- prevRowAction.actionPerformed(e)
- }
- }
- })
- }
-
- private fun loadMoreItems(service: PyPackagingToolWindowService, node: ExpandResultNode) {
- val result = service.getMoreResultsForRepo(node.repository, items.size - 1) ?: return
- items = items.dropLast(1) + result.packages
- if (result.moreItems > 0) {
- node.more = result.moreItems
- items = items + listOf(node)
- }
- this@PyPackagesTable.revalidate()
- this@PyPackagesTable.repaint()
- }
-
- companion object {
- private const val NEXT_ROW_ACTION = "selectNextRow"
- private const val PREVIOUS_ROW_ACTION = "selectPreviousRow"
- private const val ENTER_ACTION = "ENTER"
- }
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackageTableMouseAdapter.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackageTableMouseAdapter.kt
new file mode 100644
index 000000000000..af63e3a4f1d7
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackageTableMouseAdapter.kt
@@ -0,0 +1,85 @@
+// Copyright 2000-2025 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
+
+import com.intellij.codeInsight.hints.presentation.MouseButton
+import com.intellij.codeInsight.hints.presentation.mouseButton
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.Project
+import com.intellij.ui.awt.RelativePoint
+import com.jetbrains.python.packaging.common.PythonPackage
+import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
+import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
+import com.jetbrains.python.packaging.toolwindow.model.InstallablePackage
+import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
+import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
+import com.jetbrains.python.packaging.utils.PyPackageCoroutine
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.awt.event.MouseAdapter
+import java.awt.event.MouseEvent
+
+private interface PackageHandler {
+ suspend fun handle(treeTable: PyPackagesTreeTable, pkg: DisplayablePackage, event: MouseEvent)
+
+ companion object {
+ fun forPackage(pkg: DisplayablePackage): PackageHandler? = when (pkg) {
+ is InstallablePackage -> InstallablePackageHandler()
+ is InstalledPackage -> InstalledPackageHandler()
+ else -> null
+ }
+ }
+}
+
+private class InstallablePackageHandler : PackageHandler {
+ override suspend fun handle(treeTable: PyPackagesTreeTable, pkg: DisplayablePackage, event: MouseEvent) {
+ val project: Project = treeTable.project
+ val packagingService = project.service()
+ val details = packagingService.detailsForPackage(pkg) ?: return
+
+ withContext(Dispatchers.Main) {
+ PyPackagesUiComponents.createAvailableVersionsPopup(pkg, details, project)
+ .show(RelativePoint(event))
+ treeTable.tree.requestFocus()
+ }
+ }
+}
+
+private class InstalledPackageHandler : PackageHandler {
+ override suspend fun handle( treeTable: PyPackagesTreeTable, pkg: DisplayablePackage, event: MouseEvent) {
+ val project: Project = treeTable.project
+ val packagingService = project.service()
+ if (pkg !is InstalledPackage || !pkg.canBeUpdated) return
+
+ pkg.nextVersion?.let { version ->
+ val pkgToUpdate = PythonPackage(pkg.name, version.presentableText, false)
+ packagingService.installPackage(pkgToUpdate)
+ }
+ }
+}
+
+internal class PyPackageTableMouseAdapter(private val treeTable: PyPackagesTreeTable) : MouseAdapter() {
+
+ override fun mouseClicked(e: MouseEvent) {
+ if (!isValidLeftClick(e)) return
+
+ val selectedPackage = findTargetPackageAtPoint(e) ?: return
+
+ PyPackageCoroutine.launch(treeTable.project, Dispatchers.IO) {
+ PackageHandler.forPackage (selectedPackage)?.handle(treeTable, selectedPackage, e)
+ }
+ }
+
+ private fun isValidLeftClick(e: MouseEvent): Boolean =
+ e.mouseButton == MouseButton.Left && e.clickCount == SINGLE_CLICK
+
+ private fun findTargetPackageAtPoint(e: MouseEvent): DisplayablePackage? {
+ if (e.source != treeTable.table) return null
+
+ val row = treeTable.table.rowAtPoint(e.point)
+ return if (row >= 0) treeTable.items.getOrNull(row) else null
+ }
+
+ companion object {
+ private const val SINGLE_CLICK = 1
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTreeTable.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTreeTable.kt
new file mode 100644
index 000000000000..1bf3743c66ca
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTreeTable.kt
@@ -0,0 +1,195 @@
+// Copyright 2000-2025 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
+
+import com.intellij.openapi.actionSystem.ActionGroup
+import com.intellij.openapi.actionSystem.ActionManager
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.putUserData
+import com.intellij.openapi.util.Key
+import com.intellij.ui.DoubleClickListener
+import com.intellij.ui.PopupHandler
+import com.intellij.ui.components.JBTreeTable
+import com.intellij.ui.hover.TableHoverListener
+import com.intellij.ui.hover.TreeHoverListener
+import com.intellij.ui.treeStructure.treetable.TreeTableModel
+import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowPanel
+import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
+import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
+import com.jetbrains.python.packaging.toolwindow.model.ExpandResultNode
+import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.PackageNameCellRenderer
+import com.jetbrains.python.packaging.toolwindow.packages.tree.renderers.PackageVersionCellRenderer
+import org.jetbrains.annotations.ApiStatus
+import java.awt.event.MouseEvent
+import javax.swing.JScrollPane
+import javax.swing.JTable
+import javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
+import javax.swing.event.TreeExpansionEvent
+import javax.swing.event.TreeExpansionListener
+import javax.swing.event.TreeSelectionListener
+import javax.swing.tree.TreeSelectionModel.SINGLE_TREE_SELECTION
+
+@ApiStatus.Internal
+class PyPackagesTreeTable(
+ val project: Project,
+ private val controller: PyPackagingToolWindowPanel,
+ private var treeListener: PyPackagesTreeListener? = null,
+) : JBTreeTable(PyPackagesTreeTableModel()), PackageTreeTableOperations {
+
+ companion object {
+ private const val COLUMN_PROPORTION = 0.3f
+ private const val POPUP_MENU_PLACE = "PackagePopup"
+ private const val PACKAGE_ACTION_GROUP_ID = "PyPackageToolwindowContext"
+ private const val INVALID_POSITION = -1
+ internal val TREE_TABLE_KEY: Key = Key.create("PyPackageToolwindow.TreeTable")
+ }
+
+ private val treeTableModel: PyPackagesTreeTableModel
+ get() = model as PyPackagesTreeTableModel
+ private val packagingService = project.service()
+
+ var hoveredColumn: Int = INVALID_POSITION
+
+ var items: List = emptyList()
+ set(value) {
+ field = value
+ treeTableModel.items = value
+ }
+
+ init {
+ table.putUserData(TREE_TABLE_KEY, this)
+ initializeUI()
+ }
+
+ private fun initializeUI() {
+ initializeTreeTableProperties()
+ initializeTreeProperties()
+ initializeCellRenderers()
+ setupTreeInteractions()
+ setupContextMenu()
+ }
+
+ private fun initializeTreeTableProperties() {
+ splitter.setResizeEnabled(false)
+ val secondComponent = splitter.secondComponent as JScrollPane
+ secondComponent.apply {
+ horizontalScrollBarPolicy = HORIZONTAL_SCROLLBAR_NEVER
+ }
+ setColumnProportion(COLUMN_PROPORTION)
+ }
+
+ private fun initializeTreeProperties() {
+ tree.apply {
+ isRootVisible = false
+ showsRootHandles = true
+ selectionModel.selectionMode = SINGLE_TREE_SELECTION
+ }
+ }
+
+ private fun initializeCellRenderers() {
+ setDefaultRenderer(TreeTableModel::class.java, PackageNameCellRenderer())
+ setDefaultRenderer(DisplayablePackage::class.java, PackageVersionCellRenderer())
+ }
+
+ private fun setupTreeInteractions() {
+ setupTreeEventListeners()
+ setupMouseAndHoverHandlers()
+ }
+
+ private fun setupTreeEventListeners() {
+ tree.addTreeSelectionListener(createPackageSelectionListener())
+ tree.addTreeExpansionListener(createTreeExpansionListener())
+ installPackageDoubleClickHandler()
+ }
+
+ private fun createPackageSelectionListener() = TreeSelectionListener { event ->
+ val path = event.path ?: return@TreeSelectionListener
+ val node = path.lastPathComponent
+ when (val pkg = treeTableModel.getValueAt(node, 0)) {
+ is DisplayablePackage -> controller.packageSelected(pkg)
+ else -> controller.setEmpty()
+ }
+ }
+
+ private fun createTreeExpansionListener() = object : TreeExpansionListener {
+ override fun treeExpanded(event: TreeExpansionEvent) {
+ treeListener?.onTreeStructureChanged()
+ }
+ override fun treeCollapsed(event: TreeExpansionEvent) {
+ treeListener?.onTreeStructureChanged()
+ }
+ }
+
+ private fun setupMouseAndHoverHandlers() {
+ val mouseHandler = PyPackageTableMouseAdapter(this)
+ tree.addMouseListener(mouseHandler)
+ table.addMouseListener(mouseHandler)
+
+ setupHoverTracking()
+ }
+
+ private fun setupHoverTracking() {
+ TreeHoverListener.DEFAULT.addTo(tree)
+ TableHoverListener.DEFAULT.addTo(table)
+ object : TableHoverListener() {
+ override fun onHover(table: JTable, row: Int, column: Int) {
+ hoveredColumn = column
+ }
+ }.addTo(table)
+ }
+
+ private fun installPackageDoubleClickHandler() {
+ object : DoubleClickListener() {
+ override fun onDoubleClick(event: MouseEvent): Boolean {
+ val pkg = selectedItem() as? ExpandResultNode ?: return true
+ loadMoreItems(pkg)
+ return true
+ }
+ }.installOn(tree)
+ }
+
+ private fun setupContextMenu() {
+ val packageActionGroup = ActionManager.getInstance()
+ .getAction(PACKAGE_ACTION_GROUP_ID) as ActionGroup
+ PopupHandler.installPopupMenu(tree, packageActionGroup, POPUP_MENU_PLACE)
+ PopupHandler.installPopupMenu(table, packageActionGroup, POPUP_MENU_PLACE)
+ }
+
+ private fun loadMoreItems(node: ExpandResultNode) {
+ val result = packagingService.getMoreResultsForRepo(node.repository, items.size - 1) ?: return
+ items = items.dropLast(1) + result.packages
+ if (result.moreItems > 0) {
+ node.more = result.moreItems
+ items = items + listOf(node)
+ }
+ }
+
+ override fun setTreeListener(listener: PyPackagesTreeListener) {
+ treeListener = listener
+ }
+
+ override fun selectPackage(pkg: DisplayablePackage) {
+ val index = items.indexOf(pkg)
+ if (index != -1) {
+ tree.setSelectionRow(index)
+ }
+ }
+
+ override fun selectedItems(): Sequence =
+ tree.selectionRows?.asSequence()?.mapNotNull { row ->
+ val node = tree.getPathForRow(row)?.lastPathComponent ?: return@mapNotNull null
+ treeTableModel.getValueAt(node, 0) as? DisplayablePackage
+ } ?: emptySequence()
+}
+
+private interface PackageTreeTableOperations {
+ fun setTreeListener(listener: PyPackagesTreeListener)
+ fun selectPackage(pkg: DisplayablePackage)
+ fun selectedItems(): Sequence
+ fun selectedItem(): DisplayablePackage? = selectedItems().firstOrNull()
+}
+
+@ApiStatus.Internal
+interface PyPackagesTreeListener {
+ fun onTreeStructureChanged()
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTreeTableModel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTreeTableModel.kt
new file mode 100644
index 000000000000..2a9928e78720
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/PyPackagesTreeTableModel.kt
@@ -0,0 +1,97 @@
+// Copyright 2000-2025 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
+
+import com.intellij.ui.treeStructure.treetable.TreeTableModel
+import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
+import javax.swing.JTree
+import javax.swing.event.TreeModelListener
+import javax.swing.tree.DefaultMutableTreeNode
+import javax.swing.tree.DefaultTreeModel
+import javax.swing.tree.TreePath
+
+/**
+ * TreeTableModel implementation for PyPackagesTree.
+ * Provides two columns: package name and package version.
+ */
+internal class PyPackagesTreeTableModel : TreeTableModel {
+ companion object {
+ private const val ROOT_NODE_NAME = "root"
+ private const val COLUMN_COUNT = 2
+ private const val NAME_COLUMN = 0
+ private const val VERSION_COLUMN = 1
+ }
+
+ private val rootNode = DefaultMutableTreeNode(ROOT_NODE_NAME)
+ private val treeModel = DefaultTreeModel(rootNode)
+ private var tree: JTree? = null
+
+ var items: List = emptyList()
+ set(value) {
+ field = value
+ updateTreeData(value)
+ }
+
+ override fun getColumnCount(): Int = COLUMN_COUNT
+
+ override fun getColumnName(column: Int): String = when (column) {
+ NAME_COLUMN -> ""
+ VERSION_COLUMN -> ""
+ else -> ""
+ }
+
+ override fun getColumnClass(column: Int): Class<*> = when (column) {
+ NAME_COLUMN -> TreeTableModel::class.java
+ VERSION_COLUMN -> DisplayablePackage::class.java
+ else -> Any::class.java
+ }
+
+ override fun getValueAt(node: Any, column: Int): Any? {
+ val treeNode = node as? DefaultMutableTreeNode ?: return null
+ val pkg = treeNode.userObject as? DisplayablePackage ?: return null
+
+ return when (column) {
+ NAME_COLUMN -> pkg
+ VERSION_COLUMN -> pkg
+ else -> null
+ }
+ }
+
+ override fun isCellEditable(node: Any, column: Int): Boolean = false
+
+ override fun setValueAt(aValue: Any?, node: Any, column: Int) {
+ error("unsupported")
+ }
+
+ override fun getChild(parent: Any, index: Int): Any? = treeModel.getChild(parent, index)
+ override fun getChildCount(parent: Any): Int = treeModel.getChildCount(parent)
+ override fun getIndexOfChild(parent: Any, child: Any): Int = treeModel.getIndexOfChild(parent, child)
+ override fun getRoot(): Any = treeModel.root
+ override fun isLeaf(node: Any): Boolean = treeModel.isLeaf(node)
+ override fun valueForPathChanged(path: TreePath, newValue: Any) = treeModel.valueForPathChanged(path, newValue)
+ override fun addTreeModelListener(l: TreeModelListener) = treeModel.addTreeModelListener(l)
+ override fun removeTreeModelListener(l: TreeModelListener) = treeModel.removeTreeModelListener(l)
+
+ override fun setTree(tree: JTree) {
+ this.tree = tree
+ }
+
+ private fun updateTreeData(packages: List) {
+ rootNode.removeAllChildren()
+ packages.forEach { pkg ->
+ rootNode.add(createNodeRecursively(pkg))
+ }
+ treeModel.reload()
+ }
+
+ private fun createNodeRecursively(
+ pkg: DisplayablePackage,
+ ): DefaultMutableTreeNode {
+ val node = DefaultMutableTreeNode(pkg)
+
+ pkg.getRequirements().forEach { requirement ->
+ node.add(createNodeRecursively(requirement))
+ }
+
+ return node
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PackageRendererUtils.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PackageRendererUtils.kt
new file mode 100644
index 000000000000..a7b81a0e2166
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PackageRendererUtils.kt
@@ -0,0 +1,27 @@
+// Copyright 2000-2025 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.intellij.util.ui.UIUtil
+import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
+import java.awt.Color
+import javax.swing.BoxLayout
+import javax.swing.JPanel
+import javax.swing.tree.DefaultMutableTreeNode
+
+internal object PackageRendererUtils {
+ fun createBasicPanel(): JPanel = JPanel().apply {
+ layout = BoxLayout(this, BoxLayout.X_AXIS)
+ isOpaque = false
+ }
+
+ fun getBackgroundForState(isSelected: Boolean): Color = when {
+ isSelected -> UIUtil.getTreeSelectionBackground(true)
+ else -> UIUtil.getTreeBackground()
+ }
+
+ fun extractPackage(value: Any?): DisplayablePackage? = when (value) {
+ is DisplayablePackage -> value
+ is DefaultMutableTreeNode -> value.userObject as? DisplayablePackage
+ else -> null
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PackageTreeCellRenderingStrategies.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PackageTreeCellRenderingStrategies.kt
new file mode 100644
index 000000000000..8c5543fb3c28
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PackageTreeCellRenderingStrategies.kt
@@ -0,0 +1,81 @@
+// Copyright 2000-2025 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.intellij.openapi.util.NlsSafe
+import com.intellij.ui.hover.TableHoverListener
+import com.intellij.ui.hover.TreeHoverListener
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.packaging.PyPackageVersion
+import com.jetbrains.python.packaging.toolwindow.model.InstalledPackage
+import com.jetbrains.python.packaging.toolwindow.model.RequirementPackage
+import com.jetbrains.python.packaging.toolwindow.packages.tree.PyPackagesTreeTable
+import java.awt.BorderLayout
+import java.awt.font.TextAttribute
+import javax.swing.JLabel
+import javax.swing.JPanel
+import javax.swing.JTable
+
+internal fun installablePackageVersionStrategy(
+ versionPanel: JPanel,
+ tableTree: PyPackagesTreeTable,
+ row: Int,
+ linkLabel: JLabel,
+) {
+ val hoveredRow = TreeHoverListener.getHoveredRow(tableTree.tree)
+ val isRowHovered = hoveredRow == row
+ val isSelected = tableTree.tree.isRowSelected(row)
+
+ if (isRowHovered || isSelected) {
+ linkLabel.text = PyBundle.message("action.python.packages.install.text")
+ linkLabel.updateUnderline(tableTree, tableTree.table, row)
+ versionPanel.add(linkLabel, BorderLayout.WEST)
+ }
+}
+
+internal fun updatableInstalledPackageStrategy(
+ versionPanel: JPanel,
+ pkg: InstalledPackage,
+ version: PyPackageVersion,
+ tableTree: PyPackagesTreeTable,
+ row: Int,
+ linkLabel: JLabel,
+) {
+ @NlsSafe val updateLink = "${pkg.instance.version} -> ${version.presentableText}"
+ linkLabel.text = updateLink
+ linkLabel.updateUnderline(tableTree, tableTree.table, row)
+ versionPanel.add(linkLabel, BorderLayout.WEST)
+}
+
+internal fun requirementPackageStrategy(
+ versionPanel: JPanel,
+ pkg: RequirementPackage,
+ versionLabel: JLabel,
+) {
+ @NlsSafe val version = pkg.instance.version
+ versionLabel.text = version
+ versionLabel.icon = pkg.sourceRepoIcon
+ versionPanel.add(versionLabel, BorderLayout.WEST)
+}
+
+internal fun defaultPackageStrategy(
+ versionPanel: JPanel,
+ pkg: InstalledPackage,
+ versionLabel: JLabel,
+) {
+ @NlsSafe val version = pkg.instance.version
+ versionLabel.text = version
+ versionLabel.icon = pkg.sourceRepoIcon
+ versionPanel.add(versionLabel, BorderLayout.WEST)
+}
+
+private fun JLabel.updateUnderline(tableTree: PyPackagesTreeTable, table: JTable, currentRow: Int) {
+ val hoveredRow = TableHoverListener.getHoveredRow(table)
+ val hoveredColumn = tableTree.hoveredColumn
+ val underline = if (hoveredRow == currentRow && hoveredColumn == 0) TextAttribute.UNDERLINE_ON else -1
+
+ @Suppress("UNCHECKED_CAST")
+ val attributes = font.attributes as MutableMap
+ attributes[TextAttribute.UNDERLINE] = underline
+ attributes[TextAttribute.LIGATURES] = TextAttribute.LIGATURES_ON
+ font = font.deriveFont(attributes)
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTableCellRenderers.kt b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTableCellRenderers.kt
new file mode 100644
index 000000000000..538e1516dfb1
--- /dev/null
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/packages/tree/renderers/PyPackageTableCellRenderers.kt
@@ -0,0 +1,108 @@
+// Copyright 2000-2025 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.intellij.icons.AllIcons
+import com.intellij.openapi.ui.getUserData
+import com.intellij.ui.components.JBLabel
+import com.intellij.util.ui.JBEmptyBorder
+import com.intellij.util.ui.JBUI
+import com.intellij.util.ui.UIUtil
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.packaging.toolwindow.model.*
+import com.jetbrains.python.packaging.toolwindow.packages.tree.PyPackagesTreeTable
+import com.jetbrains.python.packaging.toolwindow.ui.PyPackagesUiComponents
+import java.awt.BorderLayout
+import java.awt.Color
+import java.awt.Component
+import javax.swing.JLabel
+import javax.swing.JTable
+import javax.swing.table.TableCellRenderer
+
+internal class PackageNameCellRenderer : TableCellRenderer {
+ private val nameLabel = JBLabel()
+ private val namePanel = PackageRendererUtils.createBasicPanel().apply { add(nameLabel) }
+
+ override fun getTableCellRendererComponent(
+ table: JTable,
+ value: Any?,
+ isSelected: Boolean,
+ hasFocus: Boolean,
+ row: Int,
+ column: Int,
+ ): Component {
+ val pkg = PackageRendererUtils.extractPackage(value) ?: return JLabel()
+ val background = PackageRendererUtils.getBackgroundForState(isSelected)
+
+ return when (pkg) {
+ is ExpandResultNode -> createExpandNodeComponent(pkg, background)
+ else -> createNameComponent(pkg, background)
+ }
+ }
+
+ private fun createExpandNodeComponent(
+ node: ExpandResultNode,
+ bg: Color,
+ ): Component {
+ val expandNodeLabel = JBLabel(PyBundle.message("python.toolwindow.packages.load.more", node.more)).apply {
+ foreground = UIUtil.getContextHelpForeground()
+ }
+ return PackageRendererUtils.createBasicPanel().apply {
+ add(expandNodeLabel)
+ background = bg
+ }
+ }
+
+ private fun createNameComponent(
+ pkg: DisplayablePackage,
+ bg: Color,
+ ): Component = namePanel.apply {
+ nameLabel.text = pkg.name
+ nameLabel.icon = if (pkg is InstalledPackage && pkg.isEditMode) AllIcons.Actions.Edit else null
+ background = bg
+ }
+}
+
+internal class PackageVersionCellRenderer : TableCellRenderer {
+ private val versionLabel = JLabel().apply {
+ border = JBEmptyBorder(JBUI.CurrentTheme.ActionsList.cellPadding())
+ }
+
+ private val linkLabel = JLabel(PyBundle.message("action.python.packages.install.text")).apply {
+ foreground = JBUI.CurrentTheme.Link.Foreground.ENABLED
+ border = JBEmptyBorder(JBUI.CurrentTheme.ActionsList.cellPadding())
+ }
+
+ private val versionPanel = PyPackagesUiComponents.borderPanel {
+ add(versionLabel, BorderLayout.CENTER)
+ }
+
+ override fun getTableCellRendererComponent(
+ table: JTable,
+ value: Any?,
+ isSelected: Boolean,
+ hasFocus: Boolean,
+ row: Int,
+ column: Int,
+ ): Component {
+ val pkg = PackageRendererUtils.extractPackage(value) ?: return JLabel()
+ val background = PackageRendererUtils.getBackgroundForState(isSelected)
+ val treeTable = table.getUserData(PyPackagesTreeTable.TREE_TABLE_KEY)!!
+
+ versionPanel.removeAll()
+ versionPanel.background = background
+
+ when (pkg) {
+ is InstallablePackage -> installablePackageVersionStrategy(versionPanel, treeTable, row, linkLabel)
+ is InstalledPackage -> if (pkg.nextVersion != null && pkg.canBeUpdated) {
+ updatableInstalledPackageStrategy(versionPanel, pkg, pkg.nextVersion, treeTable, row, linkLabel)
+ }
+ else {
+ defaultPackageStrategy(versionPanel, pkg, versionLabel)
+ }
+ is RequirementPackage -> requirementPackageStrategy(versionPanel, pkg, versionLabel)
+ is ExpandResultNode -> JLabel()
+ }
+
+ return versionPanel
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesTableModel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesTableModel.kt
deleted file mode 100644
index 226ea6dbb487..000000000000
--- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesTableModel.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2000-2024 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.ui
-
-import com.intellij.util.ui.ListTableModel
-import com.jetbrains.python.packaging.toolwindow.model.DisplayablePackage
-
-internal class PyPackagesTableModel : ListTableModel() {
- override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean = false
- override fun getColumnCount(): Int = 2
- override fun getColumnName(column: Int): String = column.toString()
- override fun getColumnClass(columnIndex: Int): Class<*> = DisplayablePackage::class.java
- override fun getValueAt(rowIndex: Int, columnIndex: Int): Any? = items[rowIndex]
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesUiComponents.kt b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesUiComponents.kt
index ef3bea0c3f15..61e52b5c5064 100644
--- a/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesUiComponents.kt
+++ b/python/src/com/jetbrains/python/packaging/toolwindow/ui/PyPackagesUiComponents.kt
@@ -14,6 +14,7 @@ import com.intellij.ui.SideBorder
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.NamedColorUtil
import com.intellij.util.ui.UIUtil
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonPackageDetails
import com.jetbrains.python.packaging.management.toInstallRequest
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
@@ -28,8 +29,8 @@ import java.awt.event.MouseEvent
import javax.swing.*
object PyPackagesUiComponents {
- val SELECTED_PACKAGE_DATA_CONTEXT = DataKey.create("SELECTED_PACKAGE_DATA_CONTEXT")
- val SELECTED_PACKAGES_DATA_CONTEXT = DataKey.create>("SELECTED_PACKAGES_DATA_CONTEXT")
+ val SELECTED_PACKAGE_DATA_CONTEXT: DataKey = DataKey.create("SELECTED_PACKAGE_DATA_CONTEXT")
+ val SELECTED_PACKAGES_DATA_CONTEXT: DataKey> = DataKey.create>("SELECTED_PACKAGES_DATA_CONTEXT")
internal val AnActionEvent.selectedPackage: DisplayablePackage?
get() = getData(SELECTED_PACKAGE_DATA_CONTEXT)
@@ -42,7 +43,7 @@ object PyPackagesUiComponents {
override fun onChosen(selectedValue: String?, finalChoice: Boolean): PopupStep<*>? {
return doFinalStep {
val repository = checkNotNull(selectedPackage.repository)
- val specification = repository.findPackageSpecification(selectedPackage.name, selectedValue)
+ val specification = repository.findPackageSpecification(NormalizedPythonPackageName.from(selectedPackage.name).name, selectedValue)
PyPackageCoroutine.getIoScope(project).launch(Dispatchers.IO) {
project.service().installPackage(specification!!.toInstallRequest())
}
@@ -51,7 +52,7 @@ object PyPackagesUiComponents {
}, 8)
}
- fun boxPanel(init: JPanel.() -> Unit) = object : JPanel() {
+ fun boxPanel(init: JPanel.() -> Unit): JPanel = object : JPanel() {
init {
layout = BoxLayout(this, BoxLayout.X_AXIS)
alignmentX = LEFT_ALIGNMENT
@@ -59,14 +60,14 @@ object PyPackagesUiComponents {
}
}
- fun borderPanel(init: JPanel.() -> Unit) = object : JPanel() {
+ fun borderPanel(init: JPanel.() -> Unit): JPanel = object : JPanel() {
init {
layout = BorderLayout(0, 0)
init()
}
}
- fun headerPanel(label: JLabel, component: JComponent?) = object : JPanel() {
+ fun headerPanel(label: JLabel, component: JComponent?): JPanel = object : JPanel() {
init {
background = UIUtil.getLabelBackground()
layout = BorderLayout()
@@ -81,6 +82,9 @@ object PyPackagesUiComponents {
override fun mouseClicked(e: MouseEvent?) {
component.isVisible = !component.isVisible
label.icon = if (component.isVisible) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
+ val parent = component.parent
+ parent.revalidate()
+ parent.repaint()
}
})
}
diff --git a/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt b/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt
new file mode 100644
index 000000000000..3bbae46453cf
--- /dev/null
+++ b/python/src/com/jetbrains/python/poetry/packaging/PoetryPackageRequirementsTreeExtractor.kt
@@ -0,0 +1,34 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.poetry.packaging
+
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.projectRoots.Sdk
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+import com.jetbrains.python.packaging.common.PythonPackage
+import com.jetbrains.python.packaging.packageRequirements.PackageNode
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTree
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractorProvider
+import com.jetbrains.python.sdk.poetry.isPoetry
+import com.jetbrains.python.sdk.poetry.runPoetryWithSdk
+
+/**
+ * Extracts package requirements tree using Poetry package manager.
+ */
+internal class PoetryPackageRequirementsTreeExtractor(private val sdk: Sdk) : PythonPackageRequirementsTreeExtractor {
+
+ override suspend fun extract(pkg: PythonPackage, module: Module): PackageNode {
+ val data = runPoetryWithSdk(sdk, "show", "--tree", pkg.name).getOr {
+ thisLogger().info("extracting requirements for package ${pkg.name}: error. Output: \n${it.error}")
+ return PackageNode(NormalizedPythonPackageName.from(pkg.name))
+ }
+ thisLogger().info("extracting requirements for package ${pkg.name}: \n${data.lines()}")
+ return parseTree(data.lines())
+ }
+}
+
+private class PoetryPackageRequirementsTreeExtractorProvider : PythonPackageRequirementsTreeExtractorProvider {
+ override fun createExtractor(sdk: Sdk): PythonPackageRequirementsTreeExtractor? =
+ if (sdk.isPoetry) PoetryPackageRequirementsTreeExtractor(sdk) else null
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt b/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt
index 99f867cd60bb..05a5e185f1bd 100644
--- a/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt
+++ b/python/src/com/jetbrains/python/poetry/packaging/PoetryPythonPackageRequiresExtractor.kt
@@ -6,12 +6,12 @@ import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonPackage
-import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractor
-import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractorProvider
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementExtractor
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequiresExtractorProvider
import com.jetbrains.python.sdk.poetry.isPoetry
import com.jetbrains.python.sdk.poetry.runPoetryWithSdk
-internal class PoetryPackageRequiresExtractor(private val sdk: Sdk) : PythonPackageRequiresExtractor {
+internal class PoetryPackageRequirementExtractor(private val sdk: Sdk) : PythonPackageRequirementExtractor {
override suspend fun extract(pkg: PythonPackage, module: Module): List {
val data = runPoetryWithSdk(sdk, "show", pkg.name).getOr {
@@ -52,8 +52,8 @@ internal class PoetryPackageRequiresExtractor(private val sdk: Sdk) : PythonPack
}
private class PoetryRequiresExtractorProvider: PythonPackageRequiresExtractorProvider {
- override fun createExtractor(sdk: Sdk): PythonPackageRequiresExtractor? {
+ override fun createExtractor(sdk: Sdk): PythonPackageRequirementExtractor? {
if (!sdk.isPoetry) return null
- return PoetryPackageRequiresExtractor(sdk)
+ return PoetryPackageRequirementExtractor(sdk)
}
}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/sdk/uv/Uv.kt b/python/src/com/jetbrains/python/sdk/uv/Uv.kt
index 115755c273e0..2a614723a7e1 100644
--- a/python/src/com/jetbrains/python/sdk/uv/Uv.kt
+++ b/python/src/com/jetbrains/python/sdk/uv/Uv.kt
@@ -37,6 +37,7 @@ interface UvLowLevel {
suspend fun listOutdatedPackages(): PyResult>
suspend fun listTopLevelPackages(): PyResult>
suspend fun listPackageRequirements(name: PythonPackage): PyResult>
+ suspend fun listPackageRequirementsTree(name: PythonPackage): PyResult
suspend fun isProjectSynced(inexact: Boolean): PyExecResult
suspend fun isScriptSynced(inexact: Boolean, scriptPath: Path): PyExecResult
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 3342d4088103..88abac2e344b 100644
--- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt
+++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt
@@ -141,6 +141,13 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
return PyExecResult.success(parsePackageRequirements(out))
}
+ override suspend fun listPackageRequirementsTree(name: PythonPackage): PyResult {
+ val out = uvCli.runUv(cwd, "tree", "--package", name.name)
+ .getOr { return it }
+
+ return PyExecResult.success(out)
+ }
+
override suspend fun installPackage(name: PythonPackageInstallRequest, options: List): PyExecResult {
uvCli.runUv(cwd, "pip", "install", *name.formatPackageName(), *options.toTypedArray())
.getOr { return it }
diff --git a/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt b/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt
new file mode 100644
index 000000000000..2fc8c1302e57
--- /dev/null
+++ b/python/src/com/jetbrains/python/uv/packaging/UvPackageRequirementsTreeExtractor.kt
@@ -0,0 +1,41 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.uv.packaging
+
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.projectRoots.Sdk
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+import com.jetbrains.python.packaging.common.PythonPackage
+import com.jetbrains.python.packaging.packageRequirements.PackageNode
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractor.Companion.parseTree
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementsTreeExtractorProvider
+import com.jetbrains.python.sdk.basePath
+import com.jetbrains.python.sdk.uv.UvSdkAdditionalData
+import com.jetbrains.python.sdk.uv.impl.createUvCli
+import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
+import com.jetbrains.python.sdk.uv.isUv
+import java.nio.file.Path
+
+internal class UvPackageRequirementsTreeExtractor(private val uvWorkingDirectory: Path?) : PythonPackageRequirementsTreeExtractor {
+
+ override suspend fun extract(pkg: PythonPackage, module: Module): PackageNode {
+ val uvWorkingDirectory = uvWorkingDirectory ?: Path.of(module.basePath!!)
+ val uv = createUvLowLevel(uvWorkingDirectory, createUvCli())
+ val out = uv.listPackageRequirementsTree(pkg).getOr {
+ thisLogger().info("extracting requires for package ${pkg.name}: error. Output: \n${it.error}")
+ return PackageNode(NormalizedPythonPackageName.from(pkg.name), mutableListOf())
+ }
+
+ return parseTree(out.lines())
+ }
+}
+
+
+private class UvPackageRequirementsTreeExtractorProvider : PythonPackageRequirementsTreeExtractorProvider {
+ override fun createExtractor(sdk: Sdk): PythonPackageRequirementsTreeExtractor? {
+ if (!sdk.isUv) return null
+ val data = sdk.sdkAdditionalData as? UvSdkAdditionalData ?: return null
+ return UvPackageRequirementsTreeExtractor( data.uvWorkingDirectory)
+ }
+}
diff --git a/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt b/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt
index 335c7cae5fd1..364c8417db19 100644
--- a/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt
+++ b/python/src/com/jetbrains/python/uv/packaging/UvPythonPackageRequiresExtractor.kt
@@ -6,15 +6,15 @@ import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonPackage
-import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractor
-import com.jetbrains.python.packaging.packageRequires.PythonPackageRequiresExtractorProvider
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequirementExtractor
+import com.jetbrains.python.packaging.packageRequirements.PythonPackageRequiresExtractorProvider
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.uv.UvSdkAdditionalData
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import java.nio.file.Path
-internal class UvPackageRequiresExtractor(private val uvWorkingDirectory: Path?) : PythonPackageRequiresExtractor {
+internal class UvPackageRequirementExtractor(private val uvWorkingDirectory: Path?) : PythonPackageRequirementExtractor {
override suspend fun extract(pkg: PythonPackage, module: Module): List {
val uvWorkingDirectory = uvWorkingDirectory ?: Path.of(module.basePath!!)
val uv = createUvLowLevel(uvWorkingDirectory, createUvCli())
@@ -26,8 +26,8 @@ internal class UvPackageRequiresExtractor(private val uvWorkingDirectory: Path?)
}
private class UvPackageRequiresExtractorProvider: PythonPackageRequiresExtractorProvider {
- override fun createExtractor(sdk: Sdk): PythonPackageRequiresExtractor? {
+ override fun createExtractor(sdk: Sdk): PythonPackageRequirementExtractor? {
val data = sdk.sdkAdditionalData as? UvSdkAdditionalData ?: return null
- return UvPackageRequiresExtractor(data.uvWorkingDirectory)
+ return UvPackageRequirementExtractor(data.uvWorkingDirectory)
}
}
\ No newline at end of file