Modules (180)

FileSyncManager

Description

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.

Dependencies

Variables

Private

_alreadyChecking

Guard to spot re-entrancy while syncOpenDocuments() is still in progress

Type
boolean
Private
    var _alreadyChecking = false;
Private

_restartPending

If true, we should bail from the syncOpenDocuments() process and then re-run it. See comments in syncOpenDocuments() for how this works.

Type
boolean
Private
    var _restartPending = false;

deleteConflicts

Type
Array.<{doc: Document, fileTime: number}>
    var deleteConflicts;

editConflicts

Type
Array.<{doc: Document, fileTime: number}>
    var editConflicts;

toClose

Type
Array.<Document>
    var toClose;

toReload

Type
Array.<Document>
    var toReload;

Functions

closeDeletedDocs

Closes all the documents in "toClose" silently (no prompts). Completes synchronously.

    function closeDeletedDocs() {
        toClose.forEach(function (doc) {
            DocumentManager.notifyFileDeleted(doc.file);
        });
    }

findExternalChanges

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

docs non-nullable Array.<Document>
Returns: $.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 {
                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);
    }

presentConflicts

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.

title string
Title of the dialog.
Returns: $.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);
    }

reloadChangedDocs

Reloads all the documents in "toReload" silently (no prompts). The operations are all run in parallel.

Returns: $.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);
    }

reloadDoc

Reloads the Document's contents from disk, discarding any unsaved changes in the editor.

doc non-nullable Document
Returns: $.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;
    }

showReloadError

error FileError
doc non-nullable Document
Returns: 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)
            )
        );
    }
Public API

syncOpenDocuments

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.

title string
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;
});

syncUnopenWorkingSet

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);
    }