chrome-devtools-frontend
Version:
Chrome DevTools UI
482 lines (421 loc) • 19.9 kB
text/typescript
// Copyright 2016 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 Host from '../../core/host/host.js';
import type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../bindings/bindings.js';
import * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';
import {type FileSystem, FileSystemWorkspaceBinding} from './FileSystemWorkspaceBinding.js';
import {PersistenceImpl} from './PersistenceImpl.js';
export class Automapping {
readonly #workspace: Workspace.Workspace.WorkspaceImpl;
readonly #onStatusAdded: (arg0: AutomappingStatus) => Promise<void>;
readonly #onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>;
// Used in web tests
private readonly statuses = new Set<AutomappingStatus>();
readonly #fileSystemUISourceCodes = new FileSystemUISourceCodes();
// Used in web tests
private readonly sweepThrottler = new Common.Throttler.Throttler(100);
readonly #sourceCodeToProcessingPromiseMap = new WeakMap<Workspace.UISourceCode.UISourceCode, Promise<void>>();
readonly #sourceCodeToAutoMappingStatusMap = new WeakMap<Workspace.UISourceCode.UISourceCode, AutomappingStatus>();
readonly #sourceCodeToMetadataMap =
new WeakMap<Workspace.UISourceCode.UISourceCode, Workspace.UISourceCode.UISourceCodeMetadata|null>();
readonly #filesIndex: FilePathIndex = new FilePathIndex();
readonly #projectFoldersIndex: FolderIndex = new FolderIndex();
readonly #activeFoldersIndex: FolderIndex = new FolderIndex();
readonly #interceptors: Array<(arg0: Workspace.UISourceCode.UISourceCode) => boolean> = [];
constructor(
workspace: Workspace.Workspace.WorkspaceImpl, onStatusAdded: (arg0: AutomappingStatus) => Promise<void>,
onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>) {
this.#workspace = workspace;
this.#onStatusAdded = onStatusAdded;
this.#onStatusRemoved = onStatusRemoved;
this.#workspace.addEventListener(
Workspace.Workspace.Events.UISourceCodeAdded, event => this.#onUISourceCodeAdded(event.data));
this.#workspace.addEventListener(
Workspace.Workspace.Events.UISourceCodeRemoved, event => this.#onUISourceCodeRemoved(event.data));
this.#workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRenamed, this.#onUISourceCodeRenamed, this);
this.#workspace.addEventListener(
Workspace.Workspace.Events.ProjectAdded, event => this.#onProjectAdded(event.data), this);
this.#workspace.addEventListener(
Workspace.Workspace.Events.ProjectRemoved, event => this.#onProjectRemoved(event.data), this);
for (const fileSystem of workspace.projects()) {
this.#onProjectAdded(fileSystem);
}
for (const uiSourceCode of workspace.uiSourceCodes()) {
this.#onUISourceCodeAdded(uiSourceCode);
}
}
addNetworkInterceptor(interceptor: (arg0: Workspace.UISourceCode.UISourceCode) => boolean): void {
this.#interceptors.push(interceptor);
this.scheduleRemap();
}
scheduleRemap(): void {
for (const status of this.statuses.values()) {
this.#clearNetworkStatus(status.network);
}
this.#scheduleSweep();
}
#scheduleSweep(): void {
void this.sweepThrottler.schedule(sweepUnmapped.bind(this));
function sweepUnmapped(this: Automapping): Promise<void> {
const networkProjects = this.#workspace.projectsForType(Workspace.Workspace.projectTypes.Network);
for (const networkProject of networkProjects) {
for (const uiSourceCode of networkProject.uiSourceCodes()) {
void this.computeNetworkStatus(uiSourceCode);
}
}
this.onSweepHappenedForTest();
return Promise.resolve();
}
}
private onSweepHappenedForTest(): void {
}
#onProjectRemoved(project: Workspace.Workspace.Project): void {
for (const uiSourceCode of project.uiSourceCodes()) {
this.#onUISourceCodeRemoved(uiSourceCode);
}
if (project.type() !== Workspace.Workspace.projectTypes.FileSystem) {
return;
}
const fileSystem = project as FileSystem;
for (const gitFolder of fileSystem.initialGitFolders()) {
this.#projectFoldersIndex.removeFolder(gitFolder);
}
this.#projectFoldersIndex.removeFolder(fileSystem.fileSystemPath());
this.scheduleRemap();
}
#onProjectAdded(project: Workspace.Workspace.Project): void {
if (project.type() !== Workspace.Workspace.projectTypes.FileSystem) {
return;
}
const fileSystem = project as FileSystem;
for (const gitFolder of fileSystem.initialGitFolders()) {
this.#projectFoldersIndex.addFolder(gitFolder);
}
this.#projectFoldersIndex.addFolder(fileSystem.fileSystemPath());
for (const uiSourceCode of project.uiSourceCodes()) {
this.#onUISourceCodeAdded(uiSourceCode);
}
this.scheduleRemap();
}
#onUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
const project = uiSourceCode.project();
if (project.type() === Workspace.Workspace.projectTypes.FileSystem) {
if (!FileSystemWorkspaceBinding.fileSystemSupportsAutomapping(project)) {
return;
}
this.#filesIndex.addPath(uiSourceCode.url());
this.#fileSystemUISourceCodes.add(uiSourceCode);
this.#scheduleSweep();
} else if (project.type() === Workspace.Workspace.projectTypes.Network) {
void this.computeNetworkStatus(uiSourceCode);
}
}
#onUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.FileSystem) {
this.#filesIndex.removePath(uiSourceCode.url());
this.#fileSystemUISourceCodes.delete(uiSourceCode.url());
const status = this.#sourceCodeToAutoMappingStatusMap.get(uiSourceCode);
if (status) {
this.#clearNetworkStatus(status.network);
}
} else if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network) {
this.#clearNetworkStatus(uiSourceCode);
}
}
#onUISourceCodeRenamed(event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.UISourceCodeRenamedEvent>):
void {
const {uiSourceCode, oldURL} = event.data;
if (uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.FileSystem) {
return;
}
this.#filesIndex.removePath(oldURL);
this.#fileSystemUISourceCodes.delete(oldURL);
const status = this.#sourceCodeToAutoMappingStatusMap.get(uiSourceCode);
if (status) {
this.#clearNetworkStatus(status.network);
}
this.#filesIndex.addPath(uiSourceCode.url());
this.#fileSystemUISourceCodes.add(uiSourceCode);
this.#scheduleSweep();
}
computeNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
const processingPromise = this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode);
if (processingPromise) {
return processingPromise;
}
if (this.#sourceCodeToAutoMappingStatusMap.has(networkSourceCode)) {
return Promise.resolve();
}
if (this.#interceptors.some(interceptor => interceptor(networkSourceCode))) {
return Promise.resolve();
}
if (Common.ParsedURL.schemeIs(networkSourceCode.url(), 'wasm:')) {
return Promise.resolve();
}
const createBindingPromise =
this.#createBinding(networkSourceCode).then(validateStatus.bind(this)).then(onStatus.bind(this));
this.#sourceCodeToProcessingPromiseMap.set(networkSourceCode, createBindingPromise);
return createBindingPromise;
async function validateStatus(this: Automapping, status: AutomappingStatus|null): Promise<AutomappingStatus|null> {
if (!status) {
return null;
}
if (this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
return null;
}
if (status.network.contentType().isFromSourceMap() || !status.fileSystem.contentType().isTextType()) {
return status;
}
// At the time binding comes, there are multiple user scenarios:
// 1. Both network and fileSystem files are **not** dirty.
// This is a typical scenario when user hasn't done any edits yet to the
// files in question.
// 2. FileSystem file has unsaved changes, network is clear.
// This typically happens with CSS files editing. Consider the following
// scenario:
// - user edits file that has been successfully mapped before
// - user doesn't save the file
// - user hits reload
// 3. Network file has either unsaved changes or commits, but fileSystem file is clear.
// This typically happens when we've been editing file and then realized we'd like to drop
// a folder and persist all the changes.
// 4. Network file has either unsaved changes or commits, and fileSystem file has unsaved changes.
// We consider this to be un-realistic scenario and in this case just fail gracefully.
//
// To support usecase (3), we need to validate against original network content.
if (status.fileSystem.isDirty() && (status.network.isDirty() || status.network.hasCommits())) {
return null;
}
const [fileSystemContent, networkContent] = (await Promise.all([
status.fileSystem.requestContentData(),
status.network.project().requestFileContent(status.network),
])).map(TextUtils.ContentData.ContentData.asDeferredContent);
if (fileSystemContent.content === null || networkContent === null) {
return null;
}
if (this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
return null;
}
const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(status.network);
let isValid = false;
const fileContent = fileSystemContent.content;
if (target && target.type() === SDK.Target.Type.NODE) {
if (networkContent.content) {
const rewrappedNetworkContent =
PersistenceImpl.rewrapNodeJSContent(status.fileSystem, fileContent, networkContent.content);
isValid = fileContent === rewrappedNetworkContent;
}
} else if (networkContent.content) {
// Trim trailing whitespaces because V8 adds trailing newline.
isValid = fileContent.trimEnd() === networkContent.content.trimEnd();
}
if (!isValid) {
this.prevalidationFailedForTest(status);
return null;
}
return status;
}
async function onStatus(this: Automapping, status: AutomappingStatus|null): Promise<void> {
if (this.#sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
return;
}
if (!status) {
this.onBindingFailedForTest();
this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
return;
}
// TODO(lushnikov): remove this check once there's a single uiSourceCode per url. @see crbug.com/670180
if (this.#sourceCodeToAutoMappingStatusMap.has(status.network) ||
this.#sourceCodeToAutoMappingStatusMap.has(status.fileSystem)) {
this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
return;
}
this.statuses.add(status);
this.#sourceCodeToAutoMappingStatusMap.set(status.network, status);
this.#sourceCodeToAutoMappingStatusMap.set(status.fileSystem, status);
if (status.exactMatch) {
const projectFolder = this.#projectFoldersIndex.closestParentFolder(status.fileSystem.url());
const newFolderAdded = projectFolder ? this.#activeFoldersIndex.addFolder(projectFolder) : false;
if (newFolderAdded) {
this.#scheduleSweep();
}
}
await this.#onStatusAdded.call(null, status);
this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
}
}
private prevalidationFailedForTest(_binding: AutomappingStatus): void {
}
private onBindingFailedForTest(): void {
}
#clearNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): void {
if (this.#sourceCodeToProcessingPromiseMap.has(networkSourceCode)) {
this.#sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
return;
}
const status = this.#sourceCodeToAutoMappingStatusMap.get(networkSourceCode);
if (!status) {
return;
}
this.statuses.delete(status);
this.#sourceCodeToAutoMappingStatusMap.delete(status.network);
this.#sourceCodeToAutoMappingStatusMap.delete(status.fileSystem);
if (status.exactMatch) {
const projectFolder = this.#projectFoldersIndex.closestParentFolder(status.fileSystem.url());
if (projectFolder) {
this.#activeFoldersIndex.removeFolder(projectFolder);
}
}
void this.#onStatusRemoved.call(null, status);
}
async #createBinding(networkSourceCode: Workspace.UISourceCode.UISourceCode): Promise<AutomappingStatus|null> {
const url = networkSourceCode.url();
if (Common.ParsedURL.schemeIs(url, 'file:') || Common.ParsedURL.schemeIs(url, 'snippet:')) {
const fileSourceCode = this.#fileSystemUISourceCodes.get(url);
const status = fileSourceCode ? new AutomappingStatus(networkSourceCode, fileSourceCode, false) : null;
return status;
}
let networkPath = Common.ParsedURL.ParsedURL.extractPath(url);
if (networkPath === null) {
return null;
}
if (networkPath.endsWith('/')) {
networkPath = Common.ParsedURL.ParsedURL.concatenate(networkPath, 'index.html');
}
const similarFiles =
this.#filesIndex.similarFiles(networkPath).map(path => this.#fileSystemUISourceCodes.get(path)) as
Workspace.UISourceCode.UISourceCode[];
if (!similarFiles.length) {
return null;
}
await Promise.all(similarFiles.concat(networkSourceCode).map(async sourceCode => {
this.#sourceCodeToMetadataMap.set(sourceCode, await sourceCode.requestMetadata());
}));
const activeFiles = similarFiles.filter(file => !!this.#activeFoldersIndex.closestParentFolder(file.url()));
const networkMetadata = this.#sourceCodeToMetadataMap.get(networkSourceCode);
if (!networkMetadata || (!networkMetadata.modificationTime && typeof networkMetadata.contentSize !== 'number')) {
// If networkSourceCode does not have metadata, try to match against active folders.
if (activeFiles.length !== 1) {
return null;
}
return new AutomappingStatus(networkSourceCode, activeFiles[0], false);
}
// Try to find exact matches, prioritizing active folders.
let exactMatches = this.#filterWithMetadata(activeFiles, networkMetadata);
if (!exactMatches.length) {
exactMatches = this.#filterWithMetadata(similarFiles, networkMetadata);
}
if (exactMatches.length !== 1) {
return null;
}
return new AutomappingStatus(networkSourceCode, exactMatches[0], true);
}
#filterWithMetadata(
files: Workspace.UISourceCode.UISourceCode[],
networkMetadata: Workspace.UISourceCode.UISourceCodeMetadata): Workspace.UISourceCode.UISourceCode[] {
return files.filter(file => {
const fileMetadata = this.#sourceCodeToMetadataMap.get(file);
if (!fileMetadata) {
return false;
}
// Allow a second of difference due to network timestamps lack of precision.
const timeMatches = !networkMetadata.modificationTime || !fileMetadata.modificationTime ||
Math.abs(networkMetadata.modificationTime.getTime() - fileMetadata.modificationTime.getTime()) < 1000;
const contentMatches = !networkMetadata.contentSize || fileMetadata.contentSize === networkMetadata.contentSize;
return timeMatches && contentMatches;
});
}
}
class FilePathIndex {
readonly #reversedIndex = Common.Trie.Trie.newArrayTrie<string[]>();
addPath(path: Platform.DevToolsPath.UrlString): void {
const reversePathParts = path.split('/').reverse();
this.#reversedIndex.add(reversePathParts);
}
removePath(path: Platform.DevToolsPath.UrlString): void {
const reversePathParts = path.split('/').reverse();
this.#reversedIndex.remove(reversePathParts);
}
similarFiles(networkPath: Platform.DevToolsPath.EncodedPathString): Platform.DevToolsPath.UrlString[] {
const reversePathParts = networkPath.split('/').reverse();
const longestCommonPrefix = this.#reversedIndex.longestPrefix(reversePathParts, false);
if (longestCommonPrefix.length === 0) {
return [];
}
return this.#reversedIndex.words(longestCommonPrefix)
.map(reversePathParts => reversePathParts.reverse().join('/')) as Platform.DevToolsPath.UrlString[];
}
}
class FolderIndex {
readonly #index = Common.Trie.Trie.newArrayTrie<string[]>();
readonly #folderCount = new Map<string, number>();
addFolder(path: Platform.DevToolsPath.UrlString): boolean {
const pathParts = this.#removeTrailingSlash(path).split('/');
this.#index.add(pathParts);
const pathForCount = pathParts.join('/');
const count = this.#folderCount.get(pathForCount) ?? 0;
this.#folderCount.set(pathForCount, count + 1);
return count === 0;
}
removeFolder(path: Platform.DevToolsPath.UrlString): boolean {
const pathParts = this.#removeTrailingSlash(path).split('/');
const pathForCount = pathParts.join('/');
const count = this.#folderCount.get(pathForCount) ?? 0;
if (!count) {
return false;
}
if (count > 1) {
this.#folderCount.set(pathForCount, count - 1);
return false;
}
this.#index.remove(pathParts);
this.#folderCount.delete(pathForCount);
return true;
}
closestParentFolder(path: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString {
const pathParts = path.split('/');
const commonPrefix = this.#index.longestPrefix(pathParts, /* fullWordOnly */ true);
return commonPrefix.join('/') as Platform.DevToolsPath.UrlString;
}
#removeTrailingSlash(path: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString {
if (path.endsWith('/')) {
return Common.ParsedURL.ParsedURL.substring(path, 0, path.length - 1);
}
return path;
}
}
class FileSystemUISourceCodes {
readonly #sourceCodes = new Map<Platform.DevToolsPath.UrlString, Workspace.UISourceCode.UISourceCode>();
private getPlatformCanonicalFileUrl(path: Platform.DevToolsPath.UrlString): Platform.DevToolsPath.UrlString {
return Host.Platform.isWin() ? Common.ParsedURL.ParsedURL.toLowerCase(path) : path;
}
add(sourceCode: Workspace.UISourceCode.UISourceCode): void {
const fileUrl = this.getPlatformCanonicalFileUrl(sourceCode.url());
this.#sourceCodes.set(fileUrl, sourceCode);
}
get(fileUrl: Platform.DevToolsPath.UrlString): Workspace.UISourceCode.UISourceCode|undefined {
fileUrl = this.getPlatformCanonicalFileUrl(fileUrl);
return this.#sourceCodes.get(fileUrl);
}
delete(fileUrl: Platform.DevToolsPath.UrlString): void {
fileUrl = this.getPlatformCanonicalFileUrl(fileUrl);
this.#sourceCodes.delete(fileUrl);
}
}
export class AutomappingStatus {
network: Workspace.UISourceCode.UISourceCode;
fileSystem: Workspace.UISourceCode.UISourceCode;
exactMatch: boolean;
constructor(
network: Workspace.UISourceCode.UISourceCode, fileSystem: Workspace.UISourceCode.UISourceCode,
exactMatch: boolean) {
this.network = network;
this.fileSystem = fileSystem;
this.exactMatch = exactMatch;
}
}