Skip to content

Commit

Permalink
Add copyToClipboard API for DevTools extensions (#8130)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenzieschmoll authored Aug 1, 2024
1 parent ddb5d48 commit a16e982
Show file tree
Hide file tree
Showing 12 changed files with 194 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController

late final HTMLIFrameElement _extensionIFrame;

/// A stream of [DevToolsExtensionEvent]s that will be posted from the
/// DevTools web app to the embedded extension iFrame.
final extensionPostEventStream =
StreamController<DevToolsExtensionEvent>.broadcast();

Expand Down
14 changes: 14 additions & 0 deletions packages/devtools_app/lib/src/extensions/embedded/_view_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:web/web.dart';

import '../../shared/banner_messages.dart';
import '../../shared/common_widgets.dart';
import '../../shared/config_specific/copy_to_clipboard/copy_to_clipboard.dart';
import '../../shared/globals.dart';
import '../../shared/utils.dart';
import '_controller_web.dart';
Expand Down Expand Up @@ -281,6 +282,8 @@ class _ExtensionIFrameController extends DisposableController
_handleShowNotification(event);
case DevToolsExtensionEventType.showBannerMessage:
_handleShowBannerMessage(event);
case DevToolsExtensionEventType.copyToClipboard:
_handleCopyToClipboard(event);
default:
onUnknownEvent?.call();
}
Expand Down Expand Up @@ -316,4 +319,15 @@ class _ExtensionIFrameController extends DisposableController
ignoreIfAlreadyDismissed: showBannerMessageEvent.ignoreIfAlreadyDismissed,
);
}

