[go: nahoru, domu]

Outermost target selector behind an experiment.

Screenshot: https://imgur.com/a/J25hdrN

Bug: 1418045
Change-Id: Icb4c09da58b054b9fe2cb246c4a4ca6c5043ae11
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/4295413
Reviewed-by: Simon Zünd <szuend@chromium.org>
Reviewed-by: Wolfgang Beyer <wolfi@chromium.org>
Auto-Submit: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Danil Somsikov <dsv@chromium.org>
diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni
index 17292bb..fcc343a 100644
--- a/config/gni/devtools_grd_files.gni
+++ b/config/gni/devtools_grd_files.gni
@@ -726,6 +726,7 @@
   "front_end/entrypoints/lighthouse_worker/LighthouseWorkerService.js",
   "front_end/entrypoints/main/ExecutionContextSelector.js",
   "front_end/entrypoints/main/MainImpl.js",
+  "front_end/entrypoints/main/OutermostTargetSelector.js",
   "front_end/entrypoints/main/SimpleApp.js",
   "front_end/entrypoints/node_app/NodeConnectionsPanel.js",
   "front_end/entrypoints/node_app/NodeMain.js",
diff --git a/front_end/core/host/UserMetrics.ts b/front_end/core/host/UserMetrics.ts
index 41a8045..09e69b4 100644
--- a/front_end/core/host/UserMetrics.ts
+++ b/front_end/core/host/UserMetrics.ts
@@ -737,8 +737,10 @@
   'timelineAsConsoleProfileResultPanel' = 67,
   'preloadingStatusPanel' = 68,
   'disableColorFormatSetting' = 69,
+  'outermostTargetSelector' = 71,
+
   // Increment this when new experiments are added.
-  'MaxValue' = 71,
+  'MaxValue' = 72,
 }
 /* eslint-enable @typescript-eslint/naming-convention */
 
diff --git a/front_end/core/root/Runtime.ts b/front_end/core/root/Runtime.ts
index a881dca..34e4c32 100644
--- a/front_end/core/root/Runtime.ts
+++ b/front_end/core/root/Runtime.ts
@@ -312,6 +312,7 @@
   PRELOADING_STATUS_PANEL = 'preloadingStatusPanel',
   DISABLE_COLOR_FORMAT_SETTING = 'disableColorFormatSetting',
   TIMELINE_AS_CONSOLE_PROFILE_RESULT_PANEL = 'timelineAsConsoleProfileResultPanel',
+  OUTERMOST_TARGET_SELECTOR = 'outermostTargetSelector',
 }
 
 // TODO(crbug.com/1167717): Make this a const enum again
diff --git a/front_end/entrypoints/main/BUILD.gn b/front_end/entrypoints/main/BUILD.gn
index 532319e..fef67d2 100644
--- a/front_end/entrypoints/main/BUILD.gn
+++ b/front_end/entrypoints/main/BUILD.gn
@@ -4,12 +4,14 @@
 
 import("../../../scripts/build/ninja/devtools_entrypoint.gni")
 import("../../../scripts/build/ninja/devtools_module.gni")
+import("../../../scripts/build/ninja/generate_css.gni")
 import("../visibility.gni")
 
 devtools_module("main") {
   sources = [
     "ExecutionContextSelector.ts",
     "MainImpl.ts",
+    "OutermostTargetSelector.ts",
     "SimpleApp.ts",
   ]
 
diff --git a/front_end/entrypoints/main/MainImpl.ts b/front_end/entrypoints/main/MainImpl.ts
index 210211a..cc15d78 100644
--- a/front_end/entrypoints/main/MainImpl.ts
+++ b/front_end/entrypoints/main/MainImpl.ts
@@ -409,6 +409,10 @@
         // Adding the reload hint here because users getting here are likely coming from inside the settings UI, but the regular reminder bar is only shown after the UI is closed which they're not going to see.
         'Disable the deprecated `Color format` setting (requires reloading DevTools)', false);
 
+    Root.Runtime.experiments.register(
+        Root.Runtime.ExperimentName.OUTERMOST_TARGET_SELECTOR,
+        'Enable background page selector (e.g. for prerendering debugging)', false);
+
     Root.Runtime.experiments.enableExperimentsByDefault([
       'sourceOrderViewer',
       'cssTypeComponentLength',
@@ -546,6 +550,10 @@
       resourceMapping,
       targetManager: SDK.TargetManager.TargetManager.instance(),
     });
