Add support for searching within the log details view (#9712)
diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart
index ddfa746..5917216 100644
--- a/packages/devtools_app/lib/devtools_app.dart
+++ b/packages/devtools_app/lib/devtools_app.dart
@@ -29,6 +29,7 @@
 export 'src/screens/inspector/inspector_tree_controller.dart';
 export 'src/screens/inspector_shared/inspector_screen.dart';
 export 'src/screens/inspector_shared/inspector_screen_controller.dart';
+export 'src/screens/logging/log_details_controller.dart';
 export 'src/screens/logging/logging_controller.dart';
 export 'src/screens/logging/logging_screen.dart';
 export 'src/screens/memory/framework/memory_controller.dart';
diff --git a/packages/devtools_app/lib/src/screens/debugger/codeview.dart b/packages/devtools_app/lib/src/screens/debugger/codeview.dart
index 19ca7e4..d5f1ebd 100644
--- a/packages/devtools_app/lib/src/screens/debugger/codeview.dart
+++ b/packages/devtools_app/lib/src/screens/debugger/codeview.dart
@@ -26,6 +26,7 @@
 import '../../shared/ui/history_viewport.dart';
 import '../../shared/ui/hover.dart';
 import '../../shared/ui/search.dart';
+import '../../shared/ui/search_highlighter.dart';
 import '../../shared/ui/utils.dart';
 import '../vm_developer/vm_service_private_extensions.dart';
 import 'breakpoints.dart';
@@ -1272,131 +1273,22 @@
     return null;
   }
 
-  List<InlineSpan> _contentsWithMatch(
-    List<InlineSpan> startingContents,
-    SourceToken match,
-    Color matchColor, {
-    required BuildContext context,
-  }) {
-    final contentsWithMatch = <InlineSpan>[];
-    var startColumnForSpan = 0;
-    for (final span in startingContents) {
-      final spanText = span.toPlainText();
-      final startColumnForMatch = match.position.column!;
-      if (startColumnForSpan <= startColumnForMatch &&
-          startColumnForSpan + spanText.length > startColumnForMatch) {
-        // The active search is part of this [span].
-        final matchStartInSpan = startColumnForMatch - startColumnForSpan;
-        final matchEndInSpan = matchStartInSpan + match.length;
-
-        // Add the part of [span] that occurs before the search match.
-        contentsWithMatch.add(
-          TextSpan(
-            text: spanText.substring(0, matchStartInSpan),
-            style: span.style,
-          ),
-        );
-
-        final matchStyle = (span.style ?? DefaultTextStyle.of(context).style)
-            .copyWith(color: Colors.black, backgroundColor: matchColor);
-
-        if (matchEndInSpan <= spanText.length) {
-          final matchText = spanText.substring(
-            matchStartInSpan,
-            matchEndInSpan,
-          );
-          final trailingText = spanText.substring(matchEndInSpan);
-          // Add the match and any part of [span] that occurs after the search
-          // match.
-          contentsWithMatch.addAll([
-            TextSpan(text: matchText, style: matchStyle),
-            if (trailingText.isNotEmpty)
-              TextSpan(
-                text: spanText.substring(matchEndInSpan),
-                style: span.style,
-              ),
-          ]);
-        } else {
-          // In this case, the active search match exists across multiple spans,
-          // so we need to add the part of the match that is in this [span] and
-          // continue looking for the remaining part of the match in the spans
-          // to follow.
-          contentsWithMatch.add(
-            TextSpan(
-              text: spanText.substring(matchStartInSpan),
-              style: matchStyle,
-            ),
-          );
-          final remainingMatchLength =
-              match.length - (spanText.length - matchStartInSpan);
-          match = SourceToken(
-            position: SourcePosition(
-              line: match.position.line,
-              column: startColumnForMatch + match.length - remainingMatchLength,
-            ),
-            length: remainingMatchLength,
-          );
-        }
-      } else {
-        contentsWithMatch.add(span);
-      }
-      startColumnForSpan += spanText.length;
-    }
-    return contentsWithMatch;
-  }
-
   TextSpan searchAwareLineContents(BuildContext context) {
     // If syntax highlighting is disabled for the script, then
     // `lineContents` is simply a `TextSpan` with no children.
     final lineContentsSpans = lineContents.children ?? [lineContents];
-    final activeSearchAwareContents = _activeSearchAwareLineContents(
-      lineContentsSpans,
-      context: context,
-    );
-    final allSearchAwareContents = _searchMatchAwareLineContents(
-      activeSearchAwareContents!,
-      context: context,
-    );
+    final theme = Theme.of(context);
+
     return TextSpan(
-      children: allSearchAwareContents,
+      children: SearchHighlighter.highlightSpans(
+        lineContentsSpans.cast<TextSpan>(),
+        matches: searchMatches?.map((m) => m.range).toList() ?? [],
+        activeMatch: activeSearchMatch?.range,
+        style: theme.regularTextStyle,
+      ),
       style: lineContents.style,
     );
   }
