adobe/brackets

View on GitHub
src/filesystem/FileSystem.js

Summary

Maintainability
D
2 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.
 *
 */

/**
 * FileSystem is a model object representing a complete file system. This object creates
 * and manages File and Directory instances, dispatches events when the file system changes,
 * and provides methods for showing 'open' and 'save' dialogs.
 *
 * FileSystem automatically initializes when loaded. It depends on a pluggable "impl" layer, which
 * it loads itself but must be designated in the require.config() that loads FileSystem. For details
 * see: https://github.com/adobe/brackets/wiki/File-System-Implementations
 *
 * There are three ways to get File or Directory instances:
 *    * Use FileSystem.resolve() to convert a path to a File/Directory object. This will only
 *      succeed if the file/directory already exists.
 *    * Use FileSystem.getFileForPath()/FileSystem.getDirectoryForPath() if you know the
 *      file/directory already exists, or if you want to create a new entry.
 *    * Use Directory.getContents() to return all entries for the specified Directory.
 *
 * All paths passed *to* FileSystem APIs must be in the following format:
 *    * The path separator is "/" regardless of platform
 *    * Paths begin with "/" on Mac/Linux and "c:/" (or some other drive letter) on Windows
 *
 * All paths returned *from* FileSystem APIs additionally meet the following guarantees:
 *    * No ".." segments
 *    * No consecutive "/"s
 *    * Paths to a directory always end with a trailing "/"
 * (Because FileSystem normalizes paths automatically, paths passed *to* FileSystem do not need
 * to meet these requirements)
 *
 * FileSystem dispatches the following events:
 * (NOTE: attach to these events via `FileSystem.on()` - not `$(FileSystem).on()`)
 *
 * __change__ - Sent whenever there is a change in the file system. The handler
 *   is passed up to three arguments: the changed entry and, if that changed entry
 *   is a Directory, a list of entries added to the directory and a list of entries
 *   removed from the Directory. The entry argument can be:
 *   *  a File - the contents of the file have changed, and should be reloaded.
 *   *  a Directory - an immediate child of the directory has been added, removed,
 *      or renamed/moved. Not triggered for "grandchildren".
 *      - If the added & removed arguments are null, we don't know what was added/removed:
 *        clients should assume the whole subtree may have changed.
 *      - If the added & removed arguments are 0-length, there's no net change in the set
 *        of files but a file may have been replaced: clients should assume the contents
 *        of any immediate child file may have changed.
 *   *  null - a 'wholesale' change happened, and you should assume everything may
 *      have changed.
 *   For changes made externally, there may be a significant delay before a "change" event
 *   is dispatched.
 *
 * __rename__ - Sent whenever a File or Directory is renamed. All affected File and Directory
 *   objects have been updated to reflect the new path by the time this event is dispatched.
 *   This event should be used to trigger any UI updates that may need to occur when a path
 *   has changed. Note that these events will only be sent for rename operations that happen
 *   within the filesystem. If a file is renamed externally, a change event on the parent
 *   directory will be sent instead.
 *
 * FileSystem may perform caching. But it guarantees:
 *    * File contents & metadata - reads are guaranteed to be up to date (cached data is not used
 *      without first veryifying it is up to date).
 *    * Directory structure / file listing - reads may return cached data immediately, which may not
 *      reflect external changes made recently. (However, changes made via FileSystem itself are always
 *      reflected immediately, as soon as the change operation's callback signals success).
 *
 * The FileSystem doesn't directly read or write contents--this work is done by a low-level
 * implementation object. This allows client code to use the FileSystem API without having to
 * worry about the underlying storage, which could be a local filesystem or a remote server.
 */
