diff --git a/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart b/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart index b8207bc08d4..ad9cc6c5eaa 100644 --- a/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart +++ b/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart @@ -125,6 +125,36 @@ class BreakpointManager with DisposerMixin { Future 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 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 _addBreakpointWithState( + String scriptId, + int line, { + required bool enabled, + }) async { + final breakpoint = await addBreakpoint(scriptId, line); + if (!enabled) { + await setBreakpointEnabled(breakpoint, enabled: false); + } + } + Future toggleBreakpoint(ScriptRef script, int line) async { final selectedIsolate = serviceConnection.serviceManager.isolateManager.selectedIsolate.value; @@ -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; } @@ -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, + ); } } } @@ -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) @@ -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; diff --git a/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart b/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart index 7dab8a280d3..7d127458a55 100644 --- a/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart +++ b/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart @@ -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; @@ -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(); + // 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, @@ -37,6 +51,7 @@ class Breakpoints extends StatelessWidget { return _Breakpoint( breakpoint: breakpoints[index], selectedBreakpoint: selectedBreakpoint, + showEnabledCheckbox: canDisableBreakpoints, ); }, ); @@ -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); @@ -68,6 +87,21 @@ 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, @@ -75,7 +109,7 @@ class _Breakpoint extends StatelessWidget { ), child: createCircleWidget( breakpointRadius, - theme.colorScheme.primary, + breakpointMarkerColor(theme, enabled: breakpoint.enabled), ), ), Flexible( diff --git a/packages/devtools_app/lib/src/screens/debugger/codeview.dart b/packages/devtools_app/lib/src/screens/debugger/codeview.dart index 53c83c85293..9d5b8eba3c5 100644 --- a/packages/devtools_app/lib/src/screens/debugger/codeview.dart +++ b/packages/devtools_app/lib/src/screens/debugger/codeview.dart @@ -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, @@ -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, @@ -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, @@ -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; @@ -965,7 +976,12 @@ class GutterItem extends StatelessWidget { child: Center( child: createAnimatedCircleWidget( isBreakpoint ? breakpointRadius : executableLineRadius, - isBreakpoint ? breakpointColor : subtleColor, + isBreakpoint + ? breakpointMarkerColor( + theme, + enabled: isBreakpointEnabled, + ) + : subtleColor, ), ), ), diff --git a/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart b/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart index f69e302bc01..8c224ffb1cb 100644 --- a/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart +++ b/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart @@ -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; diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 3abdfddc6d8..106f25d59e7 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -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 diff --git a/packages/devtools_app/test/screens/debugger/breakpoint_manager_test.dart b/packages/devtools_app/test/screens/debugger/breakpoint_manager_test.dart new file mode 100644 index 00000000000..54591eb9455 --- /dev/null +++ b/packages/devtools_app/test/screens/debugger/breakpoint_manager_test.dart @@ -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 => []); + 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 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); + }); +} diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart index 98e7f6fb3c3..afab83a5340 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart @@ -3,6 +3,7 @@ // 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/src/screens/debugger/breakpoints.dart'; import 'package:devtools_app/src/screens/debugger/debugger_model.dart'; import 'package:devtools_app/src/shared/diagnostics/primitives/source_location.dart'; import 'package:devtools_app_shared/ui.dart'; @@ -90,4 +91,85 @@ void main() { findsOneWidget, ); }); + + group('enabled checkbox', () { + final breakpointCheckbox = find.descendant( + of: find.byType(Breakpoints), + matching: find.byType(Checkbox), + ); + + testWidgetsWithWindowSize( + 'reflects that the breakpoint is enabled', + windowSize, + (WidgetTester tester) async { + await pumpDebuggerScreen(tester, debuggerController); + + expect(tester.widget(breakpointCheckbox).value, isTrue); + }, + ); + + testWidgetsWithWindowSize( + 'reflects that the breakpoint is disabled', + windowSize, + (WidgetTester tester) async { + final disabledBreakpoint = Breakpoint( + breakpointNumber: 2, + id: 'bp2', + resolved: false, + location: UnresolvedSourceLocation( + scriptUri: 'package:test/script.dart', + line: 20, + ), + enabled: false, + ); + when(mockBreakpointManager.breakpointsWithLocation).thenReturn( + ValueNotifier([ + BreakpointAndSourcePosition.create( + disabledBreakpoint, + const SourcePosition(line: 20, column: 1), + ), + ]), + ); + addTearDown( + () => when( + mockBreakpointManager.breakpointsWithLocation, + ).thenReturn(ValueNotifier(breakpointsWithLocation)), + ); + + await pumpDebuggerScreen(tester, debuggerController); + + expect(tester.widget(breakpointCheckbox).value, isFalse); + }, + ); + + testWidgetsWithWindowSize( + 'disables the breakpoint when unchecked', + windowSize, + (WidgetTester tester) async { + await pumpDebuggerScreen(tester, debuggerController); + + await tester.tap(breakpointCheckbox); + await tester.pump(); + + verify( + mockBreakpointManager.setBreakpointEnabled( + breakpoints.first, + enabled: false, + ), + ).called(1); + }, + ); + + testWidgetsWithWindowSize('is hidden for Dart web apps', windowSize, ( + WidgetTester tester, + ) async { + final connectedApp = fakeServiceConnection.serviceManager.connectedApp!; + mockConnectedApp(connectedApp, isWebApp: true); + addTearDown(() => mockConnectedApp(connectedApp)); + + await pumpDebuggerScreen(tester, debuggerController); + + expect(breakpointCheckbox, findsNothing); + }); + }); } diff --git a/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart b/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart index c687bb6d4cb..b217eef3814 100644 --- a/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart +++ b/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart @@ -85,6 +85,7 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { final ClassList? _classList; late final Map? _reverseResolvedUriMap; final _gcEventStream = StreamController.broadcast(); + final _debugEventStream = StreamController.broadcast(); final _flags = >{ 'flags': [ @@ -539,7 +540,11 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { Stream get onExtensionEventWithHistorySafe => const Stream.empty(); @override - Stream get onDebugEvent => const Stream.empty(); + Stream get onDebugEvent => _debugEventStream.stream; + + void emitDebugEvent(Event event) { + _debugEventStream.sink.add(event); + } @override Stream get onTimelineEvent => const Stream.empty();