Merge branch 'collaboration-presence' into shared-buffers

Conflicts:
	src/app/edit-session.coffee
	src/app/project.coffee
	src/app/text-buffer.coffee
	src/app/window.coffee
	src/packages/collaboration/lib/bootstrap.coffee
	src/packages/collaboration/lib/session-utils.coffee
	vendor/telepath
This commit is contained in:
Kevin Sawicki
2013-07-22 10:10:56 -07:00
44 changed files with 573 additions and 136 deletions

View File

@@ -49,7 +49,9 @@ class Directory
# pathToCheck - the {String} path to check.
#
# Returns a {Boolean}.
contains: (pathToCheck='') ->
contains: (pathToCheck) ->
return false unless pathToCheck
if pathToCheck.indexOf(path.join(@getPath(), path.sep)) is 0
true
else if pathToCheck.indexOf(path.join(@getRealPath(), path.sep)) is 0
@@ -62,7 +64,9 @@ class Directory
# fullPath - The {String} path to convert.
#
# Returns a {String}.
relativize: (fullPath='') ->
relativize: (fullPath) ->
return fullPath unless fullPath
if fullPath is @getPath()
''
else if fullPath.indexOf(path.join(@getPath(), path.sep)) is 0

View File

@@ -319,7 +319,7 @@ class EditSession
# Retrieves the current buffer's URI.
#
# Returns a {String}.
getUri: -> @getPath()
getUri: -> @buffer.getUri()
# {Delegates to: Buffer.isRowBlank}
isBufferRowBlank: (bufferRow) -> @buffer.isRowBlank(bufferRow)

View File

@@ -10,6 +10,28 @@ GitUtils = require 'git-utils'
# Ultimately, this is an overlay to the native [git-utils](https://github.com/atom/node-git) module.
module.exports =
class Git
### Public ###
# Creates a new `Git` instance.
#
# path - The git repository to open
# options - A hash with one key:
# refreshOnWindowFocus: A {Boolean} that identifies if the windows should refresh
#
# Returns a new {Git} object.
@open: (path, options) ->
return null unless path
try
new Git(path, options)
catch e
null
@exists: (path) ->
if git = @open(path)
git.destroy()
true
else
false
path: null
statuses: null
upstream: null
@@ -57,20 +79,6 @@ class Git
### Public ###
# Creates a new `Git` instance.
#
# path - The git repository to open
# options - A hash with one key:
# refreshOnWindowFocus: A {Boolean} that identifies if the windows should refresh
#
# Returns a new {Git} object.
@open: (path, options) ->
return null unless path
try
new Git(path, options)
catch e
null
# Retrieves the git repository.
#
# Returns a new `Repository`.
@@ -213,6 +221,14 @@ class Git
# Returns an object with two keys, `ahead` and `behind`. These will always be greater than zero.
getLineDiffs: (path, text) -> @getRepo().getLineDiffs(@relativize(path), text)
getConfigValue: (key) -> @getRepo().getConfigValue(key)
getReferenceTarget: (reference) -> @getRepo().getReferenceTarget(reference)
getAheadBehindCount: (reference) -> @getRepo().getAheadBehindCount(reference)
hasBranch: (branch) -> @getReferenceTarget("refs/heads/#{branch}")?
### Internal ###
refreshStatus: ->

View File

@@ -193,6 +193,7 @@ class Project
#
# Returns either an {EditSession} (for text) or {ImageEditSession} (for images).
open: (filePath, options={}) ->
filePath = @resolve(filePath) if filePath?
for opener in @constructor.openers
return resource if resource = opener(filePath, options)

View File

@@ -125,7 +125,7 @@ class RootView extends View
# Returns the `EditSession` for the file URI.
open: (path, options = {}) ->
changeFocus = options.changeFocus ? true
path = project.resolve(path) if path?
path = project.relativize(path)
if activePane = @getActivePane()
editSession = activePane.itemForUri(path) ? project.open(path)
activePane.showItem(editSession)

View File

@@ -147,6 +147,9 @@ class TextBuffer
getPath: ->
@file?.getPath()
getUri: ->
project?.relativize(@getPath()) ? @getPath()
# Sets the path for the file.
#
# path - A {String} representing the new file path

View File