+    UI.Context.Context.instance().addFlavorChangeListener(SDK.Target.Target, ({data}) => {
+      const outermostTarget = data?.outermostTarget();
+      SDK.TargetManager.TargetManager.instance().setScopeTarget(outermostTarget);
+    });
     // @ts-ignore layout test global
     self.Bindings.breakpointManager = Bindings.BreakpointManager.BreakpointManager.instance({
       forceNew: true,
diff --git a/front_end/entrypoints/main/OutermostTargetSelector.ts b/front_end/entrypoints/main/OutermostTargetSelector.ts
new file mode 100644
index 0000000..22d9561
--- /dev/null
+++ b/front_end/entrypoints/main/OutermostTargetSelector.ts
@@ -0,0 +1,180 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Common from '../../core/common/common.js';
+import * as i18n from '../../core/i18n/i18n.js';
+import * as Platform from '../../core/platform/platform.js';
+import * as SDK from '../../core/sdk/sdk.js';
+import * as UI from '../../ui/legacy/legacy.js';
+import type * as Protocol from '../../generated/protocol.js';
+
+const UIStrings = {
+  /**
+   *@description Title of toolbar item in outermost target selector in the main toolbar
+   */
+  targetNotSelected: 'Page: Not selected',
+  /**
+   *@description Title of toolbar item in outermost target selector in the main toolbar
+   *@example {top} PH1
+   */
+  targetS: 'Page: {PH1}',
+};
+const str_ = i18n.i18n.registerUIStrings('entrypoints/main/OutermostTargetSelector.ts', UIStrings);
+const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+
+let outermostTargetSelectorInstance: OutermostTargetSelector;
+
+export class OutermostTargetSelector implements SDK.TargetManager.Observer, UI.SoftDropDown.Delegate<SDK.Target.Target>,
+                                                UI.Toolbar.Provider {
+  readonly listItems: UI.ListModel.ListModel<SDK.Target.Target>;
+  readonly #dropDown: UI.SoftDropDown.SoftDropDown<SDK.Target.Target>;
+  readonly #toolbarItem: UI.Toolbar.ToolbarItem;
+
+  constructor() {
+    this.listItems = new UI.ListModel.ListModel();
+    this.#dropDown = new UI.SoftDropDown.SoftDropDown(this.listItems, this);
+    this.#dropDown.setRowHeight(36);
+    this.#toolbarItem = new UI.Toolbar.ToolbarItem(this.#dropDown.element);
+    this.#toolbarItem.setTitle(i18nString(UIStrings.targetNotSelected));
+    this.listItems.addEventListener(
+        UI.ListModel.Events.ItemsReplaced, () => this.#toolbarItem.setEnabled(Boolean(this.listItems.length)));
+
+    this.#toolbarItem.element.classList.add('toolbar-has-dropdown');
+    const targetManager = SDK.TargetManager.TargetManager.instance();
+    targetManager.addModelListener(
+        SDK.ChildTargetManager.ChildTargetManager, SDK.ChildTargetManager.Events.TargetInfoChanged,
+        this.#onTargetInfoChanged, this);
+    targetManager.observeTargets(this);
+
+    UI.Context.Context.instance().addFlavorChangeListener(SDK.Target.Target, this.#targetChanged, this);
+  }
+
+  static instance(opts: {
+    forceNew: boolean|null,
+  } = {forceNew: null}): OutermostTargetSelector {
+    const {forceNew} = opts;
+    if (!outermostTargetSelectorInstance || forceNew) {
+      outermostTargetSelectorInstance = new OutermostTargetSelector();
+    }
+
+    return outermostTargetSelectorInstance;
+  }
+
+  item(): UI.Toolbar.ToolbarItem {
+    return this.#toolbarItem;
+  }
+
+  highlightedItemChanged(
+      _from: SDK.Target.Target|null, _to: SDK.Target.Target|null, _fromElement: Element|null,
+      _toElement: Element|null): void {
+  }
+
+  titleFor(target: SDK.Target.Target): string {
+    if (target === SDK.TargetManager.TargetManager.instance().primaryPageTarget()) {
+      return 'Main';
+    }
+    const url = target.targetInfo()?.url;
+    if (!url) {
+      return '<unknown>';
+    }
+    const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
+    if (!parsedURL) {
+      return '<unknown>';
+    }
+    return parsedURL.lastPathComponentWithFragment();
+  }
+
+  targetAdded(target: SDK.Target.Target): void {
+    if (target.outermostTarget() !== target) {
+      return;
+    }
+    this.listItems.insertWithComparator(target, this.#targetComparator());
+
+    if (target === UI.Context.Context.instance().flavor(SDK.Target.Target)) {
+      this.#dropDown.selectItem(target);
+    }
+  }
+
+  targetRemoved(target: SDK.Target.Target): void {
+    const index = this.listItems.indexOf(target);
+    if (index === -1) {
+      return;
+    }
+    this.listItems.remove(index);
+  }
+
+  #targetComparator() {
+    return (a: SDK.Target.Target, b: SDK.Target.Target): number => {
+      const aTargetInfo = a.targetInfo();
+      const bTargetInfo = b.targetInfo();
+      if (!aTargetInfo || !bTargetInfo) {
+        return 0;
+      }
+
+      if (!aTargetInfo.subtype?.length && bTargetInfo.subtype?.length) {
+        return -1;
+      }
+      if (aTargetInfo.subtype?.length && !bTargetInfo.subtype?.length) {
+        return 1;
+      }
+      return aTargetInfo.url.localeCompare(bTargetInfo.url);
+    };
+  }
+
+  #onTargetInfoChanged(event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>): void {
+    const targetManager = SDK.TargetManager.TargetManager.instance();
+    const target = targetManager.targetById(event.data.targetId);
+    if (!target || target.outermostTarget() !== target) {
+      return;
+    }
+    this.targetRemoved(target);
+    this.targetAdded(target);
+  }
+
+  #targetChanged({
+    data: target,
+  }: Common.EventTarget.EventTargetEvent<SDK.Target.Target|null>): void {
+    this.#dropDown.selectItem(target?.outermostTarget() || null);
+  }
+
+  createElementForItem(item: SDK.Target.Target): Element {
+    const element = document.createElement('div');
+    const shadowRoot =
+        UI.Utils.createShadowRootWithCoreStyles(element, {cssFile: undefined, delegatesFocus: undefined});
+    const title = shadowRoot.createChild('div', 'title');
+    UI.UIUtils.createTextChild(title, Platform.StringUtilities.trimEndWithMaxLength(this.titleFor(item), 100));
+    const subTitle = shadowRoot.createChild('div', 'subtitle');
+    UI.UIUtils.createTextChild(subTitle, this.#subtitleFor(item));
+    return element;
+  }
+
+  #subtitleFor(target: SDK.Target.Target): string {
+    const targetInfo = target.targetInfo();
+    if (!targetInfo) {
+      return '';
+    }
+    const components = [];
+    const url = Common.ParsedURL.ParsedURL.fromString(targetInfo.url);
+    if (url) {
+      components.push(url.domain());
+    }
+    if (targetInfo.subtype) {
+      components.push(targetInfo.subtype);
+    }
+    return components.join(' ');
+  }
+
+  isItemSelectable(_item: SDK.Target.Target): boolean {
+    return true;
+  }
+
+  itemSelected(item: SDK.Target.Target|null): void {
+    const title =
+        item ? i18nString(UIStrings.targetS, {PH1: this.titleFor(item)}) : i18nString(UIStrings.targetNotSelected);
+    this.#toolbarItem.setTitle(title);
+    if (item) {
+      UI.Context.Context.instance().setFlavor(SDK.Target.Target, item);
+    }
+  }
+}
diff --git a/front_end/entrypoints/main/main-meta.ts b/front_end/entrypoints/main/main-meta.ts
index f9da548..93fdf87 100644
--- a/front_end/entrypoints/main/main-meta.ts
+++ b/front_end/entrypoints/main/main-meta.ts
@@ -909,7 +909,7 @@
 UI.Toolbar.registerToolbarItem({
   async loadItem() {
     const Main = await loadMainModule();
-    return Main.MainImpl.SettingsButtonProvider.instance();
+    return Main.OutermostTargetSelector.OutermostTargetSelector.instance();
   },
   order: 98,
   location: UI.Toolbar.ToolbarItemLocation.MAIN_TOOLBAR_RIGHT,
@@ -917,6 +917,20 @@
   condition: undefined,
   separator: undefined,
   actionId: undefined,
+  experiment: Root.Runtime.ExperimentName.OUTERMOST_TARGET_SELECTOR,
+});
+
+UI.Toolbar.registerToolbarItem({
+  async loadItem() {
+    const Main = await loadMainModule();
+    return Main.MainImpl.SettingsButtonProvider.instance();
+  },
+  order: 99,
+  location: UI.Toolbar.ToolbarItemLocation.MAIN_TOOLBAR_RIGHT,
+  showLabel: undefined,
+  condition: undefined,
+  separator: undefined,
+  actionId: undefined,
 });
 
 UI.Toolbar.registerToolbarItem({
@@ -924,7 +938,7 @@
     const Main = await loadMainModule();
     return Main.MainImpl.MainMenuItem.instance();
   },
-  order: 99,
+  order: 100,
   location: UI.Toolbar.ToolbarItemLocation.MAIN_TOOLBAR_RIGHT,
   showLabel: undefined,
   condition: undefined,
@@ -936,7 +950,7 @@
   async loadItem() {
     return UI.DockController.CloseButtonProvider.instance();
   },
-  order: 100,
+  order: 101,
   location: UI.Toolbar.ToolbarItemLocation.MAIN_TOOLBAR_RIGHT,
   showLabel: undefined,
   condition: undefined,
diff --git a/front_end/entrypoints/main/main.ts b/front_end/entrypoints/main/main.ts
index 2daa91b..e2e9797 100644
--- a/front_end/entrypoints/main/main.ts
+++ b/front_end/entrypoints/main/main.ts
@@ -4,10 +4,12 @@
 
 import * as ExecutionContextSelector from './ExecutionContextSelector.js';
 import * as MainImpl from './MainImpl.js';
+import * as OutermostTargetSelector from './OutermostTargetSelector.js';
 import * as SimpleApp from './SimpleApp.js';
 
 export {
   ExecutionContextSelector,
   MainImpl,
+  OutermostTargetSelector,
   SimpleApp,
 };
diff --git a/front_end/ui/legacy/Toolbar.ts b/front_end/ui/legacy/Toolbar.ts
index a666710..01daae1 100644
--- a/front_end/ui/legacy/Toolbar.ts
+++ b/front_end/ui/legacy/Toolbar.ts
@@ -1103,7 +1103,7 @@
 
 function getRegisteredToolbarItems(): ToolbarItemRegistration[] {
   return registeredToolbarItems.filter(
-      item => Root.Runtime.Runtime.isDescriptorEnabled({experiment: undefined, condition: item.condition}));
+      item => Root.Runtime.Runtime.isDescriptorEnabled({experiment: item.experiment, condition: item.condition}));
 }
 
 export interface ToolbarItemRegistration {
@@ -1114,6 +1114,7 @@
   actionId?: string;
   condition?: string;
   loadItem?: (() => Promise<Provider>);
+  experiment?: string;
 }
 
 // TODO(crbug.com/1167717): Make this a const enum again
diff --git a/test/unittests/front_end/entrypoints/main/BUILD.gn b/test/unittests/front_end/entrypoints/main/BUILD.gn
index 7412429..6fdf97d 100644
--- a/test/unittests/front_end/entrypoints/main/BUILD.gn
+++ b/test/unittests/front_end/entrypoints/main/BUILD.gn
@@ -9,6 +9,7 @@
   sources = [
     "ExecutionContextSelector_test.ts",
     "MainImpl_test.ts",
+    "OutermostTargetSelector_test.ts",
   ]
 
   deps = [
diff --git a/test/unittests/front_end/entrypoints/main/OutermostTargetSelector_test.ts b/test/unittests/front_end/entrypoints/main/OutermostTargetSelector_test.ts
new file mode 100644
index 0000000..6904768
--- /dev/null
+++ b/test/unittests/front_end/entrypoints/main/OutermostTargetSelector_test.ts
@@ -0,0 +1,78 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const {assert} = chai;
+
+import * as SDK from '../../../../../front_end/core/sdk/sdk.js';
+import * as UI from '../../../../../front_end/ui/legacy/legacy.js';
+import * as Main from '../../../../../front_end/entrypoints/main/main.js';
+import type * as Protocol from '../../../../../front_end/generated/protocol.js';
+import {
+  createTarget,
+} from '../../helpers/EnvironmentHelpers.js';
+
+import {
+  describeWithMockConnection,
+} from '../../helpers/MockConnection.js';
+
+describeWithMockConnection('OutermostTargetSelector', () => {
+  let tabTarget: SDK.Target.Target;
+  let primaryTarget: SDK.Target.Target;
+  let prerenderTarget: SDK.Target.Target;
+  let selector: Main.OutermostTargetSelector.OutermostTargetSelector;
+
+  beforeEach(() => {
+    tabTarget = createTarget({type: SDK.Target.Type.Tab});
+    primaryTarget = createTarget({parentTarget: tabTarget});
+    prerenderTarget =
+        createTarget({parentTarget: tabTarget, subtype: 'prerender', url: 'http://example.com/prerender1'});
+    selector = Main.OutermostTargetSelector.OutermostTargetSelector.instance({forceNew: true});
+  });
+
+  it('creates drop-down with outermost targets', () => {
+    assert.deepEqual([...selector.listItems], [primaryTarget, prerenderTarget]);
+
+    createTarget({parentTarget: primaryTarget});
+    assert.deepEqual([...selector.listItems], [primaryTarget, prerenderTarget]);
+
+    const prerenderTarget2 =
+        createTarget({parentTarget: tabTarget, subtype: 'prerender', url: 'http://example.com/prerender2'});
+    assert.deepEqual([...selector.listItems], [primaryTarget, prerenderTarget, prerenderTarget2]);
+
+    prerenderTarget.dispose('');
+    assert.deepEqual([...selector.listItems], [primaryTarget, prerenderTarget2]);
+  });
+
+  it('updates selected target when UI context flavor changes', () => {
+    UI.Context.Context.instance().setFlavor(SDK.Target.Target, primaryTarget);
+    assert.strictEqual(selector.item().element.title, 'Page: Main');
+    UI.Context.Context.instance().setFlavor(SDK.Target.Target, prerenderTarget);
+    assert.strictEqual(selector.item().element.title, 'Page: prerender1');
+  });
+
+  it('updates when target info changes', () => {
+    UI.Context.Context.instance().setFlavor(SDK.Target.Target, prerenderTarget);
+    assert.strictEqual(selector.item().element.title, 'Page: prerender1');
+    const newTargetInfo = {
+      targetId: prerenderTarget.id() as Protocol.Target.TargetID,
+      type: 'frame',
+      url: 'https://example.com/prerender3',
+      title: '',
+      attached: true,
+      canAccessOpener: true,
+    };
+    prerenderTarget.updateTargetInfo(newTargetInfo);
+    tabTarget.model(SDK.ChildTargetManager.ChildTargetManager)
+        ?.dispatchEventToListeners(SDK.ChildTargetManager.Events.TargetInfoChanged, newTargetInfo);
+
+    assert.strictEqual(selector.item().element.title, 'Page: prerender3');
+  });
+
+  it('updates UI context flavor on selection', () => {
+    selector.itemSelected(primaryTarget);
+    assert.strictEqual(UI.Context.Context.instance().flavor(SDK.Target.Target), primaryTarget);
+    selector.itemSelected(prerenderTarget);
+    assert.strictEqual(UI.Context.Context.instance().flavor(SDK.Target.Target), prerenderTarget);
+  });
+});
diff --git a/test/unittests/front_end/helpers/EnvironmentHelpers.ts b/test/unittests/front_end/helpers/EnvironmentHelpers.ts
index 1afe262..2e97dea 100644
--- a/test/unittests/front_end/helpers/EnvironmentHelpers.ts
+++ b/test/unittests/front_end/helpers/EnvironmentHelpers.ts
@@ -30,13 +30,15 @@
 
 let uniqueTargetId = 0;
 
-export function createTarget({id, name, type = SDK.Target.Type.Frame, parentTarget, subtype}: {
-  id?: Protocol.Target.TargetID,
-  name?: string,
-  type?: SDK.Target.Type,
-  parentTarget?: SDK.Target.Target,
-  subtype?: string,
-} = {}) {
+export function createTarget(
+    {id, name, type = SDK.Target.Type.Frame, parentTarget, subtype, url = 'http://example.com'}: {
+      id?: Protocol.Target.TargetID,
+      name?: string,
+      type?: SDK.Target.Type,
+      parentTarget?: SDK.Target.Target,
+      subtype?: string,
+      url?: string,
+    } = {}) {
   if (!id) {
     if (!uniqueTargetId++) {
       id = 'test' as Protocol.Target.TargetID;
@@ -48,7 +50,7 @@
   return targetManager.createTarget(
       id, name ?? id, type, parentTarget ? parentTarget : null, /* sessionId=*/ parentTarget ? id : undefined,
       /* suspended=*/ false,
-      /* connection=*/ undefined, {subtype} as Protocol.Target.TargetInfo);
+      /* connection=*/ undefined, {targetId: id, url, subtype} as Protocol.Target.TargetInfo);
 }
 
 function createSettingValue(