src/project/FileTreeView.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: FileTreeView*/
/**
* This is the view layer (template) for the file tree in the sidebar. It takes a FileTreeViewModel
* and renders it to the given element using Preact. User actions are signaled via an ActionCreator
* (in the Flux sense).
*/
define(function (require, exports, module) {
"use strict";
var Preact = require("thirdparty/preact"),
Classnames = require("thirdparty/classnames"),
Immutable = require("thirdparty/immutable"),
_ = require("thirdparty/lodash"),
FileUtils = require("file/FileUtils"),
LanguageManager = require("language/LanguageManager"),
FileTreeViewModel = require("project/FileTreeViewModel"),
ViewUtils = require("utils/ViewUtils"),
KeyEvent = require("utils/KeyEvent"),
PreferencesManager = require("preferences/PreferencesManager");
var DOM = Preact.DOM;
/**
* @private
* @type {Immutable.Map}
*
* Stores the file tree extensions for adding classes and icons. The keys of the map
* are the "categories" of the extensions and values are vectors of the callback functions.
*/
var _extensions = Immutable.Map();
/**
* @private
* @type {string}
*
* Stores the path of the currently dragged item in the filetree.
*/
var _draggedItemPath;
// Constants
// Time range from first click to second click to invoke renaming.
var CLICK_RENAME_MINIMUM = 500,
RIGHT_MOUSE_BUTTON = 2,
LEFT_MOUSE_BUTTON = 0;
var INDENTATION_WIDTH = 10;
/**
* @private
*
* Returns the name of a file without its extension.
*
* @param {string} fullname The complete name of the file (not including the rest of the path)
* @param {string} extension The file extension
* @return {string} The fullname without the extension
*/
function _getName(fullname, extension) {
return extension !== "" ? fullname.substring(0, fullname.length - extension.length - 1) : fullname;
}
/**
* Mixin that allows a component to compute the full path to its directory entry.
*/
var pathComputer = {
/**
* Computes the full path of the file represented by this input.
*/
myPath: function () {
var result = this.props.parentPath + this.props.name;
// Add trailing slash for directories
if (!FileTreeViewModel.isFile(this.props.entry) && _.last(result) !== "/") {
result += "/";
}
return result;
}
};
/**
* @private
*
* Gets an appropriate width given the text provided.
*
* @param {string} text Text to measure
* @return {int} Width to use
*/
function _measureText(text) {
var measuringElement = $("<span />", { css : { "position" : "absolute", "top" : "-200px", "left" : "-1000px", "visibility" : "hidden", "white-space": "pre" } }).appendTo("body");
measuringElement.text("pW" + text);
var width = measuringElement.width();
measuringElement.remove();
return width;
}
/**
* @private
*
* Create an appropriate div based "thickness" to indent the tree correctly.
*
* @param {int} depth The depth of the current node.
* @return {PreactComponent} The resulting div.
*/
function _createThickness(depth) {
return DOM.div({
style: {
display: "inline-block",
width: INDENTATION_WIDTH * depth
}
});
}
/**
* @private
*
* Create, and indent correctly, the arrow icons used for the folders.
*
* @param {int} depth The depth of the current node.
* @return {PreactComponent} The resulting ins.
*/
function _createAlignedIns(depth) {
return DOM.ins({
className: "jstree-icon",
style: {
marginLeft: INDENTATION_WIDTH * depth
}
});
}
/**
* This is a mixin that provides rename input behavior. It is responsible for taking keyboard input
* and invoking the correct action based on that input.
*/
var renameBehavior = {
/**
* Stop clicks from propagating so that clicking on the rename input doesn't
* cause directories to collapse.
*/
handleClick: function (e) {
e.stopPropagation();
if (e.button !== LEFT_MOUSE_BUTTON) {
e.preventDefault();
}
},
/**
* If the user presses enter or escape, we either successfully complete or cancel, respectively,
* the rename or create operation that is underway.
*/
handleKeyDown: function (e) {
if (e.keyCode === KeyEvent.DOM_VK_ESCAPE) {
this.props.actions.cancelRename();
} else if (e.keyCode === KeyEvent.DOM_VK_RETURN) {
this.props.actions.performRename();
}
},
/**
* The rename or create operation can be completed or canceled by actions outside of
* this component, so we keep the model up to date by sending every update via an action.
*/
handleInput: function (e) {
this.props.actions.setRenameValue(this.props.parentPath + this.refs.name.value.trim());
if (e.keyCode !== KeyEvent.DOM_VK_LEFT &&
e.keyCode !== KeyEvent.DOM_VK_RIGHT) {
// update the width of the input field
var node = this.refs.name,
newWidth = _measureText(node.value);
$(node).width(newWidth);
}
},
/**
* If we leave the field for any reason, complete the rename.
*/
handleBlur: function () {
this.props.actions.performRename();
}
};
/**
* This is a mixin that provides drag and drop move function.
*/
var dragAndDrop = {
handleDrag: function(e) {
// Disable drag when renaming
if (this.props.entry.get("rename")) {
e.preventDefault();
e.stopPropagation();
return false;
}
// In newer CEF versions, the drag and drop data from the event
// (i.e. e.dataTransfer.getData) cannot be used to read data in dragOver event,
// so store the drag and drop data in a global variable to read it in the dragOver
// event.
_draggedItemPath = this.myPath();
// Pass the dragged item path.
e.dataTransfer.setData("text", JSON.stringify({
path: _draggedItemPath
}));
this.props.actions.dragItem(this.myPath());
this.setDragImage(e);
e.stopPropagation();
},
handleDrop: function(e) {
var data = JSON.parse(e.dataTransfer.getData("text"));
this.props.actions.moveItem(data.path, this.myPath());
this.setDraggedOver(false);
this.clearDragTimeout();
e.stopPropagation();
},
handleDragEnd: function(e) {
this.clearDragTimeout();
},
handleDragOver: function(e) {
var data = e.dataTransfer.getData("text"),
path;
if (data) {
path = JSON.parse(data).path;
} else {
path = _draggedItemPath;
}
if (path === this.myPath() || FileUtils.getParentPath(path) === this.myPath()) {
e.preventDefault();
e.stopPropagation();
return;
}
var self = this;
this.setDraggedOver(true);
// Open the directory tree when item is dragged over a directory
if (!this.dragOverTimeout) {
this.dragOverTimeout = window.setTimeout(function() {
self.props.actions.setDirectoryOpen(self.myPath(), true);
self.dragOverTimeout = null;
}, 800);
}
e.preventDefault(); // Allow the drop
e.stopPropagation();
},
handleDragLeave: function(e) {
this.setDraggedOver(false);
this.clearDragTimeout();
},
clearDragTimeout: function() {
if (this.dragOverTimeout) {
clearTimeout(this.dragOverTimeout);
this.dragOverTimeout = null;
}
},
setDraggedOver: function(draggedOver) {
if (this.state.draggedOver !== draggedOver) {
this.setState({
draggedOver: draggedOver
});
}
},
setDragImage: function(e) {
var div = window.document.createElement('div');
div.textContent = this.props.name;
div.classList.add('jstree-dragImage');
window.document.body.appendChild(div);
e.dataTransfer.setDragImage(div, -10, -10);
setTimeout(function() {
window.document.body.removeChild(div);
}, 0);
}
};
/**
* @private
*
* This component presents an input field to the user for renaming a file.
*
* Props:
* * parentPath: the full path of the directory containing this file
* * name: the name of the file, including the extension
* * actions: the action creator responsible for communicating actions the user has taken
*/
var fileRenameInput = Preact.createFactory(Preact.createClass({
mixins: [renameBehavior],
/**
* When this component is displayed, we scroll it into view and select the portion
* of the filename that excludes the extension.
*/
componentDidMount: function () {
var fullname = this.props.name,
extension = LanguageManager.getCompoundFileExtension(fullname);
var node = this.refs.name;
node.setSelectionRange(0, _getName(fullname, extension).length);
node.focus(); // set focus on the rename input
ViewUtils.scrollElementIntoView($("#project-files-container"), $(node), true);
},
render: function () {
var width = _measureText(this.props.name);
return DOM.input({
className: "jstree-rename-input",
type: "text",
defaultValue: this.props.name,
autoFocus: true,
onKeyDown: this.handleKeyDown,
onInput: this.handleInput,
onClick: this.handleClick,
onBlur: this.handleBlur,
style: {
width: width
},
ref: "name"
});
}
}));
/**
* @private
*
* This mixin handles right click (or control click on Mac) action to make a file
* the "context" object for performing operations like rename.
*/
var contextSettable = {
/**
* Send matching mouseDown events to the action creator as a setContext action.
*/
handleMouseDown: function (e) {
e.stopPropagation();
if (e.button === RIGHT_MOUSE_BUTTON ||
(this.props.platform === "mac" && e.button === LEFT_MOUSE_BUTTON && e.ctrlKey)) {
this.props.actions.setContext(this.myPath());
e.preventDefault();
return;
}
// Return true only for mouse down in rename mode.
if (this.props.entry.get("rename")) {
return;
}
}
};
/**
* @private
*
* Returns true if the value is defined (used in `.filter`)
*
* @param {Object} value value to test
* @return {boolean} true if value is defined
*/
function isDefined(value) {
return value !== undefined;
}
/**
* Mixin for components that support the "icons" and "addClass" extension points.
* `fileNode` and `directoryNode` support this.
*/
var extendable = {
/**
* Calls the icon providers to get the collection of icons (most likely just one) for
* the current file or directory.
*
* @return {Array.<PreactComponent>} icon components to render
*/
getIcons: function () {
var result,
extensions = this.props.extensions;
if (extensions && extensions.get("icons")) {
var data = this.getDataForExtension();
result = extensions.get("icons").map(function (callback) {
try {
var result = callback(data);
if (result && !Preact.isValidElement(result)) {
result = Preact.DOM.span({
dangerouslySetInnerHTML: {
__html: $(result)[0].outerHTML
}
});
}
return result; // by this point, returns either undefined or a Preact object
} catch (e) {
console.error("Exception thrown in FileTreeView icon provider: " + e, e.stack);
}
}).filter(isDefined).toArray();
}
if (!result || result.length === 0) {
result = [DOM.ins({
className: "jstree-icon"
}, " ")];
}
return result;
},
/**
* Calls the addClass providers to get the classes (in string form) to add for the current
* file or directory.
*
* @param {string} classes Initial classes for this node
* @return {string} classes for the current node
*/
getClasses: function (classes) {
var extensions = this.props.extensions;
if (extensions && extensions.get("addClass")) {
var data = this.getDataForExtension();
classes = classes + " " + extensions.get("addClass").map(function (callback) {
try {
return callback(data);
} catch (e) {
console.error("Exception thrown in FileTreeView addClass provider: " + e, e.stack);
}
}).filter(isDefined).toArray().join(" ");
}
return classes;
}
};
/**
* @private
*
* Component to display a file in the tree.
*
* Props:
* * parentPath: the full path of the directory containing this file
* * name: the name of the file, including the extension
* * entry: the object with the relevant metadata for the file (whether it's selected or is the context file)
* * actions: the action creator responsible for communicating actions the user has taken
* * extensions: registered extensions for the file tree
* * forceRender: causes the component to run render
*/
var fileNode = Preact.createFactory(Preact.createClass({
mixins: [contextSettable, pathComputer, extendable, dragAndDrop],
/**
* Ensures that we always have a state object.
*/
getInitialState: function () {
return {
clickTimer: null
};
},
/**
* Thanks to immutable objects, we can just do a start object identity check to know
* whether or not we need to re-render.
*/
shouldComponentUpdate: function (nextProps, nextState) {
return nextProps.forceRender ||
this.props.entry !== nextProps.entry ||
this.props.extensions !== nextProps.extensions;
},
/**
* If this node is newly selected, scroll it into view. Also, move the selection or
* context boxes as appropriate.
*/
componentDidUpdate: function (prevProps, prevState) {
var wasSelected = prevProps.entry.get("selected"),
isSelected = this.props.entry.get("selected");
if (isSelected && !wasSelected) {
// TODO: This shouldn't really know about project-files-container
// directly. It is probably the case that our Preact tree should actually
// start with project-files-container instead of just the interior of
// project-files-container and then the file tree will be one self-contained
// functional unit.
ViewUtils.scrollElementIntoView($("#project-files-container"), $(Preact.findDOMNode(this)), true);
} else if (!isSelected && wasSelected && this.state.clickTimer !== null) {
this.clearTimer();
}
},
clearTimer: function () {
if (this.state.clickTimer !== null) {
window.clearTimeout(this.state.clickTimer);
this.setState({
clickTimer: null
});
}
},
startRename: function () {
if (!this.props.entry.get("rename")) {
this.props.actions.startRename(this.myPath());
}
this.clearTimer();
},
/**
* When the user clicks on the node, we'll either select it or, if they've clicked twice
* with a bit of delay in between, we'll invoke the `startRename` action.
*/
handleClick: function (e) {
// If we're renaming, allow the click to go through to the rename input.
if (this.props.entry.get("rename")) {
e.stopPropagation();
return;
}
if (e.button !== LEFT_MOUSE_BUTTON) {
return;
}
if (this.props.entry.get("selected") && !e.ctrlKey) {
if (this.state.clickTimer === null && !this.props.entry.get("rename")) {
var timer = window.setTimeout(this.startRename, CLICK_RENAME_MINIMUM);
this.setState({
clickTimer: timer
});
}
} else {
var language = LanguageManager.getLanguageForPath(this.myPath()),
doNotOpen = false;
if (language && language.isBinary() && "image" !== language.getId() &&
FileUtils.shouldOpenInExternalApplication(
FileUtils.getFileExtension(this.myPath()).toLowerCase()
)
) {
doNotOpen = true;
}
this.props.actions.setSelected(this.myPath(), doNotOpen);
}
e.stopPropagation();
e.preventDefault();
},
/**
* When the user double clicks, we will select this file and add it to the working
* set (via the `selectInWorkingSet` action.)
*/
handleDoubleClick: function () {
if (!this.props.entry.get("rename")) {
if (this.state.clickTimer !== null) {
this.clearTimer();
}
if (FileUtils.shouldOpenInExternalApplication(
FileUtils.getFileExtension(this.myPath()).toLowerCase()
)) {
this.props.actions.openWithExternalApplication(this.myPath());
return;
}
this.props.actions.selectInWorkingSet(this.myPath());
}
},
/**
* Create the data object to pass to extensions.
*
* @return {!{name:string, isFile:boolean, fullPath:string}} Data for extensions
*/
getDataForExtension: function () {
return {
name: this.props.name,
isFile: true,
fullPath: this.myPath()
};
},
render: function () {
var fullname = this.props.name,
extension = LanguageManager.getCompoundFileExtension(fullname),
name = _getName(fullname, extension);
// React automatically wraps content in a span element whereas preact doesn't, so do it manually
if (name) {
name = DOM.span({}, name);
}
if (extension) {
extension = DOM.span({
className: "extension"
}, "." + extension);
}
var nameDisplay,
cx = Classnames;
var fileClasses = cx({
'jstree-clicked selected-node': this.props.entry.get("selected"),
'context-node': this.props.entry.get("context")
});
var liArgs = [
{
className: this.getClasses("jstree-leaf"),
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onDoubleClick: this.handleDoubleClick,
draggable: true,
onDragStart: this.handleDrag
},
DOM.ins({
className: "jstree-icon"
})
];
var thickness = _createThickness(this.props.depth);
if (this.props.entry.get("rename")) {
liArgs.push(thickness);
nameDisplay = fileRenameInput({
actions: this.props.actions,
entry: this.props.entry,
name: this.props.name,
parentPath: this.props.parentPath
});
} else {
// Need to flatten the argument list because getIcons returns an array
var aArgs = _.flatten([{
href: "#",
className: fileClasses
}, thickness, this.getIcons(), name, extension]);
nameDisplay = DOM.a.apply(DOM.a, aArgs);
}
liArgs.push(nameDisplay);
return DOM.li.apply(DOM.li, liArgs);
}
}));
/**
* @private
*
* Creates a comparison function for sorting a directory's contents with directories
* appearing before files.
*
* We're sorting the keys of the directory (the names) based partly on the values,
* so we use a closure to capture the map itself so that we can look up the
* values as needed.
*
* @param {Immutable.Map} contents The directory's contents
* @return {function(string,string)} Comparator that sorts directories first.
*/
function _buildDirsFirstComparator(contents) {
function _dirsFirstCompare(a, b) {
var aIsFile = FileTreeViewModel.isFile(contents.get(a)),
bIsFile = FileTreeViewModel.isFile(contents.get(b));
if (!aIsFile && bIsFile) {
return -1;
} else if (aIsFile && !bIsFile) {
return 1;
} else {
return FileUtils.compareFilenames(a, b);
}
}
return _dirsFirstCompare;
}
/**
* @private
*
* Sort a directory either alphabetically or with subdirectories listed first.
*
* @param {Immutable.Map} contents the directory's contents
* @param {boolean} dirsFirst true to sort subdirectories first
* @return {Immutable.Map} sorted mapping
*/
function _sortDirectoryContents(contents, dirsFirst) {
if (dirsFirst) {
return contents.keySeq().sort(_buildDirsFirstComparator(contents));
} else {
return contents.keySeq().sort(FileUtils.compareFilenames);
}
}
// Forward references to keep JSLint happy.
var directoryNode, directoryContents;
/**
* @private
*
* Component that provides the input for renaming a directory.
*
* Props:
* * parentPath: the full path of the directory containing this file
* * name: the name of the file, including the extension
* * actions: the action creator responsible for communicating actions the user has taken
*/
var directoryRenameInput = Preact.createFactory(Preact.createClass({
mixins: [renameBehavior],
/**
* When this component is displayed, we scroll it into view and select the folder name.
*/
componentDidMount: function () {
var fullname = this.props.name;
var node = this.refs.name;
node.setSelectionRange(0, fullname.length);
node.focus(); // set focus on the rename input
ViewUtils.scrollElementIntoView($("#project-files-container"), $(node), true);
},
render: function () {
var width = _measureText(this.props.name);
return DOM.input({
className: "jstree-rename-input",
type: "text",
defaultValue: this.props.name,
autoFocus: true,
onKeyDown: this.handleKeyDown,
onInput: this.handleInput,
onBlur: this.handleBlur,
style: {
width: width
},
onClick: this.handleClick,
ref: "name"
});
}
}));
/**
* @private
*
* Displays a directory (but not its contents) in the tree.
*
* Props:
* * parentPath: the full path of the directory containing this file
* * name: the name of the directory
* * entry: the object with the relevant metadata for the file (whether it's selected or is the context file)
* * actions: the action creator responsible for communicating actions the user has taken
* * sortDirectoriesFirst: whether the directories should be displayed first when listing the contents of a directory
* * extensions: registered extensions for the file tree
* * forceRender: causes the component to run render
*/
directoryNode = Preact.createFactory(Preact.createClass({
mixins: [contextSettable, pathComputer, extendable, dragAndDrop],
getInitialState: function() {
return {
draggedOver: false
};
},
/**
* We need to update this component if the sort order changes or our entry object
* changes. Thanks to immutability, if any of the directory contents change, our
* entry object will change.
*/
shouldComponentUpdate: function (nextProps, nextState) {
return nextProps.forceRender ||
this.props.entry !== nextProps.entry ||
this.props.sortDirectoriesFirst !== nextProps.sortDirectoriesFirst ||
this.props.extensions !== nextProps.extensions ||
(nextState !== undefined && this.state.draggedOver !== nextState.draggedOver);
},
/**
* If you click on a directory, it will toggle between open and closed.
*/
handleClick: function (event) {
if (this.props.entry.get("rename")) {
event.stopPropagation();
return;
}
if (event.button !== LEFT_MOUSE_BUTTON) {
return;
}
var isOpen = this.props.entry.get("open"),
setOpen = isOpen ? false : true;
if (event.metaKey || event.ctrlKey) {
// ctrl-alt-click toggles this directory and its children
if (event.altKey) {
if (setOpen) {
// when opening, we only open the immediate children because
// opening a whole subtree could be really slow (consider
// a `node_modules` directory, for example).
this.props.actions.toggleSubdirectories(this.myPath(), setOpen);
this.props.actions.setDirectoryOpen(this.myPath(), setOpen);
} else {
// When closing, we recursively close the whole subtree.
this.props.actions.closeSubtree(this.myPath());
}
} else {
// ctrl-click toggles the sibling directories
this.props.actions.toggleSubdirectories(this.props.parentPath, setOpen);
}
} else {
// directory toggle with no modifier
this.props.actions.setDirectoryOpen(this.myPath(), setOpen);
}
event.stopPropagation();
event.preventDefault();
},
/**
* Create the data object to pass to extensions.
*
* @return {{name: {string}, isFile: {boolean}, fullPath: {string}}} Data for extensions
*/
getDataForExtension: function () {
return {
name: this.props.name,
isFile: false,
fullPath: this.myPath()
};
},
render: function () {
var entry = this.props.entry,
nodeClass,
childNodes,
children = entry.get("children"),
isOpen = entry.get("open");
if (isOpen && children) {
nodeClass = "open";
childNodes = directoryContents({
depth: this.props.depth + 1,
parentPath: this.myPath(),
contents: children,
extensions: this.props.extensions,
actions: this.props.actions,
forceRender: this.props.forceRender,
platform: this.props.platform,
sortDirectoriesFirst: this.props.sortDirectoriesFirst
});
} else {
nodeClass = "closed";
}
var nameDisplay,
cx = Classnames;
var directoryClasses = cx({
'jstree-clicked sidebar-selection': entry.get("selected"),
'context-node': entry.get("context")
});
var nodeClasses = "jstree-" + nodeClass;
if (this.state.draggedOver) {
nodeClasses += " jstree-draggedOver";
}
var liArgs = [
{
className: this.getClasses(nodeClasses),
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
draggable: true,
onDragStart: this.handleDrag,
onDrop: this.handleDrop,
onDragEnd: this.handleDragEnd,
onDragOver: this.handleDragOver,
onDragLeave: this.handleDragLeave
},
_createAlignedIns(this.props.depth)
];
var thickness = _createThickness(this.props.depth);
if (entry.get("rename")) {
liArgs.push(thickness);
nameDisplay = directoryRenameInput({
actions: this.props.actions,
entry: entry,
name: this.props.name,
parentPath: this.props.parentPath
});
} else {
// React automatically wraps content in a span element whereas preact doesn't, so do it manually
if (this.props.name) {
var name = DOM.span({}, this.props.name);
}
// Need to flatten the arguments because getIcons returns an array
var aArgs = _.flatten([{
href: "#",
className: directoryClasses
}, thickness, this.getIcons(), name]);
nameDisplay = DOM.a.apply(DOM.a, aArgs);
}
liArgs.push(nameDisplay);
liArgs.push(childNodes);
return DOM.li.apply(DOM.li, liArgs);
}
}));
/**
* @private
*
* Displays the contents of a directory.
*
* Props:
* * isRoot: whether this directory is the root of the tree
* * parentPath: the full path of the directory containing this file
* * contents: the map of name/child entry pairs for this directory
* * actions: the action creator responsible for communicating actions the user has taken
* * sortDirectoriesFirst: whether the directories should be displayed first when listing the contents of a directory
* * extensions: registered extensions for the file tree
* * forceRender: causes the component to run render
*/
directoryContents = Preact.createFactory(Preact.createClass({
/**
* Need to re-render if the sort order or the contents change.
*/
shouldComponentUpdate: function (nextProps, nextState) {
return nextProps.forceRender ||
this.props.contents !== nextProps.contents ||
this.props.sortDirectoriesFirst !== nextProps.sortDirectoriesFirst ||
this.props.extensions !== nextProps.extensions;
},
render: function () {
var extensions = this.props.extensions,
iconClass = extensions && extensions.get("icons") ? "jstree-icons" : "jstree-no-icons",
ulProps = this.props.isRoot ? {
className: "jstree-brackets jstree-no-dots " + iconClass
} : null;
var contents = this.props.contents,
namesInOrder = _sortDirectoryContents(contents, this.props.sortDirectoriesFirst);
return DOM.ul(ulProps, namesInOrder.map(function (name) {
var entry = contents.get(name);
if (FileTreeViewModel.isFile(entry)) {
return fileNode({
depth: this.props.depth,
parentPath: this.props.parentPath,
name: name,
entry: entry,
actions: this.props.actions,
extensions: this.props.extensions,
forceRender: this.props.forceRender,
platform: this.props.platform,
key: name
});
} else {
return directoryNode({
depth: this.props.depth,
parentPath: this.props.parentPath,
name: name,
entry: entry,
actions: this.props.actions,
extensions: this.props.extensions,
sortDirectoriesFirst: this.props.sortDirectoriesFirst,
forceRender: this.props.forceRender,
platform: this.props.platform,
key: name
});
}
}.bind(this)).toArray());
}
}));
/**
* Displays the absolutely positioned box for the selection or context in the
* file tree. Its position is determined by passed-in info about the scroller in which
* the tree resides and the top of the selected node (as reported by the node itself).
*
* Props:
* * selectionViewInfo: Immutable.Map with width, scrollTop, scrollLeft and offsetTop for the tree container
* * visible: should this be visible now
* * selectedClassName: class name applied to the element that is selected
*/
var fileSelectionBox = Preact.createFactory(Preact.createClass({
/**
* When the component has updated in the DOM, reposition it to where the currently
* selected node is located now.
*/
componentDidUpdate: function () {
if (!this.props.visible) {
return;
}
var node = Preact.findDOMNode(this),
selectedNode = $(node.parentNode).find(this.props.selectedClassName),
selectionViewInfo = this.props.selectionViewInfo;
if (selectedNode.length === 0) {
return;
}
node.style.top = selectedNode.offset().top - selectionViewInfo.get("offsetTop") + selectionViewInfo.get("scrollTop") - selectedNode.position().top + "px";
},
render: function () {
var selectionViewInfo = this.props.selectionViewInfo,
left = selectionViewInfo.get("scrollLeft"),
width = selectionViewInfo.get("width"),
scrollWidth = selectionViewInfo.get("scrollWidth");
return DOM.div({
style: {
overflow: "auto",
left: left,
display: this.props.visible ? "block" : "none"
},
className: this.props.className
});
}
}));
/**
* On Windows and Linux, the selection bar in the tree does not extend over the scroll bar.
* The selectionExtension sits on top of the scroll bar to make the selection bar appear to span the
* whole width of the sidebar.
*
* Props:
* * selectionViewInfo: Immutable.Map with width, scrollTop, scrollLeft and offsetTop for the tree container
* * visible: should this be visible now
* * selectedClassName: class name applied to the element that is selected
* * className: class to be applied to the extension element
*/
var selectionExtension = Preact.createFactory(Preact.createClass({
/**
* When the component has updated in the DOM, reposition it to where the currently
* selected node is located now.
*/
componentDidUpdate: function () {
if (!this.props.visible) {
return;
}
var node = Preact.findDOMNode(this),
selectedNode = $(node.parentNode).find(this.props.selectedClassName).closest("li"),
selectionViewInfo = this.props.selectionViewInfo;
if (selectedNode.length === 0) {
return;
}
var top = selectedNode.offset().top,
baselineHeight = node.dataset.initialHeight,
height = baselineHeight,
scrollerTop = selectionViewInfo.get("offsetTop");
if (!baselineHeight) {
baselineHeight = $(node).outerHeight();
node.dataset.initialHeight = baselineHeight;
height = baselineHeight;
}
// Check to see if the selection is completely scrolled out of view
// to prevent the extension from appearing in the working set area.
if (top < scrollerTop - baselineHeight) {
node.style.display = "none";
return;
}
node.style.display = "block";
// The selectionExtension sits on top of the other nodes
// so we need to shrink it if only part of the selection node is visible
if (top < scrollerTop) {
var difference = scrollerTop - top;
top += difference;
height = parseInt(height, 10);
height -= difference;
}
node.style.top = top + "px";
node.style.height = height + "px";
node.style.left = selectionViewInfo.get("width") - $(node).outerWidth() + "px";
},
render: function () {
return DOM.div({
style: {
display: this.props.visible ? "block" : "none"
},
className: this.props.className
});
}
}));
/**
* @private
*
* This is the root component of the file tree.
*
* Props:
* * treeData: the root of the tree (an Immutable.Map with the contents of the project root)
* * sortDirectoriesFirst: whether the directories should be displayed first when listing the contents of a directory
* * parentPath: the full path of the directory containing this file
* * actions: the action creator responsible for communicating actions the user has taken
* * extensions: registered extensions for the file tree
* * forceRender: causes the component to run render
* * platform: platform that Brackets is running on
*/
var fileTreeView = Preact.createFactory(Preact.createClass({
/**
* Update for any change in the tree data or directory sorting preference.
*/
shouldComponentUpdate: function (nextProps, nextState) {
return nextProps.forceRender ||
this.props.treeData !== nextProps.treeData ||
this.props.sortDirectoriesFirst !== nextProps.sortDirectoriesFirst ||
this.props.extensions !== nextProps.extensions ||
this.props.selectionViewInfo !== nextProps.selectionViewInfo;
},
handleDrop: function(e) {
var data = JSON.parse(e.dataTransfer.getData("text"));
this.props.actions.moveItem(data.path, this.props.parentPath);
e.stopPropagation();
},
/**
* Allow the Drop
*/
handleDragOver: function(e) {
e.preventDefault();
},
render: function () {
var selectionBackground = fileSelectionBox({
ref: "selectionBackground",
selectionViewInfo: this.props.selectionViewInfo,
className: "filetree-selection",
visible: this.props.selectionViewInfo.get("hasSelection"),
selectedClassName: ".selected-node",
forceUpdate: true
}),
contextBackground = fileSelectionBox({
ref: "contextBackground",
selectionViewInfo: this.props.selectionViewInfo,
className: "filetree-context",
visible: this.props.selectionViewInfo.get("hasContext"),
selectedClassName: ".context-node",
forceUpdate: true
}),
extensionForSelection = selectionExtension({
selectionViewInfo: this.props.selectionViewInfo,
selectedClassName: ".selected-node",
visible: this.props.selectionViewInfo.get("hasSelection"),
forceUpdate: true,
className: "filetree-selection-extension"
}),
extensionForContext = selectionExtension({
selectionViewInfo: this.props.selectionViewInfo,
selectedClassName: ".context-node",
visible: this.props.selectionViewInfo.get("hasContext"),
forceUpdate: true,
className: "filetree-context-extension"
}),
contents = directoryContents({
isRoot: true,
depth: 1,
parentPath: this.props.parentPath,
sortDirectoriesFirst: this.props.sortDirectoriesFirst,
contents: this.props.treeData,
extensions: this.props.extensions,
actions: this.props.actions,
forceRender: this.props.forceRender,
platform: this.props.platform
}),
args = {
onDrop: this.handleDrop,
onDragOver: this.handleDragOver
};
return DOM.div(
args,
contents,
selectionBackground,
contextBackground,
extensionForSelection,
extensionForContext
);
}
}));
/**
* Renders the file tree to the given element.
*
* @param {DOMNode|jQuery} element Element in which to render this file tree
* @param {FileTreeViewModel} viewModel the data container
* @param {Directory} projectRoot Directory object from which the fullPath of the project root is extracted
* @param {ActionCreator} actions object with methods used to communicate events that originate from the user
* @param {boolean} forceRender Run render on the entire tree (useful if an extension has new data that it needs rendered)
* @param {string} platform mac, win, linux
*/
function render(element, viewModel, projectRoot, actions, forceRender, platform) {
if (!projectRoot) {
return;
}
Preact.render(fileTreeView({
treeData: viewModel.treeData,
selectionViewInfo: viewModel.selectionViewInfo,
sortDirectoriesFirst: viewModel.sortDirectoriesFirst,
parentPath: projectRoot.fullPath,
actions: actions,
extensions: _extensions,
platform: platform,
forceRender: forceRender
}),
element);
}
/**
* @private
*
* Add an extension for the given category (icons, addClass).
*
* @param {string} category Category to which the extension is being added
* @param {function} callback The extension function itself
*/
function _addExtension(category, callback) {
if (!callback || typeof callback !== "function") {
console.error("Attempt to add FileTreeView", category, "extension without a callback function");
return;
}
var callbackList = _extensions.get(category);
if (!callbackList) {
callbackList = Immutable.List();
}
callbackList = callbackList.push(callback);
_extensions = _extensions.set(category, callbackList);
}
/**
* @see {@link ProjectManager::#addIconProvider}
*/
function addIconProvider(callback) {
_addExtension("icons", callback);
}
/**
* @see {@link ProjectManager::#addClassesProvider}
*/
function addClassesProvider(callback) {
_addExtension("addClass", callback);
}
// Private API for testing
exports._sortFormattedDirectory = _sortDirectoryContents;
exports._fileNode = fileNode;
exports._directoryNode = directoryNode;
exports._directoryContents = directoryContents;
exports._fileTreeView = fileTreeView;
// Public API
exports.addIconProvider = addIconProvider;
exports.addClassesProvider = addClassesProvider;
exports.render = render;
});