chrome-devtools-frontend
Version:
Chrome DevTools UI
715 lines (607 loc) • 22.9 kB
text/typescript
/*
* Copyright (C) 2011 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 @typescript-eslint/no-explicit-any */
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 TextUtils from '../text_utils/text_utils.js';
import {Events as WorkspaceImplEvents, type Project} from './WorkspaceImpl.js';
const UIStrings = {
/**
*@description Text for the index of something
*/
index: '(index)',
/**
*@description Text in UISource Code of the DevTools local workspace
*/
thisFileWasChangedExternally: 'This file was changed externally. Would you like to reload it?',
} as const;
const str_ = i18n.i18n.registerUIStrings('models/workspace/UISourceCode.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class UISourceCode extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
TextUtils.ContentProvider.ContentProvider {
readonly #origin: Platform.DevToolsPath.UrlString;
readonly #parentURL: Platform.DevToolsPath.UrlString;
#project: Project;
#url: Platform.DevToolsPath.UrlString;
#name: string;
#contentType: Common.ResourceType.ResourceType;
#requestContentPromise: Promise<TextUtils.ContentData.ContentDataOrError>|null = null;
#decorations = new Map<string, any>();
#hasCommits = false;
#messages: Set<Message>|null = null;
#content: TextUtils.ContentData.ContentDataOrError|null = null;
#forceLoadOnCheckContent = false;
#checkingContent = false;
#lastAcceptedContent: string|null = null;
#workingCopy: string|null = null;
#workingCopyGetter: (() => string)|null = null;
#disableEdit = false;
#contentEncoded: boolean|undefined;
#isKnownThirdParty = false;
#isUnconditionallyIgnoreListed = false;
#containsAiChanges = false;
constructor(project: Project, url: Platform.DevToolsPath.UrlString, contentType: Common.ResourceType.ResourceType) {
super();
this.#project = project;
this.#url = url;
const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
if (parsedURL) {
this.#origin = parsedURL.securityOrigin();
this.#parentURL = Common.ParsedURL.ParsedURL.concatenate(this.#origin, parsedURL.folderPathComponents);
if (parsedURL.queryParams && !(parsedURL.lastPathComponent && contentType.isFromSourceMap())) {
// If there is a query param, display it like a URL. Unless it is from a source map,
// in which case the query param is probably a hash that is best left hidden.
this.#name = parsedURL.lastPathComponent + '?' + parsedURL.queryParams;
} else {
// file name looks best decoded
this.#name = decodeURIComponent(parsedURL.lastPathComponent);
}
} else {
this.#origin = Platform.DevToolsPath.EmptyUrlString;
this.#parentURL = Platform.DevToolsPath.EmptyUrlString;
this.#name = url;
}
this.#contentType = contentType;
}
requestMetadata(): Promise<UISourceCodeMetadata|null> {
return this.#project.requestMetadata(this);
}
name(): string {
return this.#name;
}
mimeType(): string {
return this.#project.mimeType(this);
}
url(): Platform.DevToolsPath.UrlString {
return this.#url;
}
// Identifier used for deduplicating scripts that are considered by the
// DevTools UI to be the same script. For now this is just the url but this
// is likely to change in the future.
canonicalScriptId(): string {
return `${this.#contentType.name()},${this.#url}`;
}
parentURL(): Platform.DevToolsPath.UrlString {
return this.#parentURL;
}
origin(): Platform.DevToolsPath.UrlString {
return this.#origin;
}
fullDisplayName(): string {
return this.#project.fullDisplayName(this);
}
displayName(skipTrim?: boolean): string {
if (!this.#name) {
return i18nString(UIStrings.index);
}
const name = this.#name;
return skipTrim ? name : Platform.StringUtilities.trimEndWithMaxLength(name, 100);
}
canRename(): boolean {
return this.#project.canRename();
}
rename(newName: Platform.DevToolsPath.RawPathString): Promise<boolean> {
const {resolve, promise} = Promise.withResolvers<boolean>();
this.#project.rename(this, newName, innerCallback.bind(this));
return promise;
function innerCallback(
this: UISourceCode, success: boolean, newName?: string, newURL?: Platform.DevToolsPath.UrlString,
newContentType?: Common.ResourceType.ResourceType): void {
if (success) {
this.#updateName(
newName as Platform.DevToolsPath.RawPathString, newURL as Platform.DevToolsPath.UrlString,
newContentType as Common.ResourceType.ResourceType);
}
resolve(success);
}
}
remove(): void {
this.#project.deleteFile(this);
}
#updateName(
name: Platform.DevToolsPath.RawPathString, url: Platform.DevToolsPath.UrlString,
contentType?: Common.ResourceType.ResourceType): void {
const oldURL = this.#url;
this.#name = name;
if (url) {
this.#url = url;
} else {
this.#url = Common.ParsedURL.ParsedURL.relativePathToUrlString(name, oldURL);
}
if (contentType) {
this.#contentType = contentType;
}
this.dispatchEventToListeners(Events.TitleChanged, this);
this.project().workspace().dispatchEventToListeners(
WorkspaceImplEvents.UISourceCodeRenamed, {oldURL, uiSourceCode: this});
}
contentURL(): Platform.DevToolsPath.UrlString {
return this.url();
}
contentType(): Common.ResourceType.ResourceType {
return this.#contentType;
}
project(): Project {
return this.#project;
}
requestContentData({cachedWasmOnly}: {cachedWasmOnly?: boolean} = {}):
Promise<TextUtils.ContentData.ContentDataOrError> {
if (this.#requestContentPromise) {
return this.#requestContentPromise;
}
if (this.#content) {
return Promise.resolve(this.#content);
}
if (cachedWasmOnly && this.mimeType() === 'application/wasm') {
return Promise.resolve(new TextUtils.WasmDisassembly.WasmDisassembly([], [], []));
}
this.#requestContentPromise = this.#requestContent();
return this.#requestContentPromise;
}
async requestContent(options: {cachedWasmOnly?: boolean} = {}): Promise<TextUtils.ContentProvider.DeferredContent> {
return TextUtils.ContentData.ContentData.asDeferredContent(await this.requestContentData(options));
}
async #requestContent(): Promise<TextUtils.ContentData.ContentDataOrError> {
if (this.#content) {
throw new Error('Called UISourceCode#requestContentImpl even though content is available for ' + this.#url);
}
try {
this.#content = await this.#project.requestFileContent(this);
} catch (err) {
this.#content = {error: err ? String(err) : ''};
}
return this.#content;
}
#decodeContent(content: TextUtils.ContentProvider.DeferredContent|null): string|null {
if (!content) {
return null;
}
return content.isEncoded && content.content ? window.atob(content.content) : content.content;
}
/** Only used to compare whether content changed */
#unsafeDecodeContentData(content: TextUtils.ContentData.ContentDataOrError|null): string|null {
if (!content || TextUtils.ContentData.ContentData.isError(content)) {
return null;
}
return content.createdFromBase64 ? window.atob(content.base64) : content.text;
}
async checkContentUpdated(): Promise<void> {
if (!this.#content && !this.#forceLoadOnCheckContent) {
return;
}
if (!this.#project.canSetFileContent() || this.#checkingContent) {
return;
}
this.#checkingContent = true;
const updatedContent =
TextUtils.ContentData.ContentData.asDeferredContent(await this.#project.requestFileContent(this));
if ('error' in updatedContent) {
return;
}
this.#checkingContent = false;
if (updatedContent.content === null) {
const workingCopy = this.workingCopy();
this.#contentCommitted('', false);
this.setWorkingCopy(workingCopy);
return;
}
if (this.#lastAcceptedContent === updatedContent.content) {
return;
}
if (this.#unsafeDecodeContentData(this.#content) === this.#decodeContent(updatedContent)) {
this.#lastAcceptedContent = null;
return;
}
if (!this.isDirty() || this.#workingCopy === updatedContent.content) {
this.#contentCommitted(updatedContent.content, false);
return;
}
await Common.Revealer.reveal(this);
// Make sure we are in the next frame before stopping the world with confirm
await new Promise(resolve => window.setTimeout(resolve, 0));
const shouldUpdate = window.confirm(i18nString(UIStrings.thisFileWasChangedExternally));
if (shouldUpdate) {
this.#contentCommitted(updatedContent.content, false);
} else {
this.#lastAcceptedContent = updatedContent.content;
}
}
forceLoadOnCheckContent(): void {
this.#forceLoadOnCheckContent = true;
}
#commitContent(content: string): void {
if (this.#project.canSetFileContent()) {
void this.#project.setFileContent(this, content, false);
}
this.#contentCommitted(content, true);
}
#contentCommitted(content: string, committedByUser: boolean): void {
this.#lastAcceptedContent = null;
this.#content = new TextUtils.ContentData.ContentData(content, Boolean(this.#contentEncoded), this.mimeType());
this.#requestContentPromise = null;
this.#hasCommits = true;
this.#resetWorkingCopy();
const data = {uiSourceCode: this, content, encoded: this.#contentEncoded};
this.dispatchEventToListeners(Events.WorkingCopyCommitted, data);
this.#project.workspace().dispatchEventToListeners(WorkspaceImplEvents.WorkingCopyCommitted, data);
if (committedByUser) {
this.#project.workspace().dispatchEventToListeners(WorkspaceImplEvents.WorkingCopyCommittedByUser, data);
}
}
addRevision(content: string): void {
this.#commitContent(content);
}
hasCommits(): boolean {
return this.#hasCommits;
}
workingCopy(): string {
return this.workingCopyContent().content || '';
}
workingCopyContent(): TextUtils.ContentProvider.DeferredContent {
return this.workingCopyContentData().asDeferedContent();
}
workingCopyContentData(): TextUtils.ContentData.ContentData {
if (this.#workingCopyGetter) {
this.#workingCopy = this.#workingCopyGetter();
this.#workingCopyGetter = null;
}
const contentData = this.#content ? TextUtils.ContentData.ContentData.contentDataOrEmpty(this.#content) :
TextUtils.ContentData.EMPTY_TEXT_CONTENT_DATA;
if (this.#workingCopy !== null) {
return new TextUtils.ContentData.ContentData(this.#workingCopy, /* isBase64 */ false, contentData.mimeType);
}
return contentData;
}
resetWorkingCopy(): void {
this.#resetWorkingCopy();
this.#workingCopyChanged();
}
#resetWorkingCopy(): void {
this.#workingCopy = null;
this.#workingCopyGetter = null;
this.setContainsAiChanges(false);
}
setWorkingCopy(newWorkingCopy: string): void {
this.#workingCopy = newWorkingCopy;
this.#workingCopyGetter = null;
this.#workingCopyChanged();
}
setContainsAiChanges(containsAiChanges: boolean): void {
this.#containsAiChanges = containsAiChanges;
}
containsAiChanges(): boolean {
return this.#containsAiChanges;
}
setContent(content: string, isBase64: boolean): void {
this.#contentEncoded = isBase64;
if (this.#project.canSetFileContent()) {
void this.#project.setFileContent(this, content, isBase64);
}
this.#contentCommitted(content, true);
}
setWorkingCopyGetter(workingCopyGetter: () => string): void {
this.#workingCopyGetter = workingCopyGetter;
this.#workingCopyChanged();
}
#workingCopyChanged(): void {
this.#removeAllMessages();
this.dispatchEventToListeners(Events.WorkingCopyChanged, this);
this.#project.workspace().dispatchEventToListeners(WorkspaceImplEvents.WorkingCopyChanged, {uiSourceCode: this});
}
removeWorkingCopyGetter(): void {
if (!this.#workingCopyGetter) {
return;
}
this.#workingCopy = this.#workingCopyGetter();
this.#workingCopyGetter = null;
}
commitWorkingCopy(): void {
if (this.isDirty()) {
this.#commitContent(this.workingCopy());
}
}
isDirty(): boolean {
return this.#workingCopy !== null || this.#workingCopyGetter !== null;
}
isKnownThirdParty(): boolean {
return this.#isKnownThirdParty;
}
markKnownThirdParty(): void {
this.#isKnownThirdParty = true;
}
/**
* {@link markAsUnconditionallyIgnoreListed}
*/
isUnconditionallyIgnoreListed(): boolean {
return this.#isUnconditionallyIgnoreListed;
}
isFetchXHR(): boolean {
return [Common.ResourceType.resourceTypes.XHR, Common.ResourceType.resourceTypes.Fetch].includes(
this.contentType());
}
/**
* Unconditionally ignore list this UISourcecode, ignoring any user
* setting. We use this to mark breakpoint/logpoint condition scripts for now.
*/
markAsUnconditionallyIgnoreListed(): void {
this.#isUnconditionallyIgnoreListed = true;
}
extension(): string {
return Common.ParsedURL.ParsedURL.extractExtension(this.#name);
}
content(): string {
if (!this.#content || 'error' in this.#content) {
return '';
}
return this.#content.text;
}
loadError(): string|null {
return (this.#content && 'error' in this.#content && this.#content.error) || null;
}
searchInContent(query: string, caseSensitive: boolean, isRegex: boolean):
Promise<TextUtils.ContentProvider.SearchMatch[]> {
if (!this.#content || 'error' in this.#content) {
return this.#project.searchInFileContent(this, query, caseSensitive, isRegex);
}
return Promise.resolve(
TextUtils.TextUtils.performSearchInContentData(this.#content, query, caseSensitive, isRegex));
}
contentLoaded(): boolean {
return Boolean(this.#content);
}
uiLocation(lineNumber: number, columnNumber?: number): UILocation {
return new UILocation(this, lineNumber, columnNumber);
}
messages(): Set<Message> {
return this.#messages ? new Set(this.#messages) : new Set();
}
addLineMessage(
level: Message.Level, text: string, lineNumber: number, columnNumber?: number,
clickHandler?: (() => void)): Message {
const range = TextUtils.TextRange.TextRange.createFromLocation(lineNumber, columnNumber || 0);
const message = new Message(level, text, clickHandler, range);
this.addMessage(message);
return message;
}
addMessage(message: Message): void {
if (!this.#messages) {
this.#messages = new Set();
}
this.#messages.add(message);
this.dispatchEventToListeners(Events.MessageAdded, message);
}
removeMessage(message: Message): void {
if (this.#messages?.delete(message)) {
this.dispatchEventToListeners(Events.MessageRemoved, message);
}
}
#removeAllMessages(): void {
if (!this.#messages) {
return;
}
for (const message of this.#messages) {
this.dispatchEventToListeners(Events.MessageRemoved, message);
}
this.#messages = null;
}
setDecorationData(type: string, data: any): void {
if (data !== this.#decorations.get(type)) {
this.#decorations.set(type, data);
this.dispatchEventToListeners(Events.DecorationChanged, type);
}
}
getDecorationData(type: string): any {
return this.#decorations.get(type);
}
disableEdit(): void {
this.#disableEdit = true;
}
editDisabled(): boolean {
return this.#disableEdit;
}
}
export enum Events {
/* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
WorkingCopyChanged = 'WorkingCopyChanged',
WorkingCopyCommitted = 'WorkingCopyCommitted',
TitleChanged = 'TitleChanged',
MessageAdded = 'MessageAdded',
MessageRemoved = 'MessageRemoved',
DecorationChanged = 'DecorationChanged',
/* eslint-enable @typescript-eslint/naming-convention */
}
export interface WorkingCopyCommittedEvent {
uiSourceCode: UISourceCode;
content: string;
encoded: boolean|undefined;
}
export interface EventTypes {
[Events.WorkingCopyChanged]: UISourceCode;
[Events.WorkingCopyCommitted]: WorkingCopyCommittedEvent;
[Events.TitleChanged]: UISourceCode;
[Events.MessageAdded]: Message;
[Events.MessageRemoved]: Message;
[Events.DecorationChanged]: string;
}
export class UILocation {
uiSourceCode: UISourceCode;
lineNumber: number;
columnNumber: number|undefined;
constructor(uiSourceCode: UISourceCode, lineNumber: number, columnNumber?: number) {
this.uiSourceCode = uiSourceCode;
this.lineNumber = lineNumber;
this.columnNumber = columnNumber;
}
linkText(skipTrim = false, showColumnNumber = false): string {
const displayName = this.uiSourceCode.displayName(skipTrim);
const lineAndColumnText = this.lineAndColumnText(showColumnNumber);
let text = lineAndColumnText ? displayName + ':' + lineAndColumnText : displayName;
if (this.uiSourceCode.isDirty()) {
text = '*' + text;
}
return text;
}
lineAndColumnText(showColumnNumber = false): string|undefined {
let lineAndColumnText;
if (this.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 this.columnNumber === 'number') {
lineAndColumnText = `0x${this.columnNumber.toString(16)}`;
}
} else {
lineAndColumnText = `${this.lineNumber + 1}`;
if (showColumnNumber && typeof this.columnNumber === 'number') {
lineAndColumnText += ':' + (this.columnNumber + 1);
}
}
return lineAndColumnText;
}
id(): string {
if (typeof this.columnNumber === 'number') {
return this.uiSourceCode.project().id() + ':' + this.uiSourceCode.url() + ':' + this.lineNumber + ':' +
this.columnNumber;
}
return this.lineId();
}
lineId(): string {
return this.uiSourceCode.project().id() + ':' + this.uiSourceCode.url() + ':' + this.lineNumber;
}
static comparator(location1: UILocation, location2: UILocation): number {
return location1.compareTo(location2);
}
compareTo(other: UILocation): number {
if (this.uiSourceCode.url() !== other.uiSourceCode.url()) {
return this.uiSourceCode.url() > other.uiSourceCode.url() ? 1 : -1;
}
if (this.lineNumber !== other.lineNumber) {
return this.lineNumber - other.lineNumber;
}
// We consider `undefined` less than an actual column number, since
// UI location without a column number corresponds to the whole line.
if (this.columnNumber === other.columnNumber) {
return 0;
}
if (typeof this.columnNumber !== 'number') {
return -1;
}
if (typeof other.columnNumber !== 'number') {
return 1;
}
return this.columnNumber - other.columnNumber;
}
}
/**
* A text range inside a specific {@link UISourceCode}.
*
* We use a class instead of an interface so we can implement a revealer for it.
*/
export class UILocationRange {
readonly uiSourceCode: UISourceCode;
readonly range: TextUtils.TextRange.TextRange;
constructor(uiSourceCode: UISourceCode, range: TextUtils.TextRange.TextRange) {
this.uiSourceCode = uiSourceCode;
this.range = range;
}
}
/**
* A message associated with a range in a `UISourceCode`. The range will be
* underlined starting at the range's start and ending at the line end (the
* end of the range is currently disregarded).
* An icon is going to appear at the end of the line according to the
* `level` of the Message. This is only the model; displaying is handled
* where UISourceCode displaying is handled.
*/
export class Message {
readonly #level: Message.Level;
readonly #text: string;
range: TextUtils.TextRange.TextRange;
readonly #clickHandler?: (() => void);
constructor(level: Message.Level, text: string, clickHandler?: (() => void), range?: TextUtils.TextRange.TextRange) {
this.#level = level;
this.#text = text;
this.range = range ?? new TextUtils.TextRange.TextRange(0, 0, 0, 0);
this.#clickHandler = clickHandler;
}
level(): Message.Level {
return this.#level;
}
text(): string {
return this.#text;
}
clickHandler(): (() => void)|undefined {
return this.#clickHandler;
}
lineNumber(): number {
return this.range.startLine;
}
columnNumber(): number|undefined {
return this.range.startColumn;
}
isEqual(another: Message): boolean {
return this.text() === another.text() && this.level() === another.level() && this.range.equal(another.range);
}
}
export namespace Message {
export const enum Level {
ERROR = 'Error',
ISSUE = 'Issue',
WARNING = 'Warning',
}
}
export class UISourceCodeMetadata {
modificationTime: Date|null;
contentSize: number|null;
constructor(modificationTime: Date|null, contentSize: number|null) {
this.modificationTime = modificationTime;
this.contentSize = contentSize;
}
}