-
-  List<InlineSpan>? _activeSearchAwareLineContents(
-    List<InlineSpan> startingContents, {
-    required BuildContext context,
-  }) {
-    final match = activeSearchMatch;
-    if (match == null) return startingContents;
-    return _contentsWithMatch(
-      startingContents,
-      match,
-      activeSearchMatchColor,
-      context: context,
-    );
-  }
-
-  List<InlineSpan> _searchMatchAwareLineContents(
-    List<InlineSpan> startingContents, {
-    required BuildContext context,
-  }) {
-    final matches = searchMatches;
-    if (matches == null || matches.isEmpty) return startingContents;
-    final searchMatchesToFind = List<SourceToken>.of(matches)
-      ..remove(activeSearchMatch);
-
-    var contentsWithMatch = startingContents;
-    for (final match in searchMatchesToFind) {
-      contentsWithMatch = _contentsWithMatch(
-        contentsWithMatch,
-        match,
-        searchMatchColor,
-        context: context,
-      );
-    }
-    return contentsWithMatch;
-  }
 }
 
 class ScriptPopupMenu extends StatelessWidget {
diff --git a/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart b/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart
index 12affb7..f69e302 100644
--- a/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart
+++ b/packages/devtools_app/lib/src/screens/debugger/debugger_model.dart
@@ -52,6 +52,8 @@
 
   final int length;
 
+  Range get range => Range(position.column!, position.column! + length);
+
   @override
   String toString() {
     return '$position-${position.column! + length}';
diff --git a/packages/devtools_app/lib/src/screens/logging/_log_details.dart b/packages/devtools_app/lib/src/screens/logging/_log_details.dart
index 977af41..f277c54 100644
--- a/packages/devtools_app/lib/src/screens/logging/_log_details.dart
+++ b/packages/devtools_app/lib/src/screens/logging/_log_details.dart
@@ -11,12 +11,16 @@
 import '../../shared/globals.dart';
 import '../../shared/preferences/preferences.dart';
 import '../../shared/ui/common_widgets.dart';
+import '../../shared/ui/search.dart';
+import '../../shared/ui/search_highlighter.dart';
+import 'log_details_controller.dart';
 import 'logging_controller.dart';
 
 class LogDetails extends StatefulWidget {
-  const LogDetails({super.key, required this.log});
+  const LogDetails({super.key, required this.log, required this.controller});
 
   final LogData? log;
+  final LogDetailsController controller;
 
   @override
   State<LogDetails> createState() => _LogDetailsState();
@@ -45,6 +49,10 @@
     if (widget.log != oldWidget.log) {
       unawaited(_computeLogDetails());
     }
+    if (widget.controller != oldWidget.controller) {
+      cancelListeners();
+      addAutoDisposeListener(preferences.logging.detailsFormat);
+    }
   }
 
   Future<void> _computeLogDetails() async {
@@ -81,6 +89,7 @@
       header: _LogDetailsHeader(
         log: log,
         format: preferences.logging.detailsFormat.value,
+        controller: widget.controller,
       ),
       child: Scrollbar(
         controller: scrollController,
@@ -93,9 +102,9 @@
               ? Padding(
                   padding: const EdgeInsets.all(denseSpacing),
                   child: SelectionArea(
-                    child: Text(
-                      log?.prettyPrinted() ?? '',
-                      textAlign: TextAlign.left,
+                    child: _SearchableLogDetailsText(
+                      text: log?.prettyPrinted() ?? '',
+                      controller: widget.controller,
                     ),
                   ),
                 )
@@ -107,10 +116,15 @@
 }
 
 class _LogDetailsHeader extends StatelessWidget {
-  const _LogDetailsHeader({required this.log, required this.format});
+  const _LogDetailsHeader({
+    required this.log,
+    required this.format,
+    required this.controller,
+  });
 
   final LogData? log;
   final LoggingDetailsFormat format;
+  final LogDetailsController controller;
 
   @override
   Widget build(BuildContext context) {
@@ -122,7 +136,13 @@
       title: const Text('Details'),
       includeTopBorder: false,
       roundedTopBorder: false,
+      tall: true,
       actions: [
+        // Only supporting search for the text format now since supporting this
+        // for the expandable JSON viewer would require a more complicated
+        // refactor of that shared component.
+        if (format == LoggingDetailsFormat.text)
+          _LogDetailsSearchField(controller: controller, log: log),
         LogDetailsFormatButton(format: format),
         const SizedBox(width: densePadding),
         CopyToClipboardControl(
@@ -134,6 +154,108 @@
   }
 }
 
+/// An animated search field for the log details view that toggles between an icon
+/// and a full [SearchField].
+class _LogDetailsSearchField extends StatefulWidget {
+  const _LogDetailsSearchField({required this.controller, required this.log});
+
+  final LogDetailsController controller;
+  final LogData? log;
+
+  @override
+  State<_LogDetailsSearchField> createState() => _LogDetailsSearchFieldState();
+}
+
+class _LogDetailsSearchFieldState extends State<_LogDetailsSearchField>
+    with AutoDisposeMixin {
+  late bool _isExpanded;
+
+  @override
+  void initState() {
+    super.initState();
+    _isExpanded = widget.controller.search.isNotEmpty;
+    addAutoDisposeListener(widget.controller.searchFieldFocusNode, () {
+      final hasFocus =
+          widget.controller.searchFieldFocusNode?.hasFocus ?? false;
+      if (hasFocus != _isExpanded) {
+        setState(() {
+          _isExpanded = hasFocus;
+        });
+      }
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedContainer(
+      duration: defaultDuration,
+      curve: defaultCurve,
+      width: _isExpanded ? mediumSearchFieldWidth : defaultButtonHeight,
+      child: OverflowBox(
+        minWidth: 0.0,
+        maxWidth: mediumSearchFieldWidth,
+        child: _isExpanded
+            ? Padding(
+                padding: const EdgeInsets.symmetric(horizontal: densePadding),
+                child: SearchField<LogDetailsController>(
+                  searchController: widget.controller,
+                  searchFieldEnabled:
+                      widget.log != null && widget.log!.details != null,
+                  shouldRequestFocus: true,
+                  searchFieldWidth: mediumSearchFieldWidth,
+                ),
+              )
+            : ToolbarAction(
+                icon: Icons.search,
+                tooltip: 'Search details',
+                size: defaultIconSize,
+                onPressed: () {
+                  setState(() {
+                    _isExpanded = true;
+                  });
+                  widget.controller.searchFieldFocusNode?.requestFocus();
+                },
+              ),
+      ),
+    );
+  }
+}
+
+/// A text widget for the log details view that highlights search matches.
+class _SearchableLogDetailsText extends StatelessWidget {
+  const _SearchableLogDetailsText({
+    required this.text,
+    required this.controller,
+  });
+
+  final String text;
+  final LogDetailsController controller;
+
+  @override
+  Widget build(BuildContext context) {
+    return MultiValueListenableBuilder(
+      listenables: [controller.searchMatches, controller.activeSearchMatch],
+      builder: (context, values, _) {
+        final theme = Theme.of(context);
+
+        final matches = (values[0] as List<LogDetailsMatch>)
+            .map((m) => m.range)
+            .toList();
+        final activeMatch = (values[1] as LogDetailsMatch?)?.range;
+
+        return Text.rich(
+          SearchHighlighter.highlight(
+            text,
+            matches,
+            activeMatch: activeMatch,
+            style: theme.regularTextStyle,
+          ),
+        );
+      },
+    );
+  }
+}
+
 @visibleForTesting
 class LogDetailsFormatButton extends StatelessWidget {
   const LogDetailsFormatButton({super.key, required this.format});
diff --git a/packages/devtools_app/lib/src/screens/logging/_logs_table.dart b/packages/devtools_app/lib/src/screens/logging/_logs_table.dart
index 0475919..c406a42 100644
--- a/packages/devtools_app/lib/src/screens/logging/_logs_table.dart
+++ b/packages/devtools_app/lib/src/screens/logging/_logs_table.dart
@@ -48,6 +48,7 @@
       defaultSortDirection: SortDirection.ascending,
       secondarySortColumn: messageColumn,
       rowHeight: _logRowHeight,
+      tallHeaders: true,
     );
   }
 }
diff --git a/packages/devtools_app/lib/src/screens/logging/log_details_controller.dart b/packages/devtools_app/lib/src/screens/logging/log_details_controller.dart
new file mode 100644
index 0000000..33eec93
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/logging/log_details_controller.dart
@@ -0,0 +1,62 @@
+// Copyright 2024 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app_shared/utils.dart';
+import 'package:flutter/foundation.dart';
+
+import '../../shared/primitives/utils.dart';
+import '../../shared/ui/search.dart';
+import 'logging_controller.dart';
+
+/// A controller for the log details view that provides search functionality.
+class LogDetailsController extends DisposableController
+    with SearchControllerMixin<LogDetailsMatch>, AutoDisposeControllerMixin {
+  LogDetailsController({required ValueListenable<LogData?> selectedLog}) {
+    init();
+    addAutoDisposeListener(selectedLog, () {
+      _selectedLog = selectedLog.value;
+      refreshSearchMatches();
+    });
+  }
+
+  LogData? _selectedLog;
+
+  @override
+  List<LogDetailsMatch> matchesForSearch(
+    String search, {
+    bool searchPreviousMatches = false,
+  }) {
+    if (search.isEmpty || _selectedLog == null) return [];
+    final matches = <LogDetailsMatch>[];
+
+    final text = _selectedLog!.prettyPrinted();
+    if (text == null) return [];
+
+    final regex = RegExp(search, caseSensitive: false);
+    final allMatches = regex.allMatches(text);
+    for (final match in allMatches) {
+      matches.add(LogDetailsMatch(match.start, match.end));
+    }
+    return matches;
+  }
+
+  @override
+  void dispose() {
+    _selectedLog = null;
+    super.dispose();
+  }
+}
+
+/// A search match in the log details view.
+class LogDetailsMatch with SearchableDataMixin {
+  LogDetailsMatch(this.start, this.end);
+
+  final int start;
+  final int end;
+
+  Range get range => Range(start, end);
+
+  @override
+  bool matchesSearchToken(RegExp regExpSearch) => false;
+}
diff --git a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart
index 8f9818c..fa93986 100644
--- a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart
+++ b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart
@@ -28,6 +28,7 @@
 import '../../shared/ui/filter.dart';
 import '../../shared/ui/search.dart';
 import '../inspector/inspector_tree_controller.dart';
+import 'log_details_controller.dart';
 import 'logging_screen.dart';
 import 'metadata.dart';
 
@@ -110,6 +111,8 @@
   @override
   void init() {
     super.init();
+    logDetailsController = LogDetailsController(selectedLog: selectedLog)
+      ..init();
     addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () {
       if (serviceConnection.serviceManager.connectedState.value.connected) {
         _handleConnectionStart(serviceConnection.serviceManager.service!);
@@ -138,6 +141,7 @@
 
   @override
   void dispose() {
+    logDetailsController.dispose();
     selectedLog.dispose();
     unawaited(_logStatusController.close());
     super.dispose();
@@ -234,6 +238,8 @@
 
   final _logStatusController = StreamController<String>.broadcast();
 
+  late final LogDetailsController logDetailsController;
+
   List<LogData> data = <LogData>[];
 
   final selectedLog = ValueNotifier<LogData?>(null);
diff --git a/packages/devtools_app/lib/src/screens/logging/logging_screen.dart b/packages/devtools_app/lib/src/screens/logging/logging_screen.dart
index 2b68a9d..79d2c7c 100644
--- a/packages/devtools_app/lib/src/screens/logging/logging_screen.dart
+++ b/packages/devtools_app/lib/src/screens/logging/logging_screen.dart
@@ -87,7 +87,10 @@
               ValueListenableBuilder<LogData?>(
                 valueListenable: controller.selectedLog,
                 builder: (context, selected, _) {
-                  return LogDetails(log: selected);
+                  return LogDetails(
+                    log: selected,
+                    controller: controller.logDetailsController,
+                  );
                 },
               ),
             ],
diff --git a/packages/devtools_app/lib/src/shared/table/_flat_table.dart b/packages/devtools_app/lib/src/shared/table/_flat_table.dart
index 79bb3e7..154868a 100644
--- a/packages/devtools_app/lib/src/shared/table/_flat_table.dart
+++ b/packages/devtools_app/lib/src/shared/table/_flat_table.dart
@@ -33,6 +33,7 @@
     super.sizeColumnsToFit = true,
     super.rowHeight,
     super.selectionNotifier,
+    super.tallHeaders,
   }) : super(
          searchMatchesNotifier: searchController.searchMatches,
          activeSearchMatchNotifier: searchController.activeSearchMatch,
diff --git a/packages/devtools_app/lib/src/shared/table/_table_row.dart b/packages/devtools_app/lib/src/shared/table/_table_row.dart
index b7c6a01..afecd70 100644
--- a/packages/devtools_app/lib/src/shared/table/_table_row.dart
+++ b/packages/devtools_app/lib/src/shared/table/_table_row.dart
@@ -263,7 +263,7 @@
     final box = SizedBox(
       height: widget._rowType == _TableRowType.data
           ? defaultRowHeight
-          : defaultHeaderHeight + (widget.tall ? densePadding : 0.0),
+          : defaultHeaderHeight + (widget.tall ? 2 * densePadding : 0.0),
       child: Material(
         color: _searchAwareBackgroundColor(),
         child: onPressed != null
diff --git a/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart b/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart
new file mode 100644
index 0000000..6c93155
--- /dev/null
+++ b/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart
@@ -0,0 +1,143 @@
+// Copyright 2024 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app_shared/ui.dart';
+import 'package:flutter/material.dart';
+
+import '../primitives/utils.dart';
+
+/// A utility class for highlighting search matches in text.
+extension SearchHighlighter on Never {
+  /// Highlights search matches in [text].
+  static TextSpan highlight(
+    String text,
+    List<Range> matches, {
+    Range? activeMatch,
+    required TextStyle style,
+  }) {
+    if (matches.isEmpty) {
+      return TextSpan(text: text, style: style);
+    }
+
+    final spans = <TextSpan>[];
+    var lastIndex = 0;
+    for (final match in matches) {
+      final begin = match.begin.toInt();
+      final end = match.end.toInt();
+      if (begin > lastIndex) {
+        spans.add(TextSpan(text: text.substring(lastIndex, begin)));
+      }
+
+      final isActive = activeMatch == match;
+      spans.add(
+        _searchAwareText(
+          text: text.substring(begin, end),
+          baseStyle: style,
+          isActive: isActive,
+        ),
+      );
+      lastIndex = end;
+    }
+
+    if (lastIndex < text.length) {
+      spans.add(TextSpan(text: text.substring(lastIndex)));
+    }
+
+    return TextSpan(children: spans, style: style);
+  }
+
+  /// Highlights search matches in a list of [TextSpan]s.
+  ///
+  /// This method handles matches that span across multiple [TextSpan]s.
+  static List<InlineSpan> highlightSpans(
+    List<TextSpan> spans, {
+    required List<Range> matches,
+    Range? activeMatch,
+    required TextStyle style,
+  }) {
+    if (matches.isEmpty) return spans;
+
+    final result = <InlineSpan>[];
+    var currentOffset = 0;
+    var matchIndex = 0;
+
+    for (final span in spans) {
+      final spanText = span.toPlainText();
+      final spanEnd = currentOffset + spanText.length;
+
+      var lastSpanOffset = 0;
+
+      while (matchIndex < matches.length) {
+        final match = matches[matchIndex];
+
+        // Match is after this span.
+        if (match.begin >= spanEnd) break;
+
+        // Match ends before this span starts.
+        if (match.end <= currentOffset) {
+          matchIndex++;
+          continue;
+        }
+
+        // Add leading un-highlighted text in this span.
+        final matchStartInSpan = (match.begin - currentOffset)
+            .clamp(0, spanText.length)
+            .toInt();
+        if (matchStartInSpan > lastSpanOffset) {
+          result.add(
+            TextSpan(
+              text: spanText.substring(lastSpanOffset, matchStartInSpan),
+              style: span.style,
+            ),
+          );
+        }
+
+        // Add highlighted portion.
+        final matchEndInSpan = (match.end - currentOffset)
+            .clamp(0, spanText.length)
+            .toInt();
+        final isActive = activeMatch == match;
+        result.add(
+          _searchAwareText(
+            text: spanText.substring(matchStartInSpan, matchEndInSpan),
+            baseStyle: span.style ?? style,
+            isActive: isActive,
+          ),
+        );
+
+        lastSpanOffset = matchEndInSpan;
+
+        // If the match continues into the next span, don't increment matchIndex yet.
+        if (match.end > spanEnd) break;
+
+        matchIndex++;
+      }
+
+      // Add remaining un-highlighted text in this span.
+      if (lastSpanOffset < spanText.length) {
+        result.add(
+          TextSpan(text: spanText.substring(lastSpanOffset), style: span.style),
+        );
+      }
+
+      currentOffset = spanEnd;
+    }
+
+    return result;
+  }
+}
+
+TextSpan _searchAwareText({
+  required String text,
+  required TextStyle baseStyle,
+  bool isActive = false,
+}) {
+  return TextSpan(
+    text: text,
+    style: baseStyle.copyWith(
+      backgroundColor: isActive ? activeSearchMatchColor : searchMatchColor,
+      color: Colors.black,
+    ),
+  );
+}
diff --git a/packages/devtools_app/macos/Runner.xcodeproj/project.pbxproj b/packages/devtools_app/macos/Runner.xcodeproj/project.pbxproj
index 59aa8f3..ce7a071 100644
--- a/packages/devtools_app/macos/Runner.xcodeproj/project.pbxproj
+++ b/packages/devtools_app/macos/Runner.xcodeproj/project.pbxproj
@@ -21,14 +21,13 @@
 /* End PBXAggregateTarget section */
 
 /* Begin PBXBuildFile section */
-		11595299B00138FF6A219878 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46084CB9F244837191E61B73 /* Pods_RunnerTests.framework */; };
 		331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
 		335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
 		33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
 		33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
 		33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
 		33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
-		E678665441E5C0F7F629BAD5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5062035DDDD18FB35E98D5B6 /* Pods_Runner.framework */; };
+		78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -62,8 +61,6 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
-		11BB555C0F1767B9B5CB7CE0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
-		13053082F27293B7166BCBED /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
 		331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
 		333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
@@ -80,14 +77,9 @@
 		33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
 		33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
 		33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
-		46084CB9F244837191E61B73 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
-		5062035DDDD18FB35E98D5B6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
-		68C587FFA5A0B8F46A0C5150 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
+		78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
 		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
-		A7CE48BF63861DD9F3A9FA2F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
-		BDEB23F5F07C7F498EB77EA2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
-		E11974409F5281249C10F0E1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -95,7 +87,6 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				11595299B00138FF6A219878 /* Pods_RunnerTests.framework in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -103,7 +94,7 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				E678665441E5C0F7F629BAD5 /* Pods_Runner.framework in Frameworks */,
+				78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -136,8 +127,6 @@
 				33CEB47122A05771004F2AC0 /* Flutter */,
 				331C80D6294CF71000263BE5 /* RunnerTests */,
 				33CC10EE2044A3C60003C045 /* Products */,
-				D73912EC22F37F3D000D13A0 /* Frameworks */,
-				618DD25D42BF0C167E4D5128 /* Pods */,
 			);
 			sourceTree = "<group>";
 		};
@@ -164,6 +153,7 @@
 		33CEB47122A05771004F2AC0 /* Flutter */ = {
 			isa = PBXGroup;
 			children = (
+				78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
 				335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
 				33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
 				33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@@ -185,29 +175,6 @@
 			path = Runner;
 			sourceTree = "<group>";
 		};
-		618DD25D42BF0C167E4D5128 /* Pods */ = {
-			isa = PBXGroup;
-			children = (
-				11BB555C0F1767B9B5CB7CE0 /* Pods-Runner.debug.xcconfig */,
-				68C587FFA5A0B8F46A0C5150 /* Pods-Runner.release.xcconfig */,
-				A7CE48BF63861DD9F3A9FA2F /* Pods-Runner.profile.xcconfig */,
-				E11974409F5281249C10F0E1 /* Pods-RunnerTests.debug.xcconfig */,
-				BDEB23F5F07C7F498EB77EA2 /* Pods-RunnerTests.release.xcconfig */,
-				13053082F27293B7166BCBED /* Pods-RunnerTests.profile.xcconfig */,
-			);
-			name = Pods;
-			path = Pods;
-			sourceTree = "<group>";
-		};
-		D73912EC22F37F3D000D13A0 /* Frameworks */ = {
-			isa = PBXGroup;
-			children = (
-				5062035DDDD18FB35E98D5B6 /* Pods_Runner.framework */,
-				46084CB9F244837191E61B73 /* Pods_RunnerTests.framework */,
-			);
-			name = Frameworks;
-			sourceTree = "<group>";
-		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -215,7 +182,6 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
 			buildPhases = (
-				3E485AF46E5EF6A810E8A04C /* [CP] Check Pods Manifest.lock */,
 				331C80D1294CF70F00263BE5 /* Sources */,
 				331C80D2294CF70F00263BE5 /* Frameworks */,
 				331C80D3294CF70F00263BE5 /* Resources */,
@@ -234,13 +200,11 @@
 			isa = PBXNativeTarget;
 			buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
 			buildPhases = (
-				DC1C8B6797A659BE5B59B986 /* [CP] Check Pods Manifest.lock */,
 				33CC10E92044A3C60003C045 /* Sources */,
 				33CC10EA2044A3C60003C045 /* Frameworks */,
 				33CC10EB2044A3C60003C045 /* Resources */,
 				33CC110E2044A8840003C045 /* Bundle Framework */,
 				3399D490228B24CF009A79C7 /* ShellScript */,
-				E765EEB3239836D35A4D4672 /* [CP] Embed Pods Frameworks */,
 			);
 			buildRules = (
 			);
@@ -248,6 +212,9 @@
 				33CC11202044C79F0003C045 /* PBXTargetDependency */,
 			);
 			name = Runner;
+			packageProductDependencies = (
+				78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+			);
 			productName = Runner;
 			productReference = 33CC10ED2044A3C60003C045 /* devtools_app.app */;
 			productType = "com.apple.product-type.application";
@@ -291,6 +258,9 @@
 				Base,
 			);
 			mainGroup = 33CC10E42044A3C60003C045;
+			packageReferences = (
+				781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+			);
 			productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
 			projectDirPath = "";
 			projectRoot = "";
@@ -360,67 +330,6 @@
 			shellPath = /bin/sh;
 			shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
 		};
-		3E485AF46E5EF6A810E8A04C /* [CP] Check Pods Manifest.lock */ = {
-			isa = PBXShellScriptBuildPhase;
-			buildActionMask = 2147483647;
-			files = (
-			);
-			inputFileListPaths = (
-			);
-			inputPaths = (
-				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
-				"${PODS_ROOT}/Manifest.lock",
-			);
-			name = "[CP] Check Pods Manifest.lock";
-			outputFileListPaths = (
-			);
-			outputPaths = (
-				"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
-			);
-			runOnlyForDeploymentPostprocessing = 0;
-			shellPath = /bin/sh;
-			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
-			showEnvVarsInLog = 0;
-		};
-		DC1C8B6797A659BE5B59B986 /* [CP] Check Pods Manifest.lock */ = {
-			isa = PBXShellScriptBuildPhase;
-			buildActionMask = 2147483647;
-			files = (
-			);
-			inputFileListPaths = (
-			);
-			inputPaths = (
-				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
-				"${PODS_ROOT}/Manifest.lock",
-			);
-			name = "[CP] Check Pods Manifest.lock";
-			outputFileListPaths = (
-			);
-			outputPaths = (
-				"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
-			);
-			runOnlyForDeploymentPostprocessing = 0;
-			shellPath = /bin/sh;
-			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
-			showEnvVarsInLog = 0;
-		};
-		E765EEB3239836D35A4D4672 /* [CP] Embed Pods Frameworks */ = {
-			isa = PBXShellScriptBuildPhase;
-			buildActionMask = 2147483647;
-			files = (
-			);
-			inputFileListPaths = (
-				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
-			);
-			name = "[CP] Embed Pods Frameworks";
-			outputFileListPaths = (
-				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
-			);
-			runOnlyForDeploymentPostprocessing = 0;
-			shellPath = /bin/sh;
-			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
-			showEnvVarsInLog = 0;
-		};
 /* End PBXShellScriptBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
@@ -472,7 +381,6 @@
 /* Begin XCBuildConfiguration section */
 		331C80DB294CF71000263BE5 /* Debug */ = {
 			isa = XCBuildConfiguration;
-			baseConfigurationReference = E11974409F5281249C10F0E1 /* Pods-RunnerTests.debug.xcconfig */;
 			buildSettings = {
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CURRENT_PROJECT_VERSION = 1;
@@ -487,7 +395,6 @@
 		};
 		331C80DC294CF71000263BE5 /* Release */ = {
 			isa = XCBuildConfiguration;
-			baseConfigurationReference = BDEB23F5F07C7F498EB77EA2 /* Pods-RunnerTests.release.xcconfig */;
 			buildSettings = {
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CURRENT_PROJECT_VERSION = 1;
@@ -502,7 +409,6 @@
 		};
 		331C80DD294CF71000263BE5 /* Profile */ = {
 			isa = XCBuildConfiguration;
-			baseConfigurationReference = 13053082F27293B7166BCBED /* Pods-RunnerTests.profile.xcconfig */;
 			buildSettings = {
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CURRENT_PROJECT_VERSION = 1;
@@ -786,6 +692,20 @@
 			defaultConfigurationName = Release;
 		};
 /* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+		781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+			isa = XCLocalSwiftPackageReference;
+			relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+		};
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+		78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = FlutterGeneratedPluginSwiftPackage;
+		};
+/* End XCSwiftPackageProductDependency section */
 	};
 	rootObject = 33CC10E52044A3C60003C045 /* Project object */;
 }
diff --git a/packages/devtools_app/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/devtools_app/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/packages/devtools_app/macos/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "self:">
+   </FileRef>
+</Workspace>
diff --git a/packages/devtools_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/devtools_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index cadfa60..559bf4e 100644
--- a/packages/devtools_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/packages/devtools_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -5,6 +5,24 @@
    <BuildAction
       parallelizeBuildables = "YES"
       buildImplicitDependencies = "YES">
+      <PreActions>
+         <ExecutionAction
+            ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
+            <ActionContent
+               title = "Run Prepare Flutter Framework Script"
+               scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
+               <EnvironmentBuildable>
+                  <BuildableReference
+                     BuildableIdentifier = "primary"
+                     BlueprintIdentifier = "33CC10EC2044A3C60003C045"
+                     BuildableName = "devtools_app.app"
+                     BlueprintName = "Runner"
+                     ReferencedContainer = "container:Runner.xcodeproj">
+                  </BuildableReference>
+               </EnvironmentBuildable>
+            </ActionContent>
+         </ExecutionAction>
+      </PreActions>
       <BuildActionEntries>
          <BuildActionEntry
             buildForTesting = "YES"
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 26d92bf..6893cc7 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -11,7 +11,7 @@
 The 2.57.0 release of the Dart and Flutter DevTools
 includes the following changes among other general improvements.
 To learn more about DevTools, check out the
-[DevTools overview](/tools/devtools).
+[DevTools overview](https://docs.flutter.dev/tools/devtools).
 
 ## General updates
 
@@ -43,7 +43,8 @@
 
 ## Logging updates
 
-TODO: Remove this section if there are not any updates.
+- Added support for searching within the log details view (raw text mode). [#9712](https://github.com/flutter/devtools/pull/9712)
+  ![Search in log details](images/log_details_search.png "Searching within the log details view")
 
 ## App size tool updates
 
diff --git a/packages/devtools_app/release_notes/images/log_details_search.png b/packages/devtools_app/release_notes/images/log_details_search.png
new file mode 100644
index 0000000..a99d6e3
--- /dev/null
+++ b/packages/devtools_app/release_notes/images/log_details_search.png
Binary files differ
diff --git a/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart b/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart
index 8852ab0..ab38883 100644
--- a/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart
+++ b/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart
@@ -217,16 +217,14 @@
     const index = 9;
     bool containsJson(Widget widget) {
       if (widget is! Text) return false;
-      final content = (widget.data ?? '').trim();
+      final content = (widget.data ?? widget.textSpan?.toPlainText() ?? '')
+          .trim();
       return content.startsWith('{') &&
           content.endsWith('}') &&
           content != '{ }';
     }
 
-    final findJson = find.descendant(
-      of: find.byType(LogDetails),
-      matching: find.byWidgetPredicate(containsJson),
-    );
+    final findJson = find.byWidgetPredicate(containsJson);
 
     await pumpLoggingScreen(tester);
     await tester.pumpAndSettle();
diff --git a/packages/devtools_app/test/shared/ui/search_highlighter_test.dart b/packages/devtools_app/test/shared/ui/search_highlighter_test.dart
new file mode 100644
index 0000000..00b172d
--- /dev/null
+++ b/packages/devtools_app/test/shared/ui/search_highlighter_test.dart
@@ -0,0 +1,121 @@
+// Copyright 2024 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app/src/shared/primitives/utils.dart';
+import 'package:devtools_app/src/shared/ui/search_highlighter.dart';
+import 'package:devtools_app_shared/ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  const style = TextStyle(fontSize: 12.0);
+
+  group('SearchHighlighter.highlight', () {
+    test('no matches', () {
+      final result = SearchHighlighter.highlight(
+        'Hello World',
+        [],
+        style: style,
+      );
+      expect(result.text, 'Hello World');
+      expect(result.children, isNull);
+    });
+
+    test('single match', () {
+      final result = SearchHighlighter.highlight('Hello World', [
+        const Range(0, 5),
+      ], style: style);
+      expect(result.children!.length, 2);
+      expect(result.children![0].toPlainText(), 'Hello');
+      expect(result.children![0].style!.backgroundColor, searchMatchColor);
+      expect(result.children![1].toPlainText(), ' World');
+    });
+
+    test('multiple matches with active match', () {
+      final matches = [const Range(0, 5), const Range(6, 11)];
+      final result = SearchHighlighter.highlight(
+        'Hello World',
+        matches,
+        activeMatch: matches[1],
+        style: style,
+      );
+      expect(result.children!.length, 3);
+      expect(result.children![0].toPlainText(), 'Hello');
+      expect(result.children![0].style!.backgroundColor, searchMatchColor);
+      expect(result.children![1].toPlainText(), ' ');
+      expect(result.children![2].toPlainText(), 'World');
+      expect(
+        result.children![2].style!.backgroundColor,
+        activeSearchMatchColor,
+      );
+    });
+  });
+
+  group('SearchHighlighter.highlightSpans', () {
+    test('no matches', () {
+      final spans = [
+        const TextSpan(text: 'Hello ', style: style),
+        const TextSpan(text: 'World', style: style),
+      ];
+      final result = SearchHighlighter.highlightSpans(
+        spans,
+        matches: [],
+        style: style,
+      );
+      expect(result, spans);
+    });
+
+    test('match across spans', () {
+      final spans = [
+        const TextSpan(text: 'Hello ', style: style),
+        const TextSpan(text: 'World', style: style),
+      ];
+      // "o Wor"
+      final matches = [const Range(4, 9)];
+      final result = SearchHighlighter.highlightSpans(
+        spans,
+        matches: matches,
+        activeMatch: matches[0],
+        style: style,
+      );
+
+      expect(result.length, 4);
+      expect(result[0].toPlainText(), 'Hell');
+      expect(result[1].toPlainText(), 'o ');
+      expect(result[1].style!.backgroundColor, activeSearchMatchColor);
+      expect(result[2].toPlainText(), 'Wor');
+      expect(result[2].style!.backgroundColor, activeSearchMatchColor);
+      expect(result[3].toPlainText(), 'ld');
+    });
+
+    test('multiple matches across multiple spans', () {
+      final spans = [
+        const TextSpan(text: 'abc', style: style),
+        const TextSpan(text: 'def', style: style),
+        const TextSpan(text: 'ghi', style: style),
+      ];
+      // "bcd", "fgh"
+      final matches = [const Range(1, 4), const Range(5, 8)];
+      final result = SearchHighlighter.highlightSpans(
+        spans,
+        matches: matches,
+        activeMatch: matches[1],
+        style: style,
+      );
+
+      expect(result.length, 7);
+      expect(result[0].toPlainText(), 'a');
+      expect(result[1].toPlainText(), 'bc'); // match 1 part 1
+      expect(result[1].style!.backgroundColor, searchMatchColor);
+      expect(result[2].toPlainText(), 'd'); // match 1 part 2
+      expect(result[2].style!.backgroundColor, searchMatchColor);
+      expect(result[3].toPlainText(), 'e');
+      expect(result[4].toPlainText(), 'f'); // match 2 part 1
+      expect(result[4].style!.backgroundColor, activeSearchMatchColor);
+      expect(result[5].toPlainText(), 'gh'); // match 2 part 2
+      expect(result[5].style!.backgroundColor, activeSearchMatchColor);
+      expect(result[6].toPlainText(), 'i');
+    });
+  });
+}
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png
index 869ff8e..9b618a3 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png
index 869ff8e..9b618a3 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png
index 67d1d9f..5f17de5 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png
index b34779c..d2cc323 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png
index cf36edd..a3bcbb4 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png
index b34779c..d2cc323 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png
index cf36edd..a3bcbb4 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png
index b34779c..d2cc323 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png
index cf36edd..a3bcbb4 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png
index 67d1d9f..5f17de5 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png
index 67d1d9f..5f17de5 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png
Binary files differ
diff --git a/packages/devtools_app_shared/lib/src/ui/theme/theme.dart b/packages/devtools_app_shared/lib/src/ui/theme/theme.dart
index 3513a22..07c1bf1 100644
--- a/packages/devtools_app_shared/lib/src/ui/theme/theme.dart
+++ b/packages/devtools_app_shared/lib/src/ui/theme/theme.dart
@@ -278,6 +278,7 @@
 
 const extraWideSearchFieldWidth = 600.0;
 const wideSearchFieldWidth = 400.0;
+const mediumSearchFieldWidth = 300.0;
 const defaultSearchFieldWidth = 200.0;
 
 const defaultTextFieldHeight = 26.0;
diff --git a/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart b/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart
index f710b85..f16f273 100644
--- a/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart
+++ b/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart
@@ -196,9 +196,13 @@
   provideDummy<ListValueNotifier<LogData>>(ListValueNotifier<LogData>(data));
   final mockLoggingController = MockLoggingController();
   when(mockLoggingController.data).thenReturn(data);
+  final selectedLog = ValueNotifier<LogData?>(null);
+  when(mockLoggingController.selectedLog).thenReturn(selectedLog);
+
+  final logDetailsController = LogDetailsController(selectedLog: selectedLog);
   when(
-    mockLoggingController.selectedLog,
-  ).thenReturn(ValueNotifier<LogData?>(null));
+    mockLoggingController.logDetailsController,
+  ).thenReturn(logDetailsController);
 
   // Set up mock filter state.
   when(