adobe/brackets

View on GitHub
src/project/ProjectModel.js

Summary

Maintainability
F
3 days
Test Coverage
/*
 * Copyright (c) 2014 - 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.
 *
 */

/* unittests: ProjectModel */

/**
 * Provides the data source for a project and manages the view model for the FileTreeView.
 */
define(function (require, exports, module) {
    "use strict";

    var InMemoryFile        = require("document/InMemoryFile"),
        EventDispatcher     = require("utils/EventDispatcher"),
        FileUtils           = require("file/FileUtils"),
        _                   = require("thirdparty/lodash"),
        FileSystem          = require("filesystem/FileSystem"),
        FileSystemError     = require("filesystem/FileSystemError"),
        FileTreeViewModel   = require("project/FileTreeViewModel"),
        Async               = require("utils/Async"),
        PerfUtils           = require("utils/PerfUtils");

    // Constants
    var EVENT_CHANGE            = "change",
        EVENT_SHOULD_SELECT     = "select",
        EVENT_SHOULD_FOCUS      = "focus",
        ERROR_CREATION          = "creationError",
        ERROR_INVALID_FILENAME  = "invalidFilename",
        ERROR_NOT_IN_PROJECT    = "notInProject";

    /**
     * @private
     * File and folder names which are not displayed or searched
     * TODO: We should add the rest of the file names that TAR excludes:
     *    http://www.gnu.org/software/tar/manual/html_section/exclude.html
     * TODO: This should be user configurable
     *    https://github.com/adobe/brackets/issues/6781
     * @type {RegExp}
     */
    var _exclusionListRegEx = /\.pyc$|^\.git$|^\.gitmodules$|^\.svn$|^\.DS_Store$|^Icon\r|^Thumbs\.db$|^\.hg$|^CVS$|^\.hgtags$|^\.idea$|^\.c9revisions$|^\.SyncArchive$|^\.SyncID$|^\.SyncIgnore$|\~$/;

    /**
     * Glob definition of files and folders that should be excluded directly
     * inside node domain watching with chokidar
     */
    var defaultIgnoreGlobs = [
        "**/(.pyc|.git|.gitmodules|.svn|.DS_Store|Thumbs.db|.hg|CVS|.hgtags|.idea|.c9revisions|.SyncArchive|.SyncID|.SyncIgnore)",
        "**/bower_components",
        "**/node_modules"
    ];

    /**
     * @private
     * RegEx to validate a file path.
     */
    var _invalidChars = /([?\*\|\<\>"]+|\/{2,}|\.{2,}|\.$)/i;

    /**
     * @private
     * RegEx to validate if a filename is not allowed even if the system allows it.
     * This is done to prevent cross-platform issues.
     */
    var _illegalFilenamesRegEx = /((\b(com[0-9]+|lpt[0-9]+|nul|con|prn|aux)\b)|\.+$|\/+|\\+|\:)/i;

    /**
     * Returns true if this matches valid filename specifications.
     * See http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
     *
     * TODO: This likely belongs in FileUtils.
     *
     * @param {string} filename to check
     * @return {boolean} true if the filename is valid
     */
    function isValidFilename(filename) {
        // Fix issue adobe#13099
        // See https://github.com/adobe/brackets/issues/13099
        return !(
            filename.match(_invalidChars)|| filename.match(_illegalFilenamesRegEx)
        );
    }

    /**
     * Returns true if given path is valid.
     *
     * @param {string} path to check
     * @return {boolean} true if the filename is valid
     */
    function isValidPath(path) {
        // Fix issue adobe#13099
        // See https://github.com/adobe/brackets/issues/13099
        return !(path.match(_invalidChars));
    }

    /**
     * @private
     * @see #shouldShow
     */
    function _shouldShowName(name) {
        return !_exclusionListRegEx.test(name);
    }

    /**
     * Returns false for files and directories that are not commonly useful to display.
     *
     * @param {!FileSystemEntry} entry File or directory to filter
     * @return {boolean} true if the file should be displayed
     */
    function shouldShow(entry) {
        return _shouldShowName(entry.name);
    }

    // Constants used by the ProjectModel

    var FILE_RENAMING     = 0,
        FILE_CREATING     = 1,
        RENAME_CANCELLED  = 2;


    /**
     * @private
     *
     * Determines if a path string is pointing to a directory (does it have a trailing slash?)
     *
     * @param {string} path Path to test.
     */
    function _pathIsFile(path) {
        return _.last(path) !== "/";
    }

    /**
     * @private
     *
     * Gets the FileSystem object (either a File or Directory) based on the path provided.
     *
     * @param {string} path Path to retrieve
     */
    function _getFSObject(path) {
        if (!path) {
            return path;
        } else if (_pathIsFile(path)) {
            return FileSystem.getFileForPath(path);
        }
        return FileSystem.getDirectoryForPath(path);
    }

    /**
     * @private
     *
     * Given what is possible a FileSystem object, return its path (if a string path is passed in,
     * it will be returned as-is).
     *
     * @param {FileSystemEntry} fsobj Object from which the path should be extracted
     */
    function _getPathFromFSObject(fsobj) {
        if (fsobj && fsobj.fullPath) {
            return fsobj.fullPath;
        }
        return fsobj;
    }

    /**
     * Creates a new file or folder at the given path. The returned promise is rejected if the filename
     * is invalid, the new path already exists or some other filesystem error comes up.
     *
     * @param {string} path path to create
     * @param {boolean} isFolder true if the new entry is a folder
     * @return {$.Promise} resolved when the file or directory has been created.
     */
    function doCreate(path, isFolder) {
        var d = new $.Deferred();
        var filename = FileUtils.getBaseName(path);

        // Check if filename
        if (!isValidFilename(filename)){
            return d.reject(ERROR_INVALID_FILENAME).promise();
        }

        // Check if fullpath with filename is valid
        // This check is used to circumvent directory jumps (Like ../..)
        if (!isValidPath(path)) {
            return d.reject(ERROR_INVALID_FILENAME).promise();
        }

        FileSystem.resolve(path, function (err) {
            if (!err) {
                // Item already exists, fail with error
                d.reject(FileSystemError.ALREADY_EXISTS);
            } else {
                if (isFolder) {
                    var directory = FileSystem.getDirectoryForPath(path);

                    directory.create(function (err) {
                        if (err) {
                            d.reject(err);
                        } else {
                            d.resolve(directory);
                        }
                    });
                } else {
                    // Create an empty file
                    var file = FileSystem.getFileForPath(path);

                    FileUtils.writeText(file, "").then(function () {
                        d.resolve(file);
                    }, d.reject);
                }
            }
        });

        return d.promise();
    }

    /**
     * @constructor
     *
     * The ProjectModel provides methods for accessing information about the current open project.
     * It also manages the view model to display a FileTreeView of the project.
     *
     * Events:
     * - EVENT_CHANGE (`change`) - Fired when there's a change that should refresh the UI
     * - EVENT_SHOULD_SELECT (`select`) - Fired when a selection has been made in the file tree and the file tree should be selected
     * - EVENT_SHOULD_FOCUS (`focus`)
     * - ERROR_CREATION (`creationError`) - Triggered when there's a problem creating a file
     */
    function ProjectModel(initial) {
        initial = initial || {};
        if (initial.projectRoot) {
            this.projectRoot = initial.projectRoot;
        }

        if (initial.focused !== undefined) {
            this._focused = initial.focused;
        }
        this._viewModel = new FileTreeViewModel.FileTreeViewModel();
        this._viewModel.on(FileTreeViewModel.EVENT_CHANGE, function () {
            this.trigger(EVENT_CHANGE);
        }.bind(this));
        this._selections = {};
    }
    EventDispatcher.makeEventDispatcher(ProjectModel.prototype);

    /**
     * @type {Directory}
     *
     * The root Directory object for the project.
     */
    ProjectModel.prototype.projectRoot = null;

    /**
     * @private
     * @type {FileTreeViewModel}
     *
     * The view model for this project.
     */
    ProjectModel.prototype._viewModel = null;

    /**
     * @private
     * @type {string}
     *
     * Encoded URL
     * @see {@link ProjectModel#getBaseUrl}, {@link ProjectModel#setBaseUrl}
     */
    ProjectModel.prototype._projectBaseUrl = "";

    /**
     * @private
     * @type {{selected: ?string, context: ?string, previousContext: ?string, rename: ?Object}}
     *
     * Keeps track of selected files, context, previous context and files
     * that are being renamed or created.
     */
    ProjectModel.prototype._selections = null;

    /**
     * @private
     * @type {boolean}
     *
     * Flag to store whether the file tree has focus.
     */
    ProjectModel.prototype._focused = true;

    /**
     * @private
     * @type {string}
     *
     * Current file path being viewed.
     */
    ProjectModel.prototype._currentPath = null;

    /**
     * @private
     * @type {?$.Promise.<Array<File>>}
     *
     * A promise that is resolved with an array of all project files. Used by
     * ProjectManager.getAllFiles().
     */
    ProjectModel.prototype._allFilesCachePromise = null;

    /**
     * Sets whether the file tree is focused or not.
     *
     * @param {boolean} focused True if the file tree has the focus.
     */
    ProjectModel.prototype.setFocused = function (focused) {
        this._focused = focused;
        if (!focused) {
            this.setSelected(null);
        }
    };

    /**
     * Sets the width of the selection bar.
     *
     * @param {int} width New width
     */
    ProjectModel.prototype.setSelectionWidth = function (width) {
        this._viewModel.setSelectionWidth(width);
    };

    /**
     * Tracks the scroller position.
     *
     * @param {int} scrollWidth Width of the tree container
     * @param {int} scrollTop Top of scroll position
     * @param {int} scrollLeft Left of scroll position
     * @param {int} offsetTop Top of scroller element
     */
    ProjectModel.prototype.setScrollerInfo = function (scrollWidth, scrollTop, scrollLeft, offsetTop) {
        this._viewModel.setSelectionScrollerInfo(scrollWidth, scrollTop, scrollLeft, offsetTop);
    };

    /**
     * Returns the encoded Base URL of the currently loaded project, or empty string if no project
     * is open (during startup, or running outside of app shell).
     * @return {String}
     */
    ProjectModel.prototype.getBaseUrl = function getBaseUrl() {
        return this._projectBaseUrl;
    };

    /**
     * Sets the encoded Base URL of the currently loaded project.
     * @param {String}
     */
    ProjectModel.prototype.setBaseUrl = function setBaseUrl(projectBaseUrl) {
        // Ensure trailing slash to be consistent with projectRoot.fullPath
        // so they're interchangable (i.e. easy to convert back and forth)
        if (projectBaseUrl.length > 0 && projectBaseUrl[projectBaseUrl.length - 1] !== "/") {
            projectBaseUrl += "/";
        }

        this._projectBaseUrl = projectBaseUrl;
        return projectBaseUrl;
    };

    /**
     * Returns true if absPath lies within the project, false otherwise.
     * Does not support paths containing ".."
     *
     * @param {string|FileSystemEntry} absPathOrEntry
     * @return {boolean}
     */
    ProjectModel.prototype.isWithinProject = function isWithinProject(absPathOrEntry) {
        var absPath = absPathOrEntry.fullPath || absPathOrEntry;
        return (this.projectRoot && absPath.indexOf(this.projectRoot.fullPath) === 0);
    };

    /**
     * If absPath lies within the project, returns a project-relative path. Else returns absPath
     * unmodified.
     * Does not support paths containing ".."
     *
     * @param {!string} absPath
     * @return {!string}
     */
    ProjectModel.prototype.makeProjectRelativeIfPossible = function makeProjectRelativeIfPossible(absPath) {
        if (absPath && this.isWithinProject(absPath)) {
            return absPath.slice(this.projectRoot.fullPath.length);
        }
        return absPath;
    };

    /**
     * Returns a valid directory within the project, either the path (or Directory object)
     * provided or the project root.
     *
     * @param {string|Directory} path Directory path to verify against the project
     * @return {string} A directory path within the project.
     */
    ProjectModel.prototype.getDirectoryInProject = function (path) {
        if (path && typeof path === "string") {
            if (_.last(path) !== "/") {
                path += "/";
            }
        } else if (path && path.isDirectory) {
            path = path.fullPath;
        } else {
            path = null;
        }

        if (!path || (typeof path !== "string") || !this.isWithinProject(path)) {
            path = this.projectRoot.fullPath;
        }
        return path;
    };

    /**
     * @private
     *
     * Returns a promise that resolves with a cached copy of all project files.
     * Used by ProjectManager.getAllFiles(). Ensures that at most one un-cached
     * directory traversal is active at a time, which is useful at project load
     * time when watchers (and hence filesystem-level caching) has not finished
     * starting up. The cache is cleared on every filesystem change event, and
     * also on project load and unload.
     *
     * @param {boolean} true to sort files by their paths
     * @return {$.Promise.<Array.<File>>}
     */
    ProjectModel.prototype._getAllFilesCache = function _getAllFilesCache(sort) {
        if (!this._allFilesCachePromise) {
            var deferred = new $.Deferred(),
                allFiles = [],
                allFilesVisitor = function (entry) {
                    if (shouldShow(entry)) {
                        if (entry.isFile) {
                            allFiles.push(entry);
                        }
                        return true;
                    }
                    return false;
                };

            this._allFilesCachePromise = deferred.promise();

            var projectIndexTimer = PerfUtils.markStart("Creating project files cache: " +
                                                        this.projectRoot.fullPath),
                options = {
                    sortList : sort
                };

            this.projectRoot.visit(allFilesVisitor, options, function (err) {
                if (err) {
                    PerfUtils.finalizeMeasurement(projectIndexTimer);
                    deferred.reject(err);
                } else {
                    PerfUtils.addMeasurement(projectIndexTimer);
                    deferred.resolve(allFiles);
                }
            }.bind(this));
        }

        return this._allFilesCachePromise;
    };

    /**
     * Returns an Array of all files for this project, optionally including
     * files additional files provided. Files are filtered out by shouldShow().
     *
     * @param {function (File, number):boolean=} filter Optional function to filter
     *          the file list (does not filter directory traversal). API matches Array.filter().
     * @param {Array.<File>=} additionalFiles Additional files to include (for example, the WorkingSet)
     *          Only adds files that are *not* under the project root or untitled documents.
     * @param {boolean} true to sort files by their paths
     *
     * @return {$.Promise} Promise that is resolved with an Array of File objects.
     */
    ProjectModel.prototype.getAllFiles = function getAllFiles(filter, additionalFiles, sort) {
        // The filter and includeWorkingSet params are both optional.
        // Handle the case where filter is omitted but includeWorkingSet is
        // specified.
        if (additionalFiles === undefined && typeof (filter) !== "function") {
            additionalFiles = filter;
            filter = null;
        }

        var filteredFilesDeferred = new $.Deferred();

        // First gather all files in project proper
        // Note that with proper promises we may be able to fix this so that we're not doing this
        // anti-pattern of creating a separate deferred rather than just chaining off of the promise
        // from _getAllFilesCache
        this._getAllFilesCache(sort).done(function (result) {
            // Add working set entries, if requested
            if (additionalFiles) {
                additionalFiles.forEach(function (file) {
                    if (result.indexOf(file) === -1 && !(file instanceof InMemoryFile)) {
                        result.push(file);
                    }
                });
            }

            // Filter list, if requested
            if (filter) {
                result = result.filter(filter);
            }

            // If a done handler attached to the returned filtered files promise
            // throws an exception that isn't handled here then it will leave
            // _allFilesCachePromise in an inconsistent state such that no
            // additional done handlers will ever be called!
            try {
                filteredFilesDeferred.resolve(result);
            } catch (e) {
                console.error("Unhandled exception in getAllFiles handler: " + e, e.stack);
            }
        }).fail(function (err) {
            try {
                filteredFilesDeferred.reject(err);
            } catch (e) {
                console.error("Unhandled exception in getAllFiles handler: " + e, e.stack);
            }
        });

        return filteredFilesDeferred.promise();
    };

    /**
     * @private
     *
     * Resets the all files cache.
     */
    ProjectModel.prototype._resetCache = function _resetCache() {
        this._allFilesCachePromise = null;
    };

    /**
     * Sets the project root (effectively resetting this ProjectModel).
     *
     * @param {Directory} projectRoot new project root
     * @return {$.Promise} resolved when the project root has been updated
     */
    ProjectModel.prototype.setProjectRoot = function (projectRoot) {
        this.projectRoot = projectRoot;
        this._resetCache();
        this._viewModel._rootChanged();

        var d = new $.Deferred(),
            self = this;

        projectRoot.getContents(function (err, contents) {
            if (err) {
                d.reject(err);
            } else {
                self._viewModel.setDirectoryContents("", contents);
                d.resolve();
            }
        });
        return d.promise();
    };

    /**
     * @private
     *
     * Gets the contents of a directory at the given path.
     *
     * @param {string} path path to retrieve
     * @return {$.Promise} Resolved with the directory contents.
     */
    ProjectModel.prototype._getDirectoryContents = function (path) {
        var d = new $.Deferred();
        FileSystem.getDirectoryForPath(path).getContents(function (err, contents) {
            if (err) {
                d.reject(err);
            } else {
                d.resolve(contents);
            }
        });
        return d.promise();
    };

    /**
     * Opens or closes the given directory in the file tree.
     *
     * @param {string} path Path to open
     * @param {boolean} open `true` to open the path
     * @return {$.Promise} resolved when the path has been opened.
     */
    ProjectModel.prototype.setDirectoryOpen = function (path, open) {
        var projectRelative = this.makeProjectRelativeIfPossible(path),
            needsLoading    = !this._viewModel.isPathLoaded(projectRelative),
            d               = new $.Deferred(),
            self            = this;

        function onSuccess(contents) {
            // Update the view model
            if (contents) {
                self._viewModel.setDirectoryContents(projectRelative, contents);
            }

            if (open) {
                self._viewModel.openPath(projectRelative);
                if (self._focused) {
                    var currentPathInProject = self.makeProjectRelativeIfPossible(self._currentPath);
                    if (self._viewModel.isFilePathVisible(currentPathInProject)) {
                        self.setSelected(self._currentPath, true);
                    } else {
                        self.setSelected(null);
                    }
                }
            } else {
                self._viewModel.setDirectoryOpen(projectRelative, false);
                var selected = self._selections.selected;
                if (selected) {
                    var relativeSelected = self.makeProjectRelativeIfPossible(selected);
                    if (!self._viewModel.isFilePathVisible(relativeSelected)) {
                        self.setSelected(null);
                    }
                }
            }

            d.resolve();
        }

        // If the view model doesn't have the data it needs, we load it now, otherwise we can just
        // manage the selection and resovle the promise.
        if (open && needsLoading) {
            var parentDirectory = FileUtils.getDirectoryPath(FileUtils.stripTrailingSlash(path));
            this.setDirectoryOpen(parentDirectory, true).then(function () {
                self._getDirectoryContents(path).then(onSuccess).fail(function (err) {
                    d.reject(err);
                });
            }, function (err) {
                d.reject(err);
            });
        } else {
            onSuccess();
        }

        return d.promise();
    };

    /**
     * Shows the given path in the tree and selects it if it's a file. Any intermediate directories
     * will be opened and a promise is returned to show when the entire operation is complete.
     *
     * @param {string|File|Directory} path full path to the file or directory
     * @return {$.Promise} promise resolved when the path is shown
     */
    ProjectModel.prototype.showInTree = function (path) {
        var d = new $.Deferred();
        path = _getPathFromFSObject(path);

        if (!this.isWithinProject(path)) {
            return d.resolve().promise();
        }

        var parentDirectory = FileUtils.getDirectoryPath(path),
            self = this;
        this.setDirectoryOpen(parentDirectory, true).then(function () {
            if (_pathIsFile(path)) {
                self.setSelected(path);
            }
            d.resolve();
        }, function (err) {
            d.reject(err);
        });
        return d.promise();
    };

    /**
     * Selects the given path in the file tree and opens the file (unless doNotOpen is specified).
     * Directories will not be selected.
     *
     * When the selection changes, any rename operation that is currently underway will be completed.
     *
     * @param {string} path full path to the file being selected
     * @param {boolean} doNotOpen `true` if the file should not be opened.
     */
    ProjectModel.prototype.setSelected = function (path, doNotOpen) {
        path = _getPathFromFSObject(path);

        // Directories are not selectable
        if (!_pathIsFile(path)) {
            return;
        }

        var oldProjectPath = this.makeProjectRelativeIfPossible(this._selections.selected),
            pathInProject = this.makeProjectRelativeIfPossible(path);

        if (path && !this._viewModel.isFilePathVisible(pathInProject)) {
            path = null;
            pathInProject = null;
        }

        this.performRename();

        this._viewModel.moveMarker("selected", oldProjectPath, pathInProject);
        if (this._selections.context) {
            this._viewModel.moveMarker("context", this.makeProjectRelativeIfPossible(this._selections.context), null);
            delete this._selections.context;
        }

        var previousSelection = this._selections.selected;
        this._selections.selected = path;

        if (path) {
            if (!doNotOpen) {
                this.trigger(EVENT_SHOULD_SELECT, {
                    path: path,
                    previousPath: previousSelection,
                    hadFocus: this._focused
                });
            }

            this.trigger(EVENT_SHOULD_FOCUS);
        }
    };

    /**
     * Gets the currently selected file or directory.
     *
     * @return {FileSystemEntry} the filesystem object for the currently selected file
     */
    ProjectModel.prototype.getSelected = function () {
        return _getFSObject(this._selections.selected);
    };

    /**
     * Keeps track of which file is currently being edited.
     *
     * @param {File|string} curFile Currently edited file.
     */
    ProjectModel.prototype.setCurrentFile = function (curFile) {
        this._currentPath = _getPathFromFSObject(curFile);
    };

    /**
     * Adds the file at the given path to the Working Set and selects it there.
     *
     * @param {string} path full path of file to open in Working Set
     */
    ProjectModel.prototype.selectInWorkingSet = function (path) {
        this.performRename();
        this.trigger(EVENT_SHOULD_SELECT, {
            path: path,
            add: true
        });
    };

    /**
     * Sets the context (for context menu operations) to the given path. This is independent from the
     * open/selected file.
     *
     * @param {string} path full path of file or directory to which the context should be setBaseUrl
     * @param {boolean} _doNotRename True if this context change should not cause a rename operation to finish. This is a special case that goes with context menu handling.
     * @param {boolean} _saveContext True if the current context should be saved (see comment below)
     */
    ProjectModel.prototype.setContext = function (path, _doNotRename, _saveContext) {
        // This bit is not ideal: when the user right-clicks on an item in the file tree
        // and there is already a context menu up, the FileTreeView sends a signal to set the
        // context to the new element but the PopupManager follows that with a message that it's
        // closing the context menu (because it closes the previous one and then opens the new
        // one.) This timing means that we need to provide some special case handling here.
        if (_saveContext) {
            if (!path) {
                this._selections.previousContext = this._selections.context;
            } else {
                this._selections.previousContext = path;
            }
        } else {
            delete this._selections.previousContext;
        }

        path = _getPathFromFSObject(path);

        if (!_doNotRename) {
            this.performRename();
        }
        var currentContext = this._selections.context;
        this._selections.context = path;
        this._viewModel.moveMarker("context", this.makeProjectRelativeIfPossible(currentContext),
                                   this.makeProjectRelativeIfPossible(path));
    };

    /**
     * Restores the context to the last non-null context. This is specifically here to handle
     * the sequence of messages that we get from the project context menu.
     */
    ProjectModel.prototype.restoreContext = function () {
        if (this._selections.previousContext) {
            this.setContext(this._selections.previousContext);
        }
    };

    /**
     * Gets the currently selected context.
     *
     * @return {FileSystemEntry} filesystem object for the context file or directory
     */
    ProjectModel.prototype.getContext = function () {
        return _getFSObject(this._selections.context);
    };

    /**
     * Starts a rename operation for the file or directory at the given path. If the path is
     * not provided, the current context is used.
     *
     * If a rename operation is underway, it will be completed automatically.
     *
     * The Promise returned is resolved with an object with a `newPath` property with the renamed path. If the user cancels the operation, the promise is resolved with the value RENAME_CANCELLED.
     *
     * @param {string=} path optional path to start renaming
     * @param {boolean=} isMoved optional flag which indicates whether the entry is being moved instead of renamed
     * @return {$.Promise} resolved when the operation is complete.
     */
    ProjectModel.prototype.startRename = function (path, isMoved) {
        var d = new $.Deferred();
        path = _getPathFromFSObject(path);
        if (!path) {
            path = this._selections.context;
            if (!path) {
                return d.resolve().promise();
            }
        }

        if (this._selections.rename && this._selections.rename.path === path) {
            return d.resolve().promise();
        }

        if (!this.isWithinProject(path)) {
            return d.reject({
                type: ERROR_NOT_IN_PROJECT,
                isFolder: !_pathIsFile(path),
                fullPath: path
            }).promise();
        }

        var projectRelativePath = this.makeProjectRelativeIfPossible(path);

        if (!this._viewModel.isFilePathVisible(projectRelativePath)) {
            this.showInTree(path);
        }

        if (!isMoved) {
            if (path !== this._selections.context) {
                this.setContext(path);
            } else {
                this.performRename();
            }

            this._viewModel.moveMarker("rename", null,
                projectRelativePath);
        }
        this._selections.rename = {
            deferred: d,
            type: FILE_RENAMING,
            path: path,
            newPath: path
        };
        return d.promise();
    };

    /**
     * Sets the new value for the rename operation that is in progress (started previously with a call
     * to `startRename`).
     *
     * @param {string} newPath new path for the file or directory being renamed
     */
    ProjectModel.prototype.setRenameValue = function (newPath) {
        if (!this._selections.rename) {
            return;
        }
        this._selections.rename.newPath = newPath;
    };

    /**
     * Cancels the rename operation that is in progress. This resolves the original promise with
     * a RENAME_CANCELLED value.
     */
    ProjectModel.prototype.cancelRename = function () {
        var renameInfo = this._selections.rename;
        if (!renameInfo) {
            return;
        }

        // File creation is a special case.
        if (renameInfo.type === FILE_CREATING) {
            this._cancelCreating();
            return;
        }

        this._viewModel.moveMarker("rename", this.makeProjectRelativeIfPossible(renameInfo.path), null);
        renameInfo.deferred.resolve(RENAME_CANCELLED);
        delete this._selections.rename;
        this.setContext(null);
    };

    /**
     * Rename a file/folder. This will update the project tree data structures
     * and send notifications about the rename.
     *
     * @param {string} oldPath Old name of the item with the path
     * @param {string} newPath New name of the item with the path
     * @param {string} newName New name of the item
     * @param {boolean} isFolder True if item is a folder; False if it is a file.
     * @return {$.Promise} A promise object that will be resolved or rejected when
     *   the rename is finished.
     */
    function _renameItem(oldPath, newPath, newName, isFolder) {
        var result = new $.Deferred();

        if (oldPath === newPath) {
            result.resolve();
        } else if (!isValidFilename(newName)) {
            result.reject(ERROR_INVALID_FILENAME);
        } else {
            var entry = isFolder ? FileSystem.getDirectoryForPath(oldPath) : FileSystem.getFileForPath(oldPath);
            entry.rename(newPath, function (err) {
                if (err) {
                    result.reject(err);
                } else {
                    result.resolve();
                }
            });
        }

        return result.promise();
    }

    /**
     * @private
     *
     * Renames the item at the old path to the new name provided.
     *
     * @param {string} oldPath full path to the current location of file or directory (should include trailing slash for directory)
     * @param {string} newPath full path to the new location of the file or directory
     * @param {string} newName new name for the file or directory
     */
    ProjectModel.prototype._renameItem = function (oldPath, newPath, newName) {
        return _renameItem(oldPath, newPath, newName, !_pathIsFile(oldPath));
    };

    /**
     * Completes the rename operation that is in progress.
     */
    ProjectModel.prototype.performRename = function () {
        var renameInfo = this._selections.rename;
        if (!renameInfo) {
            return;
        }
        var oldPath         = renameInfo.path,
            isFolder        = renameInfo.isFolder || !_pathIsFile(oldPath),
            oldProjectPath  = this.makeProjectRelativeIfPossible(oldPath),

            // To get the parent directory, we need to strip off the trailing slash on a directory name
            parentDirectory = FileUtils.getDirectoryPath(isFolder ? FileUtils.stripTrailingSlash(oldPath) : oldPath),
            oldName         = FileUtils.getBaseName(oldPath),
            newPath         = renameInfo.newPath,
            newName         = FileUtils.getBaseName(newPath),
            viewModel       = this._viewModel,
            self            = this;

        if (renameInfo.type !== FILE_CREATING && oldPath === newPath) {
            this.cancelRename();
            return;
        }

        if (isFolder && _.last(newPath) !== "/") {
            newPath += "/";
        }

        delete this._selections.rename;
        delete this._selections.context;

        viewModel.moveMarker("rename", oldProjectPath, null);
        viewModel.moveMarker("context", oldProjectPath, null);
        viewModel.moveMarker("creating", oldProjectPath, null);

        function finalizeRename() {
            viewModel.renameItem(oldProjectPath, self.makeProjectRelativeIfPossible(newPath));
            if (self._selections.selected && self._selections.selected.indexOf(oldPath) === 0) {
                self._selections.selected = newPath + self._selections.selected.slice(oldPath.length);
                self.setCurrentFile(newPath);
            }
        }

        if (renameInfo.type === FILE_CREATING) {
            this.createAtPath(newPath).done(function (entry) {
                finalizeRename();
                renameInfo.deferred.resolve(entry);
            }).fail(function (error) {
                self._viewModel.deleteAtPath(self.makeProjectRelativeIfPossible(renameInfo.path));
                renameInfo.deferred.reject(error);
            });
        } else {
            this._renameItem(oldPath, newPath, newName).then(function () {
                finalizeRename();
                renameInfo.deferred.resolve({
                    newPath: newPath
                });
            }).fail(function (errorType) {
                var errorInfo = {
                    type: errorType,
                    isFolder: isFolder,
                    fullPath: oldPath
                };
                renameInfo.deferred.reject(errorInfo);
            });
        }
    };

    /**
     * Creates a file or folder at the given path. Folder paths should have a trailing slash.
     *
     * If an error comes up during creation, the ERROR_CREATION event is triggered.
     *
     * @param {string} path full path to file or folder to create
     * @return {$.Promise} resolved when creation is complete
     */
    ProjectModel.prototype.createAtPath = function (path) {
        var isFolder  = !_pathIsFile(path),
            name      = FileUtils.getBaseName(path),
            self      = this;

        return doCreate(path, isFolder).done(function (entry) {
            if (!isFolder) {
                self.selectInWorkingSet(entry.fullPath);
            }
        }).fail(function (error) {
            self.trigger(ERROR_CREATION, {
                type: error,
                name: name,
                isFolder: isFolder
            });
        });
    };

    /**
     * Starts creating a file or folder with the given name in the given directory.
     *
     * The Promise returned is resolved with an object with a `newPath` property with the renamed path. If the user cancels the operation, the promise is resolved with the value RENAME_CANCELLED.
     *
     * @param {string} basedir directory that should contain the new entry
     * @param {string} newName initial name for the new entry (the user can rename it)
     * @param {boolean} isFolder `true` if the entry being created is a folder
     * @return {$.Promise} resolved when the user is done creating the entry.
     */
    ProjectModel.prototype.startCreating = function (basedir, newName, isFolder) {
        this.performRename();
        var d = new $.Deferred(),
            self = this;

        this.setDirectoryOpen(basedir, true).then(function () {
            self._viewModel.createPlaceholder(self.makeProjectRelativeIfPossible(basedir), newName, isFolder);
            var promise = self.startRename(basedir + newName);
            self._selections.rename.type = FILE_CREATING;
            if (isFolder) {
                self._selections.rename.isFolder = isFolder;
            }
            promise.then(d.resolve).fail(d.reject);
        }).fail(function (err) {
            d.reject(err);
        });
        return d.promise();
    };

    /**
     * Cancels the creation process that is underway. The original promise returned will be resolved with the
     * RENAME_CANCELLED value. The temporary entry added to the file tree will be deleted.
     */
    ProjectModel.prototype._cancelCreating = function () {
        var renameInfo = this._selections.rename;
        if (!renameInfo || renameInfo.type !== FILE_CREATING) {
            return;
        }
        this._viewModel.deleteAtPath(this.makeProjectRelativeIfPossible(renameInfo.path));
        renameInfo.deferred.resolve(RENAME_CANCELLED);
        delete this._selections.rename;
        this.setContext(null);
    };

    /**
     * Sets the `sortDirectoriesFirst` option for the file tree view.
     *
     * @param {boolean} True if directories should appear first
     */
    ProjectModel.prototype.setSortDirectoriesFirst = function (sortDirectoriesFirst) {
        this._viewModel.setSortDirectoriesFirst(sortDirectoriesFirst);
    };

    /**
     * Gets an array of arrays where each entry of the top-level array has an array
     * of paths that are at the same depth in the tree. All of the paths are full paths.
     *
     * @return {Array.<Array.<string>>} Array of array of full paths, organized by depth in the tree.
     */
    ProjectModel.prototype.getOpenNodes = function () {
        return this._viewModel.getOpenNodes(this.projectRoot.fullPath);
    };

    /**
     * Reopens a set of nodes in the tree by full path.
     * @param {Array.<Array.<string>>} nodesByDepth An array of arrays of node ids to reopen. The ids within
     *     each sub-array are reopened in parallel, and the sub-arrays are reopened in order, so they should
     *     be sorted by depth within the tree.
     * @return {$.Deferred} A promise that will be resolved when all nodes have been fully
     *     reopened.
     */
    ProjectModel.prototype.reopenNodes = function (nodesByDepth) {
        var deferred = new $.Deferred();

        if (!nodesByDepth || nodesByDepth.length === 0) {
            // All paths are opened and fully rendered.
            return deferred.resolve().promise();
        } else {
            var self = this;
            return Async.doSequentially(nodesByDepth, function (toOpenPaths) {
                return Async.doInParallel(
                    toOpenPaths,
                    function (path) {
                        return self._getDirectoryContents(path).then(function (contents) {
                            var relative = self.makeProjectRelativeIfPossible(path);
                            self._viewModel.setDirectoryContents(relative, contents);
                            self._viewModel.setDirectoryOpen(relative, true);
                        });
                    },
                    false
                );
            });
        }
    };

    /**
     * Clears caches and refreshes the contents of the tree.
     *
     * @return {$.Promise} resolved when the tree has been refreshed
     */
    ProjectModel.prototype.refresh = function () {
        var projectRoot = this.projectRoot,
            openNodes   = this.getOpenNodes(),
            self        = this,
            selections  = this._selections,
            viewModel   = this._viewModel,
            deferred    = new $.Deferred();

        this.setProjectRoot(projectRoot).then(function () {
            self.reopenNodes(openNodes).then(function () {
                if (selections.selected) {
                    viewModel.moveMarker("selected", null, self.makeProjectRelativeIfPossible(selections.selected));
                }

                if (selections.context) {
                    viewModel.moveMarker("context", null, self.makeProjectRelativeIfPossible(selections.context));
                }

                if (selections.rename) {
                    viewModel.moveMarker("rename", null, self.makeProjectRelativeIfPossible(selections.rename));
                }

                deferred.resolve();
            });
        });

        return deferred.promise();
    };

    /**
     * Handles filesystem change events and prepares the update for the view model.
     *
     * @param {?(File|Directory)} entry File or Directory changed
     * @param {Array.<FileSystemEntry>=} added If entry is a Directory, contains zero or more added children
     * @param {Array.<FileSystemEntry>=} removed If entry is a Directory, contains zero or more removed
     */
    ProjectModel.prototype.handleFSEvent = function (entry, added, removed) {
        this._resetCache();

        if (!entry) {
            this.refresh();
            return;
        }

        if (!this.isWithinProject(entry)) {
            return;
        }

        var changes = {},
            self = this;

        if (entry.isFile) {
            changes.changed = [
                this.makeProjectRelativeIfPossible(entry.fullPath)
            ];
        } else {
            // Special case: a directory passed in without added and removed values
            // needs to be updated.
            if (!added && !removed) {
                entry.getContents(function (err, contents) {
                    if (err) {
                        console.error("Unexpected error refreshing file tree for directory " + entry.fullPath + ": " + err, err.stack);
                        return;
                    }
                    self._viewModel.setDirectoryContents(self.makeProjectRelativeIfPossible(entry.fullPath), contents);
                });

                // Exit early because we can't update the viewModel until we get the directory contents.
                return;
            }
        }

        if (added) {
            changes.added = added.map(function (entry) {
                return self.makeProjectRelativeIfPossible(entry.fullPath);
            });
        }

        if (removed) {
            if (this._selections.selected &&
                    _.find(removed, { fullPath: this._selections.selected })) {
                this.setSelected(null);
            }

            if (this._selections.rename &&
                    _.find(removed, { fullPath: this._selections.rename.path })) {
                this.cancelRename();
            }

            if (this._selections.context &&
                    _.find(removed, { fullPath: this._selections.context })) {
                this.setContext(null);
            }
            changes.removed = removed.map(function (entry) {
                return self.makeProjectRelativeIfPossible(entry.fullPath);
            });
        }

        this._viewModel.processChanges(changes);
    };

    /**
     * Closes the directory at path and recursively closes all of its children.
     *
     * @param {string} path Path of subtree to close
     */
    ProjectModel.prototype.closeSubtree = function (path) {
        this._viewModel.closeSubtree(this.makeProjectRelativeIfPossible(path));
    };

    /**
     * Toggle the open state of subdirectories.
     * @param {!string}  path        parent directory
     * @param {boolean} openOrClose  true to open directory, false to close
     * @return {$.Promise} promise resolved when the directories are open
     */
    ProjectModel.prototype.toggleSubdirectories = function (path, openOrClose) {
        var self = this,
            d = new $.Deferred();

        this.setDirectoryOpen(path, true).then(function () {
            var projectRelativePath = self.makeProjectRelativeIfPossible(path),
                childNodes = self._viewModel.getChildDirectories(projectRelativePath);

            Async.doInParallel(childNodes, function (node) {
                return self.setDirectoryOpen(path + node, openOrClose);
            }, true).then(function () {
                d.resolve();
            }, function (err) {
                d.reject(err);
            });
        });

        return d.promise();
    };

    /**
     * Although Brackets is generally standardized on folder paths with a trailing "/", some APIs here
     * receive project paths without "/" due to legacy preference storage formats, etc.
     * @param {!string} fullPath  Path that may or may not end in "/"
     * @return {!string} Path that ends in "/"
     */
    function _ensureTrailingSlash(fullPath) {
        if (_pathIsFile(fullPath)) {
            return fullPath + "/";
        }
        return fullPath;
    }

    /**
     * @private
     *
     * Returns the full path to the welcome project, which we open on first launch.
     *
     * @param {string} sampleUrl URL for getting started project
     * @param {string} initialPath Path to Brackets directory (see {@link FileUtils::#getNativeBracketsDirectoryPath})
     * @return {!string} fullPath reference
     */
    function _getWelcomeProjectPath(sampleUrl, initialPath) {
        if (sampleUrl) {
            // Back up one more folder. The samples folder is assumed to be at the same level as
            // the src folder, and the sampleUrl is relative to the samples folder.
            initialPath = initialPath.substr(0, initialPath.lastIndexOf("/")) + "/samples/" + sampleUrl;
        }

        return _ensureTrailingSlash(initialPath); // paths above weren't canonical
    }

    /**
     * @private
     *
     * Adds the path to the list of welcome projects we've ever seen, if not on the list already.
     *
     * @param {string} path Path to possibly add
     * @param {Array.<string>=} currentProjects Array of current welcome projects
     * @return {Array.<string>} New array of welcome projects with the additional project added
     */
    function _addWelcomeProjectPath(path, currentProjects) {
        var pathNoSlash = FileUtils.stripTrailingSlash(path);  // "welcomeProjects" pref has standardized on no trailing "/"

        var newProjects;

        if (currentProjects) {
            newProjects = _.clone(currentProjects);
        } else {
            newProjects = [];
        }

        if (newProjects.indexOf(pathNoSlash) === -1) {
            newProjects.push(pathNoSlash);
        }
        return newProjects;
    }

    /**
     * Returns true if the given path is the same as one of the welcome projects we've previously opened,
     * or the one for the current build.
     *
     * @param {string} path Path to check to see if it's a welcome project
     * @param {string} welcomeProjectPath Current welcome project path
     * @param {Array.<string>=} welcomeProjects All known welcome projects
     */
    function _isWelcomeProjectPath(path, welcomeProjectPath, welcomeProjects) {
        if (path === welcomeProjectPath) {
            return true;
        }

        // No match on the current path, and it's not a match if there are no previously known projects
        if (!welcomeProjects) {
            return false;
        }

        var pathNoSlash = FileUtils.stripTrailingSlash(path);  // "welcomeProjects" pref has standardized on no trailing "/"
        return welcomeProjects.indexOf(pathNoSlash) !== -1;
    }

    exports._getWelcomeProjectPath  = _getWelcomeProjectPath;
    exports._addWelcomeProjectPath  = _addWelcomeProjectPath;
    exports._isWelcomeProjectPath   = _isWelcomeProjectPath;
    exports._ensureTrailingSlash    = _ensureTrailingSlash;
    exports._shouldShowName         = _shouldShowName;
    exports._invalidChars           = "? * | : / < > \\ | \" ..";

    exports.shouldShow              = shouldShow;
    exports.defaultIgnoreGlobs      = defaultIgnoreGlobs;
    exports.isValidFilename         = isValidFilename;
    exports.isValidPath             = isValidPath;
    exports.EVENT_CHANGE            = EVENT_CHANGE;
    exports.EVENT_SHOULD_SELECT     = EVENT_SHOULD_SELECT;
    exports.EVENT_SHOULD_FOCUS      = EVENT_SHOULD_FOCUS;
    exports.ERROR_CREATION          = ERROR_CREATION;
    exports.ERROR_INVALID_FILENAME  = ERROR_INVALID_FILENAME;
    exports.ERROR_NOT_IN_PROJECT    = ERROR_NOT_IN_PROJECT;
    exports.FILE_RENAMING           = FILE_RENAMING;
    exports.FILE_CREATING           = FILE_CREATING;
    exports.RENAME_CANCELLED        = RENAME_CANCELLED;
    exports.doCreate                = doCreate;
    exports.ProjectModel            = ProjectModel;
});