Add pin rows to Profile Memory table (#9832)

* Add pin rows to Profile Memory table

* Update packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix issues

* Tighten pin column and show icon on hover

* 32->10

* fix pin hover test

* update img

* Remove unused pin helpers.

* Update profile tab golden.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart
index b1c412b..36f7ec0 100644
--- a/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart
+++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart
@@ -34,13 +34,21 @@
   factory AdaptedProfile.fromAllocationProfile(
     AllocationProfile profile,
     ClassFilter filter,
-    String? rootPackage,
-  ) {
+    String? rootPackage, {
+    Set<String> pinnedClassFullNames = const {},
+  }) {
     final adaptedProfile = AdaptedProfile._(
       total: ProfileRecord.total(profile),
       items: (profile.members ?? [])
           .where((e) => (e.instancesCurrent ?? 0) > 0)
-          .map((e) => ProfileRecord.fromClassHeapStats(e))
+          .map(
+            (e) => ProfileRecord.fromClassHeapStats(
+              e,
+              userPinned: pinnedClassFullNames.contains(
+                HeapClassName.fromClassRef(e.classRef).fullName,
+              ),
+            ),
+          )
           .toList(),
       newSpaceGCStats: profile.newSpaceGCStats,
       oldSpaceGCStats: profile.oldSpaceGCStats,
@@ -70,6 +78,31 @@
     );
   }
 
+  /// Returns a copy of [profile] with [pinnedClassFullNames] applied to items.
+  factory AdaptedProfile.withPinnedClasses(
+    AdaptedProfile profile,
+    Set<String> pinnedClassFullNames,
+    String? rootPackage,
+  ) {
+    final itemsWithPins = profile._items
+        .map(
+          (record) => record.copyWith(
+            userPinned: pinnedClassFullNames.contains(
+              record.heapClass.fullName,
+            ),
+          ),
+        )
+        .toList();
+    final updated = AdaptedProfile._(
+      total: profile._total,
+      items: itemsWithPins,
+      newSpaceGCStats: profile.newSpaceGCStats,
+      oldSpaceGCStats: profile.oldSpaceGCStats,
+      totalGCStats: profile.totalGCStats,
+    );
+    return AdaptedProfile.withNewFilter(updated, profile.filter, rootPackage);
+  }
+
   factory AdaptedProfile.fromJson(Map<String, dynamic> json) {
     return AdaptedProfile._(
       total: ProfileRecord.fromJson(json[_ProfileJson.total]),
@@ -117,6 +150,7 @@
 class _RecordJson {
   static const isTotal = 'it';
   static const heapClass = 'c';
+  static const userPinned = 'p';
   static const totalInstances = 'ti';
   static const totalSize = 'ts';
   static const totalDartHeapSize = 'tds';
@@ -135,6 +169,7 @@
   ProfileRecord._({
     required this.isTotal,
     required this.heapClass,
+    this.userPinned = false,
     required this.totalInstances,
     required this.totalSize,
     required this.totalDartHeapSize,
@@ -151,7 +186,10 @@
     _verifyIntegrity();
   }
 
-  factory ProfileRecord.fromClassHeapStats(ClassHeapStats stats) {
+  factory ProfileRecord.fromClassHeapStats(
+    ClassHeapStats stats, {
+    bool userPinned = false,
+  }) {
     assert(
       stats.bytesCurrent! == stats.newSpace.size + stats.oldSpace.size,
       '${stats.bytesCurrent}, ${stats.newSpace.size}, ${stats.oldSpace.size}',
@@ -159,6 +197,7 @@
     return ProfileRecord._(
       isTotal: false,
       heapClass: HeapClassName.fromClassRef(stats.classRef),
+      userPinned: userPinned,
       totalInstances: stats.instancesCurrent ?? 0,
       totalSize:
           stats.bytesCurrent! +
@@ -180,6 +219,7 @@
 
   ProfileRecord.total(AllocationProfile profile)
     : isTotal = true,
+      userPinned = false,
       heapClass = HeapClassName.fromPath(className: 'All Classes', library: ''),
       totalInstances = null,
       totalSize =
@@ -202,6 +242,7 @@
     return ProfileRecord._(
       isTotal: json[_RecordJson.isTotal] as bool,
       heapClass: HeapClassName.fromJson(json[_RecordJson.heapClass]),
+      userPinned: json[_RecordJson.userPinned] as bool? ?? false,
       totalInstances: json[_RecordJson.totalInstances] as int?,
       totalSize: json[_RecordJson.totalSize] as int,
       totalDartHeapSize: json[_RecordJson.totalDartHeapSize] as int,
@@ -217,11 +258,32 @@
     );
   }
 
+  ProfileRecord copyWith({bool? userPinned}) {
+    return ProfileRecord._(
+      isTotal: isTotal,
+      heapClass: heapClass,
+      userPinned: userPinned ?? this.userPinned,
+      totalInstances: totalInstances,
+      totalSize: totalSize,
+      totalDartHeapSize: totalDartHeapSize,
+      totalExternalSize: totalExternalSize,
+      newSpaceInstances: newSpaceInstances,
+      newSpaceSize: newSpaceSize,
+      newSpaceDartHeapSize: newSpaceDartHeapSize,
+      newSpaceExternalSize: newSpaceExternalSize,
+      oldSpaceInstances: oldSpaceInstances,
+      oldSpaceSize: oldSpaceSize,
+      oldSpaceDartHeapSize: oldSpaceDartHeapSize,
+      oldSpaceExternalSize: oldSpaceExternalSize,
+    );
+  }
+
   @override
   Map<String, dynamic> toJson() {
     return {
       _RecordJson.isTotal: isTotal,
       _RecordJson.heapClass: heapClass,
+      _RecordJson.userPinned: userPinned,
       _RecordJson.totalInstances: totalInstances,
       _RecordJson.totalSize: totalSize,
       _RecordJson.totalDartHeapSize: totalDartHeapSize,
@@ -239,6 +301,8 @@
 
   final bool isTotal;
 
+  final bool userPinned;
+
   final HeapClassName heapClass;
 
   final int? totalInstances;
@@ -257,7 +321,7 @@
   final int? oldSpaceExternalSize;
 
   @override
-  bool get pinToTop => isTotal;
+  bool get pinToTop => isTotal || userPinned;
 
   void _verifyIntegrity() {
     assert(() {
diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
index d469c23..0b5007e 100644
--- a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
+++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
@@ -11,20 +11,28 @@
 
 import '../../../../shared/config_specific/import_export/import_export.dart';
 import '../../../../shared/globals.dart';
+import '../../../../shared/memory/class_name.dart';
 import '../../shared/heap/class_filter.dart';
 import 'model.dart';
 
 @visibleForTesting
-enum Json { profile, rootPackage }
+enum Json { profile, rootPackage, pinnedClasses }
 
 class ProfilePaneController extends DisposableController
     with AutoDisposeControllerMixin, Serializable {
-  ProfilePaneController({required this.rootPackage, AdaptedProfile? profile}) {
+  ProfilePaneController({
+    required this.rootPackage,
+    AdaptedProfile? profile,
+    Set<String>? pinnedClassFullNames,
+  }) {
+    if (pinnedClassFullNames != null) {
+      _pinnedClassFullNames.addAll(pinnedClassFullNames);
+    }
     // [profile] should only be non-null when loading offline data.
     if (profile != null) {
-      _currentAllocationProfile.value = AdaptedProfile.withNewFilter(
-        profile,
-        classFilter.value,
+      _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses(
+        AdaptedProfile.withNewFilter(profile, classFilter.value, rootPackage),
+        _pinnedClassFullNames,
         rootPackage,
       );
     }
@@ -34,6 +42,9 @@
     return ProfilePaneController(
       profile: deserialize(json[Json.profile.name], AdaptedProfile.fromJson),
       rootPackage: json[Json.rootPackage.name],
+      pinnedClassFullNames: (json[Json.pinnedClasses.name] as List?)
+          ?.cast<String>()
+          .toSet(),
     );
   }
 
@@ -42,11 +53,35 @@
     return {
       Json.profile.name: _currentAllocationProfile.value,
       Json.rootPackage.name: rootPackage,
+      Json.pinnedClasses.name: _pinnedClassFullNames.toList(),
     };
   }
 
   bool _initialized = false;
 
+  /// Classes pinned to the top of the Profile Memory table.
+  final _pinnedClassFullNames = <String>{};
+
+  void togglePin(HeapClassName heapClass) {
+    final key = heapClass.fullName;
+    if (_pinnedClassFullNames.contains(key)) {
+      _pinnedClassFullNames.remove(key);
+    } else {
+      _pinnedClassFullNames.add(key);
+    }
+    _reapplyPinnedState();
+  }
+
+  void _reapplyPinnedState() {
+    final currentProfile = _currentAllocationProfile.value;
+    if (currentProfile == null) return;
+    _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses(
+      currentProfile,
+      _pinnedClassFullNames,
+      rootPackage,
+    );
+  }
+
   /// Initializes the controller if it is not initialized yet.
   @override
   void init() {
@@ -84,6 +119,7 @@
       profile,
       classFilter.value,
       rootPackage,
+      pinnedClassFullNames: _pinnedClassFullNames,
     );
     _initializeSelection();
   }
@@ -105,9 +141,13 @@
     _classFilter.value = filter;
     final currentProfile = _currentAllocationProfile.value;
     if (currentProfile == null) return;
-    _currentAllocationProfile.value = AdaptedProfile.withNewFilter(
-      currentProfile,
-      classFilter.value,
+    _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses(
+      AdaptedProfile.withNewFilter(
+        currentProfile,
+        classFilter.value,
+        rootPackage,
+      ),
+      _pinnedClassFullNames,
       rootPackage,
     );
   }
diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
index 7325b42..ed4637b 100644
--- a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
+++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
@@ -32,12 +32,97 @@
 /// instances, memory).
 const _defaultNumberFieldWidth = 80.0;
 
+@visibleForTesting
+const allocationProfilePinButtonKey = Key('allocation-profile-pin-button');
+
+class _PinColumn extends ColumnData<ProfileRecord>
+    implements ColumnRenderer<ProfileRecord> {
+  _PinColumn({required this.controller})
+    : super(
+        '',
+        titleTooltip: 'Pin class to the top of the table',
+        fixedWidthPx: 10.0,
+        alignment: ColumnAlignment.center,
+      );
+
+  final ProfilePaneController controller;
+
+  @override
+  bool get supportsSorting => false;
+
+  @override
+  Widget build(
+    BuildContext context,
+    ProfileRecord item, {
+    bool isRowSelected = false,
+    bool isRowHovered = false,
+  }) {
+    if (item.isTotal) return const SizedBox.shrink();
+
+    final pinned = item.userPinned;
+    return _HoverPinButton(
+      pinned: pinned,
+      onPressed: () {
+        ga.select(
+          gac.memory,
+          '${gac.MemoryEvents.profilePinClass.name}-$pinned',
+        );
+        controller.togglePin(item.heapClass);
+      },
+    );
+  }
+
+  @override
+  bool? getValue(ProfileRecord _) => null;
+
+  @override
+  int compare(ProfileRecord a, ProfileRecord b) =>
+      a.userPinned.boolCompare(b.userPinned);
+}
+
+class _HoverPinButton extends StatefulWidget {
+  const _HoverPinButton({required this.pinned, required this.onPressed});
+
+  final bool pinned;
+  final VoidCallback onPressed;
+
+  @override
+  State<_HoverPinButton> createState() => _HoverPinButtonState();
+}
+
+class _HoverPinButtonState extends State<_HoverPinButton> {
+  bool _hovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    final showIcon = widget.pinned || _hovering;
+
+    return MouseRegion(
+      key: allocationProfilePinButtonKey,
+      onEnter: (_) => setState(() => _hovering = true),
+      onExit: (_) => setState(() => _hovering = false),
+      child: showIcon
+          ? IconButton(
+              visualDensity: VisualDensity.compact,
+              padding: EdgeInsets.zero,
+              constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
+              iconSize: 18,
+              icon: Icon(
+                widget.pinned ? Icons.push_pin : Icons.push_pin_outlined,
+              ),
+              tooltip: widget.pinned ? 'Unpin class' : 'Pin class to top',
+              onPressed: widget.onPressed,
+            )
+          : const SizedBox(width: 32, height: 32),
+    );
+  }
+}
+
 class _FieldClassNameColumn extends ColumnData<ProfileRecord>
     implements
         ColumnRenderer<ProfileRecord>,
         ColumnHeaderRenderer<ProfileRecord> {
-  const _FieldClassNameColumn(this.classFilterData)
-    : super('Class', fixedWidthPx: 200);
+  const _FieldClassNameColumn(this.classFilterData) : super.wide('Class');
 
   @override
   String? getValue(ProfileRecord dataObject) => dataObject.heapClass.className;
@@ -479,40 +564,39 @@
   }
 }
 
-class _AllocationProfileTable extends StatelessWidget {
-  _AllocationProfileTable({required this.controller});
+class _AllocationProfileTable extends StatefulWidget {
+  const _AllocationProfileTable({required this.controller});
 
+  final ProfilePaneController controller;
+
+  @override
+  State<_AllocationProfileTable> createState() =>
+      _AllocationProfileTableState();
+}
+
+class _AllocationProfileTableState extends State<_AllocationProfileTable> {
   /// List of columns displayed in advanced developer mode state.
   static final _vmModeColumnGroups = [
     ColumnGroup.fromText(title: '', range: const Range(0, 1)),
+    ColumnGroup.fromText(title: '', range: const Range(1, 2)),
     ColumnGroup.fromText(
       title: HeapGeneration.total.toString(),
-      range: const Range(1, 5),
+      range: const Range(2, 6),
     ),
     ColumnGroup.fromText(
       title: HeapGeneration.newSpace.toString(),
-      range: const Range(5, 9),
+      range: const Range(6, 10),
     ),
     ColumnGroup.fromText(
       title: HeapGeneration.oldSpace.toString(),
-      range: const Range(9, 13),
+      range: const Range(10, 14),
     ),
   ];
 
   static const _fieldSizeColumn = _FieldSizeColumn(heap: HeapGeneration.total);
 
-  late final _columns = <ColumnData<ProfileRecord>>[
-    _FieldClassNameColumn(
-      ClassFilterData(
-        filter: controller.classFilter,
-        onChanged: controller.setFilter,
-        rootPackage: controller.rootPackage,
-      ),
-    ),
-    const _FieldInstanceCountColumn(heap: HeapGeneration.total),
-    _fieldSizeColumn,
-    _FieldDartHeapSizeColumn(heap: HeapGeneration.total),
-  ];
+  late _PinColumn _pinColumn;
+  late List<ColumnData<ProfileRecord>> _columns;
 
   late final _advancedDeveloperModeColumns = [
     const _FieldExternalSizeColumn(heap: HeapGeneration.total),
@@ -526,12 +610,41 @@
     const _FieldExternalSizeColumn(heap: HeapGeneration.oldSpace),
   ];
 
-  final ProfilePaneController controller;
+  @override
+  void initState() {
+    super.initState();
+    _initColumns();
+  }
+
+  @override
+  void didUpdateWidget(covariant _AllocationProfileTable oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (oldWidget.controller != widget.controller) {
+      _initColumns();
+    }
+  }
+
+  void _initColumns() {
+    _pinColumn = _PinColumn(controller: widget.controller);
+    _columns = <ColumnData<ProfileRecord>>[
+      _pinColumn,
+      _FieldClassNameColumn(
+        ClassFilterData(
+          filter: widget.controller.classFilter,
+          onChanged: widget.controller.setFilter,
+          rootPackage: widget.controller.rootPackage,
+        ),
+      ),
+      const _FieldInstanceCountColumn(heap: HeapGeneration.total),
+      _fieldSizeColumn,
+      _FieldDartHeapSizeColumn(heap: HeapGeneration.total),
+    ];
+  }
 
   @override
   Widget build(BuildContext context) {
     return ValueListenableBuilder<AdaptedProfile?>(
-      valueListenable: controller.currentAllocationProfile,
+      valueListenable: widget.controller.currentAllocationProfile,
       builder: (context, profile, _) {
         // TODO(bkonyi): make this an overlay so the table doesn't
         // disappear when we're retrieving new data, especially since the
@@ -547,18 +660,18 @@
               data: profile.records,
               dataKey: 'allocation-profile',
               columnGroups: advancedDeveloperModeEnabled
-                  ? _AllocationProfileTable._vmModeColumnGroups
+                  ? _AllocationProfileTableState._vmModeColumnGroups
                   : null,
               columns: [
                 ..._columns,
                 if (advancedDeveloperModeEnabled)
                   ..._advancedDeveloperModeColumns,
               ],
-              defaultSortColumn: _AllocationProfileTable._fieldSizeColumn,
+              defaultSortColumn: _AllocationProfileTableState._fieldSizeColumn,
               defaultSortDirection: SortDirection.descending,
               pinBehavior: FlatTablePinBehavior.pinOriginalToTop,
               includeColumnGroupHeaders: false,
-              selectionNotifier: controller.selection,
+              selectionNotifier: widget.controller.selection,
             );
           },
         );
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart
index 6549ec6..bce8a49 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart
@@ -53,6 +53,7 @@
   profileHelp,
   profileRefreshManual,
   profileRefreshOnGc,
+  profilePinClass,
 
   // 'Tracing' tab events
   tracingClear,
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index dedd867..ee62970 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -35,7 +35,7 @@
 
 ## Memory updates
 
-TODO: Remove this section if there are not any updates.
+* Added the ability to pin classes to the top of the Profile Memory table. [#8898](https://github.com/flutter/devtools/issues/8898)
 
 ## Debugger updates
 
diff --git a/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart b/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart
index c3824f0..72c0f19 100644
--- a/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart
+++ b/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart
@@ -5,6 +5,7 @@
 import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart';
 import 'package:devtools_app/src/screens/memory/panes/profile/model.dart';
 import 'package:devtools_app/src/screens/memory/panes/profile/profile_pane_controller.dart';
+import 'package:devtools_app/src/screens/memory/panes/profile/profile_view.dart';
 import 'package:devtools_app/src/shared/globals.dart';
 import 'package:devtools_app/src/shared/memory/gc_stats.dart';
 import 'package:devtools_app/src/shared/primitives/byte_utils.dart';
@@ -12,6 +13,7 @@
 import 'package:devtools_app/src/shared/table/table.dart';
 import 'package:devtools_test/devtools_test.dart';
 import 'package:devtools_test/helpers.dart';
+import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 
@@ -319,5 +321,57 @@
         lastValue = internalSize;
       }
     });
+
+    testWidgetsWithWindowSize('pins class to top of table', windowSize, (
+      WidgetTester tester,
+    ) async {
+      await scene.pump(tester);
+
+      final allocationProfileController = scene.controller.profile!;
+      await navigateToAllocationProfile(tester, allocationProfileController);
+
+      final table = find.byType(FlatTable<ProfileRecord>);
+      expect(table, findsOneWidget);
+
+      final state = tester.state<FlatTableState<ProfileRecord>>(table.first);
+
+      // "All Classes" is always pinned via [ProfileRecord.isTotal].
+      expect(state.tableController.pinnedData, isNotEmpty);
+      expect(
+        state.tableController.pinnedData.every((record) => !record.userPinned),
+        isTrue,
+      );
+
+      // Pin icons are hidden until the cell is hovered.
+      final pinTargets = find.byKey(allocationProfilePinButtonKey);
+      expect(pinTargets, findsWidgets);
+
+      final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+      await gesture.addPointer(location: Offset.zero);
+      addTearDown(gesture.removePointer);
+      await tester.pump();
+
+      await gesture.moveTo(tester.getCenter(pinTargets.first));
+      await tester.pumpAndSettle();
+
+      final pinButtons = find.byIcon(Icons.push_pin_outlined);
+      expect(pinButtons, findsWidgets);
+
+      await tester.tap(pinButtons.first);
+      await tester.pumpAndSettle();
+
+      expect(find.byIcon(Icons.push_pin), findsWidgets);
+      expect(
+        state.tableController.pinnedData.any((record) => record.userPinned),
+        isTrue,
+      );
+
+      // Pinned class stays at the top after sorting by class name.
+      await tester.tap(find.text('Class'));
+      await tester.pumpAndSettle();
+
+      final pinnedData = state.tableController.pinnedData;
+      expect(pinnedData.any((record) => record.userPinned), isTrue);
+    });
   });
 }
diff --git a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
index 1a01c25..112c1d0 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
Binary files differ