Add TreeGrid widget

Change-Id: Ie76338cdd031533c99ecb330a86c5bbc3f296db1
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tree_grid_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tree_grid_demo.ts
new file mode 100644
index 0000000..a47cebc
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tree_grid_demo.ts
@@ -0,0 +1,259 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {TreeGrid, TreeGridRow} from '../../../widgets/tree_grid';
+import {GridCell, GridHeaderCell} from '../../../widgets/grid';
+import {renderWidgetShowcase} from '../widgets_page_utils';
+import {Anchor} from '../../../widgets/anchor';
+import {CodeSnippet} from '../../../widgets/code_snippet';
+
+export function renderTreeGrid(): m.Children {
+  return [
+    m(
+      '.pf-widget-intro',
+      m('h1', 'TreeGrid'),
+      m('p', [
+        'TreeGrid is a specialized version of ',
+        m(Anchor, {href: '#!/widgets/grid'}, 'Grid'),
+        ' that automatically organizes rows into a tree structure based on slash-separated path keys.',
+      ]),
+      m('p', [
+        'Unlike the lower-level Grid component, TreeGrid handles the tree structure automatically. ',
+        'You simply provide flat rows with hierarchical paths, and TreeGrid builds the tree, ',
+        'manages expand/collapse state, and renders the appropriate indent levels and chevrons.',
+      ]),
+      m('ul', [
+        m('li', 'Automatic tree building from slash-separated paths'),
+        m('li', 'Built-in expand/collapse functionality'),
+        m('li', 'Automatic indent and chevron management'),
+        m('li', 'Supports virtualization for large datasets'),
+        m('li', 'All Grid features (column resizing, sorting, etc.)'),
+      ]),
+    ),
+
+    m('h2', 'Interactive Demo'),
+
+    renderWidgetShowcase({
+      renderWidget: ({virtualize}) => {
+        const rows: TreeGridRow[] = [
+          {
+            path: 'src/base/logging.cc',
+            cells: [
+              m(GridCell, 'logging.cc'),
+              m(GridCell, {align: 'right'}, '1,234'),
+              m(GridCell, 'C++'),
+            ],
+          },
+          {
+            path: 'src/base/string_utils.cc',
+            cells: [
+              m(GridCell, 'string_utils.cc'),
+              m(GridCell, {align: 'right'}, '856'),
+              m(GridCell, 'C++'),
+            ],
+          },
+          {
+            path: 'src/base/utils.cc',
+            cells: [
+              m(GridCell, 'utils.cc'),
+              m(GridCell, {align: 'right'}, '432'),
+              m(GridCell, 'C++'),
+            ],
+          },
+          {
+            path: 'src/trace_processor/db/table.cc',
+            cells: [
+              m(GridCell, 'table.cc'),
+              m(GridCell, {align: 'right'}, '2,145'),
+              m(GridCell, 'C++'),
+            ],
+          },
+          {
+            path: 'src/trace_processor/db/column.cc',
+            cells: [
+              m(GridCell, 'column.cc'),
+              m(GridCell, {align: 'right'}, '987'),
+              m(GridCell, 'C++'),
+            ],
+          },
+          {
+            path: 'ui/src/widgets/grid.ts',
+            cells: [
+              m(GridCell, 'grid.ts'),
+              m(GridCell, {align: 'right'}, '1,543'),
+              m(GridCell, 'TypeScript'),
+            ],
+          },
+          {
+            path: 'ui/src/widgets/tree_grid.ts',
+            cells: [
+              m(GridCell, 'tree_grid.ts'),
+              m(GridCell, {align: 'right'}, '267'),
+              m(GridCell, 'TypeScript'),
+            ],
+          },
+          {
+            path: 'ui/src/widgets/button.ts',
+            cells: [
+              m(GridCell, 'button.ts'),
+              m(GridCell, {align: 'right'}, '321'),
+              m(GridCell, 'TypeScript'),
+            ],
+          },
+          {
+            path: 'ui/src/assets/widgets/grid.scss',
+            cells: [
+              m(GridCell, 'grid.scss'),
+              m(GridCell, {align: 'right'}, '341'),
+              m(GridCell, 'SCSS'),
+            ],
+          },
+          {
+            path: 'ui/src/assets/theme.scss',
+            cells: [
+              m(GridCell, 'theme.scss'),
+              m(GridCell, {align: 'right'}, '156'),
+              m(GridCell, 'SCSS'),
+            ],
+          },
+          {
+            path: 'docs/README.md',
+            cells: [
+              m(GridCell, 'README.md'),
+              m(GridCell, {align: 'right'}, '89'),
+              m(GridCell, 'Markdown'),
+            ],
+          },
+          {
+            path: 'docs/contributing.md',
+            cells: [
+              m(GridCell, 'contributing.md'),
+              m(GridCell, {align: 'right'}, '432'),
+              m(GridCell, 'Markdown'),
+            ],
+          },
+        ];
+
+        return m(TreeGrid, {
+          key: virtualize ? 'treegrid-virtualized' : 'treegrid-full',
+          columns: [
+            {
+              key: 'name',
+              header: m(GridHeaderCell, 'File'),
+            },
+            {
+              key: 'lines',
+              header: m(GridHeaderCell, 'Lines'),
+            },
+            {
+              key: 'language',
+              header: m(GridHeaderCell, 'Language'),
+            },
+          ],
+          rows,
+          fillHeight: true,
+          virtualization: virtualize
+            ? {
+                rowHeightPx: 24,
+              }
+            : undefined,
+        });
+      },
+      initialOpts: {
+        virtualize: false,
+      },
+      noPadding: true,
+    }),
+
+    m('h2', 'Basic Usage'),
+    m('p', [
+      'TreeGrid automatically builds a tree from flat rows with slash-separated paths. ',
+      'Each row must have a ',
+      m('code', 'path'),
+      ' and an array of ',
+      m('code', 'cells'),
+      '.',
+    ]),
+    m(
+      'p',
+      m(CodeSnippet, {
+        text: `m(TreeGrid, {
+  columns: [
+    {key: 'name', header: m(GridHeaderCell, 'Name')},
+    {key: 'size', header: m(GridHeaderCell, 'Size')},
+  ],
+  rows: [
+    {
+      path: 'root/folder1/file1.txt',
+      cells: [m(GridCell, 'file1.txt'), m(GridCell, '1.2 KB')],
+    },
+    {
+      path: 'root/folder1/file2.txt',
+      cells: [m(GridCell, 'file2.txt'), m(GridCell, '856 B')],
+    },
+    {
+      path: 'root/folder2/file3.txt',
+      cells: [m(GridCell, 'file3.txt'), m(GridCell, '3.4 KB')],
+    },
+  ],
+  fillHeight: true,
+});`,
+      }),
+    ),
+
+    m('h2', 'How It Works'),
+    m('p', [
+      'TreeGrid parses the ',
+      m('code', 'path'),
+      ' field of each row (using ',
+      m('code', '/'),
+      ' as separator by default) and builds a tree structure. ',
+      'Intermediate nodes (folders) are created automatically and can be expanded/collapsed. ',
+      'Leaf nodes (files) display the provided cell data.',
+    ]),
+    m('p', [
+      'For example, the path ',
+      m('code', '"root/folder1/file.txt"'),
+      ' creates:',
+    ]),
+    m('ul', [
+      m('li', [m('code', 'root'), ' - intermediate node (folder)']),
+      m('li', [m('code', 'folder1'), ' - intermediate node (folder)']),
+      m('li', [m('code', 'file.txt'), ' - leaf node with your cell data']),
+    ]),
+
+    m('h2', 'Custom Separator'),
+    m('p', [
+      'You can use a different separator by setting the ',
+      m('code', 'separator'),
+      ' prop:',
+    ]),
+    m(
+      'p',
+      m(CodeSnippet, {
+        text: `m(TreeGrid, {
+  separator: '.',
+  rows: [
+    {
+      path: 'com.example.app.MainActivity',
+      cells: [...],
+    },
+  ],
+  ...
+});`,
+      }),
+    ),
+  ];
+}
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
index 2a30379..2d4e922 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
@@ -54,6 +54,7 @@
 import {renderTooltip} from './demos/tooltip_demo';
 import {renderTrackShell} from './demos/track_shell_demo';
 import {renderTree} from './demos/tree_demo';
