From 4abe6f0b4de628a6c83e1f91e4b74960fa683dc7 Mon Sep 17 00:00:00 2001 From: Egor Eliseev Date: Wed, 4 Mar 2026 08:40:03 +0000 Subject: [PATCH] PY-87975 UI for switching between debug backends Add a segmented button to Settings | Python | Debugger Add ComboBoxAction in the Debug tool window toolbar (Python.DebuggerBackendSwitcher) Show a one-time GotIt tooltip on the first debug session Show a confirmation dialog when switching backend during an active session Merge-request: IJ-MR-194172 Merged-by: Egor Eliseev (cherry picked from commit 965677f0668b3493290f9f3bb59ed6e7da2f2f38) GitOrigin-RevId: 7f0429d17e817a9e0c043b25a69d244a064320c6 --- .../messages/PyBundle.properties | 8 ++ .../python/debugger/PyDebuggerBackend.kt | 7 ++ .../debugger/PyDebuggerConfigurable.form | 18 +++- .../debugger/PyDebuggerConfigurable.java | 70 ++++++++++++++- .../debugger/PyDebuggerOptionsProvider.java | 89 ++++++++++++++++++- 5 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 python/src/com/jetbrains/python/debugger/PyDebuggerBackend.kt diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 498e928d0f62..f98920a3293a 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -882,6 +882,14 @@ debugger.use.the.following.code.to.connect.to.the.debugger=Use the following cod debugger.progress.title.stackframe.processing=StackFrame processing debugger.dialog.message.failed.to.create.debug=Failed to create debug session: run content descriptor is null +debugger.backend.switcher.label=Debugger mode: +debugger.backend.pydevd.description=The core debugger engine that runs inside Python. Handles breakpoints, stepping, and execution control behind the scenes. +debugger.backend.debugpy.description=Connects your IDE to a running Python program for debugging. Enables breakpoints, stepping, variable inspection, and remote debugging from your editor. +debugger.backend.debugpy.disabled.tooltip=debugpy requires Python 3.9 or later +debugger.backend.switch.during.session.title=Switch Debugger Backend +debugger.backend.switch.during.session.message=Debugger backend cannot be switched during a running debug session. To apply the new setting, your current sessions will have to be restarted. +debugger.backend.switch.and.restart=Switch and Restart + python.local.attach.group.name=Python pydev.console.runner.settings=Settings diff --git a/python/src/com/jetbrains/python/debugger/PyDebuggerBackend.kt b/python/src/com/jetbrains/python/debugger/PyDebuggerBackend.kt new file mode 100644 index 000000000000..d0457da1aa46 --- /dev/null +++ b/python/src/com/jetbrains/python/debugger/PyDebuggerBackend.kt @@ -0,0 +1,7 @@ +// 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.debugger + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +enum class PyDebuggerBackend { PYDEVD, DEBUGPY } diff --git a/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.form b/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.form index e44b4721ea6e..230ac1178346 100644 --- a/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.form +++ b/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.form @@ -1,9 +1,9 @@
- + - + @@ -133,9 +133,15 @@ + + + + + + - + @@ -187,6 +193,12 @@ + + + + + +
diff --git a/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.java b/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.java index 5d851fbda5c0..0bc02d8e6807 100644 --- a/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.java +++ b/python/src/com/jetbrains/python/debugger/PyDebuggerConfigurable.java @@ -5,7 +5,9 @@ import com.intellij.icons.AllIcons; import com.intellij.ide.IdeTooltipManager; import com.intellij.openapi.options.Configurable; import com.intellij.openapi.options.SearchableConfigurable; +import com.intellij.openapi.ui.panel.ComponentPanelBuilder; import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ProjectRootManager; import com.intellij.openapi.ui.ComboBox; import com.intellij.openapi.ui.Messages; import com.intellij.openapi.util.NlsSafe; @@ -17,10 +19,15 @@ import com.intellij.ui.components.ActionLink; import com.intellij.ui.components.JBCheckBox; import com.intellij.ui.components.JBLabel; import com.intellij.ui.components.JBTextField; +import com.intellij.ui.dsl.builder.IntelliJSpacingConfiguration; +import com.intellij.ui.dsl.builder.SegmentedButton; +import com.intellij.ui.dsl.builder.components.SegmentedButtonComponent; import com.intellij.uiDesigner.core.GridConstraints; import com.intellij.uiDesigner.core.GridLayoutManager; import com.intellij.uiDesigner.core.Spacer; import com.jetbrains.python.PyBundle; +import com.jetbrains.python.PythonRuntimeService; +import com.jetbrains.python.psi.LanguageLevel; import org.jetbrains.annotations.Nls; import org.jetbrains.annotations.NotNull; @@ -30,6 +37,7 @@ import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JPanel; import java.awt.Dimension; +import java.awt.FlowLayout; import java.awt.Insets; import java.awt.event.ItemEvent; import java.awt.event.ItemListener; @@ -56,6 +64,9 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con private final JPanel myDebuggerPortPanel; private final JBLabel myDebuggerEvaluationResponseTimeoutLabel; private final JBIntSpinner myDebuggerEvaluationResponseTimeout; + private SegmentedButtonComponent myBackendSelector; + private JPanel myBackendPanel; + private JLabel myBackendDescription; private enum PyQtBackend { AUTO(PyBundle.messagePointer("python.debugger.qt.backend.auto")), @@ -91,6 +102,7 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con public PyDebuggerConfigurable(Project project) { myProject = project; + initBackendPanel(project); { warningIcon = new JBLabel(AllIcons.General.BalloonWarning); IdeTooltipManager.getInstance().setCustomTooltip( @@ -130,7 +142,7 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con // >>> IMPORTANT!! <<< // DO NOT EDIT OR ADD ANY CODE HERE! myMainPanel = new JPanel(); - myMainPanel.setLayout(new GridLayoutManager(4, 4, new Insets(0, 0, 0, 0), -1, -1)); + myMainPanel.setLayout(new GridLayoutManager(6, 4, new Insets(0, 0, 0, 0), -1, -1)); final JPanel panel1 = new JPanel(); panel1.setLayout(new GridLayoutManager(7, 6, new Insets(0, 0, 0, 0), -1, -1)); myMainPanel.add(panel1, new GridConstraints(0, 0, 1, 4, GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_BOTH, @@ -215,7 +227,10 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con GridConstraints.SIZEPOLICY_CAN_GROW, GridConstraints.SIZEPOLICY_FIXED, null, null, null, 0, false)); final Spacer spacer2 = new Spacer(); - myMainPanel.add(spacer2, new GridConstraints(3, 1, 1, 3, GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_VERTICAL, 1, + myMainPanel.add(myBackendDescription, new GridConstraints(4, 0, 1, 4, GridConstraints.ANCHOR_WEST, GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, null, null, null, 0, false)); + myMainPanel.add(spacer2, new GridConstraints(5, 1, 1, 3, GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_VERTICAL, 1, GridConstraints.SIZEPOLICY_WANT_GROW, null, null, null, 0, false)); myAttachFilterLabel = new JBLabel(); this.$$$loadLabelText$$$(myAttachFilterLabel, this.$$$getMessageFromBundle$$$("messages/PyBundle", @@ -247,6 +262,9 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con new GridConstraints(2, 2, 1, 1, GridConstraints.ANCHOR_WEST, GridConstraints.FILL_NONE, GridConstraints.SIZEPOLICY_CAN_GROW, GridConstraints.SIZEPOLICY_FIXED, null, null, null, 0, false)); + myMainPanel.add(myBackendPanel, new GridConstraints(3, 0, 1, 4, GridConstraints.ANCHOR_WEST, GridConstraints.FILL_NONE, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, null, null, null, 0, false)); myAttachFilterLabel.setLabelFor(myAttachProcessFilter); myDebuggerEvaluationResponseTimeoutLabel.setLabelFor(myAttachProcessFilter); } @@ -267,9 +285,50 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con } }); + myBackendSelector.addChangeListener(e -> { + PyDebuggerBackend selected = myBackendSelector.getSelectedItem(); + if (selected != null) { + myBackendDescription.setText(getBackendDescription(selected)); + } + }); + myAttachFilterLabel.setText(PyBundle.message("debugger.attach.to.process.filter.names")); } + private void applyBackend() { + PyDebuggerBackend newBackend = myBackendSelector.getSelectedItem() != null + ? myBackendSelector.getSelectedItem() : PyDebuggerBackend.PYDEVD; + PyDebuggerOptionsProvider.switchBackendWithRestart(myProject, newBackend); + } + + private void initBackendPanel(@NotNull Project project) { + var projectSdk = ProjectRootManager.getInstance(project).getProjectSdk(); + boolean debugpyDisabled = projectSdk != null && + PythonRuntimeService.getInstance().getLanguageLevelForSdk(projectSdk) + .isOlderThan(LanguageLevel.PYTHON39); + myBackendSelector = new SegmentedButtonComponent<>(backend -> { + @NlsSafe String name = backend == PyDebuggerBackend.DEBUGPY ? "debugpy" : "pydevd"; + boolean enabled = backend != PyDebuggerBackend.DEBUGPY || !debugpyDisabled; + String tooltip = backend == PyDebuggerBackend.DEBUGPY && debugpyDisabled + ? PyBundle.message("debugger.backend.debugpy.disabled.tooltip") : null; + return SegmentedButton.Companion.createPresentation(name, tooltip, null, enabled); + }); + myBackendSelector.setItems(PyDebuggerBackend.getEntries()); + myBackendSelector.setSpacing(new IntelliJSpacingConfiguration()); + myBackendPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + myBackendPanel.add(new JBLabel(PyBundle.message("debugger.backend.switcher.label") + " ")); + myBackendPanel.add(myBackendSelector); + //noinspection deprecation - no Java-friendly replacement (deprecated in favor of Kotlin UI DSL) + myBackendDescription = ComponentPanelBuilder.createCommentComponent( + getBackendDescription(PyDebuggerOptionsProvider.getInstance(project).getSelectedBackend()), true); + } + + private static @Nls String getBackendDescription(@NotNull PyDebuggerBackend backend) { + return backend == PyDebuggerBackend.PYDEVD + ? PyBundle.message("debugger.backend.pydevd.description") + : PyBundle.message("debugger.backend.debugpy.description"); + } + private static Method $$$cachedGetBundleMethod$$$ = null; /** @noinspection ALL */ @@ -365,7 +424,8 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con @Override public boolean isModified() { PyDebuggerOptionsProvider settings = PyDebuggerOptionsProvider.getInstance(myProject); - return myAttachToSubprocess.isSelected() != settings.isAttachToSubprocess() || + return myBackendSelector.getSelectedItem() != settings.getSelectedBackend() || + myAttachToSubprocess.isSelected() != settings.isAttachToSubprocess() || mySaveSignatures.isSelected() != settings.isSaveCallSignatures() || mySupportGevent.isSelected() != settings.isSupportGeventDebugging() || myDropIntoDebuggerOnFailedTests.isSelected() != settings.isDropIntoDebuggerOnFailedTest() || @@ -394,9 +454,10 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con settings.setRunDebuggerInServerMode(myRunDebuggerInServerMode.isSelected()); settings.setDebuggerPort(myDebuggerPort.getNumber()); - settings.setAttachProcessFilter(myAttachProcessFilter.getText()); settings.setEvaluationResponseTimeout(myDebuggerEvaluationResponseTimeout.getNumber()); + + applyBackend(); } @Override @@ -412,5 +473,6 @@ public final class PyDebuggerConfigurable implements SearchableConfigurable, Con myRunDebuggerInServerMode.setSelected(settings.isRunDebuggerInServerMode()); myAttachProcessFilter.setText(settings.getAttachProcessFilter()); myDebuggerEvaluationResponseTimeout.setNumber(settings.getEvaluationResponseTimeout()); + myBackendSelector.setSelectedItem(PyDebuggerOptionsProvider.getInstance(myProject).getSelectedBackend()); } } diff --git a/python/src/com/jetbrains/python/debugger/PyDebuggerOptionsProvider.java b/python/src/com/jetbrains/python/debugger/PyDebuggerOptionsProvider.java index cd37eec46382..7c0c2d2ec7b3 100644 --- a/python/src/com/jetbrains/python/debugger/PyDebuggerOptionsProvider.java +++ b/python/src/com/jetbrains/python/debugger/PyDebuggerOptionsProvider.java @@ -1,24 +1,35 @@ // Copyright 2000-2020 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.debugger; +import com.intellij.execution.ProgramRunnerUtil; +import com.intellij.execution.RunnerAndConfigurationSettings; +import com.intellij.execution.executors.DefaultDebugExecutor; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.components.PersistentStateComponent; import com.intellij.openapi.components.Service; import com.intellij.openapi.components.State; import com.intellij.openapi.components.Storage; import com.intellij.openapi.components.StoragePathMacros; import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; import com.intellij.openapi.util.text.StringUtil; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.XDebugSessionListener; +import com.intellij.xdebugger.XDebuggerManager; import com.jetbrains.python.PyBundle; +import com.jetbrains.python.run.AbstractPythonRunConfiguration; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; +import com.intellij.util.containers.ContainerUtil; + +import java.util.List; + @Service(Service.Level.PROJECT) @State( name = "PyDebuggerOptionsProvider", - storages = { - @Storage(StoragePathMacros.WORKSPACE_FILE) - } + storages = @Storage(StoragePathMacros.WORKSPACE_FILE) ) @ApiStatus.Internal public final class PyDebuggerOptionsProvider implements PersistentStateComponent { @@ -49,6 +60,7 @@ public final class PyDebuggerOptionsProvider implements PersistentStateComponent public int myDebuggerPort = 29781; public @NonNls String myAttachProcessFilter = "python"; public int myEvaluationResponseTimeout = 60_000; + public @NonNls String myDebuggerBackend = PyDebuggerBackend.PYDEVD.name(); } @@ -134,5 +146,76 @@ public final class PyDebuggerOptionsProvider implements PersistentStateComponent public void setEvaluationResponseTimeout(int timeout) { myState.myEvaluationResponseTimeout = timeout; } + + public @NotNull PyDebuggerBackend getSelectedBackend() { + try { + return PyDebuggerBackend.valueOf(myState.myDebuggerBackend); + } + catch (IllegalArgumentException e) { + return PyDebuggerBackend.PYDEVD; + } + } + + public void setSelectedBackend(@NotNull PyDebuggerBackend backend) { + myState.myDebuggerBackend = backend.name(); + } + + public static boolean hasActivePythonSessions(@NotNull Project project) { + return ContainerUtil.exists( + XDebuggerManager.getInstance(project).getDebugSessions(), + session -> session.getRunProfile() instanceof AbstractPythonRunConfiguration + ); + } + + /** + * Switches to {@code newBackend}, prompting the user to confirm a session restart when sessions are active. + * + * @return {@code true} if the backend was changed (possibly with a restart), {@code false} if the user cancelled. + */ + public static boolean switchBackendWithRestart(@NotNull Project project, @NotNull PyDebuggerBackend newBackend) { + PyDebuggerOptionsProvider options = getInstance(project); + if (options.getSelectedBackend() == newBackend) return true; + boolean hasSessions = hasActivePythonSessions(project); + if (hasSessions) { + int result = Messages.showDialog( + project, + PyBundle.message("debugger.backend.switch.during.session.message"), + PyBundle.message("debugger.backend.switch.during.session.title"), + new String[]{PyBundle.message("debugger.backend.switch.and.restart"), Messages.getCancelButton()}, + 0, + Messages.getWarningIcon() + ); + if (result != 0) return false; + } + options.setSelectedBackend(newBackend); + if (hasSessions) { + restartAllPythonSessions(project); + } + return true; + } + + public static void restartAllPythonSessions(@NotNull Project project) { + List sessions = ContainerUtil.filter( + XDebuggerManager.getInstance(project).getDebugSessions(), + session -> session.getRunProfile() instanceof AbstractPythonRunConfiguration + ); + + for (XDebugSession session : sessions) { + RunnerAndConfigurationSettings settings = session.getExecutionEnvironment() != null + ? session.getExecutionEnvironment().getRunnerAndConfigurationSettings() : null; + if (settings != null) { + session.addSessionListener(new XDebugSessionListener() { + @Override + public void sessionStopped() { + // sessionStopped fires on a non-EDT thread (stopAsync callback); dispatch to EDT for executeConfiguration + ApplicationManager.getApplication().invokeLater( + () -> ProgramRunnerUtil.executeConfiguration(settings, DefaultDebugExecutor.getDebugExecutorInstance()) + ); + } + }); + } + session.stop(); + } + } }