define(function(require, exports, module) { main.consumes = [ "Plugin", "fs", "settings", "preferences", "watcher", "tabManager", "save", "dialog.question", "dialog.filechange", "threewaymerge" ]; main.provides = ["watcher.gui"]; return main; function main(options, imports, register) { var Plugin = imports.Plugin; var watcher = imports.watcher; var prefs = imports.preferences; var fs = imports.fs; var save = imports.save; var settings = imports.settings; var tabManager = imports.tabManager; var question = imports["dialog.question"]; var filechange = imports["dialog.filechange"]; var threeWayMerge = imports.threewaymerge.merge; var collabEnabled = options.collab; /***** Initialization *****/ var plugin = new Plugin("Ajax.org", main.consumes); var emit = plugin.getEmitter(); var removedPaths, changedPaths; var deleteDialog, changeDialog, initialFocus; var loaded = false; function load(){ if (loaded) return false; loaded = true; removedPaths = {}; changedPaths = {}; // Settings and preferences settings.on("read", function(e) { settings.setDefaults("user/general", [ ["automerge", "false"], ["confirmmerge", "true"] ]); }); prefs.add({ "File" : { position: 150, "Watchers" : { position: 300, "Auto-Merge Files When a Conflict Occurs" : { type: "checkbox", path: "user/general/@automerge", min: "1", max: "64", tooltip: "Whenever the file watcher detects a " + "file change on disk, 'auto merge' will fetch " + "the contents from disc and merges it with " + "the version in the editor.", position: 2200 } } } }, plugin); // Watch for new documents and hook their value initialization function initializeDocument(doc) { if (!doc.meta.$savedValue) doc.meta.$savedValue = doc.recentValue; doc.on("setValue", function(e) { doc.meta.$savedValue = e.value; }, plugin); } tabManager.getTabs().forEach(function(tab) { if (!tab.path) return; if (tab.document.undoManager.isAtBookmark()) { initializeDocument(tab.document); } else { console.error("Unsupported state"); } if (tab.classList.contains("conflict")) { addChangedTab(tab, true); } }); tabManager.on("open", function(e) { initializeDocument(e.tab.document); if (e.tab.classList.contains("conflict")) { addChangedTab(e.tab, true); } }, plugin); // Hook the save of the document value save.on("beforeSave", function(e) { e.document.meta.$savingValue = e.save; if (e.tab.classList.contains("conflict")) { showChangeDialog(e.tab); } }, plugin); save.on("afterSave", function(e) { var meta = e.document.meta; if (!e.err) { meta.$savedValue = meta.$savingValue; watcher.watch(e.path); } delete meta.$savingValue; delete meta.$mergeRoot; }, plugin); // Hook watcher events // Update a file watcher.on("change", function(e) { var tab = tabManager.findTab(e.path); if (tab) { // If collab picks this up and handles the change it will return false if (emit("docChange", {tab: tab}) === false) return; addChangedTab(tab, e.type === "change"); } }); // Directory watcher is not needed if the normal watcher works // watcher.on("directory", function(e) { // var base = e.path; // var files = e.files; // // // Rename all tabs // tabManager.getTabs().forEach(function(tab) { // if (tab.path && tab.path.indexOf(base) == 0) { // // If the file is gone, lets notify the user // if (files.indexOf(tab.path) == -1) { // resolveFileDelete(tab); // } // } // }); // }); watcher.on("delete", function(e) { var tab = tabManager.findTab(e.path); if (tab) addDeletedTab(tab); }); } /***** Methods *****/ function addChangedTab(tab, isSameFile) { // If we already have a dialog open, just update it, but mark the value dirty if (changedPaths[tab.path]) { if (changedPaths[tab.path].data) changedPaths[tab.path].dirty = true; if (changeDialog && changeDialog.visible) return; } // Ignore changes that come in while tab is being saved if (tab.document.meta.$saving) { console.log("[watchers] Watcher fired, but tab is still saving", path); return; } changedPaths[tab.path] = { tab: tab, resolve: resolve }; // If the terminal is currently focussed, lets wait until // another tab is focussed if (tabManager.focussedTab && tabManager.focussedTab.editorType == "terminal") { tabManager.once("focus", function(){ addChangedTab(tab, false); }); return; } function resolve() { console.log("[watchers] resolved change event without dialog", path); doc.tab.classList.remove("conflict"); delete doc.meta.$merge; delete changedPaths[path]; } var doc = tab.document; var path = tab.path; if (isSameFile) checkByStatOrContents(); else checkByContents(); function dialog(data) { if (!changedPaths[path]) return; changedPaths[path].data = data || changedPaths[path].data; if (changeDialog) { // The dialog is visible if (changeDialog.visible === 1) { question.all = true; return; } // The dialog still is to become visible else if (changeDialog.visible === undefined) { changeDialog.on("show", function(){ question.all = true; }); return; } } if (tabManager.focussedTab && !changedPaths[tabManager.focussedTab.path]) { doc.tab.classList.add("conflict"); // Let's try again later, maybe then one of our paths gets focus return tabManager.once("focus", function() { dialog(); }); } if (!tabManager.findTab(path)) // drat! tab is gone return; // Show dialogs for changed tabs for (var changedPath in changedPaths) { tab = changedPaths[changedPath].tab; data = changedPaths[changedPath].data; showChangeDialog(tab, data); } } function checkByStatOrContents() { fs.stat(path, function(err, stat) { if (!err && doc.meta.timestamp >= stat.mtime) return resolve(); checkByContents(stat); }); } function checkByContents(stat) { fs.readFile(path, function(err, data) { if (err) { console.warn("[watchers] Could not read", path, "will assume it got changed"); return dialog(); } // false alarm. File content didn't change if (data === doc.meta.$savedValue) return resolve(); // Store base value for merges if (doc.meta.$mergeRoot == undefined) doc.meta.$mergeRoot = doc.meta.$savedValue || doc.recentValue; // Update saved value doc.meta.$savedValue = data; if (stat) doc.meta.timestamp = stat.mtime; // short cut: remote value is the same as the current value if (data === doc.value) { // Expensive check // Remove the changed state from the document doc.undoManager.bookmark(); // Mark as resolved resolve(); return; } else { // if this is the first time change notification comes // remember undomanger state for deciding to merge or not if (doc.meta.$merge == undefined) doc.meta.$merge = !doc.undoManager.isAtBookmark(); doc.undoManager.bookmark(-2); } if (automerge(tab, data)) resolve(); else dialog(data); }); } } function automerge(tab, data) { if (!settings.getBool("user/general/@automerge")) return false; return merge(tab, data); } function merge(tab, data) { if (tab.editor.type != "ace") return false; var doc = tab.document; var root = doc.meta.$mergeRoot || doc.meta.$savedValue; if (typeof root !== "string") return false; var aceDoc = doc.getSession().session.doc; var mergedValue = threeWayMerge(root, data, aceDoc); doc.meta.$mergeRoot = data; // If the value on disk is the same as in the document, set the bookmark if (mergedValue == data) doc.undoManager.bookmark(); return true; } function getLatestValue(path, callback) { if (!changedPaths[path] || changedPaths[path].dirty || !changedPaths[path].data) { fs.readFile(path, function(err, data) { callback(err, path, data); }); } else { callback(null, path, changedPaths[path].data); } } function updateChangedPath(err, path, data) { var tab = changedPaths[path].tab || tabManager.findTab(path); if (!tab) return changedPaths[path] && changedPaths[path].resolve(); var doc = tab.document; doc.setBookmarkedValue(data, true); doc.meta.timestamp = Date.now() - settings.timeOffset; changedPaths[path].resolve(); } function mergeChangedPath(err, path, data) { merge(changedPaths[path].tab, data); changedPaths[path].resolve(); } function showChangeDialog(tab, data) { var path, merge; if (!tab) { for (path in changedPaths) { tab = changedPaths[path].tab; data = changedPaths[path].data; break; } if (!tab) return; } path = tab.path; merge = tab.document.meta.$merge && typeof tab.document.meta.$savedValue === "string"; function no(all) { // Local | No if (all) { for (var id in changedPaths) { changedPaths[id].tab.document.undoManager.bookmark(-2); changedPaths[id].resolve(); } } else { changedPaths[path].tab.document.undoManager.bookmark(-2); changedPaths[path].resolve(); } checkEmptyQueue(); } function yes(all) { // Remote | Yes if (all) { for (var id in changedPaths) { getLatestValue(id, updateChangedPath); } } else { getLatestValue(path, function(err, path, data) { updateChangedPath(err, path, data); }); } checkEmptyQueue(); } if (merge) { changeDialog = filechange.show( "File Changed", path + " has been changed on disk.", no, yes, function(all) { // Merge if (all) { askAutoMerge(); for (var id in changedPaths) { getLatestValue(id, mergeChangedPath); } } else { askAutoMerge(); getLatestValue(path, function(err, path, data) { mergeChangedPath(err, path, data); }); } checkEmptyQueue(); }, { merge: true, all: Object.keys(changedPaths).length > 1 } ); } else { changeDialog = question.show( "File Changed", path + " has been changed on disk.", "Would you like to reload this file?", yes, no, { all: Object.keys(changedPaths).length > 1 } ); } if (!initialFocus) initialFocus = tabManager.focussedTab; // Focus the tab that is changed tabManager.focusTab(tab); } function addDeletedTab(tab, force) { if (!force && removedPaths[tab.path]) return; // If the terminal is currently focussed, lets wait until // another tab is focussed if (tabManager.focussedTab && tabManager.focussedTab.editorType == "terminal") { tabManager.once("focus", function(){ addDeletedTab(tab, true); }); return; } fs.stat(tab.path, function(err, data) { if (err && err.code === "ENOENT") { removedPaths[tab.path] = tab; if (deleteDialog) { // The dialog is visible if (deleteDialog.visible === 1) { question.all = true; return; } // The dialog still is to become visible else if (deleteDialog.visible === undefined) { deleteDialog.on("show", function(){ question.all = true; }); return; } } if (!tabManager.findTab(tab.path)) // drat! tab is gone return; // Show dialog showDeleteDialog(tab); } else { watcher.watch(tab.path); // Restore file watcher } }); } function showDeleteDialog(tab) { var path; if (!tab) { for (path in removedPaths) { tab = removedPaths[path]; break; } if (!tab) return; } path = tab.path; deleteDialog = question.show( "File removed, keep tab open?", path + " has been deleted, or is no longer available.", "Do you wish to keep the file open in the editor?", function(all) { // Yes var doc; if (all) { for (var id in removedPaths) { doc = removedPaths[id].document; doc.undoManager.bookmark(-2); doc.meta.newfile = true; } removedPaths = {}; } else { doc = removedPaths[path].document; doc.undoManager.bookmark(-2); doc.meta.newfile = true; delete removedPaths[path]; showDeleteDialog(); } checkEmptyQueue(); }, function(all, cancel) { // No if (all) { for (var id in removedPaths) { closeTab(removedPaths[id], true); } removedPaths = {}; } else { closeTab(removedPaths[path]); delete removedPaths[path]; showDeleteDialog(); } checkEmptyQueue(); }, { all: Object.keys(removedPaths).length > 1 } ); deleteDialog.on("show", function(){ if (!tabManager.findTab(path)) return false; }); if (!initialFocus) initialFocus = tabManager.focussedTab; // Focus the tab that is to be deleted tabManager.focusTab(tab); } function checkEmptyQueue(){ for (var prop in changedPaths) return; for (var prop in removedPaths) return; if (initialFocus) { tabManager.focusTab(initialFocus); initialFocus = null; } } function closeTab(tab, noAnim) { // Close file without a check tab.document.meta.$ignoreSave = true; tab.close(noAnim); // Remove the flag for the case that the doc is restored delete tab.document.meta.$ignoreSave; } function askAutoMerge() { if (!settings.getBool("user/general/@confirmmerge")) return; question.show( "Always merge?", "Always merge on file changes?", "Enabling 'auto merge' makes it very easy to collaborate on " + "files with other people, especially when combined with " + "'auto save'. This setting can be controlled from the " + "settings panel as well.", function() { // on yes if (question.dontAsk) settings.set("user/general/@confirmmerge", "false"); settings.set("user/general/@automerge", "true"); }, function() { // on no if (question.dontAsk) settings.set("user/general/@confirmmerge", "false"); settings.set("user/general/@automerge", "false"); }, { showDontAsk: true } ); } /***** Lifecycle *****/ plugin.on("load", function(){ load(); }); plugin.on("enable", function(){ }); plugin.on("disable", function(){ }); plugin.on("unload", function(){ loaded = false; }); /***** Register and define API *****/ /** * */ plugin.freezePublicAPI({ }); register(null, { "watcher.gui": plugin }); } });