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 <Egor.Eliseev@jetbrains.com>
(cherry picked from commit 965677f0668b3493290f9f3bb59ed6e7da2f2f38)

GitOrigin-RevId: 7f0429d17e817a9e0c043b25a69d244a064320c6
This commit is contained in:
Egor Eliseev
2026-03-18 12:38:25 +00:00
committed by intellij-monorepo-bot
parent 4a12a549cc
commit 4abe6f0b4d
5 changed files with 182 additions and 10 deletions
@@ -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
@@ -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 }
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.intellij.com/uidesigner/form/" version="1" bind-to-class="com.jetbrains.python.debugger.PyDebuggerConfigurable">
<grid id="27dc6" binding="myMainPanel" layout-manager="GridLayoutManager" row-count="4" column-count="4" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
<grid id="27dc6" binding="myMainPanel" layout-manager="GridLayoutManager" row-count="6" column-count="4" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
<margin top="0" left="0" bottom="0" right="0"/>
<constraints>
<xy x="20" y="20" width="735" height="333"/>
<xy x="20" y="20" width="735" height="358"/>
</constraints>
<properties/>
<border type="none"/>
@@ -133,9 +133,15 @@
</grid>
</children>
</grid>
<component id="b3d2e" class="javax.swing.JLabel" binding="myBackendDescription" custom-create="true">
<constraints>
<grid row="4" column="0" row-span="1" col-span="4" vsize-policy="0" hsize-policy="3" anchor="8" fill="1" indent="0" use-parent-layout="false"/>
</constraints>
<properties/>
</component>
<vspacer id="5915a">
<constraints>
<grid row="3" column="1" row-span="1" col-span="3" vsize-policy="6" hsize-policy="1" anchor="0" fill="2" indent="0" use-parent-layout="false"/>
<grid row="5" column="1" row-span="1" col-span="3" vsize-policy="6" hsize-policy="1" anchor="0" fill="2" indent="0" use-parent-layout="false"/>
</constraints>
</vspacer>
<component id="ffb0f" class="com.intellij.ui.components.JBLabel" binding="myAttachFilterLabel">
@@ -187,6 +193,12 @@
<min value="0"/>
</properties>
</component>
<component id="a1b2c" class="javax.swing.JPanel" binding="myBackendPanel" custom-create="true">
<constraints>
<grid row="3" column="0" row-span="1" col-span="4" vsize-policy="0" hsize-policy="3" anchor="8" fill="0" indent="0" use-parent-layout="false"/>
</constraints>
<properties/>
</component>
</children>
</grid>
</form>
@@ -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<PyDebuggerBackend> 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());
}
}
@@ -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<PyDebuggerOptionsProvider.State> {
@@ -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<XDebugSession> 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();
}
}
}