Skip to content

Add an "ide" component - #13708

Open
ericwindmill wants to merge 19 commits into
mainfrom
ide-component
Open

Add an "ide" component#13708
ericwindmill wants to merge 19 commits into
mainfrom
ide-component

Conversation

@ericwindmill

@ericwindmill ericwindmill commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description of what this PR is changing or adding, and why:

This is adding an interactive IDE component, because its cool. I'm using it for the FlutterBench updates, but want to land this separately to keep the PR sane.

Also adds min/max width breakpoint scss mixin to site-shared. In a future PR, we should remove breakpoints file from sites/www, and also update all scss to use the breakpoints, but doing so here would add many files to this PR.

Screenshot 2026-08-15 at 11 53 41 AM Screenshot 2026-08-15 at 11 53 55 AM Screenshot 2026-08-15 at 11 54 18 AM

Comment thread packages/site_shared/lib/_sass/base/_breakpoints.scss
Comment thread sites/docs/src/data/flutter_bench_task_example.yml Outdated
@flutter-website-bot

flutter-website-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Staged preview of the updated flutter.dev site (updated for commit 68a0f0c):

https://flutter-dev-230821--www-pr13708-ide-component-djgv8rr8.web.app

@flutter-website-bot

flutter-website-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Staged preview of the updated docs.flutter.dev site (updated for commit 68a0f0c):

https://flutter-docs-prod--docs-pr13708-ide-component-1g9w9f0b.web.app

@ericwindmill
ericwindmill marked this pull request as ready for review August 15, 2026 18:51
@ericwindmill
ericwindmill requested review from a team and sfshaza2 as code owners August 15, 2026 18:51

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new interactive file-tree explorer component (IdeExplorer) for documentation sites, including its SASS styles, Jaspr Dart components, a custom markdown parser, and client-side interactivity scripts. The feedback highlights several critical issues: a potential hydration mismatch in SSR due to a global static counter, invalid HTML nesting of a inside a

element, a bug in the client-side folder expansion logic, and potential duplicate DOM ID generation in the markdown parser when files in different directories share the same name. Actionable refactoring suggestions and code blocks are provided to resolve these issues.

return const Component.empty();
}

final effectiveInstanceId = instanceId ?? '${_nextInstanceId++}';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a global static counter (_nextInstanceId) to generate default IDs in a server-rendered (SSR) environment like Jaspr can lead to hydration mismatches. The server's counter will persist across requests and increment, while the client's counter will reset to 0 on page load. This causes the client-side IDs to mismatch the server-rendered IDs, breaking interactivity.

To resolve this, we can generate a stable, deterministic ID based on the roots of the explorer, which will be identical on both server and client.

Suggested change
final effectiveInstanceId = instanceId ?? '${_nextInstanceId++}';
final effectiveInstanceId = instanceId ??
roots.map((r) => r.id).join('_').hashCode.abs().toRadixString(36);

Comment on lines +515 to +517
final isFolderSelf =
sidebarTarget?.parentElement?.tagName.toLowerCase() == 'summary';
var current = isFolderSelf ? ownDetails?.parentElement : ownDetails;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Clicking a folder row currently selects the folder but does not expand/collapse it. This is because event.preventDefault() is called on the click event, and selectIdeNode explicitly skips opening the folder itself when isFolderSelf is true.

To fix this and ensure that selecting a folder also expands it (so users can see its contents), we should always start the expansion loop from ownDetails instead of skipping it.

Suggested change
final isFolderSelf =
sidebarTarget?.parentElement?.tagName.toLowerCase() == 'summary';
var current = isFolderSelf ? ownDetails?.parentElement : ownDetails;
var current = ownDetails;

Comment on lines +441 to +456
classes: ['ide-node-row', if (isSelected) 'active'].toClasses,
type: ButtonType.button,
attributes: {
'data-ide-select': domId,
if (isSelected) 'aria-current': 'true',
},
[
icon,
span(classes: 'ide-node-label', [.text(node.label)]),
_IdeBadgeDot(
badge: node.badge,
color: node.badgeColor ?? IdeBadgeColor.neutral,
),
],
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Nesting a <button> inside a <summary> element is invalid HTML and causes accessibility issues (screen readers get confused by nested interactive controls).

Since the <summary> element is already interactive, we should render a <span> instead of a <button> when the node is a folder. For files (which are rendered inside <li>), we can continue using a <button>.

    final classesList = ['ide-node-row', if (isSelected) 'active'].toClasses;
    final attributesMap = {
      'data-ide-select': domId,
      if (isSelected) 'aria-current': 'true',
    };
    final childrenList = [
      icon,
      span(classes: 'ide-node-label', [.text(node.label)]),
      _IdeBadgeDot(
        badge: node.badge,
        color: node.badgeColor ?? IdeBadgeColor.neutral,
      ),
    ];

    if (node.isFolder) {
      return span(
        classes: classesList,
        attributes: attributesMap,
        childrenList,
      );
    }

    return button(
      classes: classesList,
      type: ButtonType.button,
      attributes: attributesMap,
      childrenList,
    );