@@ -74,11 +74,10 @@ class TextMateGrammar
getScore: (filePath, contents) ->
contents = fsUtils.read(filePath) if not contents? and fsUtils.isFileSync(filePath)
if syntax.grammarOverrideForPath(filePath) is @scopeName
2 + filePath.length
2 + (filePath?.length ? 0)
else if @matchesContents(contents)
1 + filePath.length
1 + (filePath?.length ? 0)
else
@getPathScore(filePath)

View File

@@ -8,6 +8,7 @@ dialog = require 'dialog'
fs = require 'fs'
path = require 'path'
net = require 'net'
url = require 'url'
socketPath = '/tmp/atom.sock'
@@ -38,7 +39,7 @@ class AtomApplication
installUpdate: null
version: null
constructor: ({@resourcePath, pathsToOpen, @version, test, pidToKillWhenClosed, @dev, newWindow}) ->
constructor: ({@resourcePath, pathsToOpen, urlsToOpen, @version, test, pidToKillWhenClosed, @dev, newWindow}) ->
global.atomApplication = this
@pidsToOpenWindows = {}
@@ -56,6 +57,8 @@ class AtomApplication
@runSpecs({exitWhenDone: true, @resourcePath})
else if pathsToOpen.length > 0
@openPaths({pathsToOpen, pidToKillWhenClosed, newWindow})
else if urlsToOpen.length > 0
@openUrl(urlToOpen) for urlToOpen in urlsToOpen
else
# Always open a editor window if this is the first instance of Atom.
@openPath({pidToKillWhenClosed, newWindow})
@@ -124,6 +127,7 @@ class AtomApplication
menus.push
label: 'File'
submenu: [
{ label: 'New Window', accelerator: 'Command+N', click: => @openPath() }
{ label: 'Open...', accelerator: 'Command+O', click: => @promptForPath() }
{ label: 'Open In Dev Mode...', accelerator: 'Command+Shift+O', click: => @promptForPath(devMode: true) }
]
@@ -170,6 +174,10 @@ class AtomApplication
event.preventDefault()
@openPath({pathToOpen})
app.on 'open-url', (event, urlToOpen) =>
event.preventDefault()
@openUrl(urlToOpen)
autoUpdater.on 'ready-for-update-on-quit', (event, version, quitAndUpdate) =>
event.preventDefault()
@installUpdate = quitAndUpdate
@@ -239,6 +247,14 @@ class AtomApplication
console.log("Killing process #{pid} failed: #{error.code}")
delete @pidsToOpenWindows[pid]
openUrl: (urlToOpen) ->
parsedUrl = url.parse(urlToOpen)
if parsedUrl.host is 'session'
sessionId = parsedUrl.path.split('/')[1]
if sessionId
bootstrapScript = 'collaboration/lib/bootstrap'
new AtomWindow({bootstrapScript, @resourcePath, sessionId})
openConfig: ->
if @configWindow
@configWindow.focus()

View File

@@ -7,6 +7,7 @@ fs = require 'fs'
path = require 'path'
optimist = require 'optimist'
nslog = require 'nslog'
dialog = require 'dialog'
_ = require 'underscore'
console.log = (args...) ->
@@ -17,11 +18,21 @@ require 'coffee-script'
delegate.browserMainParts.preMainMessageLoopRun = ->
args = parseCommandLine()
addPathToOpen = (event, filePath) ->
addPathToOpen = (event, pathToOpen) ->
event.preventDefault()
args.pathsToOpen.push(filePath)
args.pathsToOpen.push(pathToOpen)
args.urlsToOpen = []
addUrlToOpen = (event, urlToOpen) ->
event.preventDefault()
args.urlsToOpen.push(urlToOpen)
app.on 'open-url', (event, urlToOpen) ->
event.preventDefault()
args.urlsToOpen.push(urlToOpen)
app.on 'open-file', addPathToOpen
app.on 'open-url', addUrlToOpen
app.on 'will-finish-launching', ->
setupCrashReporter()
@@ -29,6 +40,7 @@ delegate.browserMainParts.preMainMessageLoopRun = ->
app.on 'finish-launching', ->
app.removeListener 'open-file', addPathToOpen
app.removeListener 'open-url', addUrlToOpen
args.pathsToOpen = args.pathsToOpen.map (pathToOpen) ->
path.resolve(args.executedFrom ? process.cwd(), pathToOpen)

View File

@@ -0,0 +1,3 @@
'.archive-view':
'k': 'core:move-up'
'j': 'core:move-down'

