blob: 165cdf9ab67f7bab21ddab0e86db54ff0291bf25 [file]
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,
});
}