Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,36 @@ class BreakpointManager with DisposerMixin {
Future<void> removeBreakpoint(Breakpoint breakpoint) =>
_service.removeBreakpoint(_isolateRefId, breakpoint.id!);

/// Enables or disables [breakpoint] without removing it.
///
/// A disabled breakpoint stays in [breakpointsWithLocation] but does not
/// pause the isolate. The VM reports the new state with a
/// [EventKind.kBreakpointUpdated] event, which updates
/// [breakpointsWithLocation].
///
/// This is not supported for Dart web apps, because DWDS does not implement
/// the `setBreakpointState` RPC.
Future<void> setBreakpointEnabled(
Breakpoint breakpoint, {
required bool enabled,
}) => _service.setBreakpointState(_isolateRefId, breakpoint.id!, enabled);

/// Adds a breakpoint at [line] of the script with [scriptId], then disables
/// it if [enabled] is false.
///
/// Used when re-establishing breakpoints, so that a breakpoint the user
/// disabled stays disabled.
Future<void> _addBreakpointWithState(
String scriptId,
int line, {
required bool enabled,
}) async {
final breakpoint = await addBreakpoint(scriptId, line);
if (!enabled) {
await setBreakpointEnabled(breakpoint, enabled: false);
}
}

Future<void> toggleBreakpoint(ScriptRef script, int line) async {
final selectedIsolate =
serviceConnection.serviceManager.isolateManager.selectedIsolate.value;
Expand Down Expand Up @@ -197,7 +227,11 @@ class BreakpointManager with DisposerMixin {
for (final scriptRef in addedScripts) ...[
for (final bp in breakpointsToRemove)
if (scriptRef.uri == bp.scriptUri)
addBreakpoint(scriptRef.id!, bp.line!),
_addBreakpointWithState(
scriptRef.id!,
bp.line!,
enabled: bp.enabled,
),
],
].wait;
}
Expand Down Expand Up @@ -251,7 +285,11 @@ class BreakpointManager with DisposerMixin {

final scriptId = newScriptRef?.id;
if (scriptId != null && breakpointLine != null) {
await addBreakpoint(scriptId, breakpointLine);
await _addBreakpointWithState(
scriptId,
breakpointLine,
enabled: breakpoint.enabled,
);
}
}
}
Expand Down Expand Up @@ -372,6 +410,7 @@ class BreakpointManager with DisposerMixin {

break;
case EventKind.kBreakpointResolved:
case EventKind.kBreakpointUpdated:
final breakpoint = event.breakpoint!;
_breakpoints.value = [
for (final b in _breakpoints.value)
Expand All @@ -382,13 +421,14 @@ class BreakpointManager with DisposerMixin {
await breakpointManager.createBreakpointWithLocation(breakpoint).then((
bp,
) {
final list = _breakpointsWithLocation.value;
// Remove the bp with the older, unresolved information from the list.
list.removeWhere((breakpoint) => breakpoint.id == bp.id);
// Add the bp with the newer, resolved information.
list.add(bp);
list.sort();
_breakpointsWithLocation.value = list;
// Replace the bp that has the older information (unresolved, or a
// previous enabled state) with the newer one. Build a new list so
// that the notifier sees a different value and notifies listeners.
_breakpointsWithLocation.value = [
for (final b in _breakpointsWithLocation.value)
if (b.id != bp.id) b,
bp,
]..sort();
});

break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.

import 'dart:async';

import 'package:devtools_app_shared/ui.dart';
import 'package:material_ui/material_ui.dart' hide Badge;

Expand All @@ -15,12 +17,24 @@ import 'debugger_model.dart';
const executableLineRadius = 1.5;
const breakpointRadius = 6.0;

/// The color of the dot that marks a breakpoint.
///
/// Disabled breakpoints use a muted color, so that they are visibly different
/// from enabled ones in both the breakpoints list and the gutter.
Color breakpointMarkerColor(ThemeData theme, {required bool enabled}) =>
enabled ? theme.colorScheme.primary : theme.unselectedWidgetColor;

class Breakpoints extends StatelessWidget {
const Breakpoints({super.key});

@override
Widget build(BuildContext context) {
final controller = screenControllers.lookup<DebuggerController>();
// DWDS does not support enabling or disabling breakpoints, so only offer
// the checkbox when the app is not a Dart web app.
final canDisableBreakpoints =
!(serviceConnection.serviceManager.connectedApp?.isDartWebAppNow ??
false);
return MultiValueListenableBuilder(
listenables: [
breakpointManager.breakpointsWithLocation,
Expand All @@ -37,6 +51,7 @@ class Breakpoints extends StatelessWidget {
return _Breakpoint(
breakpoint: breakpoints[index],
selectedBreakpoint: selectedBreakpoint,
showEnabledCheckbox: canDisableBreakpoints,
);
},
);
Expand All @@ -49,11 +64,15 @@ class _Breakpoint extends StatelessWidget {
const _Breakpoint({
required this.breakpoint,
required this.selectedBreakpoint,
required this.showEnabledCheckbox,
});

final BreakpointAndSourcePosition breakpoint;
final BreakpointAndSourcePosition? selectedBreakpoint;

/// Whether to show a checkbox that enables or disables [breakpoint].
final bool showEnabledCheckbox;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Expand All @@ -68,14 +87,29 @@ class _Breakpoint extends StatelessWidget {
padding: const EdgeInsets.all(borderPadding),
child: Row(
children: [
if (showEnabledCheckbox)
SizedBox(
width: defaultListItemHeight,
child: Checkbox(
value: breakpoint.enabled,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
onChanged: (enabled) => unawaited(
breakpointManager.setBreakpointEnabled(
breakpoint.breakpoint,
enabled: enabled!,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
left: borderPadding,
right: borderPadding * 2,
),
child: createCircleWidget(
breakpointRadius,
theme.colorScheme.primary,
breakpointMarkerColor(theme, enabled: breakpoint.enabled),
),
),
Flexible(
Expand Down
18 changes: 17 additions & 1 deletion packages/devtools_app/lib/src/screens/debugger/codeview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,9 @@ class Gutter extends StatelessWidget {
@override
Widget build(BuildContext context) {
final bpLineSet = Set.of(breakpoints.map((bp) => bp.line));
final enabledBpLineSet = Set.of(
breakpoints.where((bp) => bp.enabled).map((bp) => bp.line),
);
final theme = Theme.of(context);
final coverageLines = sourceReport.coverageHitLines.union(
sourceReport.coverageMissedLines,
Expand Down Expand Up @@ -885,6 +888,7 @@ class Gutter extends StatelessWidget {
lineNumber: lineNum,
onPressed: () => onPressed(lineNum),
isBreakpoint: bpLineSet.contains(lineNum),
isBreakpointEnabled: enabledBpLineSet.contains(lineNum),
isExecutable: executableLines.contains(lineNum),
isPausedHere: pausedFrame?.line == lineNum,
allowInteraction: allowInteraction,
Expand All @@ -901,6 +905,7 @@ class GutterItem extends StatelessWidget {
super.key,
required this.lineNumber,
required this.isBreakpoint,
this.isBreakpointEnabled = true,
required this.isExecutable,
required this.isPausedHere,
required this.onPressed,
Expand All @@ -912,6 +917,12 @@ class GutterItem extends StatelessWidget {

final bool isBreakpoint;

/// Whether the breakpoint on this line is enabled.
///
/// Only used when [isBreakpoint] is true. A disabled breakpoint is drawn in
/// a muted color.
final bool isBreakpointEnabled;

final bool isExecutable;

final bool allowInteraction;
Expand Down Expand Up @@ -965,7 +976,12 @@ class GutterItem extends StatelessWidget {
child: Center(
child: createAnimatedCircleWidget(
isBreakpoint ? breakpointRadius : executableLineRadius,
isBreakpoint ? breakpointColor : subtleColor,
isBreakpoint
? breakpointMarkerColor(
theme,
enabled: isBreakpointEnabled,
)
: subtleColor,
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ abstract class BreakpointAndSourcePosition

bool get resolved => breakpoint.resolved ?? false;

/// Whether this breakpoint pauses the isolate when it is hit.
///
/// A missing value is treated as enabled, since breakpoints are enabled when
/// they are added.
bool get enabled => breakpoint.enabled ?? true;

ScriptRef? get scriptRef;

String? get scriptUri;
Expand Down
6 changes: 5 additions & 1 deletion packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ TODO: Remove this section if there are not any updates.

## Debugger updates

TODO: Remove this section if there are not any updates.
* Added a checkbox to each breakpoint in the Breakpoints pane, so that
breakpoints can be disabled without removing them. Disabled breakpoints are
shown in a muted color and don't pause execution. This isn't yet available
for Dart web apps.
[#696](https://github.com/flutter/devtools/issues/696)

## Network profiler updates

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2026 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.

import 'package:devtools_app/devtools_app.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:devtools_test/devtools_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:vm_service/vm_service.dart';

void main() {
late FakeVmServiceWrapper fakeService;
late BreakpointManager manager;
late IsolateRef isolateRef;

setUp(() async {
final fakeServiceConnection = FakeServiceConnectionManager();
fakeService =
fakeServiceConnection.serviceManager.service! as FakeVmServiceWrapper;
mockConnectedApp(fakeServiceConnection.serviceManager.connectedApp!);
final scriptManager = MockScriptManager();
when(
scriptManager.retrieveAndSortScripts(any),
).thenAnswer((_) async => <ScriptRef>[]);
setGlobal(ServiceConnectionManager, fakeServiceConnection);
setGlobal(ScriptManager, scriptManager);

manager = BreakpointManager();
setGlobal(BreakpointManager, manager);
await manager.initialize();
isolateRef = fakeServiceConnection
.serviceManager
.isolateManager
.selectedIsolate
.value!;
});

tearDown(() {
manager
..cancelStreamSubscriptions()
..cancelListeners();
});

Breakpoint createBreakpoint({required bool enabled}) => Breakpoint(
breakpointNumber: 1,
id: 'bp1',
resolved: false,
location: UnresolvedSourceLocation(
scriptUri: 'package:test/script.dart',
line: 10,
),
enabled: enabled,
);

Future<void> emitBreakpointEvent(String kind, Breakpoint breakpoint) async {
fakeService.emitDebugEvent(
Event(kind: kind, isolate: isolateRef, breakpoint: breakpoint),
);
await pumpEventQueue();
}

test('updates the enabled state of a breakpoint when the VM reports it '
'was updated', () async {
await emitBreakpointEvent(
EventKind.kBreakpointAdded,
createBreakpoint(enabled: true),
);
expect(manager.breakpointsWithLocation.value.single.enabled, isTrue);

var notified = false;
manager.breakpointsWithLocation.addListener(() => notified = true);

await emitBreakpointEvent(
EventKind.kBreakpointUpdated,
createBreakpoint(enabled: false),
);

expect(notified, isTrue);
expect(manager.breakpointsWithLocation.value.single.enabled, isFalse);
});
}
Loading
Loading