View File

@@ -7,6 +7,7 @@ File = require 'file'
module.exports=
class ArchiveEditSession
registerDeserializer(this)
@version: 1
@activate: ->
Project = require 'project'
@@ -14,10 +15,11 @@ class ArchiveEditSession
new ArchiveEditSession(filePath) if archive.isPathSupported(filePath)
@deserialize: ({path}={}) ->
path = project.resolve(path)
if fsUtils.isFileSync(path)
new ArchiveEditSession(path)
else
console.warn "Could not build edit session for path '#{path}' because that file no longer exists"
console.warn "Could not build archive edit session for path '#{path}' because that file no longer exists"
constructor: (@path) ->
@file = new File(@path)
@@ -27,7 +29,7 @@ class ArchiveEditSession
serialize: ->
deserializer: 'ArchiveEditSession'
path: @path
path: @getUri()
getViewClass: ->
require './archive-view'
@@ -38,7 +40,7 @@ class ArchiveEditSession
else
'untitled'
getUri: -> @path
getUri: -> project?.relativize(@getPath()) ? @getPath()
getPath: -> @path

View File

@@ -1,27 +1,35 @@
require 'atom'
require 'window'
$ = require 'jquery'
{$$} = require 'space-pen'
{createPeer, connectDocument} = require './session-utils'
{createSite, Document} = require 'telepath'
GuestSession = require './guest-session'
window.setDimensions(width: 350, height: 100)
window.setDimensions(width: 350, height: 125)
window.setUpEnvironment('editor')
{sessionId} = atom.getLoadSettings()
loadingView = $$ ->
@div style: 'margin: 10px; text-align: center', =>
@div "Joining session #{sessionId}"
@div style: 'margin: 10px', =>
@h4 style: 'text-align: center', 'Joining Session'
@div class: 'progress progress-striped active', style: 'margin-bottom: 10px', =>
@div class: 'progress-bar', style: 'width: 0%'
@div class: 'progress-bar-message', 'Establishing connection\u2026'
$(window.rootViewParentSelector).append(loadingView)
atom.show()
peer = createPeer()
connection = peer.connect(sessionId, reliable: true)
connection.on 'open', ->
console.log 'connection opened'
connection.once 'data', (data) ->
loadingView.remove()
console.log 'received document'
atom.windowState = Document.deserialize(data, site: createSite(peer.id))
connectDocument(atom.windowState, connection)
window.startEditorWindow()
updateProgressBar = (message, percentDone) ->
loadingView.find('.progress-bar-message').text("#{message}\u2026")
loadingView.find('.progress-bar').css('width', "#{percentDone}%")
guestSession = new GuestSession(sessionId)
guestSession.on 'started', -> loadingView.remove()
guestSession.on 'connection-opened', -> updateProgressBar('Downloading session data', 25)
guestSession.on 'connection-document-received', -> updateProgressBar('Synchronizing repository', 50)
operationsDone = -1
guestSession.on 'mirror-progress', (message, command, operationCount) ->
operationsDone++
percentDone = Math.round((operationsDone / operationCount) * 50) + 50
updateProgressBar(message, percentDone)
atom.guestSession = guestSession

View File

@@ -1,32 +1,33 @@
GuestView = require './guest-view'
HostView = require './host-view'
HostSession = require './host-session'
JoinPromptView = require './join-prompt-view'
{createSite, Document} = require 'telepath'
{createPeer, connectDocument} = require './session-utils'
startSession = ->
peer = createPeer()
peer.on 'connection', (connection) ->
connection.on 'open', ->
console.log 'sending document'
windowState = atom.getWindowState()
connection.send(windowState.serialize())
connectDocument(windowState, connection)
peer.id
{getSessionUrl} = require './session-utils'
module.exports =
activate: ->
sessionId = null
hostView = null
rootView.command 'collaboration:copy-session-id', ->
pasteboard.write(sessionId) if sessionId
if atom.getLoadSettings().sessionId
new GuestView(atom.guestSession)
else
hostSession = new HostSession()
rootView.command 'collaboration:start-session', ->
if sessionId = startSession()
pasteboard.write(sessionId)
copySession = ->
sessionId = hostSession.getId()
pasteboard.write(getSessionUrl(sessionId)) if sessionId
rootView.command 'collaboration:join-session', ->
new JoinPromptView (id) ->
windowSettings =
bootstrapScript: require.resolve('collaboration/lib/bootstrap')
resourcePath: window.resourcePath
sessionId: id
atom.openWindow(windowSettings)
rootView.command 'collaboration:copy-session-id', copySession
rootView.command 'collaboration:start-session', ->
hostView ?= new HostView(hostSession)
copySession() if hostSession.start()
rootView.command 'collaboration:join-session', ->
new JoinPromptView (id) ->
return unless id
windowSettings =
bootstrapScript: require.resolve('collaboration/lib/bootstrap')
resourcePath: window.resourcePath
sessionId: id
atom.openWindow(windowSettings)

