src/project/FileSyncManager.js
/*
* Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/**
* FileSyncManager is a set of utilities to help track external modifications to the files and folders
* in the currently open project.
*
* Currently, we detect external changes purely by checking file timestamps against the last-sync
* timestamp recorded on Document. Brackets triggers this check whenever an external change was detected
* by our native file watchers, and on window focus. We recheck all open Documents, but with file caching
* the timestamp check is a fast no-op for everything other than files where a watcher change was just
* notified. If watchers/caching are disabled, we'll essentially check only on window focus, and we'll hit
* the disk to check every open Document's timestamp every time.
*
* FUTURE: Whenever we have a 'project file tree model,' we should manipulate that instead of notifying
* DocumentManager directly. DocumentManager, the tree UI, etc. then all listen to that model for changes.
*/
define(function (require, exports, module) {
"use strict";
// Load dependent modules
var ProjectManager = require("project/ProjectManager"),
DocumentManager = require("document/DocumentManager"),
MainViewManager = require("view/MainViewManager"),
Async = require("utils/Async"),
Dialogs = require("widgets/Dialogs"),
DefaultDialogs = require("widgets/DefaultDialogs"),
Strings = require("strings"),
StringUtils = require("utils/StringUtils"),
FileUtils = require("file/FileUtils"),
FileSystemError = require("filesystem/FileSystemError");
/**
* Guard to spot re-entrancy while syncOpenDocuments() is still in progress
* @type {boolean}
*/
var _alreadyChecking = false;
/**
* If true, we should bail from the syncOpenDocuments() process and then re-run it. See
* comments in syncOpenDocuments() for how this works.
* @type {boolean}
*/
var _restartPending = false;
/**
* @type {Array.<Document>}
*/
var toReload;
/**
* @type {Array.<Document>}
*/
var toClose;
/**
* @type {Array.<{doc: Document, fileTime: number}>}
*/
var editConflicts;
/**
* @type {Array.<{doc: Document, fileTime: number}>}
*/
var deleteConflicts;
/**
* Scans all the given Documents for changes on disk, and sorts them into four buckets,
* populating the corresponding arrays:
* toReload - changed on disk; unchanged within Brackets
* toClose - deleted on disk; unchanged within Brackets
* editConflicts - changed on disk; also dirty in Brackets
* deleteConflicts - deleted on disk; also dirty in Brackets
*
* @param {!Array.<Document>} docs
* @return {$.Promise} Resolved when all scanning done, or rejected immediately if there's any
* error while reading file timestamps. Errors are logged but no UI is shown.
*/
function findExternalChanges(docs) {
toReload = [];
toClose = [];
editConflicts = [];
deleteConflicts = [];
function checkDoc(doc) {
var result = new $.Deferred();
// Check file timestamp / existence
if (doc.isUntitled()) {
result.resolve();
} else if (doc.file.donotWatch) { // Some file might not like to be watched!
result.resolve();
} else {
doc.file.stat(function (err, stat) {
if (!err) {
// Does file's timestamp differ from last sync time on the Document?
var fileTime = stat.mtime.getTime();
if (fileTime !== doc.diskTimestamp.getTime()) {
// If the user has chosen to keep changes that conflict with the
// current state of the file on disk, then do nothing. This means
// that even if the user later undoes back to clean, we won't
// automatically reload the file on window reactivation. We could
// make it do that, but it seems better to be consistent with the
// deletion case below, where it seems clear that you don't want
// to auto-delete the file on window reactivation just because you
// undid back to clean.
if (doc.keepChangesTime !== fileTime) {
if (doc.isDirty) {
editConflicts.push({doc: doc, fileTime: fileTime});
} else {
toReload.push(doc);
}
}
}
result.resolve();
} else {
// File has been deleted externally
if (err === FileSystemError.NOT_FOUND) {
// If the user has chosen to keep changes previously, and the file
// has been deleted, then do nothing. Like the case above, this
// means that even if the user later undoes back to clean, we won't
// then automatically delete the file on window reactivation.
// (We use -1 as the "mod time" to indicate that the file didn't
// exist, since there's no actual modification time to keep track of
// and -1 isn't a valid mod time for a real file.)
if (doc.keepChangesTime !== -1) {
if (doc.isDirty) {
deleteConflicts.push({doc: doc, fileTime: -1});
} else {
toClose.push(doc);
}
}
result.resolve();
} else {
// Some other error fetching metadata: treat as a real error
console.log("Error checking modification status of " + doc.file.fullPath, err);
result.reject();
}
}
});
}
return result.promise();
}
// Check all docs in parallel
// (fail fast b/c we won't continue syncing if there was any error fetching timestamps)
return Async.doInParallel(docs, checkDoc, true);
}
/**
* Scans all the files in the working set that do not have Documents (and thus were not scanned
* by findExternalChanges()). If any were deleted on disk, removes them from the working set.
*/
function syncUnopenWorkingSet() {
// We only care about working set entries that have never been open (have no Document).
var unopenWorkingSetFiles = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES).filter(function (wsFile) {
return !DocumentManager.getOpenDocumentForPath(wsFile.fullPath);
});
function checkWorkingSetFile(file) {
var result = new $.Deferred();
file.stat(function (err, stat) {
if (!err) {
// File still exists
result.resolve();
} else {
// File has been deleted externally
if (err === FileSystemError.NOT_FOUND) {
DocumentManager.notifyFileDeleted(file);
result.resolve();
} else {
// Some other error fetching metadata: treat as a real error
console.log("Error checking for deletion of " + file.fullPath, err);
result.reject();
}
}
});
return result.promise();
}
// Check all these files in parallel
return Async.doInParallel(unopenWorkingSetFiles, checkWorkingSetFile, false);
}
/**
* Reloads the Document's contents from disk, discarding any unsaved changes in the editor.
*
* @param {!Document} doc
* @return {$.Promise} Resolved after editor has been refreshed; rejected if unable to load the
* file's new content. Errors are logged but no UI is shown.
*/
function reloadDoc(doc) {
var promise = FileUtils.readAsText(doc.file);
promise.done(function (text, readTimestamp) {
doc.refreshText(text, readTimestamp);
});
promise.fail(function (error) {
console.log("Error reloading contents of " + doc.file.fullPath, error);
});
return promise;
}
/**
* Reloads all the documents in "toReload" silently (no prompts). The operations are all run
* in parallel.
* @return {$.Promise} Resolved/rejected after all reloads done; will be rejected if any one
* file's reload failed. Errors are logged (by reloadDoc()) but no UI is shown.
*/
function reloadChangedDocs() {
// Reload each doc in turn, and once all are (async) done, signal that we're done
return Async.doInParallel(toReload, reloadDoc, false);
}
/**
* @param {FileError} error
* @param {!Document} doc
* @return {Dialog}
*/
function showReloadError(error, doc) {
return Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.ERROR_RELOADING_FILE_TITLE,
StringUtils.format(
Strings.ERROR_RELOADING_FILE,
StringUtils.breakableUrl(doc.file.fullPath),
FileUtils.getFileErrorString(error)
)
);
}
/**
* Closes all the documents in "toClose" silently (no prompts). Completes synchronously.
*/
function closeDeletedDocs() {
toClose.forEach(function (doc) {
DocumentManager.notifyFileDeleted(doc.file);
});
}
/**
* Walks through all the documents in "editConflicts" & "deleteConflicts" and prompts the user
* about each one. Processing is sequential: if the user chooses to reload a document, the next
* prompt is not shown until after the reload has completed.
*
* @param {string} title Title of the dialog.
* @return {$.Promise} Resolved/rejected after all documents have been prompted and (if
* applicable) reloaded (and any resulting error UI has been dismissed). Rejected if any
* one reload failed.
*/
function presentConflicts(title) {
var allConflicts = editConflicts.concat(deleteConflicts);
function presentConflict(docInfo, i) {
var result = new $.Deferred(),
promise = result.promise(),
doc = docInfo.doc,
fileTime = docInfo.fileTime;
// If window has been re-focused, skip all remaining conflicts so the sync can bail & restart
if (_restartPending) {
result.resolve();
return promise;
}
var toClose;
var dialogId;
var message;
var buttons;
// Prompt UI varies depending on whether the file on disk was modified vs. deleted
if (i < editConflicts.length) {
toClose = false;
dialogId = DefaultDialogs.DIALOG_ID_EXT_CHANGED;
message = StringUtils.format(
Strings.EXT_MODIFIED_MESSAGE,
StringUtils.breakableUrl(
ProjectManager.makeProjectRelativeIfPossible(doc.file.fullPath)
)
);
buttons = [
{
className: Dialogs.DIALOG_BTN_CLASS_LEFT,
id: Dialogs.DIALOG_BTN_DONTSAVE,
text: Strings.RELOAD_FROM_DISK
},
{
className: Dialogs.DIALOG_BTN_CLASS_PRIMARY,
id: Dialogs.DIALOG_BTN_CANCEL,
text: Strings.KEEP_CHANGES_IN_EDITOR
}
];
} else {
toClose = true;
dialogId = DefaultDialogs.DIALOG_ID_EXT_DELETED;
message = StringUtils.format(
Strings.EXT_DELETED_MESSAGE,
StringUtils.breakableUrl(
ProjectManager.makeProjectRelativeIfPossible(doc.file.fullPath)
)
);
buttons = [
{
className: Dialogs.DIALOG_BTN_CLASS_LEFT,
id: Dialogs.DIALOG_BTN_DONTSAVE,
text: Strings.CLOSE_DONT_SAVE
},
{
className: Dialogs.DIALOG_BTN_CLASS_PRIMARY,
id: Dialogs.DIALOG_BTN_CANCEL,
text: Strings.KEEP_CHANGES_IN_EDITOR
}
];
}
Dialogs.showModalDialog(dialogId, title, message, buttons)
.done(function (id) {
if (id === Dialogs.DIALOG_BTN_DONTSAVE) {
if (toClose) {
// Discard - close all editors
DocumentManager.notifyFileDeleted(doc.file);
result.resolve();
} else {
// Discard - load changes from disk
reloadDoc(doc)
.done(function () {
result.resolve();
})
.fail(function (error) {
// Unable to load changed version from disk - show error UI
showReloadError(error, doc)
.done(function () {
// After user dismisses, move on to next conflict prompt
result.reject();
});
});
}
} else {
// Cancel - if user doesn't manually save or close, remember that they
// chose to keep the changes in the editor and don't prompt again unless the
// file changes again
// OR programmatically canceled due to _resetPending - we'll skip all
// remaining files in the conflicts list (see above)
// If this wasn't programmatically cancelled, remember that the user
// has accepted conflicting changes as of this file version.
if (!_restartPending) {
doc.keepChangesTime = fileTime;
}
result.resolve();
}
});
return promise;
}
// Begin walking through the conflicts, one at a time
return Async.doSequentially(allConflicts, presentConflict, false);
}
/**
* Check to see whether any open files have been modified by an external app since the last time
* Brackets synced up with the copy on disk (either by loading or saving the file). For clean
* files, we silently upate the editor automatically. For files with unsaved changes, we prompt
* the user.
*
* @param {string} title Title to use for document. Default is "External Changes".
*/
function syncOpenDocuments(title) {
title = title || Strings.EXT_MODIFIED_TITLE;
// We can become "re-entrant" if the user leaves & then returns to Brackets before we're
// done -- easy if a prompt dialog is left open. Since the user may have left Brackets to
// revert some of the disk changes, etc. we want to cancel the current sync and immediately
// begin a new one. We let the orig sync run until the user-visible dialog phase, then
// bail; if we're already there we programmatically close the dialog to bail right away.
if (_alreadyChecking) {
_restartPending = true;
// Close dialog if it was open. This will 'unblock' presentConflict(), which bails back
// to us immediately upon seeing _restartPending. We then restart the sync - see below
Dialogs.cancelModalDialogIfOpen(DefaultDialogs.DIALOG_ID_EXT_CHANGED);
Dialogs.cancelModalDialogIfOpen(DefaultDialogs.DIALOG_ID_EXT_DELETED);
return;
}
_alreadyChecking = true;
// Syncing proceeds in four phases:
// 1) Check all open files for external modifications
// 2) Check any other working set entries (that are not open) for deletion, and remove
// from working set if deleted
// 3) Refresh all Documents that are clean (if file changed on disk)
// 4) Close all Documents that are clean (if file deleted on disk)
// 5) Prompt about any Documents that are dirty (if file changed/deleted on disk)
// Each phase fully completes (asynchronously) before the next one begins.
// 1) Check for external modifications
var allDocs = DocumentManager.getAllOpenDocuments();
findExternalChanges(allDocs)
.done(function () {
// 2) Check un-open working set entries for deletion (& "close" if needed)
syncUnopenWorkingSet()
.always(function () {
// If we were unable to check any un-open files for deletion, silently ignore
// (after logging to console). This doesn't have any bearing on syncing truly
// open Documents (which we've already successfully checked).
// 3) Reload clean docs as needed
reloadChangedDocs()
.always(function () {
// 4) Close clean docs as needed
// This phase completes synchronously
closeDeletedDocs();
// 5) Prompt for dirty editors (conflicts)
presentConflicts(title)
.always(function () {
if (_restartPending) {
// Restart the sync if needed
_restartPending = false;
_alreadyChecking = false;
syncOpenDocuments();
} else {
// We're really done!
_alreadyChecking = false;
// If we showed a dialog, restore focus to editor
if (editConflicts.length > 0 || deleteConflicts.length > 0) {
MainViewManager.focusActivePane();
}
// (Any errors that ocurred during presentConflicts() have already
// shown UI & been dismissed, so there's no fail() handler here)
}
});
});
// Note: if any auto-reloads failed, we silently ignore (after logging to console)
// and we still continue onto phase 4 and try to process those files anyway.
// (We'll retry the auto-reloads next time window is activated... and evenually
// we'll also be double checking before each Save).
});
}).fail(function () {
// Unable to fetch timestamps for some reason - silently ignore (after logging to console)
// (We'll retry next time window is activated... and evenually we'll also be double
// checking before each Save).
// We can't go on without knowing which files are dirty, so bail now
_alreadyChecking = false;
});
}
// Define public API
exports.syncOpenDocuments = syncOpenDocuments;
});