adobe/brackets

View on GitHub
src/filesystem/FileSystemEntry.js

Summary

Maintainability
F
4 days
Test Coverage
/*
 * Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/*
 * To ensure cache coherence, current and future asynchronous state-changing
 * operations of FileSystemEntry and its subclasses should implement the
 * following high-level sequence of steps:
 *
 * 1. Block external filesystem change events;
 * 2. Execute the low-level state-changing operation;
 * 3. Update the internal filesystem state, including caches;
 * 4. Apply the callback;
 * 5. Fire an appropriate internal change notification; and
 * 6. Unblock external change events.
 *
 * Note that because internal filesystem state is updated first, both the original
 * caller and the change notification listeners observe filesystem state that is
 * current w.r.t. the operation. Furthermore, because external change events are
 * blocked before the operation begins, listeners will only receive the internal
 * change event for the operation and not additional (or possibly inconsistent)
 * external change events.
 *
 * State-changing operations that block external filesystem change events must
 * take care to always subsequently unblock the external change events in all
 * control paths. It is safe to assume, however, that the underlying impl will
 * always apply the callback with some value.

 * Caches should be conservative. Consequently, the entry's cached data should
 * always be cleared if the underlying impl's operation fails. This is the case
 * event for read-only operations because an unexpected failure implies that the
 * system is in an unknown state. The entry should communicate this by failing
 * where appropriate, and should not use the cache to hide failure.
 *
 * Only watched entries should make use of cached data because change events are
 * only expected for such entries, and change events are used to granularly
 * invalidate out-of-date caches.
 *
 * By convention, callbacks are optional for asynchronous, state-changing
 * operations, but required for read-only operations. The first argument to the
 * callback should always be a nullable error string from FileSystemError.
 */
