blob: 8b4c4cb071f34644dbf22a2c03d51d86a4a6f4b0 [file] [log] [blame]
// 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.
/* eslint-disable rulesdir/no_underscored_properties */
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 Bindings from '../bindings/bindings.js';
import * as Workspace from '../workspace/workspace.js';
import type {FileSystem} from './FileSystemWorkspaceBinding.js';
import {FileSystemWorkspaceBinding} from './FileSystemWorkspaceBinding.js';
import {PathEncoder, PersistenceImpl} from './PersistenceImpl.js';
const UIStrings = {
/**
*@description Error message when attempting to create a binding from a malformed URI.
*@example {file://%E0%A4%A} PH1
*/
theAttemptToBindSInTheWorkspace: 'The attempt to bind "{PH1}" in the workspace failed as this URI is malformed.',
};
const str_ = i18n.i18n.registerUIStrings('models/persistence/Automapping.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class Automapping {
_workspace: Workspace.Workspace.WorkspaceImpl;
_onStatusAdded: (arg0: AutomappingStatus) => Promise<void>;
_onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>;
_statuses: Set<AutomappingStatus>;
_fileSystemUISourceCodes: Map<string, Workspace.UISourceCode.UISourceCode>;
_sweepThrottler: Common.Throttler.Throttler;
_sourceCodeToProcessingPromiseMap: WeakMap<Workspace.UISourceCode.UISourceCode, Promise<void>>;
_sourceCodeToAutoMappingStatusMap: WeakMap<Workspace.UISourceCode.UISourceCode, AutomappingStatus>;
_sourceCodeToMetadataMap:
WeakMap<Workspace.UISourceCode.UISourceCode, Workspace.UISourceCode.UISourceCodeMetadata|null>;
_filesIndex: FilePathIndex;
_projectFoldersIndex: FolderIndex;
_activeFoldersIndex: FolderIndex;
_interceptors: ((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._statuses = new Set();
this._fileSystemUISourceCodes = new Map();
this._sweepThrottler = new Common.Throttler.Throttler(100);
this._sourceCodeToProcessingPromiseMap = new WeakMap();
this._sourceCodeToAutoMappingStatusMap = new WeakMap();
this._sourceCodeToMetadataMap = new WeakMap();
const pathEncoder = new PathEncoder();
this._filesIndex = new FilePathIndex(pathEncoder);
this._projectFoldersIndex = new FolderIndex(pathEncoder);
this._activeFoldersIndex = new FolderIndex(pathEncoder);
this._interceptors = [];
this._workspace.addEventListener(
Workspace.Workspace.Events.UISourceCodeAdded,
event => this._onUISourceCodeAdded(event.data as Workspace.UISourceCode.UISourceCode));
this._workspace.addEventListener(
Workspace.Workspace.Events.UISourceCodeRemoved,
event => this._onUISourceCodeRemoved(event.data as Workspace.UISourceCode.UISourceCode));
this._workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRenamed, this._onUISourceCodeRenamed, this);
this._workspace.addEventListener(
Workspace.Workspace.Events.ProjectAdded,
event => this._onProjectAdded(event.data as Workspace.Workspace.Project), this);
this._workspace.addEventListener(
Workspace.Workspace.Events.ProjectRemoved,
event => this._onProjectRemoved(event.data as Workspace.Workspace.Project), 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 {
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()) {
this._computeNetworkStatus(uiSourceCode);
}
}
this._onSweepHappenedForTest();
return Promise.resolve();
}
}
_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());
project.uiSourceCodes().forEach(this._onUISourceCodeAdded.bind(this));
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.set(uiSourceCode.url(), uiSourceCode);
this._scheduleSweep();
} else if (project.type() === Workspace.Workspace.projectTypes.Network) {
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): void {
const uiSourceCode = event.data.uiSourceCode as Workspace.UISourceCode.UISourceCode;
const oldURL = event.data.oldURL as string;
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.set(uiSourceCode.url(), uiSourceCode);
this._scheduleSweep();
}
_computeNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): void {
if (this._sourceCodeToProcessingPromiseMap.has(networkSourceCode) ||
this._sourceCodeToAutoMappingStatusMap.has(networkSourceCode)) {
return;
}
if (this._interceptors.some(interceptor => interceptor(networkSourceCode))) {
return;
}
if (networkSourceCode.url().startsWith('wasm://')) {
return;
}
const createBindingPromise =
this._createBinding(networkSourceCode).then(validateStatus.bind(this)).then(onStatus.bind(this));
this._sourceCodeToProcessingPromiseMap.set(networkSourceCode, 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.requestContent(), status.network.project().requestFileContent(status.network)]);
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.SDKModel.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.trimRight() === networkContent.content.trimRight();
}
}
if (!isValid) {
this._prevalidationFailedForTest(status);
return null;
}
return status;
}
function onStatus(this: Automapping, status: AutomappingStatus|null): void {
if (this._sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
return;
}
this._sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
if (!status) {
this._onBindingFailedForTest();
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)) {
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();
}
}
this._onStatusAdded.call(null, status);
}
}
_prevalidationFailedForTest(_binding: AutomappingStatus): void {
}
_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);
}
}
this._onStatusRemoved.call(null, status);
}
_createBinding(networkSourceCode: Workspace.UISourceCode.UISourceCode): Promise<AutomappingStatus|null> {
const url = networkSourceCode.url();
if (url.startsWith('file://') || url.startsWith('snippet://')) {
const decodedUrl = sanitizeSourceUrl(url);
if (!decodedUrl) {
return Promise.resolve(null as AutomappingStatus | null);
}
const fileSourceCode = this._fileSystemUISourceCodes.get(decodedUrl);
const status = fileSourceCode ? new AutomappingStatus(networkSourceCode, fileSourceCode, false) : null;
return Promise.resolve(status);
}
let networkPath = Common.ParsedURL.ParsedURL.extractPath(url);
if (networkPath === null) {
return Promise.resolve(null as AutomappingStatus | null);
}
if (networkPath.endsWith('/')) {
networkPath += 'index.html';
}
const urlDecodedNetworkPath = sanitizeSourceUrl(networkPath);
if (!urlDecodedNetworkPath) {
return Promise.resolve(null as AutomappingStatus | null);
}
const similarFiles =
this._filesIndex.similarFiles(urlDecodedNetworkPath).map(path => this._fileSystemUISourceCodes.get(path)) as
Workspace.UISourceCode.UISourceCode[];
if (!similarFiles.length) {
return Promise.resolve(null as AutomappingStatus | null);
}
return this._pullMetadatas(similarFiles.concat(networkSourceCode)).then(onMetadatas.bind(this));
function sanitizeSourceUrl(url: string): string|null {
try {
const decodedUrl = decodeURI(url);
return decodedUrl;
} catch (error) {
Common.Console.Console.instance().error(i18nString(UIStrings.theAttemptToBindSInTheWorkspace, {PH1: url}));
return null;
}
}
function onMetadatas(this: Automapping): AutomappingStatus|null {
const activeFiles =
similarFiles.filter(
file => Boolean(file) && Boolean(this._activeFoldersIndex.closestParentFolder(file.url()))) as
Workspace.UISourceCode.UISourceCode[];
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);
}
}
async _pullMetadatas(uiSourceCodes: Workspace.UISourceCode.UISourceCode[]): Promise<void> {
await Promise.all(uiSourceCodes.map(async file => {
this._sourceCodeToMetadataMap.set(file, await file.requestMetadata());
}));
}
_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 {
_encoder: PathEncoder;
_reversedIndex: Common.Trie.Trie;
constructor(encoder: PathEncoder) {
this._encoder = encoder;
this._reversedIndex = new Common.Trie.Trie();
}
addPath(path: string): void {
const encodedPath = this._encoder.encode(path);
this._reversedIndex.add(Platform.StringUtilities.reverse(encodedPath));
}
removePath(path: string): void {
const encodedPath = this._encoder.encode(path);
this._reversedIndex.remove(Platform.StringUtilities.reverse(encodedPath));
}
similarFiles(networkPath: string): string[] {
const encodedPath = this._encoder.encode(networkPath);
const reversedEncodedPath = Platform.StringUtilities.reverse(encodedPath);
const longestCommonPrefix = this._reversedIndex.longestPrefix(reversedEncodedPath, false);
if (!longestCommonPrefix) {
return [];
}
return this._reversedIndex.words(longestCommonPrefix)
.map(encodedPath => this._encoder.decode(Platform.StringUtilities.reverse(encodedPath)));
}
}
class FolderIndex {
_encoder: PathEncoder;
_index: Common.Trie.Trie;
_folderCount: Map<string, number>;
constructor(encoder: PathEncoder) {
this._encoder = encoder;
this._index = new Common.Trie.Trie();
this._folderCount = new Map();
}
addFolder(path: string): boolean {
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
const encodedPath = this._encoder.encode(path);
this._index.add(encodedPath);
const count = this._folderCount.get(encodedPath) || 0;
this._folderCount.set(encodedPath, count + 1);
return count === 0;
}
removeFolder(path: string): boolean {
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
const encodedPath = this._encoder.encode(path);
const count = this._folderCount.get(encodedPath) || 0;
if (!count) {
return false;
}
if (count > 1) {
this._folderCount.set(encodedPath, count - 1);
return false;
}
this._index.remove(encodedPath);
this._folderCount.delete(encodedPath);
return true;
}
closestParentFolder(path: string): string {
const encodedPath = this._encoder.encode(path);
const commonPrefix = this._index.longestPrefix(encodedPath, true);
return this._encoder.decode(commonPrefix);
}
}
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;
}
}