adobe/brackets

View on GitHub
src/project/FileSyncManager.js

Summary

Maintainability
D
2 days
Test Coverage
/*
 * 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;
});