define(function (require, exports, module) {
    "use strict";

    var FileSystemError = require("filesystem/FileSystemError"),
        WatchedRoot     = require("filesystem/WatchedRoot");

    var VISIT_DEFAULT_MAX_DEPTH = 100,
        VISIT_DEFAULT_MAX_ENTRIES = 30000;

    /* Counter to give every entry a unique id */
    var nextId = 0;

    /**
     * Model for a file system entry. This is the base class for File and Directory,
     * and is never used directly.
     *
     * See the File, Directory, and FileSystem classes for more details.
     *
     * @constructor
     * @param {string} path The path for this entry.
     * @param {FileSystem} fileSystem The file system associated with this entry.
     */
    function FileSystemEntry(path, fileSystem) {
        this._setPath(path);
        this._fileSystem = fileSystem;
        this._id = nextId++;
    }

    // Add "fullPath", "name", "parent", "id", "isFile" and "isDirectory" getters
    Object.defineProperties(FileSystemEntry.prototype, {
        "fullPath": {
            get: function () { return this._path; },
            set: function () { throw new Error("Cannot set fullPath"); }
        },
        "name": {
            get: function () { return this._name; },
            set: function () { throw new Error("Cannot set name"); }
        },
        "parentPath": {
            get: function () { return this._parentPath; },
            set: function () { throw new Error("Cannot set parentPath"); }
        },
        "id": {
            get: function () { return this._id; },
            set: function () { throw new Error("Cannot set id"); }
        },
        "isFile": {
            get: function () { return this._isFile; },
            set: function () { throw new Error("Cannot set isFile"); }
        },
        "isDirectory": {
            get: function () { return this._isDirectory; },
            set: function () { throw new Error("Cannot set isDirectory"); }
        },
        "_impl": {
            get: function () { return this._fileSystem._impl; },
            set: function () { throw new Error("Cannot set _impl"); }
        }
    });

    /**
     * Cached stat object for this file.
     * @type {?FileSystemStats}
     */
    FileSystemEntry.prototype._stat = null;

    /**
     * Parent file system.
     * @type {!FileSystem}
     */
    FileSystemEntry.prototype._fileSystem = null;

    /**
     * The path of this entry.
     * @type {string}
     */
    FileSystemEntry.prototype._path = null;

    /**
     * The name of this entry.
     * @type {string}
     */
    FileSystemEntry.prototype._name = null;

    /**
     * The parent of this entry.
     * @type {string}
     */
    FileSystemEntry.prototype._parentPath = null;

    /**
     * Whether or not the entry is a file
     * @type {boolean}
     */
    FileSystemEntry.prototype._isFile = false;

    /**
     * Whether or not the entry is a directory
     * @type {boolean}
     */
    FileSystemEntry.prototype._isDirectory = false;

    /**
     * Cached copy of this entry's watched root
     * @type {entry: File|Directory, filter: function(FileSystemEntry):boolean, active: boolean}
     */
    FileSystemEntry.prototype._watchedRoot = undefined;

    /**
     * Cached result of _watchedRoot.filter(this.name, this.parentPath).
     * @type {boolean}
     */
    FileSystemEntry.prototype._watchedRootFilterResult = undefined;

    /**
     * Determines whether or not the entry is watched.
     * @param {boolean=} relaxed If falsey, the method will only return true if
     *      the watched root is fully active. If true, the method will return
     *      true if the watched root is either starting up or fully active.
     * @return {boolean}
     */
    FileSystemEntry.prototype._isWatched = function (relaxed) {
        var watchedRoot = this._watchedRoot,
            filterResult = this._watchedRootFilterResult;

        if (!watchedRoot) {
            watchedRoot = this._fileSystem._findWatchedRootForPath(this._path);

            if (watchedRoot) {
                this._watchedRoot = watchedRoot;
                if (watchedRoot.entry !== this) { // avoid creating entries for root's parent
                    var parentEntry = this._fileSystem.getDirectoryForPath(this._parentPath);
                    if (parentEntry._isWatched() === false) {
                        filterResult = false;
                    } else {
                        filterResult = watchedRoot.filter(this._name, this._parentPath);
                    }
                } else { // root itself is watched
                    filterResult = true;
                }
                this._watchedRootFilterResult = filterResult;
            }
        }

        if (watchedRoot) {
            if (watchedRoot.status === WatchedRoot.ACTIVE ||
                    (relaxed && watchedRoot.status === WatchedRoot.STARTING)) {
                return filterResult;
            } else {
                // We had a watched root, but it's no longer active, so it must now be invalid.
                this._watchedRoot = undefined;
                this._watchedRootFilterResult = false;
                this._clearCachedData();
            }
        }
        return false;
    };

    /**
     * Update the path for this entry
     * @private
     * @param {String} newPath
     */
    FileSystemEntry.prototype._setPath = function (newPath) {
        var parts = newPath.split("/");
        if (this.isDirectory) {
            parts.pop(); // Remove the empty string after last trailing "/"
        }
        this._name = parts[parts.length - 1];
        parts.pop(); // Remove name

        if (parts.length > 0) {
            this._parentPath = parts.join("/") + "/";
        } else {
            // root directories have no parent path
            this._parentPath = null;
        }

        this._path = newPath;

        var watchedRoot = this._watchedRoot;
        if (watchedRoot) {
            if (newPath.indexOf(watchedRoot.entry.fullPath) === 0) {
                // Update watchedRootFilterResult
                this._watchedRootFilterResult = watchedRoot.filter(this._name, this._parentPath);
            } else {
                // The entry was moved outside of the watched root
                this._watchedRoot = null;
                this._watchedRootFilterResult = false;
            }
        }
    };

    /**
     * Clear any cached data for this entry
     * @private
     */
    FileSystemEntry.prototype._clearCachedData = function () {
        this._stat = undefined;
    };

    /**
     * Helpful toString for debugging purposes
     */
    FileSystemEntry.prototype.toString = function () {
        return "[" + (this.isDirectory ? "Directory " : "File ") + this._path + "]";
    };

    /**
     * Check to see if the entry exists on disk. Note that there will NOT be an
     * error returned if the file does not exist on the disk; in that case the
     * error parameter will be null and the boolean will be false. The error
     * parameter will only be truthy when an unexpected error was encountered
     * during the test, in which case the state of the entry should be considered
     * unknown.
     *
     * @param {function (?string, boolean)} callback Callback with a FileSystemError
     *      string or a boolean indicating whether or not the file exists.
     */
    FileSystemEntry.prototype.exists = function (callback) {
        if (this._stat) {
            callback(null, true);
            return;
        }

        this._impl.exists(this._path, function (err, exists) {
            if (err) {
                this._clearCachedData();
                callback(err);
                return;
            }

            if (!exists) {
                this._clearCachedData();
            }

            callback(null, exists);
        }.bind(this));
    };

    /**
     * Returns the stats for the entry.
     *
     * @param {function (?string, FileSystemStats=)} callback Callback with a
     *      FileSystemError string or FileSystemStats object.
     */
    FileSystemEntry.prototype.stat = function (callback) {
        if (this._stat) {
            callback(null, this._stat);
            return;
        }

        this._impl.stat(this._path, function (err, stat) {
            if (err) {
                this._clearCachedData();
                callback(err);
                return;
            }

            if (this._isWatched()) {
                this._stat = stat;
            }

            callback(null, stat);
        }.bind(this));
    };

    /**
     * Rename this entry.
     *
     * @param {string} newFullPath New path & name for this entry.
     * @param {function (?string)=} callback Callback with a single FileSystemError
     *      string parameter.
     */
    FileSystemEntry.prototype.rename = function (newFullPath, callback) {
        callback = callback || function () {};

        // Block external change events until after the write has finished
        this._fileSystem._beginChange();

        this._impl.rename(this._path, newFullPath, function (err) {
            var oldFullPath = this._path;

            try {
                if (err) {
                    this._clearCachedData();
                    callback(err);
                    return;
                }

                // Update internal filesystem state
                this._fileSystem._handleRename(this._path, newFullPath, this.isDirectory);

                try {
                    // Notify the caller
                    callback(null);
                } finally {
                    // Notify rename listeners
                    this._fileSystem._fireRenameEvent(oldFullPath, newFullPath);
                }
            } finally {
                // Unblock external change events
                this._fileSystem._endChange();
            }
        }.bind(this));
    };

    /**
     * Permanently delete this entry. For Directories, this will delete the directory
     * and all of its contents. For reversible delete, see moveToTrash().
     *
     * @param {function (?string)=} callback Callback with a single FileSystemError
     *      string parameter.
     */
    FileSystemEntry.prototype.unlink = function (callback) {
        callback = callback || function () {};

        // Block external change events until after the write has finished
        this._fileSystem._beginChange();

        this._clearCachedData();
        this._impl.unlink(this._path, function (err) {
            var parent = this._fileSystem.getDirectoryForPath(this.parentPath);

            // Update internal filesystem state
            this._fileSystem._handleDirectoryChange(parent, function (added, removed) {
                try {
                    // Notify the caller
                    callback(err);
                } finally {
                    if (parent._isWatched()) {
                        // Notify change listeners
                        this._fileSystem._fireChangeEvent(parent, added, removed);
                    }

                    // Unblock external change events
                    this._fileSystem._endChange();
                }
            }.bind(this));
        }.bind(this));
    };

    /**
     * Move this entry to the trash. If the underlying file system doesn't support move
     * to trash, the item is permanently deleted.
     *
     * @param {function (?string)=} callback Callback with a single FileSystemError
     *      string parameter.
     */
    FileSystemEntry.prototype.moveToTrash = function (callback) {
        if (!this._impl.moveToTrash) {
            this.unlink(callback);
            return;
        }

        callback = callback || function () {};

        // Block external change events until after the write has finished
        this._fileSystem._beginChange();

        this._clearCachedData();
        this._impl.moveToTrash(this._path, function (err) {
            var parent = this._fileSystem.getDirectoryForPath(this.parentPath);

            // Update internal filesystem state
            this._fileSystem._handleDirectoryChange(parent, function (added, removed) {
                try {
                    // Notify the caller
                    callback(err);
                } finally {
                    if (parent._isWatched()) {
                        // Notify change listeners
                        this._fileSystem._fireChangeEvent(parent, added, removed);
                    }

                    // Unblock external change events
                    this._fileSystem._endChange();
                }
            }.bind(this));
        }.bind(this));
    };

    /**
     * Private helper function for FileSystemEntry.visit that requires sanitized options.
     *
     * @private
     * @param {FileSystemStats} stats - the stats for this entry
     * @param {{string: boolean}} visitedPaths - the set of fullPaths that have already been visited
     * @param {function(FileSystemEntry): boolean} visitor - A visitor function, which is
     *      applied to descendent FileSystemEntry objects. If the function returns false for
     *      a particular Directory entry, that directory's descendents will not be visited.
     * @param {{maxDepth: number, maxEntriesCounter: {value: number}, sortList: boolean}} options
     * @param {function(?string)=} callback Callback with single FileSystemError string parameter.
     */
    FileSystemEntry.prototype._visitHelper = function (stats, visitedPaths, visitor, options, callback) {
        var maxDepth = options.maxDepth,
            maxEntriesCounter = options.maxEntriesCounter,
            sortList = options.sortList;

        if (maxEntriesCounter.value-- <= 0 || maxDepth-- < 0) {
            // The outer FileSystemEntry.visit call is responsible for applying
            // the main callback to FileSystemError.TOO_MANY_FILES in this case
            callback(null);
            return;
        }

        if (this.isDirectory) {
            var visitedPath = stats.realPath || this.fullPath;

            if (visitedPaths.hasOwnProperty(visitedPath)) {
                // Link cycle detected
                callback(null);
                return;
            }

            visitedPaths[visitedPath] = true;
        }

        if (!visitor(this) || this.isFile) {
            callback(null);
            return;
        }

        this.getContents(function (err, entries, entriesStats) {
            if (err) {
                callback(err);
                return;
            }

            var counter = entries.length;
            if (counter === 0) {
                callback(null);
                return;
            }

            function helperCallback(err) {
                if (--counter === 0) {
                    callback(null);
                }
            }

            var nextOptions = {
                maxDepth: maxDepth,
                maxEntriesCounter: maxEntriesCounter,
                sortList : sortList
            };

            //sort entries if required
            function compareFilesWithIndices(index1, index2) {
                return entries[index1]._name.toLocaleLowerCase().localeCompare(entries[index2]._name.toLocaleLowerCase());
            }
            if (sortList) {
                var fileIndexes = [], i = 0;
                for (i = 0; i < entries.length; i++) {
                    fileIndexes[i] = i;
                }
                fileIndexes.sort(compareFilesWithIndices);
                fileIndexes.forEach(function (fileIndex) {
                    var stats = entriesStats[fileIndexes[fileIndex]];
                    entries[fileIndexes[fileIndex]]._visitHelper(stats, visitedPaths, visitor, nextOptions, helperCallback);
                });
            } else {
                entries.forEach(function (entry, index) {
                    var stats = entriesStats[index];
                    entry._visitHelper(stats, visitedPaths, visitor, nextOptions, helperCallback);
                });
            }
        }.bind(this));
    };

    /**
     * Visit this entry and its descendents with the supplied visitor function.
     * Correctly handles symbolic link cycles and options can be provided to limit
     * search depth and total number of entries visited. No particular traversal
     * order is guaranteed; instead of relying on such an order, it is preferable
     * to use the visit function to build a list of visited entries, sort those
     * entries as desired, and then process them. Whenever possible, deep
     * filesystem traversals should use this method.
     *
     * @param {function(FileSystemEntry): boolean} visitor - A visitor function, which is
     *      applied to this entry and all descendent FileSystemEntry objects. If the function returns
     *      false for a particular Directory entry, that directory's descendents will not be visited.
     * @param {{maxDepth: number=, maxEntries: number=}=} options
     * @param {function(?string)=} callback Callback with single FileSystemError string parameter.
     */
    FileSystemEntry.prototype.visit = function (visitor, options, callback) {
        if (typeof options === "function") {
            callback = options;
            options = {};
        } else {
            if (options === undefined) {
                options = {};
            }

            callback = callback || function () {};
        }

        if (options.maxDepth === undefined) {
            options.maxDepth = VISIT_DEFAULT_MAX_DEPTH;
        }

        if (options.maxEntries === undefined) {
            options.maxEntries = VISIT_DEFAULT_MAX_ENTRIES;
        }

        options.maxEntriesCounter = { value: options.maxEntries };

        this.stat(function (err, stats) {
            if (err) {
                callback(err);
                return;
            }

            this._visitHelper(stats, {}, visitor, options, function (err) {
                if (callback) {
                    if (err) {
                        callback(err);
                        return;
                    }

                    if (options.maxEntriesCounter.value < 0) {
                        callback(FileSystemError.TOO_MANY_ENTRIES);
                        return;
                    }

                    callback(null);
                }
            }.bind(this));
        }.bind(this));
    };

    // Export this class
    module.exports = FileSystemEntry;
});