Merge "ui: Improve peformance of heap graph context options" into main
diff --git a/gn/perfetto_unittests.gni b/gn/perfetto_unittests.gni
index e2f66fc..ecef372 100644
--- a/gn/perfetto_unittests.gni
+++ b/gn/perfetto_unittests.gni
@@ -26,7 +26,6 @@
   "src/tracing:unittests",
   "src/profiling:unittests",
   "src/profiling/symbolizer:unittests",
-  "src/android_sdk/perfetto_sdk_for_jni:unittests",
 ]
 
 if ((is_linux || is_android) && !perfetto_build_with_embedder) {
@@ -87,4 +86,9 @@
   perfetto_unittests_targets += [ "src/traced_relay:unittests" ]
 }
 
+if (enable_perfetto_android_java_sdk) {
+  perfetto_unittests_targets +=
+      [ "src/android_sdk/perfetto_sdk_for_jni:unittests" ]
+}
+
 perfetto_unittests_targets += [ "src/trace_redaction:unittests" ]
diff --git a/include/perfetto/base/logging.h b/include/perfetto/base/logging.h
index a882e2e..b358da1 100644
--- a/include/perfetto/base/logging.h
+++ b/include/perfetto/base/logging.h
@@ -25,8 +25,18 @@
 #include "perfetto/base/export.h"
 
 #if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
 // Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 #if PERFETTO_BUILDFLAG(PERFETTO_FORCE_DCHECK_ON)
diff --git a/include/perfetto/public/te_category_macros.h b/include/perfetto/public/te_category_macros.h
index a9f5071..6dc0e81 100644
--- a/include/perfetto/public/te_category_macros.h
+++ b/include/perfetto/public/te_category_macros.h
@@ -59,8 +59,19 @@
 // it would expand to zero arguments, because the behavior is compiler
 // dependent.
 
-#if defined(__GNUC__)
+#if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
+// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 #define PERFETTO_I_TE_CAT_DESCRIPTION_GET(D, ...) D
diff --git a/include/perfetto/tracing/internal/track_event_macros.h b/include/perfetto/tracing/internal/track_event_macros.h
index ac0af46..2031cf7 100644
--- a/include/perfetto/tracing/internal/track_event_macros.h
+++ b/include/perfetto/tracing/internal/track_event_macros.h
@@ -26,9 +26,19 @@
 #include "perfetto/tracing/string_helpers.h"
 #include "perfetto/tracing/track_event_category_registry.h"
 
-// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
+// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 // Defines data structures for backing a category registry.
diff --git a/include/perfetto/tracing/track_event.h b/include/perfetto/tracing/track_event.h
index 8db70a6..de2c75f 100644
--- a/include/perfetto/tracing/track_event.h
+++ b/include/perfetto/tracing/track_event.h
@@ -255,9 +255,19 @@
 #define PERFETTO_TRACK_EVENT_STATIC_STORAGE() \
   PERFETTO_TRACK_EVENT_STATIC_STORAGE_IN_NAMESPACE(perfetto)
 
-// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
+// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 // Begin a slice under |category| with the title |name|. Both strings must be
diff --git a/include/perfetto/tracing/track_event_legacy.h b/include/perfetto/tracing/track_event_legacy.h
index fb5922b..cd55f85 100644
--- a/include/perfetto/tracing/track_event_legacy.h
+++ b/include/perfetto/tracing/track_event_legacy.h
@@ -31,9 +31,19 @@
 #define PERFETTO_ENABLE_LEGACY_TRACE_EVENTS 0
 #endif
 
-// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
+// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 // ----------------------------------------------------------------------------
diff --git a/src/android_sdk/perfetto_sdk_for_jni/BUILD.gn b/src/android_sdk/perfetto_sdk_for_jni/BUILD.gn
index 6255a66..865c00b 100644
--- a/src/android_sdk/perfetto_sdk_for_jni/BUILD.gn
+++ b/src/android_sdk/perfetto_sdk_for_jni/BUILD.gn
@@ -1,5 +1,8 @@
+import("../../../gn/perfetto.gni")
 import("../../../gn/test.gni")
 
