From 13b58c18a353091f71f68b9058934c7cf850fb09 Mon Sep 17 00:00:00 2001 From: "Ilya.Kazakevich" Date: Wed, 5 Nov 2025 03:33:26 +0100 Subject: [PATCH] [python] PY-79486: (WIP): Front-end improvements: Remove hardcodes, add filter GitOrigin-RevId: 09e9b7dad0ac6c9ccfa5554915ee337697b8e1cb --- .../backend/BUILD.bazel | 1 + ...ntellij.python.sdkConfigurator.backend.iml | 1 + ...ntellij.python.sdkConfigurator.backend.xml | 2 +- .../src/impl/ModulesSdkConfigurator.kt | 3 +- .../common/src/impl/moduleDTOs.kt | 3 +- ...tellij.python.sdkConfigurator.frontend.xml | 2 +- ...PySdkConfiguratorFrontendBundle.properties | 3 +- .../frontend/src/components/ModuleList.kt | 178 ++++++++++-------- .../frontend/src/components/OpenArrow.kt | 1 + .../frontend/src/frontendLib.kt | 44 ++++- .../frontend/src/swingBridge.kt | 7 +- 11 files changed, 149 insertions(+), 96 deletions(-) diff --git a/python/python-sdk-configurator/backend/BUILD.bazel b/python/python-sdk-configurator/backend/BUILD.bazel index 6ab1b7c7ed20..791ade747eec 100644 --- a/python/python-sdk-configurator/backend/BUILD.bazel +++ b/python/python-sdk-configurator/backend/BUILD.bazel @@ -39,6 +39,7 @@ jvm_library( "//platform/projectModel-impl", "//platform/workspace/jps", "//platform/workspace/storage", + "@lib//:kotlinx-collections-immutable", ] ) ### auto-generated section `build intellij.python.sdkConfigurator.backend` end \ No newline at end of file diff --git a/python/python-sdk-configurator/backend/intellij.python.sdkConfigurator.backend.iml b/python/python-sdk-configurator/backend/intellij.python.sdkConfigurator.backend.iml index c64a74de6337..b5c4b1008a39 100644 --- a/python/python-sdk-configurator/backend/intellij.python.sdkConfigurator.backend.iml +++ b/python/python-sdk-configurator/backend/intellij.python.sdkConfigurator.backend.iml @@ -51,5 +51,6 @@ + \ No newline at end of file diff --git a/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml b/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml index a40a3629c62d..c334c2adea32 100644 --- a/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml +++ b/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml @@ -1,4 +1,4 @@ - + diff --git a/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt b/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt index 454c61936cd8..00e20a417cef 100644 --- a/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt +++ b/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt @@ -24,6 +24,7 @@ import com.jetbrains.python.sdk.configuration.CreateSdkInfoWithTool import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension import com.jetbrains.python.sdk.getOrCreateAdditionalData import com.jetbrains.python.sdk.setAssociationToPath +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -68,7 +69,7 @@ internal class ModulesSdkConfigurator private constructor( is CreateSdkInfo.ExistingEnv -> r.pythonInfo.languageLevel.toPythonVersion() is CreateSdkInfo.WillCreateEnv -> null } - ModuleDTO(moduleName, createInfo.toolId.id, version, children[moduleName]!!.toList().sorted()) + ModuleDTO(moduleName, createInfo.toolId.id, version, children[moduleName]!!.toList().sorted().toPersistentList()) } is ModuleCreateInfo.SameAs -> null } diff --git a/python/python-sdk-configurator/common/src/impl/moduleDTOs.kt b/python/python-sdk-configurator/common/src/impl/moduleDTOs.kt index 944204deb125..122ca03be844 100644 --- a/python/python-sdk-configurator/common/src/impl/moduleDTOs.kt +++ b/python/python-sdk-configurator/common/src/impl/moduleDTOs.kt @@ -1,6 +1,7 @@ package com.intellij.python.sdkConfigurator.common.impl import com.intellij.openapi.util.NlsSafe +import kotlinx.collections.immutable.ImmutableList import kotlinx.serialization.Serializable typealias ModuleName = @NlsSafe String @@ -15,7 +16,7 @@ data class ModuleDTO( val name: ModuleName, val createdByTool: ToolIdDTO, val existingPyVersion: @NlsSafe String?, - val childModules: List, + val childModules: ImmutableList, ) @Serializable diff --git a/python/python-sdk-configurator/frontend/resources/intellij.python.sdkConfigurator.frontend.xml b/python/python-sdk-configurator/frontend/resources/intellij.python.sdkConfigurator.frontend.xml index aee2f07687da..59e41e772185 100644 --- a/python/python-sdk-configurator/frontend/resources/intellij.python.sdkConfigurator.frontend.xml +++ b/python/python-sdk-configurator/frontend/resources/intellij.python.sdkConfigurator.frontend.xml @@ -1,4 +1,4 @@ - + diff --git a/python/python-sdk-configurator/frontend/resources/messages/PySdkConfiguratorFrontendBundle.properties b/python/python-sdk-configurator/frontend/resources/messages/PySdkConfiguratorFrontendBundle.properties index fbc30c7f8b5b..6b48342713fe 100644 --- a/python/python-sdk-configurator/frontend/resources/messages/PySdkConfiguratorFrontendBundle.properties +++ b/python/python-sdk-configurator/frontend/resources/messages/PySdkConfiguratorFrontendBundle.properties @@ -1,7 +1,6 @@ python.sdk.configurator.frontend.choose.modules.title=Configure Modules Environment python.sdk.configurator.frontend.choose.modules.text=Pick the ones you want to use to run code and set up environment for them -python.sdk.configurator.frontend.choose.modules.project.structure=Project Structure +python.sdk.configurator.frontend.choose.modules.project.structure=Project Structure: python.sdk.configurator.frontend.choose.modules.environment=Environment -python.sdk.configurator.frontend.choose.modules.workspace.member=Same as {0} python.sdk.configurator.frontend.choose.modules.workspace.existing=Use existing version {0} python.sdk.configurator.frontend.choose.modules.new=Create new environment diff --git a/python/python-sdk-configurator/frontend/src/components/ModuleList.kt b/python/python-sdk-configurator/frontend/src/components/ModuleList.kt index 3b896b3b555c..c953758be9a0 100644 --- a/python/python-sdk-configurator/frontend/src/components/ModuleList.kt +++ b/python/python-sdk-configurator/frontend/src/components/ModuleList.kt @@ -3,79 +3,65 @@ package com.intellij.python.sdkConfigurator.frontend.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateSet import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.intellij.python.sdkConfigurator.common.impl.ModuleDTO import com.intellij.python.sdkConfigurator.common.impl.ModuleName import com.intellij.python.sdkConfigurator.common.impl.ToolIdDTO +import com.intellij.python.sdkConfigurator.frontend.ModulesViewModel import com.intellij.python.sdkConfigurator.frontend.PySdkConfiguratorFrontendBundle import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.FlowPreview +import org.jetbrains.annotations.Nls import org.jetbrains.jewel.foundation.Stroke import org.jetbrains.jewel.foundation.modifier.border import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.styling.LocalCheckboxStyle import org.jetbrains.jewel.ui.icon.IconKey -import org.jetbrains.jewel.ui.theme.dividerStyle - +/** + * List of modules from [viewModel] + */ +@OptIn(FlowPreview::class) @Composable internal fun ModuleList( - moduleItems: PersistentList, - icons: ImmutableMap, - checked: SnapshotStateSet, - onCheck: (ModuleName) -> Unit, - topLabel: String, - projectStructureLabel: String, - environmentLabel: String, + viewModel: ModulesViewModel, + // UI labels + topLabel: @Nls String, + projectStructureLabel: @Nls String, + environmentLabel: @Nls String, ) { + LaunchedEffect(viewModel) { + viewModel.processFilterUpdates() + } + val space = 5.dp VerticallyScrollableContainer { - val colPadding = 2.dp - val horizontalArrangement = Arrangement.spacedBy(colPadding) - Column(Modifier.width(IntrinsicSize.Max) - .padding(end = scrollbarContentSafePadding()) - .border(Stroke.Alignment.Inside, JewelTheme.dividerStyle.metrics.thickness, JewelTheme.dividerStyle.color) - .padding(5.dp) + val checkboxArrangement = Arrangement.spacedBy(space) + Column(Modifier.width(IntrinsicSize.Max), verticalArrangement = checkboxArrangement) { + Text(text = topLabel, Modifier.padding(end = space, bottom = space)) - ) { - Text(text = topLabel, maxLines = 1, softWrap = false, modifier = Modifier - .padding(bottom = 10.dp) - ) - Divider(Orientation.Horizontal, thickness = 5.dp) - Row { - val modifier = Modifier.weight(1f) - Text(projectStructureLabel, modifier = modifier) - Divider(Orientation.Vertical) - Text(environmentLabel, modifier = modifier) - } - Divider(Orientation.Horizontal) - val hide = Modifier.alpha(0f) - for (module in moduleItems) { - var subModuleOpened by remember { mutableStateOf(false) } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = horizontalArrangement) { - OpenArrow(subModuleOpened, { subModuleOpened = !subModuleOpened }, - modifier = if (module.childModules.isEmpty()) hide else Modifier) - Module(module.name in checked, onCheck, colPadding, module, icons) - } // Module children - - for (childModule in module.childModules) { - Row(Modifier.alpha(if (subModuleOpened) 1f else 0f)) { - // Invisible elements are for padding only: will replace to the real padding - OpenArrow(false, {}, hide) - Checkbox(false, onCheckedChange = {}, modifier = hide) - CheckboxRow( - childModule, - childModule in checked, - onCheckedChange = {}, - softWrap = false, - maxLines = 1, - enabled = false, - ) + Column(Modifier.padding(space).border(Stroke.Alignment.Outside, 1.dp, JewelTheme.globalColors.borders.normal).padding(space).fillMaxWidth(), verticalArrangement = checkboxArrangement) { + Row(Modifier.height(IntrinsicSize.Min).padding(start = space, bottom = space, end = space), horizontalArrangement = checkboxArrangement) { + ModuleRow( + left = { modifier -> + Row(modifier) { + Text(projectStructureLabel) + Spacer(Modifier.width(1.dp)) + TextField(viewModel.moduleFilter, undecorated = true, modifier = Modifier.weight(1f)) + } + }, + right = { modifier -> + Text(environmentLabel, modifier = modifier) + } + ) + } + for (module in viewModel.filteredModules) { + Row(verticalAlignment = Alignment.Top, horizontalArrangement = checkboxArrangement, modifier = Modifier.padding(horizontal = space)) { + Module(module.name in viewModel.checkedModules, viewModel::clicked, module, viewModel.icons, checkboxArrangement) } } } @@ -83,36 +69,78 @@ internal fun ModuleList( } } +/** + * Renders [module] + */ @Composable -private fun RowScope.Module( +private fun Module( checked: Boolean, onCheck: (ModuleName) -> Unit, - columnPadding: Dp, - moduleInfo: ModuleDTO, + module: ModuleDTO, icons: ImmutableMap, + checkBoxArrangement: Arrangement.HorizontalOrVertical, ) { - val colModifier = Modifier.weight(1f) + var subModuleOpened by remember { mutableStateOf(false) } val newText = remember { PySdkConfiguratorFrontendBundle.message("python.sdk.configurator.frontend.choose.modules.new") } - val moduleName = moduleInfo.name - CheckboxRow( - moduleName, - softWrap = false, - maxLines = 1, - checked = checked, - onCheckedChange = { - onCheck(moduleName) - }, - modifier = colModifier - ) + val moduleName = module.name + val checkBoxWidth = Modifier.padding(start = LocalCheckboxStyle.current.metrics.checkboxSize.width) - Row(colModifier.padding(start = columnPadding)) { - val text = moduleInfo.existingPyVersion?.let { PySdkConfiguratorFrontendBundle.message("python.sdk.configurator.frontend.choose.modules.workspace.existing", it) } - ?: newText - val icon = icons[moduleInfo.createdByTool] - if (icon != null) { - Icon(icon, text) - } - Text(text, Modifier.clickable(onClick = { onCheck(moduleName) }), maxLines = 1, softWrap = false) + Row(verticalAlignment = Alignment.Top, horizontalArrangement = checkBoxArrangement) { + ModuleRow( + left = { modifier -> + Row(modifier) { + // TODO: Get real size instead of "invisible" checkbox + OpenArrow(subModuleOpened, { subModuleOpened = !subModuleOpened }, + modifier = if (module.childModules.isEmpty()) { + Modifier.alpha(0f) + } + else Modifier) + Column(verticalArrangement = checkBoxArrangement) { + CheckboxRow( + moduleName, + softWrap = false, + maxLines = 1, + checked = checked, + onCheckedChange = { + onCheck(moduleName) + }, + ) + if (subModuleOpened) { + for (childModule in module.childModules) { + Row { + CheckboxRow( + childModule, + checked, + onCheckedChange = {}, + softWrap = false, + maxLines = 1, + enabled = false, + modifier = checkBoxWidth + ) + } + } + } + } + } + }, + right = { modifier -> + Row(modifier) { + val text = module.existingPyVersion?.let { PySdkConfiguratorFrontendBundle.message("python.sdk.configurator.frontend.choose.modules.workspace.existing", it) } + ?: newText + val icon = icons[module.createdByTool] + if (icon != null) { + Icon(icon, text) + } + Text(text, Modifier.clickable(onClick = { onCheck(moduleName) }), maxLines = 1, softWrap = false) + } + }) } } +@Composable +private fun RowScope.ModuleRow(left: @Composable (modifier: Modifier) -> Unit, right: @Composable (modifier: Modifier) -> Unit) { + val modifier = Modifier.weight(1f) + left(modifier) + Divider(Orientation.Vertical, color = JewelTheme.globalColors.borders.normal, thickness = 1.dp, modifier = Modifier.fillMaxHeight()) + right(modifier) +} \ No newline at end of file diff --git a/python/python-sdk-configurator/frontend/src/components/OpenArrow.kt b/python/python-sdk-configurator/frontend/src/components/OpenArrow.kt index 76d10fb3b80e..8321b7c60cd5 100644 --- a/python/python-sdk-configurator/frontend/src/components/OpenArrow.kt +++ b/python/python-sdk-configurator/frontend/src/components/OpenArrow.kt @@ -15,6 +15,7 @@ import org.jetbrains.jewel.ui.theme.comboBoxStyle internal fun OpenArrow(opened: Boolean, onOpenChangeState: (Boolean) -> Unit, modifier: Modifier = Modifier) { var opened by remember { mutableStateOf(opened) } val rotate = if (opened) 360f else 270f + Icon(key = JewelTheme.comboBoxStyle.icons.chevronDown, "", Modifier.rotate(rotate).clickable(onClick = { diff --git a/python/python-sdk-configurator/frontend/src/frontendLib.kt b/python/python-sdk-configurator/frontend/src/frontendLib.kt index 5ac24f91e4fa..e80de62a8693 100644 --- a/python/python-sdk-configurator/frontend/src/frontendLib.kt +++ b/python/python-sdk-configurator/frontend/src/frontendLib.kt @@ -1,6 +1,7 @@ package com.intellij.python.sdkConfigurator.frontend -import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateSet import com.intellij.python.common.tools.ToolId import com.intellij.python.common.tools.getIcon @@ -9,27 +10,38 @@ import com.intellij.python.sdkConfigurator.common.impl.ModuleName import com.intellij.python.sdkConfigurator.common.impl.ModulesDTO import com.intellij.python.sdkConfigurator.common.impl.ToolIdDTO import kotlinx.collections.immutable.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.withContext import org.jetbrains.jewel.bridge.icon.fromPlatformIcon import org.jetbrains.jewel.ui.icon.IconKey import org.jetbrains.jewel.ui.icon.IntelliJIconKey +import kotlin.time.Duration.Companion.milliseconds /** - * UI should display [checkBoxItems] (either enabled or disabled). On each click call [clicked]. - * Result can be taken from [checked] + * UI should display [filteredModules] (either enabled or disabled). On each click call [clicked]. + * Show filter from [moduleFilter] + * Result can be taken from [checkedModules]. + * While composable is displayed. call [processFilterUpdates] */ internal class ModulesViewModel(modulesDTO: ModulesDTO) { - val icons: PersistentMap = persistentMapOf(*modulesDTO.modules + val icons: ImmutableMap = persistentMapOf(*modulesDTO.modules .mapNotNull { module -> val toolId = module.createdByTool val icon = getIcon(ToolId(toolId))?.let { IntelliJIconKey.fromPlatformIcon(it.first, it.second) } ?: return@mapNotNull null Pair(toolId, icon) }.toTypedArray()) - val checkBoxItems: PersistentList = modulesDTO.modules.toPersistentList() - val checked: SnapshotStateSet = mutableStateSetOf() + private val modules: List = modulesDTO.modules.sortedBy { it.name } + + var filteredModules: List by mutableStateOf(modules) + val checkedModules: SnapshotStateSet = mutableStateSetOf() + val moduleFilter = TextFieldState() // parent -> children - private val children: ImmutableMap> = checkBoxItems.associate { + private val children: ImmutableMap> = filteredModules.associate { Pair(it.name, it.childModules.toPersistentSet()) }.toPersistentMap() @@ -41,13 +53,25 @@ internal class ModulesViewModel(modulesDTO: ModulesDTO) { fun clicked(module: ModuleName) { val what = parents[module] ?: module // Get parent if child module val toChange = setOf(what) + children.getOrDefault(what, emptySet()) // Get children if parent - val checkBoxSet = what !in checked + val checkBoxSet = what !in checkedModules if (checkBoxSet) { - checked.addAll(toChange) + checkedModules.addAll(toChange) } else { - checked.removeAll(toChange) + checkedModules.removeAll(toChange) } } + + @OptIn(FlowPreview::class) + suspend fun processFilterUpdates() { + snapshotFlow { moduleFilter.text } + .debounce(500.milliseconds) + .collectLatest { filter -> + withContext(Dispatchers.Default) { + val filter = filter.trim() + filteredModules = if (filter.isEmpty()) modules else modules.filter { filter in it.name } + } + } + } } diff --git a/python/python-sdk-configurator/frontend/src/swingBridge.kt b/python/python-sdk-configurator/frontend/src/swingBridge.kt index 2a646cfade57..04b57cbc8852 100644 --- a/python/python-sdk-configurator/frontend/src/swingBridge.kt +++ b/python/python-sdk-configurator/frontend/src/swingBridge.kt @@ -21,7 +21,7 @@ internal suspend fun askUser(project: Project, modules: ModulesDTO, onResult: (S withContext(Dispatchers.EDT) { val myDialog = MyDialog(project, viewModel) if (myDialog.showAndGet()) { - onResult(viewModel.checked) + onResult(viewModel.checkedModules) } } } @@ -38,10 +38,7 @@ private class MyDialog(project: Project, private val viewModel: ModulesViewModel override fun createCenterPanel(): JComponent { enableNewSwingCompositing() return compose(focusOnClickInside = true, content = { - ModuleList(viewModel.checkBoxItems, - viewModel.icons, - viewModel.checked, - viewModel::clicked, + ModuleList(viewModel, topLabel = message("python.sdk.configurator.frontend.choose.modules.text"), projectStructureLabel = message("python.sdk.configurator.frontend.choose.modules.project.structure"), environmentLabel = message("python.sdk.configurator.frontend.choose.modules.environment"))