+import {renderTreeGrid} from './demos/tree_grid_demo';
 import {renderTreeTable} from './demos/treetable_demo';
 import {renderVegaView} from './demos/vega_view_demo';
 import {renderVirtualCanvas} from './demos/virtual_canvas_demo';
@@ -104,6 +105,7 @@
   {id: 'tooltip', label: 'Tooltip', view: renderTooltip},
   {id: 'trackshell', label: 'TrackShell', view: renderTrackShell},
   {id: 'tree', label: 'Tree', view: renderTree},
+  {id: 'treegrid', label: 'TreeGrid', view: renderTreeGrid},
   {id: 'treetable', label: 'TreeTable', view: renderTreeTable},
   {id: 'vegaview', label: 'VegaView', view: renderVegaView},
   {id: 'virtualcanvas', label: 'VirtualCanvas', view: renderVirtualCanvas},
diff --git a/ui/src/widgets/tree_grid.ts b/ui/src/widgets/tree_grid.ts
new file mode 100644
index 0000000..e36e387
--- /dev/null
+++ b/ui/src/widgets/tree_grid.ts
@@ -0,0 +1,278 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {Grid, GridCell, GridColumn, GridRow} from './grid';
+
+/**
+ * A row of data with a hierarchical path key and associated cell data.
+ */
+export interface TreeGridRow {
+  /**
+   * Slash-separated path that defines the tree hierarchy.
+   * Example: "root/child/grandchild"
+   */
+  readonly path: string;
+
+  /**
+   * Cell data for this row. The first cell will have the tree controls
+   * (indent, chevron) added automatically.
+   */
+  readonly cells: ReadonlyArray<m.Children>;
+}
+
+/**
+ * Internal tree node structure.
+ */
+interface TreeNode {
+  readonly name: string;
+  readonly fullPath: string;
+  readonly depth: number;
+  readonly children: Map<string, TreeNode>;
+  cells?: ReadonlyArray<m.Children>;
+  collapsed: boolean;
+  parent?: TreeNode;
+}
+
+/**
+ * TreeGrid component attributes.
+ */
+export interface TreeGridAttrs {
+  /**
+   * Column definitions. The first column will automatically include
+   * tree controls (indent and chevron).
+   */
+  readonly columns: ReadonlyArray<GridColumn>;
+
+  /**
+   * Row data with hierarchical paths.
+   */
+  readonly rows: ReadonlyArray<TreeGridRow>;
+
+  /**
+   * Optional virtualization configuration.
+   */
+  readonly virtualization?: {
+    readonly rowHeightPx: number;
+  };
+
+  /**
+   * Whether to fill parent container height.
+   */
+  readonly fillHeight?: boolean;
+
+  /**
+   * Optional CSS class name.
+   */
+  readonly className?: string;
+
+  /**
+   * Path separator character. Default is '/'.
+   */
+  readonly separator?: string;
+
+  /**
+   * Content to display when there are no rows.
+   */
+  readonly emptyState?: m.Children;
+}
+
+/**
+ * TreeGrid - A grid widget that automatically organizes rows into a tree
+ * structure based on slash-separated path keys.
+ *
+ * Example usage:
+ * ```typescript
+ * m(TreeGrid, {
+ *   columns: [
+ *     {key: 'name', header: m(GridHeaderCell, 'Name')},
+ *     {key: 'value', header: m(GridHeaderCell, 'Value')},
+ *   ],
+ *   rows: [
+ *     {
+ *       path: 'root/child1/leaf1',
+ *       cells: [m(GridCell, 'Leaf 1'), m(GridCell, '100')]
+ *     },
+ *     {
+ *       path: 'root/child1/leaf2',
+ *       cells: [m(GridCell, 'Leaf 2'), m(GridCell, '200')]
+ *     },
+ *     {
+ *       path: 'root/child2/leaf1',
+ *       cells: [m(GridCell, 'Leaf 1'), m(GridCell, '300')]
+ *     },
+ *   ],
+ * })
+ * ```
+ *
+ * This will create a tree:
+ * - root (expandable)
+ *   - child1 (expandable)
+ *     - Leaf 1 (leaf) - 100
+ *     - Leaf 2 (leaf) - 200
+ *   - child2 (expandable)
+ *     - Leaf 1 (leaf) - 300
+ */
+export class TreeGrid implements m.ClassComponent<TreeGridAttrs> {
+  private root: TreeNode = this.createRootNode();
+  private collapsedPaths: Set<string> = new Set();
+  private lastRowsJson = '';
+
+  private createRootNode(): TreeNode {
+    return {
+      name: '',
+      fullPath: '',
+      depth: -1,
+      children: new Map(),
+      collapsed: false,
+    };
+  }
+
+  /**
+   * Build the tree structure from flat row data.
+   * Preserves collapsed state from previous builds.
+   */
+  private buildTree(rows: ReadonlyArray<TreeGridRow>, separator: string): void {
+    this.root = this.createRootNode();
+
+    for (const row of rows) {
+      const parts = row.path.split(separator).filter((p) => p.length > 0);
+      let currentNode = this.root;
+      let currentPath = '';
+
+      for (let i = 0; i < parts.length; i++) {
+        const part = parts[i];
+        currentPath = currentPath ? `${currentPath}${separator}${part}` : part;
+
+        if (!currentNode.children.has(part)) {
+          const newNode: TreeNode = {
+            name: part,
+            fullPath: currentPath,
+            depth: i,
+            children: new Map(),
+            // Restore collapsed state if it was previously collapsed
+            collapsed: this.collapsedPaths.has(currentPath),
+            parent: currentNode,
+            // Only set cells on leaf nodes (last part of path)
+            cells: i === parts.length - 1 ? row.cells : undefined,
+          };
+          currentNode.children.set(part, newNode);
+        }
+
+        currentNode = currentNode.children.get(part)!;
+      }
+    }
+  }
+
+  /**
+   * Flatten the tree into a list of visible rows.
+   */
+  private flattenTree(node: TreeNode, output: TreeNode[]): void {
+    // Don't add the root node itself
+    if (node.depth >= 0) {
+      output.push(node);
+    }
+
+    // If not collapsed, add children
+    if (!node.collapsed) {
+      // Sort children by name for consistent display
+      const sortedChildren = Array.from(node.children.values()).sort((a, b) =>
+        a.name.localeCompare(b.name),
+      );
+
+      for (const child of sortedChildren) {
+        this.flattenTree(child, output);
+      }
+    }
+  }
+
+  view({attrs}: m.Vnode<TreeGridAttrs>) {
+    const separator = attrs.separator ?? '/';
+
+    // Only rebuild tree if data has changed
+    const rowsJson = JSON.stringify(attrs.rows.map((r) => r.path));
+    if (rowsJson !== this.lastRowsJson) {
+      this.lastRowsJson = rowsJson;
+      this.buildTree(attrs.rows, separator);
+    }
+
+    // Flatten tree to visible rows
+    const visibleNodes: TreeNode[] = [];
+    this.flattenTree(this.root, visibleNodes);
+
+    // Convert tree nodes to grid rows
+    const gridRows: GridRow[] = visibleNodes.map((node) => {
+      const row: m.Children[] = [];
+      const hasChildren = node.children.size > 0;
+      const isLeaf = !hasChildren;
+
+      // First cell gets tree controls
+      const firstCell = node.cells?.[0] ?? m(GridCell, node.name);
+
+      // Extract the content from the first cell and add tree controls
+      const chevron = isLeaf
+        ? ('leaf' as const)
+        : node.collapsed
+          ? ('collapsed' as const)
+          : ('expanded' as const);
+
+      row.push(
+        m(
+          GridCell,
+          {
+            indent: node.depth,
+            chevron,
+            onChevronClick: hasChildren
+              ? () => {
+                  node.collapsed = !node.collapsed;
+                  // Update collapsed paths set
+                  if (node.collapsed) {
+                    this.collapsedPaths.add(node.fullPath);
+                  } else {
+                    this.collapsedPaths.delete(node.fullPath);
+                  }
+                  m.redraw();
+                }
+              : undefined,
+            ...((firstCell as m.Vnode).attrs ?? {}),
+          },
+          (firstCell as m.Vnode).children ?? node.name,
+        ),
+      );
+
+      // Add remaining cells
+      if (node.cells) {
+        for (let i = 1; i < node.cells.length; i++) {
+          row.push(node.cells[i]);
+        }
+      } else {
+        // For intermediate nodes without data, add empty cells
+        for (let i = 1; i < attrs.columns.length; i++) {
+          row.push(m(GridCell, ''));
+        }
+      }
+
+      return row;
+    });
+
+    return m(Grid, {
+      columns: attrs.columns,
+      rowData: gridRows,
+      virtualization: attrs.virtualization,
+      fillHeight: attrs.fillHeight,
+      className: attrs.className,
+      emptyState: attrs.emptyState,
+    });
+  }
+}