Show Performance and CPU Profiler screens without app connection (#6567)

* Add Performance and CPU Profiler screens to list of statically available screens

* add OpenSaveButtonGroup

* release notes and changelog

* dart doc

* Update packages/devtools_app/lib/src/screens/performance/performance_screen.dart

Co-authored-by: Daniel Chevalier <danchevalier@google.com>

---------

Co-authored-by: Daniel Chevalier <danchevalier@google.com>
diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart
index 2905854..2160835 100644
--- a/packages/devtools_app/lib/src/app.dart
+++ b/packages/devtools_app/lib/src/app.dart
@@ -311,7 +311,7 @@
                   // hot restart service is available for the connected app.
                   const HotRestartButton(),
                 ],
-                ...DevToolsScaffold.defaultActions(isEmbedded: embed),
+                ...DevToolsScaffold.defaultActions(),
               ],
             ),
           );
diff --git a/packages/devtools_app/lib/src/framework/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold.dart
index 4249e0e..8c794c1 100644
--- a/packages/devtools_app/lib/src/framework/scaffold.dart
+++ b/packages/devtools_app/lib/src/framework/scaffold.dart
@@ -41,7 +41,7 @@
     this.page,
     List<Widget>? actions,
     this.embed = false,
-  })  : actions = actions ?? defaultActions(isEmbedded: embed),
+  })  : actions = actions ?? defaultActions(),
         super(key: key);
 
   DevToolsScaffold.withChild({
@@ -56,16 +56,11 @@
           embed: embed,
         );
 
-  static List<Widget> defaultActions({
-    required bool isEmbedded,
-    Color? color,
-  }) =>
-      [
+  static List<Widget> defaultActions({Color? color}) => [
         OpenSettingsAction(color: color),
         if (FeatureFlags.devToolsExtensions)
           ExtensionSettingsAction(color: color),
         ReportFeedbackButton(color: color),
-        if (!isEmbedded) ImportToolbarAction(color: color),
         OpenAboutAction(color: color),
       ];
 
diff --git a/packages/devtools_app/lib/src/framework/status_line.dart b/packages/devtools_app/lib/src/framework/status_line.dart
index 37235c7..5e31f37 100644
--- a/packages/devtools_app/lib/src/framework/status_line.dart
+++ b/packages/devtools_app/lib/src/framework/status_line.dart
@@ -113,10 +113,7 @@
         BulletSpacer(color: color),
         Row(
           crossAxisAlignment: CrossAxisAlignment.end,
-          children: DevToolsScaffold.defaultActions(
-            color: color,
-            isEmbedded: isEmbedded,
-          ),
+          children: DevToolsScaffold.defaultActions(color: color),
         ),
       ],
     ];
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart
index cbe34d1..2ecc60a 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart
@@ -12,7 +12,9 @@
 import '../../../../shared/analytics/analytics.dart' as ga;
 import '../../../../shared/analytics/constants.dart' as gac;
 import '../../../../shared/common_widgets.dart';
+import '../../../../shared/file_import.dart';
 import '../../../../shared/globals.dart';
+import '../../../../shared/screen.dart';
 import '../../panes/timeline_events/timeline_events_controller.dart';
 import '../../performance_controller.dart';
 import 'enhance_tracing/enhance_tracing.dart';
@@ -146,12 +148,9 @@
           const MoreDebuggingOptionsButton(),
         ],
         const SizedBox(width: denseSpacing),
-        GaDevToolsButton.iconOnly(
-          icon: Icons.file_download,
-          gaScreen: gac.performance,
-          gaSelection: gac.export,
-          tooltip: 'Export data',
-          onPressed: _exportPerformanceData,
+        OpenSaveButtonGroup(
+          screenId: ScreenMetaData.performance.id,
+          onSave: controller.exportData,
         ),
         const SizedBox(width: denseSpacing),
         SettingsOutlinedButton(
@@ -163,13 +162,6 @@
     );
   }
 