Comment on lines +52 to +67
final roots = [
for (final (index, rootEl) in projectRootElements.indexed)
IdeExplorerProjectRoot(
id: _generateNodeId(
rootEl.attributes,
_defaultRootPrefix,
index,
),
label: rootEl.attributes[_attrLabel] ?? '',
children: _parseTreeNodes(
rootEl.children,
builder,
customContents,
),
),
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Refactor the root generation loop to avoid duplicate ID generation and pass the root ID down to _parseTreeNodes to ensure unique IDs across the entire tree.

    final roots = <IdeExplorerProjectRoot>[];
    for (final (index, rootEl) in projectRootElements.indexed) {
      final rootId = _generateNodeId(
        rootEl.attributes,
        _defaultRootPrefix,
        index,
      );
      roots.add(IdeExplorerProjectRoot(
        id: rootId,
        label: rootEl.attributes[_attrLabel] ?? '',
        children: _parseTreeNodes(
          rootEl.children,
          builder,
          customContents,
          rootId,
        ),
      ));
    }

Comment on lines +76 to +89
String _generateNodeId(
Map<String, String> attributes,
String prefix,
int index,
) {
if (attributes[_attrId] != null) {
return attributes[_attrId]!;
}
final label = attributes[_attrLabel];
if (label != null && label.isNotEmpty) {
return slugify(label);
}
return '$prefix-$index';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If multiple files or folders in different directories have the same name (e.g., main.dart or README.md), they will generate duplicate IDs because _generateNodeId only uses the label. This causes duplicate DOM IDs and overwrites entries in the customContents map, leading to broken rendering and interactivity.

We can prevent this by making _generateNodeId accept an optional parentId to prefix the generated ID, ensuring uniqueness across different directories.

  String _generateNodeId(
    Map<String, String> attributes,
    String prefix,
    int index, [
    String? parentId,
  ]) {
    if (attributes[_attrId] != null) {
      return attributes[_attrId]!;
    }
    final label = attributes[_attrLabel];
    final localId = (label != null && label.isNotEmpty)
        ? slugify(label)
        : '$prefix-$index';
    return parentId != null ? '$parentId-$localId' : localId;
  }

Comment on lines +91 to +116
List<IdeTreeNode> _parseTreeNodes(
List<Node>? nodes,
NodesBuilder builder,
Map<String, Component> customContents,
) {
if (nodes == null || nodes.isEmpty) return const [];

final result = <IdeTreeNode>[];

for (final (index, child) in nodes.whereType<ElementNode>().indexed) {
if (child.tag != _tagIdeFolder && child.tag != _tagIdePage) {
continue;
}

final treeNode = _buildTreeNodeFromElement(
child,
index,
builder,
customContents,
);

result.add(treeNode);
}

return result;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update _parseTreeNodes to accept a parentId parameter and pass it down to _buildTreeNodeFromElement to support unique ID generation.

  List<IdeTreeNode> _parseTreeNodes(
    List<Node>? nodes,
    NodesBuilder builder,
    Map<String, Component> customContents,
    String parentId,
  ) {
    if (nodes == null || nodes.isEmpty) return const [];

    final result = <IdeTreeNode>[];

    for (final (index, child) in nodes.whereType<ElementNode>().indexed) {
      if (child.tag != _tagIdeFolder && child.tag != _tagIdePage) {
        continue;
      }

      final treeNode = _buildTreeNodeFromElement(
        child,
        index,
        builder,
        customContents,
        parentId,
      );

      result.add(treeNode);
    }

    return result;
  }

Comment on lines +119 to +154
IdeTreeNode _buildTreeNodeFromElement(
ElementNode element,
int index,
NodesBuilder builder,
Map<String, Component> customContents,
) {
final attributes = element.attributes;
final label = attributes[_attrLabel] ?? '';
final id = _generateNodeId(attributes, _defaultNodePrefix, index);

// Parse boolean attributes
final isDefaultPage = _getBoolAttribute(
attributes,
_attrIsDefaultPage,
defaultValue: false,
);
final startsClosed = _getBoolAttribute(
attributes,
_attrStartsClosed,
defaultValue: true,
);

// Parse badge attributes
final badge = attributes[_attrBadge];
final badgeColor = attributes[_attrBadgeColor] != null
? IdeBadgeColor.fromString(attributes[_attrBadgeColor])
: null;

final subtitle = attributes[_attrSubtitle];

// Recursively parse children
final nestedTreeNodes = _parseTreeNodes(
element.children,
builder,
customContents,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update _buildTreeNodeFromElement to accept a parentId parameter, pass it to _generateNodeId, and pass the current id as the parentId to the recursive _parseTreeNodes call.

  IdeTreeNode _buildTreeNodeFromElement(
    ElementNode element,
    int index,
    NodesBuilder builder,
    Map<String, Component> customContents,
    String parentId,
  ) {
    final attributes = element.attributes;
    final label = attributes[_attrLabel] ?? '';
    final id = _generateNodeId(attributes, _defaultNodePrefix, index, parentId);

    // Parse boolean attributes
    final isDefaultPage = _getBoolAttribute(
      attributes,
      _attrIsDefaultPage,
      defaultValue: false,
    );
    final startsClosed = _getBoolAttribute(
      attributes,
      _attrStartsClosed,
      defaultValue: true,
    );

    // Parse badge attributes
    final badge = attributes[_attrBadge];
    final badgeColor = attributes[_attrBadgeColor] != null
        ? IdeBadgeColor.fromString(attributes[_attrBadgeColor])
        : null;

    final subtitle = attributes[_attrSubtitle];

    // Recursively parse children
    final nestedTreeNodes = _parseTreeNodes(
      element.children,
      builder,
      customContents,
      id,
    );

@ericwindmill ericwindmill changed the title [WIP] Add an "ide" component Add an "ide" component Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants