UNPKG

20.4 kBPlain TextView Raw
1// Copyright 2016 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import * as Common from '../../core/common/common.js';
6import * as i18n from '../../core/i18n/i18n.js';
7import * as Platform from '../../core/platform/platform.js';
8import * as SDK from '../../core/sdk/sdk.js';
9import * as Bindings from '../bindings/bindings.js';
10import * as Workspace from '../workspace/workspace.js';
11
12import type {FileSystem} from './FileSystemWorkspaceBinding.js';
13import {FileSystemWorkspaceBinding} from './FileSystemWorkspaceBinding.js';
14import {PathEncoder, PersistenceImpl} from './PersistenceImpl.js';
15
16const UIStrings = {
17 /**
18 *@description Error message when attempting to create a binding from a malformed URI.
19 *@example {file://%E0%A4%A} PH1
20 */
21 theAttemptToBindSInTheWorkspace: 'The attempt to bind "{PH1}" in the workspace failed as this URI is malformed.',
22};
23const str_ = i18n.i18n.registerUIStrings('models/persistence/Automapping.ts', UIStrings);
24const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
25
26export class Automapping {
27 private readonly workspace: Workspace.Workspace.WorkspaceImpl;
28 private readonly onStatusAdded: (arg0: AutomappingStatus) => Promise<void>;
29 private readonly onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>;
30 private readonly statuses: Set<AutomappingStatus>;
31 private readonly fileSystemUISourceCodes: Map<string, Workspace.UISourceCode.UISourceCode>;
32 private readonly sweepThrottler: Common.Throttler.Throttler;
33 private readonly sourceCodeToProcessingPromiseMap: WeakMap<Workspace.UISourceCode.UISourceCode, Promise<void>>;
34 private readonly sourceCodeToAutoMappingStatusMap: WeakMap<Workspace.UISourceCode.UISourceCode, AutomappingStatus>;
35 private readonly sourceCodeToMetadataMap:
36 WeakMap<Workspace.UISourceCode.UISourceCode, Workspace.UISourceCode.UISourceCodeMetadata|null>;
37 private readonly filesIndex: FilePathIndex;
38 private readonly projectFoldersIndex: FolderIndex;
39 private readonly activeFoldersIndex: FolderIndex;
40 private readonly interceptors: ((arg0: Workspace.UISourceCode.UISourceCode) => boolean)[];
41 constructor(
42 workspace: Workspace.Workspace.WorkspaceImpl, onStatusAdded: (arg0: AutomappingStatus) => Promise<void>,
43 onStatusRemoved: (arg0: AutomappingStatus) => Promise<void>) {
44 this.workspace = workspace;
45
46 this.onStatusAdded = onStatusAdded;
47 this.onStatusRemoved = onStatusRemoved;
48 this.statuses = new Set();
49
50 this.fileSystemUISourceCodes = new Map();
51 this.sweepThrottler = new Common.Throttler.Throttler(100);
52
53 this.sourceCodeToProcessingPromiseMap = new WeakMap();
54 this.sourceCodeToAutoMappingStatusMap = new WeakMap();
55 this.sourceCodeToMetadataMap = new WeakMap();
56
57 const pathEncoder = new PathEncoder();
58 this.filesIndex = new FilePathIndex(pathEncoder);
59 this.projectFoldersIndex = new FolderIndex(pathEncoder);
60 this.activeFoldersIndex = new FolderIndex(pathEncoder);
61
62 this.interceptors = [];
63
64 this.workspace.addEventListener(
65 Workspace.Workspace.Events.UISourceCodeAdded, event => this.onUISourceCodeAdded(event.data));
66 this.workspace.addEventListener(
67 Workspace.Workspace.Events.UISourceCodeRemoved, event => this.onUISourceCodeRemoved(event.data));
68 this.workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRenamed, this.onUISourceCodeRenamed, this);
69 this.workspace.addEventListener(
70 Workspace.Workspace.Events.ProjectAdded, event => this.onProjectAdded(event.data), this);
71 this.workspace.addEventListener(
72 Workspace.Workspace.Events.ProjectRemoved, event => this.onProjectRemoved(event.data), this);
73
74 for (const fileSystem of workspace.projects()) {
75 this.onProjectAdded(fileSystem);
76 }
77 for (const uiSourceCode of workspace.uiSourceCodes()) {
78 this.onUISourceCodeAdded(uiSourceCode);
79 }
80 }
81
82 addNetworkInterceptor(interceptor: (arg0: Workspace.UISourceCode.UISourceCode) => boolean): void {
83 this.interceptors.push(interceptor);
84 this.scheduleRemap();
85 }
86
87 scheduleRemap(): void {
88 for (const status of this.statuses.values()) {
89 this.clearNetworkStatus(status.network);
90 }
91 this.scheduleSweep();
92 }
93
94 private scheduleSweep(): void {
95 this.sweepThrottler.schedule(sweepUnmapped.bind(this));
96
97 function sweepUnmapped(this: Automapping): Promise<void> {
98 const networkProjects = this.workspace.projectsForType(Workspace.Workspace.projectTypes.Network);
99 for (const networkProject of networkProjects) {
100 for (const uiSourceCode of networkProject.uiSourceCodes()) {
101 this.computeNetworkStatus(uiSourceCode);
102 }
103 }
104 this.onSweepHappenedForTest();
105 return Promise.resolve();
106 }
107 }
108
109 private onSweepHappenedForTest(): void {
110 }
111
112 private onProjectRemoved(project: Workspace.Workspace.Project): void {
113 for (const uiSourceCode of project.uiSourceCodes()) {
114 this.onUISourceCodeRemoved(uiSourceCode);
115 }
116 if (project.type() !== Workspace.Workspace.projectTypes.FileSystem) {
117 return;
118 }
119 const fileSystem = project as FileSystem;
120 for (const gitFolder of fileSystem.initialGitFolders()) {
121 this.projectFoldersIndex.removeFolder(gitFolder);
122 }
123 this.projectFoldersIndex.removeFolder(fileSystem.fileSystemPath());
124 this.scheduleRemap();
125 }
126
127 private onProjectAdded(project: Workspace.Workspace.Project): void {
128 if (project.type() !== Workspace.Workspace.projectTypes.FileSystem) {
129 return;
130 }
131 const fileSystem = project as FileSystem;
132 for (const gitFolder of fileSystem.initialGitFolders()) {
133 this.projectFoldersIndex.addFolder(gitFolder);
134 }
135 this.projectFoldersIndex.addFolder(fileSystem.fileSystemPath());
136 project.uiSourceCodes().forEach(this.onUISourceCodeAdded.bind(this));
137 this.scheduleRemap();
138 }
139
140 private onUISourceCodeAdded(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
141 const project = uiSourceCode.project();
142 if (project.type() === Workspace.Workspace.projectTypes.FileSystem) {
143 if (!FileSystemWorkspaceBinding.fileSystemSupportsAutomapping(project)) {
144 return;
145 }
146 this.filesIndex.addPath(uiSourceCode.url());
147 this.fileSystemUISourceCodes.set(uiSourceCode.url(), uiSourceCode);
148 this.scheduleSweep();
149 } else if (project.type() === Workspace.Workspace.projectTypes.Network) {
150 this.computeNetworkStatus(uiSourceCode);
151 }
152 }
153
154 private onUISourceCodeRemoved(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
155 if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.FileSystem) {
156 this.filesIndex.removePath(uiSourceCode.url());
157 this.fileSystemUISourceCodes.delete(uiSourceCode.url());
158 const status = this.sourceCodeToAutoMappingStatusMap.get(uiSourceCode);
159 if (status) {
160 this.clearNetworkStatus(status.network);
161 }
162 } else if (uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network) {
163 this.clearNetworkStatus(uiSourceCode);
164 }
165 }
166
167 private onUISourceCodeRenamed(
168 event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.UISourceCodeRenamedEvent>): void {
169 const {uiSourceCode, oldURL} = event.data;
170 if (uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.FileSystem) {
171 return;
172 }
173
174 this.filesIndex.removePath(oldURL);
175 this.fileSystemUISourceCodes.delete(oldURL);
176 const status = this.sourceCodeToAutoMappingStatusMap.get(uiSourceCode);
177 if (status) {
178 this.clearNetworkStatus(status.network);
179 }
180
181 this.filesIndex.addPath(uiSourceCode.url());
182 this.fileSystemUISourceCodes.set(uiSourceCode.url(), uiSourceCode);
183 this.scheduleSweep();
184 }
185
186 private computeNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): void {
187 if (this.sourceCodeToProcessingPromiseMap.has(networkSourceCode) ||
188 this.sourceCodeToAutoMappingStatusMap.has(networkSourceCode)) {
189 return;
190 }
191 if (this.interceptors.some(interceptor => interceptor(networkSourceCode))) {
192 return;
193 }
194 if (networkSourceCode.url().startsWith('wasm://')) {
195 return;
196 }
197 const createBindingPromise =
198 this.createBinding(networkSourceCode).then(validateStatus.bind(this)).then(onStatus.bind(this));
199 this.sourceCodeToProcessingPromiseMap.set(networkSourceCode, createBindingPromise);
200
201 async function validateStatus(this: Automapping, status: AutomappingStatus|null): Promise<AutomappingStatus|null> {
202 if (!status) {
203 return null;
204 }
205 if (this.sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
206 return null;
207 }
208 if (status.network.contentType().isFromSourceMap() || !status.fileSystem.contentType().isTextType()) {
209 return status;
210 }
211
212 // At the time binding comes, there are multiple user scenarios:
213 // 1. Both network and fileSystem files are **not** dirty.
214 // This is a typical scenario when user hasn't done any edits yet to the
215 // files in question.
216 // 2. FileSystem file has unsaved changes, network is clear.
217 // This typically happens with CSS files editing. Consider the following
218 // scenario:
219 // - user edits file that has been successfully mapped before
220 // - user doesn't save the file
221 // - user hits reload
222 // 3. Network file has either unsaved changes or commits, but fileSystem file is clear.
223 // This typically happens when we've been editing file and then realized we'd like to drop
224 // a folder and persist all the changes.
225 // 4. Network file has either unsaved changes or commits, and fileSystem file has unsaved changes.
226 // We consider this to be un-realistic scenario and in this case just fail gracefully.
227 //
228 // To support usecase (3), we need to validate against original network content.
229 if (status.fileSystem.isDirty() && (status.network.isDirty() || status.network.hasCommits())) {
230 return null;
231 }
232
233 const [fileSystemContent, networkContent] = await Promise.all(
234 [status.fileSystem.requestContent(), status.network.project().requestFileContent(status.network)]);
235 if (fileSystemContent.content === null || networkContent === null) {
236 return null;
237 }
238
239 if (this.sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
240 return null;
241 }
242
243 const target = Bindings.NetworkProject.NetworkProject.targetForUISourceCode(status.network);
244 let isValid = false;
245 const fileContent = fileSystemContent.content;
246 if (target && target.type() === SDK.Target.Type.Node) {
247 if (networkContent.content) {
248 const rewrappedNetworkContent =
249 PersistenceImpl.rewrapNodeJSContent(status.fileSystem, fileContent, networkContent.content);
250 isValid = fileContent === rewrappedNetworkContent;
251 }
252 } else {
253 if (networkContent.content) {
254 // Trim trailing whitespaces because V8 adds trailing newline.
255 isValid = fileContent.trimRight() === networkContent.content.trimRight();
256 }
257 }
258 if (!isValid) {
259 this.prevalidationFailedForTest(status);
260 return null;
261 }
262 return status;
263 }
264
265 function onStatus(this: Automapping, status: AutomappingStatus|null): void {
266 if (this.sourceCodeToProcessingPromiseMap.get(networkSourceCode) !== createBindingPromise) {
267 return;
268 }
269 this.sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
270 if (!status) {
271 this.onBindingFailedForTest();
272 return;
273 }
274 // TODO(lushnikov): remove this check once there's a single uiSourceCode per url. @see crbug.com/670180
275 if (this.sourceCodeToAutoMappingStatusMap.has(status.network) ||
276 this.sourceCodeToAutoMappingStatusMap.has(status.fileSystem)) {
277 return;
278 }
279
280 this.statuses.add(status);
281 this.sourceCodeToAutoMappingStatusMap.set(status.network, status);
282 this.sourceCodeToAutoMappingStatusMap.set(status.fileSystem, status);
283 if (status.exactMatch) {
284 const projectFolder = this.projectFoldersIndex.closestParentFolder(status.fileSystem.url());
285 const newFolderAdded = projectFolder ? this.activeFoldersIndex.addFolder(projectFolder) : false;
286 if (newFolderAdded) {
287 this.scheduleSweep();
288 }
289 }
290 this.onStatusAdded.call(null, status);
291 }
292 }
293
294 private prevalidationFailedForTest(_binding: AutomappingStatus): void {
295 }
296
297 private onBindingFailedForTest(): void {
298 }
299
300 private clearNetworkStatus(networkSourceCode: Workspace.UISourceCode.UISourceCode): void {
301 if (this.sourceCodeToProcessingPromiseMap.has(networkSourceCode)) {
302 this.sourceCodeToProcessingPromiseMap.delete(networkSourceCode);
303 return;
304 }
305 const status = this.sourceCodeToAutoMappingStatusMap.get(networkSourceCode);
306 if (!status) {
307 return;
308 }
309
310 this.statuses.delete(status);
311 this.sourceCodeToAutoMappingStatusMap.delete(status.network);
312 this.sourceCodeToAutoMappingStatusMap.delete(status.fileSystem);
313 if (status.exactMatch) {
314 const projectFolder = this.projectFoldersIndex.closestParentFolder(status.fileSystem.url());
315 if (projectFolder) {
316 this.activeFoldersIndex.removeFolder(projectFolder);
317 }
318 }
319 this.onStatusRemoved.call(null, status);
320 }
321
322 private createBinding(networkSourceCode: Workspace.UISourceCode.UISourceCode): Promise<AutomappingStatus|null> {
323 const url = networkSourceCode.url();
324 if (url.startsWith('file://') || url.startsWith('snippet://')) {
325 const decodedUrl = sanitizeSourceUrl(url);
326 if (!decodedUrl) {
327 return Promise.resolve(null as AutomappingStatus | null);
328 }
329 const fileSourceCode = this.fileSystemUISourceCodes.get(decodedUrl);
330 const status = fileSourceCode ? new AutomappingStatus(networkSourceCode, fileSourceCode, false) : null;
331 return Promise.resolve(status);
332 }
333
334 let networkPath = Common.ParsedURL.ParsedURL.extractPath(url);
335 if (networkPath === null) {
336 return Promise.resolve(null as AutomappingStatus | null);
337 }
338
339 if (networkPath.endsWith('/')) {
340 networkPath += 'index.html';
341 }
342
343 const urlDecodedNetworkPath = sanitizeSourceUrl(networkPath);
344 if (!urlDecodedNetworkPath) {
345 return Promise.resolve(null as AutomappingStatus | null);
346 }
347
348 const similarFiles =
349 this.filesIndex.similarFiles(urlDecodedNetworkPath).map(path => this.fileSystemUISourceCodes.get(path)) as
350 Workspace.UISourceCode.UISourceCode[];
351 if (!similarFiles.length) {
352 return Promise.resolve(null as AutomappingStatus | null);
353 }
354
355 return this.pullMetadatas(similarFiles.concat(networkSourceCode)).then(onMetadatas.bind(this));
356
357 function sanitizeSourceUrl(url: string): string|null {
358 try {
359 const decodedUrl = decodeURI(url);
360 return decodedUrl;
361 } catch (error) {
362 Common.Console.Console.instance().error(i18nString(UIStrings.theAttemptToBindSInTheWorkspace, {PH1: url}));
363 return null;
364 }
365 }
366
367 function onMetadatas(this: Automapping): AutomappingStatus|null {
368 const activeFiles =
369 similarFiles.filter(
370 file => Boolean(file) && Boolean(this.activeFoldersIndex.closestParentFolder(file.url()))) as
371 Workspace.UISourceCode.UISourceCode[];
372 const networkMetadata = this.sourceCodeToMetadataMap.get(networkSourceCode);
373 if (!networkMetadata || (!networkMetadata.modificationTime && typeof networkMetadata.contentSize !== 'number')) {
374 // If networkSourceCode does not have metadata, try to match against active folders.
375 if (activeFiles.length !== 1) {
376 return null;
377 }
378 return new AutomappingStatus(networkSourceCode, activeFiles[0], false);
379 }
380
381 // Try to find exact matches, prioritizing active folders.
382 let exactMatches = this.filterWithMetadata(activeFiles, networkMetadata);
383 if (!exactMatches.length) {
384 exactMatches = this.filterWithMetadata(similarFiles, networkMetadata);
385 }
386 if (exactMatches.length !== 1) {
387 return null;
388 }
389 return new AutomappingStatus(networkSourceCode, exactMatches[0], true);
390 }
391 }
392
393 private async pullMetadatas(uiSourceCodes: Workspace.UISourceCode.UISourceCode[]): Promise<void> {
394 await Promise.all(uiSourceCodes.map(async file => {
395 this.sourceCodeToMetadataMap.set(file, await file.requestMetadata());
396 }));
397 }
398
399 private filterWithMetadata(
400 files: Workspace.UISourceCode.UISourceCode[],
401 networkMetadata: Workspace.UISourceCode.UISourceCodeMetadata): Workspace.UISourceCode.UISourceCode[] {
402 return files.filter(file => {
403 const fileMetadata = this.sourceCodeToMetadataMap.get(file);
404 if (!fileMetadata) {
405 return false;
406 }
407 // Allow a second of difference due to network timestamps lack of precision.
408 const timeMatches = !networkMetadata.modificationTime || !fileMetadata.modificationTime ||
409 Math.abs(networkMetadata.modificationTime.getTime() - fileMetadata.modificationTime.getTime()) < 1000;
410 const contentMatches = !networkMetadata.contentSize || fileMetadata.contentSize === networkMetadata.contentSize;
411 return timeMatches && contentMatches;
412 });
413 }
414}
415
416class FilePathIndex {
417 private readonly encoder: PathEncoder;
418 private readonly reversedIndex: Common.Trie.Trie;
419 constructor(encoder: PathEncoder) {
420 this.encoder = encoder;
421 this.reversedIndex = new Common.Trie.Trie();
422 }
423
424 addPath(path: string): void {
425 const encodedPath = this.encoder.encode(path);
426 this.reversedIndex.add(Platform.StringUtilities.reverse(encodedPath));
427 }
428
429 removePath(path: string): void {
430 const encodedPath = this.encoder.encode(path);
431 this.reversedIndex.remove(Platform.StringUtilities.reverse(encodedPath));
432 }
433
434 similarFiles(networkPath: string): string[] {
435 const encodedPath = this.encoder.encode(networkPath);
436 const reversedEncodedPath = Platform.StringUtilities.reverse(encodedPath);
437 const longestCommonPrefix = this.reversedIndex.longestPrefix(reversedEncodedPath, false);
438 if (!longestCommonPrefix) {
439 return [];
440 }
441 return this.reversedIndex.words(longestCommonPrefix)
442 .map(encodedPath => this.encoder.decode(Platform.StringUtilities.reverse(encodedPath)));
443 }
444}
445
446class FolderIndex {
447 private readonly encoder: PathEncoder;
448 private readonly index: Common.Trie.Trie;
449 private readonly folderCount: Map<string, number>;
450 constructor(encoder: PathEncoder) {
451 this.encoder = encoder;
452 this.index = new Common.Trie.Trie();
453 this.folderCount = new Map();
454 }
455
456 addFolder(path: string): boolean {
457 if (path.endsWith('/')) {
458 path = path.substring(0, path.length - 1);
459 }
460 const encodedPath = this.encoder.encode(path);
461 this.index.add(encodedPath);
462 const count = this.folderCount.get(encodedPath) || 0;
463 this.folderCount.set(encodedPath, count + 1);
464 return count === 0;
465 }
466
467 removeFolder(path: string): boolean {
468 if (path.endsWith('/')) {
469 path = path.substring(0, path.length - 1);
470 }
471 const encodedPath = this.encoder.encode(path);
472 const count = this.folderCount.get(encodedPath) || 0;
473 if (!count) {
474 return false;
475 }
476 if (count > 1) {
477 this.folderCount.set(encodedPath, count - 1);
478 return false;
479 }
480 this.index.remove(encodedPath);
481 this.folderCount.delete(encodedPath);
482 return true;
483 }
484
485 closestParentFolder(path: string): string {
486 const encodedPath = this.encoder.encode(path);
487 const commonPrefix = this.index.longestPrefix(encodedPath, true);
488 return this.encoder.decode(commonPrefix);
489 }
490}
491
492export class AutomappingStatus {
493 network: Workspace.UISourceCode.UISourceCode;
494 fileSystem: Workspace.UISourceCode.UISourceCode;
495 exactMatch: boolean;
496 constructor(
497 network: Workspace.UISourceCode.UISourceCode, fileSystem: Workspace.UISourceCode.UISourceCode,
498 exactMatch: boolean) {
499 this.network = network;
500 this.fileSystem = fileSystem;
501 this.exactMatch = exactMatch;
502 }
503}
504
\No newline at end of file