define(function (require, exports, module) {
    "use strict";

    var Directory       = require("filesystem/Directory"),
        File            = require("filesystem/File"),
        FileIndex       = require("filesystem/FileIndex"),
        FileSystemError = require("filesystem/FileSystemError"),
        WatchedRoot     = require("filesystem/WatchedRoot"),
        EventDispatcher = require("utils/EventDispatcher"),
        PathUtils       = require("thirdparty/path-utils/path-utils"),
        _               = require("thirdparty/lodash");


    // Collection of registered protocol adapters
    var _fileProtocolPlugins = {};

    /**
     * Typical signature of a file protocol adapter.
     * @typedef {Object} FileProtocol~Adapter
     * @property {Number} priority - Indicates the priority.
     * @property {Object} fileImpl - Handle for the custom file implementation prototype.
     * @property {function} canRead - To check if this impl can read a file for a given path.
     */

    /**
     * FileSystem hook to register file protocol adapter
     * @param {string} protocol ex: "https:"|"http:"|"ftp:"|"file:"
     * @param {...FileProtocol~Adapter} adapter wrapper over file implementation
     */
    function registerProtocolAdapter(protocol, adapter) {
        var adapters;
        if (protocol) {
            adapters = _fileProtocolPlugins[protocol] || [];
            adapters.push(adapter);

            // We will keep a sorted adapter list on 'priority'
            // If priority is not provided a default of '0' is assumed
            adapters.sort(function (a, b) {
                return (b.priority || 0) - (a.priority || 0);
            });

            _fileProtocolPlugins[protocol] = adapters;
        }
    }

    /**
     * @param {string} protocol ex: "https:"|"http:"|"ftp:"|"file:"
     * @param {string} filePath fullPath of the file
     * @return adapter adapter wrapper over file implementation
     */
    function _getProtocolAdapter(protocol, filePath) {
        var protocolAdapters = _fileProtocolPlugins[protocol] || [],
            selectedAdapter;

        // Find the fisrt compatible adapter having highest priority
        _.forEach(protocolAdapters, function (adapter) {
            if (adapter.canRead && adapter.canRead(filePath)) {
                selectedAdapter = adapter;
                // Break at first compatible adapter
                return false;
            }
        });

        return selectedAdapter;
    }

    /**
     * The FileSystem is not usable until init() signals its callback.
     * @constructor
     */
    function FileSystem() {
        // Create a file index
        this._index = new FileIndex();

        // Initialize the set of watched roots
        this._watchedRoots = {};

        // Initialize the watch/unwatch request queue
        this._watchRequests = [];

        // Initialize the queue of pending external changes
        this._externalChanges = [];
    }
    EventDispatcher.makeEventDispatcher(FileSystem.prototype);

    /**
     * The low-level file system implementation used by this object.
     * This is set in the init() function and cannot be changed.
     */
    FileSystem.prototype._impl = null;

    /**
     * The FileIndex used by this object. This is initialized in the constructor.
     */
    FileSystem.prototype._index = null;

    /**
     * Refcount of any pending filesystem mutation operations (e.g., writes,
     * unlinks, etc.). Used to ensure that external change events aren't processed
     * until after index fixups, operation-specific callbacks, and internal change
     * events are complete. (This is important for distinguishing rename from
     * an unrelated delete-add pair).
     * @type {number}
     */
    FileSystem.prototype._activeChangeCount = 0;

    // For unit testing only
    FileSystem.prototype._getActiveChangeCount = function () {
        return this._activeChangeCount;
    };

    /**
     * Queue of arguments with which to invoke _handleExternalChanges(); triggered
     * once _activeChangeCount drops to zero.
     * @type {!Array.<{path:?string, stat:FileSystemStats=}>}
     */
    FileSystem.prototype._externalChanges = null;

    /** Process all queued watcher results, by calling _handleExternalChange() on each */
    FileSystem.prototype._triggerExternalChangesNow = function () {
        this._externalChanges.forEach(function (info) {
            this._handleExternalChange(info.path, info.stat);
        }, this);
        this._externalChanges.length = 0;
    };

    /**
     * Receives a result from the impl's watcher callback, and either processes it
     * immediately (if _activeChangeCount is 0) or otherwise stores it for later
     * processing.
     * @param {?string} path The fullPath of the changed entry
     * @param {FileSystemStats=} stat An optional stat object for the changed entry
     */
    FileSystem.prototype._enqueueExternalChange = function (path, stat) {
        this._externalChanges.push({path: path, stat: stat});
        if (!this._activeChangeCount) {
            this._triggerExternalChangesNow();
        }
    };


    /**
     * The queue of pending watch/unwatch requests.
     * @type {Array.<{fn: function(), cb: function()}>}
     */
    FileSystem.prototype._watchRequests = null;

    /**
     * Dequeue and process all pending watch/unwatch requests
     */
    FileSystem.prototype._dequeueWatchRequest = function () {
        if (this._watchRequests.length > 0) {
            var request = this._watchRequests[0];

            request.fn.call(null, function () {
                // Apply the given callback
                var callbackArgs = arguments;
                try {
                    request.cb.apply(null, callbackArgs);
                } finally {
                    // Process the remaining watch/unwatch requests
                    this._watchRequests.shift();
                    this._dequeueWatchRequest();
                }
            }.bind(this));
        }
    };

    /**
     * Enqueue a new watch/unwatch request.
     *
     * @param {function()} fn - The watch/unwatch request function.
     * @param {callback()} cb - The callback for the provided watch/unwatch
     *      request function.
     */
    FileSystem.prototype._enqueueWatchRequest = function (fn, cb) {
        // Enqueue the given watch/unwatch request
        this._watchRequests.push({fn: fn, cb: cb});

        // Begin processing the queue if it is not already being processed
        if (this._watchRequests.length === 1) {
            this._dequeueWatchRequest();
        }
    };

    /**
     * The set of watched roots, encoded as a mapping from full paths to WatchedRoot
     * objects which contain a file entry, filter function, and an indication of
     * whether the watched root is inactive, starting up or fully active.
     *
     * @type {Object.<string, WatchedRoot>}
     */
    FileSystem.prototype._watchedRoots = null;

    /**
     * Finds a parent watched root for a given path, or returns null if a parent
     * watched root does not exist.
     *
     * @param {string} fullPath The child path for which a parent watched root is to be found
     * @return {?{entry: FileSystemEntry, filter: function(string) boolean}} The parent
     *      watched root, if it exists, or null.
     */
    FileSystem.prototype._findWatchedRootForPath = function (fullPath) {
        var watchedRoot = null;

        Object.keys(this._watchedRoots).some(function (watchedPath) {
            if (fullPath.indexOf(watchedPath) === 0) {
                watchedRoot = this._watchedRoots[watchedPath];
                return true;
            }
        }, this);

        return watchedRoot;
    };

    /**
     * Helper function to watch or unwatch a filesystem entry beneath a given
     * watchedRoot.
     *
     * @private
     * @param {FileSystemEntry} entry - The FileSystemEntry to watch. Must be a
     *      non-strict descendent of watchedRoot.entry.
     * @param {WatchedRoot} watchedRoot - See FileSystem._watchedRoots.
     * @param {function(?string)} callback - A function that is called once the
     *      watch is complete, possibly with a FileSystemError string.
     * @param {boolean} shouldWatch - Whether the entry should be watched (true)
     *      or unwatched (false).
     */
    FileSystem.prototype._watchOrUnwatchEntry = function (entry, watchedRoot, callback, shouldWatch) {
        var impl = this._impl,
            recursiveWatch = impl.recursiveWatch,
            commandName = shouldWatch ? "watchPath" : "unwatchPath",
            filterGlobs = watchedRoot.filterGlobs;

        if (recursiveWatch) {
            // The impl can watch the entire subtree with one call on the root (we also fall into this case for
            // unwatch, although that never requires us to do the recursion - see similar final case below)
            if (entry !== watchedRoot.entry) {
                // Watch and unwatch calls to children of the watched root are
                // no-ops if the impl supports recursiveWatch
                callback(null);
            } else {
                // The impl will handle finding all subdirectories to watch.
                this._enqueueWatchRequest(function (requestCb) {
                    impl[commandName].call(impl, entry.fullPath, filterGlobs, requestCb);
                }.bind(this), callback);
            }
        } else if (shouldWatch) {
            // The impl can't handle recursive watch requests, so it's up to the
            // filesystem to recursively watch all subdirectories.
            this._enqueueWatchRequest(function (requestCb) {
                // First construct a list of entries to watch or unwatch
                var entriesToWatch = [];

                var visitor = function (child) {
                    if (watchedRoot.filter(child.name, child.parentPath)) {
                        if (child.isDirectory || child === watchedRoot.entry) {
                            entriesToWatch.push(child);
                        }
                        return true;
                    }
                    return false;
                };

                entry.visit(visitor, function (err) {
                    if (err) {
                        // Unexpected error
                        requestCb(err);
                        return;
                    }

                    // Then watch or unwatched all these entries
                    var count = entriesToWatch.length;
                    if (count === 0) {
                        requestCb(null);
                        return;
                    }

                    var watchCallback = function () {
                        if (--count === 0) {
                            requestCb(null);
                        }
                    };

                    entriesToWatch.forEach(function (entry) {
                        impl.watchPath(entry.fullPath, filterGlobs, watchCallback);
                    });
                });
            }, callback);
        } else {
            // Unwatching never requires enumerating the subfolders (which is good, since after a
            // delete/rename we may be unable to do so anyway)
            this._enqueueWatchRequest(function (requestCb) {
                impl.unwatchPath(entry.fullPath, requestCb);
            }, callback);
        }
    };

    /**
     * Watch a filesystem entry beneath a given watchedRoot.
     *
     * @private
     * @param {FileSystemEntry} entry - The FileSystemEntry to watch. Must be a
     *      non-strict descendent of watchedRoot.entry.
     * @param {WatchedRoot} watchedRoot - See FileSystem._watchedRoots.
     * @param {function(?string)} callback - A function that is called once the
     *      watch is complete, possibly with a FileSystemError string.
     */
    FileSystem.prototype._watchEntry = function (entry, watchedRoot, callback) {
        this._watchOrUnwatchEntry(entry, watchedRoot, callback, true);
    };

    /**
     * Unwatch a filesystem entry beneath a given watchedRoot.
     *
     * @private
     * @param {FileSystemEntry} entry - The FileSystemEntry to watch. Must be a
     *      non-strict descendent of watchedRoot.entry.
     * @param {WatchedRoot} watchedRoot - See FileSystem._watchedRoots.
     * @param {function(?string)} callback - A function that is called once the
     *      watch is complete, possibly with a FileSystemError string.
     */
    FileSystem.prototype._unwatchEntry = function (entry, watchedRoot, callback) {
        this._watchOrUnwatchEntry(entry, watchedRoot, function (err) {
            // Make sure to clear cached data for all unwatched entries because
            // entries always return cached data if it exists!
            this._index.visitAll(function (child) {
                if (child.fullPath.indexOf(entry.fullPath) === 0) {
                    // 'true' so entry doesn't try to clear its immediate childrens' caches too. That would be redundant
                    // with the visitAll() here, and could be slow if we've already cleared its parent (#7150).
                    child._clearCachedData(true);
                }
            }.bind(this));

            callback(err);
        }.bind(this), false);
    };

    /**
     * Initialize this FileSystem instance.
     *
     * @param {FileSystemImpl} impl The back-end implementation for this
     *      FileSystem instance.
     */
    FileSystem.prototype.init = function (impl) {
        console.assert(!this._impl, "This FileSystem has already been initialized!");

        var changeCallback = this._enqueueExternalChange.bind(this),
            offlineCallback = this._unwatchAll.bind(this);

        this._impl = impl;
        this._impl.initWatchers(changeCallback, offlineCallback);
    };

    /**
     * Close a file system. Clear all caches, indexes, and file watchers.
     */
    FileSystem.prototype.close = function () {
        this._impl.unwatchAll();
        this._index.clear();
    };

    /**
     * Returns true if the given path should be automatically added to the index & watch list when one of its ancestors
     * is a watch-root. (Files are added automatically when the watch-root is first established, or later when a new
     * directory is created and its children enumerated).
     *
     * Entries explicitly created via FileSystem.getFile/DirectoryForPath() are *always* added to the index regardless
     * of this filtering - but they will not be watched if the watch-root's filter excludes them.
     *
     * @param {string} path Full path
     * @param {string} name Name portion of the path
     */
    FileSystem.prototype._indexFilter = function (path, name) {
        var parentRoot = this._findWatchedRootForPath(path);

        if (parentRoot) {
            return parentRoot.filter(name, path);
        }

        // It might seem more sensible to return false (exclude) for files outside the watch roots, but
        // that would break usage of appFileSystem for 'system'-level things like enumerating extensions.
        // (Or in general, Directory.getContents() for any Directory outside the watch roots).
        return true;
    };

    /**
     * Indicates that a filesystem-mutating operation has begun. As long as there
     * are changes taking place, change events from the external watchers are
     * blocked and queued, to be handled once changes have finished. This is done
     * because for mutating operations that originate from within the filesystem,
     * synthetic change events are fired that do not depend on external file
     * watchers, and we prefer the former over the latter for the following
     * reasons: 1) there is no delay; and 2) they may have higher fidelity ---
     * e.g., a rename operation can be detected as such, instead of as a nearly
     * simultaneous addition and deletion.
     *
     * All operations that mutate the file system MUST begin with a call to
     * _beginChange and must end with a call to _endChange.
     */
    FileSystem.prototype._beginChange = function () {
        this._activeChangeCount++;
        //console.log("> beginChange  -> " + this._activeChangeCount);
    };

    /**
     * Indicates that a filesystem-mutating operation has completed. See
     * FileSystem._beginChange above.
     */
    FileSystem.prototype._endChange = function () {
        this._activeChangeCount--;
        //console.log("< endChange    -> " + this._activeChangeCount);

        if (this._activeChangeCount < 0) {
            console.error("FileSystem _activeChangeCount has fallen below zero!");
        }

        if (!this._activeChangeCount) {
            this._triggerExternalChangesNow();
        }
    };

    /**
     * Determines whether or not the supplied path is absolute, as opposed to relative.
     *
     * @param {!string} fullPath
     * @return {boolean} True if the fullPath is absolute and false otherwise.
     */
    FileSystem.isAbsolutePath = function (fullPath) {
        return (fullPath[0] === "/" || (fullPath[1] === ":" && fullPath[2] === "/"));
    };

    function _ensureTrailingSlash(path) {
        if (path[path.length - 1] !== "/") {
            path += "/";
        }

        return path;
    }

    /*
     * Matches continguous groups of forward slashes
     * @const
     */
    var _DUPLICATED_SLASH_RE = /\/{2,}/g;

    /**
     * Returns a canonical version of the path: no duplicated "/"es, no ".."s,
     * and directories guaranteed to end in a trailing "/"
     * @param {!string} path  Absolute path, using "/" as path separator
     * @param {boolean=} isDirectory
     * @return {!string}
     */
    FileSystem.prototype._normalizePath = function (path, isDirectory) {

        if (!FileSystem.isAbsolutePath(path)) {
            throw new Error("Paths must be absolute: '" + path + "'");  // expect only absolute paths
        }

        var isUNCPath = this._impl.normalizeUNCPaths && path.search(_DUPLICATED_SLASH_RE) === 0;

        // Remove duplicated "/"es
        path = path.replace(_DUPLICATED_SLASH_RE, "/");

        // Remove ".." segments
        if (path.indexOf("..") !== -1) {
            var segments = path.split("/"),
                i;
            for (i = 1; i < segments.length; i++) {
                if (segments[i] === "..") {
                    if (i < 2) {
                        throw new Error("Invalid absolute path: '" + path + "'");
                    }
                    segments.splice(i - 1, 2);
                    i -= 2; // compensate so we start on the right index next iteration
                }
            }
            path = segments.join("/");
        }

        if (isDirectory) {
            // Make sure path DOES include trailing slash
            path = _ensureTrailingSlash(path);
        }

        if (isUNCPath) {
            // Restore the leading double slash that was removed previously
            path = "/" + path;
        }

        return path;
    };

    /**
     * This method adds an entry for a file in the file Index. Files on disk are added
     * to the file index either on load or on open. This method is primarily needed to add
     * in memory files to the index
     *
     * @param {File} The fileEntry which needs to be added
     * @param {String} The full path to the file
     */
    FileSystem.prototype.addEntryForPathIfRequired = function (fileEntry, path) {
        var entry = this._index.getEntry(path);

        if (!entry) {
            this._index.addEntry(fileEntry);
        }
    };

    /**
     * Return a (strict subclass of a) FileSystemEntry object for the specified
     * path using the provided constuctor. For now, the provided constructor
     * should be either File or Directory.
     *
     * @private
     * @param {function(string, FileSystem)} EntryConstructor Constructor with
     *      which to initialize new FileSystemEntry objects.
     * @param {string} path Absolute path of file.
     * @return {File|Directory} The File or Directory object. This file may not
     *      yet exist on disk.
     */
    FileSystem.prototype._getEntryForPath = function (EntryConstructor, path) {
        var isDirectory = EntryConstructor === Directory;
        path = this._normalizePath(path, isDirectory);
        var entry = this._index.getEntry(path);

        if (!entry) {
            entry = new EntryConstructor(path, this);
            this._index.addEntry(entry);
        }

        return entry;
    };

    /**
     * Return a File object for the specified path.
     *
     * @param {string} path Absolute path of file.
     *
     * @return {File} The File object. This file may not yet exist on disk.
     */
    FileSystem.prototype.getFileForPath = function (path) {
        var protocol = PathUtils.parseUrl(path).protocol,
            protocolAdapter = _getProtocolAdapter(protocol);

        if (protocolAdapter && protocolAdapter.fileImpl) {
            return new protocolAdapter.fileImpl(protocol, path, this);
        } else {
            return this._getEntryForPath(File, path);
        }
    };

    /**
     * Return a Directory object for the specified path.
     *
     * @param {string} path Absolute path of directory.
     *
     * @return {Directory} The Directory object. This directory may not yet exist on disk.
     */
    FileSystem.prototype.getDirectoryForPath = function (path) {
        return this._getEntryForPath(Directory, path);
    };

    /**
     * Resolve a path.
     *
     * @param {string} path The path to resolve
     * @param {function (?string, FileSystemEntry=, FileSystemStats=)} callback Callback resolved
     *      with a FileSystemError string or with the entry for the provided path.
     */
    FileSystem.prototype.resolve = function (path, callback) {
        var normalizedPath = this._normalizePath(path, false),
            item = this._index.getEntry(normalizedPath);

        if (!item) {
            normalizedPath = _ensureTrailingSlash(normalizedPath);
            item = this._index.getEntry(normalizedPath);
        }

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

                callback(null, item, stat);
            });
        } else {
            this._impl.stat(path, function (err, stat) {
                if (err) {
                    callback(err);
                    return;
                }

                if (stat.isFile) {
                    item = this.getFileForPath(path);
                } else {
                    item = this.getDirectoryForPath(path);
                }

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

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

    /**
     * Show an "Open" dialog and return the file(s)/directories selected by the user.
     *
     * @param {boolean} allowMultipleSelection Allows selecting more than one file at a time
     * @param {boolean} chooseDirectories Allows directories to be opened
     * @param {string} title The title of the dialog
     * @param {string} initialPath The folder opened inside the window initially. If initialPath
     *                          is not set, or it doesn't exist, the window would show the last
     *                          browsed folder depending on the OS preferences
     * @param {?Array.<string>} fileTypes (Currently *ignored* except on Mac - https://trello.com/c/430aXkpq)
     *                          List of extensions that are allowed to be opened, without leading ".".
     *                          Null or empty array allows all files to be selected. Not applicable
     *                          when chooseDirectories = true.
     * @param {function (?string, Array.<string>=)} callback Callback resolved with a FileSystemError
     *                          string or the selected file(s)/directories. If the user cancels the
     *                          open dialog, the error will be falsy and the file/directory array will
     *                          be empty.
     */
    FileSystem.prototype.showOpenDialog = function (allowMultipleSelection,
                            chooseDirectories,
                            title,
                            initialPath,
                            fileTypes,
                            callback) {

        this._impl.showOpenDialog(allowMultipleSelection, chooseDirectories, title, initialPath, fileTypes, callback);
    };

    /**
     * Show a "Save" dialog and return the path of the file to save.
     *
     * @param {string} title The title of the dialog.
     * @param {string} initialPath The folder opened inside the window initially. If initialPath
     *                          is not set, or it doesn't exist, the window would show the last
     *                          browsed folder depending on the OS preferences.
     * @param {string} proposedNewFilename Provide a new file name for the user. This could be based on
     *                          on the current file name plus an additional suffix
     * @param {function (?string, string=)} callback Callback that is resolved with a FileSystemError
     *                          string or the name of the file to save. If the user cancels the save,
     *                          the error will be falsy and the name will be empty.
     */
    FileSystem.prototype.showSaveDialog = function (title, initialPath, proposedNewFilename, callback) {
        this._impl.showSaveDialog(title, initialPath, proposedNewFilename, callback);
    };

    /**
     * Fire a rename event. Clients listen for these events using FileSystem.on.
     *
     * @param {string} oldPath The entry's previous fullPath
     * @param {string} newPath The entry's current fullPath
     */
    FileSystem.prototype._fireRenameEvent = function (oldPath, newPath) {
        this.trigger("rename", oldPath, newPath);
    };

    /**
     * Fire a change event. Clients listen for these events using FileSystem.on.
     *
     * @param {File|Directory} entry The entry that has changed
     * @param {Array<File|Directory>=} added If the entry is a directory, this
     *      is a set of new entries in the directory.
     * @param {Array<File|Directory>=} removed If the entry is a directory, this
     *      is a set of removed entries from the directory.
     */
    FileSystem.prototype._fireChangeEvent = function (entry, added, removed) {
        this.trigger("change", entry, added, removed);
    };

    /**
     * @private
     * Notify the system when an entry name has changed.
     *
     * @param {string} oldFullPath
     * @param {string} newFullPath
     * @param {boolean} isDirectory
     */
    FileSystem.prototype._handleRename = function (oldFullPath, newFullPath, isDirectory) {
        // Update all affected entries in the index
        this._index.entryRenamed(oldFullPath, newFullPath, isDirectory);
    };

    /**
     * Notify the filesystem that the given directory has changed. Updates the filesystem's
     * internal state as a result of the change, and calls back with the set of added and
     * removed entries. Mutating FileSystemEntry operations should call this method before
     * applying the operation's callback, and pass along the resulting change sets in the
     * internal change event.
     *
     * @param {Directory} directory The directory that has changed.
     * @param {function(Array<File|Directory>=, Array<File|Directory>=)} callback
     *      The callback that will be applied to a set of added and a set of removed
     *      FileSystemEntry objects.
     */
    FileSystem.prototype._handleDirectoryChange = function (directory, callback) {
        var oldContents = directory._contents;

        directory._clearCachedData();
        directory.getContents(function (err, contents) {
            var addedEntries = oldContents && contents.filter(function (entry) {
                return oldContents.indexOf(entry) === -1;
            });

            var removedEntries = oldContents && oldContents.filter(function (entry) {
                return contents.indexOf(entry) === -1;
            });

            // If directory is not watched, clear children's caches manually.
            var watchedRoot = this._findWatchedRootForPath(directory.fullPath);
            if (!watchedRoot || !watchedRoot.filter(directory.name, directory.parentPath)) {
                this._index.visitAll(function (entry) {
                    if (entry.fullPath.indexOf(directory.fullPath) === 0) {
                        // Passing 'true' for a similar reason as in _unwatchEntry() - see #7150
                        entry._clearCachedData(true);
                    }
                }.bind(this));

                callback(addedEntries, removedEntries);
                return;
            }

            var addedCounter = addedEntries ? addedEntries.length : 0,
                removedCounter = removedEntries ? removedEntries.length : 0,
                counter = addedCounter + removedCounter;

            if (counter === 0) {
                callback(addedEntries, removedEntries);
                return;
            }

            var watchOrUnwatchCallback = function (err) {
                if (err) {
                    console.error("FileSystem error in _handleDirectoryChange after watch/unwatch entries: " + err);
                }

                if (--counter === 0) {
                    callback(addedEntries, removedEntries);
                }
            };

            if (addedEntries) {
                addedEntries.forEach(function (entry) {
                    this._watchEntry(entry, watchedRoot, watchOrUnwatchCallback);
                }, this);
            }

            if (removedEntries) {
                removedEntries.forEach(function (entry) {
                    this._unwatchEntry(entry, watchedRoot, watchOrUnwatchCallback);
                }, this);
            }
        }.bind(this));
    };

    /**
     * @private
     * Processes a result from the file/directory watchers. Watch results are sent from the low-level implementation
     * whenever a directory or file is changed.
     *
     * @param {string} path The path that changed. This could be a file or a directory.
     * @param {FileSystemStats=} stat Optional stat for the item that changed. This param is not always
     *         passed.
     */
    FileSystem.prototype._handleExternalChange = function (path, stat) {

        if (!path) {
            // This is a "wholesale" change event; clear all caches
            this._index.visitAll(function (entry) {
                // Passing 'true' for a similar reason as in _unwatchEntry() - see #7150
                entry._clearCachedData(true);
            });

            this._fireChangeEvent(null);
            return;
        }

        path = this._normalizePath(path, false);

        var entry = this._index.getEntry(path);
        if (entry) {
            var oldStat = entry._stat;
            if (entry.isFile) {
                // Update stat and clear contents, but only if out of date
                if (!(stat && oldStat && stat.mtime.getTime() <= oldStat.mtime.getTime())) {
                    entry._clearCachedData();
                    entry._stat = stat;
                    this._fireChangeEvent(entry);
                }
            } else {
                this._handleDirectoryChange(entry, function (added, removed) {
                    entry._stat = stat;

                    if (entry._isWatched()) {
                        // We send a change even if added & removed are both zero-length. Something may still have changed,
                        // e.g. a file may have been quickly removed & re-added before we got a chance to reread the directory
                        // listing.
                        this._fireChangeEvent(entry, added, removed);
                    }
                }.bind(this));
            }
        }
    };

    /**
     * Clears all cached content. Because of the performance implications of this, this should only be used if
     * there is a suspicion that the file system has not been updated through the normal file watchers
     * mechanism.
     */
    FileSystem.prototype.clearAllCaches = function () {
        this._handleExternalChange(null);
    };

    /**
     * Start watching a filesystem root entry.
     *
     * @param {FileSystemEntry} entry - The root entry to watch. If entry is a directory,
     *      all subdirectories that aren't explicitly filtered will also be watched.
     * @param {function(string): boolean} filter - Returns true if a particular item should
     *      be watched, given its name (not full path). Items that are ignored are also
     *      filtered from Directory.getContents() results within this subtree.
     * @param {Array<string>} filterGlobs - glob compatible string definitions for
     *      filtering out events on the node side.
     * @param {function(?string)=} callback - A function that is called when the watch has
     *      completed. If the watch fails, the function will have a non-null FileSystemError
     *      string parametr.
     */
    FileSystem.prototype.watch = function (entry, filter, filterGlobs, callback) {
        // make filterGlobs an optional argument to stay backwards compatible
        if (typeof callback === "undefined" && typeof filterGlobs === "function") {
            callback = filterGlobs;
            filterGlobs = null;
        }

        var fullPath = entry.fullPath;

        callback = callback || function () {};

        var watchingParentRoot = this._findWatchedRootForPath(fullPath);
        if (watchingParentRoot &&
                (watchingParentRoot.status === WatchedRoot.STARTING ||
                 watchingParentRoot.status === WatchedRoot.ACTIVE)) {
            callback("A parent of this root is already watched");
            return;
        }

        var watchingChildRoot = Object.keys(this._watchedRoots).some(function (path) {
            var watchedRoot = this._watchedRoots[path],
                watchedPath = watchedRoot.entry.fullPath;

            return watchedPath.indexOf(fullPath) === 0;
        }, this);

        if (watchingChildRoot &&
                (watchingChildRoot.status === WatchedRoot.STARTING ||
                 watchingChildRoot.status === WatchedRoot.ACTIVE)) {
            callback("A child of this root is already watched");
            return;
        }

        var watchedRoot = new WatchedRoot(entry, filter, filterGlobs);

        this._watchedRoots[fullPath] = watchedRoot;

        // Enter the STARTING state early to indiate that watched Directory
        // objects may cache their contents. See FileSystemEntry._isWatched.
        watchedRoot.status = WatchedRoot.STARTING;

        this._watchEntry(entry, watchedRoot, function (err) {
            if (err) {
                console.warn("Failed to watch root: ", entry.fullPath, err);
                delete this._watchedRoots[fullPath];
                callback(err);
                return;
            }

            watchedRoot.status = WatchedRoot.ACTIVE;

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

    /**
     * Stop watching a filesystem root entry.
     *
     * @param {FileSystemEntry} entry - The root entry to stop watching. The unwatch will
     *      if the entry is not currently being watched.
     * @param {function(?string)=} callback - A function that is called when the unwatch has
     *      completed. If the unwatch fails, the function will have a non-null FileSystemError
     *      string parameter.
     */
    FileSystem.prototype.unwatch = function (entry, callback) {
        var fullPath = entry.fullPath,
            watchedRoot = this._watchedRoots[fullPath];

        callback = callback || function () {};

        if (!watchedRoot) {
            callback(FileSystemError.ROOT_NOT_WATCHED);
            return;
        }

        // Mark this as inactive, but don't delete the entry until the unwatch is complete.
        // This is useful for making sure we don't try to concurrently watch overlapping roots.
        watchedRoot.status = WatchedRoot.INACTIVE;

        this._unwatchEntry(entry, watchedRoot, function (err) {
            delete this._watchedRoots[fullPath];

            this._index.visitAll(function (child) {
                if (child.fullPath.indexOf(entry.fullPath) === 0) {
                    this._index.removeEntry(child);
                }
            }.bind(this));

            if (err) {
                console.warn("Failed to unwatch root: ", entry.fullPath, err);
                callback(err);
                return;
            }

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

    /**
     * Unwatch all watched roots. Calls unwatch on the underlying impl for each
     * watched root and ignores errors.
     * @private
     */
    FileSystem.prototype._unwatchAll = function () {
        console.warn("File watchers went offline!");

        Object.keys(this._watchedRoots).forEach(function (path) {
            var watchedRoot = this._watchedRoots[path];

            watchedRoot.status = WatchedRoot.INACTIVE;
            delete this._watchedRoots[path];
            this._unwatchEntry(watchedRoot.entry, watchedRoot, function () {
                console.warn("Watching disabled for", watchedRoot.entry.fullPath);
            });
        }, this);

        // Fire a wholesale change event, clearing all caches and request that
        // clients manually update their state.
        this._handleExternalChange(null);
    };


    // The singleton instance
    var _instance;

    function _wrap(func) {
        return function () {
            return func.apply(_instance, arguments);
        };
    }

    // Export public methods as proxies to the singleton instance
    exports.init = _wrap(FileSystem.prototype.init);
    exports.close = _wrap(FileSystem.prototype.close);
    exports.shouldShow = _wrap(FileSystem.prototype.shouldShow);
    exports.getFileForPath = _wrap(FileSystem.prototype.getFileForPath);
    exports.addEntryForPathIfRequired = _wrap(FileSystem.prototype.addEntryForPathIfRequired);
    exports.getDirectoryForPath = _wrap(FileSystem.prototype.getDirectoryForPath);
    exports.resolve = _wrap(FileSystem.prototype.resolve);
    exports.showOpenDialog = _wrap(FileSystem.prototype.showOpenDialog);
    exports.showSaveDialog = _wrap(FileSystem.prototype.showSaveDialog);
    exports.watch = _wrap(FileSystem.prototype.watch);
    exports.unwatch = _wrap(FileSystem.prototype.unwatch);
    exports.clearAllCaches = _wrap(FileSystem.prototype.clearAllCaches);

    // Static public utility methods
    exports.isAbsolutePath = FileSystem.isAbsolutePath;
    exports.registerProtocolAdapter = registerProtocolAdapter;

    // For testing only
    exports._getActiveChangeCount = _wrap(FileSystem.prototype._getActiveChangeCount);

    /**
     * Add an event listener for a FileSystem event.
     *
     * @param {string} event The name of the event
     * @param {function} handler The handler for the event
     */
    exports.on = function (event, handler) {
        _instance.on(event, handler);
    };

    /**
     * Remove an event listener for a FileSystem event.
     *
     * @param {string} event The name of the event
     * @param {function} handler The handler for the event
     */
    exports.off = function (event, handler) {
        _instance.off(event, handler);
    };

    // Export the FileSystem class as "private" for unit testing only.
    exports._FileSystem = FileSystem;

    // Create the singleton instance
    _instance = new FileSystem();

    // Initialize the singleton instance
    _instance.init(require("fileSystemImpl"));
});