View File

@@ -0,0 +1,57 @@
path = require 'path'
remote = require 'remote'
url = require 'url'
_ = require 'underscore'
patrick = require 'patrick'
telepath = require 'telepath'
{connectDocument, createPeer} = require './session-utils'
module.exports =
class GuestSession
_.extend @prototype, require('event-emitter')
participants: null
repository: null
peer: null
constructor: (sessionId) ->
@peer = createPeer()
connection = @peer.connect(sessionId, {reliable: true, connectionId: @getId()})
connection.on 'open', =>
console.log 'connection opened'
@trigger 'connection-opened'
connection.once 'data', (data) =>
@trigger 'connection-document-received'
console.log 'received document', data
doc = telepath.Document.deserialize(data.doc, site: telepath.createSite(@getId()))
atom.windowState = doc.get('windowState')
@repository = doc.get('collaborationState.repositoryState')
@participants = doc.get('collaborationState.participants')
@participants.on 'changed', =>
@trigger 'participants-changed', @participants.toObject()
connectDocument(doc, connection)
@mirrorRepository(data.repoSnapshot)
mirrorRepository: (repoSnapshot)->
repoUrl = @repository.get('url')
[repoName] = url.parse(repoUrl).path.split('/')[-1..]
repoName = repoName.replace(/\.git$/, '')
repoPath = path.join(remote.require('app').getHomeDir(), 'github', repoName)
progressCallback = (args...) => @trigger 'mirror-progress', args...
patrick.mirror repoPath, repoSnapshot, {progressCallback}, (error) =>
if error?
console.error(error)
else
@trigger 'started'
atom.getLoadSettings().initialPath = repoPath
window.startEditorWindow()
@participants.push
id: @getId()
email: git.getConfigValue('user.email')
getId: -> @peer.id

View File

@@ -0,0 +1,35 @@
{$$, View} = require 'space-pen'
ParticipantView = require './participant-view'
module.exports =
class GuestView extends View
@content: ->
@div class: 'collaboration', tabindex: -1, =>
@div class: 'guest'
@div outlet: 'participants'
guestSession: null
initialize: (@guestSession) ->
@guestSession.on 'participants-changed', (participants) =>
@updateParticipants(participants)
@updateParticipants(@guestSession.participants.toObject())
@attach()
updateParticipants: (participants) ->
@participants.empty()
guestId = @guestSession.getId()
for participant in participants when participant.id isnt guestId
@participants.append(new ParticipantView(participant))
toggle: ->
if @hasParent()
@detach()
else
@attach()
attach: ->
rootView.horizontal.append(this)
@focus()

View File

@@ -0,0 +1,76 @@
fs = require 'fs'
_ = require 'underscore'
patrick = require 'patrick'
telepath = require 'telepath'
{createPeer, connectDocument} = require './session-utils'
module.exports =
class HostSession
_.extend @prototype, require('event-emitter')
doc: null
participants: null
peer: null
sharing: false
start: ->
return if @peer?
@peer = createPeer()
@doc = telepath.Document.create({}, site: telepath.createSite(@getId()))
@doc.set('windowState', atom.windowState)
patrick.snapshot project.getPath(), (error, repoSnapshot) =>
if error?
console.error(error)
return
@doc.set 'collaborationState',
participants: []
repositoryState:
url: git.getConfigValue('remote.origin.url')
branch: git.getShortHead()
@participants = @doc.get('collaborationState.participants')
@participants.push
id: @getId()
email: git.getConfigValue('user.email')
@participants.on 'changed', =>
@trigger 'participants-changed', @participants.toObject()
@peer.on 'connection', (connection) =>
connection.on 'open', =>
console.log 'sending document'
connection.send({repoSnapshot, doc: @doc.serialize()})
connectDocument(@doc, connection)
connection.on 'close', =>
console.log 'conection closed'
@participants.each (participant, index) =>
if connection.peer is participant.get('id')
@participants.remove(index)
@peer.on 'open', =>
console.log 'sharing session started'
@sharing = true
@trigger 'started'
@peer.on 'close', =>
console.log 'sharing session stopped'
@sharing = false
@trigger 'stopped'
@getId()
stop: ->
return unless @peer?
@peer.destroy()
@peer = null
getId: ->
@peer.id
isSharing: ->
@sharing

