diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 5c1173020f64..44e99a89d851 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -10,6 +10,7 @@ import com.intellij.openapi.application.readAction import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceAsync +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.module.Module @@ -91,7 +92,10 @@ import org.jetbrains.annotations.Nls @Service(Service.Level.PROJECT) internal class PyPackagingToolWindowService(val project: Project, val serviceScope: CoroutineScope) : Disposable { - private var toolWindowPanel: PyPackagingToolWindowPanel? = null + // Written on EDT when the tool window builds its content, read from every background coroutine + // here. Volatile like `sdkContext` / `installedPackages`, otherwise a refresh already in flight + // when the panel is attached can still observe `null` and silently drop its render. + @Volatile private var toolWindowPanel: PyPackagingToolWindowPanel? = null @Volatile private var installedPackages: List = emptyList() private var searchJob: Job? = null private var currentQuery: String = "" @@ -121,8 +125,22 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco fun initialize(toolWindowPanel: PyPackagingToolWindowPanel) { this.toolWindowPanel = toolWindowPanel serviceScope.launch(Dispatchers.IO) { - val sdk = currentSdk ?: readAction { project.modules.firstNotNullOfOrNull { it.pythonSdk } } - initForSdk(sdk) + val sdkToOpenOn = resolvePackagesToolWindowSdk(project) + val boundSdk = sdkContext?.sdk + if (shouldReplayBoundSdk(boundSdk, sdkToOpenOn)) { + checkNotNull(boundSdk) + publishSdkToPanel(boundSdk) + withContext(Dispatchers.EDT) { + toolWindowPanel.contentVisible = true + // `installedPackages` is already in memory from the earlier binding, so replaying the + // active query paints it into the new panel without a second package-manager round-trip. + // Its terminal `resetSearch` / `showSearchResult` also clears the loading state that + // `publishSdkToPanel` just raised. + handleSearch(currentQuery) + } + return@launch + } + initForSdk(sdkToOpenOn) } } @@ -366,6 +384,24 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco } } + /** + * Pushes everything the view derives from the SDK alone: header interpreter path, package-list + * header name and loading state, module list selection. Extracted from [initForSdk] because a + * panel can be attached *after* the service is already bound to that SDK, in which case + * [initForSdk] short-circuits and only this part has to be replayed — see [initialize] + * (PY-91300). The presentation is built off EDT: it probes SDK validity. + */ + private suspend fun publishSdkToPanel(sdk: Sdk) { + val interpreterPath = sdk.pyInterpreterPresentation().fullName + withContext(Dispatchers.EDT) { + toolWindowPanel?.let { + it.startLoadingSdk(sdk.name) + it.setInterpreterPath(interpreterPath) + it.syncSdkControllerSelection(sdk) + } + } + } + @ApiStatus.Internal suspend fun initForSdk(sdk: Sdk?) { if (project.isDisposed) return @@ -388,13 +424,7 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco return } - withContext(Dispatchers.EDT) { - toolWindowPanel?.let { - it.startLoadingSdk(sdk.name) - it.setInterpreterPath(sdk.pyInterpreterPresentation().fullName) - it.syncSdkControllerSelection(sdk) - } - } + publishSdkToPanel(sdk) sdkContext = SdkContext( sdk = sdk, @@ -850,3 +880,41 @@ internal class PyPackagingToolWindowService(val project: Project, val serviceSco } } } + +/** + * The interpreter the Python Packages tool window should open on: the one belonging to the module + * that owns the file the user is looking at, falling back to any module's interpreter when the + * editor gives no answer. + * + * Scanning `modules.firstNotNullOfOrNull { it.pythonSdk }` outright is only correct in a + * single-module project. Across independent subprojects it picks whichever module happens to come + * first, so the tool window opens on a foreign subproject's environment while the user is editing + * another one, and only starts agreeing with the editor once the next selection change reaches + * `PyPackagingToolWindowService`'s `FileEditorManagerListener` — the "switch between subprojects to + * make it refresh" half of PY-91300. + */ +internal suspend fun resolvePackagesToolWindowSdk(project: Project): Sdk? = readAction { + val selectedFile = FileEditorManager.getInstance(project).selectedFiles.firstOrNull() + val selectedModule = selectedFile?.let { ModuleUtilCore.findModuleForFile(it, project) } + selectedModule?.let { PythonSdkUtil.findPythonSdk(it) } + ?: project.modules.firstNotNullOfOrNull { it.pythonSdk } +} + +/** + * Whether a tool window attaching to an already-bound [PyPackagingToolWindowService] should have the + * binding replayed into its fresh panel instead of re-binding the service. + * + * The service is a project service and outlives the tool window, so it can already be bound by the + * time a panel is built — the install dialog and the pyproject.toml "+ Add package" inlay call + * `initForSdk` directly, and the editor / roots / `PySdkListener` subscriptions keep that binding + * fresh. Handing the same SDK back to `initForSdk` would hit its "same SDK" short-circuit and the + * new panel would learn nothing at all: no path in the header, no module selection, empty package + * tree. Binding and rendering are separate concerns, so the rendering half is replayed explicitly. + * + * Replaying is only right while the binding agrees with [sdkToOpenOn]. A binding left over from + * another subproject has to be replaced instead, or the tool window would open on a foreign + * environment — and re-binding is safe there precisely because the SDKs differ, so `initForSdk` has + * real work to do (PY-91300). + */ +internal fun shouldReplayBoundSdk(boundSdk: Sdk?, sdkToOpenOn: Sdk?): Boolean = + boundSdk != null && (sdkToOpenOn == null || sdkToOpenOn == boundSdk) diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyPackagesToolWindowReplayDecisionTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyPackagesToolWindowReplayDecisionTest.kt new file mode 100644 index 000000000000..87cbf8e12cd4 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyPackagesToolWindowReplayDecisionTest.kt @@ -0,0 +1,47 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.junit5Tests.unit.packaging + +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.packaging.toolwindow.shouldReplayBoundSdk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock + +/** + * Covers [shouldReplayBoundSdk] — whether a tool window attaching to an already-bound packaging + * service gets the binding replayed into its fresh panel, or re-binds the service instead. + * + * Both mistakes this pins down are real: replaying nothing leaves the header empty on first open, + * and replaying unconditionally shows a leftover subproject's environment (PY-91300). + */ +internal class PyPackagesToolWindowReplayDecisionTest { + private val boundSdk: Sdk = mock(Sdk::class.java) + private val otherSdk: Sdk = mock(Sdk::class.java) + + @Test + fun `replays when the binding is the interpreter to open on`() { + assertTrue(shouldReplayBoundSdk(boundSdk = boundSdk, sdkToOpenOn = boundSdk), + "initForSdk would short-circuit on the same SDK, leaving the new panel blank") + } + + @Test + fun `replays when there is nothing better to open on`() { + assertTrue(shouldReplayBoundSdk(boundSdk = boundSdk, sdkToOpenOn = null), + "An existing binding beats showing no interpreter at all") + } + + @Test + fun `rebinds when the binding belongs to another subproject`() { + assertFalse(shouldReplayBoundSdk(boundSdk = boundSdk, sdkToOpenOn = otherSdk), + "Replaying a stale binding would open the tool window on a foreign environment") + } + + @Test + fun `binds from scratch when the service is not bound yet`() { + assertFalse(shouldReplayBoundSdk(boundSdk = null, sdkToOpenOn = otherSdk), + "There is no binding to replay") + assertFalse(shouldReplayBoundSdk(boundSdk = null, sdkToOpenOn = null), + "There is no binding to replay") + } +} diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyPackagesToolWindowSdkResolutionTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyPackagesToolWindowSdkResolutionTest.kt new file mode 100644 index 000000000000..142a905198f0 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/packaging/PyPackagesToolWindowSdkResolutionTest.kt @@ -0,0 +1,104 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.junit5Tests.unit.packaging + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.junit5.fixture.moduleFixture +import com.intellij.testFramework.junit5.fixture.projectFixture +import com.intellij.testFramework.junit5.fixture.tempPathFixture +import com.jetbrains.python.PythonMockSdk +import com.jetbrains.python.PythonTestUtil +import com.jetbrains.python.junit5.framework.pyMockSdkFixture +import com.jetbrains.python.packaging.toolwindow.resolvePackagesToolWindowSdk +import com.jetbrains.python.psi.LanguageLevel +import com.jetbrains.python.sdk.PythonSdkType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Path +import kotlin.io.path.writeText + +/** + * Covers [resolvePackagesToolWindowSdk], the interpreter the Python Packages tool window opens on. + * + * Regression test for PY-91300: with an environment configured per subproject, the tool window used + * to open on whichever module came first instead of the one owning the file in the editor. + * + * The two "selected file wins" cases are deliberately symmetric. Module order is not something the + * test controls — the fixtures name modules after temp directories — so a single case could pass by + * luck if its module happened to be scanned first. Under a first-module-wins regression exactly one + * of the two must fail, whichever way the order falls. + */ +@TestApplication +internal class PyPackagesToolWindowSdkResolutionTest { + private val projectFixture = projectFixture(openAfterCreation = true) + + private val firstModulePath = tempPathFixture() + private val secondModulePath = tempPathFixture() + private val firstModule = projectFixture.moduleFixture(firstModulePath, addPathToSourceRoot = true) + private val secondModule = projectFixture.moduleFixture(secondModulePath, addPathToSourceRoot = true) + + // Distinct names matter: a module stores its SDK by name, so same-named mocks would make both + // modules resolve to the same interpreter and the test would prove nothing. + private val firstSdk = projectFixture.pyMockSdkFixture(firstModule) { mockPythonSdk("firstModuleSdk") } + private val secondSdk = projectFixture.pyMockSdkFixture(secondModule) { mockPythonSdk("secondModuleSdk") } + + @Test + fun `resolves the interpreter of the module owning the selected file`(): Unit = timeoutRunBlocking { + val expected = bothInterpretersConfigured().second + openFileIn(secondModulePath.get()) + + assertSame(expected, resolvePackagesToolWindowSdk(projectFixture.get()), + "The tool window must open on the interpreter of the subproject being edited") + } + + @Test + fun `resolves the interpreter of the other module when its file is selected`(): Unit = timeoutRunBlocking { + val expected = bothInterpretersConfigured().first + openFileIn(firstModulePath.get()) + + assertSame(expected, resolvePackagesToolWindowSdk(projectFixture.get()), + "The tool window must open on the interpreter of the subproject being edited") + } + + @Test + fun `falls back to a configured interpreter when no file is open`(): Unit = timeoutRunBlocking { + bothInterpretersConfigured() + + assertNotNull(resolvePackagesToolWindowSdk(projectFixture.get()), + "With no editor to go by, any configured interpreter is better than none") + } + + /** + * Initializes both SDK fixtures. Fixtures are lazy, so without this the module that the test does + * not name would have no interpreter at all and a first-module-wins regression would have nothing + * to pick up. + */ + private suspend fun bothInterpretersConfigured(): Pair = Pair(firstSdk.get(), secondSdk.get()) + + private suspend fun openFileIn(moduleDir: Path) { + val project: Project = projectFixture.get() + val path = moduleDir.resolve("main.py").apply { writeText("") } + val file = withContext(Dispatchers.IO) { + VirtualFileManager.getInstance().refreshAndFindFileByNioPath(path) ?: error("$path is not in VFS") + } + withContext(Dispatchers.EDT) { + FileEditorManager.getInstance(project).openFile(file, true) + } + // Guard the precondition: if the editor manager reports no selection the resolution silently + // falls back to scanning modules, and the assertions below would stop meaning anything. + assertTrue(withContext(Dispatchers.EDT) { FileEditorManager.getInstance(project).selectedFiles.isNotEmpty() }, + "Expected $path to be the selected file") + } + + private fun mockPythonSdk(name: String): Sdk = + PythonMockSdk.create(name, PythonTestUtil.getTestDataPath() + "/MockSdk", PythonSdkType.getInstance(), LanguageLevel.getLatest()) +}