diff --git a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/PyContentEntriesModuleConfigurable.kt b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/PyContentEntriesModuleConfigurable.kt index 6bcd183c8841..c25753f96113 100644 --- a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/PyContentEntriesModuleConfigurable.kt +++ b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/PyContentEntriesModuleConfigurable.kt @@ -10,36 +10,17 @@ import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.roots.ModifiableRootModel import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.roots.ui.configuration.DefaultModulesProvider -import com.intellij.openapi.ui.Splitter import com.intellij.python.pyproject.model.PyProjectModelSettings -import com.intellij.ui.AncestorListenerAdapter -import com.intellij.ui.roots.IconActionComponent -import com.intellij.ui.roots.ToolbarPanel -import com.intellij.util.ui.UIUtil import com.jetbrains.python.PyBundle import com.jetbrains.python.module.PyContentEntriesEditor import org.jetbrains.jps.model.java.JavaSourceRootType import java.awt.BorderLayout -import java.awt.Component import javax.swing.JComponent import javax.swing.JPanel -import javax.swing.JPopupMenu -import javax.swing.JScrollPane -import javax.swing.JTextField -import javax.swing.JTree -import javax.swing.event.AncestorEvent internal class PyContentEntriesModuleConfigurable(private val module: Module) : SearchableConfigurable.Parent.Abstract() { private val topPanel = JPanel(BorderLayout()) - //.apply { - // addAncestorListener(object : AncestorListenerAdapter() { - // override fun ancestorAdded(event: AncestorEvent) { - // updateEnabledState() - // } - // }) - //} - private var modifiableModel: ModifiableRootModel? = null private var editor: PyContentEntriesEditor? = null private val isPyProjectTomlManaged: Boolean @@ -87,7 +68,6 @@ internal class PyContentEntriesModuleConfigurable(private val module: Module) : ApplicationManager.getApplication().runWriteAction { modifiableModel?.commit() } resetEditor() } - //updateEnabledState() } override fun reset() { @@ -104,38 +84,6 @@ internal class PyContentEntriesModuleConfigurable(private val module: Module) : createEditor() } - private fun updateEnabledState() { - val visible = !isPyProjectTomlManaged - - UIUtil.findComponentOfType(topPanel, Splitter::class.java)?.let { splitter -> - // Hide the "Mark as" label and action toolbar (siblings of the splitter in contentPanel) - splitter.parent?.let { contentPanel -> - contentPanel.components.filter { it != splitter }.forEach { it.isVisible = visible } - } - - // In the right panel (ToolbarPanel): hide the "Add Content Root" toolbar and remove-root icons - val contentRootsPanel = splitter.secondComponent - (contentRootsPanel as? ToolbarPanel)?.let { - it.getComponent(0).isVisible = visible - } - UIUtil.findComponentsOfType(contentRootsPanel, IconActionComponent::class.java).forEach { - it.isVisible = visible - } - } - - // Suppress tree context menu without removing PopupHandler listeners - UIUtil.findComponentOfType(topPanel, JTree::class.java)?.let { tree -> - tree.componentPopupMenu = if (visible) null else EMPTY_POPUP - } - - // Hide "Exclude patterns" field, its label, and comment (all are siblings of the tree JScrollPane) - UIUtil.findComponentOfType(topPanel, JTextField::class.java)?.let { excludeField -> - excludeField.parent?.let { dialogPanel -> - dialogPanel.components.filter { it !is JScrollPane }.forEach { it.isVisible = visible } - } - } - } - override fun disposeUIResources() { editor?.let { it.disposeUIResources() @@ -149,10 +97,4 @@ internal class PyContentEntriesModuleConfigurable(private val module: Module) : override fun buildConfigurables(): Array = emptyArray() override fun getId(): String = "python.project.structure" - - companion object { - private val EMPTY_POPUP = object : JPopupMenu() { - override fun show(invoker: Component?, x: Int, y: Int) {} - } - } } diff --git a/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/package-info.java b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/package-info.java new file mode 100644 index 000000000000..e77af5fb3b6b --- /dev/null +++ b/python/ide/impl/src/com/intellij/pycharm/community/ide/impl/configuration/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@Internal +package com.intellij.pycharm.community.ide.impl.configuration; + +import org.jetbrains.annotations.ApiStatus.Internal; diff --git a/python/python-pyproject/resources/intellij.python.pyproject.xml b/python/python-pyproject/resources/intellij.python.pyproject.xml index d141629aa121..7c26d1d297b6 100644 --- a/python/python-pyproject/resources/intellij.python.pyproject.xml +++ b/python/python-pyproject/resources/intellij.python.pyproject.xml @@ -14,7 +14,6 @@ - diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt index 0dd4af267841..8edbc1e9b244 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/PyProjectModelSettings.kt @@ -10,11 +10,13 @@ import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry +import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService @Service(Service.Level.PROJECT) @State(name = "PyProjectModelSettings", storages = [Storage("pyProjectModel.xml")]) -class PyProjectModelSettings : PersistentStateComponent, Disposable { +class PyProjectModelSettings(private val project: Project) : + PersistentStateComponent, Disposable { override fun dispose() {} class State : BaseState() { var usePyprojectToml: Boolean by property(false) @@ -23,14 +25,20 @@ class PyProjectModelSettings : PersistentStateComponent().apply { + if (value) { + start() + } + else { + stop() + } + } } } @@ -50,6 +58,11 @@ class PyProjectModelSettings : PersistentStateComponent feature is disabled on the Registry. + * For user-defined setting, check [PyProjectModelSettings.usePyprojectToml]. + * Be sure to check **both** except for UI for the aforementioned service. + */ val isFeatureEnabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model") } } diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt index 0797796fa8e1..d1c45ca029bb 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt @@ -9,16 +9,18 @@ import org.jetbrains.annotations.ApiStatus /** - * Starts autoimport process if [enabled] or simply "skips" to the next step: [notifyModelRebuilt]. + * Starts autoimport process if [PyProjectModelSettings.isFeatureEnabled] or simply "skips" to the next step: [notifyModelRebuilt]. * This method usually called by [com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity] except for new projects. * In this case, it is postponed till project generation (see usages). * - * This method can only be called once (see [PyProjectAutoImportService.start]) + * This method can only be called **once** (call [PyProjectAutoImportService.start] to enable/disable import logic) */ @ApiStatus.Internal suspend fun startAutoImportIfNeeded(project: Project) { startVenvExclusion(project) - if (PyProjectModelSettings.isFeatureEnabled) { + askUserIfPyProjectMustBeEnabled(project) + // Only start autoImport if both: registry and user flags are enabled + if (PyProjectModelSettings.isFeatureEnabled && PyProjectModelSettings.getInstance(project).usePyprojectToml) { project.service().start() } else { diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt index e7a5b77bc5c6..d426d276de8d 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/PyProjectModelStartupActivity.kt @@ -8,7 +8,6 @@ import com.intellij.notification.impl.NotificationFullContent import com.intellij.openapi.application.readAction import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project -import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.util.Disposer import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope @@ -18,50 +17,48 @@ import org.jetbrains.annotations.Nls private const val NOTIFICATION_GROUP_ID = "PyProject.toml" -internal class PyProjectModelStartupActivity : ProjectActivity { - override suspend fun execute(project: Project) { - if (!PyProjectModelSettings.isFeatureEnabled) return +internal suspend fun askUserIfPyProjectMustBeEnabled(project: Project) { + if (!PyProjectModelSettings.isFeatureEnabled) return - val settings = PyProjectModelSettings.getInstance(project) - if (!settings.showConfigurationNotification) return + val settings = PyProjectModelSettings.getInstance(project) + if (!settings.showConfigurationNotification) return - val hasPyprojectToml = readAction { - !project.isDisposed && FilenameIndex.getVirtualFilesByName(PY_PROJECT_TOML, GlobalSearchScope.projectScope(project)).isNotEmpty() - } - - if (hasPyprojectToml) { - showNotification(project, settings) - } - else { - listenForPyprojectToml(project, settings) - } + val hasPyprojectToml = readAction { + !project.isDisposed && FilenameIndex.getVirtualFilesByName(PY_PROJECT_TOML, GlobalSearchScope.projectScope(project)).isNotEmpty() } - private fun listenForPyprojectToml(project: Project, settings: PyProjectModelSettings) { - val disposable = Disposer.newDisposable("PyProjectModelStartupActivity") - Disposer.register(settings, disposable) + if (hasPyprojectToml) { + showNotification(project, settings) + } + else { + listenForPyprojectToml(project, settings) + } +} - project.messageBus.connect(disposable).subscribe(DumbService.DUMB_MODE, object : DumbService.DumbModeListener { - override fun exitDumbMode() { - if (!settings.showConfigurationNotification) { - Disposer.dispose(disposable) - return - } +private fun listenForPyprojectToml(project: Project, settings: PyProjectModelSettings) { + val disposable = Disposer.newDisposable("PyProjectModelStartupActivity") + Disposer.register(settings, disposable) - val hasAnyPyprojectToml = FilenameIndex.hasVirtualFileWithName( - PY_PROJECT_TOML, - true, - GlobalSearchScope.projectScope(project), - null - ) - - if (hasAnyPyprojectToml) { - Disposer.dispose(disposable) - showNotification(project, settings) - } + project.messageBus.connect(disposable).subscribe(DumbService.DUMB_MODE, object : DumbService.DumbModeListener { + override fun exitDumbMode() { + if (!settings.showConfigurationNotification) { + Disposer.dispose(disposable) + return } - }) - } + + val hasAnyPyprojectToml = FilenameIndex.hasVirtualFileWithName( + PY_PROJECT_TOML, + true, + GlobalSearchScope.projectScope(project), + null + ) + + if (hasAnyPyprojectToml) { + Disposer.dispose(disposable) + showNotification(project, settings) + } + } + }) } private fun showNotification(project: Project, settings: PyProjectModelSettings) { @@ -82,5 +79,5 @@ private fun showNotification(project: Project, settings: PyProjectModelSettings) .notify(project) } -private class FullContentNotification(groupId: String, @Nls title: String, @Nls content: String, type: NotificationType) - : Notification(groupId, title, content, type), NotificationFullContent +private class FullContentNotification(groupId: String, @Nls title: String, @Nls content: String, type: NotificationType) : + Notification(groupId, title, content, type), NotificationFullContent diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt index 8eab8b9d0174..2f684d23fb1d 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt @@ -25,8 +25,6 @@ import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.intellij.util.messages.Topic import com.intellij.util.ui.EDT import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.VisibleForTesting import java.nio.file.Path @@ -113,12 +111,10 @@ class PyExternalSystemProjectAware private constructor( */ @ApiStatus.Internal @VisibleForTesting - suspend fun create(project: Project): PyExternalSystemProjectAware { + fun create(project: Project): PyExternalSystemProjectAware { assert(!project.isDefault) { "Default project not supported" } - val baseDir = withContext(Dispatchers.IO) { - // guessPath doesn't work: it returns first module path - project.stateStore.projectBasePath - } + // guessPath doesn't work: it returns first module path + val baseDir = project.stateStore.projectBasePath return PyExternalSystemProjectAware(project, baseDir) } } diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt index 1eb1dbcabd8b..e485a673dda3 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt @@ -2,44 +2,58 @@ package com.intellij.python.pyproject.model.internal.autoImportBridge import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.fileLogger import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectTracker import com.intellij.openapi.project.Project +import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.TestOnly /** * [project] can't be default, check for it */ @Service(Service.Level.PROJECT) -internal class PyProjectAutoImportService(private val project: Project) : Disposable { +@ApiStatus.Internal +class PyProjectAutoImportService(private val project: Project) : Disposable { + private val m = Any() + init { assert(!project.isDefault) { "Default project not supported" } } - @Volatile private var projectId: ExternalSystemProjectId? = null @get:TestOnly - internal val initialized: Boolean get() = projectId != null + internal val initialized: Boolean get() = synchronized(m) { projectId != null } /** - * Starts auto-import (`builds project module on any pyproject.toml` change). To be called only once! + * Starts auto-import (`builds project module on any pyproject.toml` change). Does nothing if already started. Method is synchronized. + * You can always [stop] it, so does [dispose] */ - internal suspend fun start() { - assert(projectId == null) { "Already started, do not call second time" } - val tracker = getTracker() - val projectAware = PyExternalSystemProjectAware.create(project) - val projectId = projectAware.projectId - this.projectId = projectId - tracker.register(projectAware) - tracker.activate(projectId) - tracker.markDirty(projectId) - tracker.scheduleProjectRefresh() + fun start(): Unit = synchronized(m) { + if (projectId != null) { + log.info("PyProject already started") + return@synchronized + } + else { + val tracker = getTracker() + val projectAware = PyExternalSystemProjectAware.create(project) + val projectId = projectAware.projectId + this.projectId = projectId + tracker.register(projectAware) + tracker.activate(projectId) + tracker.markDirty(projectId) + tracker.scheduleProjectRefresh() + log.info("PyProject started") + } } - - override fun dispose() { + /** + * Stop auto-import (started by [start]) does nothing if already stopped. Method is synchronized. + */ + fun stop(): Unit = synchronized(m) { + log.info("PyProject stopped") projectId?.let { getTracker().remove(it) projectId = null @@ -47,5 +61,14 @@ internal class PyProjectAutoImportService(private val project: Project) : Dispos } + override fun dispose() { + stop() + } + + private fun getTracker(): ExternalSystemProjectTracker = ExternalSystemProjectTracker.getInstance(project) -} \ No newline at end of file + + private companion object { + val log = fileLogger() + } +} diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt index 27698d52908b..db7188a6fcbc 100644 --- a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt @@ -2,13 +2,17 @@ package com.intellij.python.junit5Tests.unit.pyproject import com.intellij.ide.impl.OpenProjectTask import com.intellij.openapi.components.service +import com.intellij.python.pyproject.model.PyProjectModelSettings import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService import com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity +import com.intellij.python.pyproject.model.internal.startAutoImportIfNeeded import com.intellij.testFramework.common.timeoutRunBlocking import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.junit5.fixture.projectFixture import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource @TestApplication internal class PyProjectSyncActivityTest { @@ -16,9 +20,30 @@ internal class PyProjectSyncActivityTest { projectFixture(openProjectTask = OpenProjectTask().copy(isProjectCreatedWithWizard = true), openAfterCreation = true) - @Test - fun testNoAutoRebuildForWizardBasedProject(): Unit = timeoutRunBlocking { - PyProjectSyncActivity().execute(project) - assertFalse(project.service().initialized, "Newly opened project shouldn't lead to autoimport ") + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun testNoAutoRebuildForWizardBasedProject(enableAutoImport: Boolean): Unit = timeoutRunBlocking { + val settings = project.service() + val oldValue = settings.usePyprojectToml + settings.usePyprojectToml = enableAutoImport + // Setting this var starts import automatically, so we stop it check that code starts it in tests + project.service().stop() + try { + val sut = project.service() + PyProjectSyncActivity().execute(project) + assertFalse(sut.initialized, "Newly opened project shouldn't lead to autoimport ") + startAutoImportIfNeeded(project) + if (enableAutoImport) { + assertTrue(sut.initialized, "Auto import must be started when called manually") + } + else { + assertFalse(sut.initialized, "Auto import started, even though was disabled") + } + sut.stop() + assertFalse(sut.initialized, "Autoimport must be stopped") + } + finally { + settings.usePyprojectToml = oldValue + } } }