+assert(enable_perfetto_android_java_sdk)
+
 source_set("perfetto_sdk_for_jni_public") {
   sources = [ "tracing_sdk.h" ]
 }
diff --git a/src/trace_processor/storage/metadata.h b/src/trace_processor/storage/metadata.h
index 94bc646..2f556a1 100644
--- a/src/trace_processor/storage/metadata.h
+++ b/src/trace_processor/storage/metadata.h
@@ -78,9 +78,19 @@
   F(kMulti,  "multi")
 // clang-format
 
-// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
+// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 #define PERFETTO_TP_META_TYPE_ENUM(varname, ...) varname
diff --git a/src/trace_processor/storage/stats.h b/src/trace_processor/storage/stats.h
index ea03e42..508b5ea 100644
--- a/src/trace_processor/storage/stats.h
+++ b/src/trace_processor/storage/stats.h
@@ -496,9 +496,19 @@
   kAnalysis
 };
 
-// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #if defined(__GNUC__) || defined(__clang__)
+#if defined(__clang__)
+#pragma clang diagnostic push
+// Fix 'error: #pragma system_header ignored in main file' for clang in Google3.
+#pragma clang diagnostic ignored "-Wpragma-system-header-outside-header"
+#endif
+
+// Ignore GCC warning about a missing argument for a variadic macro parameter.
 #pragma GCC system_header
+
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
 #endif
 
 // Declares an enum of literals (one for each stat). The enum values of each
diff --git a/src/trace_processor/util/streaming_line_reader.cc b/src/trace_processor/util/streaming_line_reader.cc
index 6ec28b9..55a5a01 100644
--- a/src/trace_processor/util/streaming_line_reader.cc
+++ b/src/trace_processor/util/streaming_line_reader.cc
@@ -16,7 +16,6 @@
 
 #include "src/trace_processor/util/streaming_line_reader.h"
 
-#include <sys/types.h>
 #include <cstddef>
 #include <utility>
 #include <vector>
@@ -48,7 +47,7 @@
   // Unless we got very lucky, the last line in the chunk just written will be
   // incomplete. Move it to the beginning of the buffer so it gets glued
   // together on the next {Begin,End}Write() call.
-  buf_.erase(buf_.begin(), buf_.begin() + static_cast<ssize_t>(consumed));
+  buf_.erase(buf_.begin(), buf_.begin() + static_cast<int64_t>(consumed));
 }
 
 size_t StreamingLineReader::Tokenize(base::StringView input) {
diff --git a/ui/src/components/tracks/breakdown_tracks.ts b/ui/src/components/tracks/breakdown_tracks.ts
index c379477..66fc853 100644
--- a/ui/src/components/tracks/breakdown_tracks.ts
+++ b/ui/src/components/tracks/breakdown_tracks.ts
@@ -12,10 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertExists} from '../../base/logging';
 import {sqliteString} from '../../base/string_utils';
 import {uuidv4} from '../../base/uuid';
