Skip to content

Commit a7fb0c4

Browse files
Add support for searching within the log details view (#9712)
1 parent 0c1d983 commit a7fb0c4

31 files changed

Lines changed: 540 additions & 237 deletions

packages/devtools_app/lib/devtools_app.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export 'src/screens/inspector/inspector_screen_body.dart';
2929
export 'src/screens/inspector/inspector_tree_controller.dart';
3030
export 'src/screens/inspector_shared/inspector_screen.dart';
3131
export 'src/screens/inspector_shared/inspector_screen_controller.dart';
32+
export 'src/screens/logging/log_details_controller.dart';
3233
export 'src/screens/logging/logging_controller.dart';
3334
export 'src/screens/logging/logging_screen.dart';
3435
export 'src/screens/memory/framework/memory_controller.dart';

packages/devtools_app/lib/src/screens/debugger/codeview.dart

Lines changed: 9 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import '../../shared/ui/common_widgets.dart';
2626
import '../../shared/ui/history_viewport.dart';
2727
import '../../shared/ui/hover.dart';
2828
import '../../shared/ui/search.dart';
29+
import '../../shared/ui/search_highlighter.dart';
2930
import '../../shared/ui/utils.dart';
3031
import '../vm_developer/vm_service_private_extensions.dart';
3132
import 'breakpoints.dart';
@@ -1272,131 +1273,22 @@ class _HoverableLine extends StatelessWidget {
12721273
return null;
12731274
}
12741275

1275-
List<InlineSpan> _contentsWithMatch(
1276-
List<InlineSpan> startingContents,
1277-
SourceToken match,
1278-
Color matchColor, {
1279-
required BuildContext context,
1280-
}) {
1281-
final contentsWithMatch = <InlineSpan>[];
1282-
var startColumnForSpan = 0;
1283-
for (final span in startingContents) {
1284-
final spanText = span.toPlainText();
1285-
final startColumnForMatch = match.position.column!;
1286-
if (startColumnForSpan <= startColumnForMatch &&
1287-
startColumnForSpan + spanText.length > startColumnForMatch) {
1288-
// The active search is part of this [span].
1289-
final matchStartInSpan = startColumnForMatch - startColumnForSpan;
1290-
final matchEndInSpan = matchStartInSpan + match.length;
1291-
1292-
// Add the part of [span] that occurs before the search match.
1293-
contentsWithMatch.add(
1294-
TextSpan(
1295-
text: spanText.substring(0, matchStartInSpan),
1296-
style: span.style,
1297-
),
1298-
);
1299-
1300-
final matchStyle = (span.style ?? DefaultTextStyle.of(context).style)
1301-
.copyWith(color: Colors.black, backgroundColor: matchColor);
1302-
1303-
if (matchEndInSpan <= spanText.length) {
1304-
final matchText = spanText.substring(
1305-
matchStartInSpan,
1306-
matchEndInSpan,
1307-
);
1308-
final trailingText = spanText.substring(matchEndInSpan);
1309-
// Add the match and any part of [span] that occurs after the search
1310-
// match.
1311-
contentsWithMatch.addAll([
1312-
TextSpan(text: matchText, style: matchStyle),
1313-
if (trailingText.isNotEmpty)
1314-
TextSpan(
1315-
text: spanText.substring(matchEndInSpan),
1316-
style: span.style,
1317-
),
1318-
]);
1319-
} else {
1320-
// In this case, the active search match exists across multiple spans,
1321-
// so we need to add the part of the match that is in this [span] and
1322-
// continue looking for the remaining part of the match in the spans
1323-
// to follow.
1324-
contentsWithMatch.add(
1325-
TextSpan(
1326-
text: spanText.substring(matchStartInSpan),
1327-
style: matchStyle,
1328-
),
1329-
);
1330-
final remainingMatchLength =
1331-
match.length - (spanText.length - matchStartInSpan);
1332-
match = SourceToken(
1333-
position: SourcePosition(
1334-
line: match.position.line,
1335-
column: startColumnForMatch + match.length - remainingMatchLength,
1336-
),
1337-
length: remainingMatchLength,
1338-
);
1339-
}
1340-
} else {
1341-
contentsWithMatch.add(span);
1342-
}
1343-
startColumnForSpan += spanText.length;
1344-
}
1345-
return contentsWithMatch;
1346-
}
1347-
13481276
TextSpan searchAwareLineContents(BuildContext context) {
13491277
// If syntax highlighting is disabled for the script, then
13501278
// `lineContents` is simply a `TextSpan` with no children.
13511279
final lineContentsSpans = lineContents.children ?? [lineContents];
1352-
final activeSearchAwareContents = _activeSearchAwareLineContents(
1353-
lineContentsSpans,
1354-
context: context,
1355-
);
1356-
final allSearchAwareContents = _searchMatchAwareLineContents(
1357-
activeSearchAwareContents!,
1358-
context: context,
1359-
);
1280+
final theme = Theme.of(context);
1281+
13601282
return TextSpan(
1361-
children: allSearchAwareContents,
1283+
children: SearchHighlighter.highlightSpans(
1284+
lineContentsSpans.cast<TextSpan>(),
1285+
matches: searchMatches?.map((m) => m.range).toList() ?? [],
1286+
activeMatch: activeSearchMatch?.range,
1287+
style: theme.regularTextStyle,
1288+
),
13621289
style: lineContents.style,
13631290
);
13641291
}
1365-
1366-
List<InlineSpan>? _activeSearchAwareLineContents(
1367-
List<InlineSpan> startingContents, {
1368-
required BuildContext context,
1369-
}) {
1370-
final match = activeSearchMatch;
1371-
if (match == null) return startingContents;
1372-
return _contentsWithMatch(
1373-
startingContents,
1374-
match,
1375-
activeSearchMatchColor,
1376-
context: context,
1377-
);
1378-
}
1379-
1380-
List<InlineSpan> _searchMatchAwareLineContents(
1381-
List<InlineSpan> startingContents, {
1382-
required BuildContext context,
1383-
}) {
1384-
final matches = searchMatches;
1385-
if (matches == null || matches.isEmpty) return startingContents;
1386-
final searchMatchesToFind = List<SourceToken>.of(matches)
1387-
..remove(activeSearchMatch);
1388-
1389-
var contentsWithMatch = startingContents;
1390-
for (final match in searchMatchesToFind) {
1391-
contentsWithMatch = _contentsWithMatch(
1392-
contentsWithMatch,
1393-
match,
1394-
searchMatchColor,
1395-
context: context,
1396-
);
1397-
}
1398-
return contentsWithMatch;
1399-
}
14001292
}
14011293

14021294
class ScriptPopupMenu extends StatelessWidget {

packages/devtools_app/lib/src/screens/debugger/debugger_model.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class SourceToken with SearchableDataMixin {
5252

5353
final int length;
5454

55+
Range get range => Range(position.column!, position.column! + length);
56+
5557
@override
5658
String toString() {
5759
return '$position-${position.column! + length}';

packages/devtools_app/lib/src/screens/logging/_log_details.dart

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ import 'package:flutter/material.dart';
1111
import '../../shared/globals.dart';
1212
import '../../shared/preferences/preferences.dart';
1313
import '../../shared/ui/common_widgets.dart';
14+
import '../../shared/ui/search.dart';
15+
import '../../shared/ui/search_highlighter.dart';
16+
import 'log_details_controller.dart';
1417
import 'logging_controller.dart';
1518

1619
class LogDetails extends StatefulWidget {
17-
const LogDetails({super.key, required this.log});
20+
const LogDetails({super.key, required this.log, required this.controller});
1821

1922
final LogData? log;
23+
final LogDetailsController controller;
2024

2125
@override
2226
State<LogDetails> createState() => _LogDetailsState();
@@ -45,6 +49,10 @@ class _LogDetailsState extends State<LogDetails>
4549
if (widget.log != oldWidget.log) {
4650
unawaited(_computeLogDetails());
4751
}
52+
if (widget.controller != oldWidget.controller) {
53+
cancelListeners();
54+
addAutoDisposeListener(preferences.logging.detailsFormat);
55+
}
4856
}
4957

5058
Future<void> _computeLogDetails() async {
@@ -81,6 +89,7 @@ class _LogDetailsState extends State<LogDetails>
8189
header: _LogDetailsHeader(
8290
log: log,
8391
format: preferences.logging.detailsFormat.value,
92+
controller: widget.controller,
8493
),
8594
child: Scrollbar(
8695
controller: scrollController,
@@ -93,9 +102,9 @@ class _LogDetailsState extends State<LogDetails>
93102
? Padding(
94103
padding: const EdgeInsets.all(denseSpacing),
95104
child: SelectionArea(
96-
child: Text(
97-
log?.prettyPrinted() ?? '',
98-
textAlign: TextAlign.left,
105+
child: _SearchableLogDetailsText(
106+
text: log?.prettyPrinted() ?? '',
107+
controller: widget.controller,
99108
),
100109
),
101110
)
@@ -107,10 +116,15 @@ class _LogDetailsState extends State<LogDetails>
107116
}
108117

109118
class _LogDetailsHeader extends StatelessWidget {
110-
const _LogDetailsHeader({required this.log, required this.format});
119+
const _LogDetailsHeader({
120+
required this.log,
121+
required this.format,
122+
required this.controller,
123+
});
111124

112125
final LogData? log;
113126
final LoggingDetailsFormat format;
127+
final LogDetailsController controller;
114128

115129
@override
116130
Widget build(BuildContext context) {
@@ -122,7 +136,13 @@ class _LogDetailsHeader extends StatelessWidget {
122136
title: const Text('Details'),
123137
includeTopBorder: false,
124138
roundedTopBorder: false,
139+
tall: true,
125140
actions: [
141+
// Only supporting search for the text format now since supporting this
142+
// for the expandable JSON viewer would require a more complicated
143+
// refactor of that shared component.
144+
if (format == LoggingDetailsFormat.text)
145+
_LogDetailsSearchField(controller: controller, log: log),
126146
LogDetailsFormatButton(format: format),
127147
const SizedBox(width: densePadding),
128148
CopyToClipboardControl(
@@ -134,6 +154,108 @@ class _LogDetailsHeader extends StatelessWidget {
134154
}
135155
}
136156

157+
/// An animated search field for the log details view that toggles between an icon
158+
/// and a full [SearchField].
159+
class _LogDetailsSearchField extends StatefulWidget {
160+
const _LogDetailsSearchField({required this.controller, required this.log});
161+
162+
final LogDetailsController controller;
163+
final LogData? log;
164+
165+
@override
166+
State<_LogDetailsSearchField> createState() => _LogDetailsSearchFieldState();
167+
}
168+
169+
class _LogDetailsSearchFieldState extends State<_LogDetailsSearchField>
170+
with AutoDisposeMixin {
171+
late bool _isExpanded;
172+
173+
@override
174+
void initState() {
175+
super.initState();
176+
_isExpanded = widget.controller.search.isNotEmpty;
177+
addAutoDisposeListener(widget.controller.searchFieldFocusNode, () {
178+
final hasFocus =
179+
widget.controller.searchFieldFocusNode?.hasFocus ?? false;
180+
if (hasFocus != _isExpanded) {
181+
setState(() {
182+
_isExpanded = hasFocus;
183+
});
184+
}
185+
});
186+
}
187+
188+
@override
189+
Widget build(BuildContext context) {
190+
return AnimatedContainer(
191+
duration: defaultDuration,
192+
curve: defaultCurve,
193+
width: _isExpanded ? mediumSearchFieldWidth : defaultButtonHeight,
194+
child: OverflowBox(
195+
minWidth: 0.0,
196+
maxWidth: mediumSearchFieldWidth,
197+
child: _isExpanded
198+
? Padding(
199+
padding: const EdgeInsets.symmetric(horizontal: densePadding),
200+
child: SearchField<LogDetailsController>(
201+
searchController: widget.controller,
202+
searchFieldEnabled:
203+
widget.log != null && widget.log!.details != null,
204+
shouldRequestFocus: true,
205+
searchFieldWidth: mediumSearchFieldWidth,
206+
),
207+
)
208+
: ToolbarAction(
209+
icon: Icons.search,
210+
tooltip: 'Search details',
211+
size: defaultIconSize,
212+
onPressed: () {
213+
setState(() {
214+
_isExpanded = true;
215+
});
216+
widget.controller.searchFieldFocusNode?.requestFocus();
217+
},
218+
),
219+
),
220+
);
221+
}
222+
}
223+
224+
/// A text widget for the log details view that highlights search matches.
225+
class _SearchableLogDetailsText extends StatelessWidget {
226+
const _SearchableLogDetailsText({
227+
required this.text,
228+
required this.controller,
229+
});
230+
231+
final String text;
232+
final LogDetailsController controller;
233+
234+
@override
235+
Widget build(BuildContext context) {
236+
return MultiValueListenableBuilder(
237+
listenables: [controller.searchMatches, controller.activeSearchMatch],
238+
builder: (context, values, _) {
239+
final theme = Theme.of(context);
240+
241+
final matches = (values[0] as List<LogDetailsMatch>)
242+
.map((m) => m.range)
243+
.toList();
244+
final activeMatch = (values[1] as LogDetailsMatch?)?.range;
245+
246+
return Text.rich(
247+
SearchHighlighter.highlight(
248+
text,
249+
matches,
250+
activeMatch: activeMatch,
251+
style: theme.regularTextStyle,
252+
),
253+
);
254+
},
255+
);
256+
}
257+
}
258+
137259
@visibleForTesting
138260
class LogDetailsFormatButton extends StatelessWidget {
139261
const LogDetailsFormatButton({super.key, required this.format});

packages/devtools_app/lib/src/screens/logging/_logs_table.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class LogsTable extends StatelessWidget {
4848
defaultSortDirection: SortDirection.ascending,
4949
secondarySortColumn: messageColumn,
5050
rowHeight: _logRowHeight,
51+
tallHeaders: true,
5152
);
5253
}
5354
}

0 commit comments

Comments
 (0)