| /* |
| * Copyright (C) 2012 Google Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions are |
| * met: |
| * |
| * * Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * * Redistributions in binary form must reproduce the above |
| * copyright notice, this list of conditions and the following disclaimer |
| * in the documentation and/or other materials provided with the |
| * distribution. |
| * * Neither the name of Google Inc. nor the names of its |
| * contributors may be used to endorse or promote products derived from |
| * this software without specific prior written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| /* eslint-disable rulesdir/no-imperative-dom-api */ |
| |
| import * as Common from '../../../../core/common/common.js'; |
| import * as Host from '../../../../core/host/host.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 type * as Protocol from '../../../../generated/protocol.js'; |
| import * as Bindings from '../../../../models/bindings/bindings.js'; |
| import * as Breakpoints from '../../../../models/breakpoints/breakpoints.js'; |
| import * as TextUtils from '../../../../models/text_utils/text_utils.js'; |
| import type * as Trace from '../../../../models/trace/trace.js'; |
| import * as Workspace from '../../../../models/workspace/workspace.js'; |
| import type * as IconButton from '../../../components/icon_button/icon_button.js'; |
| import * as VisualLogging from '../../../visual_logging/visual_logging.js'; |
| import * as UI from '../../legacy.js'; |
| |
| const UIStrings = { |
| /** |
| *@description Text in Linkifier |
| */ |
| unknown: '(unknown)', |
| /** |
| *@description Text short for automatic |
| */ |
| auto: 'auto', |
| /** |
| *@description Text in Linkifier |
| *@example {Sources panel} PH1 |
| */ |
| revealInS: 'Reveal in {PH1}', |
| /** |
| *@description Text for revealing an item in its destination |
| */ |
| reveal: 'Reveal', |
| /** |
| *@description A context menu item in the Linkifier |
| *@example {Extension} PH1 |
| */ |
| openUsingS: 'Open using {PH1}', |
| /** |
| * @description The name of a setting which controls how links are handled in the UI. 'Handling' |
| * refers to the ability of extensions to DevTools to be able to intercept link clicks so that they |
| * can react to them. |
| */ |
| linkHandling: 'Link handling:', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/utils/Linkifier.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| const instances = new Set<Linkifier>(); |
| |
| let decorator: LinkDecorator|null = null; |
| |
| const anchorsByUISourceCode = new WeakMap<Workspace.UISourceCode.UISourceCode, Set<Element>>(); |
| |
| const infoByAnchor = new WeakMap<Node, LinkInfo>(); |
| |
| const textByAnchor = new WeakMap<Node, string>(); |
| |
| // Maps a DevTools Extension origin to a particular LinkHandler. |
| const linkHandlers = new Map<string, LinkHandlerRegistration>(); |
| |
| let linkHandlerSettingInstance: Common.Settings.Setting<string>; |
| |
| export class Linkifier extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements SDK.TargetManager.Observer { |
| private readonly maxLength: number; |
| private readonly anchorsByTarget = new Map<SDK.Target.Target, Element[]>(); |
| private readonly locationPoolByTarget = new Map<SDK.Target.Target, Bindings.LiveLocation.LiveLocationPool>(); |
| private useLinkDecorator: boolean; |
| readonly #anchorUpdaters: WeakMap<Element, (this: Linkifier, anchor: HTMLElement) => void>; |
| |
| constructor(maxLengthForDisplayedURLs?: number, useLinkDecorator?: boolean) { |
| super(); |
| this.maxLength = maxLengthForDisplayedURLs || UI.UIUtils.MaxLengthForDisplayedURLs; |
| this.useLinkDecorator = Boolean(useLinkDecorator); |
| this.#anchorUpdaters = new WeakMap(); |
| instances.add(this); |
| SDK.TargetManager.TargetManager.instance().observeTargets(this); |
| Workspace.Workspace.WorkspaceImpl.instance().addEventListener( |
| Workspace.Workspace.Events.WorkingCopyChanged, this.#onWorkingCopyChangedOrCommitted, this); |
| Workspace.Workspace.WorkspaceImpl.instance().addEventListener( |
| Workspace.Workspace.Events.WorkingCopyCommitted, this.#onWorkingCopyChangedOrCommitted, this); |
| } |
| |
| #onWorkingCopyChangedOrCommitted({ |
| data: {uiSourceCode} |
| }: Common.EventTarget.EventTargetEvent<{uiSourceCode: Workspace.UISourceCode.UISourceCode}>): void { |
| const anchors = anchorsByUISourceCode.get(uiSourceCode); |
| if (!anchors) { |
| return; |
| } |
| for (const anchor of anchors) { |
| const updater = this.#anchorUpdaters.get(anchor); |
| if (!updater) { |
| continue; |
| } |
| updater.call(this, anchor as HTMLElement); |
| } |
| } |
| |
| static setLinkDecorator(linkDecorator: LinkDecorator): void { |
| console.assert(!decorator, 'Cannot re-register link decorator.'); |
| decorator = linkDecorator; |
| linkDecorator.addEventListener(LinkDecorator.Events.LINK_ICON_CHANGED, onLinkIconChanged); |
| for (const linkifier of instances) { |
| linkifier.updateAllAnchorDecorations(); |
| } |
| |
| function onLinkIconChanged(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void { |
| const uiSourceCode = event.data; |
| const links = anchorsByUISourceCode.get(uiSourceCode) || []; |
| for (const link of links) { |
| Linkifier.updateLinkDecorations(link); |
| } |
| } |
| } |
| |
| private updateAllAnchorDecorations(): void { |
| for (const anchors of this.anchorsByTarget.values()) { |
| for (const anchor of anchors) { |
| Linkifier.updateLinkDecorations(anchor); |
| } |
| } |
| } |
| |
| private static bindUILocation(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void { |
| const linkInfo = Linkifier.linkInfo(anchor); |
| if (!linkInfo) { |
| return; |
| } |
| linkInfo.uiLocation = uiLocation; |
| if (!uiLocation) { |
| return; |
| } |
| const uiSourceCode = uiLocation.uiSourceCode; |
| let sourceCodeAnchors = anchorsByUISourceCode.get(uiSourceCode); |
| if (!sourceCodeAnchors) { |
| sourceCodeAnchors = new Set(); |
| anchorsByUISourceCode.set(uiSourceCode, sourceCodeAnchors); |
| } |
| sourceCodeAnchors.add(anchor); |
| } |
| |
| static bindUILocationForTest(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void { |
| Linkifier.bindUILocation(anchor, uiLocation); |
| } |
| |
| private static unbindUILocation(anchor: Element): void { |
| const info = Linkifier.linkInfo(anchor); |
| if (!info?.uiLocation) { |
| return; |
| } |
| |
| const uiSourceCode = info.uiLocation.uiSourceCode; |
| info.uiLocation = null; |
| const sourceCodeAnchors = anchorsByUISourceCode.get(uiSourceCode); |
| if (sourceCodeAnchors) { |
| sourceCodeAnchors.delete(anchor); |
| } |
| } |
| |
| /** |
| * When we link to a breakpoint condition, we need to stash the BreakpointLocation as the revealable |
| * in the LinkInfo. |
| */ |
| private static bindBreakpoint(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void { |
| const info = Linkifier.linkInfo(anchor); |
| if (!info) { |
| return; |
| } |
| |
| const breakpoint = Breakpoints.BreakpointManager.BreakpointManager.instance().findBreakpoint(uiLocation); |
| if (breakpoint) { |
| info.revealable = breakpoint; |
| } |
| } |
| |
| /** |
| * When we link to a breakpoint condition, we store the BreakpointLocation in the revealable. |
| * Clear it when the LiveLocation updates. |
| */ |
| private static unbindBreakpoint(anchor: Element): void { |
| const info = Linkifier.linkInfo(anchor); |
| if (info?.revealable) { |
| info.revealable = null; |
| } |
| } |
| |
| targetAdded(target: SDK.Target.Target): void { |
| this.anchorsByTarget.set(target, []); |
| this.locationPoolByTarget.set(target, new Bindings.LiveLocation.LiveLocationPool()); |
| } |
| |
| targetRemoved(target: SDK.Target.Target): void { |
| const locationPool = this.locationPoolByTarget.get(target); |
| this.locationPoolByTarget.delete(target); |
| if (!locationPool) { |
| return; |
| } |
| locationPool.disposeAll(); |
| const anchors = (this.anchorsByTarget.get(target) as HTMLElement[] | null); |
| if (!anchors) { |
| return; |
| } |
| this.anchorsByTarget.delete(target); |
| for (const anchor of anchors) { |
| const info = Linkifier.linkInfo(anchor); |
| if (!info) { |
| continue; |
| } |
| info.liveLocation = null; |
| Linkifier.unbindUILocation(anchor); |
| const fallback = info.fallback; |
| if (fallback) { |
| anchor.replaceWith(fallback); |
| } |
| } |
| } |
| |
| maybeLinkifyScriptLocation( |
| target: SDK.Target.Target|null, scriptId: Protocol.Runtime.ScriptId|null, |
| sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number|undefined, options?: LinkifyOptions): HTMLElement |
| |null { |
| let fallbackAnchor: HTMLElement|null = null; |
| const linkifyURLOptions: LinkifyURLOptions = { |
| lineNumber, |
| maxLength: this.maxLength, |
| columnNumber: options?.columnNumber, |
| showColumnNumber: Boolean(options?.showColumnNumber), |
| className: options?.className, |
| tabStop: options?.tabStop, |
| inlineFrameIndex: options?.inlineFrameIndex ?? 0, |
| userMetric: options?.userMetric, |
| jslogContext: options?.jslogContext || 'script-location', |
| omitOrigin: options?.omitOrigin, |
| }; |
| const {columnNumber, className = ''} = linkifyURLOptions; |
| if (sourceURL) { |
| fallbackAnchor = Linkifier.linkifyURL(sourceURL, linkifyURLOptions); |
| } |
| if (!target || target.isDisposed()) { |
| return fallbackAnchor; |
| } |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); |
| if (!debuggerModel) { |
| return fallbackAnchor; |
| } |
| |
| // Prefer createRawLocationByScriptId() here, since it will always produce a correct |
| // link, since the script ID is unique. Only fall back to createRawLocationByURL() |
| // when all we have is an URL, which is not guaranteed to be unique. |
| const rawLocation = scriptId ? debuggerModel.createRawLocationByScriptId( |
| scriptId, lineNumber || 0, columnNumber, linkifyURLOptions.inlineFrameIndex) : |
| debuggerModel.createRawLocationByURL( |
| sourceURL, lineNumber || 0, columnNumber, linkifyURLOptions.inlineFrameIndex); |
| if (!rawLocation) { |
| return fallbackAnchor; |
| } |
| |
| const createLinkOptions: CreateLinkOptions = { |
| tabStop: options?.tabStop, |
| jslogContext: 'script-location', |
| }; |
| const {link, linkInfo} = Linkifier.createLink( |
| fallbackAnchor?.textContent ? fallbackAnchor.textContent : '', className, createLinkOptions); |
| linkInfo.enableDecorator = this.useLinkDecorator; |
| linkInfo.fallback = fallbackAnchor; |
| linkInfo.userMetric = options?.userMetric; |
| |
| const pool = this.locationPoolByTarget.get(rawLocation.debuggerModel.target()); |
| if (!pool) { |
| return fallbackAnchor; |
| } |
| |
| const linkDisplayOptions: LinkDisplayOptions = { |
| showColumnNumber: linkifyURLOptions.showColumnNumber ?? false, |
| revealBreakpoint: options?.revealBreakpoint, |
| }; |
| |
| const updateDelegate = async(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> => { |
| await this.updateAnchor(link, linkDisplayOptions, liveLocation); |
| this.dispatchEventToListeners(Events.LIVE_LOCATION_UPDATED, liveLocation); |
| }; |
| void Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance() |
| .createLiveLocation(rawLocation, updateDelegate.bind(this), pool) |
| .then(liveLocation => { |
| if (liveLocation) { |
| linkInfo.liveLocation = liveLocation; |
| } |
| }); |
| |
| const anchors = (this.anchorsByTarget.get(rawLocation.debuggerModel.target()) as Element[]); |
| anchors.push(link); |
| return link; |
| } |
| |
| linkifyScriptLocation( |
| target: SDK.Target.Target|null, scriptId: Protocol.Runtime.ScriptId|null, |
| sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number|undefined, options?: LinkifyOptions): HTMLElement { |
| const scriptLink = this.maybeLinkifyScriptLocation(target, scriptId, sourceURL, lineNumber, options); |
| const linkifyURLOptions: LinkifyURLOptions = { |
| lineNumber, |
| maxLength: this.maxLength, |
| className: options?.className, |
| columnNumber: options?.columnNumber, |
| showColumnNumber: Boolean(options?.showColumnNumber), |
| inlineFrameIndex: options?.inlineFrameIndex ?? 0, |
| tabStop: options?.tabStop, |
| userMetric: options?.userMetric, |
| jslogContext: options?.jslogContext || 'script-source-url', |
| }; |
| |
| return scriptLink || Linkifier.linkifyURL(sourceURL, linkifyURLOptions); |
| } |
| |
| linkifyRawLocation( |
| rawLocation: SDK.DebuggerModel.Location, fallbackUrl: Platform.DevToolsPath.UrlString, |
| className?: string): Element { |
| return this.linkifyScriptLocation( |
| rawLocation.debuggerModel.target(), rawLocation.scriptId, fallbackUrl, rawLocation.lineNumber, { |
| columnNumber: rawLocation.columnNumber, |
| className, |
| inlineFrameIndex: rawLocation.inlineFrameIndex, |
| }); |
| } |
| |
| maybeLinkifyConsoleCallFrame( |
| target: SDK.Target.Target|null, callFrame: Protocol.Runtime.CallFrame|Trace.Types.Events.CallFrame, |
| options?: LinkifyOptions): HTMLElement|null { |
| const linkifyOptions: LinkifyOptions = { |
| ...options, |
| columnNumber: callFrame.columnNumber, |
| inlineFrameIndex: options?.inlineFrameIndex ?? 0, |
| }; |
| return this.maybeLinkifyScriptLocation( |
| target, String(callFrame.scriptId) as Protocol.Runtime.ScriptId, |
| callFrame.url as Platform.DevToolsPath.UrlString, callFrame.lineNumber, linkifyOptions); |
| } |
| |
| linkifyStackTraceTopFrame(target: SDK.Target.Target|null, stackTrace: Protocol.Runtime.StackTrace): HTMLElement { |
| console.assert(stackTrace.callFrames.length > 0); |
| |
| const {url, lineNumber, columnNumber} = stackTrace.callFrames[0]; |
| const fallbackAnchor = Linkifier.linkifyURL(url as Platform.DevToolsPath.UrlString, { |
| lineNumber, |
| columnNumber, |
| showColumnNumber: false, |
| inlineFrameIndex: 0, |
| maxLength: this.maxLength, |
| preventClick: true, |
| jslogContext: 'script-source-url', |
| }); |
| |
| // HAR imported network logs have no associated NetworkManager. |
| if (!target) { |
| return fallbackAnchor; |
| } |
| |
| // The contract is that disposed targets don't have a LiveLocationPool |
| // associated, whereas all active targets have one such pool. This ensures |
| // that the fallbackAnchor is only ever used when the target was disposed. |
| const pool = this.locationPoolByTarget.get(target); |
| if (!pool || target.isDisposed()) { |
| return fallbackAnchor; |
| } |
| |
| // All targets that can report stack traces also have a debugger model. |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel) as SDK.DebuggerModel.DebuggerModel; |
| |
| const {link, linkInfo} = Linkifier.createLink('', '', {jslogContext: 'script-location'}); |
| linkInfo.enableDecorator = this.useLinkDecorator; |
| linkInfo.fallback = fallbackAnchor; |
| |
| const linkDisplayOptions = {showColumnNumber: false}; |
| |
| const updateDelegate = async(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> => { |
| await this.updateAnchor(link, linkDisplayOptions, liveLocation); |
| this.dispatchEventToListeners(Events.LIVE_LOCATION_UPDATED, liveLocation); |
| }; |
| void Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance() |
| .createStackTraceTopFrameLiveLocation( |
| debuggerModel.createRawLocationsByStackTrace(stackTrace), updateDelegate.bind(this), pool) |
| .then(liveLocation => { |
| linkInfo.liveLocation = liveLocation; |
| }); |
| |
| const anchors = (this.anchorsByTarget.get(target) as Element[]); |
| anchors.push(link); |
| return link; |
| } |
| |
| linkifyCSSLocation(rawLocation: SDK.CSSModel.CSSLocation, classes?: string): Element { |
| const createLinkOptions: CreateLinkOptions = { |
| tabStop: true, |
| jslogContext: 'css-location', |
| }; |
| const {link, linkInfo} = Linkifier.createLink('', classes || '', createLinkOptions); |
| linkInfo.enableDecorator = this.useLinkDecorator; |
| |
| const pool = this.locationPoolByTarget.get(rawLocation.cssModel().target()); |
| if (!pool) { |
| return link; |
| } |
| |
| const linkDisplayOptions = {showColumnNumber: false}; |
| |
| const updateDelegate = async(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> => { |
| await this.updateAnchor(link, linkDisplayOptions, liveLocation); |
| this.dispatchEventToListeners(Events.LIVE_LOCATION_UPDATED, liveLocation); |
| }; |
| void Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance() |
| .createLiveLocation(rawLocation, updateDelegate.bind(this), pool) |
| .then(liveLocation => { |
| linkInfo.liveLocation = liveLocation; |
| }); |
| |
| const anchors = (this.anchorsByTarget.get(rawLocation.cssModel().target()) as Element[]); |
| anchors.push(link); |
| return link; |
| } |
| |
| reset(): void { |
| // Create a copy of {keys} so {targetRemoved} can safely modify the map. |
| for (const target of [...this.anchorsByTarget.keys()]) { |
| this.targetRemoved(target); |
| this.targetAdded(target); |
| } |
| this.listeners?.clear(); |
| } |
| |
| dispose(): void { |
| Workspace.Workspace.WorkspaceImpl.instance().removeEventListener( |
| Workspace.Workspace.Events.WorkingCopyChanged, this.#onWorkingCopyChangedOrCommitted, this); |
| Workspace.Workspace.WorkspaceImpl.instance().removeEventListener( |
| Workspace.Workspace.Events.WorkingCopyCommitted, this.#onWorkingCopyChangedOrCommitted, this); |
| // Create a copy of {keys} so {targetRemoved} can safely modify the map. |
| for (const target of [...this.anchorsByTarget.keys()]) { |
| this.targetRemoved(target); |
| } |
| SDK.TargetManager.TargetManager.instance().unobserveTargets(this); |
| instances.delete(this); |
| } |
| |
| private async updateAnchor( |
| anchor: HTMLElement, options: LinkDisplayOptions, |
| liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> { |
| Linkifier.unbindUILocation(anchor); |
| if (options.revealBreakpoint) { |
| Linkifier.unbindBreakpoint(anchor); |
| } |
| const uiLocation = await liveLocation.uiLocation(); |
| if (!uiLocation) { |
| if (liveLocation instanceof Bindings.CSSWorkspaceBinding.LiveLocation) { |
| const header = (liveLocation).header(); |
| if (header?.ownerNode) { |
| anchor.addEventListener('click', event => { |
| event.consume(true); |
| void Common.Revealer.reveal(header.ownerNode || null); |
| }, false); |
| Linkifier.setTrimmedText(anchor, '<style>'); |
| } |
| } |
| |
| anchor.classList.add('invalid-link'); |
| anchor.removeAttribute('role'); |
| return; |
| } |
| |
| Linkifier.bindUILocation(anchor, uiLocation); |
| if (options.revealBreakpoint) { |
| Linkifier.bindBreakpoint(anchor, uiLocation); |
| } |
| |
| const text = uiLocation.linkText(true /* skipTrim */, options.showColumnNumber); |
| Linkifier.setTrimmedText(anchor, text, this.maxLength); |
| this.#anchorUpdaters.set(anchor, function(this: Linkifier, anchor: HTMLElement) { |
| void this.updateAnchor(anchor, options, liveLocation); |
| }); |
| |
| let titleText: string = uiLocation.uiSourceCode.url(); |
| if (uiLocation.uiSourceCode.mimeType() === 'application/wasm') { |
| // For WebAssembly locations, we follow the conventions described in |
| // github.com/WebAssembly/design/blob/master/Web.md#developer-facing-display-conventions |
| if (typeof uiLocation.columnNumber === 'number') { |
| titleText += `:0x${uiLocation.columnNumber.toString(16)}`; |
| } |
| } else { |
| titleText += ':' + (uiLocation.lineNumber + 1); |
| if (options.showColumnNumber && typeof uiLocation.columnNumber === 'number') { |
| titleText += ':' + (uiLocation.columnNumber + 1); |
| } |
| } |
| UI.Tooltip.Tooltip.install(anchor, titleText); |
| anchor.classList.toggle('ignore-list-link', await liveLocation.isIgnoreListed()); |
| Linkifier.updateLinkDecorations(anchor); |
| } |
| |
| private static updateLinkDecorations(anchor: Element): void { |
| const info = Linkifier.linkInfo(anchor); |
| if (!info?.enableDecorator) { |
| return; |
| } |
| if (!decorator || !info.uiLocation) { |
| return; |
| } |
| if (info.icon?.parentElement) { |
| anchor.removeChild(info.icon); |
| } |
| const icon = decorator.linkIcon(info.uiLocation.uiSourceCode); |
| if (icon) { |
| icon.style.setProperty('margin-right', '2px'); |
| anchor.insertBefore(icon, anchor.firstChild); |
| } |
| info.icon = icon; |
| } |
| |
| static linkifyURL(url: Platform.DevToolsPath.UrlString, options?: LinkifyURLOptions): HTMLElement { |
| options = options || { |
| showColumnNumber: false, |
| inlineFrameIndex: 0, |
| }; |
| |
| const text = options.text; |
| const className = options.className || ''; |
| const lineNumber = options.lineNumber; |
| const columnNumber = options.columnNumber; |
| const showColumnNumber = options.showColumnNumber; |
| const preventClick = options.preventClick; |
| const maxLength = options.maxLength || UI.UIUtils.MaxLengthForDisplayedURLs; |
| const bypassURLTrimming = options.bypassURLTrimming; |
| const omitOrigin = options.omitOrigin; |
| if (!url || Common.ParsedURL.schemeIs(url, 'javascript:')) { |
| const element = document.createElement('span'); |
| if (className) { |
| element.className = className; |
| } |
| |
| element.textContent = text || url || i18nString(UIStrings.unknown); |
| return element; |
| } |
| |
| let linkText = text || Bindings.ResourceUtils.displayNameForURL(url); |
| |
| if (omitOrigin) { |
| const parsedUrl = URL.parse(url); |
| if (parsedUrl) { |
| linkText = url.replace(parsedUrl.origin, ''); |
| } |
| } |
| |
| if (typeof lineNumber === 'number' && !text) { |
| linkText += ':' + (lineNumber + 1); |
| if (showColumnNumber && typeof columnNumber === 'number') { |
| linkText += ':' + (columnNumber + 1); |
| } |
| } |
| const title = linkText !== url ? url : ''; |
| const linkOptions = { |
| maxLength, |
| title, |
| href: url, |
| preventClick, |
| tabStop: options.tabStop, |
| bypassURLTrimming, |
| jslogContext: options.jslogContext || 'url', |
| }; |
| const {link, linkInfo} = Linkifier.createLink(linkText, className, linkOptions); |
| if (lineNumber) { |
| linkInfo.lineNumber = lineNumber; |
| } |
| if (columnNumber) { |
| linkInfo.columnNumber = columnNumber; |
| } |
| linkInfo.userMetric = options?.userMetric; |
| return link; |
| } |
| |
| static linkifyRevealable( |
| revealable: Object, text: string|HTMLElement, fallbackHref?: Platform.DevToolsPath.UrlString, title?: string, |
| className?: string, jslogContext?: string): HTMLElement { |
| const createLinkOptions: CreateLinkOptions = { |
| maxLength: UI.UIUtils.MaxLengthForDisplayedURLs, |
| href: (fallbackHref), |
| title, |
| jslogContext, |
| }; |
| const {link, linkInfo} = Linkifier.createLink(text, className || '', createLinkOptions); |
| linkInfo.revealable = revealable; |
| return link; |
| } |
| |
| private static createLink(text: string|HTMLElement, className: string, options: CreateLinkOptions = {}): |
| {link: HTMLElement, linkInfo: LinkInfo} { |
| const {maxLength, title, href, preventClick, tabStop, bypassURLTrimming, jslogContext} = options; |
| const link = document.createElement(options.preventClick ? 'span' : 'button'); |
| if (className) { |
| link.className = className; |
| } |
| link.classList.add('devtools-link'); |
| if (!options.preventClick) { |
| link.classList.add('text-button', 'link-style'); |
| } |
| if (title) { |
| UI.Tooltip.Tooltip.install(link, title); |
| } |
| if (href) { |
| // @ts-expect-error |
| link.href = href; |
| } |
| link.setAttribute('jslog', `${VisualLogging.link(jslogContext).track({click: true})}`); |
| |
| if (text instanceof HTMLElement) { |
| link.appendChild(text); |
| } else if (bypassURLTrimming) { |
| link.classList.add('devtools-link-styled-trim'); |
| Linkifier.appendTextWithoutHashes(link, text); |
| } else { |
| Linkifier.setTrimmedText(link, text, maxLength); |
| } |
| |
| const linkInfo = { |
| icon: null, |
| enableDecorator: false, |
| uiLocation: null, |
| liveLocation: null, |
| url: href || null, |
| lineNumber: null, |
| columnNumber: null, |
| inlineFrameIndex: 0, |
| revealable: null, |
| fallback: null, |
| }; |
| infoByAnchor.set(link, linkInfo); |
| if (!preventClick) { |
| const handler = (event: MouseEvent|KeyboardEvent): void => { |
| if (event instanceof KeyboardEvent && event.key !== Platform.KeyboardUtilities.ENTER_KEY && event.key !== ' ') { |
| return; |
| } |
| if (Linkifier.handleClick(event)) { |
| event.consume(true); |
| } |
| }; |
| link.onclick = handler; |
| link.onkeydown = handler; |
| } else { |
| link.classList.add('devtools-link-prevent-click'); |
| } |
| UI.ARIAUtils.markAsLink(link); |
| link.tabIndex = tabStop ? 0 : -1; |
| return {link, linkInfo}; |
| } |
| |
| private static setTrimmedText(link: Element, text: string, maxLength?: number): void { |
| link.removeChildren(); |
| if (maxLength && text.length > maxLength) { |
| const middleSplit = splitMiddle(text, maxLength); |
| Linkifier.appendTextWithoutHashes(link, middleSplit[0]); |
| Linkifier.appendHiddenText(link, middleSplit[1]); |
| Linkifier.appendTextWithoutHashes(link, middleSplit[2]); |
| } else { |
| Linkifier.appendTextWithoutHashes(link, text); |
| } |
| |
| function splitMiddle(string: string, maxLength: number): string[] { |
| let leftIndex = Math.floor(maxLength / 2); |
| let rightIndex = string.length - Math.ceil(maxLength / 2) + 1; |
| |
| const codePointAtRightIndex = string.codePointAt(rightIndex - 1); |
| // Do not truncate between characters that use multiple code points (emojis). |
| if (typeof codePointAtRightIndex !== 'undefined' && codePointAtRightIndex >= 0x10000) { |
| rightIndex++; |
| leftIndex++; |
| } |
| const codePointAtLeftIndex = string.codePointAt(leftIndex - 1); |
| if (typeof codePointAtLeftIndex !== 'undefined' && leftIndex > 0 && codePointAtLeftIndex >= 0x10000) { |
| leftIndex--; |
| } |
| return [string.substring(0, leftIndex), string.substring(leftIndex, rightIndex), string.substring(rightIndex)]; |
| } |
| } |
| |
| private static appendTextWithoutHashes(link: Element, string: string): void { |
| const hashSplit = TextUtils.TextUtils.Utils.splitStringByRegexes(string, [/[a-f0-9]{20,}/g]); |
| for (const match of hashSplit) { |
| if (match.regexIndex === -1) { |
| UI.UIUtils.createTextChild(link, match.value); |
| } else { |
| UI.UIUtils.createTextChild(link, match.value.substring(0, 7)); |
| Linkifier.appendHiddenText(link, match.value.substring(7)); |
| } |
| } |
| } |
| |
| private static appendHiddenText(link: Element, string: string): void { |
| const ellipsisNode = UI.UIUtils.createTextChild(link.createChild('span', 'devtools-link-ellipsis'), '…'); |
| textByAnchor.set(ellipsisNode, string); |
| } |
| |
| static untruncatedNodeText(node: Node): string { |
| return textByAnchor.get(node) || node.textContent || ''; |
| } |
| |
| static linkInfo(link: Element|null): LinkInfo|null { |
| return link ? infoByAnchor.get(link) || null : null as LinkInfo | null; |
| } |
| |
| private static handleClick(event: Event): boolean { |
| const link = (event.currentTarget as Element); |
| if (UI.UIUtils.isBeingEdited((event.target as Node)) || link.hasSelection()) { |
| return false; |
| } |
| const linkInfo = Linkifier.linkInfo(link); |
| if (!linkInfo) { |
| return false; |
| } |
| return Linkifier.invokeFirstAction(linkInfo); |
| } |
| |
| static handleClickFromNewComponentLand(linkInfo: LinkInfo): void { |
| Linkifier.invokeFirstAction(linkInfo); |
| } |
| |
| static invokeFirstAction(linkInfo: LinkInfo): boolean { |
| const actions = Linkifier.linkActions(linkInfo); |
| if (actions.length) { |
| void actions[0].handler.call(null); |
| if (linkInfo.userMetric) { |
| Host.userMetrics.actionTaken(linkInfo.userMetric); |
| } |
| return true; |
| } |
| return false; |
| } |
| |
| static linkHandlerSetting(): Common.Settings.Setting<string> { |
| if (!linkHandlerSettingInstance) { |
| linkHandlerSettingInstance = |
| Common.Settings.Settings.instance().createSetting('open-link-handler', i18nString(UIStrings.auto)); |
| } |
| return linkHandlerSettingInstance; |
| } |
| |
| static registerLinkHandler(registration: LinkHandlerRegistration): void { |
| for (const origin of linkHandlers.keys()) { |
| const existingHandler = linkHandlers.get(origin); |
| if (existingHandler?.scheme === registration.scheme) { |
| const schemeString = registration.scheme ? `scheme '${registration.scheme}'` : 'all schemes'; |
| Common.Console.Console.instance().warn( |
| `DevTools extension '${registration.title}' registered with setOpenResourceHandler for ${ |
| schemeString}, which is already registered by '${ |
| existingHandler?.title}'. This can lead to unexpected results.`); |
| } |
| } |
| |
| linkHandlers.set(registration.origin, registration); |
| LinkHandlerSettingUI.instance().update(); |
| } |
| |
| static unregisterLinkHandler(registration: LinkHandlerRegistration): void { |
| const {origin} = registration; |
| linkHandlers.delete(origin); |
| LinkHandlerSettingUI.instance().update(); |
| } |
| |
| // The primary filter implementation for the openResourceHandlers. Returns false |
| // if the handler is NOT supposed to handle the `url`. Usually, this happens if |
| // a handler has registered for a particular `scheme` and the scheme for that url |
| // does not match. If no openResourceScheme is provided, it means the handler is |
| // interested in all urls (except those handled by scheme-specific handlers, see |
| // otherSchemeRegistrations). |
| static shouldHandleOpenResource = |
| (openResourceScheme: string|null, url: Platform.DevToolsPath.UrlString, otherSchemeRegistrations: Set<string>): |
| boolean => { |
| // If this is a scheme-specific handler, make sure the registered scheme is |
| // present in the url. |
| if (openResourceScheme) { |
| return url.startsWith(openResourceScheme); |
| } |
| |
| // Global handlers (that register for no scheme) can handle all urls, with the |
| // exception of urls that scheme-specific handlers have registered for. |
| const scheme = URL.parse(url)?.protocol || ''; |
| return !otherSchemeRegistrations.has(scheme); |
| }; |
| |
| static uiLocation(link: Element): Workspace.UISourceCode.UILocation|null { |
| const info = Linkifier.linkInfo(link); |
| return info ? info.uiLocation : null; |
| } |
| |
| static linkActions(info: LinkInfo): Array<{ |
| section: string, |
| title: string, |
| jslogContext: string, |
| handler: () => Promise<void>| void, |
| }> { |
| const result: Array<{ |
| section: string, |
| title: string, |
| jslogContext: string, |
| handler: () => Promise<void>| void, |
| }> = []; |
| |
| if (!info) { |
| return result; |
| } |
| |
| let url = Platform.DevToolsPath.EmptyUrlString; |
| let uiLocation: Workspace.UISourceCode.UILocation|(Workspace.UISourceCode.UILocation | null)|null = null; |
| if (info.uiLocation) { |
| uiLocation = info.uiLocation; |
| url = uiLocation.uiSourceCode.contentURL(); |
| } else if (info.url) { |
| url = info.url; |
| const uiSourceCode = Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url) || |
| Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL( |
| Common.ParsedURL.ParsedURL.urlWithoutHash(url) as Platform.DevToolsPath.UrlString); |
| uiLocation = uiSourceCode ? uiSourceCode.uiLocation(info.lineNumber || 0, info.columnNumber || 0) : null; |
| } |
| const resource = url ? Bindings.ResourceUtils.resourceForURL(url) : null; |
| const contentProvider = uiLocation ? uiLocation.uiSourceCode : resource; |
| |
| const revealable = info.revealable || uiLocation || resource; |
| if (revealable) { |
| const destination = Common.Revealer.revealDestination(revealable); |
| result.push({ |
| section: 'reveal', |
| title: destination ? i18nString(UIStrings.revealInS, {PH1: destination}) : i18nString(UIStrings.reveal), |
| jslogContext: 'reveal', |
| handler: () => Common.Revealer.reveal(revealable), |
| }); |
| } |
| |
| const contentProviderOrUrl = contentProvider || url; |
| const lineNumber = uiLocation ? uiLocation.lineNumber : info.lineNumber || 0; |
| const columnNumber = uiLocation ? uiLocation.columnNumber : info.columnNumber || 0; |
| |
| // Build the set of schemes that the currently registered extensions handle |
| // (not counting ones that are scheme-agnostic). |
| const specificSchemeHandlers = new Set<string>(); |
| for (const registration of linkHandlers.values()) { |
| if (registration.scheme) { |
| specificSchemeHandlers.add(registration.scheme); |
| } |
| } |
| |
| for (const registration of linkHandlers.values()) { |
| if (!registration?.handler) { |
| continue; |
| } |
| const {title, handler, filter: shouldHandleOpenResource} = registration; |
| if (url && !shouldHandleOpenResource(url, specificSchemeHandlers)) { |
| continue; |
| } |
| const action = { |
| section: 'reveal', |
| title: i18nString(UIStrings.openUsingS, {PH1: title}), |
| jslogContext: 'open-using', |
| handler: handler.bind(null, contentProviderOrUrl, lineNumber, columnNumber), |
| }; |
| if (title === Linkifier.linkHandlerSetting().get()) { |
| result.unshift(action); |
| } else { |
| result.push(action); |
| } |
| } |
| if (resource || info.url) { |
| result.push({ |
| section: 'reveal', |
| title: UI.UIUtils.openLinkExternallyLabel(), |
| jslogContext: 'open-in-new-tab', |
| handler: () => UI.UIUtils.openInNewTab(url), |
| }); |
| result.push({ |
| section: 'clipboard', |
| title: UI.UIUtils.copyLinkAddressLabel(), |
| jslogContext: 'copy-link-address', |
| handler: () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(url), |
| }); |
| } |
| |
| if (uiLocation?.uiSourceCode) { |
| const contentProvider = uiLocation.uiSourceCode; |
| result.push({ |
| section: 'clipboard', |
| title: UI.UIUtils.copyFileNameLabel(), |
| jslogContext: 'copy-file-name', |
| handler: () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName()), |
| }); |
| } |
| |
| return result; |
| } |
| } |
| |
| export interface LinkDecorator extends Common.EventTarget.EventTarget<LinkDecorator.EventTypes> { |
| linkIcon(uiSourceCode: Workspace.UISourceCode.UISourceCode): IconButton.Icon.Icon|null; |
| } |
| |
| export namespace LinkDecorator { |
| export const enum Events { |
| LINK_ICON_CHANGED = 'LinkIconChanged', |
| } |
| |
| export interface EventTypes { |
| [Events.LINK_ICON_CHANGED]: Workspace.UISourceCode.UISourceCode; |
| } |
| } |
| |
| export class LinkContextMenuProvider implements UI.ContextMenu.Provider<Node> { |
| appendApplicableItems(_event: Event, contextMenu: UI.ContextMenu.ContextMenu, target: Node): void { |
| let targetNode: Node|null = target; |
| while (targetNode && !infoByAnchor.get(targetNode)) { |
| targetNode = targetNode.parentNodeOrShadowHost(); |
| } |
| const link = (targetNode as Element | null); |
| const linkInfo = Linkifier.linkInfo(link); |
| if (!linkInfo) { |
| return; |
| } |
| |
| const actions = Linkifier.linkActions(linkInfo); |
| for (const action of actions) { |
| contextMenu.section(action.section).appendItem(action.title, action.handler, {jslogContext: action.jslogContext}); |
| } |
| } |
| } |
| |
| let linkHandlerSettingUIInstance: LinkHandlerSettingUI; |
| |
| export class LinkHandlerSettingUI implements UI.SettingsUI.SettingUI { |
| private element: HTMLSelectElement; |
| |
| private constructor() { |
| this.element = document.createElement('select'); |
| this.element.addEventListener('change', this.onChange.bind(this), false); |
| this.update(); |
| } |
| |
| static instance(opts: { |
| forceNew: boolean|null, |
| } = {forceNew: null}): LinkHandlerSettingUI { |
| const {forceNew} = opts; |
| if (!linkHandlerSettingUIInstance || forceNew) { |
| linkHandlerSettingUIInstance = new LinkHandlerSettingUI(); |
| } |
| |
| return linkHandlerSettingUIInstance; |
| } |
| |
| update(): void { |
| this.element.removeChildren(); |
| const names = [...linkHandlers.keys()]; |
| names.unshift(i18nString(UIStrings.auto)); |
| for (const name of names) { |
| const option = document.createElement('option'); |
| option.textContent = name; |
| option.selected = name === Linkifier.linkHandlerSetting().get(); |
| this.element.appendChild(option); |
| } |
| this.element.disabled = names.length <= 1; |
| } |
| |
| private onChange(event: Event): void { |
| if (!event.target) { |
| return; |
| } |
| const value = (event.target as HTMLSelectElement).value; |
| Linkifier.linkHandlerSetting().set(value); |
| } |
| |
| settingElement(): Element|null { |
| return UI.SettingsUI.createCustomSetting(i18nString(UIStrings.linkHandling), this.element); |
| } |
| } |
| |
| let listeningToNewEvents = false; |
| function listenForNewComponentLinkifierEvents(): void { |
| if (listeningToNewEvents) { |
| return; |
| } |
| |
| listeningToNewEvents = true; |
| |
| window.addEventListener('linkifieractivated', function(event: Event) { |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const unknownEvent = (event as any); |
| const eventWithData = (unknownEvent as { |
| data: LinkInfo, |
| }); |
| Linkifier.handleClickFromNewComponentLand(eventWithData.data); |
| }); |
| } |
| |
| listenForNewComponentLinkifierEvents(); |
| |
| export class ContentProviderContextMenuProvider implements |
| UI.ContextMenu |
| .Provider<Workspace.UISourceCode.UISourceCode|SDK.Resource.Resource|SDK.NetworkRequest.NetworkRequest> { |
| appendApplicableItems( |
| _event: Event, contextMenu: UI.ContextMenu.ContextMenu, |
| contentProvider: Workspace.UISourceCode.UISourceCode|SDK.Resource.Resource| |
| SDK.NetworkRequest.NetworkRequest): void { |
| const contentUrl = contentProvider.contentURL(); |
| if (!contentUrl) { |
| return; |
| } |
| |
| if (!Common.ParsedURL.schemeIs(contentUrl, 'file:')) { |
| contextMenu.revealSection().appendItem( |
| UI.UIUtils.openLinkExternallyLabel(), |
| () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab( |
| contentUrl.endsWith(':formatted') ? |
| Common.ParsedURL.ParsedURL.slice(contentUrl, 0, contentUrl.lastIndexOf(':')) : |
| contentUrl), |
| {jslogContext: 'open-in-new-tab'}); |
| } |
| for (const origin of linkHandlers.keys()) { |
| const registration = linkHandlers.get(origin); |
| if (!registration) { |
| continue; |
| } |
| const {title} = registration; |
| contextMenu.revealSection().appendItem( |
| i18nString(UIStrings.openUsingS, {PH1: title}), registration.handler.bind(null, contentProvider, 0), |
| {jslogContext: 'open-using'}); |
| } |
| if (contentProvider instanceof SDK.NetworkRequest.NetworkRequest) { |
| return; |
| } |
| |
| contextMenu.clipboardSection().appendItem( |
| UI.UIUtils.copyLinkAddressLabel(), |
| () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentUrl), |
| {jslogContext: 'copy-link-address'}); |
| |
| // TODO(bmeurer): `displayName` should be an accessor/data property consistently. |
| if (contentProvider instanceof Workspace.UISourceCode.UISourceCode) { |
| contextMenu.clipboardSection().appendItem( |
| UI.UIUtils.copyFileNameLabel(), |
| () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName()), |
| {jslogContext: 'copy-file-name'}); |
| } else { |
| contextMenu.clipboardSection().appendItem( |
| UI.UIUtils.copyFileNameLabel(), |
| () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName), |
| {jslogContext: 'copy-file-name'}); |
| } |
| } |
| } |
| |
| interface LinkInfo { |
| icon: IconButton.Icon.Icon|null; |
| enableDecorator: boolean; |
| uiLocation: Workspace.UISourceCode.UILocation|null; |
| liveLocation: Bindings.LiveLocation.LiveLocation|null; |
| url: Platform.DevToolsPath.UrlString|null; |
| lineNumber: number|null; |
| columnNumber: number|null; |
| inlineFrameIndex: number; |
| revealable: Object|null; |
| fallback: Element|null; |
| userMetric?: Host.UserMetrics.Action; |
| jslogContext?: string; |
| } |
| |
| export interface LinkifyURLOptions { |
| text?: string; |
| className?: string; |
| lineNumber?: number; |
| columnNumber?: number; |
| showColumnNumber?: boolean; |
| inlineFrameIndex?: number; |
| preventClick?: boolean; |
| maxLength?: number; |
| tabStop?: boolean; |
| bypassURLTrimming?: boolean; |
| userMetric?: Host.UserMetrics.Action; |
| jslogContext?: string; |
| omitOrigin?: boolean; |
| } |
| |
| export interface LinkifyOptions { |
| className?: string; |
| columnNumber?: number; |
| showColumnNumber?: boolean; |
| inlineFrameIndex: number; |
| tabStop?: boolean; |
| userMetric?: Host.UserMetrics.Action; |
| jslogContext?: string; |
| omitOrigin?: boolean; |
| |
| /** |
| * {@link LinkDisplayOptions.revealBreakpoint} |
| */ |
| revealBreakpoint?: boolean; |
| } |
| |
| interface CreateLinkOptions { |
| maxLength?: number; |
| title?: string; |
| href?: Platform.DevToolsPath.UrlString; |
| preventClick?: boolean; |
| tabStop?: boolean; |
| bypassURLTrimming?: boolean; |
| jslogContext?: string; |
| } |
| |
| interface LinkDisplayOptions { |
| showColumnNumber: boolean; |
| |
| /** |
| * If true, we'll check if there is a breakpoint at the UILocation we get |
| * from the LiveLocation. If we find a breakpoint, we'll reveal the corresponding |
| * {@link Breakpoints.BreakpointManager.BreakpointLocation}. Which opens the |
| * breakpoint edit dialog. |
| */ |
| revealBreakpoint?: boolean; |
| } |
| |
| // The filter function for the openResourceHandlers. Returns true if the `url` |
| // should be considered for a particular handler. `specificSchemeHandlers` |
| // is the set of all schemes handled by all registered DevTools extensions |
| // (that specify a particular scheme). |
| export type LinkHandlerPredicate = (url: Platform.DevToolsPath.UrlString, specificSchemeHandlers: Set<string>) => |
| boolean; |
| |
| export type LinkHandler = |
| (arg0: TextUtils.ContentProvider.ContentProvider|Platform.DevToolsPath.UrlString, lineNumber: number, |
| columnNumber?: number) => void; |
| |
| export interface LinkHandlerRegistration { |
| // The title (read: manifest name) of DevTools extension registering as an openResourceHandler. |
| // This value is provided by the developer of the extension. |
| title: string; |
| // The origin of the DevTools extension handling the url. |
| origin: Platform.DevToolsPath.UrlString; |
| // The scheme that the handler wants to register for. If set, only links that match this scheme |
| // will be considered, otherwise all links will be considered. |
| scheme?: string; |
| // The openResourceHandler handling the requests to open a resource. |
| handler: LinkHandler; |
| // A filter function used to determine whether the `handler` wants to handle the link clicks. |
| filter: LinkHandlerPredicate; |
| } |
| |
| export const enum Events { |
| LIVE_LOCATION_UPDATED = 'liveLocationUpdated', |
| } |
| |
| export interface EventTypes { |
| [Events.LIVE_LOCATION_UPDATED]: Bindings.LiveLocation.LiveLocation; |
| } |