Skip to content

Commit

Permalink
Add an option to display log details as JSON (#8445)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenzieschmoll authored Oct 16, 2024
1 parent eff1b1d commit c4bac12
Show file tree
Hide file tree
Showing 7 changed files with 256 additions and 64 deletions.
97 changes: 79 additions & 18 deletions packages/devtools_app/lib/src/screens/logging/_log_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import 'package:devtools_app_shared/ui.dart';
import 'package:flutter/material.dart';

import '../../shared/common_widgets.dart';
import '../../shared/console/console.dart';
import 'logging_controller.dart';

class LogDetails extends StatefulWidget {
Expand All @@ -28,6 +27,10 @@ class _LogDetailsState extends State<LogDetails>
String? _lastDetails;
late final ScrollController scrollController;

// TODO(kenz): store this as a setting in logging preferences instead of in
// this state class.
bool showDetailsAsText = true;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -73,32 +76,45 @@ class _LogDetailsState extends State<LogDetails>
_lastDetails = details;
}

return RoundedOutlinedBorder(
clip: true,
child: ConsoleFrame(
title: _LogDetailsHeader(log: log),
child: Padding(
padding: const EdgeInsets.all(denseSpacing),
child: Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: SelectableText(
log?.prettyPrinted() ?? '',
textAlign: TextAlign.left,
),
),
),
return DevToolsAreaPane(
header: _LogDetailsHeader(
log: log,
showDetailsAsText: showDetailsAsText,
onDetailsFormatPressed: (value) {
setState(() {
showDetailsAsText = value;
});
},
),
child: Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: showDetailsAsText
? Padding(
padding: const EdgeInsets.all(denseSpacing),
child: SelectableText(
log?.prettyPrinted() ?? '',
textAlign: TextAlign.left,
),
)
: JsonViewer(encodedJson: log?.encodedDetails ?? ''),
),
),
);
}
}

