define(function(require, module, exports) { main.consumes = [ "Plugin", "ace", "util", "apf", "collab.connect", "collab.util", "collab.workspace", "timeslider", "CursorLayer", "AuthorLayer", "error_handler", "vfs.log" ]; main.provides = ["OTDocument"]; return main; function main(options, imports, register) { var Plugin = imports.Plugin; var connect = imports["collab.connect"]; var c9Util = imports.util; var collabUtil = imports["collab.util"]; var apf = imports.apf; var workspace = imports["collab.workspace"]; var timeslider = imports.timeslider; var CursorLayer = imports.CursorLayer; var AuthorLayer = imports.AuthorLayer; var errorHandler = imports.error_handler; var logger = imports["vfs.log"]; var lang = require("ace/lib/lang"); // var Range = require("ace/range").Range; var xform = require("./xform"); var operations = require("./operations"); var apply = require("./apply"); var applyContents = apply.applyContents; var applyAce = apply.applyAce; var IndexCache = require("./index_cache"); var applyAuthorAttributes = require("./author_attributes")().apply; // The minimum delay that should be between a commited EDIT_UPDATE and the next // Happens when I'm collaboratively editing and the collaborators cursors are nearby var MIN_DELAY = options.minDelay; // The maximum delay that should be between a commited EDIT_UPDATE and the next // happens when I'm editing alone var MAX_DELAY = options.maxDelay; var MAX_COMMIT_TRIALS = 20; var COLLAB_SAVE_FILE_TIMEOUT = 5000; var MAX_OP_SIZE = 1024 * 1024; function OTDocument(docId, c9Document) { var plugin = new Plugin("Ajax.org", main.consumes); var emit = plugin.getEmitter(); var cloneObject = c9Util.cloneObject; var debug = connect.debug; var doc, session; var docStream, revStream; var cursorLayer, authorLayer; var revCache, rev0Cache; var revisions, starRevNums; var latestRevNum, lastSel; var commitTrials; var sendTimer, cursorTimer, saveTimer; var incoming, outgoing; var ignoreChanges; var packedCs; var loaded, loading, inited; var state; var stateWhenSaveCalled, saveStateDebugging; // Debug variables, for tracking down errors in prod var pendingSave; var readonly; var reqId; var rejoinReason; resetState(); function resetState() { if (session) { session.off("change", handleUserChanges); session.off("changeEditor", onChangeEditor); session.selection.off("changeCursor", onCursorChange); session.selection.off("changeSelection", onCursorChange); session.selection.off("addRange", onCursorChange); } if (inited) { cursorLayer.dispose(); authorLayer.dispose(); } docStream = revStream = undefined; revCache = rev0Cache = lastSel = undefined; // latestRevNum stays since we use it in joinWithSession() clearTimeout(sendTimer); clearTimeout(cursorTimer); endSaveWatchDog(); sendTimer = cursorTimer = undefined; commitTrials = 0; incoming = []; outgoing = []; packedCs = []; revisions = []; starRevNums = []; loaded = loading = inited = ignoreChanges = false; state = "IDLE"; emit.unsticky("joined"); } // @see docs in the API section below function setSession(aceSession) { if (!aceSession) return console.warn("[OT] setSession null aceSession!"); if (session) return console.warn("[OT] Ace's session previously set!"); session = aceSession; session.collabDoc = plugin; var aceDoc = session.doc; IndexCache(aceDoc); aceDoc.oldSetValue = aceDoc.oldSetValue || aceDoc.setValue; aceDoc.setValue = patchedSetValue; aceDoc.getNewLineCharacter = patchedGetNewLineCharacter; // aceDoc.applyDeltas = patchedApplyDeltas.bind(aceDoc); // aceDoc.revertDeltas = patchedRevertDeltas.bind(aceDoc); if (loaded) joinWithSession(); incoming.forEach(handleMessage); incoming = []; session.on("change", handleUserChanges); session.on("changeEditor", onChangeEditor); session.selection.on("changeCursor", onCursorChange); session.selection.on("changeSelection", onCursorChange); session.selection.on("addRange", onCursorChange); } /** * patch the original Ace document's setValue to only apply the edits */ function patchedSetValue(text) { var prev = this.getValue(); if (!prev) return this.oldSetValue(text); text = collabUtil.normalizeTextLT(text); applyAce(operations.operation(prev, text), this); } function patchedGetNewLineCharacter() { return "\n"; } /* // Patch the Ace document's applyDeltas to record the deltas's author function patchedApplyDeltas(deltas) { for (var i=0; i=0; i--) { var delta = deltas[i]; this.fromDelta = delta; var range = Range.fromPoints(delta.range.start, delta.range.end); if (delta.action == "insertLines") this._removeLines(range.start.row, range.end.row - 1); else if (delta.action == "insertText") this.remove(range); else if (delta.action == "removeLines") this._insertLines(range.start.row, delta.lines); else if (delta.action == "removeText") this.insert(range.start, delta.text); } this.fromDelta = null; } */ /** * Record and buffer the user changes to the document into packedCs for it to be sent * to the collab server as EDIT_UPDATE */ function handleUserChanges(delta) { // needed to provide immediate feedback for remote selection changes caused by local edits session._emit("changeBackMarker"); if (!inited || ignoreChanges) return; try { var aceDoc = session.doc; packedCs = handleUserChanges2(aceDoc, packedCs, delta); scheduleSend(); } catch (ex) { reportError(ex, { data: delta }); rejoin("E_EDIT_UPDATE"); } } function handleUserChanges2(aceDoc, packedCs, delta) { packedCs = packedCs.slice(); var nlCh = "\n"; var startOff = aceDoc.positionToIndex(delta.start, false, true); var offset = startOff, opOff = 0; var op = packedCs[0]; while (op) { if (operations.type(op) === "delete") ; // process next op else if (offset < operations.length(op)) break; else offset -= operations.length(op); op = packedCs[++opOff]; } if (op && offset !== 0) { var splitted = operations.split(op, offset); packedCs.splice(opOff, 1, splitted[0], splitted[1]); opOff++; } var authorI; if (delta.action === "insert") { var newText = delta.lines.join(nlCh); /*if (aceDoc.fromDelta && aceDoc.fromDelta.authAttribs) { var undoAuthAttribs = aceDoc.fromDelta.authAttribs; var reversedAuthorPool = workspace.reversedAuthorPool; var i = 0; while (i < undoAuthAttribs.length) { var startI = i; authorI = undoAuthAttribs[i++]; while (authorI === undoAuthAttribs[i]) i++; outgoing.push({ docId: docId, op: ["r" + (startOff + startI), "i" + newText.substring(startI, i)], author: reversedAuthorPool[authorI] || -2 }); } doc.authAttribs.splice.apply(doc.authAttribs, [startOff, 0].concat(undoAuthAttribs)); } else {*/ // Manage coloring my own edits authorI = workspace.authorPool[workspace.myUserId]; packedCs.splice(opOff, 0, "i" + newText); applyAuthorAttributes(doc.authAttribs, ["r" + startOff, "i" + newText], authorI); //} } else if (delta.action === "remove") { var removedText = delta.lines.join(nlCh); var remainingText = removedText; var opIdx = opOff; var nextOp = packedCs[opIdx]; while (remainingText.length) { if (!nextOp) { // Seems like we're in an inconsistent state; rejoin reportError(new Error("Collab: failed to remove text past end of document; rejoining"), { data: delta }); return rejoin(); } var opLen = operations.length(nextOp); var toRem = Math.min(remainingText.length, opLen); switch (operations.type(nextOp)) { case "retain": packedCs[opIdx] = "d" + remainingText.substring(0, toRem); if (opLen > remainingText.length) packedCs.splice(opIdx + 1, 0, "r" + (opLen - remainingText.length)); remainingText = remainingText.substring(toRem); opIdx++; break; case "insert": packedCs.splice(opIdx, 1); if (opLen > remainingText.length) packedCs.splice(opIdx, 0, operations.split(nextOp, toRem)[1]); remainingText = remainingText.substring(toRem); break; case "delete": opIdx++; break; } nextOp = packedCs[opIdx]; } // data.authAttribs = doc.authAttribs.slice(startOff, startOff + removedText.length); applyAuthorAttributes(doc.authAttribs, ["r" + startOff, "d" + removedText], authorI); } return operations.pack(packedCs); } /** * Calculate the edit update delays based on the number of clients * joined to a document and thier latest updated cursor positions */ function calculateDelay() { var delay = MAX_DELAY; var selections = cursorLayer ? cursorLayer.selections : {}; var aceEditor = (c9Document.editor || "").ace; // todo unfocused document shouldn't keep ref to editor if (!aceEditor || aceEditor.session != session) return MAX_DELAY; var config = aceEditor.renderer.layerConfig; for (var clientId in selections) { delay -= 3000; var ranges = selections[clientId].screenRanges || []; for (var i = 0; i < ranges.length; i++) { var range = ranges[i]; var cursorPos = range.start; // range.cursor // same editing region if (config.firstRow <= cursorPos.row && cursorPos.row <= config.lastRow) delay = MIN_DELAY; } } delay = Math.max(MIN_DELAY, delay); return delay; } function scheduleSend() { if (sendTimer) return; var delay = (pendingSave || commitTrials > 0) ? 0 : calculateDelay(); sendTimer = setTimeout(function () { doSend(); sendTimer = null; }, delay); } function addOutgoingEdit() { var uiDoc = session.getValue(); var msg = { docId: docId, op: operations.pack(packedCs) }; clearCs(uiDoc.length); outgoing.push(msg); } // send an "EDIT_UPDATE" message, and piggy-pack it with the current seletion function doSend() { if (state !== "IDLE") return; if (sendTimer) { clearTimeout(sendTimer); sendTimer = null; } var st = new Date(); if (!outgoing.length && !isPackedUnity()) addOutgoingEdit(); if (outgoing.length) { state = "COMMITTING"; commitTrials++; var top = outgoing[0]; top.revNum = latestRevNum + 1; top.selection = lastSel = CursorLayer.selectionToData(session.selection); if (top.op.filter(function(o) { return o.length > MAX_OP_SIZE; }).length) { connect.send("LARGE_DOC", { docId: top.docId }); // TODO: rejoin when doc becomes ok again emit("largeDocument"); return leave(); } connect.send("EDIT_UPDATE", top); emit("send", { revNum: top.revNum }); } else { onCursorChange(); } if (debug) console.log("[OT] send took", new Date() - st, "ms"); } // Set the Ace document's value and optionally reset and/or bookmark the undo manager function setValue(contents, reset, bookmark, callback) { var state = c9Document.getState(); state.value = contents; c9Document.setState(state); clearCs(contents.length); lang.delayedCall(function() { if (reset) c9Document.undoManager.reset(); if (bookmark) c9Document.undoManager.bookmark(); callback && callback(); }).schedule(); } // @see docs in the API section below var lastData, chunkError; // for debugging function joinData(data) { var st = new Date(); loaded = false; if (data.reqId && reqId !== data.reqId) return console.log("Ignored unrequested join data"); var err = data.err; if (err) { if (err.code != "ENOENT" && err.code != "ELARGE") console.error("JOIN_DOC Error:", docId, err); if (err.code == "ELARGE") emit("largeDocument"); loading = false; reportError(new Error("Couldn't join to document"), err); return emit.sticky("joined", { err: err }); } var copiedData = { userId: data.userId, clientId: data.clientId, docId: data.docId, reqId: data.reqId, chunkNum: data.chunkNum, chunksLength: data.chunksLength }; if (!lastData && copiedData.chunkNum != 1 || copiedData.chunkNum == 1 && docStream || copiedData.chunkNum != (lastData && lastData.chunkNum || 0) + 1 || lastData && lastData.reqId != copiedData.reqId && copiedData.chunkNum != 1 || typeof data.chunk != "string" || !data.chunk ) { chunkError = { lastData: lastData, newData: copiedData, hasDocStream: typeof docStream != "string" && docStream, chunk: typeof data.chunk != "string" && data.chunk }; } lastData = copiedData; if (data.chunkNum === 1) docStream = ""; docStream += data.chunk; var complete = data.chunkNum === data.chunksLength; if (!c9Document.hasValue()) emit("joinProgress", { loaded: data.chunkNum, total: data.chunksLength, complete: complete }); if (!complete) return; try { doc = JSON.parse(docStream); } catch (e) { var error = typeof docStream == "string" ? { startCh: docStream.slice(0, 10), endCh: docStream.slice(-10) } : { docStream: docStream, type: typeof docStream }; error.copiedData = copiedData; error.chunk = typeof data.chunk != "string" && data.chunk; error.chunkError = chunkError; // this happens because sending lots of data via socket breaks it very often // we need to refactor this to use rest api instead // until that do not report this error return rejoin("E_JOIN"); } finally { lastData = null; docStream = null; chunkError = null; } if (docId !== data.docId) reportError(new Error("docId mismatch"), { serverDocId: data.docId }); revisions = []; starRevNums = []; revCache = { revNum: doc.revNum, contents: doc.contents, authAttribs: cloneObject(doc.authAttribs) }; state = "IDLE"; outgoing = []; incoming = []; if (pendingSave) { // Document was updated to latest state on disk, // but that information won't really help users, // so we're just going to save the state as it is now. saveWatchDog(true); save(pendingSave.silent); } delete doc.selections[workspace.myOldClientId]; // in case of being away delete doc.selections[workspace.myClientId]; // in case of a rejoin (can also be restored, if existing) if (session) joinWithSession(); if (debug) console.log("[OT] init took", new Date() - st, "ms"); loaded = true; loading = false; emit.sticky("joined", { contents: doc.contents, metadata: doc.metadata }); } /** * Completes the initialization of the document after the document has * finished loading. * * When this is called, it's possible the document is already * open or even edited, but collab is joining it late. This * can happen e.g. when the IDE initially loads or with rejoin(). */ function joinWithSession() { var clean = doc.fsHash === doc.docHash; if (!clean) console.log("[OT] doc latest state fs diff", docId); var clientContents = session.getValue(); var serverContents = doc.contents; var clientRevNum = latestRevNum; var serverRevNum = doc.revNum; // Reset state latestRevNum = serverRevNum; // No one else edited the document: send any local changes to server var otherMembersNums = Object.keys(doc.selections).length; if (clientRevNum === serverRevNum || (!otherMembersNums && clientContents)) { packedCs = operations.operation(serverContents, clientContents); scheduleSend(); if (!clientRevNum) { var authorI = workspace.authorPool[workspace.myUserId]; applyAuthorAttributes(doc.authAttribs, packedCs, authorI); } } // Seems document wasn't loaded yet; set server contents else if (!clientRevNum) { if (rejoinReason && clientContents && clientContents !== serverContents) { reportError(new Error("Collab: reverting local changes on uninited document"), { rejoinReason: rejoinReason, serverRevNum: serverRevNum, clientContents: clientContents, serverContents: serverContents }); logger.log("Collab: reverting local changes on document " + doc.path + " rev: " + serverRevNum + " rejoin reason: " + rejoinReason); } setValue(serverContents, clean, clean); // reset and bookmark if clean } // Other people edited the document, or there was a newer version on the server // Restore consistency by overwiting any local changes else { if (clientRevNum > serverRevNum) { reportError(new Error("Collab: clientRevNum is higher than serverRevNum"), { rejoinReason: rejoinReason, clientRevNum: clientRevNum, serverRevNum: serverRevNum, clientContents: clientContents, serverContents: serverContents }); logger.log("Collab: clientrevNum is higher than serverRevNum " + doc.path + " clientRev: " + clientRevNum + " serverRev: " + serverRevNum + " rejoin reason: " + rejoinReason); } if (rejoinReason && clientContents && clientContents !== serverContents) { // TODO: nicely merge local and remote edits reportError(new Error("Collab: reverting local changes on collaborative document"), { rejoinReason: rejoinReason, clientRevNum: clientRevNum, serverRevNum: serverRevNum, clientContents: clientContents, serverContents: serverContents }); logger.log("Collab: reverting local changes on collaborative document " + doc.path + " clientRev: " + clientRevNum + " serverRev: " + serverRevNum + " rejoin reason: " + rejoinReason); } // Will auto-aptimize to use 'patchedSetValue' setValue(serverContents, clean, clean); // reset and bookmark } if (doc.newLineChar) { setAceNewLineMode(doc.newLineChar); } else if (doc.newLineChar === undefined) { // needed for interoperability with old versions of collab server reportError(new Error("doc.newLineChar is undefined")); var nlCh = collabUtil.detectNewLineType(doc.contents || ""); doc.permanentNlCh = nlCh; setAceNewLineMode(nlCh); } else { console.log("[OT] doc.newLineChar empty for ", docId, "new file? ok - syncing newline mode to collab server"); onChangeNewLineMode(); } rejoinReason = undefined; cursorTimer = setTimeout(changedSelection, 500); cursorLayer && cursorLayer.dispose(); cursorLayer = new CursorLayer(session, workspace); cursorLayer.updateSelections(doc.selections); authorLayer && authorLayer.dispose(); authorLayer = new AuthorLayer(session, workspace); authorLayer.refresh(); inited = true; } function setAceNewLineMode(lineEndChar) { if (!session || !session.doc) return; var mode; switch (lineEndChar) { case "\r\n": mode = "windows"; break; case "\n": case undefined: mode = "unix"; break; default: // Like Ace, this is all we support reportError(new Error("Warning: unexpected newLine mode: " + lineEndChar)); mode = "unix"; } session.doc.off("changeNewLineMode", onChangeNewLineMode); session.doc.$fsNewLine = lineEndChar; session.doc.setNewLineMode(mode); session.doc.on("changeNewLineMode", onChangeNewLineMode); } function onChangeNewLineMode() { var mode = session.doc.getNewLineMode(); var nlCh = mode == "windows" ? "\r\n" : "\n"; // needed for interoperability with old versions of collab server if (doc.permanentNlCh) { nlCh = doc.permanentNlCh; setAceNewLineMode(nlCh); } session.doc.$fsNewLine = nlCh; connect.send("UPDATE_NL_CHAR", { docId: docId, newLineChar: nlCh }); } function reportError(exception, details) { details = details || {}; details.state = state; details.session = !!session; details.newLineMode = session && session.doc && session.doc.getNewLineMode(); details.docId = docId; details.latestRevNum = latestRevNum; details.onlineCount = workspace.onlineCount; details.rejoinReason = rejoinReason; errorHandler.reportError(exception, details, ["collab"]); } // Checks if the packesCs is empty & doesn't have real-edit changes function isPackedUnity() { // Empty doc - or all retain - no user edits return !packedCs.length || (packedCs.length === 1 && packedCs[0][0] === "r"); } // Clears the packedCs from any edits function clearCs(len) { if (!len) packedCs = []; else packedCs = ["r" + len]; } // OT-Transform the outgoing edits in regard to the inMsg edit operation and vice versa function xformEach(outgoing, inMsg) { var ops = inMsg.op; var msg; function k(aPrime, bPrime) { msg.op = aPrime; ops = bPrime; } for (var i = 0, len = outgoing.length; i < len; i++) { msg = outgoing[i]; xform(msg.op, ops, k); } inMsg.op = ops; } // If the edit message doesn't have an 'op' attribute, then, it's my edit // The collab server is being smart optimizing huge edits network consumption function isOurOutgoing(msg, top) { return !msg.op && // msg.clientId === workspace.myClientId && ---> break tests msg.docId === top.docId && msg.revNum === top.revNum; } // Validate and handle incoming edits and OT xform and apply other authors' edits into the document // An edit message can also have a piggy-packed selection update - optimization function handleIncomingEdit(msg) { if (msg.revNum !== latestRevNum + 1) { reportError(new Error("Collab: Incoming edit revNum mismatch"), { serverRevNum: msg.revNum, latestRevNum: latestRevNum }); return; } var st = new Date(); if (outgoing.length && isOurOutgoing(msg, outgoing[0])) { // 'op' not sent to save network bandwidth commitTrials = 0; msg.op = outgoing[0].op; outgoing.shift(); addRevision(msg); state = "IDLE"; if (pendingSave && --pendingSave.outLen === 0) doSaveFile(pendingSave.silent); scheduleSend(); } else { addRevision(msg); if (msg.sync) { // revert & discard all my uncommited changesets because: // a- a file watcher caused this document sync on (overriding my changes) // b- my changes may lead to inconsist state or can fail to be applied to the new synced document state /* Clear the timer that watcher set to ensure that collab applies its changes */ c9Document.tab.meta.$lastCollabChange = Date.now(); if (c9Document.tab.meta.$collabChangeRegistered) { clearTimeout(c9Document.tab.meta.$collabChangeRegistered); delete c9Document.tab.meta.$collabChangeRegistered; } var path = msg.path || msg.docId; logger.log("Collab: Received new document, discarding any pending changes " + path + " msg: ", msg, " latestRevNum: " + latestRevNum); try { revertMyPendingChanges(msg.userId); } catch (e) { var cause = e.message; e.message = "Collab: unable to revert pending changes, reloading"; reportError(e, { cause: cause }); return rejoin("E_REVERT"); } pendingSave = null; } else { // maintain my outgoing and current changeset outgoing.push({ op: packedCs }); xformEach(outgoing, msg); packedCs = outgoing.pop().op; } var sel = session.selection; // insert edits on the right of the cursor/selection to not move the user's cursor unexpectedly (done by Google Docs) cursorLayer.setInsertRight(msg.clientId, false); sel.anchor.$insertRight = sel.lead.$insertRight = true; // do not include remote changes in the open undo group var rev = session.$undoManager.startNewGroup(); applyEdit(msg, session.doc); session.$undoManager.markIgnored(rev); sel.anchor.$insertRight = sel.lead.$insertRight = false; // reset the right cursor/selection behaviour cursorLayer.setInsertRight(msg.clientId, true); if (msg.sync) { flagFileSaved(msg, true, true); clearCs(session.getValue().length); } } latestRevNum = msg.revNum; if (msg.selection) cursorLayer.updateSelection(msg); if (debug) console.log("[OT] handleIncomingEdit took", new Date() - st, "ms", latestRevNum); } // If the document is at the latest state, apply a edit into the current document state // Else if the timeslider is visible, do nothing - we can later get the contents from the revisions function applyEdit(msg, aceDoc, revertData) { if (timeslider.visible) return; var err; ignoreChanges = true; try { applyAce(msg.op, aceDoc); applyAuthorAttributes(doc.authAttribs, msg.op, workspace.authorPool[msg.userId]); } catch (e) { var oldMessage = e.message; if (e.code === "EMISMATCH") { e.message = // distinguish different error categories based on state !revertData ? "Mismatch applying client-side OT operation" : revertData.userId ? "Mismatch applying client-side OT operation, revert with userId" : "Mismatch applying client-side OT operation, revert without userId"; } reportError(e, { cause: e.message !== oldMessage ? oldMessage : undefined, msg: msg, revertData: revertData }); err = e; } finally { authorLayer.refresh(); if (msg.revNum) doc.revNum = msg.revNum; ignoreChanges = false; // try rejoin if (err) rejoin("E_APPLYEDIT"); } } // send a selection update to the collab server if not an exact match of the previous sent selection function changedSelection() { if (!session || !session.selection) return; cursorTimer = null; var currentSel = CursorLayer.selectionToData(session.selection); if (lastSel && lastSel.join('') === currentSel.join('')) return; lastSel = currentSel; connect.send("CURSOR_UPDATE", { docId: docId, selection: lastSel }); if (cursorLayer && cursorLayer.tooltipIsOpen) cursorLayer.hideAllTooltips(); } function onChangeEditor(e) { var oldEditor = e.oldEditor; if (oldEditor) { // console.log("[OT] old editor readonly", oldEditor.getReadOnly()); if (readonly) oldEditor.setReadOnly(false); } if (e.editor && readonly) { // console.log("[OT] new editor readonly", readonly); e.editor.setReadOnly(true); } } // my cursor or selection changes, schedule an update message function onCursorChange() { if (!loaded || ignoreChanges || cursorTimer) return; // Don't send too many cursor change messages cursorTimer = setTimeout(changedSelection, 200); } // Add an author's edit revision to the local revision history // Update the timeslider if rendering this document function addRevision(msg) { if (!msg.op.length) reportError(new Error("Empty rev operation should never happen!"), { msg: msg }); revisions[msg.revNum] = { operation: msg.op, revNum: msg.revNum, author: msg.userId, updated_at: msg.updated_at || Date.now() }; if (isActiveTimesliderDocument()) timeslider.sliderLength = msg.revNum; } // determines whether the document is the current and visible timeslider's active document or not function isActiveTimesliderDocument() { return timeslider.visible && timeslider.activeDocument === plugin; } /** * Gets a revision with the contents and authorship attributes of the document at this revision * Only works if revisions were previously loaded */ function getDetailedRevision(revNum, contentsOnly) { // Create the first revision and save it in rev0cache // It is created by undoing every operation performed on the document in reverse order if (!rev0Cache && !contentsOnly) { rev0Cache = createFirstRevisionViaInverseOperations(); revCache = null; } if (!revCache || revCache.revNum > revNum) { revCache = cloneObject(rev0Cache); } // These are the contents and authAttribs of the first revision var contents = revCache.contents; var authAttribs = cloneObject(revCache.authAttribs); // Apply each revision operation one after the other to get to the revision we are seeking. for (var i = revCache.revNum + 1; i <= revNum; i++) { contents = applyContents(revisions[i].operation, contents); applyAuthorAttributes(authAttribs, revisions[i].operation, workspace.authorPool[revisions[i].author]); } var rev = cloneObject(revisions[revNum]); if (!rev) return null; rev.contents = contents; rev.authAttribs = authAttribs; // Update revCache revCache.contents = contents; revCache.authAttribs = cloneObject(authAttribs); revCache.revNum = revNum; return rev; } function createFirstRevisionViaInverseOperations() { var i; var rev0Contents = revCache.contents; for (i = revCache.revNum; i > 0; i--) { var op = operations.inverse(revisions[i].operation); try { rev0Contents = applyContents(op, rev0Contents); } catch (e) { reportError(new Error("Revision history is not working for document"), { applyContentsError: e.message, revNum: i }); break; } } var originalRevision = { revNum: i, // this is the last revision we could get to, should be 0, but could be higher if revision history is broken contents: rev0Contents, authAttribs: [rev0Contents.length, null] }; return originalRevision; } /** * Gets the contents of a particular revision. Returns null * if the revision is not currently available locally. */ function getRevisionContents(revNum) { var rev = getDetailedRevision(revNum, true); return rev && rev.contents; } // @see docs in the API section below function historicalSearch(query) { var searchString = lang.escapeRegExp(query); var revNums = revisions.length; var result = { revNums: revNums }; for (var revNo = 0; revNo < revNums; revNo++) { var rev = getDetailedRevision(revNo); var count = 0; if (rev.contents.match(new RegExp(searchString, 'i'))) { count = rev.contents.match(new RegExp(searchString, 'gi')).length; result[revNo] = count; } } return result; } function isReadOnly() { return readonly || (c9Document.editor && c9Document.editor.ace && c9Document.editor.ace.getReadOnly()); } // @see docs in the API section below function updateToRevision(revNum) { if (!revisions[0]) return console.warn("[OT] revisions may haven't yet been loaded!"); if (!isReadOnly()) return console.error("[OT] Can't updateToRevNum while editing!"); if (typeof revNum === "undefined") revNum = revisions.length - 1; if (debug) console.log("[OT] REV:", revNum); if (doc.revNum === revNum) return; var rev = getDetailedRevision(revNum); timeslider.updateTimer(rev.updated_at); ignoreChanges = true; // TODO beter manage the undo manager stack: getState() & setState() var resetAndBookmark = starRevNums.indexOf(revNum) !== -1; setValue(rev.contents, resetAndBookmark, resetAndBookmark); // FIXME not a good practice to have mutable data // affecting the behaviour of the app doc.authAttribs = rev.authAttribs; authorLayer.refresh(); doc.revNum = revNum; ignoreChanges = false; } // @see docs in the API section below function revertToRevision(revNum) { var latestRev = getDetailedRevision(revisions.length - 1); var revertToRev = getDetailedRevision(revNum); ignoreChanges = true; // trick the undo manager that we were in a saved state: setValue(revertToRev.contents, false, false); // don't reset or bookmark to keep the doc changed var op = operations.operation(latestRev.contents, revertToRev.contents); var authAttribs = latestRev.authAttribs; var authorI = workspace.authorPool[workspace.myUserId]; applyAuthorAttributes(authAttribs, op, authorI); doc.authAttribs = authAttribs; authorLayer.refresh(); packedCs = op; scheduleSend(); ignoreChanges = false; } function revertMyPendingChanges(userId) { if (!isPackedUnity()) outgoing.push({ op: packedCs }); if (!outgoing.length) return; // TODO: determine when is this an error exactly? good to log it anyway now var doc = session.doc || {}; reportError(new Error("Collab: reverting pending changes to document because of server sync commit"), { outgoing: outgoing, doc: doc }); logger.log("Collab: reverting pending chagnes to document because of server sync commit " + doc.path + " revNum: " + doc.revNum); userId = userId || workspace.myUserId; for (var i = outgoing.length - 1; i >= 0; i--) { applyEdit( { op: operations.inverse(outgoing[i].op), userId: userId }, session.doc, { outgoing: outgoing, userId: userId } ); } outgoing = []; } // @see docs in the API section below function handleMessage(event) { if (inited && !doc) reportError(new Error("Weird state: inited, but no document")); if (!inited || !doc) return incoming.push(event); var data = event.data; switch (event.type) { case "EDIT_UPDATE": handleIncomingEdit(data); break; case "UPDATE_NL_CHAR": setAceNewLineMode(data.newLineChar); break; case "SYNC_COMMIT": handleSyncCommit(data); break; case "CURSOR_UPDATE": cursorLayer && cursorLayer.updateSelection(data); break; case "FILE_SAVED": handleFileSaved(data); break; case "FILE_LOCKING": saveStateDebugging = "LOCKING"; break; case "FILE_LOCKED": saveStateDebugging = "LOCKED"; break; case "FILE_RETRIEVED": saveStateDebugging = "RETRIEVED"; break; case "DATA_WRITTEN": saveStateDebugging = "DATAWRITTEN"; break; case "GET_REVISIONS": receiveRevisions(data); break; default: reportError(new Error("Unknown OT document event type:" + event.type + " " + JSON.stringify(event))); } } function handleSyncCommit(data) { state = "IDLE"; // this can happen if another users change reached server before ours, do not report error in that case if (data.code == "VERSION_E" && latestRevNum !== data.revNum) { reportError(new Error("Collab: OT version inconsistency"), { serverRevNum: data.revNum, latestRevNum: latestRevNum }); latestRevNum = data.revNum; } if (data.code == "OT_E" || commitTrials > MAX_COMMIT_TRIALS) { console.warn("[OT] Local document inconsistent with server; attempting rejoin -- SYNC_COMMIT", data.reason, data.code); logger.log("[OT] Local document inconsistent with server; attempting rejoin -- SYNC_COMMIT", data.reason, data.code); rejoin("OT_E"); } else { console.warn("Collab: [OT] Local document inconsistent with server; reapplying changes -- SYNC_COMMIT", data.reason, data.code); logger.log("Collab: [OT] Local document inconsistent with server; reapplying changes -- SYNC_COMMIT", data.reason, data.code); scheduleSend(); } } function handleFileSaved(data) { endSaveWatchDog(); var err = data.err; if (err) { console.error("[OT] Failed saving file!", err, docId); return emit("saved", { err: err }); } // pendingSave exists: save triggered by me // otherwise: other collaborator save if (pendingSave) { var value = getRevisionContents(data.revNum); if (value == undefined) { // value can be null if doc is just loaded and there are no revisions // but then fsHash should match if (plugin.docHash !== data.fsHash) { reportError(new Error("File saved, unable to confirm checksum"), { docHash: plugin.docHash, fsHash: data.fsHash, revNum: data.revNum, docId: docId }); } } else if (apf.crypto.MD5.hex_md5(value) !== data.fsHash) { reportError(new Error("File saving checksum failed; retrying with XHR")); return emit("saved", { err: "Save content mismatch", code: "EMISMATCH" }); } } var isClean = !outgoing.length || latestRevNum === data.revNum; var rev = revisions[data.revNum] || { revNum: data.revNum, updated_at: Date.now() }; flagFileSaved(rev, data.star, isClean); pendingSave = null; } function flagFileSaved(revision, isStar, isClean) { emit("saved", {}); if (isClean) { lang.delayedCall(function() { c9Document.undoManager.bookmark(); }).schedule(); } if (isStar) { starRevNums.push(revision.revNum); if (isActiveTimesliderDocument()) timeslider.addSavedRevision(revision); } } function receiveRevisions(data) { if (data.chunkNum === 1) revStream = ""; revStream += data.chunk; if (data.chunkNum < data.chunksLength) return; var revisionsObj = JSON.parse(revStream); revStream = null; revisions = revisionsObj.revisions; starRevNums = revisionsObj.starRevNums; emit("revisions", { revisions: revisions, stars: starRevNums }); if (isActiveTimesliderDocument()) loadRevisions(); } // @see docs in the API section below function loadRevisions() { if (!revisions[0]) { console.log("[OT] Loading revisions ..."); timeslider.loading = true; connect.send("GET_REVISIONS", { docId: docId }); return; } if (!isActiveTimesliderDocument()) return; var numRevs = revisions.length - 1; var lastRev = revisions[numRevs]; var starRevisions = starRevNums.map(function (revNum) { return revisions[revNum]; }); timeslider.loading = false; timeslider.sliderLength = numRevs; timeslider.setSavedRevisions(starRevisions); timeslider.sliderPosition = numRevs; timeslider.updateTimer(lastRev.updated_at); // Call again to re-render all slider elements timeslider.sliderLength = numRevs; cursorLayer.hideAllTooltips(); authorLayer.refresh(); } // @see docs in the API section below function save(silent) { saveWatchDog(); stateWhenSaveCalled = state; saveStateDebugging = null; var isUnity = isPackedUnity(); if (!isUnity) addOutgoingEdit(); pendingSave = { silent: !!silent, outLen: outgoing.length }; if (state === "IDLE" && isUnity) return doSaveFile(silent); doSend(); } function doSaveFile(silent) { saveWatchDog(); if (!pendingSave) // should be set, but let's make sure pendingSave = { silent: silent }; var event = { docId: docId }; emit("beforeSave", event); saveStateDebugging = "SAVING"; connect.send("SAVE_FILE", { postProcessor: event.postProcessor, docId: docId, silent: !!silent }); } function saveWatchDog(restart, timeout) { if (saveTimer && !restart) return; endSaveWatchDog(); saveTimer = setTimeout(function onSaveTimeout() { saveTimer = pendingSave = null; emit("saved", { err: "File save timeout", code: "ETIMEOUT" }); }, timeout || COLLAB_SAVE_FILE_TIMEOUT); } function endSaveWatchDog() { clearTimeout(saveTimer); saveTimer = null; } /** * Reinitializes collab for this document, making sure it is in a * consistent state. * * See also joinWithSession() for what happens to the current text. */ function rejoin(reason) { console.log("[OT] rejoining document", docId, "reason: ", reason); resetState(); rejoinReason = reason; var sameSession = session; session = null; setSession(sameSession); doLoad(); } // @see docs in the API section below function load() { if (state == "DISCONNECTED") rejoin("DISCONNECTED"); else doLoad(); } function doLoad() { loading = true; reqId = Math.floor(Math.random() * 9007199254740993); connect.send("JOIN_DOC", { docId: docId, reqId: reqId }); } // @see docs in the API section below function leave() { if (connect.connected) connect.send("LEAVE_DOC", { docId: docId }); resetState(); } // @see docs in the API section below function disconnect() { // We don't do fallback saving on disconnect, so we call // resetState(), which calls endSaveWatchDog() (working around #3680) resetState(); state = "DISCONNECTED"; logger.log("[OT] document", docId, "disconnected"); } // @see docs in the API section below function clientLeave(clientId) { cursorLayer && cursorLayer.clearSelection(clientId); } // @see docs in the API section below function isChanged () { var lastRev = revisions[revisions.length - 1]; return !isPackedUnity() || (revisions.length > 1 && starRevNums.indexOf(lastRev.revNum) === -1); } plugin.freezePublicAPI({ _events: [ /** * Fires when the document has joined * @event joined * @param {Object} e * @param {String} e.err the error encountered in joining the document, if any * @param {String} e.contents the contents of the joined document */ "joined", /** * Fires when the document join progress changes * @event joinProgress * @param {Object} e * @param {Number} e.loaded the error encountered in joining the document, if any * @param {Number} e.total the total number of chunks for this document to compeletely join */ "joinProgress", /** * Fires when the document is saved * @event saved * @param {Object} e * @param {Document} e.doc the document for which the state is set * @param {Object} e.state the state object */ "saved", /** * Fires when the revisions is loaded * @event revisions * @param {Object} e * @param [{Revision}] e.revisions the loaded revisions * @param [{Number}] e.stars the star/saved revision numbers */ "revisions", /** * Fires when a revision is sent to the server * @event send * @param {Object} e * @param {Number e.revNum the revision sent */ "send", ], /** * Get the collab document id * @property {String} id */ get id() { return docId; }, /** * Gets the reason why we had to rejoin this document * @property {Object} rejoinReason */ get rejoinReason() { return rejoinReason; }, /** * Get the collab document file path * @property {String} path */ get path() { return docId; }, /** * The current state * @property {String} state */ get state() { return state; }, /** * The current saveStateDebugging * @property {String} saveStateDebugging */ get stateWhenSaveCalled() { return stateWhenSaveCalled; }, /** * The current saveStateDebugging * @property {String} saveStateDebugging */ get saveStateDebugging() { return saveStateDebugging; }, /** * Get the collab Ace session /** * Get the collab Ace session * @property {EditSession} session */ get session() { return session; }, /** * Get the collab document's original Cloud9 document * @property {Document} c9Document */ get original() { return c9Document; }, /** * Specifies wether the document is loading or not * @property {Boolean} loading */ get loading() { return loading; }, /** * Specifies wether the document has finished loading or not * @property {Boolean} loaded */ get loaded() { return loaded; }, /** * Specifies wether the document has inited with session or not * @property {Boolean} inited */ get inited() { return inited; }, /** * Get the document's file system contents hash at the moment of joining * @property {String} fsHash */ get fsHash() { return doc && doc.fsHash; }, /** * Get the document's contents hash at the moment of joining * @property {String} docHash */ get docHash() { return doc && doc.docHash; }, /** * Get the document's authorship attributes * @property {AuthorAttributes} authAttribs */ get authAttribs() { return doc ? doc.authAttribs : []; }, /** * Get the revisions, if loaded * @property [{Revision}] revisions */ get revisions() { return revisions; }, /** * Get the document's cursor layer if the document was inited * @property {CursorLayer} cursorLayer */ get cursorLayer() { return cursorLayer; }, /** * Get the document's authorship info layer if the document was inited * @property {AuthorLayer} authorLayer */ get authorLayer() { return authorLayer; }, /** * Get the latest revision number * @property {Number} latestRevNum */ get latestRevNum() { return latestRevNum; }, /** * Indicates wether the collab document has unsaved changes. * @property {Boolean} changed */ get changed() { return isChanged(); }, /** * Indicates whether the document has changes that pend sending to the server. * @property {Boolean} pendingUpdates */ get pendingUpdates() { return packedCs.length > 1; }, /** * Indicates whether the document is readonly. * @property {Boolean} readonly */ get readonly() { return isReadOnly(); }, /** * Sets the document to readonly mode. * @property {Boolean} readonly */ set readonly(r) { readonly = r; c9Document.tab.editor.setOption("readOnly", r); }, /** * Load/Join the document from the collab server * loaded = false * loading = true */ load: load, /** * Sets the document's Ace session when the tab is completely inited and its document's EditSession is created and initialized * This happens on tab/file open, or on first tab focus * @param {EditSession} session */ setSession: setSession, /** * Leave the document * Unload the document and tell the collab server that I'm leaving it */ leave: leave, /** * Dispose the resources used by the document like the cursorLayer and authorLayer and reset its state */ dispose: resetState, /** * Disconnect the document and it will need to reload when the client comes back online */ disconnect: disconnect, /** * A client leaving the document - should clear his selections or cursors */ clientLeave: clientLeave, /** * Load the document's revisions from the collab server */ loadRevisions: loadRevisions, /** * Send any pending updates immediately. */ sendNow: doSend, /** * Update the document to its state on a certain revision number * Only works if the revisions were previously loaded * @param {Number} revNum {optional - defaults to the latest revision number} */ updateToRevision: updateToRevision, /** * Revert the document to its contents at a previous revision * Only works if the revisions were previously loaded * The authorship attributes may differ because the edit operations applied to revert to that revision maybe different * @param {Number} revNum {optional - defaults to the latest revision number} */ revertToRevision: revertToRevision, /** * Save the collab document to the filesystem through the collab server * @param {Boolean} silent - if true, save the file without adding a star revision number */ save: save, /** * Search accross the history of the document for existence of a certain text in any previous revision, with count * @param {String} query - the query string to search the history for */ historicalSearch: historicalSearch, /** * Receive join data chunk messages until the document loads * When the document is loaded: * loaded = true * loading = false * * @param {Object} data - a chunk of data received of the join data stream */ joinData: joinData, /** * Handles document-related messages of types: * "EDIT_UPDATE", "SYNC_COMMIT", "CURSOR_UPDATE", "FILE_SAVED", "GET_REVISIONS" * @param {Object} data - a chunk of data received of the join data stream */ handleMessage: handleMessage }); return plugin; } register(null, { "OTDocument": OTDocument }); } });