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.
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;
}
function _addTimestamp(item) {
return item.set("_timestamp", new Date().getTime());
}
Closes a subtree path, given by an object path.
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;
}
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;
}
function _createNotFullyLoadedDirectory(directory) {
if (!directory) {
return Immutable.Map({
notFullyLoaded: true,
children: Immutable.Map()
});
}
return directory.merge({
notFullyLoaded: true,
children: Immutable.Map()
});
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
function _markAsChanged(treeData, changed) {
changed.forEach(function (filePath) {
var objectPath = _filePathToObjectPath(treeData, filePath);
if (objectPath) {
treeData = treeData.updateIn(objectPath, _addTimestamp);
}
});
return treeData;
}
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;
}
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;
}
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;
}
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
};
}
function _setIn(treeData, objectPath, newValue) {
return treeData.updateIn(objectPath, function (oldValue) {
return newValue;
});
}
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);
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;
}
});
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);
}
};
Returns the object at the given file path.
FileTreeViewModel.prototype._getObject = function (path) {
var objectPath = _filePathToObjectPath(this._treeData, path);
if (!objectPath) {
return null;
}
return this._treeData.getIn(objectPath);
};
FileTreeViewModel.prototype._rootChanged = function () {
this._treeData = Immutable.Map();
};
Closes the directory at path and recursively closes all of its children.
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);
};
Creates a placeholder file or directory that appears in the tree so that the user can provide a name for the new entry.
FileTreeViewModel.prototype.createPlaceholder = function (basedir, name, isFolder) {
var treeData = _createPlaceholder(this._treeData, basedir, name, isFolder);
this._commit(treeData);
};
Deletes the entry at the given path.
FileTreeViewModel.prototype.deleteAtPath = function (path) {
var treeData = _deleteAtPath(this._treeData, path);
if (treeData) {
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.
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);
};
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.
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);
};
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.
FileTreeViewModel.prototype.isFilePathVisible = function (path) {
return _isFilePathVisible(this._treeData, path);
};
Determines if a given 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;
};
Moves a boolean marker flag from one file path to another.
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);
};
Opens the directories along the given path.
FileTreeViewModel.prototype.openPath = function (path) {
this._commit(_openPath(this._treeData, path));
};
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-renderedadded
: new entries that need to appear in the treeremoved
: entries that have been deleted from the tree 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);
};
Changes the path of the item at the currentPath
to newPath
.
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);
};
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.
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);
};
Sets the directory at the given path to open or closed. Returns 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;
};
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).
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;
});
Sets the width of the selection bar.
FileTreeViewModel.prototype.setSelectionWidth = function (width) {
var selectionViewInfo = this._selectionViewInfo;
selectionViewInfo = selectionViewInfo.set("width", width);
this._commit(null, selectionViewInfo);
};
Sets the value of the sortDirectoriesFirst
flag which tells to view that directories
should be listed before the alphabetical listing of files.
FileTreeViewModel.prototype.setSortDirectoriesFirst = function (sortDirectoriesFirst) {
if (sortDirectoriesFirst !== this.sortDirectoriesFirst) {
this.sortDirectoriesFirst = sortDirectoriesFirst;
this.trigger(EVENT_CHANGE);
}
};