-  void _exportPerformanceData() {
-    ga.select(gac.performance, gac.export);
-    controller.exportData();
-    // TODO(kenz): investigate if we need to do any error handling here. Is the
-    // download always successful?
-  }
-
   void _openSettingsDialog(BuildContext context) {
     unawaited(
       showDialog(
diff --git a/packages/devtools_app/lib/src/screens/performance/performance_screen.dart b/packages/devtools_app/lib/src/screens/performance/performance_screen.dart
index d08cf83..eed8a83 100644
--- a/packages/devtools_app/lib/src/screens/performance/performance_screen.dart
+++ b/packages/devtools_app/lib/src/screens/performance/performance_screen.dart
@@ -9,11 +9,15 @@
 import 'package:devtools_shared/devtools_shared.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_markdown/flutter_markdown.dart';
+import 'package:provider/provider.dart';
 import 'package:url_launcher/url_launcher.dart';
 
 import '../../shared/analytics/analytics.dart' as ga;
+import '../../shared/analytics/constants.dart' as gac;
 import '../../shared/banner_messages.dart';
 import '../../shared/common_widgets.dart';
+import '../../shared/config_specific/import_export/import_export.dart';
+import '../../shared/file_import.dart';
 import '../../shared/globals.dart';
 import '../../shared/screen.dart';
 import '../../shared/utils.dart';
@@ -35,6 +39,12 @@
 
   @override
   Widget build(BuildContext context) {
+    final connected = serviceConnection.serviceManager.hasConnection &&
+        serviceConnection.serviceManager.connectedAppInitialized;
+    if (!connected && !offlineController.offlineMode.value) {
+      return const DisconnectedPerformanceScreenBody();
+    }
+
     if (serviceConnection.serviceManager.connectedApp?.isDartWebAppNow ??
         false) {
       return const WebPerformanceScreenBody();
@@ -148,6 +158,28 @@
   }
 }
 
+class DisconnectedPerformanceScreenBody extends StatelessWidget {
+  const DisconnectedPerformanceScreenBody({super.key});
+
+  static const importInstructions =
+      'Open a performance data file that was previously saved from DevTools.';
+
+  @override
+  Widget build(BuildContext context) {
+    return FileImportContainer(
+      instructions: importInstructions,
+      actionText: 'Load data',
+      gaScreen: gac.appSize,
+      gaSelectionImport: gac.PerformanceEvents.openDataFile.name,
+      gaSelectionAction: gac.PerformanceEvents.loadDataFromFile.name,
+      onAction: (jsonFile) {
+        Provider.of<ImportController>(context, listen: false)
+            .importData(jsonFile, expectedScreenId: PerformanceScreen.id);
+      },
+    );
+  }
+}
+
 const timelineLink =
     'https://api.flutter.dev/flutter/dart-developer/Timeline-class.html';
 const timelineTaskLink =
diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart b/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart
index ead2e93..4b3a006 100644
--- a/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart
@@ -7,7 +7,9 @@
 
 import '../../../../shared/analytics/constants.dart' as gac;
 import '../../../../shared/common_widgets.dart';
+import '../../../../shared/file_import.dart';
 import '../../../../shared/globals.dart';
+import '../../../../shared/screen.dart';
 import '../../../../shared/ui/vm_flag_widgets.dart';
 import '../../profiler_screen_controller.dart';
 
@@ -107,8 +109,6 @@
     required this.profilerBusy,
   });
 
-  static const _secondaryControlsMinScreenWidthForText = 1170.0;
-
   static const _profilingControlsMinScreenWidthForText = 875.0;
 
   final ProfilerScreenController controller;
@@ -155,15 +155,13 @@
               controller.cpuProfilerController.profilePeriodFlag!,
         ),
         const SizedBox(width: denseSpacing),
-        ExportButton(
-          gaScreen: gac.cpuProfiler,
-          onPressed: !profilerBusy &&
+        OpenSaveButtonGroup(
+          screenId: ScreenMetaData.cpuProfiler.id,
+          onSave: !profilerBusy &&
                   controller.cpuProfileData != null &&
                   controller.cpuProfileData?.isEmpty == false
               ? _exportPerformance
               : null,
-          minScreenWidthForTextBeforeScaling:
-              _secondaryControlsMinScreenWidthForText,
         ),
       ],
     );
diff --git a/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart b/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart
index efc147b..5146b33 100644
--- a/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart
@@ -6,11 +6,15 @@
 import 'package:devtools_app_shared/utils.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
 import 'package:vm_service/vm_service.dart' hide Stack;
 
 import '../../shared/analytics/analytics.dart' as ga;