void _handleCopyToClipboard(DevToolsExtensionEvent event) {
final copyToClipboardEvent = CopyToClipboardExtensionEvent.from(event);
unawaited(
copyToClipboard(
copyToClipboardEvent.content,
successMessage: copyToClipboardEvent.successMessage,
showSuccessMessageOnFallback: true,
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ final _log = Logger('copy_to_clipboard');

/// Attempts to copy a String of `data` to the clipboard.
///
/// Shows a `successMessage` [Notification] on the passed in `context`, if the
/// Shows a [successMessage] [Notification] on the passed in `context`, if the
/// copy is successfully done using the [Clipboard.setData] api. Otherwise it
/// attempts to post the [data] to the parent frame where the parent frame will
/// try to complete the copy (this fallback will only work in VSCode).
/// try to complete the copy (this fallback will only work in VSCode). When
/// [showSuccessMessageOnFallback] is true, the [successMessage] will always be
/// shown after attempting the fallback copy approach, even though we cannot
/// guarantee that the fallback copy was actually successful.
Future<void> copyToClipboard(
String data, {
String? successMessage,
bool showSuccessMessageOnFallback = false,
}) async {
try {
await Clipboard.setData(ClipboardData(text: data));
Expand All @@ -40,6 +44,9 @@ Future<void> copyToClipboard(
// See https://github.com/Dart-Code/Dart-Code/issues/4540 for more
// information.
copyToClipboardVSCode(data);
if (showSuccessMessageOnFallback && successMessage != null) {
notificationService.push(successMessage);
}
} else {
rethrow;
}
Expand Down
7 changes: 1 addition & 6 deletions packages/devtools_app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ environment:

dependencies:
ansi_up: ^1.0.0
# path: ../../third_party/packages/ansi_up
# Pin ansicolor to version before pre-NNBD version 1.1.0, should be ^1.0.5
# See https://github.com/flutter/devtools/issues/2530
ansicolor: ^2.0.0
async: ^2.0.0
clock: ^1.1.1
Expand All @@ -25,7 +22,7 @@ dependencies:
dap: ^1.1.0
dds_service_extensions: ^2.0.0
devtools_app_shared: ^0.2.2
devtools_extensions: ^0.2.2
devtools_extensions: ^0.3.0-wip
devtools_shared: ^10.0.1
dtd: ^2.2.0
file: ">=6.0.0 <8.0.0"
Expand Down Expand Up @@ -59,11 +56,9 @@ dependencies:
unified_analytics: ^6.1.0
vm_service: ^14.2.1
vm_service_protos: ^1.0.0
# TODO https://github.com/dart-lang/sdk/issues/52853 - unpin this version
vm_snapshot_analysis: ^0.7.6
web: ^0.5.0
web_socket_channel: ^2.1.0
# widget_icons: ^0.0.1

dev_dependencies:
args: ^2.4.2
Expand Down
2 changes: 2 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ TODO: Remove this section if there are not any general updates.

* Fixed an issue where extensions did not load with the proper theme when
embedded in an IDE. - [#8034](https://github.com/flutter/devtools/pull/8034)
* Added an API for copying text to clipboard by proxy of the parent DevTools web app, which has
workarounds for copy issues when embedded inside an IDE. - [#8130](https://github.com/flutter/devtools/pull/8130)

## Full commit history

Expand Down
5 changes: 5 additions & 0 deletions packages/devtools_extensions/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.3.0-wip
* Add `ExtensionManager.copyToClipboard` method.
* Add `DevToolsExtensionEventType.copyToClipboard` enum type.
* Add `CopyToClipboardExtensionEvent` extension event type.

## 0.2.2
* Load the IDE Theme from the extension URL instead of creating
a placeholder `IdeTheme` object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ class CallingDevToolsExtensionsAPIsExample extends StatelessWidget {
'Show DevTools warning (can show again after dismiss)',
),
),
const SizedBox(height: 16.0),
ElevatedButton(
onPressed: () => extensionManager.copyToClipboard(
'Some text I copied!',
successMessage: 'Successfully copied text',
),
child: const Text('Copy text to clipboard'),
),
],
);
}
Expand Down
16 changes: 13 additions & 3 deletions packages/devtools_extensions/lib/src/api/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,25 @@ enum DevToolsExtensionEventType {
/// the active DevTools theme (light or dark).
themeUpdate(ExtensionEventDirection.toExtension),

/// An event that an extension will send to DevTools asking DevTools to post
/// An event that an extension can send to DevTools asking DevTools to post
/// a notification to the DevTools global [notificationService].
showNotification(ExtensionEventDirection.toDevTools),

/// An event that an extension will send to DevTools asking DevTools to post
/// a banner message to the extension's screen using the global
/// An event that an extension can send to DevTools asking DevTools to post
/// a banner message to the extension screen using the global
/// [bannerMessages].
showBannerMessage(ExtensionEventDirection.toDevTools),

/// An event that an extension can send to DevTools asking DevTools to copy
/// some content to the user's clipboard.
///
/// It is preferred that extensions send this event to DevTools to copy text
/// instead of calling `Clipboard.setData` directly because DevTools contains
/// additional logic for copying text from within an IDE-embedded web view.
/// This scenario will occur when a user is using a DevTools extension from
/// within their IDE.
copyToClipboard(ExtensionEventDirection.toDevTools),

/// Any unrecognized event that is not one of the above supported event types.
unknown(ExtensionEventDirection.bidirectional);

Expand Down
33 changes: 33 additions & 0 deletions packages/devtools_extensions/lib/src/api/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,39 @@ class ShowBannerMessageExtensionEvent extends DevToolsExtensionEvent {
(data![_ignoreIfAlreadyDismissedKey] as bool?) ?? true;
}

/// An extension event of type [DevToolsExtensionEventType.copyToClipboard]
/// that is sent from an extension to DevTools asking DevTools copy content to
/// the user's clipboard.
class CopyToClipboardExtensionEvent extends DevToolsExtensionEvent {
CopyToClipboardExtensionEvent({
required String content,
String successMessage = defaultSuccessMessage,
}) : super(
DevToolsExtensionEventType.copyToClipboard,
data: {
_contentKey: content,
_successMessageKey: successMessage,
},
);

factory CopyToClipboardExtensionEvent.from(DevToolsExtensionEvent event) {
assert(event.type == DevToolsExtensionEventType.copyToClipboard);
final content = event.data!.checkValid<String>(_contentKey);
final successMessage = event.data!.checkValid<String>(_successMessageKey);
return CopyToClipboardExtensionEvent(
content: content,
successMessage: successMessage,
);
}

static const _contentKey = 'content';
static const _successMessageKey = 'successMessage';
static const defaultSuccessMessage = 'Copied to clipboard';

String get content => data![_contentKey] as String;
String get successMessage => data![_successMessageKey] as String;
}

extension ParseExtension on Map<String, Object?> {
T checkValid<T>(String key) {
final element = this[key];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,28 @@ class ExtensionManager {
);
}

/// Copy [content] to clipboard from DevTools.
///
/// [successMessage] is an optional message that DevTools will show as a
/// notification when [content] has been successfully copied to the clipboard.
/// Defaults to [CopyToClipboardExtensionEvent.defaultSuccessMessage].
///
/// This method of copying text is preferred over calling `Clipboard.setData`
/// directly because DevTools contains additional logic for copying text from
/// within an IDE-embedded web view. This scenario will occur when a user is
/// using a DevTools extension from within their IDE.
void copyToClipboard(
String content, {
String successMessage = CopyToClipboardExtensionEvent.defaultSuccessMessage,
}) {
postMessageToDevTools(
CopyToClipboardExtensionEvent(
content: content,
successMessage: successMessage,
),
);
}

void _updateQueryParameter(String key, String? value) {
final newQueryParams = Map.of(loadQueryParams());
if (value == null) {
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools_extensions/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
name: devtools_extensions
description: A package for building and supporting extensions for Dart DevTools.
version: 0.2.2
version: 0.3.0-wip

repository: https://github.com/flutter/devtools/tree/master/packages/devtools_extensions

environment:
# TODO(kenz): bump min SDK versions to next beta.
sdk: ">=3.4.3 <4.0.0"
flutter: ">=3.22.2"

Expand Down
83 changes: 83 additions & 0 deletions packages/devtools_extensions/test/api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ void main() {
DevToolsExtensionEventType.showBannerMessage,
);

expect(
DevToolsExtensionEventType.from('copyToClipboard'),
DevToolsExtensionEventType.copyToClipboard,
);

expect(
DevToolsExtensionEventType.from('vmServiceConnection'),
DevToolsExtensionEventType.vmServiceConnection,
Expand Down Expand Up @@ -147,6 +152,10 @@ void main() {
DevToolsExtensionEventType.showBannerMessage,
(bidirectional: false, toDevTools: true, toExtension: false),
);
verifyEventDirection(
DevToolsExtensionEventType.copyToClipboard,
(bidirectional: false, toDevTools: true, toExtension: false),
);
verifyEventDirection(
DevToolsExtensionEventType.unknown,
(bidirectional: true, toDevTools: true, toExtension: true),
Expand Down Expand Up @@ -327,6 +336,80 @@ void main() {
);
});
});

group('$CopyToClipboardExtensionEvent', () {
test('constructs for expected values', () {
final event = DevToolsExtensionEvent.parse({
'type': 'copyToClipboard',
'data': {
'content': 'foo content',
'successMessage': 'foo success',
},
});
final copyToClipboardEvent = CopyToClipboardExtensionEvent.from(event);
expect(copyToClipboardEvent.content, 'foo content');
expect(copyToClipboardEvent.successMessage, 'foo success');
});
test('throws for unexpected values', () {
final event1 = DevToolsExtensionEvent.parse({
'type': 'copyToClipboard',
'data': {
// Missing required fields.
},
});
expect(
() {
CopyToClipboardExtensionEvent.from(event1);
},
throwsFormatException,
);

final event2 = DevToolsExtensionEvent.parse({
'type': 'copyToClipboard',
'data': {
// Bad key.
'msg': 'foo content',
'successMessage': 'foo success',
},
});
expect(
() {
CopyToClipboardExtensionEvent.from(event2);
},
throwsFormatException,
);

final event3 = DevToolsExtensionEvent.parse({
'type': 'copyToClipboard',
'data': {
// Bad value.
'content': false,
'successMessage': 'foo success',
},
});
expect(
() {
CopyToClipboardExtensionEvent.from(event3);
},
throwsFormatException,
);

final event4 = DevToolsExtensionEvent.parse({
// Wrong type.
'type': 'showBannerMessage',
'data': {
'content': 'foo content',
'successMessage': 'foo success',
},
});
expect(
() {
CopyToClipboardExtensionEvent.from(event4);
},
throwsAssertionError,
);
});
});
}

void verifyEventDirection(
Expand Down

0 comments on commit a16e982

Please sign in to comment.