blob: 9f1206810629639d57117da2cab1c718773933a6 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// This library must not have direct dependencies on dart:html.
///
/// This allows tests of the complicated logic in this class to run on the VM
/// and will help simplify porting this code to work with Hummingbird.
///
/// This code is directly based on
/// src/io/flutter/view/InspectorPanel.java
/// with some refactors to make the code more of a controller than a combination
/// of view and controller. View specific portions of InspectorPanel.java have
/// been moved to inspector.dart.
library inspector_controller;
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:vm_service/vm_service.dart';
import '../globals.dart';
import '../ui/fake_flutter/fake_flutter.dart';
import '../ui/icons.dart';
import '../utils.dart';
import 'diagnostics_node.dart';
import 'inspector_service.dart';
import 'inspector_text_styles.dart' as inspector_text_styles;
import 'inspector_tree.dart';
void _logError(Object error) {
print(error);
}
TextStyle textStyleForLevel(DiagnosticLevel level) {
switch (level) {
case DiagnosticLevel.hidden:
return inspector_text_styles.unimportant;
case DiagnosticLevel.warning:
return inspector_text_styles.warning;
case DiagnosticLevel.error:
return inspector_text_styles.error;
case DiagnosticLevel.debug:
case DiagnosticLevel.info:
case DiagnosticLevel.fine:
default:
return inspector_text_styles.regular;
}
}
/// This class is based on the InspectorPanel class from the Flutter IntelliJ
/// plugin with some refactors to make it more of a true controller than a view.
///
/// No changes to this class are allowed to pull in dependencies on dart:html.
class InspectorController implements InspectorServiceClient {
InspectorController({
@required this.inspectorService,
@required InspectorTreeFactory inspectorTreeFactory,
@required this.treeType,
this.parent,
this.isSummaryTree = true,
}) : _treeGroups = InspectorObjectGroupManager(inspectorService, 'tree'),
_selectionGroups =
InspectorObjectGroupManager(inspectorService, 'selection') {
_refreshRateLimiter = RateLimiter(refreshFramesPerSecond, refresh);
inspectorTree = inspectorTreeFactory(
summaryTree: isSummaryTree,
treeType: treeType,
onNodeAdded: _onNodeAdded,
onHover: highlightShowNode,
onSelectionChange: selectionChanged,
onExpand: _onExpand,
);
if (isSummaryTree) {
details = InspectorController(
inspectorService: inspectorService,
inspectorTreeFactory: inspectorTreeFactory,
treeType: treeType,
parent: this,
isSummaryTree: false,
);
} else {
details = null;
}
flutterIsolateSubscription = serviceManager.isolateManager
.getSelectedIsolate((IsolateRef flutterIsolate) {
// Any time we have a new isolate it means the previous isolate stopped.
onIsolateStopped();
});
}
/// Maximum frame rate to refresh the inspector at to avoid taxing the
/// physical device with too many requests to recompute properties and trees.
///
/// A value up to around 30 frames per second could be reasonable for
/// debugging highly interactive cases particularly when the user is on a
/// simulator or high powered native device. The frame rate is set low
/// for now mainly to minimize risk.
static const double refreshFramesPerSecond = 5.0;
final bool isSummaryTree;
/// Parent InspectorController if this is a details subtree.
InspectorController parent;
InspectorController details;
InspectorTree inspectorTree;
final FlutterTreeType treeType;
final InspectorService inspectorService;
StreamSubscription<IsolateRef> flutterIsolateSubscription;
bool _disposed = false;
RateLimiter _refreshRateLimiter;
/// Groups used to manage and cancel requests to load data to display directly
/// in the tree.
InspectorObjectGroupManager _treeGroups;
/// Groups used to manage and cancel requests to determine what the current
/// selection is.
///
/// This group needs to be kept separate from treeGroups as the selection is
/// shared more with the details subtree.
/// TODO(jacobr): is there a way we can unify the selection and tree groups?
InspectorObjectGroupManager _selectionGroups;
/// Node being highlighted due to the current hover.
InspectorTreeNode get currentShowNode => inspectorTree.hover;
set currentShowNode(InspectorTreeNode node) => inspectorTree.hover = node;
bool flutterAppFrameReady = false;
bool treeLoadStarted = false;
RemoteDiagnosticsNode subtreeRoot;
bool programaticSelectionChangeInProgress = false;
InspectorTreeNode selectedNode;
InspectorTreeNode lastExpanded;
bool isActive = false;
final Map<InspectorInstanceRef, InspectorTreeNode> valueToInspectorTreeNode =
{};
/// When visibleToUser is false we should dispose all allocated objects and
/// not perform any actions.
bool visibleToUser = false;
bool highlightNodesShownInBothTrees = false;
bool get detailsSubtree => parent != null;
RemoteDiagnosticsNode get selectedDiagnostic => selectedNode?.diagnostic;
FlutterTreeType getTreeType() {
return treeType;
}
void setVisibleToUser(bool visible) {
if (visibleToUser == visible) {
return;
}
visibleToUser = visible;
details?.setVisibleToUser(visible);
if (visibleToUser) {
if (parent == null) {
maybeLoadUI();
}
} else {
shutdownTree(false);
}
}
bool hasDiagnosticsValue(InspectorInstanceRef ref) {
return valueToInspectorTreeNode.containsKey(ref);
}
RemoteDiagnosticsNode findDiagnosticsValue(InspectorInstanceRef ref) {
return valueToInspectorTreeNode[ref]?.diagnostic;
}
void endShowNode() {
highlightShowNode(null, null);
}
bool highlightShowFromNodeInstanceRef(InspectorInstanceRef ref) {
return highlightShowNode(valueToInspectorTreeNode[ref], null);
}
bool highlightShowNode(InspectorTreeNode node, Icon icon) {
if (node == null && parent != null) {
// If nothing is highlighted, highlight the node selected in the parent
// tree so user has context of where the node selected in the parent is
// in the details tree.
node = findMatchingInspectorTreeNode(parent.selectedDiagnostic);
}
currentShowNode = node;
return true;
}
InspectorTreeNode findMatchingInspectorTreeNode(RemoteDiagnosticsNode node) {
if (node?.valueRef == null) {
return null;
}
return valueToInspectorTreeNode[node.valueRef];
}
Future<void> getPendingUpdateDone() async {
// Wait for the selection to be resolved followed by waiting for the tree to be computed.
await _selectionGroups?.pendingUpdateDone;
await _treeGroups?.pendingUpdateDone;
// TODO(jacobr): are there race conditions we need to think mroe carefully about here?
}
Future<void> refresh() {
if (!visibleToUser) {
// We will refresh again once we are visible.
// There is a risk a refresh got triggered before the view was visble.
return Future.value(null);
}
// TODO(jacobr): refresh the tree as well as just the properties.
if (details != null) {
return Future.wait(
[getPendingUpdateDone(), details.getPendingUpdateDone()]);
} else {
return getPendingUpdateDone();
}
}
void shutdownTree(bool isolateStopped) {
// It is critical we clear all data that is kept alive by inspector object
// references in this method as that stale data will trigger inspector
// exceptions.
programaticSelectionChangeInProgress = true;
_treeGroups.clear(isolateStopped);
_selectionGroups.clear(isolateStopped);
currentShowNode = null;
selectedNode = null;
lastExpanded = null;
selectedNode = null;
subtreeRoot = null;
inspectorTree.root = inspectorTree.createNode();
details?.shutdownTree(isolateStopped);
programaticSelectionChangeInProgress = false;
valueToInspectorTreeNode.clear();
}
void onIsolateStopped() {
flutterAppFrameReady = false;
treeLoadStarted = false;
shutdownTree(true);
}
@override
Future<void> onForceRefresh() {
assert(!_disposed);
if (!visibleToUser || _disposed) {
return Future.value(null);
}
recomputeTreeRoot(null, null, false);
return getPendingUpdateDone();
}
void setActivate(bool enabled) {
if (!enabled) {
onIsolateStopped();
isActive = false;
return;
}
if (isActive) {
// Already activated.
return;
}
isActive = true;
inspectorService.addClient(this);
maybeLoadUI();
}
Future<void> maybeLoadUI() async {
if (!visibleToUser || !isActive) {
return;
}
if (flutterAppFrameReady) {
// We need to start by querying the inspector service to find out the
// current state of the UI.
await inspectorService.inferPubRootDirectoryIfNeeded();
await updateSelectionFromService(firstFrame: true);
} else {
final ready = await inspectorService.isWidgetTreeReady();
flutterAppFrameReady = ready;
if (isActive && ready) {
await maybeLoadUI();
}
}
}
Future<void> recomputeTreeRoot(RemoteDiagnosticsNode newSelection,
RemoteDiagnosticsNode detailsSelection, bool setSubtreeRoot) async {
assert(!_disposed);
if (_disposed) {
return;
}
_treeGroups.cancelNext();
try {
final group = _treeGroups.next;
final node = await (detailsSubtree
? group.getDetailsSubtree(subtreeRoot)
: group.getRoot(treeType));
if (node == null || group.disposed) {
return;
}
// TODO(jacobr): as a performance optimization we should check if the
// new tree is identical to the existing tree in which case we should
// dispose the new tree and keep the old tree.
_treeGroups.promoteNext();
clearValueToInspectorTreeNodeMapping();
if (node != null) {
final InspectorTreeNode rootNode = inspectorTree.setupInspectorTreeNode(
inspectorTree.createNode(),
node,
expandChildren: true,
expandProperties: false,
);
inspectorTree.root = rootNode;
} else {
inspectorTree.root = inspectorTree.createNode();
}
refreshSelection(newSelection, detailsSelection, setSubtreeRoot);
} catch (error) {
_logError(error);
_treeGroups.cancelNext();
return;
}
}
void clearValueToInspectorTreeNodeMapping() {
if (parent != null) {
valueToInspectorTreeNode.keys.forEach(parent.maybeUpdateValueUI);
}
valueToInspectorTreeNode.clear();
}
/// Show the details subtree starting with node subtreeRoot highlighting
/// node subtreeSelection.
void showDetailSubtrees(
RemoteDiagnosticsNode subtreeRoot,
RemoteDiagnosticsNode subtreeSelection,
) {
this.subtreeRoot = subtreeRoot;
details?.setSubtreeRoot(subtreeRoot, subtreeSelection);
}
InspectorInstanceRef getSubtreeRootValue() {
return subtreeRoot?.valueRef;
}
void setSubtreeRoot(
RemoteDiagnosticsNode node,
RemoteDiagnosticsNode selection,
) {
assert(detailsSubtree);
selection ??= node;
if (node != null && node == subtreeRoot) {
// Select the new node in the existing subtree.
applyNewSelection(selection, null, false);
return;
}
subtreeRoot = node;
if (node == null) {
// Passing in a null node indicates we should clear the subtree and free any memory allocated.
shutdownTree(false);
return;
}
// Clear now to eliminate frame of highlighted nodes flicker.
clearValueToInspectorTreeNodeMapping();
recomputeTreeRoot(selection, null, false);
}
InspectorTreeNode getSubtreeRootNode() {
if (subtreeRoot == null) {
return null;
}
return valueToInspectorTreeNode[subtreeRoot.valueRef];
}
void refreshSelection(RemoteDiagnosticsNode newSelection,
RemoteDiagnosticsNode detailsSelection, bool setSubtreeRoot) {
newSelection ??= selectedDiagnostic;
setSelectedNode(findMatchingInspectorTreeNode(newSelection));
syncSelectionHelper(setSubtreeRoot, detailsSelection);
if (details != null) {
if (subtreeRoot != null && getSubtreeRootNode() == null) {
subtreeRoot = newSelection;
details.setSubtreeRoot(newSelection, detailsSelection);
}
}
syncTreeSelection();
}
void syncTreeSelection() {
programaticSelectionChangeInProgress = true;
inspectorTree.selection = selectedNode;
programaticSelectionChangeInProgress = false;
animateTo(selectedNode);
}
void selectAndShowNode(RemoteDiagnosticsNode node) {
if (node == null) {
return;
}
selectAndShowInspectorInstanceRef(node.valueRef);
}
void selectAndShowInspectorInstanceRef(InspectorInstanceRef ref) {
final node = valueToInspectorTreeNode[ref];
if (node == null) {
return;
}
setSelectedNode(node);
syncTreeSelection();
}
InspectorTreeNode getTreeNode(RemoteDiagnosticsNode node) {
if (node == null) {
return null;
}
return valueToInspectorTreeNode[node.valueRef];
}
void maybeUpdateValueUI(InspectorInstanceRef valueRef) {
final node = valueToInspectorTreeNode[valueRef];
if (node == null) {
// The value isn't shown in the parent tree. Nothing to do.
return;
}
inspectorTree.nodeChanged(node);
}
@override
void onFlutterFrame() {
flutterAppFrameReady = true;
if (!visibleToUser) {
return;
}
if (!treeLoadStarted) {
treeLoadStarted = true;
// This was the first frame.
maybeLoadUI();
}
_refreshRateLimiter.scheduleRequest();
}
bool identicalDiagnosticsNodes(
RemoteDiagnosticsNode a,
RemoteDiagnosticsNode b,
) {
if (a == b) {
return true;
}
if (a == null || b == null) {
return false;
}
return a.dartDiagnosticRef == b.dartDiagnosticRef;
}
@override
void onInspectorSelectionChanged() {
if (!visibleToUser) {
// Don't do anything. We will update the view once it is visible again.
return;
}
if (detailsSubtree) {
// Wait for the master to update.
return;
}
updateSelectionFromService(firstFrame: false);
}
Future<void> updateSelectionFromService({@required bool firstFrame}) async {
if (_selectionGroups == null) {
// Already disposed. Ignore this requested to update selection.
return;
}
treeLoadStarted = true;
_selectionGroups.cancelNext();
final group = _selectionGroups.next;
final pendingSelectionFuture =
group.getSelection(selectedDiagnostic, treeType, isSummaryTree);
final Future<RemoteDiagnosticsNode> pendingDetailsFuture = isSummaryTree
? group.getSelection(selectedDiagnostic, treeType, false)
: null;
try {
final RemoteDiagnosticsNode newSelection = await pendingSelectionFuture;
if (group.disposed) return;
RemoteDiagnosticsNode detailsSelection;
if (pendingDetailsFuture != null) {
detailsSelection = await pendingDetailsFuture;
if (group.disposed) return;
}
if (!firstFrame &&
detailsSelection?.valueRef == details.selectedDiagnostic?.valueRef &&
newSelection?.valueRef == selectedDiagnostic?.valueRef) {
// No need to change the selection as it didn't actually change.
_selectionGroups.cancelNext();
return;
}
_selectionGroups.promoteNext();
subtreeRoot = newSelection;
applyNewSelection(newSelection, detailsSelection, true);
} catch (error) {
if (_selectionGroups.next == group) {
_logError(error);
_selectionGroups.cancelNext();
}
}
}
void applyNewSelection(
RemoteDiagnosticsNode newSelection,
RemoteDiagnosticsNode detailsSelection,
bool setSubtreeRoot,
) {
final InspectorTreeNode nodeInTree =
findMatchingInspectorTreeNode(newSelection);
if (nodeInTree == null) {
// The tree has probably changed since we last updated. Do a full refresh
// so that the tree includes the new node we care about.
recomputeTreeRoot(newSelection, detailsSelection, setSubtreeRoot);
}
refreshSelection(newSelection, detailsSelection, setSubtreeRoot);
}
void animateTo(InspectorTreeNode node) {
if (node == null) {
return;
}
final List<InspectorTreeNode> targets = [node];
// Backtrack to the the first non-property parent so that all properties
// for the node are visible if one property is animated to. This is helpful
// as typically users want to view the properties of a node as a chunk.
while (node.parent != null && node.diagnostic?.isProperty == true) {
node = node.parent;
}
// Make sure we scroll so that immediate un-expanded children
// are also in view. There is no risk in including these children as
// the amount of space they take up is bounded. This also ensures that if
// a node is selected, its properties will also be selected as by
// convention properties are the first children of a node and properties
// typically do not have children and are never expanded by default.
for (InspectorTreeNode child in node.children) {
final RemoteDiagnosticsNode diagnosticsNode = child.diagnostic;
targets.add(child);
if (!child.isLeaf && child.isExpanded) {
// Stop if we get to expanded children as they might be too large
// to try to scroll into view.
break;
}
if (diagnosticsNode != null && !diagnosticsNode.isProperty) {
break;
}
}
inspectorTree.animateToTargets(targets);
}
void setSelectedNode(InspectorTreeNode newSelection) {
if (newSelection == selectedNode) {
return;
}
if (selectedNode != null) {
if (!detailsSubtree) {
inspectorTree.nodeChanged(selectedNode.parent);
}
}
selectedNode = newSelection;
lastExpanded = null; // New selected node takes precedence.
endShowNode();
if (details != null) {
details.endShowNode();
} else if (parent != null) {
parent.endShowNode();
}
animateTo(selectedNode);
}
void _onExpand(InspectorTreeNode node) {
inspectorTree.maybePopulateChildren(node);
}
void selectionChanged() {
if (visibleToUser == false) {
return;
}
final InspectorTreeNode node = inspectorTree.selection;
if (node != null) {
inspectorTree.maybePopulateChildren(node);
}
if (programaticSelectionChangeInProgress) {
return;
}
if (node != null) {
setSelectedNode(node);
// Don't reroot if the selected value is already visible in the details tree.
final bool maybeReroot = isSummaryTree &&
details != null &&
selectedDiagnostic != null &&
!details.hasDiagnosticsValue(selectedDiagnostic.valueRef);
syncSelectionHelper(maybeReroot, null);
if (maybeReroot == false) {
if (isSummaryTree && details != null) {
details.selectAndShowNode(selectedDiagnostic);
} else if (parent != null) {
parent.selectAndShowNode(firstAncestorInParentTree(selectedNode));
}
}
}
}
RemoteDiagnosticsNode firstAncestorInParentTree(InspectorTreeNode node) {
if (parent == null) {
return node.diagnostic;
}
while (node != null) {
final diagnostic = node.diagnostic;
if (diagnostic != null &&
parent.hasDiagnosticsValue(diagnostic.valueRef)) {
return parent.findDiagnosticsValue(diagnostic.valueRef);
}
node = node.parent;
}
return null;
}
void syncSelectionHelper(
bool maybeRerootDetailsTree, RemoteDiagnosticsNode detailsSelection) {
if (!detailsSubtree && selectedNode != null) {
inspectorTree.nodeChanged(selectedNode.parent);
}
final RemoteDiagnosticsNode diagnostic = selectedDiagnostic;
if (diagnostic != null) {
if (diagnostic.isCreatedByLocalProject) {
_navigateTo(diagnostic);
}
}
if (detailsSubtree || details == null) {
if (diagnostic != null) {
var toSelect = selectedNode;
while (toSelect != null && toSelect.diagnostic.isProperty) {
toSelect = toSelect.parent;
}
if (toSelect != null) {
final diagnosticToSelect = toSelect.diagnostic;
diagnosticToSelect.setSelectionInspector(true);
}
}
}
if (maybeRerootDetailsTree) {
showDetailSubtrees(diagnostic, detailsSelection);
} else if (diagnostic != null) {
// We can't rely on the details tree to update the selection on the server in this case.
final selection = detailsSelection ?? diagnostic;
selection.setSelectionInspector(true);
}
}
void _navigateTo(RemoteDiagnosticsNode diagnostic) {
// TODO(jacobr): dispatch an event over the inspectorService requesting a
// navigate operation.
}
void dispose() {
assert(!_disposed);
_disposed = true;
flutterIsolateSubscription.cancel();
if (inspectorService != null) {
shutdownTree(false);
}
_treeGroups?.clear(false);
_treeGroups = null;
_selectionGroups?.clear(false);
_selectionGroups = null;
details?.dispose();
}
static String treeTypeDisplayName(FlutterTreeType treeType) {
switch (treeType) {
case FlutterTreeType.widget:
return 'Widget';
case FlutterTreeType.renderObject:
return 'Render Objects';
default:
return null;
}
}
void _onNodeAdded(
InspectorTreeNode node,
RemoteDiagnosticsNode diagnosticsNode,
) {
final InspectorInstanceRef valueRef = diagnosticsNode.valueRef;
// Properties do not have unique values so should not go in the valueToInspectorTreeNode map.
if (valueRef.id != null && !diagnosticsNode.isProperty) {
valueToInspectorTreeNode[valueRef] = node;
}
parent?.maybeUpdateValueUI(valueRef);
}
}