src/project/ProjectManager.js
/*
* 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;
});