UNPKG

chrome-devtools-frontend

Version:
270 lines (245 loc) 10.3 kB
// Copyright 2026 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // Side-effect import: registers the `<devtools-menu-button>` custom element // used by `PLUS_BUTTON_VIEW` below. The named imports are type-only. import './ContextMenu.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import type * as Platform from '../../core/platform/platform.js'; import {Directives, html, render} from '../lit/lit.js'; import type {ContextMenu, MenuButton} from './ContextMenu.js'; import type {View} from './View.js'; import {ViewLocationValues} from './ViewRegistration.js'; const UIStrings = { /** * @description Default tooltip / accessible name of the "plus" button shown * after the visible tabs in a tab strip. Clicking it opens a menu listing * tools that are not currently shown as a visible tab. */ moreTools: 'More tools', } as const; const str_ = i18n.i18n.registerUIStrings('ui/legacy/PlusButton.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** Declarative configuration for the plus button. */ export interface PlusButtonOptions { title?: Platform.UIString.LocalizedString; jslogContext?: string; } /** * Minimal `TabbedPane` surface read by the populator. Defined as an * interface so test doubles can satisfy it without an `as unknown as * TabbedPane` double-cast. */ export interface PlusButtonTabbedPane { element: HTMLElement; hiddenTabs(): ReadonlyArray<{id: string, title: string, jslogContext?: string}>; hasTab(id: string): boolean; firstHiddenTabIndex(): number; moveTab(tabId: string, newIndex: number): void; selectTab(tabId: string, userGesture?: boolean, forceFocus?: boolean): boolean; } export interface PlusButtonMenuContext { tabbedPane: PlusButtonTabbedPane; location: string; /** * Production callers pass `() => location.views.values()` (NOT * `manager.viewsForLocation(location)`) so views moved in via * `appendView` are reflected immediately. Called fresh on every * menu open. */ views: () => Iterable<View>; manager: { viewsForLocation(location: string): View[], moveView(viewId: string, locationName: string): void, }; showView: (view: View) => void; } interface AddToolEntry { title: string; jslogContext: string; isPreviewFeature: boolean; action: () => void; } export interface OverflowTabModel { id: string; title: string; jslogContext?: string; } export interface PlusButtonMenuModel { overflowTabs: readonly OverflowTabModel[]; addToolEntries: readonly AddToolEntry[]; } /** * Presenter (MVP) for the plus-button menu. {@link buildModel} is called * fresh on every menu open so newly-registered views — or views that * just left the visible tab strip — are reflected immediately. */ export class PlusButtonPresenter { readonly #context: PlusButtonMenuContext; constructor(context: PlusButtonMenuContext) { this.#context = context; } buildModel(): PlusButtonMenuModel { const {tabbedPane, location, views, manager} = this.#context; const overflowTabs: readonly OverflowTabModel[] = tabbedPane.hiddenTabs().map(tab => ({id: tab.id, title: tab.title, jslogContext: tab.jslogContext})); const addToolEntries: AddToolEntry[] = []; // Seed dedup sets from the overflowed tabs so an addable entry // (e.g. a closeable view in the other main location) sharing an id // or title with an overflowed tab is not listed twice. const seenIds = new Set<string>(overflowTabs.map(tab => tab.id)); const seenTitles = new Set<string>(overflowTabs.map(tab => tab.title)); for (const view of views()) { // Skip views that already have a tab. Hidden tabs are already listed // in the overflow section above, and visible tabs are accessible // directly in the tab strip. if (tabbedPane.hasTab(view.viewId())) { continue; } // Transient views are not user-addable. if (view.isTransient()) { continue; } if (seenIds.has(view.viewId()) || seenTitles.has(view.title())) { continue; } seenIds.add(view.viewId()); seenTitles.add(view.title()); const isIssuesPane = view.viewId() === 'issues-pane'; addToolEntries.push({ title: view.title(), jslogContext: view.viewId(), isPreviewFeature: view.isPreviewFeature(), action: () => { if (isIssuesPane) { // Distinct from `HAMBURGER_MENU` so plus-button opens are // not conflated with three-dot-menu opens in the dashboard. Host.userMetrics.issuesPanelOpenedFrom(Host.UserMetrics.IssueOpener.MORE_TOOLS_MENU); } this.#context.showView(view); }, }); } // Offer cross-location moves between the two main surfaces: the // panel plus button lists drawer views and vice versa. const otherLocation = location === ViewLocationValues.PANEL ? ViewLocationValues.DRAWER_VIEW : location === ViewLocationValues.DRAWER_VIEW ? ViewLocationValues.PANEL : null; if (otherLocation) { for (const view of manager.viewsForLocation(otherLocation)) { // Non-closeable views (e.g. Console) cannot be moved between // locations, so they're excluded here. They still appear in the // overflow section when their own location's tab strip overflows. if (view.isTransient() || !view.isCloseable() || seenIds.has(view.viewId()) || seenTitles.has(view.title())) { continue; } seenIds.add(view.viewId()); seenTitles.add(view.title()); const viewId = view.viewId(); addToolEntries.push({ title: view.title(), jslogContext: viewId, isPreviewFeature: view.isPreviewFeature(), action: () => manager.moveView(viewId, location), }); } } addToolEntries.sort((a, b) => a.title.localeCompare(b.title)); return {overflowTabs, addToolEntries}; } } /** * Renders the plus-button menu by asking {@link PlusButtonPresenter} * for a model and pushing it into `contextMenu`. Overflowed tabs (in * tab order) come first, followed by deduplicated "add tool" entries * sorted alphabetically. */ export function populatePlusButtonMenu(contextMenu: ContextMenu, context: PlusButtonMenuContext): void { const model = new PlusButtonPresenter(context).buildModel(); const hasOverflow = model.overflowTabs.length > 0; // When there are no overflowed tabs, surface the add-tool entries in // the default section so they are not visually demoted to a footer. for (const tab of model.overflowTabs) { contextMenu.defaultSection().appendItem( tab.title, () => revealOverflowTab(context.tabbedPane, tab.id), {jslogContext: tab.jslogContext ?? tab.id}); } const addToolSection = hasOverflow ? contextMenu.footerSection() : contextMenu.defaultSection(); for (const entry of model.addToolEntries) { addToolSection.appendItem( entry.title, entry.action, {isPreviewFeature: entry.isPreviewFeature, jslogContext: entry.jslogContext}); } } /** * Reveals an overflowed tab and persists its new position via * `moveTab(firstHidden - 1)` so the tab stays in the visible region * after a reload — independent of any runtime `currentTab` / * `lastSelectedOverflowTab` priority logic. The previously-last-visible * tab is pushed to the start of the overflow region, matching the * intuition that the newly opened tab replaces the one the user * implicitly stopped using. * * Exported only for testing. */ export function revealOverflowTab(tabbedPane: PlusButtonTabbedPane, tabId: string): void { const firstHidden = tabbedPane.firstHiddenTabIndex(); if (firstHidden > 0) { // `firstHidden - 1` is the index of the last currently-visible tab. tabbedPane.moveTab(tabId, firstHidden - 1); } tabbedPane.selectTab(tabId, /* userGesture */ true, /* forceFocus */ true); } interface PlusButtonViewInput { title: string; jslogContext: string; populateMenuCall: (menu: ContextMenu) => void; } /** * Standard `(input, output, target)` view function so `Lit.render` is * called inside a view (per `@devtools/no-lit-render-outside-of-view`). * `output.button` is captured via `ref` to avoid a `querySelector` * round-trip in {@link installPlusButton}. * * `slot` is set declaratively in the template so the attribute is * present on the very first connection — the first `slotchange` then * sees the button as the trailing-slot target and no extra layout pass * is needed. */ export const PLUS_BUTTON_VIEW = (input: PlusButtonViewInput, output: {button?: MenuButton}, target: HTMLElement): void => { render( html` <devtools-menu-button ${Directives.ref(el => { output.button = el as MenuButton | undefined; })} slot="trailing-button" .iconName=${'plus'} .title=${input.title} .jslogContext=${input.jslogContext} .populateMenuCall=${input.populateMenuCall}> </devtools-menu-button>`, target); }; /** * Renders a `<devtools-menu-button>` configured as the plus button into * `tabbedPane`'s `trailing-button` slot and returns the slotted host. * The returned `MenuButton` is used by the next CL to toggle visibility * (e.g. when the drawer is minimized). */ export function installPlusButton(context: PlusButtonMenuContext, options: PlusButtonOptions = {}): MenuButton { const output: {button?: MenuButton} = {}; // `render` is synchronous and the `ref` directive fires during render, // so `output.button` is assigned by the time the view returns. PLUS_BUTTON_VIEW( { title: options.title ?? i18nString(UIStrings.moreTools), jslogContext: options.jslogContext ?? '', populateMenuCall: menu => populatePlusButtonMenu(menu, context), }, output, context.tabbedPane.element); if (!output.button) { throw new Error('installPlusButton: ref directive did not capture <devtools-menu-button>'); } return output.button; }