ui: Support deeplinks

Change-Id: Ib5d457a9931fcccfed9a063f15dff0bd852e9783
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 15167eb..7b8f462 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -458,6 +458,15 @@
     }
   },
 
+  maybeSetPendingDeeplink(
+      state: StateDraft, args: {ts?: string, dur?: string, tid?: string}) {
+    state.pendingDeeplink = args;
+  },
+
+  clearPendingDeeplink(state: StateDraft, _: {}) {
+    state.pendingDeeplink = undefined;
+  },
+
   // TODO(hjd): engine.ready should be a published thing. If it's part
   // of the state it interacts badly with permalinks.
   setEngineReady(
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index aa28c8e..52855b8 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -104,6 +104,7 @@
 // 29. Add ftrace state. <-- Borked, state contains a non-serializable object.
 // 30. Convert ftraceFilter.excludedNames from Set<string> to string[].
 // 31. Convert all timestamps to bigints.
+// 32. Add pendingDeeplink.
 export const STATE_VERSION = 31;
 
 export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
@@ -515,6 +516,12 @@
   excludedNames: string[];
 }
 
+export interface PendingDeeplinkState {
+  ts?: string;
+  dur?: string;
+  tid?: string;
+}
+
 export interface State {
   version: number;
   nextId: string;
@@ -609,6 +616,10 @@
 
   // Omnibox info.
   omniboxState: OmniboxState;
+
+  // Pending deeplink which will happen when we first finish opening a
+  // trace.
+  pendingDeeplink?: PendingDeeplinkState;
 }
 
 export const defaultTraceTime = {
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index f0e2d02..49301e3 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -39,7 +39,12 @@
   STR_NULL,
 } from '../common/query_result';
 import {onSelectionChanged} from '../common/selection_observer';
-import {defaultTraceTime, EngineMode, ProfileType} from '../common/state';
+import {
+  defaultTraceTime,
+  EngineMode,
+  PendingDeeplinkState,
+  ProfileType,
+} from '../common/state';
 import {Span} from '../common/time';
 import {
   TPTime,
@@ -523,6 +528,12 @@
       await this.selectPerfSample();
     }
 
+    const pendingDeeplink = globals.state.pendingDeeplink;
+    if (pendingDeeplink !== undefined) {
+      globals.dispatch(Actions.clearPendingDeeplink({}));
+      await this.selectPendingDeeplink(pendingDeeplink);
+    }
+
     // If the trace was shared via a permalink, it might already have a
     // selection. Emit onSelectionChanged to ensure that the components (like
     // current selection details) react to it.
@@ -530,6 +541,8 @@
       onSelectionChanged(globals.state.currentSelection, undefined);
     }
 
+    globals.dispatch(Actions.maybeExpandOnlyTrackGroup({}));
+
     // Trace Processor doesn't support the reliable range feature for JSON
     // traces.
     if (!isJsonTrace && ENABLE_CHROME_RELIABLE_RANGE_ANNOTATION_FLAG.get()) {
@@ -583,6 +596,52 @@
     globals.dispatch(Actions.selectHeapProfile({id: 0, upid, ts, type}));
   }
 
+  private async selectPendingDeeplink(link: PendingDeeplinkState) {
+    const conditions = [];
+    const {ts, dur} = link;
+
+    if (ts !== undefined) {
+      conditions.push(`ts = ${ts}`);
+    }
+    if (dur !== undefined) {
+      conditions.push(`dur = ${dur}`);
+    }
+
+    if (conditions.length === 0) {
+      return;
+    }
+
+    const query = `
+      select
+        id,
+        track_id as traceProcessorTrackId,
+        type
+      from slice
+      where ${conditions.join(' and ')}
+    ;`;
+
+    const result = await assertExists(this.engine).query(query);
+    if (result.numRows() > 0) {
+      const row = result.firstRow({
+        id: NUM,
+        traceProcessorTrackId: NUM,
+        type: STR,
+      });
+
+      const id = row.traceProcessorTrackId;
+      const trackId = globals.state.uiTrackIdByTraceTrackId[id];
+      if (trackId === undefined) {
+        return;
+      }
+      globals.makeSelection(Actions.selectChromeSlice({
+        id: row.id,
+        trackId,
+        table: '',
+        scroll: true,
+      }));
+    }
+  }
+
   private async listTracks() {
     this.updateStatus('Loading tracks');
     const engine = assertExists<Engine>(this.engine);
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index a5f4520..053316f 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -337,6 +337,14 @@
   // accidentially clober the state of an open trace processor instance
   // otherwise.
   CheckHttpRpcConnection().then(() => {
+    const route = Router.parseUrl(window.location.href);
+
+    globals.dispatch(Actions.maybeSetPendingDeeplink({
+      ts: route.args.ts,
+      tid: route.args.tid,
+      dur: route.args.dur,
+    }));
+
     if (!globals.embeddedMode) {
       installFileDropHandler();
     }
@@ -353,7 +361,7 @@
 
     // Handles the initial ?local_cache_key=123 or ?s=permalink or ?url=...
     // cases.
-    maybeOpenTraceFromRoute(Router.parseUrl(window.location.href));
+    maybeOpenTraceFromRoute(route);
   });
 }
 