View File

@@ -0,0 +1,47 @@
{$$, View} = require 'space-pen'
ParticipantView = require './participant-view'
module.exports =
class HostView extends View
@content: ->
@div class: 'collaboration', tabindex: -1, =>
@div outlet: 'share', type: 'button', class: 'share'
@div outlet: 'participants'
hostSession: null
initialize: (@hostSession) ->
if @hostSession.isSharing()
@share.addClass('running')
@share.on 'click', =>
@share.disable()
if @hostSession.isSharing()
@hostSession.stop()
else
@hostSession.start()
@hostSession.on 'started stopped', =>
@share.toggleClass('running').enable()
@hostSession.on 'participants-changed', (participants) =>
@updateParticipants(participants)
@attach()
updateParticipants: (participants) ->
@participants.empty()
hostId = @hostSession.getId()
for participant in participants when participant.id isnt hostId
@participants.append(new ParticipantView(participant))
toggle: ->
if @hasParent()
@detach()
else
@attach()
attach: ->
rootView.horizontal.append(this)
@focus()

View File

@@ -2,7 +2,8 @@
Editor = require 'editor'
$ = require 'jquery'
_ = require 'underscore'
Guid = require 'guid'
{getSessionId} = require './session-utils'
module.exports =
class JoinPromptView extends View
@@ -19,8 +20,8 @@ class JoinPromptView extends View
@on 'core:cancel', => @remove()
clipboard = pasteboard.read()[0]
if Guid.isGuid(clipboard)
@miniEditor.setText(clipboard)
if sessionId = getSessionId(clipboard)
@miniEditor.setText(sessionId)
@attach()
@@ -29,7 +30,7 @@ class JoinPromptView extends View
@miniEditor.setText('')
confirm: ->
@confirmed(@miniEditor.getText())
@confirmed(@miniEditor.getText().trim())
@remove()
attach: ->

View File

@@ -0,0 +1,12 @@
crypto = require 'crypto'
{View} = require 'space-pen'
module.exports =
class ParticipantView extends View
@content: ->
@div class: 'participant', =>
@img class: 'avatar', outlet: 'avatar'
initialize: ({id, email}) ->
emailMd5 = crypto.createHash('md5').update(email).digest('hex')
@avatar.attr('src', "http://www.gravatar.com/avatar/#{emailMd5}?s=32")

View File

@@ -1,7 +1,26 @@
Peer = require './peer'
Peer = require '../vendor/peer.js'
Guid = require 'guid'
url = require 'url'
module.exports =
getSessionId: (text) ->
return null unless text
text = text.trim()
sessionUrl = url.parse(text)
if sessionUrl.host is 'session'
sessionId = sessionUrl.path.split('/')[1]
else
sessionId = text
if Guid.isGuid(sessionId)
sessionId
else
null
getSessionUrl: (sessionId) -> "atom://session/#{sessionId}"
createPeer: ->
id = Guid.create().toString()
new Peer(id, key: '0njqmaln320dlsor')
@@ -9,6 +28,7 @@ module.exports =
connectDocument: (doc, connection) ->
nextOutputEventId = 1
outputListener = (event) ->
return unless connection.open
event.id = nextOutputEventId++
console.log 'sending event', event.id, event
connection.send(event)
@@ -39,7 +59,7 @@ module.exports =
queuedEvents.push(event)
connection.on 'close', ->
doc.off('output', outputListener)
doc.off('replicate-change', outputListener)
connection.on 'error', (error) ->
console.error 'connection error', error.stack ? error

View File

