| // 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. |
| |
| export default class Automapping { |
| /** |
| * @param {!Workspace.Workspace} workspace |
| * @param {function(!AutomappingStatus)} onStatusAdded |
| * @param {function(!AutomappingStatus)} onStatusRemoved |
| */ |
| constructor(workspace, onStatusAdded, onStatusRemoved) { |
| this._workspace = workspace; |
| |
| this._onStatusAdded = onStatusAdded; |
| this._onStatusRemoved = onStatusRemoved; |
| /** @type {!Set<!AutomappingStatus>} */ |
| this._statuses = new Set(); |
| this._statusSymbol = Symbol('Automapping.Status'); |
| this._processingPromiseSymbol = Symbol('Automapping.ProcessingPromise'); |
| this._metadataSymbol = Symbol('Automapping.Metadata'); |
| |
| |
| /** @type {!Map<string, !Workspace.UISourceCode>} */ |
| this._fileSystemUISourceCodes = new Map(); |
| this._sweepThrottler = new Common.Throttler(100); |
| |
| const pathEncoder = new Persistence.PathEncoder(); |
| this._filesIndex = new FilePathIndex(pathEncoder); |
| this._projectFoldersIndex = new FolderIndex(pathEncoder); |
| this._activeFoldersIndex = new FolderIndex(pathEncoder); |
| |
| /** @type {!Array<function(!Workspace.UISourceCode):boolean>} */ |
| this._interceptors = []; |
| |
| this._workspace.addEventListener( |
| Workspace.Workspace.Events.UISourceCodeAdded, |
| event => this._onUISourceCodeAdded(/** @type {!Workspace.UISourceCode} */ (event.data))); |
| this._workspace.addEventListener( |
| Workspace.Workspace.Events.UISourceCodeRemoved, |
| event => this._onUISourceCodeRemoved(/** @type {!Workspace.UISourceCode} */ (event.data))); |
| this._workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRenamed, this._onUISourceCodeRenamed, this); |
| this._workspace.addEventListener( |
| Workspace.Workspace.Events.ProjectAdded, |
| event => this._onProjectAdded(/** @type {!Workspace.Project} */ (event.data)), this); |
| this._workspace.addEventListener( |
| Workspace.Workspace.Events.ProjectRemoved, |
| event => this._onProjectRemoved(/** @type {!Workspace.Project} */ (event.data)), this); |
| |
| for (const fileSystem of workspace.projects()) { |
| this._onProjectAdded(fileSystem); |
| } |
| for (const uiSourceCode of workspace.uiSourceCodes()) { |
| this._onUISourceCodeAdded(uiSourceCode); |
| } |
| } |
| |
| /** |
| * @param {function(!Workspace.UISourceCode):boolean} interceptor |
| */ |
| addNetworkInterceptor(interceptor) { |
| this._interceptors.push(interceptor); |
| this.scheduleRemap(); |
| } |
| |
| scheduleRemap() { |
| for (const status of this._statuses.valuesArray()) { |
| this._clearNetworkStatus(status.network); |
| } |
| this._scheduleSweep(); |
| } |
| |
| _scheduleSweep() { |
| this._sweepThrottler.schedule(sweepUnmapped.bind(this)); |
| |
| /** |
| * @this {Automapping} |
| * @return {!Promise} |
| */ |
| function sweepUnmapped() { |
| const networkProjects = this._workspace.projectsForType(Workspace.projectTypes.Network); |
| for (const networkProject of networkProjects) { |
| for (const uiSourceCode of networkProject.uiSourceCodes()) { |
| this._computeNetworkStatus(uiSourceCode); |
| } |
| } |
| this._onSweepHappenedForTest(); |
| return Promise.resolve(); |
| } |
| } |
| |
| _onSweepHappenedForTest() { |
| } |
| |
| /** |
| * @param {!Workspace.Project} project |
| */ |
| _onProjectRemoved(project) { |
| for (const uiSourceCode of project.uiSourceCodes()) { |
| this._onUISourceCodeRemoved(uiSourceCode); |
| } |
| if (project.type() !== Workspace.projectTypes.FileSystem) { |
| return; |
| } |
| const fileSystem = /** @type {!Persistence.FileSystemWorkspaceBinding.FileSystem} */ (project); |
| for (const gitFolder of fileSystem.initialGitFolders()) { |
| this._projectFoldersIndex.removeFolder(gitFolder); |
| } |
| this._projectFoldersIndex.removeFolder(fileSystem.fileSystemPath()); |
| this.scheduleRemap(); |
| } |
| |
| /** |
| * @param {!Workspace.Project} project |
| */ |
| _onProjectAdded(project) { |
| if (project.type() !== Workspace.projectTypes.FileSystem) { |
| return; |
| } |
| const fileSystem = /** @type {!Persistence.FileSystemWorkspaceBinding.FileSystem} */ (project); |
| for (const gitFolder of fileSystem.initialGitFolders()) { |
| this._projectFoldersIndex.addFolder(gitFolder); |
| } |
| this._projectFoldersIndex.addFolder(fileSystem.fileSystemPath()); |
| project.uiSourceCodes().forEach(this._onUISourceCodeAdded.bind(this)); |
| this.scheduleRemap(); |
| } |
| |
| /** |
| * @param {!Workspace.UISourceCode} uiSourceCode |
| */ |
| _onUISourceCodeAdded(uiSourceCode) { |
| const project = uiSourceCode.project(); |
| if (project.type() === Workspace.projectTypes.FileSystem) { |
| if (!Persistence.FileSystemWorkspaceBinding.fileSystemSupportsAutomapping(project)) { |
| return; |
| } |
| this._filesIndex.addPath(uiSourceCode.url()); |
| this._fileSystemUISourceCodes.set(uiSourceCode.url(), uiSourceCode); |
| this._scheduleSweep(); |
| } else if (project.type() === Workspace.projectTypes.Network) { |
| this._computeNetworkStatus(uiSourceCode); |
| } |
| } |
| |
| /** |
| * @param {!Workspace.UISourceCode} uiSourceCode |
| */ |
| _onUISourceCodeRemoved(uiSourceCode) { |
| if (uiSourceCode.project().type() === Workspace.projectTypes.FileSystem) { |
| this._filesIndex.removePath(uiSourceCode.url()); |
| this._fileSystemUISourceCodes.delete(uiSourceCode.url()); |
| const status = uiSourceCode[this._statusSymbol]; |
| if (status) { |
| this._clearNetworkStatus(status.network); |
| } |
| } else if (uiSourceCode.project().type() === Workspace.projectTypes.Network) { |
| this._clearNetworkStatus(uiSourceCode); |
| } |
| } |
| |
| /** |
| * @param {!Common.Event} event |
| */ |
| _onUISourceCodeRenamed(event) { |
| const uiSourceCode = /** @type {!Workspace.UISourceCode} */ (event.data.uiSourceCode); |
| const oldURL = /** @type {string} */ (event.data.oldURL); |
| if (uiSourceCode.project().type() !== Workspace.projectTypes.FileSystem) { |
| return; |
| } |
| |
| this._filesIndex.removePath(oldURL); |
| this._fileSystemUISourceCodes.delete(oldURL); |
| const status = uiSourceCode[this._statusSymbol]; |
| if (status) { |
| this._clearNetworkStatus(status.network); |
| } |
| |
| this._filesIndex.addPath(uiSourceCode.url()); |
| this._fileSystemUISourceCodes.set(uiSourceCode.url(), uiSourceCode); |
| this._scheduleSweep(); |
| } |
| |
| /** |
| * @param {!Workspace.UISourceCode} networkSourceCode |
| */ |
| _computeNetworkStatus(networkSourceCode) { |
| if (networkSourceCode[this._processingPromiseSymbol] || networkSourceCode[this._statusSymbol]) { |
| 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)); |
| networkSourceCode[this._processingPromiseSymbol] = createBindingPromise; |
| |
| /** |
| * @param {?AutomappingStatus} status |
| * @return {!Promise<?AutomappingStatus>} |
| * @this {Automapping} |
| */ |
| async function validateStatus(status) { |
| if (!status) { |
| return null; |
| } |
| if (networkSourceCode[this._processingPromiseSymbol] !== 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 (networkSourceCode[this._processingPromiseSymbol] !== createBindingPromise) { |
| return null; |
| } |
| |
| const target = Bindings.NetworkProject.targetForUISourceCode(status.network); |
| let isValid = false; |
| const fileContent = fileSystemContent.content; |
| if (target && target.type() === SDK.Target.Type.Node) { |
| const rewrappedNetworkContent = |
| Persistence.Persistence.rewrapNodeJSContent(status.fileSystem, fileContent, networkContent.content); |
| isValid = fileContent === rewrappedNetworkContent; |
| } else { |
| // Trim trailing whitespaces because V8 adds trailing newline. |
| isValid = fileContent.trimRight() === networkContent.content.trimRight(); |
| } |
| if (!isValid) { |
| this._prevalidationFailedForTest(status); |
| return null; |
| } |
| return status; |
| } |
| |
| /** |
| * @param {?AutomappingStatus} status |
| * @this {Automapping} |
| */ |
| function onStatus(status) { |
| if (networkSourceCode[this._processingPromiseSymbol] !== createBindingPromise) { |
| return; |
| } |
| networkSourceCode[this._processingPromiseSymbol] = null; |
| if (!status) { |
| this._onBindingFailedForTest(); |
| return; |
| } |
| // TODO(lushnikov): remove this check once there's a single uiSourceCode per url. @see crbug.com/670180 |
| if (status.network[this._statusSymbol] || status.fileSystem[this._statusSymbol]) { |
| return; |
| } |
| |
| this._statuses.add(status); |
| status.network[this._statusSymbol] = status; |
| status.fileSystem[this._statusSymbol] = 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); |
| } |
| } |
| |
| /** |
| * @param {!AutomappingStatus} binding |
| */ |
| _prevalidationFailedForTest(binding) { |
| } |
| |
| _onBindingFailedForTest() { |
| } |
| |
| /** |
| * @param {!Workspace.UISourceCode} networkSourceCode |
| */ |
| _clearNetworkStatus(networkSourceCode) { |
| if (networkSourceCode[this._processingPromiseSymbol]) { |
| networkSourceCode[this._processingPromiseSymbol] = null; |
| return; |
| } |
| const status = networkSourceCode[this._statusSymbol]; |
| if (!status) { |
| return; |
| } |
| |
| this._statuses.delete(status); |
| status.network[this._statusSymbol] = null; |
| status.fileSystem[this._statusSymbol] = null; |
| if (status.exactMatch) { |
| const projectFolder = this._projectFoldersIndex.closestParentFolder(status.fileSystem.url()); |
| if (projectFolder) { |
| this._activeFoldersIndex.removeFolder(projectFolder); |
| } |
| } |
| this._onStatusRemoved.call(null, status); |
| } |
| |
| /** |
| * @param {!Workspace.UISourceCode} networkSourceCode |
| * @return {!Promise<?AutomappingStatus>} |
| */ |
| _createBinding(networkSourceCode) { |
| if (networkSourceCode.url().startsWith('file://') || networkSourceCode.url().startsWith('snippet://')) { |
| const decodedUrl = decodeURI(networkSourceCode.url()); |
| const fileSourceCode = this._fileSystemUISourceCodes.get(decodedUrl); |
| const status = fileSourceCode ? new AutomappingStatus(networkSourceCode, fileSourceCode, false) : null; |
| return Promise.resolve(status); |
| } |
| |
| let networkPath = Common.ParsedURL.extractPath(networkSourceCode.url()); |
| if (networkPath === null) { |
| return Promise.resolve(/** @type {?AutomappingStatus} */ (null)); |
| } |
| |
| if (networkPath.endsWith('/')) { |
| networkPath += 'index.html'; |
| } |
| const urlDecodedNetworkPath = decodeURI(networkPath); |
| const similarFiles = |
| this._filesIndex.similarFiles(urlDecodedNetworkPath).map(path => this._fileSystemUISourceCodes.get(path)); |
| if (!similarFiles.length) { |
| return Promise.resolve(/** @type {?AutomappingStatus} */ (null)); |
| } |
| |
| return this._pullMetadatas(similarFiles.concat(networkSourceCode)).then(onMetadatas.bind(this)); |
| |
| /** |
| * @this {Automapping} |
| */ |
| function onMetadatas() { |
| const activeFiles = similarFiles.filter(file => !!this._activeFoldersIndex.closestParentFolder(file.url())); |
| const networkMetadata = networkSourceCode[this._metadataSymbol]; |
| 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); |
| } |
| } |
| |
| /** |
| * @param {!Array<!Workspace.UISourceCode>} uiSourceCodes |
| * @return {!Promise} |
| */ |
| _pullMetadatas(uiSourceCodes) { |
| return Promise.all(uiSourceCodes.map(async file => { |
| file[this._metadataSymbol] = await file.requestMetadata(); |
| })); |
| } |
| |
| /** |
| * @param {!Array<!Workspace.UISourceCode>} files |
| * @param {!Workspace.UISourceCodeMetadata} networkMetadata |
| * @return {!Array<!Workspace.UISourceCode>} |
| */ |
| _filterWithMetadata(files, networkMetadata) { |
| return files.filter(file => { |
| const fileMetadata = file[this._metadataSymbol]; |
| if (!fileMetadata) { |
| return false; |
| } |
| // Allow a second of difference due to network timestamps lack of precision. |
| const timeMatches = !networkMetadata.modificationTime || |
| Math.abs(networkMetadata.modificationTime - fileMetadata.modificationTime) < 1000; |
| const contentMatches = !networkMetadata.contentSize || fileMetadata.contentSize === networkMetadata.contentSize; |
| return timeMatches && contentMatches; |
| }); |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| class FilePathIndex { |
| /** |
| * @param {!Persistence.PathEncoder} encoder |
| */ |
| constructor(encoder) { |
| this._encoder = encoder; |
| this._reversedIndex = new Common.Trie(); |
| } |
| |
| /** |
| * @param {string} path |
| */ |
| addPath(path) { |
| const encodedPath = this._encoder.encode(path); |
| this._reversedIndex.add(encodedPath.reverse()); |
| } |
| |
| /** |
| * @param {string} path |
| */ |
| removePath(path) { |
| const encodedPath = this._encoder.encode(path); |
| this._reversedIndex.remove(encodedPath.reverse()); |
| } |
| |
| /** |
| * @param {string} networkPath |
| * @return {!Array<string>} |
| */ |
| similarFiles(networkPath) { |
| const encodedPath = this._encoder.encode(networkPath); |
| const longestCommonPrefix = this._reversedIndex.longestPrefix(encodedPath.reverse(), false); |
| if (!longestCommonPrefix) { |
| return []; |
| } |
| return this._reversedIndex.words(longestCommonPrefix) |
| .map(encodedPath => this._encoder.decode(encodedPath.reverse())); |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| class FolderIndex { |
| /** |
| * @param {!Persistence.PathEncoder} encoder |
| */ |
| constructor(encoder) { |
| this._encoder = encoder; |
| this._index = new Common.Trie(); |
| /** @type {!Map<string, number>} */ |
| this._folderCount = new Map(); |
| } |
| |
| /** |
| * @param {string} path |
| * @return {boolean} |
| */ |
| addFolder(path) { |
| 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; |
| } |
| |
| /** |
| * @param {string} path |
| * @return {boolean} |
| */ |
| removeFolder(path) { |
| 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; |
| } |
| |
| /** |
| * @param {string} path |
| * @return {string} |
| */ |
| closestParentFolder(path) { |
| const encodedPath = this._encoder.encode(path); |
| const commonPrefix = this._index.longestPrefix(encodedPath, true); |
| return this._encoder.decode(commonPrefix); |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| export class AutomappingStatus { |
| /** |
| * @param {!Workspace.UISourceCode} network |
| * @param {!Workspace.UISourceCode} fileSystem |
| * @param {boolean} exactMatch |
| */ |
| constructor(network, fileSystem, exactMatch) { |
| this.network = network; |
| this.fileSystem = fileSystem; |
| this.exactMatch = exactMatch; |
| } |
| } |
| |
| /* Legacy exported object */ |
| self.Persistence = self.Persistence || {}; |
| |
| /* Legacy exported object */ |
| Persistence = Persistence || {}; |
| |
| /** @constructor */ |
| Persistence.Automapping = Automapping; |
| |
| /** @constructor */ |
| Persistence.AutomappingStatus = AutomappingStatus; |