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