diff --git a/src/command/Commands.js b/src/command/Commands.js index b0023e52bab..0a1b0ca195e 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -34,6 +34,7 @@ define(function (require, exports, module) { // FILE exports.FILE_NEW = "file.new"; + exports.FILE_NEW_FOLDER = "file.newFolder"; exports.FILE_OPEN = "file.open"; exports.FILE_OPEN_FOLDER = "file.openFolder"; exports.FILE_SAVE = "file.save"; @@ -43,6 +44,7 @@ define(function (require, exports, module) { exports.FILE_CLOSE_WINDOW = "file.close_window"; // string must MATCH string in native code (brackets_extensions) exports.FILE_ADD_TO_WORKING_SET = "file.addToWorkingSet"; exports.FILE_LIVE_FILE_PREVIEW = "file.liveFilePreview"; + exports.FILE_RENAME = "file.rename"; exports.FILE_QUIT = "file.quit"; // string must MATCH string in native code (brackets_extensions) // EDIT diff --git a/src/command/Menus.js b/src/command/Menus.js index 04feaf3c9b4..5e59755b340 100644 --- a/src/command/Menus.js +++ b/src/command/Menus.js @@ -848,6 +848,7 @@ define(function (require, exports, module) { var menu; menu = addMenu(Strings.FILE_MENU, AppMenuBar.FILE_MENU); menu.addMenuItem(Commands.FILE_NEW, "Ctrl-N"); + menu.addMenuItem(Commands.FILE_NEW_FOLDER); menu.addMenuItem(Commands.FILE_OPEN, "Ctrl-O"); menu.addMenuItem(Commands.FILE_OPEN_FOLDER); menu.addMenuItem(Commands.FILE_CLOSE, "Ctrl-W"); @@ -962,6 +963,8 @@ define(function (require, exports, module) { */ var project_cmenu = registerContextMenu(ContextMenuIds.PROJECT_MENU); project_cmenu.addMenuItem(Commands.FILE_NEW); + project_cmenu.addMenuItem(Commands.FILE_NEW_FOLDER); + project_cmenu.addMenuItem(Commands.FILE_RENAME); var editor_cmenu = registerContextMenu(ContextMenuIds.EDITOR_MENU); editor_cmenu.addMenuItem(Commands.TOGGLE_QUICK_EDIT); diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 6c59bc049ff..6995654c1f2 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -95,9 +95,13 @@ define(function (require, exports, module) { } } - function handleCurrentDocumentChange() { + function updateDocumentTitle() { var newDocument = DocumentManager.getCurrentDocument(); - var perfTimerName = PerfUtils.markStart("DocumentCommandHandlers._onCurrentDocumentChange():\t" + (!newDocument || newDocument.file.fullPath)); + + // TODO: This timer is causing a "Recursive tests with the same name are not supporte" + // exception. This code should be removed (if not needed), or updated with a unique + // timer name (if needed). + // var perfTimerName = PerfUtils.markStart("DocumentCommandHandlers._onCurrentDocumentChange():\t" + (!newDocument || newDocument.file.fullPath)); if (newDocument) { var fullPath = newDocument.file.fullPath; @@ -113,7 +117,7 @@ define(function (require, exports, module) { // Update title text & "dirty dot" display updateTitle(); - PerfUtils.addMeasurement(perfTimerName); + // PerfUtils.addMeasurement(perfTimerName); } function handleDirtyChange(event, changedDoc) { @@ -253,10 +257,11 @@ define(function (require, exports, module) { * @param {string} dir The directory to use * @param {string} baseFileName The base to start with, "-n" will get appened to make unique * @param {string} fileExt The file extension + * @param {boolean} isFolder True if the suggestion is for a folder name * @return {$.Promise} a jQuery promise that will be resolved with a unique name starting with * the given base name */ - function _getUntitledFileSuggestion(dir, baseFileName, fileExt) { + function _getUntitledFileSuggestion(dir, baseFileName, fileExt, isFolder) { var result = new $.Deferred(); var suggestedName = baseFileName + fileExt; var dirEntry = new NativeFileSystem.DirectoryEntry(dir); @@ -269,18 +274,30 @@ define(function (require, exports, module) { } //check this name - dirEntry.getFile( - suggestedName, - {}, - function successCallback(entry) { - //file exists, notify to the next progress - result.notify(baseFileName + "-" + nextIndexToUse + fileExt, nextIndexToUse + 1); - }, - function errorCallback(error) { - //most likely error is FNF, user is better equiped to handle the rest - result.resolve(suggestedName); - } - ); + var successCallback = function (entry) { + //file exists, notify to the next progress + result.notify(baseFileName + "-" + nextIndexToUse + fileExt, nextIndexToUse + 1); + }; + var errorCallback = function (error) { + //most likely error is FNF, user is better equiped to handle the rest + result.resolve(suggestedName); + }; + + if (isFolder) { + dirEntry.getDirectory( + suggestedName, + {}, + successCallback, + errorCallback + ); + } else { + dirEntry.getFile( + suggestedName, + {}, + successCallback, + errorCallback + ); + } }); //kick it off @@ -297,9 +314,11 @@ define(function (require, exports, module) { * file creation call is outstanding */ var fileNewInProgress = false; - - function handleFileNewInProject() { - + + /** + * Bottleneck function for creating new files and folders in the project tree. + */ + function _handleNewItemInProject(isFolder) { if (fileNewInProgress) { ProjectManager.forceFinishRename(); return; @@ -320,21 +339,37 @@ define(function (require, exports, module) { // Create the new node. The createNewItem function does all the heavy work // of validating file name, creating the new file and selecting. - var deferred = _getUntitledFileSuggestion(baseDir, Strings.UNTITLED, ".js"); + var deferred = _getUntitledFileSuggestion(baseDir, Strings.UNTITLED, isFolder ? "" : ".js", isFolder); var createWithSuggestedName = function (suggestedName) { - ProjectManager.createNewItem(baseDir, suggestedName, false) + ProjectManager.createNewItem(baseDir, suggestedName, false, isFolder) .pipe(deferred.resolve, deferred.reject, deferred.notify) .always(function () { fileNewInProgress = false; }) .done(function (entry) { - FileViewController.addToWorkingSetAndSelect(entry.fullPath, FileViewController.PROJECT_MANAGER); + if (!isFolder) { + FileViewController.addToWorkingSetAndSelect(entry.fullPath, FileViewController.PROJECT_MANAGER); + } }); }; deferred.done(createWithSuggestedName); - deferred.fail(function createWithDefault() { createWithSuggestedName("Untitled.js"); }); + deferred.fail(function createWithDefault() { createWithSuggestedName(isFolder ? "Untitled" : "Untitled.js"); }); return deferred; } + + /** + * Create a new file in the project tree. + */ + function handleFileNewInProject() { + _handleNewItemInProject(false); + } + /** + * Create a new folder in the project tree. + */ + function handleNewFolderInProject() { + _handleNewItemInProject(true); + } + function showSaveFileError(code, path) { return Dialogs.showModalDialog( Dialogs.DIALOG_ID_ERROR, @@ -711,6 +746,10 @@ define(function (require, exports, module) { ); } + function handleFileRename() { + ProjectManager.renameSelectedItem(); + } + /** Closes the window, then quits the app */ function handleFileQuit(commandData) { return _handleWindowGoingAway( @@ -791,11 +830,13 @@ define(function (require, exports, module) { // File > New should open a new blank tab, and handleFileNewInProject should // be called from a "+" button in the project CommandManager.register(Strings.CMD_FILE_NEW, Commands.FILE_NEW, handleFileNewInProject); + CommandManager.register(Strings.CMD_FILE_NEW_FOLDER, Commands.FILE_NEW_FOLDER, handleNewFolderInProject); CommandManager.register(Strings.CMD_FILE_SAVE, Commands.FILE_SAVE, handleFileSave); CommandManager.register(Strings.CMD_FILE_SAVE_ALL, Commands.FILE_SAVE_ALL, handleFileSaveAll); CommandManager.register(Strings.CMD_FILE_CLOSE, Commands.FILE_CLOSE, handleFileClose); CommandManager.register(Strings.CMD_FILE_CLOSE_ALL, Commands.FILE_CLOSE_ALL, handleFileCloseAll); + CommandManager.register(Strings.CMD_FILE_RENAME, Commands.FILE_RENAME, handleFileRename); CommandManager.register(Strings.CMD_CLOSE_WINDOW, Commands.FILE_CLOSE_WINDOW, handleFileCloseWindow); CommandManager.register(Strings.CMD_QUIT, Commands.FILE_QUIT, handleFileQuit); CommandManager.register(Strings.CMD_REFRESH_WINDOW, Commands.DEBUG_REFRESH_WINDOW, handleFileReload); @@ -805,7 +846,7 @@ define(function (require, exports, module) { // Listen for changes that require updating the editor titlebar $(DocumentManager).on("dirtyFlagChange", handleDirtyChange); - $(DocumentManager).on("currentDocumentChange", handleCurrentDocumentChange); + $(DocumentManager).on("currentDocumentChange fileNameChange", updateDocumentTitle); } // Define public API diff --git a/src/document/DocumentManager.js b/src/document/DocumentManager.js index 5980a2f8896..7c2abb8cb02 100644 --- a/src/document/DocumentManager.js +++ b/src/document/DocumentManager.js @@ -64,6 +64,8 @@ * 2nd arg to the listener is the removed FileEntry. * - workingSetRemoveList -- When a list of files is to be removed from the working set (e.g. project close). * The 2nd arg to the listener is the array of removed FileEntry objects. + * - fileNameChange -- When the name of a file or folder has changed. The 2nd arg is the old name. + * The 3rd arg is the new name. * * These are jQuery events, so to listen for them you do something like this: * $(DocumentManager).on("eventname", handler); @@ -1061,7 +1063,58 @@ define(function (require, exports, module) { } } - + /** + * Called after a file or folder name has changed. This function is responsible + * for updating underlying model data and notifying all views of the change. + * + * @param {string} oldName The old name of the file/folder + * @param {string} newName The new name of the file/folder + * @param {boolean} isFolder True if path is a folder; False if it is a file. + */ + function notifyPathNameChanged(oldName, newName, isFolder) { + var i, path; + + // Update currentDocument + if (_currentDocument) { + FileUtils.updateFileEntryPath(_currentDocument.file, oldName, newName); + } + + // Update open documents + var keysToDelete = []; + for (path in _openDocuments) { + if (_openDocuments.hasOwnProperty(path)) { + if (path.indexOf(oldName) === 0) { + // Copy value to new key + var newKey = path.replace(oldName, newName); + + _openDocuments[newKey] = _openDocuments[path]; + keysToDelete.push(path); + + // Update document file + FileUtils.updateFileEntryPath(_openDocuments[newKey].file, oldName, newName); + + if (!isFolder) { + // If the path name is a file, there can only be one matched entry in the open document + // list, which we just updated. Break out of the for .. in loop. + break; + } + } + } + } + // Delete the old keys + for (i = 0; i < keysToDelete.length; i++) { + delete _openDocuments[keysToDelete[i]]; + } + + // Update working set + for (i = 0; i < _workingSet.length; i++) { + FileUtils.updateFileEntryPath(_workingSet[i], oldName, newName); + } + + // Send a "fileNameChanged" event. This will trigger the views to update. + $(exports).triggerHandler("fileNameChange", [oldName, newName]); + } + // Define public API exports.Document = Document; exports.getCurrentDocument = getCurrentDocument; @@ -1080,10 +1133,11 @@ define(function (require, exports, module) { exports.closeFullEditor = closeFullEditor; exports.closeAll = closeAll; exports.notifyFileDeleted = notifyFileDeleted; + exports.notifyPathNameChanged = notifyPathNameChanged; // Setup preferences _prefs = PreferencesManager.getPreferenceStorage(PREFERENCES_CLIENT_ID); - $(exports).bind("currentDocumentChange workingSetAdd workingSetAddList workingSetRemove workingSetRemoveList", _savePreferences); + $(exports).bind("currentDocumentChange workingSetAdd workingSetAddList workingSetRemove workingSetRemoveList fileNameChange", _savePreferences); // Performance measurements PerfUtils.createPerfMeasurement("DOCUMENT_MANAGER_GET_DOCUMENT_FOR_PATH", "DocumentManager.getDocumentForPath()"); diff --git a/src/file/FileUtils.js b/src/file/FileUtils.js index 0070ca7d093..9685d2d447e 100644 --- a/src/file/FileUtils.js +++ b/src/file/FileUtils.js @@ -261,6 +261,36 @@ define(function (require, exports, module) { } return path; } + + /** + * Update a file entry path after a file/folder name change. + * @param {FileEntry} entry The FileEntry or DirectoryEntry to update + * @param {string} oldName The full path of the old name + * @param {string} newName The full path of the new name + * @return {boolean} Returns true if the file entry was updated + */ + function updateFileEntryPath(entry, oldName, newName) { + if (entry.fullPath.indexOf(oldName) === 0) { + var fullPath = entry.fullPath.replace(oldName, newName); + + entry.fullPath = fullPath; + + // TODO: Should this be a method on Entry instead? + entry.name = null; // default if extraction fails + if (fullPath) { + var pathParts = fullPath.split("/"); + + // Extract name from the end of the fullPath (account for trailing slash(es)) + while (!entry.name && pathParts.length) { + entry.name = pathParts.pop(); + } + } + + return true; + } + + return false; + } // Define public API exports.LINE_ENDINGS_CRLF = LINE_ENDINGS_CRLF; @@ -276,4 +306,5 @@ define(function (require, exports, module) { exports.getNativeBracketsDirectoryPath = getNativeBracketsDirectoryPath; exports.getNativeModuleDirectoryPath = getNativeModuleDirectoryPath; exports.canonicalizeFolderPath = canonicalizeFolderPath; + exports.updateFileEntryPath = updateFileEntryPath; }); diff --git a/src/file/NativeFileSystem.js b/src/file/NativeFileSystem.js index 679c1f5b18e..770c9302bd3 100644 --- a/src/file/NativeFileSystem.js +++ b/src/file/NativeFileSystem.js @@ -517,8 +517,88 @@ define(function (require, exports, module) { }; NativeFileSystem.DirectoryEntry.prototype.getDirectory = function (path, options, successCallback, errorCallback) { - // TODO (issue #241) - // http://www.w3.org/TR/2011/WD-file-system-api-20110419/#widl-DirectoryEntry-getDirectory + var directoryFullPath = path; + + function isRelativePath(path) { + // If the path contains a colons it must be a full path on Windows (colons are + // not valid path characters on mac or in URIs) + if (path.indexOf(":") !== -1) { + return false; + } + + // For everyone else, absolute paths start with a "/" + return path[0] !== "/"; + } + + // resolve relative paths relative to the DirectoryEntry + if (isRelativePath(path)) { + directoryFullPath = this.fullPath + path; + } + + var createDirectoryEntry = function () { + if (successCallback) { + successCallback(new NativeFileSystem.DirectoryEntry(directoryFullPath)); + } + }; + + var createDirectoryError = function (err) { + if (errorCallback) { + errorCallback(NativeFileSystem._nativeToFileError(err)); + } + }; + + // Use stat() to check if file exists + brackets.fs.stat(directoryFullPath, function (err, stats) { + if ((err === brackets.fs.NO_ERROR)) { + // NO_ERROR implies the path already exists + + // throw error if the file the path is not a directory + if (!stats.isDirectory()) { + if (errorCallback) { + errorCallback(new NativeFileSystem.FileError(FileError.TYPE_MISMATCH_ERR)); + } + + return; + } + + // throw error if the file exists but create is exclusive + if (options.create && options.exclusive) { + if (errorCallback) { + errorCallback(new NativeFileSystem.FileError(FileError.PATH_EXISTS_ERR)); + } + + return; + } + + // Create a file entry for the existing directory. If create == true, + // a file entry is created without error. + createDirectoryEntry(); + } else if (err === brackets.fs.ERR_NOT_FOUND) { + // ERR_NOT_FOUND implies we write a new, empty file + + // create the file + if (options.create) { + // TODO: Pass permissions. The current implementation of fs.makedir() always + // creates the directory with the full permissions available to the current user. + brackets.fs.makedir(directoryFullPath, 0, function (err) { + if (err) { + createDirectoryError(err); + } else { + createDirectoryEntry(); + } + }); + return; + } + + // throw error if file not found and the create == false + if (errorCallback) { + errorCallback(new NativeFileSystem.FileError(FileError.NOT_FOUND_ERR)); + } + } else { + // all other brackets.fs.stat() errors + createDirectoryError(err); + } + }); }; NativeFileSystem.DirectoryEntry.prototype.removeRecursively = function (successCallback, errorCallback) { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 9694f6ba3be..b502e3d46fd 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -35,6 +35,7 @@ define({ "NOT_READABLE_ERR" : "The file could not be read.", "NO_MODIFICATION_ALLOWED_ERR" : "The target directory cannot be modified.", "NO_MODIFICATION_ALLOWED_ERR_FILE" : "The permissions do not allow you to make modifications.", + "FILE_EXISTS_ERR" : "The file already exists.", // Project error strings "ERROR_LOADING_PROJECT" : "Error loading project", @@ -49,6 +50,8 @@ define({ "ERROR_RELOADING_FILE" : "An error occurred when trying to reload the file {0}. {1}", "ERROR_SAVING_FILE_TITLE" : "Error saving file", "ERROR_SAVING_FILE" : "An error occurred when trying to save the file {0}. {1}", + "ERROR_RENAMING_FILE_TITLE" : "Error renaming file", + "ERROR_RENAMING_FILE" : "An error occurred when trying to rename the file {0}. {1}", "INVALID_FILENAME_TITLE" : "Invalid file name", "INVALID_FILENAME_MESSAGE" : "Filenames cannot contain the following characters: /?*:;{}<>\\|", "FILE_ALREADY_EXISTS" : "The file {0} already exists.", @@ -139,7 +142,8 @@ define({ // File menu commands "FILE_MENU" : "File", - "CMD_FILE_NEW" : "New", + "CMD_FILE_NEW" : "New File", + "CMD_FILE_NEW_FOLDER" : "New Folder", "CMD_FILE_OPEN" : "Open\u2026", "CMD_ADD_TO_WORKING_SET" : "Add To Working Set", "CMD_OPEN_FOLDER" : "Open Folder\u2026", @@ -148,6 +152,7 @@ define({ "CMD_FILE_SAVE" : "Save", "CMD_FILE_SAVE_ALL" : "Save All", "CMD_LIVE_FILE_PREVIEW" : "Live Preview", + "CMD_FILE_RENAME" : "Rename", "CMD_QUIT" : "Quit", // Edit menu commands diff --git a/src/project/FileIndexManager.js b/src/project/FileIndexManager.js index ca40902da2b..0e6e2dd78a5 100644 --- a/src/project/FileIndexManager.js +++ b/src/project/FileIndexManager.js @@ -395,7 +395,7 @@ define(function (require, exports, module) { } ); - $(ProjectManager).on("projectOpen", function (event, projectRoot) { + $(ProjectManager).on("projectOpen projectFilesChange", function (event, projectRoot) { markDirty(); }); diff --git a/src/project/ProjectManager.js b/src/project/ProjectManager.js index cd9849e518a..29172c92029 100644 --- a/src/project/ProjectManager.js +++ b/src/project/ProjectManager.js @@ -32,6 +32,8 @@ * This module dispatches these events: * - beforeProjectClose -- before _projectRoot changes * - projectOpen -- after _projectRoot changes + * - projectFilesChange -- sent if one of the project files has changed-- + * added, removed, renamed, etc. * * These are jQuery events, so to listen for them you do something like this: * $(ProjectManager).on("eventname", handler); @@ -134,6 +136,8 @@ define(function (require, exports, module) { fullPathToIdMap : {} /* mapping of fullPath to tree node id attr */ }; + var suppressToggleOpen = false; + /** * @private */ @@ -318,8 +322,7 @@ define(function (require, exports, module) { * http://www.jstree.com/documentation/json_data */ function _renderTree(treeDataProvider) { - var result = new $.Deferred(), - suppressToggleOpen = false; + var result = new $.Deferred(); // For #1542, make sure the tree is scrolled to the top before refreshing. // If we try to do this later (e.g. after the tree has been refreshed), it @@ -817,18 +820,40 @@ define(function (require, exports, module) { return result.promise(); } - + /** + * @private + * + * Check a filename for illegal characters. If any are found, show an error + * dialog and return false. If no illegal characters are found, return true. + */ + function _checkForValidFilename(filename) { + // Validate file name + // TODO (issue #270): There are some filenames like COM1, LPT3, etc. that are not valid on Windows. + // We may want to add checks for those here. + // See http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx + if (filename.search(/[\/?*:;\{\}<>\\|]+/) !== -1) { + Dialogs.showModalDialog( + Dialogs.DIALOG_ID_ERROR, + Strings.INVALID_FILENAME_TITLE, + Strings.INVALID_FILENAME_MESSAGE + ); + return false; + } + return true; + } + /** * Create a new item in the project tree. * * @param baseDir {string} Full path of the directory where the item should go * @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 FileEntry * of the created object, or rejected if the user cancelled or entered an illegal * filename. */ - function createNewItem(baseDir, initialName, skipRename) { + function createNewItem(baseDir, initialName, skipRename, isFolder) { var node = null, selection = _projectTree.jstree("get_selected"), selectionEntry = null, @@ -894,57 +919,80 @@ define(function (require, exports, module) { if (!escapeKeyPressed) { // Validate file name - // TODO (issue #270): There are some filenames like COM1, LPT3, etc. that are not valid on Windows. - // We may want to add checks for those here. - // See http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx - if (data.rslt.name.search(/[\/?*:;\{\}<>\\|]+/) !== -1) { - Dialogs.showModalDialog( - Dialogs.DIALOG_ID_ERROR, - Strings.INVALID_FILENAME_TITLE, - Strings.INVALID_FILENAME_MESSAGE - ); - + if (!_checkForValidFilename(data.rslt.name)) { errorCleanup(); return; } - // Use getFile() to create the new file - selectionEntry.getFile( - data.rslt.name, - {create: true, exclusive: true}, - function (entry) { - data.rslt.obj.data("entry", entry); - _projectTree.jstree("select_node", data.rslt.obj, true); - result.resolve(entry); - }, - function (error) { - if ((error.code === FileError.PATH_EXISTS_ERR) - || (error.code === FileError.TYPE_MISMATCH_ERR)) { - Dialogs.showModalDialog( - Dialogs.DIALOG_ID_ERROR, - Strings.INVALID_FILENAME_TITLE, - StringUtils.format(Strings.FILE_ALREADY_EXISTS, - StringUtils.htmlEscape(data.rslt.name)) - ); - } else { - var errString = error.code === FileError.NO_MODIFICATION_ALLOWED_ERR ? - Strings.NO_MODIFICATION_ALLOWED_ERR : - StringUtils.format(String.GENERIC_ERROR, error.code); - - var errMsg = StringUtils.format(Strings.ERROR_CREATING_FILE, - StringUtils.htmlEscape(data.rslt.name), - errString); - - Dialogs.showModalDialog( - Dialogs.DIALOG_ID_ERROR, - Strings.ERROR_CREATING_FILE_TITLE, - errMsg - ); - } + var successCallback = function (entry) { + data.rslt.obj.data("entry", entry); + if (isFolder) { + // If the new item is a folder, remove the leaf and folder related + // classes and add "jstree-closed". Selecting the item will open + // the folder. + data.rslt.obj.removeClass("jstree-leaf jstree-closed jstree-open") + .addClass("jstree-closed"); + } + + // If the new item is a folder, force a re-sort here. Windows sorts folders + // and files separately. + if (isFolder) { + _projectTree.jstree("sort", data.rslt.obj.parent()); + } + + _projectTree.jstree("select_node", data.rslt.obj, true); - errorCleanup(); + // Notify listeners that the project model has changed + $(exports).triggerHandler("projectFilesChange"); + + result.resolve(entry); + }; + + var errorCallback = function (error) { + if ((error.code === FileError.PATH_EXISTS_ERR) + || (error.code === FileError.TYPE_MISMATCH_ERR)) { + Dialogs.showModalDialog( + Dialogs.DIALOG_ID_ERROR, + Strings.INVALID_FILENAME_TITLE, + StringUtils.format(Strings.FILE_ALREADY_EXISTS, + StringUtils.htmlEscape(data.rslt.name)) + ); + } else { + var errString = error.code === FileError.NO_MODIFICATION_ALLOWED_ERR ? + Strings.NO_MODIFICATION_ALLOWED_ERR : + StringUtils.format(String.GENERIC_ERROR, error.code); + + var errMsg = StringUtils.format(Strings.ERROR_CREATING_FILE, + StringUtils.htmlEscape(data.rslt.name), + errString); + + Dialogs.showModalDialog( + Dialogs.DIALOG_ID_ERROR, + Strings.ERROR_CREATING_FILE_TITLE, + errMsg + ); } - ); + + errorCleanup(); + }; + + if (isFolder) { + // Use getDirectory() to create the new folder + selectionEntry.getDirectory( + data.rslt.name, + {create: true, exclusive: true}, + successCallback, + errorCallback + ); + } else { + // Use getFile() to create the new file + selectionEntry.getFile( + data.rslt.name, + {create: true, exclusive: true}, + successCallback, + errorCallback + ); + } } else { //escapeKeyPressed errorCleanup(); } @@ -978,6 +1026,130 @@ define(function (require, exports, module) { return result.promise(); } + /** + * Rename a file/folder. This will update the project tree data structures + * and send notifications about the rename. + * + * @prarm {string} oldName Old item name + * @param {string} newName New item name + * @param {boolean} isFolder True if item is a folder; False if it is a file. + * @return {$.Promise} A promise object that will be resolved or rejected when + * the rename is finished. + */ + function renameItem(oldName, newName, isFolder) { + var result = new $.Deferred(); + + if (oldName === newName) { + result.resolve(); + return result; + } + + // TODO: This should call FileEntry.moveTo(), but that isn't implemented + // yet. For now, call directly to the low-level fs.rename() + brackets.fs.rename(oldName, newName, function (err) { + if (!err) { + // Update all nodes in the project tree. + // All other updating is done by DocumentManager.notifyPathNameChanged() below + var nodes = _projectTree.find(".jstree-leaf, .jstree-open, .jstree-closed"), + i; + + for (i = 0; i < nodes.length; i++) { + var node = $(nodes[i]); + FileUtils.updateFileEntryPath(node.data("entry"), oldName, newName); + } + + // Notify that one of the project files has changed + $(exports).triggerHandler("projectFilesChange"); + + // 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, isFolder); + + // Finally, re-open the selected document + if (DocumentManager.getCurrentDocument()) { + FileViewController.openAndSelectDocument( + DocumentManager.getCurrentDocument().file.fullPath, + FileViewController.getFileSelectionFocus() + ); + } + + _redraw(true); + + result.resolve(); + } else { + // Show and error alert + Dialogs.showModalDialog( + Dialogs.DIALOG_ID_ERROR, + Strings.ERROR_RENAMING_FILE_TITLE, + StringUtils.format( + Strings.ERROR_RENAMING_FILE, + StringUtils.htmlEscape(newName), + err === brackets.fs.ERR_FILE_EXISTS ? + Strings.FILE_EXISTS_ERR : + FileUtils.getFileErrorString(err) + ) + ); + + result.reject(err); + } + }); + + return result; + } + + /** + * Rename the selected item in the project tree + */ + function renameSelectedItem() { + var selected = _projectTree.jstree("get_selected"), + isFolder = selected.hasClass("jstree-open") || selected.hasClass("jstree-closed"); + + if (selected) { + _projectTree.on("rename.jstree", function (event, data) { + $(event.target).off("rename.jstree"); + + // Make sure the file was actually renamed + if (data.rslt.old_name === data.rslt.new_name) { + return; + } + + var _resetOldFilename = function () { + _projectTree.jstree("set_text", selected, data.rslt.old_name); + _projectTree.jstree("refresh", -1); + }; + + if (!_checkForValidFilename(data.rslt.new_name)) { + // Invalid filename. Reset the old name and bail. + _resetOldFilename(); + return; + } + + var oldName = selected.data("entry").fullPath; + var newName = oldName.replace(data.rslt.old_name, data.rslt.new_name); + + renameItem(oldName, newName, isFolder) + .done(function () { + + // If a folder was renamed, re-select it here, since openAndSelectDocument() + // changed the selection. + if (isFolder) { + var oldSuppressToggleOpen = suppressToggleOpen; + + // Supress the open/close toggle + suppressToggleOpen = true; + _projectTree.jstree("select_node", selected, true); + suppressToggleOpen = oldSuppressToggleOpen; + } + }) + .fail(function (err) { + // Error during rename. Reset to the old name and alert the user. + _resetOldFilename(); + }); + }); + _projectTree.jstree("rename"); + } + } + /** * Forces createNewItem() to complete by removing focus from the rename field which causes * the new file to be written to disk @@ -1020,5 +1192,6 @@ define(function (require, exports, module) { exports.isWelcomeProjectPath = isWelcomeProjectPath; exports.updateWelcomeProjectPath = updateWelcomeProjectPath; exports.createNewItem = createNewItem; + exports.renameSelectedItem = renameSelectedItem; exports.forceFinishRename = forceFinishRename; }); diff --git a/src/project/WorkingSetView.js b/src/project/WorkingSetView.js index 51b77727e27..f34a8b5ee80 100644 --- a/src/project/WorkingSetView.js +++ b/src/project/WorkingSetView.js @@ -333,6 +333,18 @@ define(function (require, exports, module) { } + /** + * @private + * @param {string} oldName + * @param {string} newName + */ + function _handleFileNameChanged(oldName, newName) { + // Rebuild the working set if any file or folder name changed. + // We could be smarter about this and only update the + // nodes that changed, if needed... + _rebuildWorkingSet(); + } + function create(element) { // Init DOM element $openFilesContainer = element; @@ -359,6 +371,10 @@ define(function (require, exports, module) { _handleDirtyFlagChanged(doc); }); + $(DocumentManager).on("fileNameChange", function (event, oldName, newName) { + _handleFileNameChanged(oldName, newName); + }); + $(FileViewController).on("documentSelectionFocusChange fileViewFocusChange", _handleDocumentSelectionChange); // Show scroller shadows when open-files-container scrolls diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index 8766de1cb76..03960a9bc86 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -52,6 +52,7 @@ define(function (require, exports, module) { FileIndexManager = require("project/FileIndexManager"), KeyEvent = require("utils/KeyEvent"); + var searchResults = []; var FIND_IN_FILES_MAX = 100; @@ -287,11 +288,12 @@ define(function (require, exports, module) { function doFindInFiles() { var dialog = new FindInFilesDialog(); - var searchResults = []; // Default to searching for the current selection var currentEditor = EditorManager.getFocusedEditor(); var initialString = currentEditor && currentEditor.getSelectedText(); + + searchResults = []; dialog.showDialog(initialString) .done(function (query) { @@ -333,5 +335,16 @@ define(function (require, exports, module) { }); } + function _fileNameChangeHandler(event, oldName, newName) { + if ($("#search-results").is(":visible")) { + // Update the search results + searchResults.forEach(function (item) { + item.fullPath = item.fullPath.replace(oldName, newName); + }); + _showSearchResults(searchResults); + } + } + + $(DocumentManager).on("fileNameChange", _fileNameChangeHandler); CommandManager.register(Strings.CMD_FIND_IN_FILES, Commands.EDIT_FIND_IN_FILES, doFindInFiles); }); \ No newline at end of file diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 66c05290ba1..2e1becfc5b2 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -464,6 +464,7 @@ ins.jstree-icon { } // TODO (Issue #1615): Remove this hack when we get a new CEF +/* @media all and (-webkit-min-device-pixel-ratio:2),(min-device-pixel-ratio:2) { .inline-widget { .shadow.top { @@ -474,6 +475,7 @@ ins.jstree-icon { } } } +*/ /* CSSInlineEditor rule list */ .related-container { diff --git a/test/spec/LowLevelFileIO-test-files/rename_me/hello.txt b/test/spec/LowLevelFileIO-test-files/rename_me/hello.txt new file mode 100644 index 00000000000..349db2bfe13 --- /dev/null +++ b/test/spec/LowLevelFileIO-test-files/rename_me/hello.txt @@ -0,0 +1 @@ +Hello, World. diff --git a/test/spec/LowLevelFileIO-test.js b/test/spec/LowLevelFileIO-test.js index 3510b2fceaa..34f3f45032a 100644 --- a/test/spec/LowLevelFileIO-test.js +++ b/test/spec/LowLevelFileIO-test.js @@ -504,5 +504,176 @@ define(function (require, exports, module) { }); }); // describe("unlink") + + describe("mkdir", function () { + it("should make a new directory", function () { + // TODO: Write this test once we have a function to delete the directory + }); + }); + + describe("rename", function () { + var error, complete; + + it("should rename a file", function () { + var oldName = baseDir + "file_one.txt", + newName = baseDir + "file_one_renamed.txt"; + + complete = false; + + brackets.fs.rename(oldName, newName, function (err) { + error = err; + complete = true; + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.NO_ERROR); + }); + + // Verify new file is found and old one is missing + runs(function () { + complete = false; + brackets.fs.stat(oldName, function (err, stat) { + complete = true; + error = err; + }); + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.ERR_NOT_FOUND); + }); + + runs(function () { + complete = false; + brackets.fs.stat(newName, function (err, stat) { + complete = true; + error = err; + }); + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.NO_ERROR); + }); + + // Rename the file back to the old name + runs(function () { + complete = false; + brackets.fs.rename(newName, oldName, function (err) { + complete = true; + error = err; + }); + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.NO_ERROR); + }); + + }); + it("should rename a folder", function () { + var oldName = baseDir + "rename_me", + newName = baseDir + "renamed_folder"; + + complete = false; + + brackets.fs.rename(oldName, newName, function (err) { + error = err; + complete = true; + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.NO_ERROR); + }); + + // Verify new folder is found and old one is missing + runs(function () { + complete = false; + brackets.fs.stat(oldName, function (err, stat) { + complete = true; + error = err; + }); + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.ERR_NOT_FOUND); + }); + + runs(function () { + complete = false; + brackets.fs.stat(newName, function (err, stat) { + complete = true; + error = err; + }); + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.NO_ERROR); + }); + + // Rename the folder back to the old name + runs(function () { + complete = false; + brackets.fs.rename(newName, oldName, function (err) { + complete = true; + error = err; + }); + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.NO_ERROR); + }); + }); + it("should return an error if the new name already exists", function () { + var oldName = baseDir + "file_one.txt", + newName = baseDir + "file_two.txt"; + + complete = false; + + brackets.fs.rename(oldName, newName, function (err) { + error = err; + complete = true; + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.ERR_FILE_EXISTS); + }); + }); + it("should return an error if the parent folder is read only (Mac only)", function () { + if (brackets.platform === "mac") { + var oldName = baseDir + "cant_write_here/readme.txt", + newName = baseDir + "cant_write_here/readme_renamed.txt"; + + complete = false; + + brackets.fs.rename(oldName, newName, function (err) { + error = err; + complete = true; + }); + + waitsFor(function () { return complete; }, 1000); + + runs(function () { + expect(error).toBe(brackets.fs.ERR_CANT_WRITE); + }); + } + }); + // TODO: More testing of error cases? + }); }); });