From f23b2dea400893e98fad4aed22b5a535c5716b97 Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Wed, 24 Jun 2026 12:54:25 +0200 Subject: [PATCH] PY-90077 show newly added interpreter in "Show All" without reopening The interpreter list was served from a cached ProjectSdksModel held by the PyConfigurableInterpreterList project service, refreshed only on explicit reset(). Adding an interpreter writes straight to ProjectJdkTable, so the cache went stale and the new interpreter appeared only on the second opening of the dialog. Read the live SDK table instead: the combo and widget query ProjectJdkTable directly, and the "Show All" dialog now owns a short-lived ProjectSdksModel reset on open. The logic moves to Project.getAssignablePythonSdks / filterAssignablePythonSdks extensions in python-sdk; PythonInterpreterTargetEnvironmentFactory and its target helpers move down to python-sdk so the filtering can live there (packages unchanged). PyConfigurableInterpreterList is kept as a deprecated shim delegating to the new API, since external plugins still reference getInstance()/getAllPythonSdks(). Also tie the run-config common-options form SDK-table listener to a parentDisposable, fixing a pre-existing listener leak. (cherry picked from commit d20a1a1b7c2af46f8d44a10a7824c86a32698c1b) IJ-MR-210000 GitOrigin-RevId: 77dc194b005e8b295081e424e3a3234ccf4d5ff2 --- .../ide/impl/PyIdeCommonOptionsForm.java | 59 ++++-------- .../impl/run/PyPluginCommonOptionsForm.java | 3 +- ...thonInterpreterTargetEnvironmentFactory.kt | 4 +- .../TargetConfigurationWithLocalFsAccessEx.kt | 4 +- ...lpersAwareLocalTargetEnvironmentRequest.kt | 0 .../com/jetbrains/python/sdk/ProjectExt.kt | 51 ++++++++++ .../targetWithVfs/TargetWithMappedLocalVfs.kt | 0 .../PyEnvironmentYmlSdkConfiguration.kt | 6 +- .../PyActiveSdkConfigurable.java | 31 +++--- .../PyConfigurableInterpreterList.java | 95 ++++--------------- .../PythonInterpreterConfigurable.kt | 8 +- .../PythonInterpreterMasterDetails.kt | 19 +++- .../console/PyConsoleOptionsConfigurable.kt | 1 + .../console/PyConsoleSpecificOptionsPanel.kt | 11 ++- .../run/AbstractPyCommonOptionsForm.java | 8 +- .../jetbrains/python/sdk/PySdkPopupFactory.kt | 4 +- 16 files changed, 148 insertions(+), 156 deletions(-) rename python/{ => python-sdk}/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt (96%) rename python/{ => python-sdk}/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt (86%) rename python/{ => python-sdk}/src/com/jetbrains/python/run/target/HelpersAwareLocalTargetEnvironmentRequest.kt (100%) rename python/{ => python-sdk}/src/com/jetbrains/python/target/targetWithVfs/TargetWithMappedLocalVfs.kt (100%) diff --git a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/PyIdeCommonOptionsForm.java b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/PyIdeCommonOptionsForm.java index 35d52949544b..79a2cbdb4fe6 100644 --- a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/PyIdeCommonOptionsForm.java +++ b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/PyIdeCommonOptionsForm.java @@ -2,21 +2,20 @@ package com.intellij.pycharm.community.ide.impl; import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.Disposable; import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; import com.intellij.openapi.module.Module; import com.intellij.openapi.module.ModuleManager; import com.intellij.openapi.project.Project; +import com.intellij.openapi.projectRoots.ProjectJdkTable; import com.intellij.openapi.projectRoots.Sdk; -import com.intellij.openapi.projectRoots.SdkModel; import com.intellij.openapi.roots.ModuleRootManager; -import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.util.text.StringUtil; import com.intellij.ui.CollectionComboBoxModel; import com.intellij.util.PathMappingSettings; +import com.intellij.util.messages.MessageBusConnection; import com.jetbrains.python.PyBundle; -import com.jetbrains.python.configuration.PyConfigurableInterpreterList; -import com.jetbrains.python.psi.impl.PyPsiUtils; import com.jetbrains.python.run.AbstractPyCommonOptionsForm; import com.jetbrains.python.run.PyCommonOptionsFormData; import com.jetbrains.python.sdk.PySdkListCellRenderer; @@ -95,11 +94,21 @@ public class PyIdeCommonOptionsForm implements AbstractPyCommonOptionsForm { } @Override - public void subscribe() { - PyConfigurableInterpreterList myInterpreterList = PyConfigurableInterpreterList.getInstance(myProject); - ProjectSdksModel myProjectSdksModel = myInterpreterList.getModel(); - myProjectSdksModel.addListener(new MyListener(this, myInterpreterList)); - updateSdkList(true, myInterpreterList); + public void subscribe(@NotNull Disposable parentDisposable) { + // Refresh the interpreter combo from the live SDK table whenever it changes. The connection is tied to + // `parentDisposable`, so the listener does not outlive the owning UI. + MessageBusConnection connection = myProject.getMessageBus().connect(parentDisposable); + connection.subscribe(ProjectJdkTable.JDK_TABLE_TOPIC, new ProjectJdkTable.Listener() { + @Override + public void jdkAdded(@NotNull Sdk jdk) { updateSdkList(true); } + + @Override + public void jdkRemoved(@NotNull Sdk jdk) { updateSdkList(true); } + + @Override + public void jdkNameChanged(@NotNull Sdk jdk, @NotNull String previousName) { updateSdkList(true); } + }); + updateSdkList(true); } @Override @@ -193,7 +202,7 @@ public class PyIdeCommonOptionsForm implements AbstractPyCommonOptionsForm { ); } - public void updateSdkList(boolean preserveSelection, PyConfigurableInterpreterList myInterpreterList) { + public void updateSdkList(boolean preserveSelection) { myPythonSdks = new ArrayList<>(PythonSdkUtil.getAllSdks()); Sdk selection = preserveSelection ? (Sdk)content.interpreterComboBox.getSelectedItem() : null; if (!myPythonSdks.contains(selection)) { @@ -319,36 +328,6 @@ public class PyIdeCommonOptionsForm implements AbstractPyCommonOptionsForm { myEnvPaths = strings; } - private static class MyListener implements SdkModel.Listener { - private final PyIdeCommonOptionsForm myForm; - private final PyConfigurableInterpreterList myInterpreterList; - - MyListener(PyIdeCommonOptionsForm form, PyConfigurableInterpreterList interpreterList) { - myForm = form; - myInterpreterList = interpreterList; - } - - - private void update() { - myForm.updateSdkList(true, myInterpreterList); - } - - @Override - public void sdkAdded(@NotNull Sdk sdk) { - update(); - } - - @Override - public void beforeSdkRemove(@NotNull Sdk sdk) { - update(); - } - - @Override - public void sdkChanged(@NotNull Sdk sdk, String previousName) { - update(); - } - } - @Override public String getModuleName() { Module module = getModule(); diff --git a/python/pluginCore/impl/src/com/intellij/python/community/plugin/impl/run/PyPluginCommonOptionsForm.java b/python/pluginCore/impl/src/com/intellij/python/community/plugin/impl/run/PyPluginCommonOptionsForm.java index 7f7c97feeb80..a7482c054589 100644 --- a/python/pluginCore/impl/src/com/intellij/python/community/plugin/impl/run/PyPluginCommonOptionsForm.java +++ b/python/pluginCore/impl/src/com/intellij/python/community/plugin/impl/run/PyPluginCommonOptionsForm.java @@ -1,6 +1,7 @@ // Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.python.community.plugin.impl.run; +import com.intellij.openapi.Disposable; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; import com.intellij.openapi.module.Module; @@ -89,7 +90,7 @@ public class PyPluginCommonOptionsForm implements AbstractPyCommonOptionsForm { } @Override - public void subscribe() { + public void subscribe(@NotNull Disposable parentDisposable) { } private void addInterpreterComboBoxActionListener(ActionListener listener) { diff --git a/python/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt b/python/python-sdk/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt similarity index 96% rename from python/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt rename to python/python-sdk/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt index 8aa5e4f842e4..4295469606d3 100644 --- a/python/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt +++ b/python/python-sdk/src/com/jetbrains/python/run/PythonInterpreterTargetEnvironmentFactory.kt @@ -96,7 +96,7 @@ interface PythonInterpreterTargetEnvironmentFactory : PluginAware { companion object { @JvmStatic - val EP_NAME = ExtensionPointName("Pythonid.interpreterTargetEnvironmentFactory") + val EP_NAME: ExtensionPointName = ExtensionPointName("Pythonid.interpreterTargetEnvironmentFactory") @JvmStatic fun findPythonTargetInterpreter(sdk: Sdk, project: Project): HelpersAwareTargetEnvironmentRequest = @@ -137,7 +137,7 @@ interface PythonInterpreterTargetEnvironmentFactory : PluginAware { * Target provides access to its filesystem using VFS (like WSL) */ @JvmStatic - fun getTargetWithMappedLocalVfs(targetEnvironmentConfiguration: TargetEnvironmentConfiguration) = EP_NAME.extensionList.asSequence().mapNotNull { + fun getTargetWithMappedLocalVfs(targetEnvironmentConfiguration: TargetEnvironmentConfiguration): TargetWithMappedLocalVfs? = EP_NAME.extensionList.asSequence().mapNotNull { it.asTargetWithMappedLocalVfs(targetEnvironmentConfiguration) }.firstOrNull() diff --git a/python/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt b/python/python-sdk/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt similarity index 86% rename from python/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt rename to python/python-sdk/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt index 3e842aaedae6..60bc41204cf9 100644 --- a/python/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt +++ b/python/python-sdk/src/com/jetbrains/python/run/TargetConfigurationWithLocalFsAccessEx.kt @@ -14,7 +14,7 @@ import org.jetbrains.annotations.ApiStatus * ``\\wsl$`` projects allow WSL and Docker */ @ApiStatus.Internal -internal fun TargetConfigurationWithLocalFsAccess.allowCreationTargetOfThisType(confType: TargetEnvironmentType<*>): Boolean { +fun TargetConfigurationWithLocalFsAccess.allowCreationTargetOfThisType(confType: TargetEnvironmentType<*>): Boolean { val javaClass = asTargetConfig.getTargetType().javaClass return javaClass == confType.javaClass || javaClass in confType.canProbablyRunCodeForeignTypes } @@ -25,7 +25,7 @@ internal fun TargetConfigurationWithLocalFsAccess.allowCreationTargetOfThisType( * ``\\wsl$`` projects allow Docker AND only WSL with right distro */ @ApiStatus.Internal -internal fun TargetConfigurationWithLocalFsAccess.codeCouldProbablyBeRunWithConfig(config: TargetEnvironmentConfiguration?): Boolean { +fun TargetConfigurationWithLocalFsAccess.codeCouldProbablyBeRunWithConfig(config: TargetEnvironmentConfiguration?): Boolean { if (config == null) return false // For now no local target could run remote if (asTargetConfig == config) return true // Same config (like same wsl distro) return asTargetConfig.getTargetType().javaClass in config.getTargetType().canProbablyRunCodeForeignTypes diff --git a/python/src/com/jetbrains/python/run/target/HelpersAwareLocalTargetEnvironmentRequest.kt b/python/python-sdk/src/com/jetbrains/python/run/target/HelpersAwareLocalTargetEnvironmentRequest.kt similarity index 100% rename from python/src/com/jetbrains/python/run/target/HelpersAwareLocalTargetEnvironmentRequest.kt rename to python/python-sdk/src/com/jetbrains/python/run/target/HelpersAwareLocalTargetEnvironmentRequest.kt diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/ProjectExt.kt b/python/python-sdk/src/com/jetbrains/python/sdk/ProjectExt.kt index 61a104be70c8..02b138cbc6f4 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/ProjectExt.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/ProjectExt.kt @@ -1,17 +1,29 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.sdk +import com.intellij.execution.target.TargetBasedSdkAdditionalData +import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.platform.eel.EelMachine +import com.intellij.platform.eel.provider.LocalEelMachine +import com.intellij.platform.eel.provider.getEelMachine +import com.intellij.platform.eel.provider.ownsPath import com.intellij.util.concurrency.annotations.RequiresWriteLock import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory +import com.jetbrains.python.run.codeCouldProbablyBeRunWithConfig import com.jetbrains.python.sdk.impl.PySdkBundle.message +import com.jetbrains.python.sdk.legacy.PythonSdkUtil import org.jetbrains.annotations.ApiStatus.Internal +import java.nio.file.InvalidPathException +import java.nio.file.Path /** * Renames the SDK currently registered as [oldName] to [newName] and keeps this project's references pointing at it. @@ -72,3 +84,42 @@ fun Project.renameSdk(oldName: String, newName: String): PyResult { } return Result.success(Unit) } + +/** + * Returns all Python SDKs registered in the IDE that are usable from this project, optionally restricted to the target + * the given [module] resides on. Remote interpreters are sorted last, then by name. + */ +@Internal +fun Project.getAssignablePythonSdks(module: Module?): List = filterAssignablePythonSdks(PythonSdkUtil.getAllSdks(), module) + +/** + * Filters and sorts [sdks] the same way [getAssignablePythonSdks] does. The "Python Interpreters" dialog passes the editable + * copies from its own `ProjectSdksModel` here, so the displayed list matches the live one. + */ +@Internal +fun Project.filterAssignablePythonSdks(sdks: Collection, module: Module?): List { + val eelMachine = getEelMachine() + val targetModuleSitsOn = module?.let { PythonInterpreterTargetEnvironmentFactory.getTargetModuleResidesOn(it) } + return sdks + .filter { sdk -> + PythonSdkUtil.isPythonSdk(sdk) && + sdkMatchesEel(eelMachine, sdk) && + (targetModuleSitsOn == null || targetModuleSitsOn.codeCouldProbablyBeRunWithConfig(sdk.targetEnvConfiguration)) + } + .sortedWith(compareBy({ PythonSdkUtil.isRemote(it) }, { it.name })) +} + +/** + * Mirrors `ProjectSdksModel.sdkMatchesEel` (which lives in lang-impl and is unavailable here): target-based SDKs are + * always eligible; other SDKs must have a home path owned by the project's [eelMachine]. + */ +private fun sdkMatchesEel(eelMachine: EelMachine, sdk: Sdk): Boolean { + if (sdk.sdkAdditionalData is TargetBasedSdkAdditionalData) return true + val home = sdk.homePath ?: return false + return try { + eelMachine.ownsPath(Path.of(home)) + } + catch (_: InvalidPathException) { + eelMachine == LocalEelMachine + } +} diff --git a/python/src/com/jetbrains/python/target/targetWithVfs/TargetWithMappedLocalVfs.kt b/python/python-sdk/src/com/jetbrains/python/target/targetWithVfs/TargetWithMappedLocalVfs.kt similarity index 100% rename from python/src/com/jetbrains/python/target/targetWithVfs/TargetWithMappedLocalVfs.kt rename to python/python-sdk/src/com/jetbrains/python/target/targetWithVfs/TargetWithMappedLocalVfs.kt diff --git a/python/src/com/jetbrains/python/conda/sdk/configuration/PyEnvironmentYmlSdkConfiguration.kt b/python/src/com/jetbrains/python/conda/sdk/configuration/PyEnvironmentYmlSdkConfiguration.kt index f70f0ff75cbe..fb11c9766ab1 100644 --- a/python/src/com/jetbrains/python/conda/sdk/configuration/PyEnvironmentYmlSdkConfiguration.kt +++ b/python/src/com/jetbrains/python/conda/sdk/configuration/PyEnvironmentYmlSdkConfiguration.kt @@ -18,7 +18,6 @@ import com.intellij.util.FileName import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.jetbrains.python.PyBundle import com.jetbrains.python.PythonBinary -import com.jetbrains.python.configuration.PyConfigurableInterpreterList import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.getOrNull import com.jetbrains.python.onSuccess @@ -48,6 +47,7 @@ import com.jetbrains.python.sdk.flavors.conda.NewCondaEnvRequest import com.jetbrains.python.sdk.flavors.conda.PyCondaCommand import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity +import com.jetbrains.python.sdk.legacy.PythonSdkUtil import com.jetbrains.python.sdk.setAssociationToModule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -145,7 +145,7 @@ internal class PyEnvironmentYmlSdkConfiguration : PyProjectSdkConfigurationExten return PyCondaCommand(condaExecutable.path.pathString, null).createCondaSdkFromExistingEnvironment( getCondaEnvIdentity(module, condaExecutable) ?: return PyResult.localizedError(PyBundle.message("sdk.cannot.use.existing.conda.environment")), - PyConfigurableInterpreterList.getInstance(module.project).model.sdks.toList(), + PythonSdkUtil.getAllSdks(), ) } @@ -173,7 +173,7 @@ internal class PyEnvironmentYmlSdkConfiguration : PyProjectSdkConfigurationExten val binaryToExec = BinOnEel(condaExecutable.path) val existingEnvs = PyCondaEnv.getEnvs(binaryToExec, forceRefresh = true).getOrNull() ?: emptyList() - val existingSdks = PyConfigurableInterpreterList.getInstance(project).model.sdks + val existingSdks = PythonSdkUtil.getAllSdks() val newCondaEnvInfo = NewCondaEnvRequest.LocalEnvByLocalEnvironmentFile(environmentYml.toNioPath(), existingEnvs) val sdk = PyCondaCommand(condaExecutable.path.pathString, null) .createCondaSdkAlongWithNewEnv(newCondaEnvInfo, existingSdks.toList()).getOr { diff --git a/python/src/com/jetbrains/python/configuration/PyActiveSdkConfigurable.java b/python/src/com/jetbrains/python/configuration/PyActiveSdkConfigurable.java index 1b277fef14e6..3fbcbdb40b33 100644 --- a/python/src/com/jetbrains/python/configuration/PyActiveSdkConfigurable.java +++ b/python/src/com/jetbrains/python/configuration/PyActiveSdkConfigurable.java @@ -12,7 +12,6 @@ import com.intellij.openapi.options.UnnamedConfigurable; import com.intellij.openapi.project.Project; import com.intellij.openapi.projectRoots.Sdk; import com.intellij.openapi.roots.ProjectRootManager; -import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel; import com.intellij.openapi.ui.ComboBox; import com.intellij.openapi.ui.popup.JBPopupFactory; import com.intellij.openapi.ui.popup.ListPopup; @@ -40,6 +39,7 @@ import com.jetbrains.python.sdk.PyTransferredSdkRootsKt; import com.jetbrains.python.sdk.PythonSdkType; import com.jetbrains.python.sdk.SdkExtKt; import com.jetbrains.python.sdk.legacy.PythonSdkUtil; +import com.jetbrains.python.sdk.ProjectExtKt; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -68,10 +68,6 @@ public class PyActiveSdkConfigurable implements UnnamedConfigurable { private final @Nullable Module myModule; - private final @NotNull PyConfigurableInterpreterList myInterpreterList; - - private final @NotNull ProjectSdksModel myProjectSdksModel; - private final @NotNull JPanel myMainPanel; private final @NotNull ComboBox mySdkCombo; @@ -114,9 +110,6 @@ public class PyActiveSdkConfigurable implements UnnamedConfigurable { packagesNotificationPanel, customizer); - myInterpreterList = PyConfigurableInterpreterList.getInstance(myProject); - myProjectSdksModel = myInterpreterList.getModel(); - // Reflect the SDK configuration mutex reactively: disable the controls only while the lock is // held and refresh the combo once it is released, since a background configuration may have // created or changed the interpreter in the meantime. @@ -196,7 +189,7 @@ public class PyActiveSdkConfigurable implements UnnamedConfigurable { // do not use `getOriginalSelectedSdk()` here since `model` won't find original sdk for selected item due to applying final Sdk currentSelectedSdk = getEditableSelectedSdk(); - if (currentSelectedSdk != null && myProjectSdksModel.findSdk(currentSelectedSdk.getName()) != null) { + if (currentSelectedSdk != null && getEditableSdkUsingOriginal(currentSelectedSdk) != null) { // nothing has been selected but previously selected sdk still exists, stay with it updateSdkListAndSelect(currentSelectedSdk); } @@ -219,8 +212,8 @@ public class PyActiveSdkConfigurable implements UnnamedConfigurable { @Nullable private Sdk getOriginalSelectedSdk() { - final Sdk editableSdk = getEditableSelectedSdk(); - return editableSdk == null ? null : myProjectSdksModel.findSdk(editableSdk); + // The combo holds the real SDKs from the live table, so the selected item is already the original. + return getEditableSelectedSdk(); } @Nullable @@ -271,17 +264,13 @@ public class PyActiveSdkConfigurable implements UnnamedConfigurable { @Override public final void reset() { - // The SDK model is cached per project (see PyConfigurableInterpreterList) and may have been - // populated before a new interpreter was created — e.g. by the interpreter widget popup, which - // builds its list from the same model. Refresh it from the live SDK table so a just-created - // interpreter is present and selectable instead of the combo showing "". - myProjectSdksModel.reset(myProject); + // The combo reads the live SDK table, so a just-created interpreter is present without any extra refresh. updateSdkListAndSelect(getSdk()); } @NotNull private List getAvailableSdks() { - return myInterpreterList.getAllPythonSdks(myModule); + return ProjectExtKt.getAssignablePythonSdks(myProject, myModule); } private void updateSdkListAndSelect(@Nullable Sdk selectedSdk) { @@ -317,12 +306,16 @@ public class PyActiveSdkConfigurable implements UnnamedConfigurable { @Nullable private Sdk getEditableSdkUsingOriginal(@Nullable Sdk sdk) { - return sdk == null ? null : myProjectSdksModel.findSdk(sdk.getName()); + if (sdk == null) return null; + // The SDK may come from the "Python Interpreters" dialog as an editable copy; match the live table SDK by name. + for (Sdk available : getAvailableSdks()) { + if (available.getName().equals(sdk.getName())) return available; + } + return null; } @Override public final void disposeUIResources() { - myInterpreterList.disposeModel(); if (myDisposable != null) { Disposer.dispose(myDisposable); } diff --git a/python/src/com/jetbrains/python/configuration/PyConfigurableInterpreterList.java b/python/src/com/jetbrains/python/configuration/PyConfigurableInterpreterList.java index 22fbde840233..e1734d2b9603 100644 --- a/python/src/com/jetbrains/python/configuration/PyConfigurableInterpreterList.java +++ b/python/src/com/jetbrains/python/configuration/PyConfigurableInterpreterList.java @@ -1,101 +1,42 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.configuration; -import com.intellij.openapi.components.Service; -import com.intellij.openapi.module.Module; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.projectRoots.Sdk; -import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel; -import com.intellij.openapi.util.Comparing; -import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory; -import com.jetbrains.python.run.TargetConfigurationWithLocalFsAccessExKt; -import com.jetbrains.python.sdk.SdkExtKt; -import com.jetbrains.python.sdk.legacy.PythonSdkUtil; -import org.jetbrains.annotations.ApiStatus; +import com.jetbrains.python.sdk.ProjectExtKt; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import static com.jetbrains.python.sdk.legacy.PythonSdkUtil.isRemote; - /** - * Manages the SDK model shared between PythonSdkConfigurable and PyActiveSdkConfigurable. + * @deprecated The cached SDK model was removed; query the live SDK table via + * {@link ProjectExtKt#getAssignablePythonSdks(Project, com.intellij.openapi.module.Module)} instead. Retained for + * external plugins that still reference this service. */ -@Service(Service.Level.PROJECT) +@Deprecated public final class PyConfigurableInterpreterList { - private ProjectSdksModel myModel; + private final @NotNull Project myProject; + private PyConfigurableInterpreterList(@NotNull Project project) { + myProject = project; + } + + /** + * @deprecated Obtain interpreters via {@link ProjectExtKt#getAssignablePythonSdks(Project, com.intellij.openapi.module.Module)}. + */ + @Deprecated public static PyConfigurableInterpreterList getInstance(@Nullable Project project) { final Project effectiveProject = project != null ? project : ProjectManager.getInstance().getDefaultProject(); - final PyConfigurableInterpreterList instance = effectiveProject.getService(PyConfigurableInterpreterList.class); - if (effectiveProject != project) { - instance.disposeModel(); - } - return instance; - } - - public ProjectSdksModel getModel() { - if (myModel == null) { - myModel = new ProjectSdksModel(); - myModel.reset(null); - } - return myModel; - } - - public void disposeModel() { - if (myModel != null) { - myModel.disposeUIResources(); - myModel = null; - } + return new PyConfigurableInterpreterList(effectiveProject); } /** - * @param module if not null and module resides on certain target, returns only SDKs for this target - */ - @ApiStatus.Internal - public @NotNull List getAllPythonSdks(@Nullable Module module) { - var targetModuleSitsOn = (module != null) - ? PythonInterpreterTargetEnvironmentFactory.Companion.getTargetModuleResidesOn(module) - : null; - - List result = new ArrayList<>(); - for (Sdk sdk : getModel().getSdks()) { - if (!PythonSdkUtil.isPythonSdk(sdk)) continue; - if (targetModuleSitsOn != null) { - var sdkConfig = SdkExtKt.getTargetEnvConfiguration(sdk); - if (!TargetConfigurationWithLocalFsAccessExKt.codeCouldProbablyBeRunWithConfig(targetModuleSitsOn, sdkConfig)) { - continue; - } - } - result.add(sdk); - } - result.sort(new PyInterpreterComparator()); - return result; - } - - /** - * Returns all Python SDKs visible across the IDE. - * - * @deprecated The global SDK table is being replaced with per-project SDK visibility. + * @deprecated Use {@link ProjectExtKt#getAssignablePythonSdks(Project, com.intellij.openapi.module.Module)} with a {@code null} module. */ @Deprecated public @NotNull List getAllPythonSdks() { - return getAllPythonSdks(null); - } - - private static class PyInterpreterComparator implements Comparator { - @Override - public int compare(@NotNull Sdk o1, Sdk o2) { - // Remote SDKs last - final boolean isRemote1 = isRemote(o1); - final boolean isRemote2 = isRemote(o2); - if (isRemote1 != isRemote2) return isRemote1 ? 1 : -1; - - return Comparing.compare(o1.getName(), o2.getName()); - } + return ProjectExtKt.getAssignablePythonSdks(myProject, null); } } diff --git a/python/src/com/jetbrains/python/configuration/PythonInterpreterConfigurable.kt b/python/src/com/jetbrains/python/configuration/PythonInterpreterConfigurable.kt index 0cca53442e1e..b15edaf2da6d 100644 --- a/python/src/com/jetbrains/python/configuration/PythonInterpreterConfigurable.kt +++ b/python/src/com/jetbrains/python/configuration/PythonInterpreterConfigurable.kt @@ -56,15 +56,17 @@ internal class PythonInterpreterConfigurable(moduleOrProject: ModuleOrProject) : val configurable = PythonInterpreterConfigurable(if (module != null) ModuleAndProject(module) else ProjectOnly(project)) // `ShowSettingsUtil.editConfigurable()` with `advancedInitialization` parameter could be possibly also used here val dialogWrapper = SettingsDialogFactory.getInstance().create(project, DIMENSION_KEY, configurable, true, false) - // select project Python interpreter - configurable.masterDetailsComponent.selectNodeInTree(initiallySelectedSdk) + // Select the project Python interpreter. The dialog owns its model, which `create()` has just reloaded from the + // live SDK table; re-resolve the caller's `initiallySelectedSdk` against it by name because the model holds + // editable copies, not the caller's instance. + val sdkToSelect = initiallySelectedSdk?.let { configurable.masterDetailsComponent.projectSdksModel.findSdk(it.name) ?: it } + configurable.masterDetailsComponent.selectNodeInTree(sdkToSelect) val isOKClicked = dialogWrapper.showAndGet() // note that clicking "Apply" button and then "Cancel" results in `false` value of `isOKClicked` return if (isOKClicked) { configurable.masterDetailsComponent.storedSelectedSdk } else { - configurable.masterDetailsComponent.projectSdksModel.reset(project) null } } diff --git a/python/src/com/jetbrains/python/configuration/PythonInterpreterMasterDetails.kt b/python/src/com/jetbrains/python/configuration/PythonInterpreterMasterDetails.kt index abf3af674ecd..2291546815f1 100644 --- a/python/src/com/jetbrains/python/configuration/PythonInterpreterMasterDetails.kt +++ b/python/src/com/jetbrains/python/configuration/PythonInterpreterMasterDetails.kt @@ -18,6 +18,7 @@ import com.intellij.openapi.project.DumbAwareToggleAction import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.OrderRootType +import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel import com.intellij.openapi.ui.InputValidatorEx import com.intellij.openapi.ui.MasterDetailsComponent import com.intellij.openapi.ui.Messages @@ -32,6 +33,7 @@ import com.jetbrains.python.sdk.ModuleOrProject.ProjectOnly import com.jetbrains.python.sdk.PythonSdkUpdater import com.jetbrains.python.sdk.collectAddInterpreterActions import com.jetbrains.python.sdk.customizeWithSdkValue +import com.jetbrains.python.sdk.filterAssignablePythonSdks import com.jetbrains.python.sdk.isAssociatedWithAnotherModule import com.jetbrains.python.sdk.legacy.PythonSdkUtil import com.jetbrains.python.sdk.noInterpreterMarker @@ -48,8 +50,6 @@ import javax.swing.tree.TreePath * the list. */ internal class PythonInterpreterMasterDetails(private val moduleOrProject: ModuleOrProject, private val parentConfigurable: Configurable) : MasterDetailsComponent() { - private val pythonConfigurableInterpreterList: PyConfigurableInterpreterList = PyConfigurableInterpreterList.getInstance(moduleOrProject.project) - private val project = moduleOrProject.project // Temporary hack as lots of legacy code accept nullable module. Remove after legacy code migrated to ModuleOrProject. @@ -58,7 +58,9 @@ internal class PythonInterpreterMasterDetails(private val moduleOrProject: Modul is ProjectOnly -> null } - internal val projectSdksModel = pythonConfigurableInterpreterList.model + // The dialog owns a short-lived editable SDK model instead of sharing a cached project-level one. It is reloaded + // from the live SDK table in `reset()` and disposed in `disposeUIResources()` (PY-90077). + internal val projectSdksModel = ProjectSdksModel() /** * Indicates whether Python paths of one or more interpreters have been changed by user via Python Paths dialog. @@ -116,11 +118,15 @@ internal class PythonInterpreterMasterDetails(private val moduleOrProject: Modul } private val allPythonSdksInEdit: List - get() = pythonConfigurableInterpreterList.getAllPythonSdks(module) + get() = project.filterAssignablePythonSdks(projectSdksModel.sdks.toList(), module) override fun reset() { pythonPathsModified = false + // Reload the editable model from the live SDK table so interpreters added elsewhere (e.g. the "Add Interpreter" + // link or the status-bar widget) appear immediately; the tree is then built from these editable copies. + projectSdksModel.reset(project) + myRoot.removeAllChildren() val visiblePythonSdks = when { @@ -152,6 +158,11 @@ internal class PythonInterpreterMasterDetails(private val moduleOrProject: Modul projectSdksModel.apply(this) } + override fun disposeUIResources() { + super.disposeUIResources() + projectSdksModel.disposeUIResources() + } + override fun createActions(fromPopup: Boolean): List = if (fromPopup) { listOf(RemoveAction(), RenameAction(), ShowPathsAction()) diff --git a/python/src/com/jetbrains/python/console/PyConsoleOptionsConfigurable.kt b/python/src/com/jetbrains/python/console/PyConsoleOptionsConfigurable.kt index e41ec6a08479..e433072b6750 100644 --- a/python/src/com/jetbrains/python/console/PyConsoleOptionsConfigurable.kt +++ b/python/src/com/jetbrains/python/console/PyConsoleOptionsConfigurable.kt @@ -122,6 +122,7 @@ class PyConsoleOptionsConfigurable(private val myProject: Project) : SearchableC override fun isModified(): Boolean = panel.isModified override fun apply() = panel.apply() override fun reset() = panel.reset() + override fun disposeUIResources() = panel.disposeUIResources() } } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/console/PyConsoleSpecificOptionsPanel.kt b/python/src/com/jetbrains/python/console/PyConsoleSpecificOptionsPanel.kt index 70d05207bc12..6f9c417825e0 100644 --- a/python/src/com/jetbrains/python/console/PyConsoleSpecificOptionsPanel.kt +++ b/python/src/com/jetbrains/python/console/PyConsoleSpecificOptionsPanel.kt @@ -5,6 +5,8 @@ import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.openapi.ui.DialogPanel import com.intellij.psi.PsiDocumentManager import com.intellij.ui.EditorTextField @@ -27,10 +29,13 @@ internal class PyConsoleSpecificOptionsPanel(private val project: Project) { private lateinit var editorTextField: EditorTextField private lateinit var commonOptionsForm: AbstractPyCommonOptionsForm + // Owns the SDK-table subscription set up by `commonOptionsForm.subscribe`; disposed in `disposeUIResources`. + private val uiDisposable: Disposable = Disposer.newDisposable() + fun createPanel(optionsProvider: PyConsoleOptions.PyConsoleSettings): JComponent { consoleSettings = optionsProvider commonOptionsForm = PyCommonOptionsFormFactory.getInstance().createForm(createCommonOptionsFormData(project)).also { - it.subscribe() + it.subscribe(uiDisposable) } editorTextField = createEditorTextField(optionsProvider.myCustomStartScript) @@ -50,6 +55,10 @@ internal class PyConsoleSpecificOptionsPanel(private val project: Project) { consoleSettings.reset(project, commonOptionsForm) } + fun disposeUIResources() { + Disposer.dispose(uiDisposable) + } + private fun createPanel(): DialogPanel { return panel { row { diff --git a/python/src/com/jetbrains/python/run/AbstractPyCommonOptionsForm.java b/python/src/com/jetbrains/python/run/AbstractPyCommonOptionsForm.java index 1ba4e827b720..a284e36a3448 100644 --- a/python/src/com/jetbrains/python/run/AbstractPyCommonOptionsForm.java +++ b/python/src/com/jetbrains/python/run/AbstractPyCommonOptionsForm.java @@ -1,8 +1,10 @@ // Copyright 2000-2021 JetBrains s.r.o. 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.run; +import com.intellij.openapi.Disposable; import com.intellij.ui.PanelWithAnchor; import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; import javax.swing.JComponent; import java.util.function.Consumer; @@ -13,7 +15,11 @@ public interface AbstractPyCommonOptionsForm extends AbstractPythonRunConfigurat JComponent getMainPanel(); - void subscribe(); + /** + * Subscribes to live SDK-table changes to keep the interpreter list up to date. The subscription is released when + * {@code parentDisposable} is disposed. + */ + void subscribe(@NotNull Disposable parentDisposable); void addInterpreterModeListener(Consumer listener); } diff --git a/python/src/com/jetbrains/python/sdk/PySdkPopupFactory.kt b/python/src/com/jetbrains/python/sdk/PySdkPopupFactory.kt index ce747a031764..c8686cb97006 100644 --- a/python/src/com/jetbrains/python/sdk/PySdkPopupFactory.kt +++ b/python/src/com/jetbrains/python/sdk/PySdkPopupFactory.kt @@ -18,7 +18,6 @@ import com.intellij.openapi.wm.ToolWindowManager import com.intellij.util.SlowOperations import com.intellij.util.ui.SwingHelper import com.jetbrains.python.PyBundle -import com.jetbrains.python.configuration.PyConfigurableInterpreterList import com.jetbrains.python.inspections.interpreter.InterpreterSettingsQuickFix import com.jetbrains.python.psi.LanguageLevel import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory @@ -53,9 +52,8 @@ class PySdkPopupFactory(val module: Module) { fun createPopup(context: DataContext): ListPopup { val group = DefaultActionGroup() - val interpreterList = PyConfigurableInterpreterList.getInstance(module.project) val moduleSdksByTypes = SlowOperations.knownIssue("PY-76167").use { - groupModuleSdksByTypes(interpreterList.getAllPythonSdks(module), module) { + groupModuleSdksByTypes(module.project.getAssignablePythonSdks(module), module) { !it.isSdkSeemsValid || !LanguageLevel.SUPPORTED_LEVELS.contains(PythonSdkType.getLanguageLevelForSdk(it)) } }