src/project/FileTreeViewModel.js
/*
* Copyright (c) 2014 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*unittests: FileTreeViewModel*/
/**
* The view model (or a Store in the Flux terminology) used by the file tree.
*
* Many of the view model's methods are implemented by pure functions, which can be
* helpful for composability. Many of the methods commit the new treeData and send a
* change event when they're done whereas the functions do not do this.
*/
define(function (require, exports, module) {
"use strict";
var Immutable = require("thirdparty/immutable"),
_ = require("thirdparty/lodash"),
EventDispatcher = require("utils/EventDispatcher"),
FileUtils = require("file/FileUtils");
// Constants
var EVENT_CHANGE = "change";
/**
* Determine if an entry from the treeData map is a file.
*
* @param {Immutable.Map} entry entry to test
* @return {boolean} true if this is a file and not a directory
*/
function isFile(entry) {
return entry.get("children") === undefined;
}
/**
* @constructor
*
* Contains the treeData used to generate the file tree and methods used to update that
* treeData.
*
* Instances dispatch the following events:
* - "change" (FileTreeViewModel.EVENT_CHANGE constant): Fired any time there's a change that should be reflected in the view.
*/
function FileTreeViewModel() {
// For convenience in callbacks, make a bound version of this method so that we can
// just refer to it as this._commit when passing in a callback.
this._commit = this._commit.bind(this);
}
EventDispatcher.makeEventDispatcher(FileTreeViewModel.prototype);
/**
* @type {boolean}
*
* Preference for whether directories should all be sorted to the top of listings
*/
FileTreeViewModel.prototype.sortDirectoriesFirst = false;
/**
* @type {Immutable.Map}
* @private
*
* The data for the tree. Some notes about its structure:
*
* * It starts with a Map for the project root's contents.
* * Each directory entry has a `children` key.
* * `children` will be null if the directory has not been loaded
* * An `open` key denotes whether the directory is open
* * Most file entries are just empty maps
* * They can have flags like selected, context, rename, create with state information for the tree
*/
FileTreeViewModel.prototype._treeData = Immutable.Map();
Object.defineProperty(FileTreeViewModel.prototype, "treeData", {
get: function () {
return this._treeData;
}
});
/**
* @private
* @type {Immutable.Map}
* Selection view information determines how the selection bar appears.
*
* * width: visible width of the selection area
* * scrollTop: current scroll position.
* * scrollLeft: current horizontal scroll position
* * offsetTop: top of the scroller element
* * hasSelection: is the selection bar visible?
* * hasContext: is the context bar visible?
*/
FileTreeViewModel.prototype._selectionViewInfo = Immutable.Map({
width: 0,
scrollTop: 0,
scrollLeft: 0,
offsetTop: 0,
hasContext: false,
hasSelection: false
});
Object.defineProperty(FileTreeViewModel.prototype, "selectionViewInfo", {
get: function () {
return this._selectionViewInfo;
}
});
/**
* @private
*
* If the project root changes, we reset the tree data so that everything will be re-read.
*/
FileTreeViewModel.prototype._rootChanged = function () {
this._treeData = Immutable.Map();
};
/**
* @private
*
* The FileTreeViewModel is like a database for storing the directory contents and its
* state moves atomically from one value to the next. This method stores the next version
* of the state, if it has changed, and triggers a change event so that the UI can update.
*
* @param {?Immutable.Map} treeData new treeData state
* @param {?Immutable.Map} selectionViewInfo updated information for the selection/context bars
*/
FileTreeViewModel.prototype._commit = function (treeData, selectionViewInfo) {
var changed = false;
if (treeData && treeData !== this._treeData) {
this._treeData = treeData;
changed = true;
}
if (selectionViewInfo && selectionViewInfo !== this._selectionViewInfo) {
this._selectionViewInfo = selectionViewInfo;
changed = true;
}
if (changed) {
this.trigger(EVENT_CHANGE);
}
};
/**
* @private
*
* Converts a project-relative file path into an object path array suitable for
* `Immutable.Map.getIn` and `Immutable.Map.updateIn`.
*
* The root path is "".
*
* @param {Immutable.Map} treeData
* @param {string} path project relative path to the file or directory. Can include trailing slash.
* @return {Array.<string>|null} Returns null if the path can't be found in the tree, otherwise an array of strings representing the path through the object.
*/
function _filePathToObjectPath(treeData, path) {
if (path === null) {
return null;
} else if (path === "") {
return [];
}
var parts = path.split("/"),
part = parts.shift(),
result = [],
node;
// Step through the parts of the path and the treeData object simultaneously
while (part) {
// We hit the end of the tree without finding our object, so return null
if (!treeData) {
return null;
}
node = treeData.get(part);
// The name represented by `part` isn't in the tree, so return null.
if (node === undefined) {
return null;
}
// We've verified this part, so store it.
result.push(part);
// Pull the next part of the path
part = parts.shift();
// If we haven't passed the end of the path string, then the object we've got in hand
// *should* be a directory. Confirm that and add `children` to the path to move down
// to the next directory level.
if (part) {
treeData = node.get("children");
if (treeData) {
result.push("children");
}
}
}
return result;
}
/**
* @private
*
* See `FileTreeViewModel.isFilePathVisible`
*/
function _isFilePathVisible(treeData, path) {
if (path === null) {
return null;
} else if (path === "") {
return true;
}
var parts = path.split("/"),
part = parts.shift(),
result = [],
node;
while (part) {
if (treeData === null) {
return false;
}
node = treeData.get(part);
if (node === undefined) {
return null;
}
result.push(part);
part = parts.shift();
if (part) {
if (!node.get("open")) {
return false;
}
treeData = node.get("children");
if (treeData) {
result.push("children");
}
}
}
return true;
}
/**
* Determines if a given file path is visible within the tree.
*
* For detailed documentation on how the loop works, see `_filePathToObjectPath` which
* follows the same pattern. This differs from that function in that this one checks for
* the open state of directories and has a different return value.
*
* @param {string} path project relative file path
* @return {boolean|null} true if the given path is currently visible in the tree, null if the given path is not present in the tree.
*/
FileTreeViewModel.prototype.isFilePathVisible = function (path) {
return _isFilePathVisible(this._treeData, path);
};
/**
* Determines if a given path has been loaded.
*
* @param {string} path project relative file or directory path
* @return {boolean} true if the path has been loaded
*/
FileTreeViewModel.prototype.isPathLoaded = function (path) {
var objectPath = _filePathToObjectPath(this._treeData, path);
if (!objectPath) {
return false;
}
// If it's a directory, make sure that its children are loaded
if (_.last(path) === "/") {
var directory = this._treeData.getIn(objectPath);
if (!directory.get("children") || directory.get("notFullyLoaded")) {
return false;
}
}
return true;
};
/**
* @private
*
* See `FileTreeViewModel.getOpenNodes`.
*/
function _getOpenNodes(treeData, projectRootPath) {
var openNodes = [];
function addNodesAtDepth(treeData, parent, depth) {
if (!treeData) {
return;
}
treeData.forEach(function (value, key) {
if (isFile(value)) {
return;
}
var directoryPath = parent + key + "/";
if (value.get("open")) {
var nodeList = openNodes[depth];
if (!nodeList) {
nodeList = openNodes[depth] = [];
}
nodeList.push(directoryPath);
addNodesAtDepth(value.get("children"), directoryPath, depth + 1);
}
});
}
// start at the top of the tree and the first array element
addNodesAtDepth(treeData, projectRootPath, 0);
return openNodes;
}
/**
* @private
* TODO: merge with _getOpenNodes?!
* See `FileTreeViewModel.getChildNodes`.
*/
function _getChildDirectories(treeData, projectRootPath) {
var childDirectories = [];
function addNodesAtDepth(treeData, parent, depth) {
if (!treeData) {
return;
}
treeData.forEach(function (value, key) {
if (!isFile(value)) {
var directoryPath = key + "/";
childDirectories.push(directoryPath);
}
});
}
// start at the top of the tree and the first array element
addNodesAtDepth(treeData, projectRootPath, 0);
return childDirectories;
}
/**
* Creates an array of arrays where each entry of the top-level array has an array
* of paths that are at the same depth in the tree. All of the paths are full paths.
*
* This is used for saving the current set of open nodes to the preferences system
* for restoring on project open.
*
* @param {string} projectRootPath Full path to the project root
* @return {Array.<Array.<string>>} Array of array of full paths, organized by depth in the tree.
*/
FileTreeViewModel.prototype.getOpenNodes = function (projectRootPath) {
return _getOpenNodes(this._treeData, projectRootPath);
};
FileTreeViewModel.prototype.getChildDirectories = function (parent) {
var treeData = this._treeData,
objectPath = _filePathToObjectPath(treeData, parent);
if (!objectPath) {
return;
}
var children;
if (objectPath.length === 0) {
// this is the root of the tree
children = this._treeData;
} else {
objectPath.push("children");
children = this._treeData.getIn(objectPath);
}
return _getChildDirectories(children, parent);
};
/**
* @private
*
* The Immutable package does not have a `setIn` method, which is what this effectively
* provides. This is a simple function that does an `updateIn` on treeData, replacing
* the current value with the new one.
*
* @param {Immutable.Map} treeData
* @param {Array.<string>} objectPath path to object that should be replaced
* @param {Immutable.Map} newValue new value to provide at that path
* @return {Immutable.Map} updated treeData
*/
function _setIn(treeData, objectPath, newValue) {
return treeData.updateIn(objectPath, function (oldValue) {
return newValue;
});
}
/**
* @private
*
* See `FileTreeViewModel.moveMarker`
*/
function _moveMarker(treeData, markerName, oldPath, newPath) {
var objectPath;
if (newPath) {
objectPath = _filePathToObjectPath(treeData, newPath);
}
var newTreeData = treeData;
if (oldPath && oldPath !== newPath) {
var lastObjectPath = _filePathToObjectPath(treeData, oldPath);
if (lastObjectPath) {
newTreeData = newTreeData.updateIn(lastObjectPath, function (entry) {
return entry.delete(markerName);
});
}
}
if (newPath && objectPath && objectPath.length !== 0) {
newTreeData = newTreeData.updateIn(objectPath, function (entry) {
return entry.set(markerName, true);
});
}
return newTreeData;
}
/**
* Moves a boolean marker flag from one file path to another.
*
* @param {string} markerName Name of the flag to set (for example, "selected")
* @param {string|null} oldPath Project relative file path with the location of the marker to move, or null if it's not being moved from elsewhere in the tree
* @param {string|null} newPath Project relative file path with where to place the marker, or null if the marker is being removed from the tree
*/
FileTreeViewModel.prototype.moveMarker = function (markerName, oldPath, newPath) {
var newTreeData = _moveMarker(this._treeData, markerName, oldPath, newPath),
selectionViewInfo = this._selectionViewInfo;
if (markerName === "selected") {
selectionViewInfo = selectionViewInfo.set("hasSelection", !!newPath);
} else if (markerName === "context") {
selectionViewInfo = selectionViewInfo.set("hasContext", !!newPath);
}
this._commit(newTreeData, selectionViewInfo);
};
/**
* Changes the path of the item at the `currentPath` to `newPath`.
*
* @param {string} currentPath project relative file path to the current item
* @param {string} newPath project relative new path to give the item
*/
FileTreeViewModel.prototype.renameItem = function (oldPath, newPath) {
var treeData = this._treeData,
oldObjectPath = _filePathToObjectPath(treeData, oldPath),
newDirectoryPath = FileUtils.getParentPath(newPath),
newObjectPath = _filePathToObjectPath(treeData, newDirectoryPath);
if (!oldObjectPath || !newObjectPath) {
return;
}
var originalName = _.last(oldObjectPath),
newName = FileUtils.getBaseName(newPath),
currentObject;
// Back up to the parent directory
oldObjectPath.pop();
// Remove the oldPath
treeData = treeData.updateIn(oldObjectPath, function (directory) {
currentObject = directory.get(originalName);
directory = directory.delete(originalName);
return directory;
});
// Add the newPath
// If the new directory is not loaded, create a not fully loaded directory there,
// so that we can add the new item as a child of new directory
if (!this.isPathLoaded(newDirectoryPath)) {
treeData = treeData.updateIn(newObjectPath, _createNotFullyLoadedDirectory);
}
// If item moved to root directory, objectPath should not have "children",
// otherwise the objectPath should have "children"
if (newObjectPath.length > 0) {
newObjectPath.push("children");
}
treeData = treeData.updateIn(newObjectPath, function (children) {
return children.set(newName, currentObject);
});
this._commit(treeData);
};
/**
* @private
*
* See `FileTreeViewModel.setDirectoryOpen`
*/
function _setDirectoryOpen(treeData, path, open) {
var objectPath = _filePathToObjectPath(treeData, path),
directory = treeData.getIn(objectPath);
if (!objectPath) {
return {
needsLoading: true,
treeData: treeData
};
}
if (isFile(directory)) {
return;
}
var alreadyOpen = directory.get("open") === true;
if ((alreadyOpen && open) || (!alreadyOpen && !open)) {
return;
}
treeData = treeData.updateIn(objectPath, function (directory) {
if (open) {
return directory.set("open", true);
} else {
return directory.delete("open");
}
});
if (open && (directory.get("children") === null || directory.get("notFullyLoaded"))) {
return {
needsLoading: true,
treeData: treeData
};
}
return {
needsLoading: false,
treeData: treeData
};
}
/**
* Sets the directory at the given path to open or closed. Returns true if the directory
* contents need to be loaded.
*
* @param {string} path Project relative file path to the directory
* @param {boolean} open True to open the directory
* @return {boolean} true if the directory contents need to be loaded.
*/
FileTreeViewModel.prototype.setDirectoryOpen = function (path, open) {
var result = _setDirectoryOpen(this._treeData, path, open);
if (result && result.treeData) {
this._commit(result.treeData);
}
return result ? result.needsLoading : false;
};
/**
* Returns the object at the given file path.
*
* @param {string} path Path to the object
* @return {Immutable.Map=} directory or file object from the tree. Null if it's not found.
*/
FileTreeViewModel.prototype._getObject = function (path) {
var objectPath = _filePathToObjectPath(this._treeData, path);
if (!objectPath) {
return null;
}
return this._treeData.getIn(objectPath);
};
/**
* Closes a subtree path, given by an object path.
*
* @param {Immutable.Map} directory Current directory
* @return {Immutable.Map} new directory
*/
function _closeSubtree(directory) {
directory = directory.delete("open");
var children = directory.get("children");
if (children) {
children.keySeq().forEach(function (name) {
var subdir = children.get(name);
if (!isFile(subdir)) {
subdir = _closeSubtree(subdir);
children = children.set(name, subdir);
}
});
}
directory = directory.set("children", children);
return directory;
}
/**
* Closes the directory at path and recursively closes all of its children.
*
* @param {string} path Path of subtree to close
*/
FileTreeViewModel.prototype.closeSubtree = function (path) {
var treeData = this._treeData,
subtreePath = _filePathToObjectPath(treeData, path);
if (!subtreePath) {
return;
}
var directory = treeData.getIn(subtreePath);
directory = _closeSubtree(directory);
treeData = _setIn(treeData, subtreePath, directory);
this._commit(treeData);
};
/**
* @private
*
* Takes an array of file system entries and merges them into the children map
* of a directory in the view model treeData.
*
* @param {Immutable.Map} children current children in the directory
* @param {Array.<FileSystemEntry>} contents FileSystemEntry objects currently in the directory
* @return {Immutable.Map} updated children
*/
function _mergeContentsIntoChildren(children, contents) {
// We keep track of the names we've seen among the current directory entries to make
// it easy to spot the names that we *haven't* seen (in other words, files that have
// been deleted).
var keysSeen = [];
children = children.withMutations(function (children) {
// Loop through the directory entries
contents.forEach(function (entry) {
keysSeen.push(entry.name);
var match = children.get(entry.name);
if (match) {
// Confirm that a name that used to represent a file and now represents a
// directory (or vice versa) isn't what we've encountered here. If we have
// hit this situation, pretend the current child of treeData doesn't exist
// so we can replace it.
var matchIsFile = isFile(match);
if (matchIsFile !== entry.isFile) {
match = undefined;
}
}
// We've got a new entry that we need to add.
if (!match) {
if (entry.isFile) {
children.set(entry.name, Immutable.Map());
} else {
children.set(entry.name, Immutable.Map({
children: null
}));
}
}
});
// Look at the list of names that we currently have in the treeData that no longer
// appear in the directory and delete those.
var currentEntries = children.keySeq().toJS(),
deletedEntries = _.difference(currentEntries, keysSeen);
deletedEntries.forEach(function (name) {
children.delete(name);
});
});
return children;
}
/**
* @private
*
* Creates a directory object (or updates an existing directory object) to look like one
* that has not yet been loaded, but in which we want to start displaying entries.
* @param {Immutable.Map=} directory Directory entry to update
* @return {Immutable.Map} New or updated directory object
*/
function _createNotFullyLoadedDirectory(directory) {
if (!directory) {
return Immutable.Map({
notFullyLoaded: true,
children: Immutable.Map()
});
}
return directory.merge({
notFullyLoaded: true,
children: Immutable.Map()
});
}
/**
* @private
*
* Creates the directories necessary to display the given path, even if those directories
* do not yet exist in the tree and have not been loaded.
*
* @param {Immutable.Map} treeData
* @param {string} path Path to the final directory to be added in the tree
* @return {{treeData: Immutable.Map, objectPath: Array.<string>}} updated treeData and object path to the created object
*/
function _createIntermediateDirectories(treeData, path) {
var objectPath = [],
result = {
objectPath: objectPath,
treeData: treeData
},
treePointer = treeData;
if (path === "") {
return result;
}
var parts = path.split("/"),
part = parts.shift(),
node;
while (part) {
if (treePointer === null) {
return null;
}
node = treePointer.get(part);
objectPath.push(part);
// This directory is missing, so create it.
if (node === undefined) {
treeData = treeData.updateIn(objectPath, _createNotFullyLoadedDirectory);
node = treeData.getIn(objectPath);
}
part = parts.shift();
if (part) {
treePointer = node.get("children");
if (treePointer) {
objectPath.push("children");
} else {
// The directory is there, but the directory hasn't been loaded.
// Update the directory to be a `notFullyLoaded` directory.
treeData = treeData.updateIn(objectPath, _createNotFullyLoadedDirectory);
objectPath.push("children");
treePointer = treeData.getIn(objectPath);
}
}
}
result.treeData = treeData;
return result;
}
/**
* Updates the directory at the given path with the new contents. If the parent directories
* of this directory have not been loaded yet, they will be created. This allows directories
* to be loaded in any order.
*
* @param {string} Project relative path to the directory that is being updated.
* @param {Array.<FileSystemEntry>} Current contents of the directory
*/
FileTreeViewModel.prototype.setDirectoryContents = function (path, contents) {
path = FileUtils.stripTrailingSlash(path);
var intermediate = _createIntermediateDirectories(this._treeData, path),
objectPath = intermediate.objectPath,
treeData = intermediate.treeData;
if (objectPath === null) {
return;
}
var directory = treeData.getIn(objectPath),
children = directory;
// The root directory doesn't need this special handling.
if (path !== "") {
// The user of this API passed in a path to a file rather than a directory.
// Perhaps this should be an exception?
if (isFile(directory)) {
return;
}
// If the directory had been created previously as `notFullyLoaded`, we can
// remove that flag now because this is the step that is loading the directory.
if (directory.get("notFullyLoaded")) {
directory = directory.delete("notFullyLoaded");
}
if (!directory.get("children")) {
directory = directory.set("children", Immutable.Map());
}
treeData = _setIn(treeData, objectPath, directory);
objectPath.push("children");
children = directory.get("children");
}
children = _mergeContentsIntoChildren(children, contents);
treeData = _setIn(treeData, objectPath, children);
this._commit(treeData);
};
/**
* @private
*
* Opens the directories along the provided path.
*
* @param {Immutable.Map} treeData
* @param {string} path Path to open
*/
function _openPath(treeData, path) {
var objectPath = _filePathToObjectPath(treeData, path);
function setOpen(node) {
return node.set("open", true);
}
while (objectPath && objectPath.length) {
var node = treeData.getIn(objectPath);
if (isFile(node)) {
objectPath.pop();
} else {
if (!node.get("open")) {
treeData = treeData.updateIn(objectPath, setOpen);
}
objectPath.pop();
if (objectPath.length) {
objectPath.pop();
}
}
}
return treeData;
}
/**
* Opens the directories along the given path.
*
* @param {string} path Project-relative path
*/
FileTreeViewModel.prototype.openPath = function (path) {
this._commit(_openPath(this._treeData, path));
};
/**
* @private
*
* See FileTreeViewModel.createPlaceholder
*/
function _createPlaceholder(treeData, basedir, name, isFolder, options) {
options = options || {};
var parentPath = _filePathToObjectPath(treeData, basedir);
if (!parentPath) {
return;
}
var newObject = {
};
if (!options.notInCreateMode) {
newObject.creating = true;
}
if (isFolder) {
// If we're creating a folder, then we know it's empty.
// But if we're not in create mode, (we're adding a folder based on an
// FS event), we don't know anything about the new directory's children.
if (options.notInCreateMode) {
newObject.children = null;
} else {
newObject.children = Immutable.Map();
}
}
var newFile = Immutable.Map(newObject);
if (!options.doNotOpen) {
treeData = _openPath(treeData, basedir);
}
if (parentPath.length > 0) {
var childrenPath = _.clone(parentPath);
childrenPath.push("children");
treeData = treeData.updateIn(childrenPath, function (children) {
return children.set(name, newFile);
});
} else {
treeData = treeData.set(name, newFile);
}
return treeData;
}
/**
* Creates a placeholder file or directory that appears in the tree so that the user
* can provide a name for the new entry.
*
* @param {string} basedir Directory that contains the new file or folder
* @param {string} name Initial name to give the new entry
* @param {boolean} isFolder true if the entry being created is a folder
*/
FileTreeViewModel.prototype.createPlaceholder = function (basedir, name, isFolder) {
var treeData = _createPlaceholder(this._treeData, basedir, name, isFolder);
this._commit(treeData);
};
/**
* @private
*
* See FileTreeViewModel.deleteAtPath
*/
function _deleteAtPath(treeData, path) {
var objectPath = _filePathToObjectPath(treeData, path);
if (!objectPath) {
return;
}
var originalName = _.last(objectPath);
// Back up to the parent directory
objectPath.pop();
treeData = treeData.updateIn(objectPath, function (directory) {
directory = directory.delete(originalName);
return directory;
});
return treeData;
}
/**
* Deletes the entry at the given path.
*
* @param {string} path Project-relative path to delete
*/
FileTreeViewModel.prototype.deleteAtPath = function (path) {
var treeData = _deleteAtPath(this._treeData, path);
if (treeData) {
this._commit(treeData);
}
};
/**
* @private
*
* Adds a timestamp to an entry (much like the "touch" command) to force a given entry
* to rerender.
*/
function _addTimestamp(item) {
return item.set("_timestamp", new Date().getTime());
}
/**
* @private
*
* Sets/updates the timestamp on the file paths listed in the `changed` array.
*
* @param {Immutable.Map} treeData
* @param {Array.<string>} changed list of changed project-relative file paths
* @return {Immutable.Map} revised treeData
*/
function _markAsChanged(treeData, changed) {
changed.forEach(function (filePath) {
var objectPath = _filePathToObjectPath(treeData, filePath);
if (objectPath) {
treeData = treeData.updateIn(objectPath, _addTimestamp);
}
});
return treeData;
}
/**
* @private
*
* Adds entries at the paths listed in the `added` array. Directories should have a trailing slash.
*
* @param {Immutable.Map} treeData
* @param {Array.<string>} added list of new project-relative file paths
* @return {Immutable.Map} revised treeData
*/
function _addNewEntries(treeData, added) {
added.forEach(function (filePath) {
var isFolder = _.last(filePath) === "/";
filePath = isFolder ? filePath.substr(0, filePath.length - 1) : filePath;
var parentPath = FileUtils.getDirectoryPath(filePath),
parentObjectPath = _filePathToObjectPath(treeData, parentPath),
basename = FileUtils.getBaseName(filePath);
if (parentObjectPath) {
// Verify that the children are loaded
var childrenPath = _.clone(parentObjectPath);
childrenPath.push("children");
if (treeData.getIn(childrenPath) === null) {
return;
}
treeData = _createPlaceholder(treeData, parentPath, basename, isFolder, {
notInCreateMode: true,
doNotOpen: true
});
}
});
return treeData;
}
/**
* Applies changes to the tree. The `changes` object can have one or more of the following keys which all
* have arrays of project-relative paths as their values:
*
* * `changed`: entries that have changed in some way that should be re-rendered
* * `added`: new entries that need to appear in the tree
* * `removed`: entries that have been deleted from the tree
*
* @param {{changed: Array.<string>=, added: Array.<string>=, removed: Array.<string>=}}
*/
FileTreeViewModel.prototype.processChanges = function (changes) {
var treeData = this._treeData;
if (changes.changed) {
treeData = _markAsChanged(treeData, changes.changed);
}
if (changes.added) {
treeData = _addNewEntries(treeData, changes.added);
}
if (changes.removed) {
changes.removed.forEach(function (path) {
treeData = _deleteAtPath(treeData, path);
});
}
this._commit(treeData);
};
/**
* Makes sure that the directory exists. This will create a directory object (unloaded)
* if the directory does not already exist. A change message is also fired in that case.
*
* This is useful for file system events which can refer to a directory that we don't
* know about already.
*
* @param {string} path Project-relative path to the directory
*/
FileTreeViewModel.prototype.ensureDirectoryExists = function (path) {
var treeData = this._treeData,
pathWithoutSlash = FileUtils.stripTrailingSlash(path),
parentPath = FileUtils.getDirectoryPath(pathWithoutSlash),
name = pathWithoutSlash.substr(parentPath.length),
targetPath = [];
if (parentPath) {
targetPath = _filePathToObjectPath(treeData, parentPath);
if (!targetPath) {
return;
}
targetPath.push("children");
if (!treeData.getIn(targetPath)) {
return;
}
}
targetPath.push(name);
if (treeData.getIn(targetPath)) {
return;
}
treeData = _setIn(treeData, targetPath, Immutable.Map({
children: null
}));
this._commit(treeData);
};
/**
* Sets the value of the `sortDirectoriesFirst` flag which tells to view that directories
* should be listed before the alphabetical listing of files.
*
* @param {boolean} sortDirectoriesFirst True if directories should be displayed first
*/
FileTreeViewModel.prototype.setSortDirectoriesFirst = function (sortDirectoriesFirst) {
if (sortDirectoriesFirst !== this.sortDirectoriesFirst) {
this.sortDirectoriesFirst = sortDirectoriesFirst;
this.trigger(EVENT_CHANGE);
}
};
/**
* Sets the width of the selection bar.
*
* @param {int} width New width
*/
FileTreeViewModel.prototype.setSelectionWidth = function (width) {
var selectionViewInfo = this._selectionViewInfo;
selectionViewInfo = selectionViewInfo.set("width", width);
this._commit(null, selectionViewInfo);
};
/**
* Sets the scroll position of the file tree to help position the selection bar.
* SPECIAL CASE NOTE: this does not trigger a change event because this data is
* explicitly set in the rendering process (see ProjectManager._renderTree).
*
* @param {int} scrollWidth width of the tree content
* @param {int} scrollTop Scroll position
* @param {int=} scrollLeft Horizontal scroll position
* @param {int=} offsetTop top of the scroller
*/
FileTreeViewModel.prototype.setSelectionScrollerInfo = function (scrollWidth, scrollTop, scrollLeft, offsetTop) {
this._selectionViewInfo = this._selectionViewInfo.set("scrollWidth", scrollWidth);
this._selectionViewInfo = this._selectionViewInfo.set("scrollTop", scrollTop);
if (scrollLeft !== undefined) {
this._selectionViewInfo = this._selectionViewInfo.set("scrollLeft", scrollLeft);
}
if (offsetTop !== undefined) {
this._selectionViewInfo = this._selectionViewInfo.set("offsetTop", offsetTop);
}
// Does not emit change event. See SPECIAL CASE NOTE in docstring above.
};
// Private API
exports.EVENT_CHANGE = EVENT_CHANGE;
exports._filePathToObjectPath = _filePathToObjectPath;
exports._isFilePathVisible = _isFilePathVisible;
exports._createPlaceholder = _createPlaceholder;
// Public API
exports.isFile = isFile;
exports.FileTreeViewModel = FileTreeViewModel;
});