| import 'package:devtools_icons/devtools_icons.dart'; |
| import 'package:jaspr/dom.dart'; |
| import 'package:jaspr/jaspr.dart'; |
| |
| import '../../../utils/styles.dart'; |
| import '../../shared/components/icon_button.dart'; |
| import '../../shared/components/material_icon.dart'; |
| |
| class InspectorTree extends StatefulComponent { |
| final Map<String, dynamic> tree; |
| final String? selectedNodeId; |
| final void Function(String nodeId, Map<String, dynamic> node) onNodeSelected; |
| final void Function(String nodeId, String widgetName, Map<String, dynamic> node) onTakeScreenshot; |
| final bool showImplementationWidgets; |
| |
| const InspectorTree({ |
| required this.tree, |
| required this.selectedNodeId, |
| required this.onNodeSelected, |
| required this.onTakeScreenshot, |
| required this.showImplementationWidgets, |
| super.key, |
| }); |
| |
| @override |
| State<InspectorTree> createState() => _InspectorTreeState(); |
| |
| @css |
| static List<StyleRule> get styles => _InspectorTreeState.styles; |
| } |
| |
| class _InspectorTreeState extends State<InspectorTree> { |
| final Set<String> _expandedNodes = {}; |
| String? _lastRootId; |
| |
| @override |
| void didUpdateComponent(covariant InspectorTree oldComponent) { |
| super.didUpdateComponent(oldComponent); |
| // If the root tree node changes, reset state and run auto-expand |
| final oldRootId = (oldComponent.tree['valueId'] ?? oldComponent.tree['id']) as String?; |
| final newRootId = (component.tree['valueId'] ?? component.tree['id']) as String?; |
| if (oldRootId != newRootId) { |
| _lastRootId = newRootId; |
| _expandedNodes.clear(); |
| _determineInitialExpansion(component.tree, _expandedNodes); |
| if (component.selectedNodeId case final selectedId?) { |
| _expandPathToNode(component.tree, selectedId, _expandedNodes); |
| } |
| component.tree['_visited'] = true; |
| } else if (component.selectedNodeId != null && component.selectedNodeId != oldComponent.selectedNodeId) { |
| // Expand the path to the newly selected node |
| _expandPathToNode(component.tree, component.selectedNodeId!, _expandedNodes); |
| } |
| } |
| |
| @override |
| Component build(BuildContext context) { |
| final tree = component.tree; |
| final rootId = (tree['valueId'] ?? tree['id']) as String?; |
| |
| if (rootId != null && (_expandedNodes.isEmpty || rootId != _lastRootId) && !tree.containsKey('_visited')) { |
| _lastRootId = rootId; |
| _expandedNodes.clear(); |
| _determineInitialExpansion(tree, _expandedNodes); |
| if (component.selectedNodeId case final selectedId?) { |
| _expandPathToNode(tree, selectedId, _expandedNodes); |
| } |
| tree['_visited'] = true; |
| } |
| |
| final flatRows = <_FlatTreeRow>[]; |
| final virtualRoots = _getVirtualRoots(tree, component.showImplementationWidgets); |
| for (int i = 0; i < virtualRoots.length; i++) { |
| final isLast = i == virtualRoots.length - 1; |
| _flattenTree( |
| node: virtualRoots[i], |
| depth: 0, |
| activeLines: const [], |
| isLastChild: isLast, |
| isIndented: false, |
| result: flatRows, |
| expandedNodes: _expandedNodes, |
| showAll: component.showImplementationWidgets, |
| ); |
| } |
| |
| return div(classes: 'inspector-tree-container', [ |
| for (final row in flatRows) _buildRow(row), |
| ]); |
| } |
| |
| // Traverse the tree recursively to auto-expand: |
| // 1. The root node. |
| // 2. Any node created by the local project (createdByLocalProject == true). |
| // 3. Ancestors of local project widgets. |
| // 4. Single-child parent nodes (so developers don't have to click through single padding/align wrappers). |
| bool _determineInitialExpansion(Map<String, dynamic> node, Set<String> expanded) { |
| final id = (node['valueId'] ?? node['id']) as String?; |
| if (id == null) { |
| return false; |
| } |
| |
| final children = (node['children'] as List<dynamic>? ?? const []).whereType<Map<String, dynamic>>().toList(); |
| |
| final isCreatedByLocal = node['createdByLocalProject'] == true; |
| |
| bool hasLocalDescendant = false; |
| for (final child in children) { |
| if (_determineInitialExpansion(child, expanded)) { |
| hasLocalDescendant = true; |
| } |
| } |
| |
| final isRoot = node == component.tree; |
| final isSingleChild = children.length == 1; |
| |
| final shouldExpand = isRoot || isCreatedByLocal || hasLocalDescendant || isSingleChild; |
| |
| if (shouldExpand) { |
| expanded.add(id); |
| } |
| |
| return isCreatedByLocal || hasLocalDescendant; |
| } |
| |
| // Find the selected node and expand all its ancestors to make it visible |
| bool _expandPathToNode(Map<String, dynamic> node, String targetId, Set<String> expanded) { |
| final id = (node['valueId'] ?? node['id']) as String?; |
| if (id == null) { |
| return false; |
| } |
| |
| if (id == targetId) { |
| return true; |
| } |
| |
| final children = (node['children'] as List<dynamic>? ?? const []).whereType<Map<String, dynamic>>().toList(); |
| |
| for (final child in children) { |
| if (_expandPathToNode(child, targetId, expanded)) { |
| expanded.add(id); |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| List<Map<String, dynamic>> _getVirtualRoots(Map<String, dynamic> rootNode, bool showAll) { |
| final isRootRendered = showAll || (rootNode['createdByLocalProject'] == true); |
| if (isRootRendered) { |
| return [rootNode]; |
| } |
| |
| final List<Map<String, dynamic>> virtualRoots = []; |
| void find(Map<String, dynamic> current) { |
| final children = (current['children'] as List<dynamic>? ?? const []).whereType<Map<String, dynamic>>(); |
| for (final child in children) { |
| final isChildRendered = showAll || (child['createdByLocalProject'] == true); |
| if (isChildRendered) { |
| virtualRoots.add(child); |
| } else { |
| find(child); |
| } |
| } |
| } |
| |
| find(rootNode); |
| return virtualRoots; |
| } |
| |
| List<Map<String, dynamic>> _getRenderedChildren(Map<String, dynamic> node, bool showAll) { |
| final List<Map<String, dynamic>> rendered = []; |
| |
| void find(Map<String, dynamic> current) { |
| final children = (current['children'] as List<dynamic>? ?? const []).whereType<Map<String, dynamic>>(); |
| for (final child in children) { |
| final isChildRendered = showAll || (child['createdByLocalProject'] == true); |
| if (isChildRendered) { |
| rendered.add(child); |
| } else { |
| find(child); |
| } |
| } |
| } |
| |
| find(node); |
| return rendered; |
| } |
| |
| void _flattenTree({ |
| required Map<String, dynamic> node, |
| required int depth, |
| required List<bool> activeLines, |
| required bool isLastChild, |
| required bool isIndented, |
| required List<_FlatTreeRow> result, |
| required Set<String> expandedNodes, |
| required bool showAll, |
| }) { |
| final id = (node['valueId'] ?? node['id']) as String? ?? ''; |
| final children = _getRenderedChildren(node, showAll); |
| final hasChildren = children.isNotEmpty; |
| |
| result.add( |
| _FlatTreeRow( |
| node: node, |
| depth: depth, |
| activeLines: activeLines, |
| hasChildren: hasChildren, |
| isLastChild: isLastChild, |
| isIndented: isIndented, |
| ), |
| ); |
| |
| if (hasChildren && expandedNodes.contains(id)) { |
| if (children.length == 1) { |
| _flattenTree( |
| node: children.first, |
| depth: depth, |
| activeLines: activeLines, |
| isLastChild: true, |
| isIndented: false, |
| result: result, |
| expandedNodes: expandedNodes, |
| showAll: showAll, |
| ); |
| } else { |
| for (int i = 0; i < children.length; i++) { |
| final child = children[i]; |
| final childIsLast = i == children.length - 1; |
| _flattenTree( |
| node: child, |
| depth: depth + 1, |
| activeLines: [...activeLines, !childIsLast], |
| isLastChild: childIsLast, |
| isIndented: true, |
| result: result, |
| expandedNodes: expandedNodes, |
| showAll: showAll, |
| ); |
| } |
| } |
| } |
| } |
| |
| Component _buildRow(_FlatTreeRow row) { |
| final node = row.node; |
| final id = (node['valueId'] ?? node['id']) as String? ?? ''; |
| final name = node['name'] as String?; |
| final description = node['description'] as String? ?? 'Widget'; |
| final hasChildren = row.hasChildren; |
| |
| final isExpanded = _expandedNodes.contains(id); |
| final isSelected = component.selectedNodeId == id; |
| final widgetName = _extractWidgetName(description); |
| |
| return div( |
| key: ValueKey('node-$id'), |
| classes: 'tree-node-row${isSelected ? ' selected' : ''}', |
| events: { |
| 'click': (e) { |
| component.onNodeSelected(id, node); |
| }, |
| }, |
| [ |
| for (int j = 0; j < row.depth; j++) |
| div(classes: 'tree-node-indent-spacer', [ |
| if (row.isIndented && j == row.depth - 1) ...[ |
| const div(classes: 'line-horizontal-full', []), |
| if (row.isLastChild) |
| const div(classes: 'line-vertical-top-half', []) |
| else |
| const div(classes: 'line-vertical-full', []), |
| ] else if (row.activeLines[j]) |
| const div(classes: 'line-vertical-full', []), |
| ]), |
| |
| span( |
| classes: 'tree-node-arrow', |
| [ |
| if (row.isIndented) const div(classes: 'line-horizontal-arrow-half', []), |
| |
| if (hasChildren && isExpanded && _hasSingleChild(node)) |
| const div(classes: 'line-vertical-center-bottom', []), |
| |
| if (hasChildren) |
| svg( |
| viewBox: '0 0 24 24', |
| attributes: {'width': '14', 'height': '14', 'fill': 'currentColor'}, |
| styles: Styles(transform: isExpanded ? .rotate(0.deg) : .rotate((-90).deg)), |
| events: { |
| 'click': (e) { |
| e.stopPropagation(); |
| setState(() { |
| if (isExpanded) { |
| _expandedNodes.remove(id); |
| } else { |
| _expandedNodes.add(id); |
| } |
| }); |
| }, |
| }, |
| [ |
| const path(attributes: {'d': MyMaterialIcons.keyboardArrowDown}, []), |
| ], |
| ), |
| ], |
| ), |
| |
| span(classes: 'tree-node-content', [ |
| _buildNodeIcon(description), |
| if (name != null && name.isNotEmpty) ...[ |
| span(classes: 'tree-node-prop-name', [.text('$name:')]), |
| ], |
| span(classes: 'tree-node-widget', [.text(widgetName)]), |
| if (_extractWidgetDetail(description) case final detail?) ...[ |
| span(classes: 'tree-node-desc', [.text(detail)]), |
| ], |
| ]), |
| |
| div(classes: 'tree-node-actions', [ |
| IconButton( |
| tooltip: 'Add snapshot of $widgetName to Agent', |
| classes: 'tree-node-action-btn', |
| onClick: (e) { |
| e.stopPropagation(); |
| component.onTakeScreenshot(id, widgetName, node); |
| }, |
| child: div(styles: Styles(display: .flex, alignItems: .center, gap: Gap.all(3.px)), [ |
| materialIcon(MyMaterialIcons.photoCamera, size: 12.0), |
| const span([.text('Agent')]), |
| ]), |
| ), |
| ]), |
| ], |
| ); |
| } |
| |
| Component _buildNodeIcon(String description) { |
| final widgetName = _extractWidgetName(description); |
| final widgetTheme = WidgetTheme.fromName(widgetName); |
| |
| if (widgetTheme.iconAsset case final assetPath?) { |
| final src = 'packages/devtools_icons/$assetPath'; |
| return img( |
| classes: 'tree-node-icon', |
| src: src, |
| ); |
| } else { |
| final firstLetter = widgetName.isNotEmpty ? widgetName[0].toUpperCase() : 'W'; |
| return div( |
| classes: 'tree-node-icon-fallback', |
| styles: Styles( |
| backgroundColor: Color(widgetTheme.color), |
| ), |
| [.text(firstLetter)], |
| ); |
| } |
| } |
| |
| // Extract the widget type (e.g. Container from "Container(hasBorder: true)") |
| String _extractWidgetName(String desc) { |
| final idx = desc.indexOf('('); |
| if (idx != -1) { |
| return desc.substring(0, idx); |
| } |
| final colIdx = desc.indexOf(':'); |
| if (colIdx != -1) { |
| return desc.substring(0, colIdx); |
| } |
| return desc; |
| } |
| |
| // Extract configuration detail inside parens if any |
| String? _extractWidgetDetail(String desc) { |
| final idx = desc.indexOf('('); |
| if (idx != -1 && desc.endsWith(')')) { |
| final detail = desc.substring(idx + 1, desc.length - 1); |
| if (detail.trim().isNotEmpty) { |
| return '($detail)'; |
| } |
| } |
| return null; |
| } |
| |
| bool _hasSingleChild(Map<String, dynamic> node) { |
| final children = _getRenderedChildren(node, component.showImplementationWidgets); |
| return children.length == 1; |
| } |
| |
| static List<StyleRule> get styles => [ |
| css('.inspector-tree-container').styles( |
| display: .flex, |
| minHeight: .zero, |
| padding: Padding.all(4.px), |
| overflow: .auto, |
| flexDirection: .column, |
| flex: const Flex(grow: 1, basis: .zero), |
| ), |
| // Tree styling |
| css('.tree-node-row').styles( |
| display: .flex, |
| position: const .relative(), |
| height: 24.px, |
| padding: const .only(right: .zero), |
| radius: .circular(4.px), |
| cursor: .pointer, |
| userSelect: .none, |
| transition: Transition('background-color', duration: 100.ms, curve: .ease), |
| alignItems: .center, |
| flex: const .shrink(0), |
| fontFamily: const .list([FontFamilies.courierNew, FontFamilies.monospace]), |
| fontSize: 12.px, |
| ), |
| css('.tree-node-row:hover').styles( |
| backgroundColor: colorOnSurface.withOpacity(0.04), |
| ), |
| css('.tree-node-row.selected').styles( |
| color: colorOnSurface, |
| backgroundColor: colorPrimary.withOpacity(0.2), |
| ), |
| css('.tree-node-arrow').styles( |
| display: .inlineFlex, |
| position: const .relative(), |
| width: 16.px, |
| margin: .only(left: 4.px, right: 4.px), |
| transition: Transition('transform', duration: 150.ms, curve: .ease), |
| justifyContent: .center, |
| alignItems: .center, |
| alignSelf: .stretch, |
| color: colorOnSurfaceVariant, |
| ), |
| css('.tree-node-arrow:hover').styles( |
| color: colorOnSurface, |
| ), |
| css('.tree-node-indent-spacer').styles( |
| display: .inlineBlock, |
| position: const .relative(), |
| width: 24.px, |
| flex: const .shrink(0), |
| alignSelf: .stretch, |
| ), |
| css('.line-vertical-full').styles( |
| position: .absolute(left: 11.5.px, top: .zero, bottom: .zero), |
| width: 1.px, |
| backgroundColor: colorOnSurfaceVariant, |
| ), |
| css('.line-vertical-top-half').styles( |
| position: .absolute(left: 11.5.px, top: .zero), |
| width: 1.px, |
| height: 50.percent, |
| backgroundColor: colorOnSurfaceVariant, |
| ), |
| css('.line-horizontal-full').styles( |
| position: .absolute(left: 11.5.px, right: .zero, top: 11.5.px), |
| height: 1.px, |
| backgroundColor: colorOnSurfaceVariant, |
| ), |
| css('.line-horizontal-arrow-half').styles( |
| position: .absolute(left: (-4).px, top: 11.5.px), |
| width: 4.px, |
| height: 1.px, |
| backgroundColor: colorOnSurfaceVariant, |
| ), |
| css('.line-vertical-center-bottom').styles( |
| position: .absolute(left: 26.5.px, top: 20.px), |
| width: 1.px, |
| height: 8.px, |
| backgroundColor: colorOnSurfaceVariant, |
| ), |
| css('.tree-node-content').styles( |
| display: .inlineFlex, |
| minWidth: .zero, |
| overflow: .hidden, |
| alignItems: .center, |
| flex: const .grow(1), |
| ), |
| css('.tree-node-widget').styles( |
| overflow: .hidden, |
| color: colorPrimary, |
| fontWeight: .w600, |
| textOverflow: .ellipsis, |
| whiteSpace: .noWrap, |
| ), |
| css('.tree-node-row.selected .tree-node-widget').styles( |
| color: colorPrimary, |
| ), |
| css('.tree-node-prop-name').styles( |
| margin: Margin.only(right: 4.px), |
| color: colorOnSurfaceVariant, |
| ), |
| css('.tree-node-row.selected .tree-node-prop-name').styles( |
| color: colorOnSurfaceVariant, |
| ), |
| css('.tree-node-desc').styles( |
| margin: Margin.only(left: 4.px), |
| opacity: 0.7, |
| color: colorOnSurface, |
| ), |
| css('.tree-node-row.selected .tree-node-desc').styles( |
| opacity: 0.9, |
| color: colorOnSurface, |
| ), |
| css('.tree-node-icon').styles( |
| width: 14.px, |
| height: 14.px, |
| margin: Margin.only(right: 6.px), |
| flex: const .shrink(0), |
| ), |
| css('.tree-node-icon-fallback').styles( |
| display: .inlineFlex, |
| width: 14.px, |
| height: 14.px, |
| margin: Margin.only(right: 6.px), |
| radius: .circular(50.percent), |
| justifyContent: .center, |
| alignItems: .center, |
| flex: const .shrink(0), |
| color: const Color('#231F20'), |
| fontSize: 9.px, |
| fontWeight: .w600, |
| ), |
| css('.tree-node-actions').styles( |
| display: .flex, |
| height: 24.px, |
| margin: const .only(left: .auto), |
| opacity: 0, |
| transition: Transition('opacity', duration: 150.ms, curve: .ease), |
| alignItems: .stretch, |
| flex: const .shrink(0), |
| ), |
| css('.tree-node-row:hover .tree-node-actions, .tree-node-row.selected .tree-node-actions').styles( |
| opacity: 1, |
| ), |
| css( |
| '.inspector-tree-container:has(.tree-node-row:hover) .tree-node-row.selected:not(:hover) .tree-node-actions', |
| ).styles( |
| opacity: 0, |
| ), |
| css('.tree-node-action-btn.icon-button').styles( |
| width: .auto, |
| height: 100.percent, |
| padding: Padding.symmetric(horizontal: 8.px), |
| radius: .only( |
| topRight: Radius.circular(4.px), |
| bottomRight: Radius.circular(4.px), |
| topLeft: Radius.zero, |
| bottomLeft: Radius.zero, |
| ), |
| transition: Transition('background-color', duration: 150.ms, curve: .ease), |
| color: colorOnSurface, |
| fontSize: 11.px, |
| backgroundColor: colorContainerHigh.withOpacity(0.8), |
| ), |
| css('.tree-node-action-btn.icon-button:not(.disabled):hover').styles( |
| color: colorOnPrimary, |
| backgroundColor: colorPrimary.withOpacity(0.8), |
| ), |
| ]; |
| } |
| |
| class _FlatTreeRow { |
| final Map<String, dynamic> node; |
| final int depth; |
| final List<bool> activeLines; |
| final bool hasChildren; |
| final bool isLastChild; |
| final bool isIndented; |
| |
| _FlatTreeRow({ |
| required this.node, |
| required this.depth, |
| required this.activeLines, |
| required this.hasChildren, |
| required this.isLastChild, |
| required this.isIndented, |
| }); |
| } |