Only cache pub root directories set by the user (#6897)
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart
index 14f0def..8564620 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart
@@ -126,7 +126,7 @@
         searchPreventClose = false;
       }
     });
-    addAutoDisposeListener(preferences.inspector.customPubRootDirectories, () {
+    addAutoDisposeListener(preferences.inspector.pubRootDirectories, () {
       if (serviceConnection.serviceManager.hasConnection &&
           controller.firstInspectorTreeLoadCompleted) {
         _refreshInspector();
@@ -579,16 +579,19 @@
           child: EditableList(
             gaScreen: gac.inspector,
             gaRefreshSelection: gac.refreshPubRoots,
-            entries: preferences.inspector.customPubRootDirectories,
+            entries: preferences.inspector.pubRootDirectories,
             textFieldLabel: 'Enter a new package directory',
-            isRefreshing:
-                preferences.inspector.isRefreshingCustomPubRootDirectories,
-            onEntryAdded: (p0) =>
-                unawaited(preferences.inspector.addPubRootDirectories([p0])),
+            isRefreshing: preferences.inspector.isRefreshingPubRootDirectories,
+            onEntryAdded: (p0) => unawaited(
+              preferences.inspector.addPubRootDirectories(
+                [p0],
+                shouldCache: true,
+              ),
+            ),
             onEntryRemoved: (p0) =>
                 unawaited(preferences.inspector.removePubRootDirectories([p0])),
             onRefreshTriggered: () =>
-                unawaited(preferences.inspector.loadCustomPubRootDirectories()),
+                unawaited(preferences.inspector.loadPubRootDirectories()),
           ),
         );
       },
diff --git a/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart b/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart
index 52b8cfd..1c328b0 100644
--- a/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart
+++ b/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart
@@ -21,6 +21,7 @@
 
 import '../console/primitives/simple_items.dart';
 import '../globals.dart';
+import '../utils.dart';
 import 'diagnostics_node.dart';
 import 'generic_instance_reference.dart';
 import 'object_group_api.dart';
@@ -29,8 +30,6 @@
 
 const _inspectorLibraryUri =
     'package:flutter/src/widgets/widget_inspector.dart';
-const _google3PathSegment = 'google3';
-const _thirdPartyPathSegment = 'third_party';
 
 abstract class InspectorServiceBase extends DisposableController
     with AutoDisposeControllerMixin {
@@ -355,8 +354,8 @@
       final libIndex = parts.lastIndexOf('lib');
       final path = libIndex > 0 ? parts.sublist(0, libIndex) : parts;
       // Special case handling of bazel packages.
-      if (_isGoogle3Path(path)) {
-        var packageParts = _stripGoogle3(path);
+      if (isGoogle3Path(path)) {
+        var packageParts = stripGoogle3(path);
         // A well formed third_party dart package should be in a directory of
         // the form
         // third_party/dart/packageName                    (package:packageName)
@@ -479,88 +478,6 @@
     return response as Map<String, dynamic>;
   }
 
-  /// As we aren't running from an IDE, we don't know exactly what the pub root
-  /// directories are for the current project so we make a best guess if needed
-  /// based on the root directory of the first non artificial widget in the
-  /// tree.
-  Future<List<String>> inferPubRootDirectoryIfNeeded() async {
-    final group = createObjectGroup('temp');
-    List<String> directories = await group.getPubRootDirectories();
-    if (directories.isEmpty) {
-      final directory = await inferPubRootDirectoryIfNeededHelper();
-      if (directory != null) {
-        directories = [directory];
-      }
-    }
-
-    await _onRootDirectoriesChanged(directories);
-    return directories;
-  }
-
-  Future<String?> inferPubRootDirectoryIfNeededHelper() async {
-    final path = await serviceConnection.rootLibraryForMainIsolate();
-    if (path == null) {
-      return null;
-    }
-    // TODO(jacobr): Once https://github.com/flutter/flutter/issues/26615 is
-    // fixed we will be able to use package: paths. Temporarily all tools
-    // tracking widget locations will need to support both path formats.
-    // TODO(jacobr): use the list of loaded scripts to determine the appropriate
-    // package root directory given that the root script of this project is in
-    // this directory rather than guessing based on url structure.
-    final parts = path.split('/');
-    String? pubRootDirectory;
-    // For google3, we grab the top-level directory in the google3 directory
-    // (e.g. /education), or the top-level directory in third_party (e.g.
-    // /third_party/dart):
-    if (_isGoogle3Path(parts)) {
-      pubRootDirectory = _pubRootDirectoryForGoogle3(parts);
-    } else {
-      final parts = path.split('/');
-
-      for (int i = parts.length - 1; i >= 0; i--) {
-        final part = parts[i];
-        if (part == 'lib' || part == 'web') {
-          pubRootDirectory = parts.sublist(0, i).join('/');
-          break;
-        }
-
-        if (part == 'packages') {
-          pubRootDirectory = parts.sublist(0, i + 1).join('/');
-          break;
-        }
-      }
-    }
-    pubRootDirectory ??= (parts..removeLast()).join('/');
-
-    await _addPubRootDirectories([pubRootDirectory]);
-    return pubRootDirectory;
-  }
-
-  bool _isGoogle3Path(List<String> pathParts) =>
-      pathParts.contains(_google3PathSegment);
-
-  List<String> _stripGoogle3(List<String> pathParts) {
-    final google3Index = pathParts.lastIndexOf(_google3PathSegment);
-    if (google3Index != -1 && google3Index + 1 < pathParts.length) {
-      return pathParts.sublist(google3Index + 1);
-    }
-    return pathParts;
-  }
-
-  String? _pubRootDirectoryForGoogle3(List<String> pathParts) {
-    final strippedParts = _stripGoogle3(pathParts);
-    if (strippedParts.isEmpty) return null;
-
-    final topLevelDirectory = strippedParts.first;
-    if (topLevelDirectory == _thirdPartyPathSegment &&
-        strippedParts.length >= 2) {
-      return '/${strippedParts.sublist(0, 2).join('/')}';
-    } else {
-      return '/${strippedParts.first}';
-    }
-  }
-
   RemoteDiagnosticsNode? _currentSelection;
 
   InspectorObjectGroupManager get _selectionGroups {
diff --git a/packages/devtools_app/lib/src/shared/preferences.dart b/packages/devtools_app/lib/src/shared/preferences.dart
index c7d718c..45acca3 100644
--- a/packages/devtools_app/lib/src/shared/preferences.dart
+++ b/packages/devtools_app/lib/src/shared/preferences.dart
@@ -17,6 +17,9 @@
 import 'constants.dart';
 import 'diagnostics/inspector_service.dart';
 import 'globals.dart';
+import 'utils.dart';
+
+const _thirdPartyPathSegment = 'third_party';
 
 /// A controller for global application preferences.
 class PreferencesController extends DisposableController
@@ -142,16 +145,15 @@
 class InspectorPreferencesController extends DisposableController
     with AutoDisposeControllerMixin {
   ValueListenable<bool> get hoverEvalModeEnabled => _hoverEvalMode;
-  ListValueNotifier<String> get customPubRootDirectories =>
-      _customPubRootDirectories;
-  ValueListenable<bool> get isRefreshingCustomPubRootDirectories =>
-      _customPubRootDirectoriesAreBusy;
+  ListValueNotifier<String> get pubRootDirectories => _pubRootDirectories;
+  ValueListenable<bool> get isRefreshingPubRootDirectories =>
+      _pubRootDirectoriesAreBusy;
   InspectorServiceBase? get _inspectorService =>
       serviceConnection.inspectorService;
 
   final _hoverEvalMode = ValueNotifier<bool>(false);
-  final _customPubRootDirectories = ListValueNotifier<String>([]);
-  final _customPubRootDirectoriesAreBusy = ValueNotifier<bool>(false);
+  final _pubRootDirectories = ListValueNotifier<String>([]);
+  final _pubRootDirectoriesAreBusy = ValueNotifier<bool>(false);
   final _busyCounter = ValueNotifier<int>(0);
   static const _hoverEvalModeStorageId = 'inspector.hoverEvalMode';
   static const _customPubRootDirectoriesStoragePrefix =
@@ -174,7 +176,7 @@
   Future<void> init() async {
     await _initHoverEvalMode();
     // TODO(jacobr): consider initializing this first as it is not blocking.
-    _initCustomPubRootDirectories();
+    _initPubRootDirectories();
   }
 
   Future<void> _initHoverEvalMode() async {
@@ -197,19 +199,19 @@
     setHoverEvalMode(hoverEvalModeEnabledValue == 'true');
   }
 
-  void _initCustomPubRootDirectories() {
+  void _initPubRootDirectories() {
     addAutoDisposeListener(
       serviceConnection.serviceManager.connectedState,
       () async {
         if (serviceConnection.serviceManager.connectedState.value.connected) {
-          await _handleConnectionToNewService();
+          await handleConnectionToNewService();
         } else {
           _handleConnectionClosed();
         }
       },
     );
     addAutoDisposeListener(_busyCounter, () {
-      _customPubRootDirectoriesAreBusy.value = _busyCounter.value != 0;
+      _pubRootDirectoriesAreBusy.value = _busyCounter.value != 0;
     });
     addAutoDisposeListener(
       serviceConnection.serviceManager.isolateManager.mainIsolate,
@@ -223,13 +225,13 @@
           if (debuggerState?.isPaused.value == false) {
             // the isolate is already unpaused, we can try to load
             // the directories
-            unawaited(preferences.inspector.loadCustomPubRootDirectories());
+            unawaited(preferences.inspector.loadPubRootDirectories());
           } else {
             late Function() pausedListener;
 
             pausedListener = () {
               if (debuggerState?.isPaused.value == false) {
-                unawaited(preferences.inspector.loadCustomPubRootDirectories());
+                unawaited(preferences.inspector.loadPubRootDirectories());
 
                 debuggerState?.isPaused.removeListener(pausedListener);
               }
@@ -245,41 +247,125 @@
 
   void _handleConnectionClosed() {
     _mainScriptDir = null;
-    _customPubRootDirectories.clear();
+    _pubRootDirectories.clear();
   }
 
-  Future<void> _handleConnectionToNewService() async {
+  @visibleForTesting
+  Future<void> handleConnectionToNewService() async {
     await _updateMainScriptRef();
     await _updateHoverEvalMode();
+    await loadPubRootDirectories();
+  }
 
-    final localInspectorService = _inspectorService;
-    if (localInspectorService is InspectorService) {
-      _customPubRootDirectories.clear();
-      await loadCustomPubRootDirectories();
+  Future<void> loadPubRootDirectories() async {
+    await _pubRootDirectoryBusyTracker(() async {
+      await addPubRootDirectories(await _determinePubRootDirectories());
+      await _refreshPubRootDirectoriesFromService();
+    });
+  }
 
-      if (_customPubRootDirectories.value.isEmpty) {
-        // If there are no pub root directories set on the first connection
-        // then try inferring them.
-        await _customPubRootDirectoryBusyTracker(() async {
-          await localInspectorService.inferPubRootDirectoryIfNeeded();
-          await loadCustomPubRootDirectories();
-        });
+  Future<List<String>> _determinePubRootDirectories() async {
+    final cachedDirectories = await readCachedPubRootDirectories();
+    final inferredDirectory = await _inferPubRootDirectory();
+
+    if (inferredDirectory == null) return cachedDirectories;
+    return {inferredDirectory, ...cachedDirectories}.toList();
+  }
+
+  @visibleForTesting
+  Future<List<String>> readCachedPubRootDirectories() async {
+    final cachedDirectoriesJson =
+        await storage.getValue(_customPubRootStorageId());
+    if (cachedDirectoriesJson == null) return <String>[];
+
+    return List<String>.from(
+      jsonDecode(cachedDirectoriesJson),
+    );
+  }
+
+  /// As we aren't running from an IDE, we don't know exactly what the pub root
+  /// directories are for the current project so we make a best guess based on
+  /// the root library for the main isolate.
+  Future<String?> _inferPubRootDirectory() async {
+    final path = await serviceConnection.rootLibraryForMainIsolate();
+    if (path == null) {
+      return null;
+    }
+    // TODO(jacobr): Once https://github.com/flutter/flutter/issues/26615 is
+    // fixed we will be able to use package: paths. Temporarily all tools
+    // tracking widget locations will need to support both path formats.
+    // TODO(jacobr): use the list of loaded scripts to determine the appropriate
+    // package root directory given that the root script of this project is in
+    // this directory rather than guessing based on url structure.
+    final parts = path.split('/');
+    String? pubRootDirectory;
+    // For google3, we grab the top-level directory in the google3 directory
+    // (e.g. /education), or the top-level directory in third_party (e.g.
+    // /third_party/dart):
+    if (isGoogle3Path(parts)) {
+      pubRootDirectory = _pubRootDirectoryForGoogle3(parts);
+    } else {
+      final parts = path.split('/');
+
+      for (int i = parts.length - 1; i >= 0; i--) {
+        final part = parts[i];
+        if (part == 'lib' || part == 'web') {
+          pubRootDirectory = parts.sublist(0, i).join('/');
+          break;
+        }
+
+        if (part == 'packages') {
+          pubRootDirectory = parts.sublist(0, i + 1).join('/');
+          break;
+        }
       }
     }
+    pubRootDirectory ??= (parts..removeLast()).join('/');
+    return pubRootDirectory;
+  }
+
+  String? _pubRootDirectoryForGoogle3(List<String> pathParts) {
+    final strippedParts = stripGoogle3(pathParts);
+    if (strippedParts.isEmpty) return null;
+
+    final topLevelDirectory = strippedParts.first;
+    if (topLevelDirectory == _thirdPartyPathSegment &&
+        strippedParts.length >= 2) {
+      return '/${strippedParts.sublist(0, 2).join('/')}';
+    } else {
+      return '/${strippedParts.first}';
+    }
   }
 
-  void _persistCustomPubRootDirectoriesToStorage() {
-    unawaited(
-      storage.setValue(
-        _customPubRootStorageId(),
-        jsonEncode(_customPubRootDirectories.value),
-      ),
+  Future<void> _cachePubRootDirectories(
+    List<String> pubRootDirectories,
+  ) async {
+    final cachedDirectories = await readCachedPubRootDirectories();
+    await storage.setValue(
+      _customPubRootStorageId(),
+      jsonEncode([
+        ...cachedDirectories,
+        ...pubRootDirectories,
+      ]),
+    );
+  }
+
+  Future<void> _uncachePubRootDirectories(
+    List<String> pubRootDirectories,
+  ) async {
+    final directoriesToCache = (await readCachedPubRootDirectories())
+        .where((dir) => !pubRootDirectories.contains(dir))
+        .toList();
+    await storage.setValue(
+      _customPubRootStorageId(),
+      jsonEncode(directoriesToCache),
     );
   }
 
   Future<void> addPubRootDirectories(
-    List<String> pubRootDirectories,
-  ) async {
+    List<String> pubRootDirectories, {
+    bool shouldCache = false,
+  }) async {
     // TODO(https://github.com/flutter/devtools/issues/4380):
     // Add validation to EditableList Input.
     // Directories of just / will break the inspector tree local package checks.
@@ -288,11 +374,14 @@
     );
 
     if (!serviceConnection.serviceManager.hasConnection) return;
-    await _customPubRootDirectoryBusyTracker(() async {
+    await _pubRootDirectoryBusyTracker(() async {
       final localInspectorService = _inspectorService;
       if (localInspectorService is! InspectorService) return;
 
       await localInspectorService.addPubRootDirectories(pubRootDirectories);
+      if (shouldCache) {
+        await _cachePubRootDirectories(pubRootDirectories);
+      }
       await _refreshPubRootDirectoriesFromService();
     });
   }
@@ -301,17 +390,18 @@
     List<String> pubRootDirectories,
   ) async {
     if (!serviceConnection.serviceManager.hasConnection) return;
-    await _customPubRootDirectoryBusyTracker(() async {
+    await _pubRootDirectoryBusyTracker(() async {
       final localInspectorService = _inspectorService;
       if (localInspectorService is! InspectorService) return;
 
       await localInspectorService.removePubRootDirectories(pubRootDirectories);
+      await _uncachePubRootDirectories(pubRootDirectories);
       await _refreshPubRootDirectoriesFromService();
     });
   }
 
   Future<void> _refreshPubRootDirectoriesFromService() async {
-    await _customPubRootDirectoryBusyTracker(() async {
+    await _pubRootDirectoryBusyTracker(() async {
       final localInspectorService = _inspectorService;
       if (localInspectorService is! InspectorService) return;
 
@@ -319,14 +409,13 @@
           await localInspectorService.getPubRootDirectories();
       if (freshPubRootDirectories != null) {
         final newSet = Set<String>.of(freshPubRootDirectories);
-        final oldSet = Set<String>.of(_customPubRootDirectories.value);
+        final oldSet = Set<String>.of(_pubRootDirectories.value);
         final directoriesToAdd = newSet.difference(oldSet);
         final directoriesToRemove = oldSet.difference(newSet);
 
-        _customPubRootDirectories.removeAll(directoriesToRemove);
-        _customPubRootDirectories.addAll(directoriesToAdd);
-
-        _persistCustomPubRootDirectoriesToStorage();
+        _pubRootDirectories
+          ..removeAll(directoriesToRemove)
+          ..addAll(directoriesToAdd);
       }
     });
   }
@@ -337,25 +426,7 @@
     return '${_customPubRootDirectoriesStoragePrefix}_$packageId';
   }
 
-  Future<void> loadCustomPubRootDirectories() async {
-    if (!serviceConnection.serviceManager.hasConnection) return;
-
-    await _customPubRootDirectoryBusyTracker(() async {
-      final storedCustomPubRootDirectories =
-          await storage.getValue(_customPubRootStorageId());
-
-      if (storedCustomPubRootDirectories != null) {
-        await addPubRootDirectories(
-          List<String>.from(
-            jsonDecode(storedCustomPubRootDirectories),
-          ),
-        );
-      }
-      await _refreshPubRootDirectoriesFromService();
-    });
-  }
-
-  Future<void> _customPubRootDirectoryBusyTracker(
+  Future<void> _pubRootDirectoryBusyTracker(
     Future<void> Function() callback,
   ) async {
     try {
diff --git a/packages/devtools_app/lib/src/shared/utils.dart b/packages/devtools_app/lib/src/shared/utils.dart
index 29cd8d3..853b864 100644
--- a/packages/devtools_app/lib/src/shared/utils.dart
+++ b/packages/devtools_app/lib/src/shared/utils.dart
@@ -214,3 +214,16 @@
   final queryParameters = loadQueryParams();
   return queryParameters[key];
 }
+
+const _google3PathSegment = 'google3';
+
+bool isGoogle3Path(List<String> pathParts) =>
+    pathParts.contains(_google3PathSegment);
+
+List<String> stripGoogle3(List<String> pathParts) {
+  final google3Index = pathParts.lastIndexOf(_google3PathSegment);
+  if (google3Index != -1 && google3Index + 1 < pathParts.length) {
+    return pathParts.sublist(google3Index + 1);
+  }
+  return pathParts;
+}
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index eb4a204..59ced0c 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -14,7 +14,7 @@
 
 ## Inspector updates
 
-TODO: Remove this section if there are not any general updates.
+* Only cache pub root directories added by the user - [6897](https://github.com/flutter/devtools/pull/6897)
 
 ## Performance updates
 
diff --git a/packages/devtools_app/test/inspector/inspector_integration_test.dart b/packages/devtools_app/test/inspector/inspector_integration_test.dart
index eafed2f..bfea308 100644
--- a/packages/devtools_app/test/inspector/inspector_integration_test.dart
+++ b/packages/devtools_app/test/inspector/inspector_integration_test.dart
@@ -37,10 +37,6 @@
         isAlive: null,
       );
     }
-
-    if (service is InspectorService) {
-      await service.inferPubRootDirectoryIfNeeded();
-    }
   };
 
   setUp(() async {
diff --git a/packages/devtools_app/test/inspector/inspector_service_test.dart b/packages/devtools_app/test/inspector/inspector_service_test.dart
index 0579a9e..7faccfd 100644
--- a/packages/devtools_app/test/inspector/inspector_service_test.dart
+++ b/packages/devtools_app/test/inspector/inspector_service_test.dart
@@ -17,10 +17,6 @@
 import '../test_infra/flutter_test_environment.dart';
 import '../test_infra/matchers/matchers.dart';
 
-// TODO(elliette): Add testing that project directories can be inferred from
-// google3-paths. This will require mocking the main isolate so that we can
-// change the root library during testing instead of using the
-// LiveTestWidgetsFlutterBinding.
 void main() {
   initializeLiveTestWidgetsFlutterBindingWithAssets();
 
@@ -35,9 +31,6 @@
     setGlobal(IdeTheme, IdeTheme());
 
     inspectorService = InspectorService();
-    if (env.runConfig.trackWidgetCreation) {
-      await inspectorService!.inferPubRootDirectoryIfNeeded();
-    }
   };
 
   env.beforeEveryTearDown = () async {
@@ -86,24 +79,6 @@
           await env.tearDownEnvironment(force: true);
         });
 
-        // TODO(elliette): Figure out why this didn't catch
-        // https://github.com/flutter/devtools/issues/6841 and fix so that it
-        // catches future regressions.
-        test('can be inferred', () async {
-          await env.setupEnvironment();
-          final inspectorServiceLocal = inspectorService!;
-
-          final group = inspectorServiceLocal.createObjectGroup('test-group');
-          // These tests are moot if widget creation is not tracked.
-          expect(await inspectorServiceLocal.isWidgetCreationTracked(), isTrue);
-          await inspectorServiceLocal.addPubRootDirectories([]);
-          final List<String> rootDirectories =
-              await inspectorServiceLocal.inferPubRootDirectoryIfNeeded();
-          expect(rootDirectories.length, 1);
-          expect(rootDirectories.first, endsWith('/fixtures/flutter_app'));
-          await group.dispose();
-        });
-
         test('can be added and removed', () async {
           await env.setupEnvironment();
           final inspectorServiceLocal = inspectorService!;
@@ -143,16 +118,17 @@
           () async {
             await env.setupEnvironment();
             final inspectorServiceLocal = inspectorService!;
-
             final group = inspectorServiceLocal.createObjectGroup('test-group');
             // These tests are moot if widget creation is not tracked.
             expect(
               await inspectorServiceLocal.isWidgetCreationTracked(),
               isTrue,
             );
-            await inspectorServiceLocal.addPubRootDirectories([]);
+            final rootLibrary =
+                await serviceConnection.rootLibraryForMainIsolate();
+            await inspectorServiceLocal.addPubRootDirectories([rootLibrary!]);
             final List<String> rootDirectories =
-                await inspectorServiceLocal.inferPubRootDirectoryIfNeeded();
+                await inspectorServiceLocal.getPubRootDirectories() ?? [];
             expect(rootDirectories.length, 1);
             expect(rootDirectories.first, endsWith('/fixtures/flutter_app'));
             final originalRootDirectories = rootDirectories.toList();
@@ -269,8 +245,7 @@
           expect(await inspectorServiceLocal.isWidgetCreationTracked(), isTrue);
           await inspectorServiceLocal.addPubRootDirectories([]);
           final originalRootDirectories =
-              (await inspectorServiceLocal.inferPubRootDirectoryIfNeeded())
-                  .toList();
+              await inspectorServiceLocal.getPubRootDirectories();
           try {
             await inspectorServiceLocal.addPubRootDirectories(
               ['/usr/me/clients/google3/foo/bar/baz/lib/src/bla'],
@@ -379,7 +354,7 @@
           } finally {
             // Restore.
             await inspectorServiceLocal
-                .addPubRootDirectories(originalRootDirectories);
+                .addPubRootDirectories(originalRootDirectories ?? []);
 
             await group.dispose();
           }
diff --git a/packages/devtools_app/test/shared/preferences_controller_test.dart b/packages/devtools_app/test/shared/preferences_controller_test.dart
index 6e4d211..b7dc518 100644
--- a/packages/devtools_app/test/shared/preferences_controller_test.dart
+++ b/packages/devtools_app/test/shared/preferences_controller_test.dart
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'dart:convert';
+
 import 'package:devtools_app/devtools_app.dart';
 import 'package:devtools_app_shared/utils.dart';
 import 'package:devtools_test/devtools_test.dart';
@@ -75,16 +77,26 @@
     });
   });
 
+  // TODO(https://github.com/flutter/devtools/issues/4342): Add more tests.
   group('$InspectorPreferencesController', () {
+    late InspectorPreferencesController controller;
+    late FlutterTestStorage storage;
+
+    void updateMainIsolateRootLibrary(String? rootLibrary) {
+      setGlobal(
+        ServiceConnectionManager,
+        FakeServiceConnectionManager(
+          rootLibrary: rootLibrary,
+        ),
+      );
+    }
+
+    setUp(() {
+      setGlobal(Storage, storage = FlutterTestStorage());
+      controller = InspectorPreferencesController();
+    });
+
     group('hoverEvalMode', () {
-      late InspectorPreferencesController controller;
-      late FlutterTestStorage storage;
-
-      setUp(() {
-        setGlobal(Storage, storage = FlutterTestStorage());
-        controller = InspectorPreferencesController();
-      });
-
       test('default value equals inspector service default value', () async {
         await controller.init();
         expect(
@@ -113,8 +125,180 @@
           newHoverModeValue.toString(),
         );
       });
-      // TODO(https://github.com/flutter/devtools/issues/4342): make inspector
-      // preferences testable, then test it
+    });
+
+    group(
+      'infers the pub root directory based on the main isolate\'s root library',
+      () {
+        final rootLibToExpectedPubRoot = {
+          'test_dir/fake_app/lib/main.dart': 'test_dir/fake_app',
+          'my_user/google3/dart_apps/test_app/lib/main.dart': '/dart_apps',
+          'my_user/google3/third_party/dart/dart_apps/test_app/lib/main.dart':
+              '/third_party/dart',
+        };
+
+        for (final MapEntry(
+              key: rootLib,
+              value: expectedPubRoot,
+            ) in rootLibToExpectedPubRoot.entries) {
+          test(
+            '$rootLib -> $expectedPubRoot',
+            () async {
+              updateMainIsolateRootLibrary(rootLib);
+              await controller.handleConnectionToNewService();
+              final directories = controller.pubRootDirectories.value;
+
+              expect(directories, equals([expectedPubRoot]));
+            },
+          );
+        }
+      },
+    );
+
+    group('Caching custom pub root directories', () {
+      final customPubRootDirectories = [
+        'test_dir/fake_app/custom_dir1',
+        'test_dir/fake_app/custom_dir2',
+      ];
+
+      setUp(() async {
+        updateMainIsolateRootLibrary('test_dir/fake_app/lib/main.dart');
+        await controller.handleConnectionToNewService();
+        await controller.addPubRootDirectories(
+          customPubRootDirectories,
+          shouldCache: true,
+        );
+      });
+
+      test(
+        'fetches custom pub root directories from the local cache',
+        () {
+          final directories = controller.pubRootDirectories.value;
+
+          expect(
+            directories,
+            containsAll(customPubRootDirectories),
+          );
+        },
+      );
+
+      test(
+        'custom pub root directories are cached across multiple connections',
+        () async {
+          var directories = controller.pubRootDirectories.value;
+          var cachedDirectories =
+              await controller.readCachedPubRootDirectories();
+
+          expect(
+            directories,
+            containsAll(customPubRootDirectories),
+          );
+          expect(
+            cachedDirectories,
+            containsAll(customPubRootDirectories),
+          );
+
+          await controller.handleConnectionToNewService();
+          directories = controller.pubRootDirectories.value;
+          cachedDirectories = await controller.readCachedPubRootDirectories();
+
+          expect(
+            directories,
+            containsAll(customPubRootDirectories),
+          );
+          expect(cachedDirectories, containsAll(customPubRootDirectories));
+        },
+      );
+
+      test(
+        'adding more directories to cache doesn\'t overwrite pre-existing values',
+        () async {
+          await controller.addPubRootDirectories(
+            ['test_dir/fake_app/custom_dir3'],
+            shouldCache: true,
+          );
+
+          final cachedDirectories =
+              await controller.readCachedPubRootDirectories();
+
+          expect(
+            cachedDirectories,
+            containsAll([
+              ...customPubRootDirectories,
+              'test_dir/fake_app/custom_dir3',
+            ]),
+          );
+        },
+      );
+
+      test(
+        'removing directories from cache removes the correct values',
+        () async {
+          const notRemoved = 'test_dir/fake_app/custom_dir1';
+          const removed = 'test_dir/fake_app/custom_dir2';
+          var cachedDirectories =
+              await controller.readCachedPubRootDirectories();
+
+          expect(cachedDirectories, containsAll([notRemoved, removed]));
+
+          await controller.removePubRootDirectories([removed]);
+          cachedDirectories = await controller.readCachedPubRootDirectories();
+
+          expect(
+            cachedDirectories,
+            isNot(contains(removed)),
+          );
+          expect(
+            cachedDirectories,
+            contains(notRemoved),
+          );
+        },
+      );
+
+      test(
+        'directories includes inferred directory as well',
+        () {
+          final directories = controller.pubRootDirectories.value;
+
+          expect(
+            directories,
+            contains('test_dir/fake_app'),
+          );
+        },
+      );
+
+      test(
+        'does not save inferred directory to local cache',
+        () async {
+          final cachedDirectoriesJson = await storage
+              .getValue('inspector.customPubRootDirectories_myPackage');
+          final cachedDirectories = List<String>.from(
+            jsonDecode(cachedDirectoriesJson!),
+          );
+
+          expect(cachedDirectories, isNot(contains('test_dir/fake_app')));
+        },
+      );
+
+      test(
+        'directories added with "no caching" specified are not cached',
+        () async {
+          await controller.addPubRootDirectories(
+            ['test_dir/fake_app/do_not_cache_dir'],
+          );
+
+          final cachedDirectoriesJson = await storage
+              .getValue('inspector.customPubRootDirectories_myPackage');
+          final cachedDirectories = List<String>.from(
+            jsonDecode(cachedDirectoriesJson!),
+          );
+
+          expect(
+            cachedDirectories,
+            isNot(contains('test_dir/fake_app/do_not_cache_dir')),
+          );
+        },
+      );
     });
   });
 
diff --git a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart
index 65654c4..10644ed 100644
--- a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart
+++ b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart
@@ -14,6 +14,12 @@
 import 'generated.mocks.dart';
 
 base class FakeIsolateManager extends Fake with TestIsolateManager {
+  FakeIsolateManager({
+    this.rootLibrary = 'package:my_app/main.dart',
+  });
+
+  final String? rootLibrary;
+
   @override
   ValueListenable<IsolateRef?> get selectedIsolate => _selectedIsolate;
   final _selectedIsolate = ValueNotifier(
@@ -51,7 +57,7 @@
   IsolateState isolateState(IsolateRef? isolate) {
     final state = MockIsolateState();
     final mockIsolate = MockIsolate();
-    final rootLib = LibraryRef(id: '0', uri: 'package:my_app/main.dart');
+    final rootLib = LibraryRef(id: '0', uri: rootLibrary);
     when(mockIsolate.libraries).thenReturn(
       [
         rootLib,
diff --git a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart
index 8493b47..0e7855f 100644
--- a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart
+++ b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart
@@ -29,6 +29,7 @@
     bool hasService = true,
     List<String> availableServices = const [],
     List<String> availableLibraries = const [],
+    String? rootLibrary,
   }) {
     _serviceManager = FakeServiceManager(
       service: service,
@@ -37,6 +38,7 @@
       availableLibraries: availableLibraries,
       availableServices: availableServices,
       onVmServiceOpened: resolvedUriManager.vmServiceOpened,
+      rootLibrary: rootLibrary,
     );
     for (var screenId in screenIds) {
       when(errorBadgeManager.erroredItemsForPage(screenId)).thenReturn(
@@ -92,6 +94,13 @@
   }) {
     return Future.value();
   }
+
+  @override
+  Future<String?> rootLibraryForMainIsolate() {
+    final fakeIsolateManager =
+        _serviceManager.isolateManager as FakeIsolateManager;
+    return Future.value(fakeIsolateManager.rootLibrary);
+  }
 }
 
 // ignore: subtype_of_sealed_class, fake for testing.
@@ -105,8 +114,10 @@
     this.availableLibraries = const [],
     this.onVmServiceOpened,
     Map<String, Response>? serviceExtensionResponses,
-  }) : serviceExtensionResponses =
-            serviceExtensionResponses ?? _defaultServiceExtensionResponses {
+    String? rootLibrary,
+  })  : serviceExtensionResponses =
+            serviceExtensionResponses ?? _defaultServiceExtensionResponses,
+        _isolateManager = FakeIsolateManager(rootLibrary: rootLibrary) {
     this.service = service ?? createFakeService();
     mockConnectedApp(
       connectedApp!,
@@ -153,6 +164,8 @@
 
   final Map<String, Response> serviceExtensionResponses;
 
+  final IsolateManager _isolateManager;
+
   static final _defaultServiceExtensionResponses = <String, Response>{
     isImpellerEnabled: Response.parse({'enabled': false})!,
   };
@@ -177,7 +190,7 @@
   bool connectedAppInitialized;
 
   @override
-  final IsolateManager isolateManager = FakeIsolateManager();
+  IsolateManager get isolateManager => _isolateManager;
 
   @override
   final FakeServiceExtensionManager serviceExtensionManager =
diff --git a/packages/devtools_test/lib/src/mocks/mocks.dart b/packages/devtools_test/lib/src/mocks/mocks.dart
index 36c373e..6b6bcf0 100644
--- a/packages/devtools_test/lib/src/mocks/mocks.dart
+++ b/packages/devtools_test/lib/src/mocks/mocks.dart
@@ -28,11 +28,6 @@
   }
 
   @override
-  Future<List<String>> inferPubRootDirectoryIfNeeded() async {
-    return ['/some/directory'];
-  }
-
-  @override
   Future<List<String>?> getPubRootDirectories() {
     return Future.value(pubRootDirectories.toList());
   }