adobe/brackets

View on GitHub
src/project/ProjectManager.js

Summary

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

/**
 * ProjectManager glues together the project model and file tree view and integrates as needed with other parts
 * of Brackets. It is responsible for creating and updating the project tree when projects are opened
 * and when changes occur to the file tree.
 *
 * This module dispatches these events:
 *    - beforeProjectClose -- before `_projectRoot` changes, but working set files still open
 *    - projectClose       -- *just* before `_projectRoot` changes; working set already cleared
 *      & project root unwatched
 *    - beforeAppClose     -- before Brackets quits entirely
 *    - projectOpen        -- after `_projectRoot` changes and the tree is re-rendered
 *    - projectRefresh     -- when project tree is re-rendered for a reason other than
 *      a project being opened (e.g. from the Refresh command)
 *
 * To listen for events, do something like this: (see EventDispatcher for details on this pattern)
 *    ProjectManager.on("eventname", handler);
 */
define(function (require, exports, module) {
    "use strict";

    require("utils/Global");

    var _ = require("thirdparty/lodash");

    // Load dependent modules
    var AppInit             = require("utils/AppInit"),
        Async               = require("utils/Async"),
        PreferencesDialogs  = require("preferences/PreferencesDialogs"),
        PreferencesManager  = require("preferences/PreferencesManager"),
        DocumentManager     = require("document/DocumentManager"),
        MainViewManager     = require("view/MainViewManager"),
        CommandManager      = require("command/CommandManager"),
        Commands            = require("command/Commands"),
        Dialogs             = require("widgets/Dialogs"),
        DefaultDialogs      = require("widgets/DefaultDialogs"),
        EventDispatcher     = require("utils/EventDispatcher"),
        LanguageManager     = require("language/LanguageManager"),
        Menus               = require("command/Menus"),
        StringUtils         = require("utils/StringUtils"),
        Strings             = require("strings"),
        FileSystem          = require("filesystem/FileSystem"),
        FileViewController  = require("project/FileViewController"),
        PerfUtils           = require("utils/PerfUtils"),
        FileUtils           = require("file/FileUtils"),
        FileSystemError     = require("filesystem/FileSystemError"),
        Urls                = require("i18n!nls/urls"),
        FileSyncManager     = require("project/FileSyncManager"),
        ProjectModel        = require("project/ProjectModel"),
        FileTreeView        = require("project/FileTreeView"),
        ViewUtils           = require("utils/ViewUtils");

    // Needed to ensure that menus are set up when we need them.
    // See #10115
    require("command/DefaultMenus");

    /**
     * @private
     * Filename to use for project settings files.
     * @type {string}
     */
    var SETTINGS_FILENAME = "." + PreferencesManager.SETTINGS_FILENAME;

    /**
     * Name of the preferences for sorting directories first
     *
     * @type {string}
     */
    var SORT_DIRECTORIES_FIRST = "sortDirectoriesFirst";

    /**
     * @private
     * Forward declarations to make JSLint happy.
     */
    var _fileSystemChange,
        _fileSystemRename,
        _showErrorDialog,
        _saveTreeState,
        renameItemInline,
        _renderTreeSync,
        _renderTree;

    /**
     * @const
     * @private
     * Error context to show the correct error message
     * @type {int}
     */
    var ERR_TYPE_CREATE                 = 1,
        ERR_TYPE_CREATE_EXISTS          = 2,
        ERR_TYPE_RENAME                 = 3,
        ERR_TYPE_DELETE                 = 4,
        ERR_TYPE_LOADING_PROJECT        = 5,
        ERR_TYPE_LOADING_PROJECT_NATIVE = 6,
        ERR_TYPE_MAX_FILES              = 7,
        ERR_TYPE_OPEN_DIALOG            = 8,
        ERR_TYPE_INVALID_FILENAME       = 9,
        ERR_TYPE_MOVE                   = 10;

    /**
     * @private
     * Reference to the tree control container div. Initialized by
     * htmlReady handler
     * @type {jQueryObject}
     */
    var $projectTreeContainer;

    /**
     * @private
     *
     * Reference to the container of the Preact component. Everything in this
     * node is managed by Preact.
     * @type {Element}
     */
    var fileTreeViewContainer;

    /**
     * @private
     *
     * Does the file tree currently have the focus?
     *
     * @return {boolean} `true` if the file tree has the focus
     */
    function _hasFileSelectionFocus() {
        return FileViewController.getFileSelectionFocus() === FileViewController.PROJECT_MANAGER;
    }

    /**
     * @private
     * Singleton ProjectModel object.
     * @type {ProjectModel.ProjectModel}
     */
    var model = new ProjectModel.ProjectModel({
        focused: _hasFileSelectionFocus()
    });

    /**
     * @private
     * @type {boolean}
     * A flag to remember when user has been warned about too many files, so they
     * are only warned once per project/session.
     */
    var _projectWarnedForTooManyFiles = false;

    /**
     * @private
     *
     * Event handler which displays an error based on a problem creating a file.
     *
     * @param {$.Event} e jQuery event object
     * @param {{type:any,isFolder:boolean}} errorInfo Information passed in the error events
     */
    function _displayCreationError(e, errorInfo) {
        window.setTimeout(function () {
            var error = errorInfo.type,
                isFolder = errorInfo.isFolder,
                name = errorInfo.name;

            if (error === FileSystemError.ALREADY_EXISTS) {
                _showErrorDialog(ERR_TYPE_CREATE_EXISTS, isFolder, null, name);
            } else if (error === ProjectModel.ERROR_INVALID_FILENAME) {
                _showErrorDialog(ERR_TYPE_INVALID_FILENAME, isFolder, ProjectModel._invalidChars);
            } else {
                var errString = error === FileSystemError.NOT_WRITABLE ?
                        Strings.NO_MODIFICATION_ALLOWED_ERR :
                        StringUtils.format(Strings.GENERIC_ERROR, error);

                _showErrorDialog(ERR_TYPE_CREATE, isFolder, errString, name).getPromise();
            }
        }, 10);
    }

    /**
     * @private
     *
     * Reverts to the previous selection (useful if there's an error).
     *
     * @param {string|File} previousPath The previously selected path.
     * @param {boolean} switchToWorkingSet True if we need to switch focus to the Working Set
     */
    function _revertSelection(previousPath, switchToWorkingSet) {
        model.setSelected(previousPath);
        if (switchToWorkingSet) {
            FileViewController.setFileViewFocus(FileViewController.WORKING_SET_VIEW);
        }
    }

    /**
     * @constructor
     * @private
     *
     * Manages the interaction between the view and the model. This is loosely structured in
     * the style of [Flux](https://github.com/facebook/flux), but the initial implementation did
     * not need all of the parts of Flux yet. This ActionCreator could be replaced later with
     * a real ActionCreator that talks to a Dispatcher.
     *
     * Most of the methods just delegate to the ProjectModel. Some are responsible for integration
     * with other parts of Brackets.
     *
     * @param {ProjectModel} model store (in Flux terminology) with the project data
     */
    function ActionCreator(model) {
        this.model = model;
        this._bindEvents();
    }

    /**
     * @private
     *
     * Listen to events on the ProjectModel and cause the appropriate behavior within the rest of the system.
     */
    ActionCreator.prototype._bindEvents = function () {

        // Change events are the standard Flux signal to rerender the view. Note that
        // current Flux style is to have the view itself listen to the Store for change events
        // and re-render itself.
        this.model.on(ProjectModel.EVENT_CHANGE, function () {
            _renderTree();
        });

        // The "should select" event signals that we need to open the document based on file tree
        // activity.
        this.model.on(ProjectModel.EVENT_SHOULD_SELECT, function (e, data) {
            if (data.add) {
                FileViewController.openFileAndAddToWorkingSet(data.path).fail(_.partial(_revertSelection, data.previousPath, !data.hadFocus));
            } else {
                FileViewController.openAndSelectDocument(data.path, FileViewController.PROJECT_MANAGER).fail(_.partial(_revertSelection, data.previousPath, !data.hadFocus));
            }
        });

        this.model.on(ProjectModel.EVENT_SHOULD_FOCUS, function () {
            FileViewController.setFileViewFocus(FileViewController.PROJECT_MANAGER);
        });

        this.model.on(ProjectModel.ERROR_CREATION, _displayCreationError);
    };

    /**
     * Sets the directory at the given path to open in the tree and saves the open nodes to view state.
     *
     * See `ProjectModel.setDirectoryOpen`
     */
    ActionCreator.prototype.setDirectoryOpen = function (path, open) {
        this.model.setDirectoryOpen(path, open).then(_saveTreeState);
    };

    /**
     * See `ProjectModel.setSelected`
     */
    ActionCreator.prototype.setSelected = function (path, doNotOpen) {
        this.model.setSelected(path, doNotOpen);
    };

    /**
     * See `ProjectModel.selectInWorkingSet`
     */
    ActionCreator.prototype.selectInWorkingSet = function (path) {
        this.model.selectInWorkingSet(path);
    };

    /**
     * See `FileViewController.openWithExternalApplication`
     */
    ActionCreator.prototype.openWithExternalApplication = function (path) {
        FileViewController.openWithExternalApplication(path);
    };


    /**
     * See `ProjectModel.setContext`
     */
    ActionCreator.prototype.setContext = function (path) {
        this.model.setContext(path);
    };

    /**
     * See `ProjectModel.restoreContext`
     */
    ActionCreator.prototype.restoreContext = function () {
        this.model.restoreContext();
    };

    /**
     * See `ProjectModel.startRename`
     */
    ActionCreator.prototype.startRename = function (path, isMoved) {
        // This is very not Flux-like, which is a sign that Flux may not be the
        // right choice here *or* that this architecture needs to evolve subtly
        // in how errors are reported (more like the create case).
        // See #9284.
        renameItemInline(path, isMoved);
    };

    /**
     * See `ProjectModel.setRenameValue`
     */
    ActionCreator.prototype.setRenameValue = function (path) {
        this.model.setRenameValue(path);
    };

    /**
     * See `ProjectModel.cancelRename`
     */
    ActionCreator.prototype.cancelRename = function () {
        this.model.cancelRename();
    };

    /**
     * See `ProjectModel.performRename`
     */
    ActionCreator.prototype.performRename = function () {
        return this.model.performRename();
    };

    /**
     * See `ProjectModel.startCreating`
     */
    ActionCreator.prototype.startCreating = function (basedir, newName, isFolder) {
        return this.model.startCreating(basedir, newName, isFolder);
    };

    /**
     * See `ProjectModel.setSortDirectoriesFirst`
     */
    ActionCreator.prototype.setSortDirectoriesFirst = function (sortDirectoriesFirst) {
        this.model.setSortDirectoriesFirst(sortDirectoriesFirst);
    };

    /**
     * See `ProjectModel.setFocused`
     */
    ActionCreator.prototype.setFocused = function (focused) {
        this.model.setFocused(focused);
    };

    /**
     * See `ProjectModel.setCurrentFile`
     */
    ActionCreator.prototype.setCurrentFile = function (curFile) {
        this.model.setCurrentFile(curFile);
    };

    /**
     * See `ProjectModel.toggleSubdirectories`
     */
    ActionCreator.prototype.toggleSubdirectories = function (path, openOrClose) {
        this.model.toggleSubdirectories(path, openOrClose).then(_saveTreeState);
    };

    /**
     * See `ProjectModel.closeSubtree`
     */
    ActionCreator.prototype.closeSubtree = function (path) {
        this.model.closeSubtree(path);
        _saveTreeState();
    };

    ActionCreator.prototype.dragItem = function (path) {
        // Close open menus on drag and clear the context, but only if there's a menu open.
        if ($(".dropdown.open").length > 0) {
            Menus.closeAll();
            this.setContext(null);
        }

        // Close directory, if dragged item is directory
        if (_.last(path) === '/') {
            this.setDirectoryOpen(path, false);
        }
    };

    /**
     * Moves the item in the oldPath to the newDirectory directory
     */
    ActionCreator.prototype.moveItem = function (oldPath, newDirectory) {
        var fileName = FileUtils.getBaseName(oldPath),
            newPath = newDirectory + fileName,
            self = this;

        // If item dropped onto itself or onto its parent directory, return
        if (oldPath === newDirectory || FileUtils.getParentPath(oldPath) === newDirectory) {
            return;
        }

        // Add trailing slash if directory is moved
        if (_.last(oldPath) === '/') {
            newPath = ProjectModel._ensureTrailingSlash(newPath);
        }

        this.startRename(oldPath, true);
        this.setRenameValue(newPath);

        this.performRename();
        this.setDirectoryOpen(newDirectory, true);
    };

    /**
     * See `ProjectModel.refresh`
     */
    ActionCreator.prototype.refresh = function () {
        this.model.refresh();
    };

    /**
     * @private
     * @type {ActionCreator}
     *
     * Singleton actionCreator that is used for dispatching changes to the ProjectModel.
     */
    var actionCreator = new ActionCreator(model);
    
    /**
     * Returns the File or Directory corresponding to the item that was right-clicked on in the file tree menu.
     * @return {?(File|Directory)}
     */
    function getFileTreeContext() {
        var selectedEntry = model.getContext();
        return selectedEntry;
    }

    /**
     * Returns the File or Directory corresponding to the item selected in the sidebar panel, whether in
     * the file tree OR in the working set; or null if no item is selected anywhere in the sidebar.
     * May NOT be identical to the current Document - a folder may be selected in the sidebar, or the sidebar may not
     * have the current document visible in the tree & working set.
     * @return {?(File|Directory)}
     */
    function getSelectedItem() {
        // Prefer file tree context, then file tree selection, else use working set
        var selectedEntry = getFileTreeContext();
        if (!selectedEntry) {
            selectedEntry = model.getSelected();
        }
        if (!selectedEntry) {
            selectedEntry = MainViewManager.getCurrentlyViewedFile();
        }
        return selectedEntry;
    }

    /**
     * @private
     *
     * Handler for changes in the focus between working set and file tree view.
     */
    function _fileViewControllerChange() {
        actionCreator.setFocused(_hasFileSelectionFocus());
        _renderTree();
    }

    /**
     * @private
     *
     * Handler for changes in document selection.
     */
    function _documentSelectionFocusChange() {
        var curFullPath = MainViewManager.getCurrentlyViewedPath(MainViewManager.ACTIVE_PANE);
        if (curFullPath && _hasFileSelectionFocus()) {
            actionCreator.setSelected(curFullPath, true);
        } else {
            actionCreator.setSelected(null);
        }
        _fileViewControllerChange();
    }

    /**
     * @private
     *
     * Handler for changes to which file is currently viewed.
     *
     * @param {Object} e jQuery event object
     * @param {File} curFile Currently viewed file.
     */
    function _currentFileChange(e, curFile) {
        actionCreator.setCurrentFile(curFile);
    }

    /**
     * @private
     *
     * Creates a context object for doing project view state lookups.
     */
    function _getProjectViewStateContext() {
        return { location : { scope: "user",
                             layer: "project",
                             layerID: model.projectRoot.fullPath } };
    }

    /**
     * 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}
     */
    function getBaseUrl() {
        return model.getBaseUrl();
    }

    /**
     * Sets the encoded Base URL of the currently loaded project.
     * @param {String}
     */
    function setBaseUrl(projectBaseUrl) {
        var context = _getProjectViewStateContext();

        projectBaseUrl = model.setBaseUrl(projectBaseUrl);

        PreferencesManager.setViewState("project.baseUrl", projectBaseUrl, context);
    }

    /**
     * Returns true if absPath lies within the project, false otherwise.
     * Does not support paths containing ".."
     * @param {string|FileSystemEntry} absPathOrEntry
     * @return {boolean}
     */
    function isWithinProject(absPathOrEntry) {
        return model.isWithinProject(absPathOrEntry);
    }

    /**
     * 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}
     */
    function makeProjectRelativeIfPossible(absPath) {
        return model.makeProjectRelativeIfPossible(absPath);
    }

    /**
     * Returns the root folder of the currently loaded project, or null if no project is open (during
     * startup, or running outside of app shell).
     * @return {Directory}
     */
    function getProjectRoot() {
        return model.projectRoot;
    }

    /**
     * @private
     *
     * Sets the project root to the given directory, resetting the ProjectModel and file tree in the process.
     *
     * @param {Directory} rootEntry directory object for the project root
     * @return {$.Promise} resolved when the project is done setting up
     */
    function _setProjectRoot(rootEntry) {
        var d = new $.Deferred();
        model.setProjectRoot(rootEntry).then(function () {
            d.resolve();
            model.reopenNodes(PreferencesManager.getViewState("project.treeState", _getProjectViewStateContext()));
        });
        return d.promise();
    }

    /**
     * @private
     *
     * Saves the project path.
     */
    var _saveProjectPath = function () {
        // save the current project
        PreferencesManager.setViewState("projectPath", model.projectRoot.fullPath);
    };

    /**
     * @private
     * Save tree state.
     */
    _saveTreeState = function () {
        var context = _getProjectViewStateContext(),
            openNodes = model.getOpenNodes();

        // Store the open nodes by their full path and persist to storage
        PreferencesManager.setViewState("project.treeState", openNodes, context);
    };

    /**
     * @private
     *
     * Displays an error dialog for problems when working with files in the file tree.
     *
     * @param {number} errType type of error that occurred
     * @param {boolean} isFolder did the error occur because of a folder operation?
     * @param {string} error message with detail about the error
     * @param {string} path path to file or folder that had the error
     * @return {Dialog|null} Dialog if the error message was created
     */
    _showErrorDialog = function (errType, isFolder, error, path) {
        var titleType = isFolder ? Strings.DIRECTORY_TITLE : Strings.FILE_TITLE,
            entryType = isFolder ? Strings.DIRECTORY : Strings.FILE,
            title,
            message;
        path = StringUtils.breakableUrl(path);

        switch (errType) {
        case ERR_TYPE_CREATE:
            title = StringUtils.format(Strings.ERROR_CREATING_FILE_TITLE, titleType);
            message = StringUtils.format(Strings.ERROR_CREATING_FILE, entryType, path, error);
            break;
        case ERR_TYPE_CREATE_EXISTS:
            title = StringUtils.format(Strings.INVALID_FILENAME_TITLE, titleType);
            message = StringUtils.format(Strings.ENTRY_WITH_SAME_NAME_EXISTS, path);
            break;
        case ERR_TYPE_RENAME:
            title = StringUtils.format(Strings.ERROR_RENAMING_FILE_TITLE, titleType);
            message = StringUtils.format(Strings.ERROR_RENAMING_FILE, path, error, entryType);
            break;
        case ERR_TYPE_MOVE:
            title = StringUtils.format(Strings.ERROR_MOVING_FILE_TITLE, titleType);
            message = StringUtils.format(Strings.ERROR_MOVING_FILE, path, error, entryType);
            break;
        case ERR_TYPE_DELETE:
            title = StringUtils.format(Strings.ERROR_DELETING_FILE_TITLE, titleType);
            message = StringUtils.format(Strings.ERROR_DELETING_FILE, path, error, entryType);
            break;
        case ERR_TYPE_LOADING_PROJECT:
            title = Strings.ERROR_LOADING_PROJECT;
            message = StringUtils.format(Strings.READ_DIRECTORY_ENTRIES_ERROR, path, error);
            break;
        case ERR_TYPE_LOADING_PROJECT_NATIVE:
            title = Strings.ERROR_LOADING_PROJECT;
            message = StringUtils.format(Strings.REQUEST_NATIVE_FILE_SYSTEM_ERROR, path, error);
            break;
        case ERR_TYPE_MAX_FILES:
            title = Strings.ERROR_MAX_FILES_TITLE;
            message = Strings.ERROR_MAX_FILES;
            break;
        case ERR_TYPE_OPEN_DIALOG:
            title = Strings.ERROR_LOADING_PROJECT;
            message = StringUtils.format(Strings.OPEN_DIALOG_ERROR, error);
            break;
        case ERR_TYPE_INVALID_FILENAME:
            title = StringUtils.format(Strings.INVALID_FILENAME_TITLE, isFolder ? Strings.DIRECTORY_NAME : Strings.FILENAME);
            message = StringUtils.format(Strings.INVALID_FILENAME_MESSAGE, isFolder ? Strings.DIRECTORY_NAMES_LEDE : Strings.FILENAMES_LEDE, error);
            break;
        }

        if (title && message) {
            return Dialogs.showModalDialog(
                DefaultDialogs.DIALOG_ID_ERROR,
                title,
                message
            );
        }
        return null;
    };

    var _RENDER_DEBOUNCE_TIME = 100;

    /**
     * @private
     *
     * Rerender the file tree view.
     *
     * @param {boolean} forceRender Force the tree to rerender. Should only be needed by extensions that call rerenderTree.
     */
    _renderTreeSync = function (forceRender) {
        var projectRoot = getProjectRoot();
        if (!projectRoot) {
            return;
        }
        model.setScrollerInfo($projectTreeContainer[0].scrollWidth, $projectTreeContainer.scrollTop(), $projectTreeContainer.scrollLeft(), $projectTreeContainer.offset().top);
        FileTreeView.render(fileTreeViewContainer, model._viewModel, projectRoot, actionCreator, forceRender, brackets.platform);
    };

    _renderTree = _.debounce(_renderTreeSync, _RENDER_DEBOUNCE_TIME);

    /**
     * @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 FileUtils.getNativeBracketsDirectoryPath())
     * @return {!string} fullPath reference
     */
    function _getWelcomeProjectPath() {
        return ProjectModel._getWelcomeProjectPath(Urls.GETTING_STARTED, FileUtils.getNativeBracketsDirectoryPath());
    }

    /**
     * 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
     */
    function addWelcomeProjectPath(path) {
        var welcomeProjects = ProjectModel._addWelcomeProjectPath(path,
                                                                 PreferencesManager.getViewState("welcomeProjects"));
        PreferencesManager.setViewState("welcomeProjects", welcomeProjects);
    }


    /**
     * 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 path
     * @return {boolean} true if this is a welcome project path
     */
    function isWelcomeProjectPath(path) {
        return ProjectModel._isWelcomeProjectPath(path, _getWelcomeProjectPath(), PreferencesManager.getViewState("welcomeProjects"));
    }

    /**
     * If the provided path is to an old welcome project, returns the current one instead.
     */
    function updateWelcomeProjectPath(path) {
        if (isWelcomeProjectPath(path)) {
            return _getWelcomeProjectPath();
        } else {
            return path;
        }
    }

    /**
     * After failing to load a project, this function determines which project path to fallback to.
     * @return {$.Promise} Promise that resolves to a project path {string}
     */
    function _getFallbackProjectPath() {
        var fallbackPaths = [],
            recentProjects = PreferencesManager.getViewState("recentProjects") || [],
            deferred = new $.Deferred();

        // Build ordered fallback path array
        if (recentProjects.length > 1) {
            // *Most* recent project is the one that just failed to load, so use second most recent
            fallbackPaths.push(recentProjects[1]);
        }

        // Next is Getting Started project
        fallbackPaths.push(_getWelcomeProjectPath());

        // Helper func for Async.firstSequentially()
        function processItem(path) {
            var deferred = new $.Deferred(),
                fileEntry = FileSystem.getDirectoryForPath(path);

            fileEntry.exists(function (err, exists) {
                if (!err && exists) {
                    deferred.resolve();
                } else {
                    deferred.reject();
                }
            });

            return deferred.promise();
        }

        // Find first path that exists
        Async.firstSequentially(fallbackPaths, processItem)
            .done(function (fallbackPath) {
                deferred.resolve(fallbackPath);
            })
            .fail(function () {
                // Last resort is Brackets source folder which is guaranteed to exist
                deferred.resolve(FileUtils.getNativeBracketsDirectoryPath());
            });

        return deferred.promise();
    }

    /**
     * Initial project path is stored in prefs, which defaults to the welcome project on
     * first launch.
     */
    function getInitialProjectPath() {
        return updateWelcomeProjectPath(PreferencesManager.getViewState("projectPath"));
    }

    /**
     * @private
     *
     * Watches the project for filesystem changes so that the tree can be updated.
     */
    function _watchProjectRoot(rootPath) {
        FileSystem.on("change", _fileSystemChange);
        FileSystem.on("rename", _fileSystemRename);

        FileSystem.watch(FileSystem.getDirectoryForPath(rootPath), ProjectModel._shouldShowName, ProjectModel.defaultIgnoreGlobs, function (err) {
            if (err === FileSystemError.TOO_MANY_ENTRIES) {
                if (!_projectWarnedForTooManyFiles) {
                    _showErrorDialog(ERR_TYPE_MAX_FILES);
                    _projectWarnedForTooManyFiles = true;
                }
            } else if (err) {
                console.error("Error watching project root: ", rootPath, err);
            }
        });

        // Reset allFiles cache
        model._resetCache();
    }


    /**
     * @private
     * Close the file system and remove listeners.
     * @return {$.Promise} A promise that's resolved when the root is unwatched. Rejected if
     *     there is no project root or if the unwatch fails.
     */
    function _unwatchProjectRoot() {
        var result = new $.Deferred();
        if (!model.projectRoot) {
            result.reject();
        } else {
            FileSystem.off("change", _fileSystemChange);
            FileSystem.off("rename", _fileSystemRename);

            FileSystem.unwatch(model.projectRoot, function (err) {
                if (err) {
                    console.error("Error unwatching project root: ", model.projectRoot.fullPath, err);
                    result.reject(err);
                } else {
                    result.resolve();
                }
            });

            // Reset allFiles cache
            model._resetCache();
        }

        return result.promise();
    }

    /**
     * @private
     * Reloads the project preferences.
     */
    function _reloadProjectPreferencesScope() {
        var root = getProjectRoot();
        if (root) {
            // Alias the "project" Scope to the path Scope for the project-level settings file
            PreferencesManager._setProjectSettingsFile(root.fullPath + SETTINGS_FILENAME);
        } else {
            PreferencesManager._setProjectSettingsFile();
        }
    }

    /**
     * Loads the given folder as a project. Does NOT prompt about any unsaved changes - use openProject()
     * instead to check for unsaved changes and (optionally) let the user choose the folder to open.
     *
     * @param {!string} rootPath  Absolute path to the root folder of the project.
     *  A trailing "/" on the path is optional (unlike many Brackets APIs that assume a trailing "/").
     * @param {boolean=} isUpdating  If true, indicates we're just updating the tree;
     *  if false, a different project is being loaded.
     * @return {$.Promise} A promise object that will be resolved when the
     *  project is loaded and tree is rendered, or rejected if the project path
     *  fails to load.
     */
    function _loadProject(rootPath, isUpdating) {
        var result = new $.Deferred(),
            startLoad = new $.Deferred();

        // Some legacy code calls this API with a non-canonical path
        rootPath = ProjectModel._ensureTrailingSlash(rootPath);

        var projectPrefFullPath = (rootPath + SETTINGS_FILENAME),
            file   = FileSystem.getFileForPath(projectPrefFullPath);

        //Verify that the project preferences file (.brackets.json) is NOT corrupted.
        //If corrupted, display the error message and open the file in editor for the user to edit.
        FileUtils.readAsText(file)
            .done(function (text) {
                try {
                    if (text) {
                        JSON.parse(text);
                    }
                } catch (err) {
                    // Cannot parse the text read from the project preferences file.
                    var info = MainViewManager.findInAllWorkingSets(projectPrefFullPath);
                    var paneId;
                    if (info.length) {
                        paneId = info[0].paneId;
                    }
                    FileViewController.openFileAndAddToWorkingSet(projectPrefFullPath, paneId)
                        .done(function () {
                            Dialogs.showModalDialog(
                                DefaultDialogs.DIALOG_ID_ERROR,
                                Strings.ERROR_PREFS_CORRUPT_TITLE,
                                Strings.ERROR_PROJ_PREFS_CORRUPT
                            ).done(function () {
                                // give the focus back to the editor with the pref file
                                MainViewManager.focusActivePane();
                            });
                        });
                }
            });

        if (isUpdating) {
            // We're just refreshing. Don't need to unwatch the project root, so we can start loading immediately.
            startLoad.resolve();
        } else {
            if (model.projectRoot && model.projectRoot.fullPath === rootPath) {
                return (new $.Deferred()).resolve().promise();
            }

            // About to close current project (if any)
            if (model.projectRoot) {
                exports.trigger("beforeProjectClose", model.projectRoot);
            }

            // close all the old files
            MainViewManager._closeAll(MainViewManager.ALL_PANES);

            _unwatchProjectRoot().always(function () {
                // Done closing old project (if any)
                if (model.projectRoot) {
                    LanguageManager._resetPathLanguageOverrides();
                    PreferencesManager._reloadUserPrefs(model.projectRoot);
                    exports.trigger("projectClose", model.projectRoot);
                }

                startLoad.resolve();
            });
        }

        startLoad.done(function () {
            var context = { location : { scope: "user",
                                         layer: "project" } };

            // Clear project path map
            if (!isUpdating) {
                PreferencesManager._stateProjectLayer.setProjectPath(rootPath);
            }

            // Populate file tree as long as we aren't running in the browser
            if (!brackets.inBrowser) {
                if (!isUpdating) {
                    _watchProjectRoot(rootPath);
                }
                // Point at a real folder structure on local disk
                var rootEntry = FileSystem.getDirectoryForPath(rootPath);
                rootEntry.exists(function (err, exists) {
                    if (exists) {
                        var projectRootChanged = (!model.projectRoot || !rootEntry) ||
                            model.projectRoot.fullPath !== rootEntry.fullPath;

                        // Success!
                        var perfTimerName = PerfUtils.markStart("Load Project: " + rootPath);

                        _projectWarnedForTooManyFiles = false;

                        _setProjectRoot(rootEntry).always(function () {
                            model.setBaseUrl(PreferencesManager.getViewState("project.baseUrl", context) || "");

                            if (projectRootChanged) {
                                _reloadProjectPreferencesScope();
                                PreferencesManager._setCurrentFile(rootPath);
                            }

                            // If this is the most current welcome project, record it. In future launches, we want
                            // to substitute the latest welcome project from the current build instead of using an
                            // outdated one (when loading recent projects or the last opened project).
                            if (rootPath === _getWelcomeProjectPath()) {
                                addWelcomeProjectPath(rootPath);
                            }

                            if (projectRootChanged) {
                                // Allow asynchronous event handlers to finish before resolving result by collecting promises from them
                                exports.trigger("projectOpen", model.projectRoot);
                                result.resolve();
                            } else {
                                exports.trigger("projectRefresh", model.projectRoot);
                                result.resolve();
                            }
                            PerfUtils.addMeasurement(perfTimerName);
                        });
                    } else {
                        console.log("error loading project");
                        _showErrorDialog(ERR_TYPE_LOADING_PROJECT_NATIVE, true, err || FileSystemError.NOT_FOUND, rootPath)
                            .done(function () {
                                // Reset _projectRoot to null so that the following _loadProject call won't
                                // run the 'beforeProjectClose' event a second time on the original project,
                                // which is now partially torn down (see #6574).
                                model.projectRoot = null;

                                // The project folder stored in preference doesn't exist, so load the default
                                // project directory.
                                // TODO (issue #267): When Brackets supports having no project directory
                                // defined this code will need to change
                                _getFallbackProjectPath().done(function (path) {
                                    _loadProject(path).always(function () {
                                        // Make sure not to reject the original deferred until the fallback
                                        // project is loaded, so we don't violate expectations that there is always
                                        // a current project before continuing after _loadProject().
                                        result.reject();
                                    });
                                });
                            });
                    }
                });
            }
        });

        return result.promise();
    }

    /**
     * @const
     * @private
     * @type {number} Minimum delay in milliseconds between calls to refreshFileTree
     */
    var _refreshDelay = 1000;

    /**
     * Refresh the project's file tree, maintaining the current selection.
     *
     * Note that the original implementation of this returned a promise to be resolved when the refresh is complete.
     * That use is deprecated and `refreshFileTree` is now a "fire and forget" kind of function.
     */
    var refreshFileTree = function refreshFileTree() {
        FileSystem.clearAllCaches();
        return new $.Deferred().resolve().promise();
    };

    refreshFileTree = _.debounce(refreshFileTree, _refreshDelay);

    /**
     * Expands tree nodes to show the given file or folder and selects it. Silently no-ops if the
     * path lies outside the project, or if it doesn't exist.
     *
     * @param {!(File|Directory)} entry File or Directory to show
     * @return {$.Promise} Resolved when done; or rejected if not found
     */
    function showInTree(entry) {
        return model.showInTree(entry).then(_saveTreeState);
    }


    /**
     * Open a new project. Currently, Brackets must always have a project open, so
     * this method handles both closing the current project and opening a new project.
     *
     * @param {string=} path Optional absolute path to the root folder of the project.
     *  If path is undefined or null, displays a dialog where the user can choose a
     *  folder to load. If the user cancels the dialog, nothing more happens.
     * @return {$.Promise} A promise object that will be resolved when the
     *  project is loaded and tree is rendered, or rejected if the project path
     *  fails to load.
     */
    function openProject(path) {

        var result = new $.Deferred();

        // Confirm any unsaved changes first. We run the command in "prompt-only" mode, meaning it won't
        // actually close any documents even on success; we'll do that manually after the user also oks
        // the folder-browse dialog.
        CommandManager.execute(Commands.FILE_CLOSE_ALL, { promptOnly: true })
            .done(function () {
                if (path) {
                    // use specified path
                    _loadProject(path, false).then(result.resolve, result.reject);
                } else {
                    // Pop up a folder browse dialog
                    FileSystem.showOpenDialog(false, true, Strings.CHOOSE_FOLDER, model.projectRoot.fullPath, null, function (err, files) {
                        if (!err) {
                            // If length == 0, user canceled the dialog; length should never be > 1
                            if (files.length > 0) {
                                // Load the new project into the folder tree
                                _loadProject(files[0]).then(result.resolve, result.reject);
                            } else {
                                result.reject();
                            }
                        } else {
                            _showErrorDialog(ERR_TYPE_OPEN_DIALOG, null, err);
                            result.reject();
                        }
                    });
                }
            })
            .fail(function () {
                result.reject();
            });

        // if fail, don't open new project: user canceled (or we failed to save its unsaved changes)
        return result.promise();
    }

    /**
     * Invoke project settings dialog.
     * @return {$.Promise}
     */
    function _projectSettings() {
        return PreferencesDialogs.showProjectPreferencesDialog(getBaseUrl()).getPromise();
    }

    /**
     * Create a new item in the current project.
     *
     * @param baseDir {string|Directory} Full path of the directory where the item should go.
     *   Defaults to the project root if the entry is not valid or not within the project.
     * @param initialName {string} Initial name for the item
     * @param skipRename {boolean} If true, don't allow the user to rename the item
     * @param isFolder {boolean} If true, create a folder instead of a file
     * @return {$.Promise} A promise object that will be resolved with the File
     *  of the created object, or rejected if the user cancelled or entered an illegal
     *  filename.
     */
    function createNewItem(baseDir, initialName, skipRename, isFolder) {
        baseDir = model.getDirectoryInProject(baseDir);

        if (skipRename) {
            if(isFolder) {
                return model.createAtPath(baseDir + initialName + "/");
            }
            return model.createAtPath(baseDir + initialName);
        }
        return actionCreator.startCreating(baseDir, initialName, isFolder);
    }

    /**
     * Delete file or directore from project
     * @param {!(File|Directory)} entry File or Directory to delete
     */
    function deleteItem(entry) {
        var result = new $.Deferred();

        entry.moveToTrash(function (err) {
            if (!err) {
                DocumentManager.notifyPathDeleted(entry.fullPath);
                result.resolve();
            } else {
                _showErrorDialog(ERR_TYPE_DELETE, entry.isDirectory, FileUtils.getFileErrorString(err), entry.fullPath);

                result.reject(err);
            }
        });

        return result.promise();
    }

    /**
     * Returns a filter for use with getAllFiles() that filters files based on LanguageManager language id
     * @param {!(string|Array.<string>)} languageId a single string of a language id or an array of language ids
     * @return {!function(File):boolean}
     */
    function getLanguageFilter(languageId) {
        return function languageFilter(file) {
            var id = LanguageManager.getLanguageForPath(file.fullPath).getId();
            if (typeof languageId === "string") {
                return (id === languageId);
            } else {
                return (languageId.indexOf(id) !== -1);
            }
        };
    }

    /**
     * @private
     *
     * Respond to a FileSystem change event. Note that if renames are initiated
     * externally, they may be reported as a separate removal and addition. In
     * this case, the editor state isn't currently preserved.
     *
     * @param {$.Event} event
     * @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 children
     */
    _fileSystemChange = function (event, entry, added, removed) {
        FileSyncManager.syncOpenDocuments();

        model.handleFSEvent(entry, added, removed);

        // @TODO: DocumentManager should implement its own fsChange  handler
        //          we can clean up the calls to DocumentManager.notifyPathDeleted
        //          and privatize DocumentManager.notifyPathDeleted as well
        //        We can also remove the _fileSystemRename handler below and move
        //          it to DocumentManager
        if (removed) {
            removed.forEach(function (file) {
                // The call to syncOpenDocuemnts above will not nofify
                //  document manager about deleted images that are
                //  not in the working set -- try to clean that up here
                DocumentManager.notifyPathDeleted(file.fullPath);
            });
        }
    };

    /**
     * @private
     * Respond to a FileSystem rename event.
     */
    _fileSystemRename = function (event, oldName, newName) {
        // Tell the document manager about the name change. This will update
        // all of the model information and send notification to all views
        DocumentManager.notifyPathNameChanged(oldName, newName);
    };

    /**
     * Causes the rename operation that's in progress to complete.
     */
    function forceFinishRename() {
        actionCreator.performRename();
    }

    /**
     * @private
     *
     * Sets the width of the selection bar in the file tree.
     *
     * @param {int} width New width value
     */
    function _setFileTreeSelectionWidth(width) {
        model.setSelectionWidth(width);
        _renderTreeSync();
    }

    // Initialize variables and listeners that depend on the HTML DOM
    AppInit.htmlReady(function () {
        $projectTreeContainer = $("#project-files-container");
        $projectTreeContainer.addClass("jstree jstree-brackets");
        $projectTreeContainer.css("overflow", "auto");
        $projectTreeContainer.css("position", "relative");

        fileTreeViewContainer = $("<div>").appendTo($projectTreeContainer)[0];

        model.setSelectionWidth($projectTreeContainer.width());

        $(".main-view").click(function (jqEvent) {
            if (!jqEvent.target.classList.contains("jstree-rename-input")) {
                forceFinishRename();
                actionCreator.setContext(null);
            }
        });

        $("#working-set-list-container").on("contentChanged", function () {
            $projectTreeContainer.trigger("contentChanged");
        });

        Menus.getContextMenu(Menus.ContextMenuIds.PROJECT_MENU).on("beforeContextMenuOpen", function () {
            actionCreator.restoreContext();
        });

        Menus.getContextMenu(Menus.ContextMenuIds.PROJECT_MENU).on("beforeContextMenuClose", function () {
            model.setContext(null, false, true);
        });

        $projectTreeContainer.on("contextmenu", function () {
            forceFinishRename();
        });

        $projectTreeContainer.on("dragover", function(e) {
            e.preventDefault();
        });

        // Add support for moving items to root directory
        $projectTreeContainer.on("drop", function(e) {
            var data = JSON.parse(e.originalEvent.dataTransfer.getData("text"));
            actionCreator.moveItem(data.path, getProjectRoot().fullPath);
            e.stopPropagation();
        });

        // When a context menu item is selected, we need to clear the context
        // because we don't get a beforeContextMenuClose event since Bootstrap
        // handles this directly.
        $("#project-context-menu").on("click.dropdown-menu", function () {
            model.setContext(null, true);
        });

        $projectTreeContainer.on("scroll", function () {
            // Close open menus on scroll and clear the context, but only if there's a menu open.
            if ($(".dropdown.open").length > 0) {
                Menus.closeAll();
                actionCreator.setContext(null);
            }
            // we need to render the tree without a delay to not cause selection extension issues (#10573)
            _renderTreeSync();
        });

        _renderTree();

        ViewUtils.addScrollerShadow($projectTreeContainer[0]);
    });

    EventDispatcher.makeEventDispatcher(exports);

    // Init default project path to welcome project
    PreferencesManager.stateManager.definePreference("projectPath", "string", _getWelcomeProjectPath());

    exports.on("projectOpen", _reloadProjectPreferencesScope);
    exports.on("projectOpen", _saveProjectPath);
    exports.on("beforeAppClose", _unwatchProjectRoot);

    // Due to circular dependencies, not safe to call on() directly for other modules' events
    EventDispatcher.on_duringInit(FileViewController, "documentSelectionFocusChange", _documentSelectionFocusChange);
    EventDispatcher.on_duringInit(FileViewController, "fileViewFocusChange", _fileViewControllerChange);
    EventDispatcher.on_duringInit(MainViewManager, "currentFileChange", _currentFileChange);

    // Commands
    CommandManager.register(Strings.CMD_OPEN_FOLDER,      Commands.FILE_OPEN_FOLDER,      openProject);
    CommandManager.register(Strings.CMD_PROJECT_SETTINGS, Commands.FILE_PROJECT_SETTINGS, _projectSettings);
    CommandManager.register(Strings.CMD_FILE_REFRESH,     Commands.FILE_REFRESH,          refreshFileTree);

    // Define the preference to decide how to sort the Project Tree files
    PreferencesManager.definePreference(SORT_DIRECTORIES_FIRST, "boolean", brackets.platform !== "mac", {
        description: Strings.DESCRIPTION_SORT_DIRECTORIES_FIRST
    })
        .on("change", function () {
            actionCreator.setSortDirectoriesFirst(PreferencesManager.get(SORT_DIRECTORIES_FIRST));
        });

    actionCreator.setSortDirectoriesFirst(PreferencesManager.get(SORT_DIRECTORIES_FIRST));

    /**
     * Gets the filesystem object for the current context in the file tree.
     */
    function getContext() {
        return model.getContext();
    }

    /**
     * Starts a rename operation, completing the current operation if there is one.
     *
     * 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 {FileSystemEntry} entry file or directory filesystem object to rename
     * @param {boolean=} isMoved optional flag which indicates whether the entry is being moved instead of renamed
     * @return {$.Promise} a promise resolved when the rename is done.
     */
    renameItemInline = function (entry, isMoved) {
        var d = new $.Deferred();

        model.startRename(entry, isMoved)
            .done(function () {
                d.resolve();
            })
            .fail(function (errorInfo) {
                // Need to do display the error message on the next event loop turn
                // because some errors can come up synchronously and then the dialog
                // is not displayed.
                window.setTimeout(function () {
                    if (isMoved) {
                        switch (errorInfo.type) {
                            case FileSystemError.ALREADY_EXISTS:
                                _showErrorDialog(ERR_TYPE_MOVE, errorInfo.isFolder, Strings.FILE_EXISTS_ERR, errorInfo.fullPath);
                                break;
                            case ProjectModel.ERROR_NOT_IN_PROJECT:
                                _showErrorDialog(ERR_TYPE_MOVE, errorInfo.isFolder, Strings.ERROR_MOVING_NOT_IN_PROJECT, errorInfo.fullPath);
                                break;
                            default:
                                _showErrorDialog(ERR_TYPE_MOVE, errorInfo.isFolder, FileUtils.getFileErrorString(errorInfo.type), errorInfo.fullPath);
                        }
                    } else {
                        switch (errorInfo.type) {
                            case ProjectModel.ERROR_INVALID_FILENAME:
                                _showErrorDialog(ERR_TYPE_INVALID_FILENAME, errorInfo.isFolder, ProjectModel._invalidChars);
                                break;
                            case FileSystemError.ALREADY_EXISTS:
                                _showErrorDialog(ERR_TYPE_RENAME, errorInfo.isFolder, Strings.FILE_EXISTS_ERR, errorInfo.fullPath);
                                break;
                            case ProjectModel.ERROR_NOT_IN_PROJECT:
                                _showErrorDialog(ERR_TYPE_RENAME, errorInfo.isFolder, Strings.ERROR_RENAMING_NOT_IN_PROJECT, errorInfo.fullPath);
                                break;
                            default:
                                _showErrorDialog(ERR_TYPE_RENAME, errorInfo.isFolder, FileUtils.getFileErrorString(errorInfo.type), errorInfo.fullPath);
                        }
                    }
                }, 10);
                d.reject(errorInfo);
            });
        return d.promise();
    };

    /**
     * Returns an Array of all files for this project, optionally including
     * files in the working set that are *not* under the project root. Files are
     * filtered first by ProjectModel.shouldShow(), then by the custom filter
     * argument (if one was provided).
     *
     * @param {function (File, number):boolean=} filter Optional function to filter
     *          the file list (does not filter directory traversal). API matches Array.filter().
     * @param {boolean=} includeWorkingSet If true, include files in the working set
     *          that are not under the project root (*except* for untitled documents).
     * @param {boolean=} sort If true, The files will be sorted by their paths
     *
     * @return {$.Promise} Promise that is resolved with an Array of File objects.
     */
    function getAllFiles(filter, includeWorkingSet, sort) {
        var viewFiles, deferred;

        // The filter and includeWorkingSet params are both optional.
        // Handle the case where filter is omitted but includeWorkingSet is
        // specified.
        if (includeWorkingSet === undefined && typeof (filter) !== "function") {
            includeWorkingSet = filter;
            filter = null;
        }

        if (includeWorkingSet) {
            viewFiles = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES);
        }

        deferred = new $.Deferred();
        model.getAllFiles(filter, viewFiles, sort)
            .done(function (fileList) {
                deferred.resolve(fileList);
            })
            .fail(function (err) {
                if (err === FileSystemError.TOO_MANY_ENTRIES && !_projectWarnedForTooManyFiles) {
                    _showErrorDialog(ERR_TYPE_MAX_FILES);
                    _projectWarnedForTooManyFiles = true;
                }
                // resolve with empty list
                deferred.resolve([]);
            });
        return deferred.promise();
    }

    /**
     * Adds an icon provider. The callback is invoked before each tree item is rendered, and can
     * return content to prepend to the item.
     *
     * @param {!function(!{name:string, fullPath:string, isFile:boolean}):?string|jQuery|DOMNode|Preact.DOM.ins} callback
     * * `name`: the file or directory name
     * * `fullPath`: full path to the file or directory
     * * `isFile`: true if it's a file, false if it's a directory
     * Return a string of HTML text, a Preact.DOM.ins instance, a jQuery object, or a DOM node; or undefined
     * to prepend nothing.
     */
    function addIconProvider(callback) {
        return FileTreeView.addIconProvider(callback);
    }

    /**
     * Adds a CSS class provider, invoked before each tree item is rendered.
     *
     * @param {!function(!{name:string, fullPath:string, isFile:boolean}):?string} callback
     * * `name`: the file or directory name
     * * `fullPath`: full path to the file or directory
     * * `isFile`: true if it's a file, false if it's a directory
     * Return a string containing space-separated CSS class(es) to add, or undefined to leave CSS unchanged.
     */
    function addClassesProvider(callback) {
        return FileTreeView.addClassesProvider(callback);
    }

    /**
     * Forces the file tree to rerender. Typically, the tree only rerenders the portions of the
     * tree that have changed data. If an extension that augments the tree has changes that it
     * needs to display, calling rerenderTree will cause the components for the whole tree to
     * be rerendered.
     */
    function rerenderTree() {
        _renderTree(true);
    }


    // Private API helpful in testing
    exports._actionCreator                = actionCreator;
    exports._RENDER_DEBOUNCE_TIME         = _RENDER_DEBOUNCE_TIME;

    // Private API for use with SidebarView
    exports._setFileTreeSelectionWidth    = _setFileTreeSelectionWidth;

    // Define public API
    exports.getProjectRoot                = getProjectRoot;
    exports.getBaseUrl                    = getBaseUrl;
    exports.setBaseUrl                    = setBaseUrl;
    exports.isWithinProject               = isWithinProject;
    exports.makeProjectRelativeIfPossible = makeProjectRelativeIfPossible;
    exports.shouldShow                    = ProjectModel.shouldShow;
    exports.openProject                   = openProject;
    exports.getFileTreeContext            = getFileTreeContext;
    exports.getSelectedItem               = getSelectedItem;
    exports.getContext                    = getContext;
    exports.getInitialProjectPath         = getInitialProjectPath;
    exports.isWelcomeProjectPath          = isWelcomeProjectPath;
    exports.updateWelcomeProjectPath      = updateWelcomeProjectPath;
    exports.createNewItem                 = createNewItem;
    exports.renameItemInline              = renameItemInline;
    exports.deleteItem                    = deleteItem;
    exports.forceFinishRename             = forceFinishRename;
    exports.showInTree                    = showInTree;
    exports.refreshFileTree               = refreshFileTree;
    exports.getAllFiles                   = getAllFiles;
    exports.getLanguageFilter             = getLanguageFilter;
    exports.addIconProvider               = addIconProvider;
    exports.addClassesProvider            = addClassesProvider;
    exports.rerenderTree                  = rerenderTree;
});