diff --git a/ui/src/frontend/router.ts b/ui/src/frontend/router.ts
index d407a20..1057d80 100644
--- a/ui/src/frontend/router.ts
+++ b/ui/src/frontend/router.ts
@@ -72,6 +72,11 @@
 
   // Should we hide the sidebar?
   hideSidebar: optBool,
+
+  // Deep link support
+  ts: optStr,
+  dur: optStr,
+  tid: optStr,
 });
 type RouteArgs = ValidatedType<typeof routeArgs>;
 
@@ -145,15 +150,6 @@
       newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
     }
 
-    // Javascript sadly distinguishes between foo[bar] === undefined
-    // and foo[bar] is not set at all. Here we need the second case to
-    // avoid making the URL ugly.
-    for (const key of Object.keys(newRoute)) {
-      if ((newRoute as any)[key] === undefined) {
-        delete (newRoute as any)[key];
-      }
-    }
-
     const args = m.buildQueryString(newRoute.args);
     let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
     normalizedFragment += args.length > 0 ? '?' + args : '';
@@ -187,9 +183,7 @@
   // '#!/record/gpu?local_cache_key=abcd-1234'
   // Sample output:
   // {page: '/record', subpage: '/gpu', args: {local_cache_key: 'abcd-1234'}}
-  static parseFragment(hash: string, optDefaultArgs?: RouteArgs): Route {
-    const defaultArgs = optDefaultArgs ?? {};
-
+  static parseFragment(hash: string): Route {
     const prefixLength = ROUTE_PREFIX.length;
     let route = '';
     if (hash.startsWith(ROUTE_PREFIX)) {
@@ -206,11 +200,20 @@
 
     const argsStart = hash.indexOf('?');
     const argsStr = argsStart < 0 ? '' : hash.substring(argsStart + 1);
-    const fragmentArgs =
+    const rawArgs =
         argsStr ? m.parseQueryString(hash.substring(argsStart)) : {};
-    const rawArgs = Object.assign({}, defaultArgs, fragmentArgs);
+
     const args = runValidator(routeArgs, rawArgs).result;
 
+    // Javascript sadly distinguishes between foo[bar] === undefined
+    // and foo[bar] is not set at all. Here we need the second case to
+    // avoid making the URL ugly.
+    for (const key of Object.keys(args)) {
+      if ((args as any)[key] === undefined) {
+        delete (args as any)[key];
+      }
+    }
+
     return {page, subpage, args};
   }
 
@@ -218,6 +221,16 @@
     const query = (new URL(url)).search;
     const rawArgs = m.parseQueryString(query);
     const args = runValidator(routeArgs, rawArgs).result;
+
+    // Javascript sadly distinguishes between foo[bar] === undefined
+    // and foo[bar] is not set at all. Here we need the second case to
+    // avoid making the URL ugly.
+    for (const key of Object.keys(args)) {
+      if ((args as any)[key] === undefined) {
+        delete (args as any)[key];
+      }
+    }
+
     return args;
   }
 
@@ -227,7 +240,9 @@
 
     const hashPos = url.indexOf('#');
     const fragment = hashPos < 0 ? '' : url.substring(hashPos);
-    const route = Router.parseFragment(fragment, searchArgs);
+    const route = Router.parseFragment(fragment);
+    route.args = Object.assign({}, searchArgs, route.args);
+
     return route;
   }
 
diff --git a/ui/src/frontend/trace_url_handler.ts b/ui/src/frontend/trace_url_handler.ts
index 8b398fb..5e329f5 100644
--- a/ui/src/frontend/trace_url_handler.ts
+++ b/ui/src/frontend/trace_url_handler.ts
@@ -23,7 +23,6 @@
 import {Route, Router} from './router';
 import {taskTracker} from './task_tracker';
 
-
 export function maybeOpenTraceFromRoute(route: Route) {
   if (route.args.s) {
     // /?s=xxxx for permalinks.