class _LogDetailsHeader extends StatelessWidget {
const _LogDetailsHeader({required this.log});
const _LogDetailsHeader({
required this.log,
required this.showDetailsAsText,
required this.onDetailsFormatPressed,
});

final LogData? log;
final bool showDetailsAsText;
final void Function(bool) onDetailsFormatPressed;

@override
Widget build(BuildContext context) {
Expand All @@ -111,6 +127,11 @@ class _LogDetailsHeader extends StatelessWidget {
includeTopBorder: false,
roundedTopBorder: false,
actions: [
LogDetailsFormatButton(
showDetailsAsText: showDetailsAsText,
onPressed: onDetailsFormatPressed,
),
const SizedBox(width: densePadding),
CopyToClipboardControl(
dataProvider: dataProvider,
buttonKey: LogDetails.copyToClipboardButtonKey,
Expand All @@ -119,3 +140,43 @@ class _LogDetailsHeader extends StatelessWidget {
);
}
}

@visibleForTesting
class LogDetailsFormatButton extends StatelessWidget {
const LogDetailsFormatButton({
super.key,
required this.showDetailsAsText,
required this.onPressed,
});

final bool showDetailsAsText;
final void Function(bool) onPressed;

static const viewAsJsonTooltip = 'View as JSON';
static const viewAsRawTextTooltip = 'View as raw text';

@override
Widget build(BuildContext context) {
final tooltip =
showDetailsAsText ? viewAsJsonTooltip : viewAsRawTextTooltip;
return showDetailsAsText
? Padding(
// This padding aligns this button with the copy button.
padding: const EdgeInsets.only(bottom: borderPadding),
child: SmallAction(
tooltip: tooltip,
onPressed: () => onPressed(!showDetailsAsText),
child: Text(
' { } ',
style: Theme.of(context).regularTextStyle,
),
),
)
: ToolbarAction(
icon: Icons.text_fields,
tooltip: tooltip,
onPressed: () => onPressed(!showDetailsAsText),
size: defaultIconSize,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,29 @@ class LogData with SearchableDataMixin {
}
}

String get encodedDetails {
if (_encodedDetails != null) return _encodedDetails!;
if (details == null) return '';

// TODO(kenz): ensure this doesn't cause performance issues.
String encoded;
try {
// Attempt to decode the input string as JSON
jsonDecode(details!);
// If decoding is successful, it's already JSON encoded
encoded = details!;
} catch (e) {
// If decoding fails, it's not JSON encoded, so encode it
encoded = jsonEncode(details!);
}

// Only cache the value if details have already been computed.
if (detailsComputed.value) _encodedDetails = encoded;
return encoded;
}

String? _encodedDetails;

@override
bool matchesSearchToken(RegExp regExpSearch) {
return kind.caseInsensitiveContains(regExpSearch) ||
Expand Down
115 changes: 70 additions & 45 deletions packages/devtools_app/lib/src/shared/common_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:vm_service/vm_service.dart';

import '../screens/debugger/debugger_controller.dart';
import 'analytics/analytics.dart' as ga;
import 'analytics/constants.dart' as gac;
import 'config_specific/copy_to_clipboard/copy_to_clipboard.dart';
Expand Down Expand Up @@ -641,6 +640,41 @@ class ToolbarAction extends StatelessWidget {
final String? gaScreen;
final String? gaSelection;

@override
Widget build(BuildContext context) {
return SmallAction(
onPressed: onPressed,
tooltip: tooltip,
style: style,
gaScreen: gaScreen,
gaSelection: gaSelection,
child: Icon(
icon,
size: size ?? actionsIconSize,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
);
}
}

class SmallAction extends StatelessWidget {
const SmallAction({
required this.child,
required this.onPressed,
this.tooltip,
super.key,
this.style,
this.gaScreen,
this.gaSelection,
}) : assert((gaScreen == null) == (gaSelection == null));

final TextStyle? style;
final Widget child;
final String? tooltip;
final VoidCallback? onPressed;
final String? gaScreen;
final String? gaSelection;

@override
Widget build(BuildContext context) {
final button = TextButton(
Expand All @@ -655,11 +689,7 @@ class ToolbarAction extends StatelessWidget {
}
onPressed?.call();
},
child: Icon(
icon,
size: size ?? actionsIconSize,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
child: child,
);

return tooltip == null
Expand Down Expand Up @@ -1114,16 +1144,17 @@ class JsonViewer extends StatefulWidget {
const JsonViewer({
super.key,
required this.encodedJson,
this.scrollable = true,
});

final String encodedJson;
final bool scrollable;

@override
State<JsonViewer> createState() => _JsonViewerState();
}

class _JsonViewerState extends State<JsonViewer>
with ProvidedControllerMixin<DebuggerController, JsonViewer> {
class _JsonViewerState extends State<JsonViewer> {
late Future<void> _initializeTree;
late DartObjectNode variable;
static const jsonEncoder = JsonEncoder.withIndent(' ');
Expand Down Expand Up @@ -1172,7 +1203,9 @@ class _JsonViewerState extends State<JsonViewer>
@override
void didUpdateWidget(JsonViewer oldWidget) {
super.didUpdateWidget(oldWidget);
_updateVariablesTree();
if (oldWidget.encodedJson != widget.encodedJson) {
_updateVariablesTree();
}
}

@override
Expand All @@ -1184,47 +1217,39 @@ class _JsonViewerState extends State<JsonViewer>
.removeJsonObject(variable.value as Instance);
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
// Currently a redundant check, but adding it anyway to prevent future
// bugs being introduced.
if (!initController()) {
return;
}
// Any additional initialization code should be added after this line.
}

@override
Widget build(BuildContext context) {
Widget child = FutureBuilder(
future: _initializeTree,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Container();
}
return ExpandableVariable(
variable: variable,
onCopy: (copiedVariable) {
unawaited(
copyToClipboard(
jsonEncoder.convert(
serviceConnection.serviceManager.service!.fakeServiceCache
.instanceToJson(copiedVariable.value as Instance),
),
successMessage: 'JSON copied to clipboard',
),
);
},
);
},
);
if (widget.scrollable) {
child = SingleChildScrollView(
child: child,
);
}
return SelectionArea(
child: Padding(
padding: const EdgeInsets.all(denseSpacing),
child: SingleChildScrollView(
child: FutureBuilder(
future: _initializeTree,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Container();
}
return ExpandableVariable(
variable: variable,
onCopy: (copiedVariable) {
unawaited(
copyToClipboard(
jsonEncoder.convert(
serviceConnection
.serviceManager.service!.fakeServiceCache
.instanceToJson(copiedVariable.value as Instance),
),
successMessage: 'JSON copied to clipboard',
),
);
},
);
},
),
),
child: child,
),
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ severity, category, zone, and isolate.
[#8433](https://github.com/flutter/devtools/pull/8433)
![Log level filter](images/log_level_filter.png "Log level filter")

* Added a button to toggle the log details display between raw text and JSON. -
[#8445](https://github.com/flutter/devtools/pull/8445)

* Fixed a bug where logs would get out of order after midnight. -
[#8420](https://github.com/flutter/devtools/pull/8420)

Expand Down
Loading

0 comments on commit c4bac12

Please sign in to comment.