Skip to content
Merged
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
126 changes: 126 additions & 0 deletions packages/devtools_app/lib/src/http/curl_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import '../primitives/utils.dart';
import 'http_request_data.dart';

class CurlCommand {
/// [CurlCommand] provides the ability to create a cURL command string
/// based on the passed [DartIOHttpRequestData].
///
/// When [followRedirects] is false, the `--location` option is omitted from cURL.
/// When [multiline] is false, the command will be forced to be in a single line.
factory CurlCommand.from(
DartIOHttpRequestData data, {
bool followRedirects = true,
bool multiline = true,
}) {
return CurlCommand._(
commandParts: [
'curl',
if (followRedirects) '--location',
'--request',
data.method,
_escapeString(data.uri),
..._headers(data, multiline: multiline),
..._body(data, multiline: multiline),
],
);
}

CurlCommand._({
required this.commandParts,
});

static const _lineBreak = '\\\n';

final List<String> commandParts;

/// Returns the cURL command as a string.
@override
String toString() {
return _buildCommandString(commandParts);
}

static List<String> _headers(
DartIOHttpRequestData data, {
required bool multiline,
}) {
final parts = <String>[];
final headers = data.requestHeaders;

if (headers != null && headers.isNotEmpty) {
for (final header in headers.entries) {
final headerKey = header.key.toLowerCase();
final headerValue = _unwrapHeaderValue(header.value);

if (headerValue == null) continue;

parts.addAll([
if (multiline) _lineBreak,
'--header',
_escapeString('$headerKey: $headerValue')
]);
}
}

return parts;
}

static List<String> _body(
DartIOHttpRequestData data, {
required bool multiline,
}) {
final requestBody = data.requestBody;
if (requestBody == null) return [];

return [
if (multiline) _lineBreak,
'--data-raw',
_escapeString(requestBody),
];
}

/// Escapes an arbitrary string by wrapping it inside single quotes.
///
/// Enclosing characters in single quotes preserves the literal value of each
/// character in the string. Single quotes can't occur within, which is why it
/// is necessary to replace all occurences of the character ' with '\''.
///
/// See: https://www.gnu.org/software/bash/manual/html_node/Quoting.html
static String _escapeString(String text) {
final content = text.replaceAll("'", "'\\''");

return "'$content'";
}

static String? _unwrapHeaderValue(dynamic value) {
if (value is String) {
return value;
} else if (value is List<dynamic>) {
return value.safeFirst as String?;
}

return null;
}

/// Given a list of [commandParts], build the cURL command string.
static String _buildCommandString(List<String> commandParts) {
String commandString = '';

for (int index = 0; index < commandParts.length; index++) {
final previousPart = commandParts.safeGet(index - 1);

// Only insert a space when this is not the first element AND the previous
// part is not a line break.
if (index != 0 && previousPart != _lineBreak) {
commandString += ' ';
}

commandString += commandParts[index];
}

return commandString;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ class DartIOHttpRequestData extends NetworkRequest {
final acceptedMethods = {'POST', 'PUT', 'PATCH'};
if (!acceptedMethods.contains(_request.method)) return null;
if (_requestBody != null) return _requestBody;
if (fullRequest.requestBody == null) return null;
_requestBody = utf8.decode(fullRequest.requestBody!);
return _requestBody;
} on FormatException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import 'package:provider/provider.dart';

import '../../analytics/analytics.dart' as ga;
import '../../analytics/constants.dart' as analytics_constants;
import '../../http/curl_command.dart';
import '../../http/http_request_data.dart';
import '../../primitives/auto_dispose_mixin.dart';
import '../../primitives/utils.dart';
import '../../shared/common_widgets.dart';
Expand Down Expand Up @@ -331,6 +333,7 @@ class NetworkRequestsTable extends StatelessWidget {
static TypeColumn typeColumn = TypeColumn();
static DurationColumn durationColumn = DurationColumn();
static TimestampColumn timestampColumn = TimestampColumn();
static ActionsColumn actionsColumn = ActionsColumn();

final NetworkController networkController;
final List<NetworkRequest> requests;
Expand All @@ -348,6 +351,7 @@ class NetworkRequestsTable extends StatelessWidget {
typeColumn,
durationColumn,
timestampColumn,
actionsColumn
],
data: requests,
keyFactory: (NetworkRequest? data) => ValueKey<NetworkRequest?>(data),
Expand Down Expand Up @@ -408,6 +412,78 @@ class MethodColumn extends ColumnData<NetworkRequest> {
}
}

class ActionsColumn extends ColumnData<NetworkRequest>
implements ColumnRenderer<NetworkRequest> {
ActionsColumn()
: super(
'',
fixedWidthPx: scaleByFontFactor(32),
alignment: ColumnAlignment.right,
);

static const _actionSplashRadius = 16.0;

@override
bool get supportsSorting => false;

@override
bool get includeHeader => false;

@override
dynamic getValue(NetworkRequest dataObject) {
return '';
}

List<PopupMenuItem> _buildOptions(BuildContext context, NetworkRequest data) {
return [
if (data is DartIOHttpRequestData) ...[
PopupMenuItem(
child: const Text('Copy as URL'),
onTap: () {
copyToClipboard(
data.uri,
'Copied the URL to the clipboard',
context,
);
},
),
PopupMenuItem(
child: const Text('Copy as cURL'),
onTap: () {
copyToClipboard(
CurlCommand.from(data).toString(),
'Copied the cURL command to the clipboard',
context,
);
},
)
]
];
}

@override
Widget build(
BuildContext context,
NetworkRequest data, {
bool isRowSelected = false,
VoidCallback? onPressed,
}) {
final options = _buildOptions(context, data);

// Only show the actions button when there are options and the row is
// currently selected.
if (options.isEmpty || !isRowSelected) return const SizedBox.shrink();

return PopupMenuButton(
icon: const Icon(Icons.more_vert),
padding: const EdgeInsets.symmetric(horizontal: densePadding),
splashRadius: _actionSplashRadius,
tooltip: '',
itemBuilder: (context) => options,
);
}
}

class StatusColumn extends ColumnData<NetworkRequest>
implements ColumnRenderer<NetworkRequest> {
StatusColumn()
Expand Down
62 changes: 33 additions & 29 deletions packages/devtools_app/lib/src/shared/table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1327,37 +1327,41 @@ class _TableRowState<T> extends State<TableRow<T>>
overflow: TextOverflow.ellipsis,
);

content = InkWell(
canRequestFocus: false,
onTap: () => _handleSortChange(
column,
secondarySortColumn: widget.secondarySortColumn,
),
child: Row(
mainAxisAlignment: _mainAxisAlignmentFor(column),
children: [
if (isSortColumn)
Icon(
widget.sortDirection == SortDirection.ascending
? Icons.expand_less
: Icons.expand_more,
size: defaultIconSize,
),
if (isSortColumn) const SizedBox(width: densePadding),
// TODO: This Flexible wrapper was added to get the
// network_profiler_test.dart tests to pass.
Flexible(
child: column.titleTooltip != null
? DevToolsTooltip(
message: column.titleTooltip,
padding: const EdgeInsets.all(denseSpacing),
child: title,
)
: title,
final headerContent = Row(
mainAxisAlignment: _mainAxisAlignmentFor(column),
children: [
if (isSortColumn)
Icon(
widget.sortDirection == SortDirection.ascending
? Icons.expand_less
: Icons.expand_more,
size: defaultIconSize,
),
],
),
if (isSortColumn) const SizedBox(width: densePadding),
// TODO: This Flexible wrapper was added to get the
// network_profiler_test.dart tests to pass.
Flexible(
child: column.titleTooltip != null
? DevToolsTooltip(
message: column.titleTooltip,
padding: const EdgeInsets.all(denseSpacing),
child: title,
)
: title,
),
],
);

content = column.includeHeader
? InkWell(
canRequestFocus: false,
onTap: () => _handleSortChange(
column,
secondarySortColumn: widget.secondarySortColumn,
),
child: headerContent,
)
: headerContent;
} else {
final padding = column.getNodeIndentPx(node);
assert(padding >= 0);
Expand Down
2 changes: 2 additions & 0 deletions packages/devtools_app/lib/src/shared/table_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ abstract class ColumnData<T> {

bool get numeric => false;

bool get includeHeader => true;

bool get supportsSorting => numeric;

int compare(T a, T b) {
Expand Down
Loading