-import {runQuery} from '../../components/query_table/queries';
 import {DatasetSliceTrack} from '../../components/tracks/dataset_slice_track';
 import {
   createQueryCounterTrack,
@@ -24,7 +22,7 @@
 import {createQuerySliceTrack} from '../../components/tracks/query_slice_track';
 import {Trace} from '../../public/trace';
 import {TrackNode} from '../../public/workspace';
-import {ColumnType} from '../../trace_processor/query_result';
+import {ColumnType, NUM} from '../../trace_processor/query_result';
 
 /**
  * Aggregation types for the BreakdownTracks.
@@ -233,22 +231,22 @@
 
   async createTracks() {
     if (this.modulesClause !== '') {
-      this.props.trace.engine.query(this.modulesClause);
+      await this.props.trace.engine.query(this.modulesClause);
     }
 
     if (this.props.aggregationType !== BreakdownTrackAggType.COUNT) {
-      this.props.trace.engine.query(`
-          CREATE OR REPLACE PERFETTO FUNCTION _ui_dev_perfetto_breakdown_tracks_is_spans_overlapping(
-            ts1 LONG,
-            ts_end1 LONG,
-            ts2 LONG,
-            ts_end2 LONG)
-          RETURNS BOOL
-          AS
-          SELECT (IIF($ts1 < $ts2, $ts2, $ts1) < IIF($ts_end1 < $ts_end2, $ts_end1, $ts_end2));
+      await this.props.trace.engine.query(`
+        CREATE OR REPLACE PERFETTO FUNCTION _ui_dev_perfetto_breakdown_tracks_is_spans_overlapping(
+          ts1 LONG,
+          ts_end1 LONG,
+          ts2 LONG,
+          ts_end2 LONG)
+        RETURNS BOOL
+        AS
+        SELECT (IIF($ts1 < $ts2, $ts2, $ts1) < IIF($ts_end1 < $ts_end2, $ts_end1, $ts_end2));
 
-          ${this.getIntervals()}
-        `);
+        ${this.getIntervals()}
+      `);
     }
 
     const rootTrackNode = await this.createCounterTrackNode(
@@ -283,30 +281,29 @@
     const joinClause = this.getTrackSpecificJoinClause(trackType);
 
     const query = `
-    ${this.modulesClause}
+      ${this.modulesClause}
 
-    SELECT DISTINCT ${currColName}
-    FROM ${this.props.aggregation.tableName}
-    ${joinClause !== undefined ? joinClause : ''}
-    ${filters.length > 0 ? `WHERE ${buildFilterSqlClause(filters)}` : ''}`;
+      SELECT DISTINCT ${currColName}
+      FROM ${this.props.aggregation.tableName}
+      ${joinClause !== undefined ? joinClause : ''}
+      ${filters.length > 0 ? `WHERE ${buildFilterSqlClause(filters)}` : ''}
+    `;
 
-    const res = await runQuery(query, this.props.trace.engine);
+    const res = await this.props.trace.engine.query(query);
 
-    if (res.error) {
-      throw Error(`Track hierarchy query error: ${res.error}`);
-    }
+    for (const iter = res.iter({}); iter.valid(); iter.next()) {
+      const colRaw = iter.get(currColName);
+      const colValue = colRaw === null ? 'NULL' : colRaw.toString();
+      const title = colValue;
 
-    res.rows.forEach(async (row) => {
       const newFilters = [
         ...filters,
         {
           columnName: currColName,
-          value: row[currColName]?.toString(),
+          value: colValue,
         },
       ];
 
-      const title = `${row[currColName]?.toString()}`;
-
       let currNode;
       let nextTrackType = trackType;
       let nextColIndex = colIndex + 1;
@@ -353,7 +350,7 @@
         nextColIndex,
         nextTrackType,
       );
-    });
+    }
   }
 
   private getTrackSpecificJoinClause(trackType: BreakdownTrackType) {
@@ -406,19 +403,11 @@
   }
 
   private async getCounterTrackSortOrder(filtersClause: string) {
-    const counts = await runQuery(
-      `
-                SELECT MAX(value) as max_value FROM
-                (
-                  ${this.getAggregationQuery(filtersClause)}
-                )
-              `,
-      this.props.trace.engine,
-    );
-
-    return Number.parseInt(
-      assertExists(counts.rows[0]['max_value']).toString(),
-    );
+    const aggregationQuery = this.getAggregationQuery(filtersClause);
+    const result = await this.props.trace.engine.query(`
+      SELECT MAX(value) as max_value FROM (${aggregationQuery})
+    `);
+    return result.firstRow({max_value: NUM}).max_value;
   }
 
   private async createCounterTrackNode(title: string, newFilters: Filter[]) {
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts b/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts
index bbcba70..c5ec9a6 100644
--- a/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts
+++ b/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts
@@ -24,9 +24,26 @@
 import {MenuItem} from '../../widgets/menu';
 import {Icons} from '../../base/semantic_icons';
 import {VisViewSource} from './data_visualiser/view_source';
+import {PopupMenu} from '../../widgets/menu';
+import {createModal} from './query_builder/builder';
+import {
+  StdlibTableAttrs,
+  StdlibTableNode,
+  StdlibTableSource,
+} from './query_builder/sources/stdlib_table';
+import {
+  SlicesSource,
+  SlicesSourceAttrs,
+  SlicesSourceNode,
+} from './query_builder/sources/slices_source';
+import {
+  SqlSource,
+  SqlSourceAttrs,
+  SqlSourceNode,
+} from './query_builder/sources/sql_source';
 
 export interface ExplorePageState {
-  rootNode?: QueryNode; // Root Query Node
+  rootNodes: QueryNode[];
   selectedNode?: QueryNode; // Selected Query Node on which to perform actions
   activeViewSource?: VisViewSource; // View Source of activeQueryNode
   mode: ExplorePageModes;
@@ -59,6 +76,82 @@
     });
   }
 
+  addSourcePopupMenu(attrs: ExplorePageAttrs): m.Children {
+    const {trace, state} = attrs;
+    const sqlModules = attrs.sqlModulesPlugin.getSqlModules();
+    return [
+      m(MenuItem, {
+        label: 'Standard library table',
+        onclick: async () => {
+          const stdlibTableAttrs: StdlibTableAttrs = {
+            filters: [],
+            sourceCols: [],
+            groupByColumns: [],
+            aggregations: [],
+            trace,
+            sqlModules,
+            modal: () =>
+              createModal(
+                'Standard library table',
+                () => m(StdlibTableSource, stdlibTableAttrs),
+                () => {
+                  const newNode = new StdlibTableNode(stdlibTableAttrs);
+                  state.rootNodes.push(newNode);
+                  state.selectedNode = newNode;
+                },
+              ),
+          };
+          // Adding trivial modal to open the table selection.
+          createModal(
+            'Standard library table',
+            () => m(StdlibTableSource, stdlibTableAttrs),
+            () => {},
+          );
+        },
+      }),
+      m(MenuItem, {
+        label: 'Custom slices',
+        onclick: () => {
+          const newSimpleSlicesAttrs: SlicesSourceAttrs = {
+            sourceCols: [],
+            filters: [],
+            groupByColumns: [],
+            aggregations: [],
+          };
+          createModal(
+            'Slices',
+            () => m(SlicesSource, newSimpleSlicesAttrs),
+            () => {
+              const newNode = new SlicesSourceNode(newSimpleSlicesAttrs);
+              state.rootNodes.push(newNode);
+              state.selectedNode = newNode;
+            },
+          );
+        },
+      }),
+      m(MenuItem, {
+        label: 'Custom SQL',
+        onclick: () => {
+          const newSqlSourceAttrs: SqlSourceAttrs = {
+            sourceCols: [],
+            filters: [],
+            groupByColumns: [],
+            aggregations: [],
+          };
+          createModal(
+            'SQL',
+            () => m(SqlSource, newSqlSourceAttrs),
+            () => {
+              const newNode = new SqlSourceNode(newSqlSourceAttrs);
+              state.rootNodes.push(newNode);
+              state.selectedNode = newNode;
+            },
+          );
+        },
+      }),
+    ];
+  }
+
   view({attrs}: m.CVnode<ExplorePageAttrs>) {
     const {trace, state} = attrs;
 
@@ -69,14 +162,29 @@
         m('h1', `${ExplorePageModeToLabel[state.mode]}`),
         m('span', {style: {flexGrow: 1}}),
         state.mode === ExplorePageModes.QUERY_BUILDER
-          ? m(Button, {
-              label: 'Clear All Query Nodes',
-              intent: Intent.Primary,
-              onclick: () => {
-                state.rootNode = undefined;
-                state.selectedNode = undefined;
-              },
-            })
+          ? m(
+              '',
+              m(
+                PopupMenu,
+                {
+                  trigger: m(Button, {
+                    label: 'Add new node',
+                    icon: Icons.Add,
+                    intent: Intent.Primary,
+                  }),
+                },
+                this.addSourcePopupMenu(attrs),
+              ),
+              m(Button, {
+                label: 'Clear All Query Nodes',
+                intent: Intent.Primary,
+                onclick: () => {
+                  state.rootNodes = [];
+                  state.selectedNode = undefined;
+                },
+                style: {marginLeft: '10px'},
+              }),
+            )
           : m(Button, {
               label: 'Back to Query Builder',
               intent: Intent.Primary,
@@ -91,7 +199,7 @@
           trace,
           sqlModules: attrs.sqlModulesPlugin.getSqlModules(),
           onRootNodeCreated(arg) {
-            state.rootNode = arg;
+            state.rootNodes.push(arg);
             state.selectedNode = arg;
           },
           onNodeSelected(arg) {
@@ -99,11 +207,12 @@
           },
           visualiseDataMenuItems: (node: QueryNode) =>
             this.renderVisualiseDataMenuItems(node, state),
-          rootNode: state.rootNode,
+          rootNodes: state.rootNodes,
           selectedNode: state.selectedNode,
+          addSourcePopupMenu: () => this.addSourcePopupMenu(attrs),
         }),
       state.mode === ExplorePageModes.DATA_VISUALISER &&
-        state.rootNode &&
+        state.rootNodes.length !== 0 &&
         m(DataVisualiser, {
           trace,
           state,
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/index.ts b/ui/src/plugins/dev.perfetto.ExplorePage/index.ts
index a1a2fa1..6746248 100644
--- a/ui/src/plugins/dev.perfetto.ExplorePage/index.ts
+++ b/ui/src/plugins/dev.perfetto.ExplorePage/index.ts
@@ -27,6 +27,7 @@
   // trace.
   private readonly state: ExplorePageState = {
     mode: ExplorePageModes.QUERY_BUILDER,
+    rootNodes: [],
   };
 
   async onTraceLoad(trace: Trace): Promise<void> {
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts
index 670c48b..b2cd824 100644
--- a/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts
+++ b/ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts
@@ -39,18 +39,19 @@
 export interface QueryBuilderTable {
   name: string;
   asSqlTable: SqlTable;
-  columnOptions: ColumnControllerRow[];
+  columnOptions: ColumnControllerRow;
   sql: string;
 }
 
 export interface QueryBuilderAttrs extends PageWithTraceAttrs {
   readonly sqlModules: SqlModules;
-  readonly rootNode?: QueryNode;
+  readonly rootNodes: QueryNode[];
   readonly selectedNode?: QueryNode;
 
   readonly onRootNodeCreated: (node: QueryNode) => void;
-  readonly onNodeSelected: (node: QueryNode) => void;
+  readonly onNodeSelected: (node?: QueryNode) => void;
   readonly visualiseDataMenuItems: (node: QueryNode) => m.Children;
+  readonly addSourcePopupMenu: () => m.Children;
 }
 
 interface NodeAttrs {
@@ -81,103 +82,7 @@
 
 export class QueryBuilder implements m.ClassComponent<QueryBuilderAttrs> {
   view({attrs}: m.CVnode<QueryBuilderAttrs>) {
-    const {
-      trace,
-      sqlModules,
-      rootNode,
-      onRootNodeCreated,
-      onNodeSelected,
-      selectedNode,
-    } = attrs;
-
-    const chooseSourceButton = (): m.Child => {
-      return m(
-        PopupMenu,
-        {
-          trigger: m(Button, {
-            icon: Icons.Add,
-            intent: Intent.Primary,
-            style: {
-              height: '100px',
-              width: '100px',
-              display: 'flex',
-              justifyContent: 'center',
-              alignItems: 'center',
-              fontSize: '48px',
-            },
-          }),
-        },
-        m(MenuItem, {
-          label: 'Standard library table',
-          onclick: async () => {
-            const attrs: StdlibTableAttrs = {
-              filters: [],
-              sourceCols: [],
-              groupByColumns: [],
-              aggregations: [],
-              trace,
-              sqlModules,
-              modal: () =>
-                createModal(
-                  'Standard library table',
-                  () => m(StdlibTableSource, attrs),
-                  () => {
-                    const newNode = new StdlibTableNode(attrs);
-                    onRootNodeCreated(newNode);
-                    onNodeSelected(newNode);
-                  },
-                ),
-            };
-            // Adding trivial modal to open the table selection.
-            createModal(
-              'Standard library table',
-              () => m(StdlibTableSource, attrs),
-              () => {},
-            );
-          },
-        }),
-        m(MenuItem, {
-          label: 'Custom slices',
-          onclick: () => {
-            const newSimpleSlicesAttrs: SlicesSourceAttrs = {
-              sourceCols: [],
-              filters: [],
-              groupByColumns: [],
-              aggregations: [],
-            };
-            createModal(
-              'Slices',
-              () => m(SlicesSource, newSimpleSlicesAttrs),
-              () => {
-                const newNode = new SlicesSourceNode(newSimpleSlicesAttrs);
-                onRootNodeCreated(newNode);
-                onNodeSelected(newNode);
-              },
-            );
-          },
-        }),
-        m(MenuItem, {
-          label: 'Custom SQL',
-          onclick: () => {
-            const newSqlSourceAttrs: SqlSourceAttrs = {
-              sourceCols: [],
-              filters: [],
-              groupByColumns: [],
-              aggregations: [],
-            };
-            createModal(
-              'SQL',
-              () => m(SqlSource, newSqlSourceAttrs),
-              () => {
-                const newNode = new SqlSourceNode(newSqlSourceAttrs);
-                onRootNodeCreated(newNode);
-                onNodeSelected(newNode);
-              },
-            );
-          },
-        }),
-      );
-    };
+    const {trace, rootNodes, onNodeSelected, selectedNode} = attrs;
 
     const renderNodeActions = (curNode: QueryNode) => {
       return m(
@@ -199,12 +104,11 @@
                   'Standard library table',
                   () => m(StdlibTableSource, attrsCopy as StdlibTableAttrs),
                   () => {
-                    curNode = new StdlibTableNode(
+                    // TODO: Support editing non root nodes.
+                    rootNodes[rootNodes.indexOf(curNode)] = new StdlibTableNode(
                       attrsCopy as StdlibTableAttrs,
                     );
                     onNodeSelected(curNode);
-                    // TODO: remove this hack after handling multiple roots
-                    onRootNodeCreated(curNode);
                   },
                 );
                 curNode = new StdlibTableNode(attrsCopy as StdlibTableAttrs);
@@ -214,12 +118,10 @@
                   'Slices',
                   () => m(SlicesSource, attrsCopy as SlicesSourceAttrs),
                   () => {
-                    curNode = new SlicesSourceNode(
-                      attrsCopy as SlicesSourceAttrs,
-                    );
+                    // TODO: Support editing non root nodes.
+                    rootNodes[rootNodes.indexOf(curNode)] =
+                      new SlicesSourceNode(attrsCopy as SlicesSourceAttrs);
                     onNodeSelected(curNode);
-                    // TODO: remove this hack after handling multiple roots
-                    onRootNodeCreated(curNode);
                   },
                 );
                 break;
@@ -228,45 +130,88 @@
                   'SQL',
                   () => m(SqlSource, attrsCopy as SqlSourceAttrs),
                   () => {
-                    curNode = new SqlSourceNode(attrsCopy as SqlSourceAttrs);
+                    // TODO: Support editing non root nodes.
+                    rootNodes[rootNodes.indexOf(curNode)] = new SqlSourceNode(
+                      attrsCopy as SqlSourceAttrs,
+                    );
                     onNodeSelected(curNode);
-                    // TODO: remove this hack after handling multiple roots
-                    onRootNodeCreated(curNode);
                   },
                 );
             }
           },
         }),
+        m(MenuItem, {
+          label: 'Duplicate',
+          onclick: async () => {
+            attrs.rootNodes.push(cloneQueryNode(curNode));
+          },
+        }),
+        m(MenuItem, {
+          label: 'Delete',
+          onclick: async () => {
+            const idx = attrs.rootNodes.indexOf(curNode);
+            if (idx !== -1) {
+              attrs.rootNodes.splice(idx, 1);
+              onNodeSelected(undefined);
+            }
+          },
+        }),
       );
     };
 
     const renderNodesPanel = (): m.Children => {
       const nodes: m.Child[] = [];
-      let row = 1;
+      const numRoots = rootNodes.length;
 
-      if (!rootNode) {
+      if (numRoots === 0) {
         nodes.push(
-          m('', {style: {gridColumn: 3, gridRow: 2}}, chooseSourceButton()),
+          m(
+            '',
+            {style: {gridColumn: 3, gridRow: 2}},
+            m(
+              PopupMenu,
+              {
+                trigger: m(Button, {
+                  icon: Icons.Add,
+                  intent: Intent.Primary,
+                  style: {
+                    height: '100px',
+                    width: '100px',
+                    display: 'flex',
+                    justifyContent: 'center',
+                    alignItems: 'center',
+                    fontSize: '48px',
+                  },
+                }),
+              },
+              attrs.addSourcePopupMenu(),
+            ),
+          ),
         );
       } else {
-        let curNode: QueryNode | undefined = rootNode;
-        while (curNode) {
-          const localCurNode = curNode;
-          nodes.push(
-            m(
-              '',
-              {style: {display: 'flex', gridColumn: 3, gridRow: row}},
-              m(NodeBox, {
-                node: localCurNode,
-                isSelected: selectedNode === localCurNode,
-                onNodeSelected,
-              }),
-              renderNodeActions(curNode),
-            ),
-          );
-          row++;
-          curNode = curNode.nextNode;
-        }
+        let col = 1;
+        rootNodes.forEach((rootNode) => {
+          let row = 1;
+          let curNode: QueryNode | undefined = rootNode;
+          while (curNode) {
+            const localCurNode = curNode;
+            nodes.push(
+              m(
+                '',
+                {style: {display: 'flex', gridColumn: col, gridRow: row}},
+                m(NodeBox, {
+                  node: localCurNode,
+                  isSelected: selectedNode === localCurNode,
+                  onNodeSelected,
+                }),
+                renderNodeActions(curNode),
+              ),
+            );
+            row++;
+            curNode = curNode.nextNode;
+          }
+          col += 1;
+        });
       }
 
       return m(
@@ -274,7 +219,7 @@
         {
           style: {
             display: 'grid',
-            gridTemplateColumns: 'repeat(5, 1fr)',
+            gridTemplateColumns: `repeat(${numRoots} - 1, 1fr)`,
             gridTemplateRows: 'repeat(3, 1fr)',
             gap: '10px',
           },
@@ -316,3 +261,15 @@
     content,
   });
 };
+
+function cloneQueryNode(node: QueryNode): QueryNode {
+  const attrsCopy = node.getState();
+  switch (node.type) {
+    case NodeType.kStdlibTable:
+      return new StdlibTableNode(attrsCopy as StdlibTableAttrs);
+    case NodeType.kSimpleSlices:
+      return new SlicesSourceNode(attrsCopy as SlicesSourceAttrs);
+    case NodeType.kSqlSource:
+      return new SqlSourceNode(attrsCopy as SqlSourceAttrs);
+  }
+}