@@ -0,0 +1,30 @@
@import "bootstrap/less/variables.less";
@import "octicon-mixins.less";
.collaboration {
@item-line-height: @line-height-base * 1.25;
padding: 10px;
-webkit-user-select: none;
.share {
cursor: pointer;
.mini-icon(notifications);
position: relative;
right: -1px;
margin-bottom: 5px;
}
.guest {
.mini-icon(watchers);
position: relative;
right: -2px;
margin-bottom: 5px;
}
.avatar {
border-radius: 3px;
height: 16px;
width: 16px;
margin: 2px;
}
}

View File

@@ -782,7 +782,7 @@ var util = {
global.attachEvent('onmessage', handleMessage);
}
return setZeroTimeoutPostMessage;
}(this)),
}(window)),
blobToArrayBuffer: function(blob, cb){
var fr = new FileReader();

View File

@@ -8,6 +8,7 @@ _ = require 'underscore'
module.exports=
class ImageEditSession
registerDeserializer(this)
@version: 1
@activate: ->
# Files with these extensions will be opened as images
@@ -18,7 +19,8 @@ class ImageEditSession
new ImageEditSession(filePath)
@deserialize: ({path}={}) ->
if fsUtils.exists(path)
path = project.resolve(path)
if fsUtils.isFileSync(path)
new ImageEditSession(path)
else
console.warn "Could not build image edit session for path '#{path}' because that file no longer exists"
@@ -27,7 +29,7 @@ class ImageEditSession
serialize: ->
deserializer: 'ImageEditSession'
path: @path
path: @getUri()
getViewClass: ->
require './image-view'
@@ -48,7 +50,7 @@ class ImageEditSession
# Retrieves the URI of the current image.
#
# Returns a {String}.
getUri: -> @path
getUri: -> project?.relativize(@getPath()) ? @getPath()
# Retrieves the path of the current image.
#

View File

@@ -12,6 +12,8 @@
'a': 'tree-view:add'
'delete': 'tree-view:remove'
'backspace': 'tree-view:remove'
'k': 'core:move-up'
'j': 'core:move-down'
'.tree-view-dialog .mini.editor':
'enter': 'core:confirm'

View File

@@ -43,7 +43,7 @@ class BufferedProcess
addNodeDirectoryToPath: (options) ->
options.env ?= process.env
pathSegments = []
nodeDirectoryPath = path.resolve(process.execPath, '..', '..', '..', '..', 'Resources')
nodeDirectoryPath = path.resolve(process.execPath, '..', '..', '..', '..', '..', 'Resources')
pathSegments.push(nodeDirectoryPath)
pathSegments.push(options.env.PATH) if options.env.PATH
options.env = _.extend({}, options.env, PATH: pathSegments.join(path.delimiter))

View File

@@ -180,52 +180,4 @@ _.mixin
newObject[key] = value if value?
newObject
originalIsEqual = _.isEqual
extendedIsEqual = (a, b, aStack=[], bStack=[]) ->
return originalIsEqual(a, b) if a is b
return originalIsEqual(a, b) if _.isFunction(a) or _.isFunction(b)
return a.isEqual(b) if _.isFunction(a?.isEqual)
return b.isEqual(a) if _.isFunction(b?.isEqual)
stackIndex = aStack.length
while stackIndex--
return bStack[stackIndex] is b if aStack[stackIndex] is a
aStack.push(a)
bStack.push(b)
equal = false
if _.isArray(a) and _.isArray(b) and a.length is b.length
equal = true
for aElement, i in a
unless extendedIsEqual(aElement, b[i], aStack, bStack)
equal = false
break
else if _.isObject(a) and _.isObject(b)
aCtor = a.constructor
bCtor = b.constructor
aCtorValid = _.isFunction(aCtor) and aCtor instanceof aCtor
bCtorValid = _.isFunction(bCtor) and bCtor instanceof bCtor
if aCtor isnt bCtor and not (aCtorValid and bCtorValid)
equal = false
else
aKeyCount = 0
equal = true
for key, aValue of a
continue unless _.has(a, key)
aKeyCount++
unless _.has(b, key) and extendedIsEqual(aValue, b[key], aStack, bStack)
equal = false
break
if equal
bKeyCount = 0
for key, bValue of b
bKeyCount++ if _.has(b, key)
equal = aKeyCount is bKeyCount
else
equal = originalIsEqual(a, b)
aStack.pop()
bStack.pop()
equal
_.isEqual = (a, b) -> extendedIsEqual(a, b)
_.isEqual = require 'tantamount'