+import '../../shared/analytics/constants.dart' as gac;
 import '../../shared/banner_messages.dart';
 import '../../shared/common_widgets.dart';
+import '../../shared/config_specific/import_export/import_export.dart';
+import '../../shared/file_import.dart';
 import '../../shared/globals.dart';
 import '../../shared/primitives/listenable.dart';
 import '../../shared/screen.dart';
@@ -35,7 +39,15 @@
       const FixedValueListenable<bool>(true);
 
   @override
-  Widget build(BuildContext context) => const ProfilerScreenBody();
+  Widget build(BuildContext context) {
+    final connected = serviceConnection.serviceManager.hasConnection &&
+        serviceConnection.serviceManager.connectedAppInitialized;
+    if (!connected && !offlineController.offlineMode.value) {
+      return const DisconnectedCpuProfilerScreenBody();
+    }
+
+    return const ProfilerScreenBody();
+  }
 }
 
 class ProfilerScreenBody extends StatefulWidget {
@@ -164,3 +176,25 @@
     );
   }
 }
+
+class DisconnectedCpuProfilerScreenBody extends StatelessWidget {
+  const DisconnectedCpuProfilerScreenBody({super.key});
+
+  static const importInstructions =
+      'Open a CPU profile that was previously saved from DevTools';
+
+  @override
+  Widget build(BuildContext context) {
+    return FileImportContainer(
+      instructions: importInstructions,
+      actionText: 'Load data',
+      gaScreen: gac.appSize,
+      gaSelectionImport: gac.CpuProfilerEvents.openDataFile.name,
+      gaSelectionAction: gac.CpuProfilerEvents.loadDataFromFile.name,
+      onAction: (jsonFile) {
+        Provider.of<ImportController>(context, listen: false)
+            .importData(jsonFile, expectedScreenId: ProfilerScreen.id);
+      },
+    );
+  }
+}
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart
index c51c3d5..4546930 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart
@@ -129,7 +129,8 @@
 const clear = 'clear';
 const record = 'record';
 const stop = 'stop';
-const export = 'export';
+const openFile = 'openFile';
+const saveFile = 'saveFile';
 const expandAll = 'expandAll';
 const collapseAll = 'collapseAll';
 const profileModeDocs = 'profileModeDocs';
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart
index 4784183..01ffbff 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart
@@ -11,6 +11,8 @@
   cpuProfileFlameChartHelp,
   cpuProfileProcessingTime,
   cpuProfileDisplayTreeGuidelines,
+  openDataFile,
+  loadDataFromFile;
 }
 
 enum CpuProfilerDocs {
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart
index 5336fee..ceaf8d9 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart
@@ -29,7 +29,9 @@
   perfettoScrollToTimeRange,
   perfettoShowHelp,
   performanceSettings,
-  traceCategories;
+  traceCategories,
+  openDataFile,
+  loadDataFromFile;
 
   const PerformanceEvents([this.nameOverride]);
 
diff --git a/packages/devtools_app/lib/src/shared/common_widgets.dart b/packages/devtools_app/lib/src/shared/common_widgets.dart
index 67eb428..c16bfd4 100644
--- a/packages/devtools_app/lib/src/shared/common_widgets.dart
+++ b/packages/devtools_app/lib/src/shared/common_widgets.dart
@@ -704,25 +704,6 @@
   Size get preferredSize => Size.zero;
 }
 
-/// Button to export data.
-///
-/// * `minScreenWidthForTextBeforeScaling`: The minimum width the button can be before the text is
-///    omitted.
-/// * `onPressed`: The callback to be called upon pressing the button.
-class ExportButton extends GaDevToolsButton {
-  ExportButton({
-    required super.gaScreen,
-    super.key,
-    super.onPressed,
-    super.minScreenWidthForTextBeforeScaling,
-    super.tooltip = 'Export data',
-  }) : super(
-          icon: Icons.file_download,
-          label: 'Export',
-          gaSelection: gac.export,
-        );
-}
-
 /// Button to open related information / documentation.
 ///
 /// [tooltip] specifies the hover text for the button.
diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
index d8f8f0d..34e5cdf 100644
--- a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
+++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
@@ -2,19 +2,12 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:async';
 import 'dart:convert';
 
 import 'package:devtools_app_shared/service.dart';
-import 'package:flutter/material.dart';
 import 'package:intl/intl.dart';
-import 'package:provider/provider.dart';
 
 import '../../../../devtools.dart';
-import '../../analytics/analytics.dart' as ga;
-import '../../analytics/constants.dart' as gac;
-import '../../common_widgets.dart';
-import '../../file_import.dart';
 import '../../globals.dart';
 import '../../primitives/simple_items.dart';
 import '../../primitives/utils.dart';
@@ -42,7 +35,6 @@
   activeScreenId,
 }
 
-// TODO(kenz): we should support a file picker import for desktop.
 class ImportController {
   ImportController(
     this._pushSnapshotScreenForImport,
@@ -55,7 +47,7 @@
   DateTime? previousImportTime;
 
   // TODO(kenz): improve error handling here or in snapshot_screen.dart.
-  void importData(DevToolsJsonFile jsonFile) {
+  void importData(DevToolsJsonFile jsonFile, {String? expectedScreenId}) {
     final json = jsonFile.data;
 
     // Do not allow two different imports within 500 ms of each other. This is a
@@ -81,6 +73,14 @@
     // TODO(kenz): support imports for more than one screen at a time.
     final activeScreenId =
         devToolsSnapshot[DevToolsExportKeys.activeScreenId.name];
+    if (expectedScreenId != null && activeScreenId != expectedScreenId) {
+      notificationService.push(
+        'Expected a data file for screen \'$expectedScreenId\' but received one'
+        ' for screen \'$activeScreenId\'. Please open a file for screen \'$expectedScreenId\'.',
+      );
+      return;
+    }
+
     final connectedApp =
         (devToolsSnapshot[DevToolsExportKeys.connectedApp.name] ??
                 <String, Object>{})
@@ -185,29 +185,3 @@
     return jsonEncode(data);
   }
 }
-
-class ImportToolbarAction extends ScaffoldAction {
-  ImportToolbarAction({super.key, Color? color})
-      : super(
-          icon: Icons.upload_rounded,
-          tooltip: 'Load data for viewing in DevTools.',
-          color: color,
-          onPressed: (context) => unawaited(_importFile(context)),
-        );
-
-  static Future<void> _importFile(BuildContext context) async {
-    ga.select(
-      gac.devToolsMain,
-      gac.importFile,
-    );
-    final DevToolsJsonFile? importedFile = await importFileFromPicker(
-      acceptedTypes: ['json'],
-    );
-
-    if (importedFile != null) {
-      // ignore: use_build_context_synchronously, by design
-      Provider.of<ImportController>(context, listen: false)
-          .importData(importedFile);
-    }
-  }
-}
diff --git a/packages/devtools_app/lib/src/shared/file_import.dart b/packages/devtools_app/lib/src/shared/file_import.dart
index 2ae18dd..88dadf8 100644
--- a/packages/devtools_app/lib/src/shared/file_import.dart
+++ b/packages/devtools_app/lib/src/shared/file_import.dart
@@ -7,18 +7,70 @@
 import 'package:devtools_app_shared/ui.dart';
 import 'package:file_selector/file_selector.dart';
 import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
 
+import 'analytics/analytics.dart' as ga;
+import 'analytics/constants.dart' as gac;
 import 'common_widgets.dart';
 import 'config_specific/drag_and_drop/drag_and_drop.dart';
+import 'config_specific/import_export/import_export.dart';
 import 'globals.dart';
 import 'primitives/utils.dart';
 
+class OpenSaveButtonGroup extends StatelessWidget {
+  const OpenSaveButtonGroup({
+    super.key,
+    required this.screenId,
+    required this.onSave,
+  });
+
+  final String screenId;
+
+  final VoidCallback? onSave;
+
+  @override
+  Widget build(BuildContext context) {
+    return RoundedButtonGroup(
+      items: [
+        ButtonGroupItemData(
+          icon: Icons.file_upload,
+          tooltip: 'Open a file that was previously saved from DevTools',
+          onPressed: () async {
+            ga.select(screenId, gac.openFile);
+            final importedFile =
+                await importFileFromPicker(acceptedTypes: const ['json']);
+            if (importedFile != null) {
+              // ignore: use_build_context_synchronously, intentional use.
+              Provider.of<ImportController>(context, listen: false)
+                  .importData(importedFile, expectedScreenId: screenId);
+            } else {
+              notificationService.push(
+                'Something went wrong. Could not open selected file.',
+              );
+            }
+          },
+        ),
+        ButtonGroupItemData(
+          icon: Icons.file_download,
+          tooltip: 'Save this screen\'s data for offline viewing',
+          onPressed: onSave != null
+              ? () {
+                  ga.select(screenId, gac.saveFile);
+                  onSave!.call();
+                }
+              : null,
+        ),
+      ],
+    );
+  }
+}
+
 class FileImportContainer extends StatefulWidget {
   const FileImportContainer({
-    required this.title,
     required this.instructions,
     required this.gaScreen,
     required this.gaSelectionImport,
+    this.title,
     this.gaSelectionAction,
     this.actionText,
     this.onAction,
@@ -28,7 +80,7 @@
     super.key,
   });
 
-  final String title;
+  final String? title;
 
   final String instructions;
 
@@ -59,13 +111,16 @@
 
   @override
   Widget build(BuildContext context) {
+    final title = widget.title;
     return Column(
       children: [
-        Text(
-          widget.title,
-          style: TextStyle(fontSize: scaleByFontFactor(18.0)),
-        ),
-        const SizedBox(height: defaultSpacing),
+        if (title != null) ...[
+          Text(
+            title,
+            style: TextStyle(fontSize: scaleByFontFactor(18.0)),
+          ),
+          const SizedBox(height: defaultSpacing),
+        ],
         Expanded(
           // TODO(kenz): improve drag over highlight.
           child: DragAndDrop(
@@ -165,7 +220,6 @@
               gaScreen: widget.gaScreen,
               gaSelection: widget.gaSelectionAction!,
               label: widget.actionText!,
-              icon: Icons.highlight,
               elevated: true,
               onPressed: importedFile != null
                   ? () => widget.onAction!(importedFile!)
@@ -248,7 +302,7 @@
     return GaDevToolsButton(
       onPressed: onPressed,
       icon: Icons.file_upload,
-      label: 'Import File',
+      label: 'Open file',
       gaScreen: gaScreen,
       gaSelection: gaSelection,
       elevated: elevatedButton,
diff --git a/packages/devtools_app/lib/src/shared/screen.dart b/packages/devtools_app/lib/src/shared/screen.dart
index 3e662c8..fdc3922 100644
--- a/packages/devtools_app/lib/src/shared/screen.dart
+++ b/packages/devtools_app/lib/src/shared/screen.dart
@@ -37,6 +37,7 @@
     title: 'Performance',
     icon: Octicons.pulse,
     worksOffline: true,
+    requiresConnection: false,
     tutorialVideoTimestamp: '?t=261',
   ),
   cpuProfiler(
@@ -45,6 +46,7 @@
     icon: Octicons.dashboard,
     requiresDartVm: true,
     worksOffline: true,
+    requiresConnection: false,
     tutorialVideoTimestamp: '?t=340',
   ),
   memory(
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 01f51fc..9d08ee5 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -32,9 +32,18 @@
 
     ![Track platform channels setting](images/track_platform_channels.png "Track platform channels setting")
 
+* Made the Performance screen available when there is no connected app. Performance data that was
+previously saved from DevTools can be reloaded for viewing from this screen. [#6567](https://github.com/flutter/devtools/pull/6567)
+* Added an "Open" button to the Performance controls for loading data that was previously saved
+from DevTools. [#6567](https://github.com/flutter/devtools/pull/6567)
+
 ## CPU profiler updates
 
 * Tree guidelines are now always enabled for the "Bottom Up" and "Call Tree" tabs. [#6534](https://github.com/flutter/devtools/pull/6534)
+* Made the CPU profiler screen available when there is no connected app. CPU profiles that were
+previously saved from DevTools can be reloaded for viewing from this screen. [#6567](https://github.com/flutter/devtools/pull/6567)
+* Added an "Open" button to the CPU profiler controls for loading data that was previously saved
+from DevTools. [#6567](https://github.com/flutter/devtools/pull/6567)
 
 ## Memory updates
 
diff --git a/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart b/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart
index 120df12..adc6e25 100644
--- a/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart
+++ b/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart
@@ -7,6 +7,7 @@
 import 'package:devtools_app/src/screens/profiler/panes/controls/cpu_profiler_controls.dart';
 import 'package:devtools_app/src/screens/profiler/profiler_status.dart';
 import 'package:devtools_app/src/service/vm_flags.dart' as vm_flags;
+import 'package:devtools_app/src/shared/file_import.dart';
 import 'package:devtools_app/src/shared/ui/vm_flag_widgets.dart';
 import 'package:devtools_test/devtools_test.dart';
 import 'package:flutter/material.dart';
@@ -38,6 +39,7 @@
         expect(find.text('Profile app start up'), findsOneWidget);
       }
       expect(find.byType(CpuSamplingRateDropdown), findsOneWidget);
+      expect(find.byType(OpenSaveButtonGroup), findsOneWidget);
       expect(
         find.byType(ProfileRecordingInstructions),
         findsOneWidget,
@@ -144,6 +146,7 @@
         expect(find.byType(StopRecordingButton), findsNothing);
         expect(find.byType(ClearButton), findsNothing);
         expect(find.byType(CpuSamplingRateDropdown), findsNothing);
+        expect(find.byType(OpenSaveButtonGroup), findsNothing);
 
         await tester.runAsync(() async {
           await tester.tap(find.text('Enable profiler'));
diff --git a/packages/devtools_app/test/performance/controls/performance_controls_test.dart b/packages/devtools_app/test/performance/controls/performance_controls_test.dart
index f7fa2b5..ff7b261 100644
--- a/packages/devtools_app/test/performance/controls/performance_controls_test.dart
+++ b/packages/devtools_app/test/performance/controls/performance_controls_test.dart
@@ -4,6 +4,7 @@
 
 import 'package:devtools_app/devtools_app.dart';
 import 'package:devtools_app/src/screens/performance/panes/controls/performance_controls.dart';
+import 'package:devtools_app/src/shared/file_import.dart';
 import 'package:devtools_app_shared/ui.dart';
 import 'package:devtools_app_shared/utils.dart';
 import 'package:devtools_test/devtools_test.dart';
@@ -76,7 +77,7 @@
         expect(find.text('Performance Overlay'), findsOneWidget);
         expect(find.text('Enhance Tracing'), findsOneWidget);
         expect(find.text('More debugging options'), findsOneWidget);
-        expect(find.byIcon(Icons.file_download), findsOneWidget);
+        expect(find.byType(OpenSaveButtonGroup), findsOneWidget);
         expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
       },
     );
@@ -99,7 +100,7 @@
         expect(find.text('Performance Overlay'), findsNothing);
         expect(find.text('Enhance Tracing'), findsNothing);
         expect(find.text('More debugging options'), findsNothing);
-        expect(find.byIcon(Icons.file_download), findsOneWidget);
+        expect(find.byType(OpenSaveButtonGroup), findsOneWidget);
         expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
       },
     );
@@ -118,7 +119,7 @@
         expect(find.text('Performance Overlay'), findsNothing);
         expect(find.text('Enhance Tracing'), findsNothing);
         expect(find.text('More debugging options'), findsNothing);
-        expect(find.byIcon(Icons.file_download), findsNothing);
+        expect(find.byType(OpenSaveButtonGroup), findsNothing);
         expect(find.byIcon(Icons.settings_outlined), findsNothing);
         offlineController.exitOfflineMode();
       },
diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md
index bb98535..476d802 100644
--- a/packages/devtools_app_shared/CHANGELOG.md
+++ b/packages/devtools_app_shared/CHANGELOG.md
@@ -1,3 +1,6 @@
+## 0.0.7
+* Add `RoundedButtonGroup` common widget.
+
 ## 0.0.6
 * Add `profilePlatformChannels` to known service extensions.
 * Fix a bug where service extension states were not getting cleared on app disconnect.
diff --git a/packages/devtools_app_shared/lib/src/ui/common.dart b/packages/devtools_app_shared/lib/src/ui/common.dart
index 83e45f2..adb1b97 100644
--- a/packages/devtools_app_shared/lib/src/ui/common.dart
+++ b/packages/devtools_app_shared/lib/src/ui/common.dart
@@ -566,6 +566,117 @@
   }
 }
 
+/// A group of buttons that share a common border.
+/// 
+/// This widget ensures the buttons are displayed with proper borders on the
+/// interior and exterior of the group. The attirbutes for each button can be
+/// defined by [ButtonGroupItemData] and included in [items].
+final class RoundedButtonGroup extends StatelessWidget {
+  const RoundedButtonGroup({
+    super.key,
+    required this.items,
+    this.minScreenWidthForTextBeforeScaling,
+  });
+
+  final List<ButtonGroupItemData> items;
+  final double? minScreenWidthForTextBeforeScaling;
+
+  @override
+  Widget build(BuildContext context) {
+    Widget buildButton(int index) {
+      final itemData = items[index];
+      Widget button = _ButtonGroupButton(
+        buttonData: itemData,
+        roundedLeftBorder: index == 0,
+        roundedRightBorder: index == items.length - 1,
+        minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling,
+      );
+      if (index != 0) {
+        button = Container(
+          decoration: BoxDecoration(
+            border: Border(
+              left: BorderSide(
+                color: Theme.of(context).focusColor,
+              ),
+            ),
+          ),
+          child: button,
+        );
+      }
+      return button;
+    }
+
+    return SizedBox(
+      height: defaultButtonHeight,
+      child: RoundedOutlinedBorder(
+        child: Row(
+          children: [
+            for (int i = 0; i < items.length; i++) buildButton(i),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+final class _ButtonGroupButton extends StatelessWidget {
+  const _ButtonGroupButton({
+    required this.buttonData,
+    this.roundedLeftBorder = false,
+    this.roundedRightBorder = false,
+    this.minScreenWidthForTextBeforeScaling,
+  });
+
+  final ButtonGroupItemData buttonData;
+  final bool roundedLeftBorder;
+  final bool roundedRightBorder;
+  final double? minScreenWidthForTextBeforeScaling;
+
+  @override
+  Widget build(BuildContext context) {
+    return DevToolsTooltip(
+      message: buttonData.tooltip,
+      child: OutlinedButton(
+        autofocus: buttonData.autofocus,
+        style: OutlinedButton.styleFrom(
+          padding: const EdgeInsets.symmetric(horizontal: densePadding),
+          side: BorderSide.none,
+          shape: RoundedRectangleBorder(
+            borderRadius: BorderRadius.horizontal(
+              left: roundedLeftBorder ? defaultRadius : Radius.zero,
+              right: roundedRightBorder ? defaultRadius : Radius.zero,
+            ),
+          ),
+        ),
+        onPressed: buttonData.onPressed,
+        child: MaterialIconLabel(
+          label: buttonData.label,
+          iconData: buttonData.icon,
+          minScreenWidthForTextBeforeScaling:
+              minScreenWidthForTextBeforeScaling,
+        ),
+      ),
+    );
+  }
+}
+
+final class ButtonGroupItemData {
+  const ButtonGroupItemData({
+    this.label,
+    this.icon,
+    String? tooltip,
+    this.onPressed,
+    this.autofocus = false,
+  })  : tooltip = tooltip ?? label,
+        assert(label != null || icon != null);
+
+  final String? label;
+  final IconData? icon;
+  final String? tooltip;
+  final VoidCallback? onPressed;
+  final bool autofocus;
+}
+
 final class DevToolsFilterButton extends StatelessWidget {
   const DevToolsFilterButton({
     Key? key,
diff --git a/packages/devtools_app_shared/pubspec.yaml b/packages/devtools_app_shared/pubspec.yaml
index 99a86f7..16ad65d 100644
--- a/packages/devtools_app_shared/pubspec.yaml
+++ b/packages/devtools_app_shared/pubspec.yaml
@@ -1,6 +1,6 @@
 name: devtools_app_shared
 description: Package of Dart & Flutter structures shared between devtools_app and devtools extensions.
-version: 0.0.6
+version: 0.0.7
 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_extensions
 
 environment: