Skip to content
Open
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
163 changes: 158 additions & 5 deletions packages/devtools_app/lib/src/screens/logging/_log_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import 'package:flutter/material.dart';
import '../../shared/globals.dart';
import '../../shared/preferences/preferences.dart';
import '../../shared/ui/common_widgets.dart';
import '../../shared/ui/search.dart';
import 'log_details_controller.dart';
import 'logging_controller.dart';

class LogDetails extends StatefulWidget {
const LogDetails({super.key, required this.log});
const LogDetails({super.key, required this.log, required this.controller});

final LogData? log;
final LogDetailsController controller;

@override
State<LogDetails> createState() => _LogDetailsState();
Expand Down Expand Up @@ -45,6 +48,10 @@ class _LogDetailsState extends State<LogDetails>
if (widget.log != oldWidget.log) {
unawaited(_computeLogDetails());
}
if (widget.controller != oldWidget.controller) {
cancelListeners();
addAutoDisposeListener(preferences.logging.detailsFormat);
}
}

Future<void> _computeLogDetails() async {
Expand Down Expand Up @@ -81,6 +88,7 @@ class _LogDetailsState extends State<LogDetails>
header: _LogDetailsHeader(
log: log,
format: preferences.logging.detailsFormat.value,
controller: widget.controller,
),
child: Scrollbar(
controller: scrollController,
Expand All @@ -93,9 +101,9 @@ class _LogDetailsState extends State<LogDetails>
? Padding(
padding: const EdgeInsets.all(denseSpacing),
child: SelectionArea(
child: Text(
log?.prettyPrinted() ?? '',
textAlign: TextAlign.left,
child: _SearchableLogDetailsText(
text: log?.prettyPrinted() ?? '',
controller: widget.controller,
),
),
)
Expand All @@ -107,10 +115,15 @@ class _LogDetailsState extends State<LogDetails>
}

class _LogDetailsHeader extends StatelessWidget {
const _LogDetailsHeader({required this.log, required this.format});
const _LogDetailsHeader({
required this.log,
required this.format,
required this.controller,
});

final LogData? log;
final LoggingDetailsFormat format;
final LogDetailsController controller;

@override
Widget build(BuildContext context) {
Expand All @@ -122,7 +135,13 @@ class _LogDetailsHeader extends StatelessWidget {
title: const Text('Details'),
includeTopBorder: false,
roundedTopBorder: false,
tall: true,
actions: [
// Only supporting search for the text format now since supporting this
// for the expandable JSON viewer would require a more complicated
// refactor of that shared component.
if (format == LoggingDetailsFormat.text)
_LogDetailsSearchField(controller: controller, log: log),
LogDetailsFormatButton(format: format),
const SizedBox(width: densePadding),
CopyToClipboardControl(
Expand All @@ -134,6 +153,140 @@ class _LogDetailsHeader extends StatelessWidget {
}
}

/// An animated search field for the log details view that toggles between an icon
/// and a full [SearchField].
class _LogDetailsSearchField extends StatefulWidget {
const _LogDetailsSearchField({required this.controller, required this.log});

final LogDetailsController controller;
final LogData? log;

@override
State<_LogDetailsSearchField> createState() => _LogDetailsSearchFieldState();
}

class _LogDetailsSearchFieldState extends State<_LogDetailsSearchField>
with AutoDisposeMixin {
late bool _isExpanded;

@override
void initState() {
super.initState();
_isExpanded = widget.controller.search.isNotEmpty;
addAutoDisposeListener(widget.controller.searchFieldFocusNode, () {
final hasFocus =
widget.controller.searchFieldFocusNode?.hasFocus ?? false;
if (hasFocus != _isExpanded) {
setState(() {
_isExpanded = hasFocus;
});
}
});
}

@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: defaultDuration,
curve: defaultCurve,
width: _isExpanded ? mediumSearchFieldWidth : defaultButtonHeight,
child: OverflowBox(
minWidth: 0.0,
maxWidth: mediumSearchFieldWidth,
child: _isExpanded
? Padding(
padding: const EdgeInsets.symmetric(horizontal: densePadding),
child: SearchField<LogDetailsController>(
searchController: widget.controller,
searchFieldEnabled:
widget.log != null && widget.log!.details != null,
shouldRequestFocus: true,
searchFieldWidth: mediumSearchFieldWidth,
),
)
: ToolbarAction(
icon: Icons.search,
tooltip: 'Search details',
size: defaultIconSize,
onPressed: () {
setState(() {
_isExpanded = true;
});
widget.controller.searchFieldFocusNode?.requestFocus();
},
),
),
);
}
}

/// A text widget for the log details view that highlights search matches.
class _SearchableLogDetailsText extends StatelessWidget {
const _SearchableLogDetailsText({
required this.text,
required this.controller,
});

final String text;
final LogDetailsController controller;

@override
Widget build(BuildContext context) {
return MultiValueListenableBuilder(
listenables: [controller.searchMatches, controller.activeSearchMatch],
builder: (context, values, _) {
final theme = Theme.of(context);

final matches = values[0] as List<LogDetailsMatch>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider pulling some of this into a shared helper that can be used here and in the codeview highlighter - looks like there is some shared logic here but not sure how much work it would be to refactor

final activeMatch = values[1] as LogDetailsMatch?;
if (matches.isEmpty) {
return Text(
text,
textAlign: TextAlign.left,
style: theme.regularTextStyle,
);
}

final spans = <TextSpan>[];
int previousEnd = 0;
for (final match in matches) {
if (match.range.begin > previousEnd) {
spans.add(
TextSpan(
text: text.substring(previousEnd, match.range.begin as int),
),
);
}
final isActive = match == activeMatch;
spans.add(
TextSpan(
text: text.substring(
match.range.begin as int,
match.range.end as int,
),
style: theme.regularTextStyle.copyWith(
backgroundColor: isActive
? activeSearchMatchColor
: searchMatchColor,
color: Colors.black,
),
),
);
previousEnd = match.range.end as int;
}

if (previousEnd < text.length) {
spans.add(TextSpan(text: text.substring(previousEnd)));
}

return Text.rich(
TextSpan(style: theme.regularTextStyle, children: spans),
);
},
);
}
}

@visibleForTesting
class LogDetailsFormatButton extends StatelessWidget {
const LogDetailsFormatButton({super.key, required this.format});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class LogsTable extends StatelessWidget {
defaultSortDirection: SortDirection.ascending,
secondarySortColumn: messageColumn,
rowHeight: _logRowHeight,
tallHeaders: true,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2024 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_shared/utils.dart';
import 'package:flutter/foundation.dart';

import '../../shared/primitives/utils.dart';
import '../../shared/ui/search.dart';
import 'logging_controller.dart';

/// A controller for the log details view that provides search functionality.
class LogDetailsController extends DisposableController
with SearchControllerMixin<LogDetailsMatch>, AutoDisposeControllerMixin {
LogDetailsController({required ValueListenable<LogData?> selectedLog}) {
init();
addAutoDisposeListener(selectedLog, () {
_selectedLog = selectedLog.value;
refreshSearchMatches();
});
}

LogData? _selectedLog;

@override
List<LogDetailsMatch> matchesForSearch(
String search, {
bool searchPreviousMatches = false,
}) {
if (search.isEmpty || _selectedLog == null) return [];
final matches = <LogDetailsMatch>[];

final text = _selectedLog!.prettyPrinted();
if (text == null) return [];

final regex = RegExp(search, caseSensitive: false);
final allMatches = regex.allMatches(text);
for (final match in allMatches) {
matches.add(LogDetailsMatch(match.start, match.end));
}
return matches;
}

@override
void dispose() {
_selectedLog = null;
super.dispose();
}
}

/// A search match in the log details view.
class LogDetailsMatch with SearchableDataMixin {
LogDetailsMatch(this.start, this.end);

final int start;
final int end;

Range get range => Range(start, end);

@override
bool matchesSearchToken(RegExp regExpSearch) => false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import '../../shared/primitives/utils.dart';
import '../../shared/ui/filter.dart';
import '../../shared/ui/search.dart';
import '../inspector/inspector_tree_controller.dart';
import 'log_details_controller.dart';
import 'logging_screen.dart';
import 'metadata.dart';

Expand Down Expand Up @@ -110,6 +111,8 @@ class LoggingController extends DevToolsScreenController
@override
void init() {
super.init();
logDetailsController = LogDetailsController(selectedLog: selectedLog)
..init();
addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () {
if (serviceConnection.serviceManager.connectedState.value.connected) {
_handleConnectionStart(serviceConnection.serviceManager.service!);
Expand Down Expand Up @@ -138,6 +141,7 @@ class LoggingController extends DevToolsScreenController

@override
void dispose() {
logDetailsController.dispose();
selectedLog.dispose();
unawaited(_logStatusController.close());
super.dispose();
Expand Down Expand Up @@ -234,6 +238,8 @@ class LoggingController extends DevToolsScreenController

final _logStatusController = StreamController<String>.broadcast();

late final LogDetailsController logDetailsController;

List<LogData> data = <LogData>[];

final selectedLog = ValueNotifier<LogData?>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ class _LoggingScreenState extends State<LoggingScreenBody>
ValueListenableBuilder<LogData?>(
valueListenable: controller.selectedLog,
builder: (context, selected, _) {
return LogDetails(log: selected);
return LogDetails(
log: selected,
controller: controller.logDetailsController,
);
},
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class SearchableFlatTable<T extends SearchableDataMixin> extends FlatTable<T> {
super.sizeColumnsToFit = true,
super.rowHeight,
super.selectionNotifier,
super.tallHeaders,
}) : super(
searchMatchesNotifier: searchController.searchMatches,
activeSearchMatchNotifier: searchController.activeSearchMatch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ class _TableRowState<T> extends State<TableRow<T>>
final box = SizedBox(
height: widget._rowType == _TableRowType.data
? defaultRowHeight
: defaultHeaderHeight + (widget.tall ? densePadding : 0.0),
: defaultHeaderHeight + (widget.tall ? 2 * densePadding : 0.0),
child: Material(
color: _searchAwareBackgroundColor(),
child: onPressed != null
Expand Down
Loading
Loading