blob: 2388d7e18704dc3ee3bec0d5a636a4f3b7a7b74d [file] [log] [blame]
/*
* Copyright (C) 2013 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.
*/
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import type * as TextUtils from '../text_utils/text_utils.js';
import type {IsolatedFileSystemManager} from './IsolatedFileSystemManager.js';
import {Events} from './IsolatedFileSystemManager.js';
import {PlatformFileSystem} from './PlatformFileSystem.js';
const UIStrings = {
/**
*@description Text in Isolated File System of the Workspace settings in Settings
*@example {folder does not exist} PH1
*/
fileSystemErrorS: 'File system error: {PH1}',
/**
*@description Error message when reading a remote blob
*/
blobCouldNotBeLoaded: 'Blob could not be loaded.',
/**
*@description Error message when reading a file.
*@example {c:\dir\file.js} PH1
*@example {Underlying error} PH2
*/
cantReadFileSS: 'Can\'t read file: {PH1}: {PH2}',
/**
*@description Error message when failing to load a file
*@example {c:\dir\file.js} PH1
*/
unknownErrorReadingFileS: 'Unknown error reading file: {PH1}',
/**
*@description Text to show something is linked to another
*@example {example.url} PH1
*/
linkedToS: 'Linked to {PH1}',
};
const str_ = i18n.i18n.registerUIStrings('models/persistence/IsolatedFileSystem.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class IsolatedFileSystem extends PlatformFileSystem {
private readonly manager: IsolatedFileSystemManager;
private readonly embedderPathInternal: string;
private readonly domFileSystem: FileSystem;
private readonly excludedFoldersSetting: Common.Settings.Setting<{[path: string]: string[]}>;
private excludedFoldersInternal: Set<string>;
private readonly excludedEmbedderFolders: string[];
private readonly initialFilePathsInternal: Set<Platform.DevToolsPath.EncodedPathString>;
private readonly initialGitFoldersInternal: Set<Platform.DevToolsPath.EncodedPathString>;
private readonly fileLocks: Map<string, Promise<void>>;
constructor(
manager: IsolatedFileSystemManager, path: string, embedderPath: string, domFileSystem: FileSystem, type: string) {
super(path, type);
this.manager = manager;
this.embedderPathInternal = embedderPath;
this.domFileSystem = domFileSystem;
this.excludedFoldersSetting =
Common.Settings.Settings.instance().createLocalSetting('workspaceExcludedFolders', {});
this.excludedFoldersInternal = new Set(this.excludedFoldersSetting.get()[path] || []);
this.excludedEmbedderFolders = [];
this.initialFilePathsInternal = new Set();
this.initialGitFoldersInternal = new Set();
this.fileLocks = new Map();
}
static async create(
manager: IsolatedFileSystemManager, path: string, embedderPath: string, type: string, name: string,
rootURL: string): Promise<IsolatedFileSystem|null> {
const domFileSystem = Host.InspectorFrontendHost.InspectorFrontendHostInstance.isolatedFileSystem(name, rootURL);
if (!domFileSystem) {
return null as IsolatedFileSystem | null;
}
const fileSystem = new IsolatedFileSystem(manager, path, embedderPath, domFileSystem, type);
return fileSystem.initializeFilePaths().then(() => fileSystem).catch(error => {
console.error(error);
return null;
});
}
static errorMessage(error: DOMError): string {
// @ts-ignore TODO(crbug.com/1172300) Properly type this after jsdoc to ts migration
return i18nString(UIStrings.fileSystemErrorS, {PH1: error.message});
}
private serializedFileOperation<T>(path: string, operation: () => Promise<T>): Promise<T> {
const promise = Promise.resolve(this.fileLocks.get(path)).then(() => operation.call(null));
this.fileLocks.set(path, promise as unknown as Promise<void>);
return promise;
}
getMetadata(path: string): Promise<Metadata|null> {
let fulfill: (arg0: Metadata|null) => void;
const promise = new Promise<Metadata|null>(f => {
fulfill = f;
});
this.domFileSystem.root.getFile(decodeURIComponent(path), undefined, fileEntryLoaded, errorHandler);
return promise;
function fileEntryLoaded(entry: FileEntry): void {
entry.getMetadata(fulfill, errorHandler);
}
function errorHandler(error: DOMError): void {
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when getting file metadata \'' + path);
fulfill(null);
}
}
initialFilePaths(): Platform.DevToolsPath.EncodedPathString[] {
return [...this.initialFilePathsInternal];
}
initialGitFolders(): Platform.DevToolsPath.EncodedPathString[] {
return [...this.initialGitFoldersInternal];
}
embedderPath(): string {
return this.embedderPathInternal;
}
private initializeFilePaths(): Promise<void> {
return new Promise(fulfill => {
let pendingRequests = 1;
const boundInnerCallback = innerCallback.bind(this);
this.requestEntries('', boundInnerCallback);
function innerCallback(this: IsolatedFileSystem, entries: FileEntry[]): void {
for (let i = 0; i < entries.length; ++i) {
const entry = entries[i];
if (!entry.isDirectory) {
if (this.isFileExcluded(entry.fullPath)) {
continue;
}
this.initialFilePathsInternal.add(Common.ParsedURL.ParsedURL.rawPathToEncodedPathString(
Common.ParsedURL.ParsedURL.substr(entry.fullPath as Platform.DevToolsPath.RawPathString, 1)));
} else {
if (entry.fullPath.endsWith('/.git')) {
const lastSlash = entry.fullPath.lastIndexOf('/');
const parentFolder = Common.ParsedURL.ParsedURL.substr(
entry.fullPath as Platform.DevToolsPath.RawPathString, 1, lastSlash);
this.initialGitFoldersInternal.add(Common.ParsedURL.ParsedURL.rawPathToEncodedPathString(parentFolder));
}
if (this.isFileExcluded(entry.fullPath + '/')) {
const url = Common.ParsedURL.ParsedURL.concatenate(this.path(), entry.fullPath);
this.excludedEmbedderFolders.push(
Common.ParsedURL.ParsedURL.urlToRawPathString(url, Host.Platform.isWin()));
continue;
}
++pendingRequests;
this.requestEntries(entry.fullPath, boundInnerCallback);
}
}
if ((--pendingRequests === 0)) {
fulfill();
}
}
});
}
private async createFoldersIfNotExist(folderPath: string): Promise<DirectoryEntry|null> {
// Fast-path. If parent directory already exists we return it immidiatly.
let dirEntry = await new Promise<DirectoryEntry|null>(
resolve => this.domFileSystem.root.getDirectory(folderPath, undefined, resolve, () => resolve(null)));
if (dirEntry) {
return dirEntry;
}
const paths = folderPath.split('/');
let activePath = '';
for (const path of paths) {
activePath = activePath + '/' + path;
dirEntry = await this.innerCreateFolderIfNeeded(activePath);
if (!dirEntry) {
return null;
}
}
return dirEntry;
}
private innerCreateFolderIfNeeded(path: string): Promise<DirectoryEntry|null> {
return new Promise(resolve => {
this.domFileSystem.root.getDirectory(path, {create: true}, dirEntry => resolve(dirEntry), error => {
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' trying to create directory \'' + path + '\'');
resolve(null);
});
});
}
async createFile(path: string, name: Platform.DevToolsPath.RawPathString|null): Promise<string|null> {
const dirEntry = await this.createFoldersIfNotExist(decodeURIComponent(path));
if (!dirEntry) {
return null;
}
const fileEntry =
await this.serializedFileOperation(
path, createFileCandidate.bind(this, name || 'NewFile' as Platform.DevToolsPath.RawPathString)) as
FileEntry |
null;
if (!fileEntry) {
return null;
}
return Common.ParsedURL.ParsedURL.rawPathToEncodedPathString(
Common.ParsedURL.ParsedURL.substr(fileEntry.fullPath as Platform.DevToolsPath.RawPathString, 1));
function createFileCandidate(
this: IsolatedFileSystem, name: Platform.DevToolsPath.RawPathString,
newFileIndex?: number): Promise<FileEntry|null> {
return new Promise(resolve => {
const nameCandidate = Common.ParsedURL.ParsedURL.concatenate(name, (newFileIndex || '').toString());
(dirEntry as DirectoryEntry).getFile(nameCandidate, {create: true, exclusive: true}, resolve, error => {
if (error.name === 'InvalidModificationError') {
resolve(createFileCandidate.call(this, name, (newFileIndex ? newFileIndex + 1 : 1)));
return;
}
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(
errorMessage + ' when testing if file exists \'' + (this.path() + '/' + path + '/' + nameCandidate) +
'\'');
resolve(null);
});
});
}
}
deleteFile(path: Platform.DevToolsPath.EncodedPathString): Promise<boolean> {
let resolveCallback: (arg0: boolean) => void;
const promise = new Promise<boolean>(resolve => {
resolveCallback = resolve;
});
this.domFileSystem.root.getFile(
decodeURIComponent(path), undefined, fileEntryLoaded.bind(this), errorHandler.bind(this));
return promise;
function fileEntryLoaded(this: IsolatedFileSystem, fileEntry: FileEntry): void {
fileEntry.remove(fileEntryRemoved, errorHandler.bind(this));
}
function fileEntryRemoved(): void {
resolveCallback(true);
}
/**
* TODO(jsbell): Update externs replacing DOMError with DOMException. https://crbug.com/496901
*/
function errorHandler(this: IsolatedFileSystem, error: DOMError): void {
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when deleting file \'' + (this.path() + '/' + path) + '\'');
resolveCallback(false);
}
}
requestFileBlob(path: Platform.DevToolsPath.EncodedPathString): Promise<Blob|null> {
return new Promise(resolve => {
this.domFileSystem.root.getFile(decodeURIComponent(path), undefined, entry => {
entry.file(resolve, errorHandler.bind(this));
}, errorHandler.bind(this));
function errorHandler(this: IsolatedFileSystem, error: DOMError): void {
if (error.name === 'NotFoundError') {
resolve(null);
return;
}
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when getting content for file \'' + (this.path() + '/' + path) + '\'');
resolve(null);
}
});
}
requestFileContent(path: Platform.DevToolsPath.EncodedPathString):
Promise<TextUtils.ContentProvider.DeferredContent> {
return this.serializedFileOperation(path, () => this.innerRequestFileContent(path));
}
private async innerRequestFileContent(path: Platform.DevToolsPath.EncodedPathString):
Promise<TextUtils.ContentProvider.DeferredContent> {
const blob = await this.requestFileBlob(path);
if (!blob) {
return {content: null, error: i18nString(UIStrings.blobCouldNotBeLoaded), isEncoded: false};
}
const reader = new FileReader();
const extension = Common.ParsedURL.ParsedURL.extractExtension(path);
const encoded = BinaryExtensions.has(extension);
const readPromise = new Promise(x => {
reader.onloadend = x;
});
if (encoded) {
reader.readAsBinaryString(blob);
} else {
reader.readAsText(blob);
}
await readPromise;
if (reader.error) {
const error = i18nString(UIStrings.cantReadFileSS, {PH1: path, PH2: reader.error.toString()});
console.error(error);
return {content: null, isEncoded: false, error};
}
let result: string|null = null;
let error: Common.UIString.LocalizedString|null = null;
try {
result = reader.result as string;
} catch (e) {
result = null;
error = i18nString(UIStrings.cantReadFileSS, {PH1: path, PH2: e.message});
}
if (result === undefined || result === null) {
error = error || i18nString(UIStrings.unknownErrorReadingFileS, {PH1: path});
console.error(error);
return {content: null, isEncoded: false, error};
}
return {isEncoded: encoded, content: encoded ? btoa(result) : result};
}
async setFileContent(path: Platform.DevToolsPath.EncodedPathString, content: string, isBase64: boolean):
Promise<void> {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.FileSavedInWorkspace);
let callback: (event?: ProgressEvent<EventTarget>) => void;
const innerSetFileContent = (): Promise<ProgressEvent<EventTarget>> => {
const promise = new Promise<ProgressEvent<EventTarget>>(x => {
// @ts-ignore TODO(crbug.com/1172300) Properly type this after jsdoc to ts migration
callback = x;
});
this.domFileSystem.root.getFile(
decodeURIComponent(path), {create: true}, fileEntryLoaded.bind(this), errorHandler.bind(this));
return promise;
};
void this.serializedFileOperation(path, innerSetFileContent);
function fileEntryLoaded(this: IsolatedFileSystem, entry: FileEntry): void {
entry.createWriter(fileWriterCreated.bind(this), errorHandler.bind(this));
}
async function fileWriterCreated(this: IsolatedFileSystem, fileWriter: FileWriter): Promise<void> {
fileWriter.onerror = errorHandler.bind(this);
fileWriter.onwriteend = fileWritten;
let blob: Blob;
if (isBase64) {
blob = await (await fetch(`data:application/octet-stream;base64,${content}`)).blob();
} else {
blob = new Blob([content], {type: 'text/plain'});
}
fileWriter.write(blob);
function fileWritten(): void {
fileWriter.onwriteend = callback;
fileWriter.truncate(blob.size);
}
}
function errorHandler(this: IsolatedFileSystem, error: DOMError|ProgressEvent<EventTarget>): void {
// @ts-ignore TODO(crbug.com/1172300) Properly type this after jsdoc to ts migration
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when setting content for file \'' + (this.path() + '/' + path) + '\'');
callback(undefined);
}
}
renameFile(
path: Platform.DevToolsPath.EncodedPathString, newName: Platform.DevToolsPath.RawPathString,
callback: (arg0: boolean, arg1?: string|undefined) => void): void {
newName = newName ? newName.trim() as Platform.DevToolsPath.RawPathString : newName;
if (!newName || newName.indexOf('/') !== -1) {
callback(false);
return;
}
let fileEntry: FileEntry;
let dirEntry: DirectoryEntry;
this.domFileSystem.root.getFile(
decodeURIComponent(path), undefined, fileEntryLoaded.bind(this), errorHandler.bind(this));
function fileEntryLoaded(this: IsolatedFileSystem, entry: FileEntry): void {
if (entry.name === newName) {
callback(false);
return;
}
fileEntry = entry;
fileEntry.getParent(dirEntryLoaded.bind(this), errorHandler.bind(this));
}
function dirEntryLoaded(this: IsolatedFileSystem, entry: Entry): void {
dirEntry = entry as DirectoryEntry;
dirEntry.getFile(newName, undefined, newFileEntryLoaded, newFileEntryLoadErrorHandler.bind(this));
}
function newFileEntryLoaded(_entry: FileEntry): void {
callback(false);
}
function newFileEntryLoadErrorHandler(this: IsolatedFileSystem, error: DOMError): void {
if (error.name !== 'NotFoundError') {
callback(false);
return;
}
fileEntry.moveTo(dirEntry, newName, fileRenamed, errorHandler.bind(this));
}
function fileRenamed(entry: Entry): void {
callback(true, entry.name);
}
function errorHandler(this: IsolatedFileSystem, error: DOMError): void {
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when renaming file \'' + (this.path() + '/' + path) + '\' to \'' + newName + '\'');
callback(false);
}
}
private readDirectory(dirEntry: DirectoryEntry, callback: (arg0: Array<FileEntry>) => void): void {
const dirReader = dirEntry.createReader();
let entries: FileEntry[] = [];
function innerCallback(results: Entry[]): void {
if (!results.length) {
callback(entries.sort());
} else {
entries = entries.concat(toArray(results));
dirReader.readEntries(innerCallback, errorHandler);
}
}
function toArray(list: Entry[]): FileEntry[] {
return Array.prototype.slice.call(list || [], 0);
}
dirReader.readEntries(innerCallback, errorHandler);
function errorHandler(error: DOMError): void {
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when reading directory \'' + dirEntry.fullPath + '\'');
callback([]);
}
}
private requestEntries(path: string, callback: (arg0: Array<FileEntry>) => void): void {
this.domFileSystem.root.getDirectory(decodeURIComponent(path), undefined, innerCallback.bind(this), errorHandler);
function innerCallback(this: IsolatedFileSystem, dirEntry: DirectoryEntry): void {
this.readDirectory(dirEntry, callback);
}
function errorHandler(error: DOMError): void {
const errorMessage = IsolatedFileSystem.errorMessage(error);
console.error(errorMessage + ' when requesting entry \'' + path + '\'');
callback([]);
}
}
private saveExcludedFolders(): void {
const settingValue = this.excludedFoldersSetting.get();
settingValue[this.path()] = [...this.excludedFoldersInternal];
this.excludedFoldersSetting.set(settingValue);
}
addExcludedFolder(path: string): void {
this.excludedFoldersInternal.add(path);
this.saveExcludedFolders();
this.manager.dispatchEventToListeners(Events.ExcludedFolderAdded, path);
}
removeExcludedFolder(path: string): void {
this.excludedFoldersInternal.delete(path);
this.saveExcludedFolders();
this.manager.dispatchEventToListeners(Events.ExcludedFolderRemoved, path);
}
fileSystemRemoved(): void {
const settingValue = this.excludedFoldersSetting.get();
delete settingValue[this.path()];
this.excludedFoldersSetting.set(settingValue);
}
isFileExcluded(folderPath: string): boolean {
if (this.excludedFoldersInternal.has(folderPath)) {
return true;
}
const regex = (this.manager.workspaceFolderExcludePatternSetting() as Common.Settings.RegExpSetting).asRegExp();
return Boolean(regex && regex.test(folderPath));
}
excludedFolders(): Set<string> {
return this.excludedFoldersInternal;
}
searchInPath(query: string, progress: Common.Progress.Progress): Promise<string[]> {
return new Promise(resolve => {
const requestId = this.manager.registerCallback(innerCallback);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.searchInPath(
requestId, this.embedderPathInternal, query);
function innerCallback(files: Platform.DevToolsPath.RawPathString[]): void {
resolve(files.map(path => Common.ParsedURL.ParsedURL.rawPathToUrlString(path)));
progress.incrementWorked(1);
}
});
}
indexContent(progress: Common.Progress.Progress): void {
progress.setTotalWork(1);
const requestId = this.manager.registerProgress(progress);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.indexPath(
requestId, this.embedderPathInternal, JSON.stringify(this.excludedEmbedderFolders));
}
mimeFromPath(path: string): string {
return Common.ResourceType.ResourceType.mimeFromURL(path) || 'text/plain';
}
canExcludeFolder(path: string): boolean {
return Boolean(path) && this.type() !== 'overrides';
}
contentType(path: string): Common.ResourceType.ResourceType {
const extension = Common.ParsedURL.ParsedURL.extractExtension(path);
if (STYLE_SHEET_EXTENSIONS.has(extension)) {
return Common.ResourceType.resourceTypes.Stylesheet;
}
if (DOCUMENT_EXTENSIONS.has(extension)) {
return Common.ResourceType.resourceTypes.Document;
}
if (IMAGE_EXTENSIONS.has(extension)) {
return Common.ResourceType.resourceTypes.Image;
}
if (SCRIPT_EXTENSIONS.has(extension)) {
return Common.ResourceType.resourceTypes.Script;
}
return BinaryExtensions.has(extension) ? Common.ResourceType.resourceTypes.Other :
Common.ResourceType.resourceTypes.Document;
}
tooltipForURL(url: Platform.DevToolsPath.UrlString): string {
const path = Platform.StringUtilities.trimMiddle(
Common.ParsedURL.ParsedURL.urlToRawPathString(url, Host.Platform.isWin()), 150);
return i18nString(UIStrings.linkedToS, {PH1: path});
}
supportsAutomapping(): boolean {
return this.type() !== 'overrides';
}
}
const STYLE_SHEET_EXTENSIONS = new Set<string>(['css', 'scss', 'sass', 'less']);
const DOCUMENT_EXTENSIONS = new Set<string>(['htm', 'html', 'asp', 'aspx', 'phtml', 'jsp']);
const SCRIPT_EXTENSIONS = new Set<string>([
'asp', 'aspx', 'c', 'cc', 'cljs', 'coffee', 'cpp', 'cs', 'dart', 'java', 'js',
'jsp', 'jsx', 'h', 'm', 'mjs', 'mm', 'py', 'sh', 'ts', 'tsx', 'ls',
]);
const IMAGE_EXTENSIONS = new Set<string>(['jpeg', 'jpg', 'svg', 'gif', 'webp', 'png', 'ico', 'tiff', 'tif', 'bmp']);
export const BinaryExtensions = new Set<string>([
// Executable extensions, roughly taken from https://en.wikipedia.org/wiki/Comparison_of_executable_file_formats
'cmd',
'com',
'exe',
// Archive extensions, roughly taken from https://en.wikipedia.org/wiki/List_of_archive_formats
'a',
'ar',
'iso',
'tar',
'bz2',
'gz',
'lz',
'lzma',
'z',
'7z',
'apk',
'arc',
'cab',
'dmg',
'jar',
'pak',
'rar',
'zip',
// Audio file extensions, roughly taken from https://en.wikipedia.org/wiki/Audio_file_format#List_of_formats
'3gp',
'aac',
'aiff',
'flac',
'm4a',
'mmf',
'mp3',
'ogg',
'oga',
'raw',
'sln',
'wav',
'wma',
'webm',
// Video file extensions, roughly taken from https://en.wikipedia.org/wiki/Video_file_format
'mkv',
'flv',
'vob',
'ogv',
'gifv',
'avi',
'mov',
'qt',
'mp4',
'm4p',
'm4v',
'mpg',
'mpeg',
// Image file extensions
'jpeg',
'jpg',
'gif',
'webp',
'png',
'ico',
'tiff',
'tif',
'bmp',
]);